<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="https://www.taricorp.net/xml/base.min.xml"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>タリ</title><link>https://www.taricorp.net/feed.xml</link><description>Recent content on タリ</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Wed, 28 Jan 2026 20:06:00 +1100</lastBuildDate><atom:link href="https://www.taricorp.net/feed.xml" rel="self" type="application/rss+xml"/><item><title>A few CPU hardware bugs</title><link>https://www.taricorp.net/2026/a-few-cpu-bugs/</link><pubDate>Wed, 28 Jan 2026 20:06:00 +1100</pubDate><guid>https://www.taricorp.net/2026/a-few-cpu-bugs/</guid><description>&lt;p>Catherine (Whitequark)&amp;rsquo;s recent &lt;a href="https://crackhead.technology/">observations on poorly&lt;/a>&lt;a href="https://social.treehouse.systems/@whitequark/115946915331426694">-engineered firmware&lt;/a> reminded me of a few mistakes I&amp;rsquo;ve seen in vendors&amp;rsquo; CPUs; some unimportant and others surprisingly bad. Since I&amp;rsquo;ve never seen these widely discussed, here&amp;rsquo;s some discussion and links to supporting evidence to make them more widely known, since I think they&amp;rsquo;re interesting.&lt;/p>
&lt;h2 id="intels-misspelled-cpuids">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2026/a-few-cpu-bugs/#intels-misspelled-cpuids">Intel&amp;rsquo;s misspelled CPUIDs&lt;/a>
&lt;/h2>
&lt;p>I&amp;rsquo;m aware of two situations where Intel have sold CPUs that report misspelled names in some of the strings returned by the &lt;a href="https://en.wikipedia.org/wiki/CPUID">&lt;code>CPUID&lt;/code> instruction&lt;/a>. This seems embarrassing for an organization of Intel&amp;rsquo;s size, but probably doesn&amp;rsquo;t hurt anybody&amp;rsquo;s ability to use the CPUs in question.&lt;/p>
&lt;h3 id="genuineiotel">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2026/a-few-cpu-bugs/#genuineiotel">GenuineIotel&lt;/a>
&lt;/h3>
&lt;p>A web search for &amp;ldquo;GenuineIotel&amp;rdquo; reveals some discussions regarding this apparent typo, where some processors such as the &lt;a href="https://instlatx64.github.io/InstLatx64/GenuineIotel/GenuineIotel00306C3_Haswell_CPUID5.txt">Xeon E3-1231 v3&lt;/a> return the string &amp;ldquo;GenuineIotel&amp;rdquo; (instead of the usual &amp;ldquo;GenuineIntel&amp;rdquo;) for the CPU manufacturer ID. This one is well-known enough to be mentioned in the list of manufacturer IDs on Wikipedia.&lt;/p>
&lt;p>It&amp;rsquo;s possible this misspelling is actually caused by some kind of random bit error, since the characters &amp;rsquo;n&amp;rsquo; and &amp;lsquo;o&amp;rsquo; differ by only one bit; an unpredictable error that sets that bit could change &lt;code>GenuineIntel&lt;/code> to &lt;code>GenuineIotel&lt;/code>.&lt;/p>
&lt;h3 id="ore-i5">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2026/a-few-cpu-bugs/#ore-i5">ore i5&lt;/a>
&lt;/h3>
&lt;p>Another error that seems more likely to be human error in the CPU is in the Core i5-1245U CPU, which returns a processor brand string &lt;code>Intel(R) ore(TM) i5-1245U&lt;/code> which is simply missing the &amp;lsquo;C&amp;rsquo; in &lt;code>Core(TM) i5&lt;/code>. Web searches for &amp;ldquo;Intel(R) ore(TM)&amp;rdquo; show a number of results which could be errors introduced by non-technical users attempting to copy down text from their screen when asking for tech support, but the &lt;a href="https://ubuntu.com/certified/platforms/12916">Ubuntu certified configuration of the Dell Latitude 5430 with this CPU&lt;/a> attests to this error actually being present in at least some machines using that CPU.&lt;/p>
&lt;p>It&amp;rsquo;s possible this misspelling is not part of the physical CPU design and is instead part of the system firmware because &lt;a href="https://chipsandcheese.com/p/why-you-cant-trust-cpuid">at least on many AMD CPUs the CPU name is normally set by the system firmware&lt;/a>. Probably either the CPU design or its microcode encode this misspelling, or Intel&amp;rsquo;s firmware package that vendors use is the ultimate source. In either case, it seems embarrassing for them that such an error made it out into machines purchased by members of the public because it seems very likely to be the result of human error.&lt;/p>
&lt;h2 id="ites-pipeline-bug">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2026/a-few-cpu-bugs/#ites-pipeline-bug">ITE&amp;rsquo;s pipeline bug&lt;/a>
&lt;/h2>
&lt;p>This one is an actual hardware bug that I discovered at work, but in an embedded processor which most people will never see.&lt;/p>
&lt;p>&lt;a href="https://www.ite.com.tw/en">ITE Tech&lt;/a> is a Taiwanese chip company that sells a variety of specialized ICs, including a selection of PC embedded controllers (which are used for tasks like making the keyboard work and managing battery charging in most laptops). &lt;a href="https://www.ite.com.tw/en/product/cate2/IT81202">IT81202&lt;/a> is one of those, with on-chip peripherals for communicating with an x86 processor and plenty of memory for private use by its RISC-V CPU.&lt;/p>
&lt;p>It turns out there&amp;rsquo;s a pipeline bug in the IT81202 CPU, where &lt;a href="https://web.archive.org/web/20220531074654/https://www.ite.com.tw/uploads/product_download/it81202-bx-chip-errata.pdf">instructions modifying some registers immediately following a multiply (&lt;code>mul&lt;/code> instruction) may have no effect&lt;/a>. The workaround for this is to cripple the system, telling your compiler that the CPU doesn&amp;rsquo;t support multiplication or division instructions. Some of the performance can be regained by &lt;a href="https://github.com/zephyrproject-rtos/zephyr/pull/45881">providing implementations of the library functions that provide integer multiply/divide operations that work in terms of the &lt;code>mul&lt;/code> and &lt;code>div&lt;/code> instructions&lt;/a>, which works because inserting no-op instructions after them prevents the issue.&lt;/p>
&lt;p>To me, this issue doesn&amp;rsquo;t seem as embarrassing as Intel&amp;rsquo;s wrong CPUIDs. Pipelined CPUs are hard to build, and at the time they designed the IT81202 CPU RISC-V wasn&amp;rsquo;t widely used in industry yet so they probably had a pretty immature core implementation. In addition, that&amp;rsquo;s an embedded processor which very few people will ever need to write software for so an invasive workaround like that isn&amp;rsquo;t a big deal. This one seems more like a cautionary tale to be aware of than any reason to mock the vendor!&lt;/p></description></item><item><title>WEB1999: the web of 1999 in math class</title><link>https://www.taricorp.net/2025/web1999/</link><pubDate>Wed, 22 Jan 2025 16:00:00 +1100</pubDate><guid>https://www.taricorp.net/2025/web1999/</guid><description>&lt;p>I often find that imposed limitations make it easier to create things: it&amp;rsquo;s
easy to aim for perfection if you can expend as much effort as you like on
something and thus end up with nothing that you&amp;rsquo;ll ever call good enough to
share. Over on Cemetech in the final months of 2023,&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup> we &lt;a href="https://www.cemetech.net/forum/viewtopic.php?t=19320">held a programming
contest&lt;/a>: write a
screensaver, any kind of screensaver. I&amp;rsquo;m not often one to do any kind of
competitive programming, but as a prompt for a constrained project this was a
good one for me. As a result, &lt;a href="https://www.cemetech.net/forum/viewtopic.php?t=19329">I wrote a program I called
WEB1999&lt;/a>, that won second
place.&lt;/p>
&lt;!-- more -->
&lt;p>A screensaver is a nice project because there&amp;rsquo;s a lot of room in which to play and many of the hard aspects of programming (interacting with humans) can be completely ignored! I wasn&amp;rsquo;t originally planning to write anything to enter, but after thinking to myself what I might expect other people to enter, I came up with an idea that piqued my interest and didn&amp;rsquo;t seem too hard to build.&lt;/p>
&lt;details style="padding: 0 1em; margin: 0.5em; background-color: #eee;">
&lt;summary>In this post you will find...&lt;/summary>
&lt;aside>
&lt;nav id="TableOfContents">
&lt;ul>
&lt;li>&lt;a href="#get-the-program">Get the program&lt;/a>&lt;/li>
&lt;li>&lt;a href="#web1999-in-action">WEB1999 in action&lt;/a>&lt;/li>
&lt;li>&lt;a href="#implementation-notes">Implementation notes&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#cursor-behavior">Cursor behavior&lt;/a>&lt;/li>
&lt;li>&lt;a href="#window-occlusion">Window occlusion&lt;/a>&lt;/li>
&lt;li>&lt;a href="#distance-metrics">Distance metrics&lt;/a>&lt;/li>
&lt;li>&lt;a href="#cursor-movement">Cursor movement&lt;/a>&lt;/li>
&lt;li>&lt;a href="#cursor-pacing">Cursor pacing&lt;/a>&lt;/li>
&lt;li>&lt;a href="#window-placement">Window placement&lt;/a>&lt;/li>
&lt;li>&lt;a href="#selecting-window-kinds">Selecting window kinds&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#design-notes">Design notes&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#window-styles-and-color">Window styles and color&lt;/a>&lt;/li>
&lt;li>&lt;a href="#malware-and-malvertising">Malware and malvertising&lt;/a>&lt;/li>
&lt;li>&lt;a href="#memes">Memes&lt;/a>&lt;/li>
&lt;li>&lt;a href="#mass-media">Mass media&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#bytecoded-graphics-and-future-work">Bytecoded graphics and future work&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#bytecodes">Bytecodes&lt;/a>&lt;/li>
&lt;li>&lt;a href="#compilation-and-preview">Compilation and preview&lt;/a>&lt;/li>
&lt;li>&lt;a href="#limitations">Limitations&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#conclusions">Conclusions&lt;/a>&lt;/li>
&lt;/ul>
&lt;/nav>
&lt;/aside>
&lt;/details>
&lt;h2 id="get-the-program">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#get-the-program">Get the program&lt;/a>
&lt;/h2>
&lt;p>If you want to play with the program instead of or in addition to simply
reading about it, a copy of WEB1999 suitable for loading onto a TI-84+ CE or
TI-83 Premium CE calculator can be obtained from the releases section of my
Gitlab project: &lt;br>
&lt;strong>&lt;a href="https://gitlab.com/taricorp/web1999/-/releases">https://gitlab.com/taricorp/web1999/-/releases&lt;/a>&lt;/strong>&lt;/p>
&lt;p>The source code can also be found on Gitlab, or a copy can also be obtained
from the &lt;a href="https://www.cemetech.net/downloads/files/2402">Cemetech archives&lt;/a> or
&lt;a href="https://www.ticalc.org/archives/files/fileinfo/479/47909.html">ticalc.org&lt;/a>.
All of the release packages include the full source code and documentation, so
you shouldn&amp;rsquo;t miss anything regardless of where you choose to get a copy from.&lt;/p>
&lt;h2 id="web1999-in-action">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#web1999-in-action">WEB1999 in action&lt;/a>
&lt;/h2>
&lt;figure>
&lt;video controls autoplay muted
width="320" height="240" src="https://www.taricorp.net/2025/web1999/full99.webm"
poster="/2025/web1999/promo.png">
&lt;figcaption>WEB1999 in action.&lt;/figcaption>
&lt;/figure>
&lt;p>Inspired heavily by the &lt;a href="https://web.archive.org/web/20021204203905/https://www2.b3ta.com/realistic-internet-simulator/">Realistic Internet Simulator&lt;/a> (&amp;ldquo;Kill the Pop-ups&amp;rdquo;), WEB1999 invokes the spirit of pop-up advertising and Internet culture around the turn of the millennium in the form of a program that runs on Texas Instruments&amp;rsquo; TI-84+ CE color-screen graphing calculators.&lt;/p>
&lt;p>The contest judges had some very nice things to say:&lt;/p>
&lt;blockquote>
&lt;p>An excellent instance of a screensaver modeled after both the golden age of screensavers and the golden age of the internet. Callbacks to both classic internet culture and the calculator community of yore, with a few easter eggs to boot.&lt;/p>
&lt;/blockquote>
&lt;blockquote>
&lt;p>An HTML readme that looks like it&amp;rsquo;s from &amp;lsquo;99? Nice!&lt;/p>
&lt;/blockquote>
&lt;p>I &lt;em>did&lt;/em> spend some significant effort writing the documentation for the
program, but I didn&amp;rsquo;t intentionally design the README with retro style; it
mostly just uses the default HTML structure provided by
&lt;a href="https://pandoc.org/">Pandoc&lt;/a>!&lt;/p>
&lt;blockquote>
&lt;p>It looks great. The posterization and dithering really gives the feel of the
90s, and the use of an even more limited palette is a clever way to work
within the bounds. Capturing the nostalgia of having just way too many
popups.&lt;/p>
&lt;/blockquote>
&lt;p>As described further in the following sections, not only did I have a lot of
fun using historical inspiration and realistic limitations to restrict myself,
but there were also a few interesting aspects to the implementation that I&amp;rsquo;ll
describe!&lt;/p>
&lt;p>Continue reading for notes on &lt;a href="#design-notes">the program&amp;rsquo;s design&lt;/a> and
&lt;a href="#implementation-notes">interesting aspects of its implementation&lt;/a>, plus
&lt;a href="#bytecoded-graphics-and-future-work">additional improvements I&amp;rsquo;d like to
make&lt;/a>.&lt;/p>
&lt;h2 id="implementation-notes">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#implementation-notes">Implementation notes&lt;/a>
&lt;/h2>
&lt;p>WEB1999 has two major tasks that it needs to handle while running:&lt;/p>
&lt;ol>
&lt;li>Periodically open a new window.&lt;/li>
&lt;li>Move the cursor to an open window and close it.&lt;/li>
&lt;/ol>
&lt;p>Opening windows is fairly easy, but how that interacts with moving the cursor
and interacting with windows is rather more complex. When there are multiple
windows onscreen, some UI elements might be obscured because the windows are
stacked. This presents a challenge to the cursor, because it should not
&amp;ldquo;interact&amp;rdquo; with UI elements that are not visible (not that it actually
interacts, it just needs to close a window when that&amp;rsquo;s plausibly possible)
and consequently needs to choose a window and an element of that window to
move to and interact with.&lt;/p>
&lt;p>As it turns out, it&amp;rsquo;s much easier as a software author to assume an intelligent
user than it is to automate behavior that looks intelligent. On the upside
however, automating everything means none of the interaction actually needs to
work, and shortcuts can be taken there.&lt;/p>
&lt;h3 id="cursor-behavior">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#cursor-behavior">Cursor behavior&lt;/a>
&lt;/h3>
&lt;figure>&lt;img src="https://www.taricorp.net/2025/web1999/testwindow.png"
alt="A blank window titled &amp;#39;Large Test window&amp;#39;">&lt;figcaption>
&lt;p>The Large Test Window illustrates the important UI elements.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Starting with the goal of closing a window by pressing the &amp;lsquo;X&amp;rsquo; button in its
upper-right corner, it&amp;rsquo;s simple to close a window when it&amp;rsquo;s alone onscreen.
Simply by moving the cursor to the &amp;lsquo;X&amp;rsquo; button, we can plausibly interact with
it and then remove the window.&lt;/p>
&lt;p>If the &amp;lsquo;X&amp;rsquo; button is offscreen however, it&amp;rsquo;s impossible to interact with. It
would be reasonable to ensure that the button is never placed offscreen when
creating a new window, but that would be less visually interesting. So to
handle such a situation, the cursor must move to the window&amp;rsquo;s titlebar and
&amp;ldquo;drag&amp;rdquo; the entire window to the left until its close button is visible. In the
video below, the program only ever has one window to consider, but it moves the
cursor to that window and closes it, moving the window if needed.&lt;/p>
&lt;figure>
&lt;video controls muted width="320" height="240"
src="https://www.taricorp.net/2025/web1999/testwindows.webm">
&lt;/figure>
&lt;p>When there are multiple windows onscreen, they can be modelled as a simple
list where the first window in the list is on top. By drawing them on the
screen from the bottom up, it&amp;rsquo;s easy to ensure windows higher in the stack
obscure the lower ones.&lt;/p>
&lt;h3 id="window-occlusion">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#window-occlusion">Window occlusion&lt;/a>
&lt;/h3>
&lt;p>When there are multiple windows onscreen, some of them might be obscuring
important parts of others like the close button or even hiding the lower
one completely.&lt;/p>
&lt;p>Although the topmost window should always be interactable, I didn&amp;rsquo;t think
always going to the topmost window would be interesting behavior, and it
doesn&amp;rsquo;t seem to match what a human would do in this situation: a human would
probably choose a window located near the cursor and close it, unless it became
obscured before it could be closed. As it turns out, that human behavior is
fairly straightforward to express as long as it&amp;rsquo;s clear which windows are not
obscured.&lt;/p>
&lt;p>This was one of the situations where I took a shortcut, since although a
window&amp;rsquo;s close button or titlebar should be interactable if any part of it is
visible, it was much simpler to treat a UI element as visible only if none of
it was obscured. While looping over all windows stacked on top of a candidate
window, the ones that cannot be closed or moved can be ignored.&lt;/p>
&lt;p>This code sample (adapted from the real program&amp;rsquo;s &lt;code>getNewTarget&lt;/code> function)
illustrates generally how the logic to select a window
works, assuming one that we want to interact with is returned by
&lt;code>getAWindow&lt;/code>. &lt;code>wm&lt;/code> is the list of windows sorted with the topmost first:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">extern&lt;/span> Window wm[MAX_WINDOWS];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Window&lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> candidate &lt;span style="color:#000;font-weight:bold">=&lt;/span> getAWindow();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Rect closeTarget &lt;span style="color:#000;font-weight:bold">=&lt;/span> candidate.getCloseTarget();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#458;font-weight:bold">bool&lt;/span> closeOccluded &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">false&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Rect moveTarget &lt;span style="color:#000;font-weight:bold">=&lt;/span> candidate.getMoveTarget();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#458;font-weight:bold">bool&lt;/span> moveOccluded &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">false&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">while&lt;/span> (occluderIdx&lt;span style="color:#000;font-weight:bold">--&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span> &lt;span style="color:#099">0&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Occlusion is simply based on having any overlap, because partial overlap
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// involves much more complicated geometry.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">auto&lt;/span> occBounds &lt;span style="color:#000;font-weight:bold">=&lt;/span> wm[occluderIdx]&lt;span style="color:#000;font-weight:bold">-&amp;gt;&lt;/span>getBounds();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> closeOccluded &lt;span style="color:#000;font-weight:bold">|=&lt;/span> occBounds.overlapsWith(closeTarget);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> moveOccluded &lt;span style="color:#000;font-weight:bold">|=&lt;/span> occBounds.overlapsWith(moveTarget);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// If both targets are occluded, this window is not interactable.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>&lt;span style="color:#000;font-weight:bold">if&lt;/span> (closeOccluded &lt;span style="color:#000;font-weight:bold">&amp;amp;&amp;amp;&lt;/span> moveOccluded) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> dbg_printf(&lt;span style="color:#d14">&amp;#34;Window %p is fully occluded, will not interact.&lt;/span>&lt;span style="color:#d14">\n&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span>candidate);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">continue&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>In the real program, this runs in a loop until an interactable window
is found with candidates chosen by their distance from the cursor: the
program will choose to interact with the window that is closest to the
cursor, as long as the region that the cursor needs to be in to either
move or close the window is not occluded by another window.&lt;/p>
&lt;p>This code sample also illustrates a few of the abstractions I wrote to
hide some of the details of the math. A &lt;code>Window&lt;/code>&amp;rsquo;s location is described
by a &lt;code>Rect&lt;/code> which has a position onscreen, width and height; there are also
shortcut functions to get the &lt;code>Rect&lt;/code>s corresponding to where the window&amp;rsquo;s
titlebar is (which can be interacted with to bring the window to the top and
move it), as well as its close button. The &lt;code>overlapsWith&lt;/code> method on &lt;code>Rect&lt;/code>s
checks whether any part of a rectangle overlaps with some other rectangle.&lt;/p>
&lt;h3 id="distance-metrics">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#distance-metrics">Distance metrics&lt;/a>
&lt;/h3>
&lt;p>The next moderately interesting question to answer was how to determine
which window is closest to the cursor, since it&amp;rsquo;s now understood how to
determine if a given window is actually an acceptable candidate to interact
with.&lt;/p>
&lt;p>The obvious approach is to use basic geometry and get the length of a vector
between the cursor and the closest point of a window to the cursor. If
&lt;code>cx&lt;/code> and &lt;code>cy&lt;/code> are the cursor&amp;rsquo;s X and Y coordinates while &lt;code>wx&lt;/code> and &lt;code>wy&lt;/code> are
the coordinates of a point on a window, the distance is clearly
&lt;code>sqrt((cx-wx)*(cx-wx) + (cy - wy)*(cy - wy))&lt;/code>: simply apply the Pythagorean
theorem.&lt;/p>
&lt;p>On an eZ80 processsor where I could be doing this computation with fairly high
frequency, I didn&amp;rsquo;t think getting a true hypotenuse length would be acceptably
performant. There&amp;rsquo;s no hardware support for computing the square root of
numbers, and I wasn&amp;rsquo;t very interested in trying to implement a fast square
root.&lt;/p>
&lt;p>Instead, I noticed that the actual distance between two points doesn&amp;rsquo;t actually
matter, as long as I can know which of multiple points (points on windows that
we might want to interact with) is closest to a chosen point (the cursor&amp;rsquo;s
current location). Since the relationship between any two distances (which is
longer) before computing the square root and even before squaring the
coordinate offsets is the same as after doing those operations, I simplified
distance computations to work in terms of the &lt;a href="https://en.wikipedia.org/wiki/Taxicab_geometry">rectilinear
distance&lt;/a> instead:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> &lt;span style="color:#458;font-weight:bold">int&lt;/span> &lt;span style="color:#900;font-weight:bold">distance_NonLinear&lt;/span>(gfx_point_t a, gfx_point_t b) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> abs(a.x &lt;span style="color:#000;font-weight:bold">-&lt;/span> b.x) &lt;span style="color:#000;font-weight:bold">+&lt;/span> abs(a.y &lt;span style="color:#000;font-weight:bold">-&lt;/span> b.y);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>With a distance metric, the &lt;code>getNewTarget&lt;/code> function can iterate over all
windows onscreen and find the one nearest to the cursor. The actual distance
is not known, but we do know the nearest window is found. As discussed in the
previous section, windows where none of the interaction targets are visible
(abstracted out as the &lt;code>isFullyOccluded&lt;/code> function) are ignored.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>Window&lt;span style="color:#000;font-weight:bold">*&lt;/span> nearest &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">nullptr&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#458;font-weight:bold">int&lt;/span> nearestMetric &lt;span style="color:#000;font-weight:bold">=&lt;/span> INT_MAX;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">for&lt;/span> (&lt;span style="color:#000;font-weight:bold">auto&lt;/span> candidateIdx &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>; candidateIdx &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> wm.size(); candidateIdx&lt;span style="color:#000;font-weight:bold">++&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Window&lt;span style="color:#000;font-weight:bold">*&lt;/span> candidate &lt;span style="color:#000;font-weight:bold">=&lt;/span> wm[candidateIdx];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (isFullyOccluded(candidate)) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">continue&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">auto&lt;/span> candidateDest &lt;span style="color:#000;font-weight:bold">=&lt;/span> closeTarget.getNearestPoint(cursor.getLocation());
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">auto&lt;/span> candidateDist &lt;span style="color:#000;font-weight:bold">=&lt;/span> distance_NonLinear(cursor.getLocation(), candidateDest);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (candidateDist &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> nearestMetric) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nearest &lt;span style="color:#000;font-weight:bold">=&lt;/span> candidate;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nearestMetric &lt;span style="color:#000;font-weight:bold">=&lt;/span> candidateDist;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">return&lt;/span> nearest;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;hr>
&lt;p>With that logic in mind, here&amp;rsquo;s a prototype in action where new windows were
periodically being spawned and an appropriate one was being closed, with
movement of the window as required.&lt;/p>
&lt;figure>
&lt;video controls muted width="320" height="240"
id="video-morewindows"
src="https://www.taricorp.net/2025/web1999/morewindows.webm">
&lt;/figure>
&lt;p>The cursor&amp;rsquo;s speed seems inconsistent here because this particular prototype
restarted moving the cursor every time it considered whether a new window
should be targeted (when a window was created), and (as described in the
following sections) at the time I recorded this video the cursor moved over
short distances moved much more slowly than a human would.&lt;/p>
&lt;h3 id="cursor-movement">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#cursor-movement">Cursor movement&lt;/a>
&lt;/h3>
&lt;p>In the previous video, the cursor is already moving along a line between
its original location and its destination. This is very easy for a human
to do when using a mouse, but took some effort to implement in software
in a way that was reasonably performant on a (e)Z80 processor.&lt;/p>
&lt;p>The first algorithm I thought of when considering how to determine where
to move the cursor was to use &lt;a href="https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm">Bresenhams&amp;rsquo; line
algorithm&lt;/a>,
which I have previously heard of used for fast line-drawing functions on
TI calculators.
After spending some time familiarizing myself with Bresenham&amp;rsquo;s algorithm,
I realized there was an issue with simply applying that algorithm: I need
the cursor to move over time, not merely draw the line between two points.&lt;/p>
&lt;p>At this point I decided it made sense to take advantage of the eZ80 being
more capable than most 8-bit processors, in that it is able to do arithmetic
on integers that are up to 24 bits wide and can multiply (but not divide)
16-bit integers in hardware. So I reached for fixed-point math.&lt;/p>
&lt;p>What I ended up with was a &lt;code>Mover&lt;/code> class encapsulating the details of
computing how the cursor should move, which is constructed with two points to
move between and can be advanced over time to sweep between the start
and end points.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> &lt;span style="color:#458;font-weight:bold">Mover&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">public&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Mover(gfx_point_t start, gfx_point_t end);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Return the new position after moving at the given speed,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// setting *done to true if the new position is the endpoint.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> gfx_point_t &lt;span style="color:#900;font-weight:bold">advance&lt;/span>(&lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span> speed, &lt;span style="color:#458;font-weight:bold">bool&lt;/span> &lt;span style="color:#000;font-weight:bold">*&lt;/span>done);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>};&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Because it seemed reasonable to move the cursor in up to 128 steps, I chose to
express the progress from start to end point as an 8-bit unsigned value, with 1
bit before the decimal point and 7 after: the 8-bit integer &lt;code>0&lt;/code> is 0.0, and
&lt;code>0x80&lt;/code> is 1.0. In this way, moving the cursor at minimum nonzero speed involves
adding 1/128 (integer value &lt;code>1&lt;/code>) to the progress along the line at each step.&lt;/p>
&lt;p>Inspired by Bresenham&amp;rsquo;s line algorithm, for each of the x and y coordinates
I multiplied the required distance to be moved by the fraction of the distance
to be moved (that fixed-point number) to get the distance to move the cursor
in one step. To prevent drift, the fractional part is saved and accumulates
between calls, adding to the distance moved when it exceeds 1 pixel.
The code looks like this, where &lt;code>speed&lt;/code> is a fixed-point fraction of the
total distance to cover, &lt;code>progress&lt;/code> is the fraction of the distance covered
already, &lt;code>dx&lt;/code> and &lt;code>dy&lt;/code> are the total distance to move on the X and Y axes,
and &lt;code>errx&lt;/code> and &lt;code>erry&lt;/code> are the accumulated error for each axis:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>gfx_point_t Mover&lt;span style="color:#000;font-weight:bold">::&lt;/span>advance(&lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span> speed) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// If speed would cause overshoot, clamp progress to 1.0.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (speed &lt;span style="color:#000;font-weight:bold">+&lt;/span> progress &lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span> &lt;span style="color:#099">0x80&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> speed &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0x80&lt;/span> &lt;span style="color:#000;font-weight:bold">-&lt;/span> progress;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gfx_point_t out;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Fixed-point multiplication, taking the integer part.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> out.x &lt;span style="color:#000;font-weight:bold">=&lt;/span> (dx &lt;span style="color:#000;font-weight:bold">*&lt;/span> speed) &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">0x80&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Take the fractional part and add to accumulated error.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> errx &lt;span style="color:#000;font-weight:bold">+=&lt;/span> abs(dx &lt;span style="color:#000;font-weight:bold">*&lt;/span> speed) &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#099">0x7f&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// If error is greater than 1.0, advance towards the destination
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// and subtract 1.0 from the error.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (errx &lt;span style="color:#000;font-weight:bold">&amp;gt;=&lt;/span> &lt;span style="color:#099">0x80&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> out.x &lt;span style="color:#000;font-weight:bold">+=&lt;/span> signum(dx);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> errx &lt;span style="color:#000;font-weight:bold">-=&lt;/span> &lt;span style="color:#099">0x80&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Do the same for the y axis...
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> out;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Notably, this implementation shouldn&amp;rsquo;t require any actual division because
the divisions are always by 128, which can be expressed as an arithmetic
right shift by 7 bits instead. Multiplication is still needed, but eZ80&amp;rsquo;s
hardware multiplier should be sufficient to make that fast.&lt;/p>
&lt;h3 id="cursor-pacing">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#cursor-pacing">Cursor pacing&lt;/a>
&lt;/h3>
&lt;p>As I noted in relation to the &lt;a href="#video-morewindows">last video&lt;/a>, the initial
approach I chose to selecting the speed at which the cursor should move yielded
awkward results where it tended to move much slower than a human would if
it only needed to move a short distance. This had two reasons:&lt;/p>
&lt;ul>
&lt;li>The amount of time required to move between two points was constant: the
speed used to advance the &lt;code>Mover&lt;/code> was always 1/128, so the cursor would
always take 128 ticks to move between two positions.&lt;/li>
&lt;li>When a new window was created, cursor movement was reset. If the new
destination was close to the cursor&amp;rsquo;s current position, it would always
take 128 ticks to reach the new position which could make it seem to
suddenly change speed to move very slowly when a new window appeared.&lt;/li>
&lt;/ul>
&lt;p>Clearly, constant-time cursor movement doesn&amp;rsquo;t look like what a human would do.
I expect that a human attempting to move a mouse cursor to a given location
onscreen would tend to move faster (cover more pixels each second) for longer
moves, and might slow down as they near the target. In general, the time taken
to move a given distance onscreen would be roughly constant whereas what I had
implemented was that the fraction of the distance covered would be roughly
constant.&lt;/p>
&lt;p>Because the &lt;code>Mover&lt;/code> still works in fractions of the distance to cover however,
there needs to be a conversion between speed in pixels and the fraction of the
total distance to cover in each tick. The obvious approach is to compute the
distance to cover, then divide that distance by the desired speed in pixels per
tick and multiply by 1.0 (as a fixed-point value; 128): &lt;code>speed_px * 128 / distance_px&lt;/code>. For example, if the cursor moves at a constant speed of 4 pixels
per tick and it needs to move 64 pixels total, then it&amp;rsquo;s obvious that it should
move one sixteenth of the total distance per tick (because 64 divided by 4 is
16): 4 * 128 / 64 = 8, which is equal to 1/16 when interpreted as a fixed-point
value with 7 fractional bits.&lt;/p>
&lt;p>That obvious approach bumps into both of the issues that we&amp;rsquo;ve
already used tricks to improve the performance of, though: it both needs to
accurately compute the distance to traverse (which was previously replaced
with the rectilinear distance in &lt;code>distance_NonLinear&lt;/code>) and divide by that
number (whereas we chose to do fixed-point arithmetic so the only required
division can be expressed as a bitshift).&lt;/p>
&lt;p>Noticing that accurately expressing the fraction of the desired distance to
move would always require an accurate computation of the Euclidean distance
between two points on the screen (which I didn&amp;rsquo;t want to do, because computing
the square root of a number would probably be slow), I instead chose to manually
write a piecewise function that looked about right:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// d is the rectilinear distance to travel
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>&lt;span style="color:#000;font-weight:bold">if&lt;/span> (d &lt;span style="color:#000;font-weight:bold">==&lt;/span> &lt;span style="color:#099">0&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#099">128&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>} &lt;span style="color:#000;font-weight:bold">else&lt;/span> &lt;span style="color:#900;font-weight:bold">if&lt;/span> (d &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> &lt;span style="color:#099">3&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#099">64&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>} &lt;span style="color:#000;font-weight:bold">else&lt;/span> &lt;span style="color:#900;font-weight:bold">if&lt;/span> (d &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> &lt;span style="color:#099">9&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#099">32&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>} &lt;span style="color:#000;font-weight:bold">else&lt;/span> &lt;span style="color:#900;font-weight:bold">if&lt;/span> (d &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> &lt;span style="color:#099">16&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#099">16&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>} &lt;span style="color:#000;font-weight:bold">else&lt;/span> &lt;span style="color:#900;font-weight:bold">if&lt;/span> (d &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> &lt;span style="color:#099">48&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#099">8&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>} &lt;span style="color:#000;font-weight:bold">else&lt;/span> &lt;span style="color:#900;font-weight:bold">if&lt;/span> (d &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> &lt;span style="color:#099">96&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#099">4&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>} &lt;span style="color:#000;font-weight:bold">else&lt;/span> &lt;span style="color:#900;font-weight:bold">if&lt;/span> (d &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> &lt;span style="color:#099">152&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#099">2&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>} &lt;span style="color:#000;font-weight:bold">else&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#099">1&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>In practice, this means that moves of 0 distance will take one tick to complete,
2 or less take 2 ticks, 8 or less take 4 ticks and so forth. I initially
wrote that function to work in powers of two exclusively (branches for values
of &lt;code>d&lt;/code> less than 1, 2, 4, 8, &amp;hellip; taking 1, 2, 4, 8, 16, &amp;hellip; ticks to complete)
and then manually adjusted the points where the speed would change by just
watching the program run while printing debug output (how far the cursor was
going to move next and the chosen speed), so when I thought a cursor movement
looked bad I could make a note and eventually converge on good-looking speeds.&lt;/p>
&lt;p>This speed computation function is clearly not very accurate, but because
it&amp;rsquo;s meant to behave like a hypothetical human operating a computer mouse it&amp;rsquo;s
possibly even better this way than if it were accurate: a human&amp;rsquo;s speed won&amp;rsquo;t
always be exactly the same, and it builds in some bias toward moving slightly
slower for very short moves where a human might be more careful to move
accurately.&lt;/p>
&lt;h3 id="window-placement">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#window-placement">Window placement&lt;/a>
&lt;/h3>
&lt;p>The last somewhat awkward thing in the &lt;a href="#video-morewindows">previous video&lt;/a>
is that the placement of windows on the screen doesn&amp;rsquo;t seem as random as it
should be: they tend to be toward the right side of the screen, whereas it seems
more likely that pop-up windows would attempt to place themselves near the
center of the screen for maximum &lt;del>annoyance&lt;/del> visibility.&lt;/p>
&lt;p>To choose the position of a window when it was created, I was simply generating
uniform (or near enough to uniform) random screen coordinates for it to be
displayed at (0-319 on the X axis and 0-239 on the Y), then clamping values to
ensure there would always be something interactable visible (the titlebar to
drag the window, or its close button).&lt;/p>
&lt;p>However, the coordinates of a window were the position of its &lt;code>Rect&lt;/code>, which is
defined to be the top left corner. That meant that windows would never hang off
the left side of the screen, and could hang almost all the way off the right
which would tend to look like a bias toward the right side of the screen.&lt;/p>
&lt;p>The obvious first thing to change was to make the randomly-selected X coordinate
represent the center of a window, rather than the left edge. While I did make that
change, I also considered how to make windows prefer to be placed toward the
center of the screen while still appearing to be random. (I left the Y coordinate
as it was, because a window with its top edge offscreen cannot be interacted
with at all.)&lt;/p>
&lt;p>I assumed that a standard normal distribution tends to look random in a way
that is satisfactory to the eye, so after doing some research I decided to
approximate a normal distribution with an &lt;a href="https://en.wikipedia.org/wiki/Irwin%E2%80%93Hall_distribution">Irwin-Hall
distribution&lt;/a> of
degree twelve. With some tweaking of the values output from the distribution
with random numbers in the range 0-255 as input (generated as three 32-bit
integers and split into twelve bytes), I generated an approximation of a normal
distribution centered at 208:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> &lt;span style="color:#458;font-weight:bold">int&lt;/span> &lt;span style="color:#900;font-weight:bold">genXCoord&lt;/span>() {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// This uses an Irwin-Hall distribution approximating a normal distribution in
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// range 0-512 to generate coordinates with approximate normal distribution
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// between -96 and 416 which is a pretty good range for window X coordinates.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#458;font-weight:bold">int&lt;/span> sum &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> (&lt;span style="color:#000;font-weight:bold">auto&lt;/span> i &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>; i &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> &lt;span style="color:#099">3&lt;/span>; i&lt;span style="color:#000;font-weight:bold">++&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#458;font-weight:bold">uint32_t&lt;/span> x &lt;span style="color:#000;font-weight:bold">=&lt;/span> random();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sum &lt;span style="color:#000;font-weight:bold">+=&lt;/span> x &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#099">0xff&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sum &lt;span style="color:#000;font-weight:bold">+=&lt;/span> (x &lt;span style="color:#000;font-weight:bold">&amp;gt;&amp;gt;&lt;/span> &lt;span style="color:#099">8&lt;/span>) &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#099">0xff&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sum &lt;span style="color:#000;font-weight:bold">+=&lt;/span> (x &lt;span style="color:#000;font-weight:bold">&amp;gt;&amp;gt;&lt;/span> &lt;span style="color:#099">16&lt;/span>) &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#099">0xff&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sum &lt;span style="color:#000;font-weight:bold">+=&lt;/span> (x &lt;span style="color:#000;font-weight:bold">&amp;gt;&amp;gt;&lt;/span> &lt;span style="color:#099">24&lt;/span>) &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#099">0xff&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> (sum &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">6&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&lt;/span> &lt;span style="color:#099">96&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>A division operation snuck in here, which at a glance seems like an odd choice
given the effort I went through earlier to avoid any division. In this case I
decided it was okay, because:&lt;/p>
&lt;ol>
&lt;li>This function is called only when a new window is created, where it&amp;rsquo;s
plausible that a computer might &amp;ldquo;lag&amp;rdquo; a little bit because it&amp;rsquo;s busy
rendering the window.&lt;/li>
&lt;li>Because the division is by a constant integer, it&amp;rsquo;s &lt;a href="https://doi.org/10.1145/178243.178249">possible
(and easy) to convert that to a multiplication and
bit-shift&lt;/a> instead. I don&amp;rsquo;t recall
checking if the compiler I used to build WEB1999 actually did such a
transformation (though &lt;a href="https://lemire.me/blog/2019/02/08/faster-remainders-when-the-divisor-is-a-constant-beating-compilers-and-libdivide/">Clang does support doing
so&lt;/a>),
but recognizing that it could is good enough for me.&lt;/li>
&lt;/ol>
&lt;h3 id="selecting-window-kinds">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#selecting-window-kinds">Selecting window kinds&lt;/a>
&lt;/h3>
&lt;p>A final interesting piece of WEB1999&amp;rsquo;s implementation is how it selects which
kind of window to display when a new one is to be created. I wanted there to be
as many different pop-ups as I had time and memory available to make, but also
never wanted the same pop-up to be displayed more than once on the screen at any
given time: every visible window should be unique.&lt;/p>
&lt;p>I achieved this by indirection in the window manager and dynamic dispatch. The
&lt;code>WindowManager&lt;/code> owns an array of &lt;code>Window*&lt;/code> which are the windows being displayed
onscreen, or &lt;code>nullptr&lt;/code> if the window that goes in a slot is not open. Each slot
in this array is assigned to a particular kind of window, which is a subclass of
&lt;code>Window&lt;/code>. Those slots are assigned manually in the source code, something like
this (where in the actual implementation, use of a macro avoids repeating most
of the code for each case):&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> Window &lt;span style="color:#000;font-weight:bold">*&lt;/span>instances[&lt;span style="color:#099">10&lt;/span>];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#458;font-weight:bold">int&lt;/span> x; &lt;span style="color:#998;font-style:italic">// Random number representing a valid index in instances
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#458;font-weight:bold">int&lt;/span> created_type;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">switch&lt;/span>(x) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">createWrap&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">case&lt;/span> &lt;span style="color:#099">0&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (instances[&lt;span style="color:#099">0&lt;/span>] &lt;span style="color:#000;font-weight:bold">==&lt;/span> &lt;span style="color:#000;font-weight:bold">nullptr&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> instances[&lt;span style="color:#099">0&lt;/span>] &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">new&lt;/span> HotSingles();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> created_type &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">break&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Fall through to try the next kind if there&amp;#39;s already a
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// HotSingles instance.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">case&lt;/span> &lt;span style="color:#099">1&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Try to create a DragonballFanpage in the same way,
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// in instances[1].
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">case&lt;/span> &lt;span style="color:#099">2&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// After 10 window kinds, wrap back around
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">goto&lt;/span> createWrap;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Note that if a window of the randomly-chosen type already exists, this function
will attempt each of the others in sequence until one that doesn&amp;rsquo;t yet exist is
created. That requires a check beforehand to ensure that there is at least one
available slot in &lt;code>instances&lt;/code> to avoid an infinite loop, which I&amp;rsquo;ve omitted from
the above sample code. The use of a &lt;code>goto&lt;/code> statement at all is also rather
unconventional, but I found it to be a useful shortcut that allowed me to
guarantee that this function would always complete in a reasonable amount of
time. It would be more random to continuously generate random numbers until an
empty &lt;code>instances&lt;/code> slot were found, but then in an unluckly case the computer
could spend a long time searching for a free slot.&lt;/p>
&lt;hr>
&lt;p>The &lt;code>created_type&lt;/code> value, not yet discussed, is important to how the window
manager tracks the order of windows (which one is topmost). Because the order
of &lt;code>instances&lt;/code> is determined by the type of each of its members, a level of
indirection is required to refer to each instance in the on-screen order.
This is where &lt;code>created_type&lt;/code> comes in, as well as a second array of the same
size as &lt;code>instances&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> &lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span> order[&lt;span style="color:#099">10&lt;/span>];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// Move all entries in order down
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>memmove(order &lt;span style="color:#000;font-weight:bold">+&lt;/span> &lt;span style="color:#099">1&lt;/span>, order, &lt;span style="color:#000;font-weight:bold">sizeof&lt;/span>(&lt;span style="color:#000;font-weight:bold">*&lt;/span>order) &lt;span style="color:#000;font-weight:bold">*&lt;/span> &lt;span style="color:#099">9&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// Place the newly-created window on top
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>order[&lt;span style="color:#099">0&lt;/span>] &lt;span style="color:#000;font-weight:bold">=&lt;/span> created_type;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Then in order to iterate over windows in their onscreen order, code
walks though &lt;code>order&lt;/code> and uses those values to index into &lt;code>instances&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// Iterate through windows, topmost first
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>&lt;span style="color:#000;font-weight:bold">for&lt;/span> (&lt;span style="color:#458;font-weight:bold">int&lt;/span> i &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>; i &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> slots_in_use; i&lt;span style="color:#000;font-weight:bold">++&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Window &lt;span style="color:#000;font-weight:bold">*&lt;/span>w &lt;span style="color:#000;font-weight:bold">=&lt;/span> instances[order[i]];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Do something with w
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Although somewhat unusual in structure (similar to &lt;a href="https://en.wikipedia.org/wiki/Duff's_device">Duff&amp;rsquo;s
device&lt;/a> but less syntatically
confusing), I think this approach nicely avoids creating multiples of any given
window type while also avoiding any need for more complex data structures,
depending on randomness to bound execution time, or wasting extra memory on
pointers.&lt;/p>
&lt;hr>
&lt;p>One mildly annoying limitation to this solution is that because instances
of each window type are downcast to &lt;code>Window&lt;/code> pointers and get freed
(with the &lt;code>delete&lt;/code> operator) when closed, &lt;code>Window&lt;/code> must have a virtual
destructor in order to avoid undefined behavior when the instances are
deleted even though no further action is required in the destructor:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> &lt;span style="color:#458;font-weight:bold">Window&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">public&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">/*
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"> * Window instances are meant to be POD, but delete on a superclass pointer for an
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"> * instance of a subclass is undefined behavior even for POD types unless there is
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"> * a virtual destructor.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">virtual&lt;/span> &lt;span style="color:#000;font-weight:bold">~&lt;/span>Window() &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">default&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>How I wrote the code isn&amp;rsquo;t the only thing I find worthy of comment in WEB1999,
though. I also have thoughts to share on how I designed the windows that it can
display and why they look the way they do!&lt;/p>
&lt;h2 id="design-notes">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#design-notes">Design notes&lt;/a>
&lt;/h2>
&lt;p>I included many of my design notes in the documentation that comes along with
the program, but in the interest of easy reading I&amp;rsquo;ll reproduce much of that
here alongside images that are much easier to study than they are when the
program is in motion.&lt;/p>
&lt;h3 id="window-styles-and-color">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#window-styles-and-color">Window styles and color&lt;/a>
&lt;/h3>
&lt;p>With an idea in mind to build a &amp;ldquo;kill the pop-ups&amp;rdquo;-style screensaver, the first
code I had to write was something that could display a window which I could
then add interesting things to. Since I had retro PCs in mind, the window
decorations and background color are based on the default styles of Windows 95,
98, and Me; grey borders with an outset/inset shading effect, and a blue
titlebar (changing to grey when a window is inactive).&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2025/web1999/testwindow.png"
alt="A blank window titled &amp;#39;Large Test window&amp;#39;">&lt;figcaption>
&lt;p>The general concept for displaying windows.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>I also limited myself to approximately the &lt;a href="https://en.wikipedia.org/wiki/Web_colors#Web-safe_colors">web-safe color
palette&lt;/a> with only
216 colors available, although the conscious choice to limit the color palette
to that one came a bit later in the process. The CE calculators have displays
capable of doing 16-bit color (5-6-5 RGB), but programmers usually run it in an
8-bit palettized mode instead (where each byte refers to one of 256 colors)
because it&amp;rsquo;s considerably faster on the calculator&amp;rsquo;s relatively slow
Z80-derived processor that also lacks any purpose-built hardware to make it
more efficient to display images.&lt;/p>
&lt;p>As I was developing graphics to display in the program, I had some difficulty
dealing with the limited 256-color palette and ensuring that my graphics looked
like I intended. Once I realized I could lean into the theme with a web-safe
palette and use existing tools (the &lt;a href="https://www.gimp.org/">GNU Image Manipulation
Program&lt;/a>) to make my images look the same on my computer
and a calculator, it was an easy choice! GIMP offers a web-safe palette as an
option &amp;ldquo;out of the box&amp;rdquo; alongside a choice of several dithering methods, so it
was rather fun and easy to develop graphics once I committed to that approach.&lt;/p>
&lt;p>Once I nailed down the general look and feel, the ideas I had for what to put
in each kind of window fell into several categories.&lt;/p>
&lt;h3 id="malware-and-malvertising">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#malware-and-malvertising">Malware and malvertising&lt;/a>
&lt;/h3>
&lt;figure>&lt;img src="https://www.taricorp.net/2025/web1999/malvertising.png"
alt="Three windows, each containing something that seems untrustworthy. The leftmost is titled &amp;#39;Limewire&amp;#39; and has a &amp;#39;Download&amp;#39; link for a 3MB exe file seemingly claiming to be a copy of &amp;#39;Crawling&amp;#39; by Linkin Park. The middle one advertises &amp;#39;HOT SINGLES waiting to meet today!&amp;#39; where some of the text is stylized to look like flames and a large portion of the window is taken up by the frame for an image that is not displayed. The window on the right claims &amp;#39;You are visitor #1,000,000!&amp;#39; with a button to claim a prize. An image of a yellow sports car is between them.">&lt;figcaption>
&lt;p>Malvertising lives in 2024, much as it did in 1999.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>These windows are inspired by malware and malvertising (advertisements designed
to take advantage of people who interact them, often by roping them into scams)
that feel to me like they were common around 1999. It turns out that LimeWire
as appears on the left is a little bit anachronistic because LimeWire didn&amp;rsquo;t
reach its peak until 2001, but malware on peer-to-peer networks is a perennial
concern for their users. The other two advertisements here are much more
generic and would be at home on a web page today with some style adjustments.&lt;/p>
&lt;p>Pornographic (or nearly-so) advertisements are not uncommon on web sites
peddling illegal (or questionably-legal) goods, so the &amp;ldquo;Hot singles&amp;rdquo; window
alludes to that concept without compromising the all-ages friendliness of my
program. As a bonus, using the &amp;ldquo;broken image&amp;rdquo; icon as appeared in Netscape
Navigator (in place of an image that would not be appropriate for all viewers)
nicely captures the feeling of personal computing prior to the year 2000. I was
also quite pleased with the idea of text that looks like it&amp;rsquo;s on fire, which
feels strongly reminiscent of
&lt;a href="https://en.wikipedia.org/wiki/Microsoft_Office_shared_tools#WordArt">WordArt&lt;/a>
(which I feel has become much less used since the 90s).&lt;/p>
&lt;p>Finally, the image of a sports car combined with a dubious claim of being the
millionth visitor to a web page is an easy way for baddies to collect
information from gullible targets. Dangling an apparent prize in front of a
user raises plenty of questions to somebody who takes a moment to think about
it (Why is a prize being given to the millionth visitor specifically?), but
those who get excited and only think carefully later could find their personal
information in the hands of n&amp;rsquo;er-do-wells before they realize!&lt;/p>
&lt;h3 id="memes">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#memes">Memes&lt;/a>
&lt;/h3>
&lt;figure>&lt;img src="https://www.taricorp.net/2025/web1999/memes.png"
alt="Three windows, left to right: an image of a man with half his face covered in machinery, captioned &amp;#39;CATS: ALL YOUR BASE ARE BELONG TO US&amp;#39;; a cartoon luchador in front of a sunset, with text &amp;#39;STRONG BAD SINGS!&amp;#39;, crossed out &amp;#39;$99.99?&amp;#39; followed by &amp;#39;$193.75&amp;#39;, and a phone number 1-800-555-SBSINGS; the bow of a sunken ship sticking out above water with the legend &amp;#39;Mistakes: Your purpose may only be to act as a warning to others&amp;#39;.">&lt;figcaption>
&lt;p>Internet users still enjoy memes today, though these may not be immediately recognized by younger viewers.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Meme culture remains strong today, and while brainstorming ideas for things to
include in WEB1999 I recalled several &amp;ldquo;classic&amp;rdquo; memes that worked out nicely.
&amp;ldquo;All your base are belong to us&amp;rdquo; seems comfortably classified as a classic that
many people will still recognize today, and in browsing a very old collection
of memes I had stored I was reminded of the &amp;ldquo;demotivator&amp;rdquo; genre inspired by
&lt;a href="https://despair.com/">Despair&lt;/a>&amp;rsquo;s parodies of motivational posters; &amp;ldquo;Mistakes&amp;rdquo;
pictured above was one of the earliest examples of their work I found in old
versions of their web site on &lt;a href="https://web.archive.org/web/19981212024329/http://www.despair.com/">the Internet Archive&amp;rsquo;s Wayback
Machine&lt;/a>
that I thought would still look okay when reduced to 256 colors and greatly
reduced in resolution.&lt;/p>
&lt;p>Finally, although I might not classify &amp;ldquo;Strong Bad Sings&amp;rdquo; as a meme, it comes
from &lt;a href="https://homestarrunner.com/">Homestar Runner&lt;/a> which I get the impression
became popular with many of the same people who would have been plugged into
meme culture around the turn of the millennium.&lt;/p>
&lt;h3 id="mass-media">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#mass-media">Mass media&lt;/a>
&lt;/h3>
&lt;figure>&lt;img src="https://www.taricorp.net/2025/web1999/culture.png"
alt="To the left, a window titled &amp;#39;Dragonball Oasis&amp;#39; proclaiming &amp;#39;Welcome to my DRAGONBALL Z fan page!&amp;#39; in rainbow text, featuring chibi-style drawings of two anime characters with dark, spiky hair. On the right, a window titled &amp;#39;What droid?&amp;#39; asks &amp;#39;What kind of droid are you?&amp;#39; featuring a photo of Star Wars&amp;#39; R2-D2 astromech droid and referring to a &amp;#39;Free personality test!&amp;#39; that now includes Episode I.">&lt;figcaption>
&lt;p>The Star Wars prequels were just getting started at the end of the 90s, and young people watching cartoons were very into Dragonball Z.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>In developing ideas for windows, an early one I hit upon was the unprofessional
style of pages that might be found on
&lt;a href="https://en.wikipedia.org/wiki/GeoCities">GeoCities&lt;/a>. Thanks to the &lt;a href="https://blog.archive.org/2009/08/25/geocities-preserved/">efforts of
the Archive Team&lt;/a>, I
was able to easily browse semi-random pages from GeoCities, and one that I
found had exactly the title I used here: &amp;ldquo;Dragonball Oasis.&amp;rdquo; To capture the
feeling of a web page designed by a young fan of anime an excess of enthusiasm
but little concern for easy readability, I thought it would be fun to make some
of the text be rainbow-colored with the color changing for every letter. This
page is also part of a webring, which is a concept that has all but completely
died since the early 2000s.&lt;/p>
&lt;p>Later on, I realized that Star Wars Episode I was released in 1999, so was
perfectly contemporary with the intended timeframe this program represents. By
browsing old versions of the official Star Wars web page, I was inspired to ask
viewers what kind of droid they are best represented by, and took some liberty
to combine that idea with the sketchy concept of a &amp;ldquo;free personality test&amp;rdquo;
which I feel has a proud and dubious history on the Web.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2025/web1999/commercialism.png"
alt="Two advertisements, one of them for America Online advertising 540 hours free and blazing 56k speeds and the other simply saying &amp;#39;Got Milk?&amp;#39; and picturing a tall glass of milk.">&lt;figcaption>
&lt;p>Advertisements just like these were a common sight in the late 90s.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Taking a step back from the malicious and scammy elements I took inspiration
from, I also borrowed ideas from a few of the most well-known 90s advertising
campaigns. AOL &lt;a href="https://web.archive.org/web/20030423062842/http://www.cbc.ca/consumers/market/files/home/aol_discs/">became notorious for sending out enormous quantities of
CDs&lt;/a>
to a public who grew increasingly hostile (or perhaps indifferent) to AOL, so
an advertisement like this that advertises many hours of free service would
have been a common sight. &lt;a href="https://en.wikipedia.org/wiki/Got_Milk%3F">&amp;ldquo;Got
Milk&amp;rdquo;&lt;/a> was another campaign that
appeared across many forms of media; there were few people in the United States
who were never exposed to those ads, so this ought to be familiar to anybody of
at least a certain age.&lt;/p>
&lt;h2 id="bytecoded-graphics-and-future-work">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#bytecoded-graphics-and-future-work">Bytecoded graphics and future work&lt;/a>
&lt;/h2>
&lt;p>At this point, discussion has reached the limit of what I implemented in time
to submit WEB1999 to the contest I wrote it for. After that however, I had more
ideas that seemed interesting to play with even if they weren&amp;rsquo;t going to be
included in my contest submission. This section discusses those ideas, which
involve a variety of technical decisions that I think are worthy of discussion.&lt;/p>
&lt;hr>
&lt;p>In the version of WEB1999 I submitted to the contest, there were 11 different
windows. I might have added more, but the program was nearing the limit for
reasonable size. On CE calculators the tooling supports compressing programs so
they can be larger than 64 kilobytes, but even a compressed program is limited
by the available user RAM once decompressed (up to 154 KB) and I found the
program was nearing that limit.&lt;/p>
&lt;p>Having designed those 11 window kinds, I had a pretty good idea of what
sorts of graphical operations are useful for this application. Since it
seemed interesting to try to allow windows to be provided separately from
the program itself, I set out to define windows as a simple domain-specific
language rather than as C++ classes that must be compiled in to the program.&lt;/p>
&lt;p>That approach could allow window definitions to be stored independently from
the main program and act more like plug-ins than an integral part of WEB1999,
so it would be easy for users to pick and choose which windows they want
to see as well as create their own.&lt;/p>
&lt;h3 id="bytecodes">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#bytecodes">Bytecodes&lt;/a>
&lt;/h3>
&lt;p>Knowing what operations are useful, I came up with the following bytecode
instructions that the program should be able to interpret. A window
would be described by its size, text to be shown in the titlebar,
and a series of bytecode instructions. Each instruction includes up to
four operands that specify things like a position in the window&amp;rsquo;s
content area or colors to use:&lt;/p>
&lt;ul>
&lt;li>&lt;code>Fill(c)&lt;/code>: fills the entire window content area with a specified color.&lt;/li>
&lt;li>&lt;code>Color(c)&lt;/code>: sets the color used by all following shape drawing commands,
until a new color is set.&lt;/li>
&lt;li>&lt;code>Rect(x, y, w, h)&lt;/code>: draw an unfilled rectangle with the top left corner
at the specified x and y coordinates, with specified width and height.&lt;/li>
&lt;li>&lt;code>FillRect(x, y, w, h)&lt;/code>: same as &lt;code>Rect()&lt;/code> but fills the rectangle with the
same color as its outline.&lt;/li>
&lt;li>&lt;code>Horiz(x, y, w)&lt;/code>: draw a horizontal line start at specified (x,y)
coordinates, with width w.&lt;/li>
&lt;li>&lt;code>Vert(x, y, h)&lt;/code>: same as &lt;code>Horiz()&lt;/code> but draws a vertical line with height
h.&lt;/li>
&lt;li>&lt;code>Circle(x, y, r)&lt;/code>: draw an unfilled circle centered at (x,y) with
radius r.&lt;/li>
&lt;li>&lt;code>TextFG(c)&lt;/code>: set the text foreground color to c.&lt;/li>
&lt;li>&lt;code>TextColors(f, b)&lt;/code>: set the test foreground color to f and background
color to b.&lt;/li>
&lt;li>&lt;code>StrXY(x, y, s)&lt;/code>: display the string s at (x,y).&lt;/li>
&lt;li>&lt;code>Str(s)&lt;/code>: display the string s immediately to the right of the last
displayed text.&lt;/li>
&lt;li>&lt;code>Sprite(x, y, i)&lt;/code>: display the image i at (x,y)&lt;/li>
&lt;li>&lt;code>SpriteRLET(x, y, i)&lt;/code>: display the image i (with transparency and
RLE compression) at (x,y)&lt;/li>
&lt;/ul>
&lt;p>Clearly some of these commands partially duplicate the functionality
of others, but I found it useful to have simplified versions of some
commands like setting the text color (where I often wanted to change
the foreground color but not the background color), or encode data
differently (RLET sprites are faster to display and require less
memory to store, provided a sprite has moderately large areas of
contiguous transparency).&lt;/p>
&lt;hr>
&lt;p>The encoding of bytecode that the program consumes is as a stream of
single-byte opcodes (basically the names of each of the supported functions),
each followed by its parameters encoded in a way defined by the opcode. The
&lt;code>Fill&lt;/code> and &lt;code>Color&lt;/code> operations for example are followed by a single byte
specifying the palette index to use, whereas &lt;code>Sprite&lt;/code> is followed by a 16-bit X
coordinate, 8-bit Y coordinate, 8-bit image width and height, then width×height
bytes of image data.&lt;/p>
&lt;p>Each of these bytecode functions translates pretty directly to graphics
functions as they exist in the calculator libraries, so that aspect of the
implementation is mostly uninteresting. But to simplify writing windows in
terms of the bytecode I spent some effort building tools, and those are also
worth discussing.&lt;/p>
&lt;h3 id="compilation-and-preview">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#compilation-and-preview">Compilation and preview&lt;/a>
&lt;/h3>
&lt;p>I quickly found while translating some of the window types to bytecode that
it was difficult to manually write the bytecodes. Even with the assistance
of some macros, my initial approach of writing the bytecode as byte arrays
in the program&amp;rsquo;s C++ source clearly wouldn&amp;rsquo;t scale to creating more windows
with ease. The &amp;ldquo;Hot Singles&amp;rdquo; window was the first one I converted, and
it looked like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">extern&lt;/span> &lt;span style="color:#000;font-weight:bold">const&lt;/span> &lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span> HotSingles_descriptor[] &lt;span style="color:#000;font-weight:bold">=&lt;/span> WINDOW_BYTECODE(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#099">180&lt;/span>, &lt;span style="color:#099">126&lt;/span>, &lt;span style="color:#d14">&amp;#34;Singles near you!&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> FILL(C_WHITE),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> RECT(C_BORDER_TOP, &lt;span style="color:#099">0&lt;/span>, &lt;span style="color:#099">0&lt;/span>, &lt;span style="color:#099">96&lt;/span>, &lt;span style="color:#099">64&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> SPRITE(broken_image, (&lt;span style="color:#099">96&lt;/span> &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">2&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&lt;/span> (broken_image_width &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">2&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (&lt;span style="color:#099">64&lt;/span> &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">2&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&lt;/span> (broken_image_height &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">2&lt;/span>)),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> SPRITE_RLET(hotsingles_left, &lt;span style="color:#099">5&lt;/span>, &lt;span style="color:#099">66&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> SPRITE_RLET(hotsingles_right, &lt;span style="color:#099">63&lt;/span>, &lt;span style="color:#099">66&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> STR_XY(&lt;span style="color:#d14">&amp;#34;Waiting to meet today!&amp;#34;&lt;/span>, &lt;span style="color:#099">10&lt;/span>, &lt;span style="color:#099">94&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> TEXT_COLOR(C_GREEN),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> STR_XY(&lt;span style="color:#d14">&amp;#34;CHAT&amp;#34;&lt;/span>, &lt;span style="color:#099">118&lt;/span>, &lt;span style="color:#099">22&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> STR_XY(&lt;span style="color:#d14">&amp;#34;NOW!&amp;#34;&lt;/span>, &lt;span style="color:#099">120&lt;/span>, &lt;span style="color:#099">30&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>);&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Although not too difficult to read, I needed to edit other project files to
import image files in a format suitable for embedding in a program (variables
like &lt;code>broken_image&lt;/code> and &lt;code>hotsingles_left&lt;/code> refer to image data) and although it&amp;rsquo;s
not difficult to understand how the window is meant to look from reading this
code, making changes while developing it is tedious because that required
rebuilding and relaunching the program.&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup>&lt;/p>
&lt;p>To improve on those things, I chose to define a domain-specific language that
expresses the bytecode operations nicely and integrate that with a way to
generate a live preview of the window definition code so I could get a
WYSIWYG experience.&lt;/p>
&lt;p>My first approach to a domain-specific language was building a small Python
library that allowed me to write a window definition as a sequence of
Python function calls. Something like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">w9bcc&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> &lt;span style="color:#000;font-weight:bold">*&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>FillWindow(C_WHITE)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Rectangle(C_BORDER_TOP, &lt;span style="color:#099">0&lt;/span>, &lt;span style="color:#099">0&lt;/span>, &lt;span style="color:#099">96&lt;/span>, &lt;span style="color:#099">64&lt;/span>)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>That was a little easier to write, but I wasn&amp;rsquo;t satisfied by the options I had
to load images or conveniently specify colors while still selecting a color
that actually exists in the palette used by the program. In particular,
although I could choose existing libraries to load images and quantize them to
the same palette as used by WEB1999 I thought it would be difficult to build a
Python interpreter and those libraries into a format that would be easy to
distribute so anybody could write window bytecode. In particular, my ideal was
that it should be possible for anybody to write window definitions just by
loading an editor in their web browser and save the resulting bytecode in a
format suitable for sending to a calculator. So, instead of continuing further
down the Python path I decided to make it so bytecode would be written in Lua
using some WEB1999-specific libraries.&lt;/p>
&lt;p>Although I had never worked with Lua before, it seemed like a good choice for
this application because it&amp;rsquo;s easy to embed in larger programs and it&amp;rsquo;s not
too complex as a language so it should be fairly accessible to other people.
The result looks like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-lua" data-lang="lua">&lt;span style="display:flex;">&lt;span>require &lt;span style="color:#d14">&amp;#39;w9bcc&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">local&lt;/span> WW &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">168&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>WindowTitle(&lt;span style="color:#d14">&amp;#34;Singles near you!&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>WindowSize{width &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">168&lt;/span>, height &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">105&lt;/span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>FillWindow(&lt;span style="color:#d14">&amp;#39;white&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>SetDrawColor(&lt;span style="color:#d14">&amp;#39;silver&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>DrawRectangle{x &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>, y &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>, width &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">96&lt;/span>, height &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">64&lt;/span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">local&lt;/span> broken_image &lt;span style="color:#000;font-weight:bold">=&lt;/span> Sprite:load(&lt;span style="color:#d14">&amp;#39;broken_image.png&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>broken_image:draw_at{x &lt;span style="color:#000;font-weight:bold">=&lt;/span> (&lt;span style="color:#099">96&lt;/span> &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">2&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&lt;/span> (broken_image.width &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">2&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> y &lt;span style="color:#000;font-weight:bold">=&lt;/span> (&lt;span style="color:#099">64&lt;/span> &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">2&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&lt;/span> (broken_image.height &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">2&lt;/span>)}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>It&amp;rsquo;s not difficult to see a similarity between this Lua and the Python sample
above, but it has solutions to the difficulties I already discussed:&lt;/p>
&lt;ul>
&lt;li>Colors are specified as strings, which are interpreted according to the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color_value">rules
of CSS&lt;/a>. That
means that where it&amp;rsquo;s convenient basic names (like &lt;code>white&lt;/code> or &lt;code>green&lt;/code>)
can be used, or colors can be specified with their RGB components (&lt;code>#fdcb12&lt;/code>)
or even by hue, saturation and lightness (HSL; &lt;code>hsl(50 80% 40%)&lt;/code>).&lt;/li>
&lt;li>Images can be loaded as &lt;code>Sprite&lt;/code> objects, which are converted to the appropriate
palette when loaded. Their dimensions are exposed as properties of the &lt;code>Sprite&lt;/code>
object, which is useful to compute the position to draw at when an image is
meant to be centered at some position.&lt;/li>
&lt;/ul>
&lt;p>The Lua interpreter that runs this code is embedded in a Rust program,
because I needed to write code to handle image loading and color conversion.
It was reasonably easy to do those tasks in Rust and expose the relevant
functions to Lua as a library, with a few magic functions to emit data
into the final bytecode stream (hidden behind functions like &lt;code>FillWindow&lt;/code>
in the above example).&lt;/p>
&lt;hr>
&lt;p>In order to get WYSIWYG editing (and to satisfy the goal that everything should
be usable by anybody with access to a web browser), I embedded the Rust program
in a web application (which I knew would be feasible when I started using Rust
for this application). It has a text editor and a canvas to display a window in
(both things that are easy to do in a web page but moderately difficult to do
in programs generally), and when the text changes it attempts to run the
bytecode compiler. If successful, it then interprets the bytecode to display
the results. Since this is a web application, you can &lt;a href="
https://www.taricorp.net/2025/web1999/w9bcc-web/index.html
">go try it out for
yourself!&lt;/a>&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2025/web1999/w9bcc-snapshot.png"
alt="A screenshot of a web browser, with a text box containing syntax-highlighted Lua code on the left side and an image of a WEB1999 window containing several colored boxes, text and sprites. A text box below the preview reads &amp;#39;Generated: 608 bytes of bytecode.&amp;#39;">&lt;figcaption>
&lt;p>The bytecode editor is a simple split view between code and a preview of the result, with status and error information also shown.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>One annoyance with this simple web application is in how images need to be
loaded. Because the app does not have access to files in general, they need
to be manually loaded via a file picker before they can be loaded with
&lt;code>Sprite:load&lt;/code>. I think this could be improved with the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_API">web file system
API&lt;/a>, but
because my bytecode compiler can also be built as a regular program and run on
the command line it&amp;rsquo;s not annoying enough for me to have investigated: it&amp;rsquo;s
reasonably easy to load images into the webapp for testing, then save a copy
of the images and final bytecode source as the &amp;ldquo;final&amp;rdquo; version which can
be recompiled at any time without using the web app.&lt;/p>
&lt;hr>
&lt;p>Interpreting the bytecode here reuses the C++ decoder that I built for the
actual program (here called &lt;code>w99bci&lt;/code>), which is compiled separately from the
Rust bytecode compiler (&lt;code>w99bcc&lt;/code>). A small Javascript driver program takes
bytecode from the compiler and passes it to the renderer which is exposed to
Javascript using Emscripten&amp;rsquo;s binding generation tools. I had to write
versions of the calculator graphics functions that worked in terms of a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D">2d
canvas rendering
context&lt;/a>,
but that wasn&amp;rsquo;t terribly difficult. Displaying text in the same way it would
appear on a calculator was most complex, because I needed to embed a copy of
the calculator&amp;rsquo;s bitmap font and decode text into a series of images to
display. The Javascript driver looks something like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// interpreter is the bytecode compiler (Rust with embedded Lua interpreter)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>&lt;span style="color:#000;font-weight:bold">const&lt;/span> interpreter &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">new&lt;/span> Interpreter()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// renderer takes bytecode and displays to a canvas (C++)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>&lt;span style="color:#000;font-weight:bold">const&lt;/span> renderer &lt;span style="color:#000;font-weight:bold">=&lt;/span> Renderer.create(&lt;span style="color:#0086b3">document&lt;/span>.getElementById(&lt;span style="color:#d14">&amp;#39;rendererCanvas&amp;#39;&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// editor refers to the text input widget
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>&lt;span style="color:#000;font-weight:bold">const&lt;/span> script &lt;span style="color:#000;font-weight:bold">=&lt;/span> editor.getValue()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">const&lt;/span> result &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">await&lt;/span> interpreter.evaluate(script)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>renderer.render(result.bytecode)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The C++ renderer has a small amount of code that&amp;rsquo;s emscripten-specific,
but most of the class implementation is the same between the web and
calculator. When constructing a renderer to be embedded in a web page,
the caller is expected to pass in a handle to the DOM canvas element,
palette data, and data representing the bitmaps corresponding to how
characters are displayed in the calculator&amp;rsquo;s font. The palette and font
data are loaded from image files in the caller in my application.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">#ifdef __EMSCRIPTEN__
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">&lt;/span>Renderer&lt;span style="color:#000;font-weight:bold">::&lt;/span>Renderer(emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val canvasElement, emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val paletteValues,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val fontBitmaps)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">:&lt;/span> x(&lt;span style="color:#099">0&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> y(&lt;span style="color:#099">0&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> w(canvasElement[&lt;span style="color:#d14">&amp;#34;width&amp;#34;&lt;/span>].as&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>&lt;span style="color:#458;font-weight:bold">uint16_t&lt;/span>&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>()),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> h(canvasElement[&lt;span style="color:#d14">&amp;#34;height&amp;#34;&lt;/span>].as&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>&lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span>&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>()) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">using&lt;/span> &lt;span style="color:#000;font-weight:bold">namespace&lt;/span> std&lt;span style="color:#000;font-weight:bold">::&lt;/span>string_literals;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">auto&lt;/span> ctx &lt;span style="color:#000;font-weight:bold">=&lt;/span> canvasElement.call&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>(&lt;span style="color:#d14">&amp;#34;getContext&amp;#34;&lt;/span>, &lt;span style="color:#d14">&amp;#34;2d&amp;#34;&lt;/span>s);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gfx_Begin(ctx, paletteValues, fontBitmaps);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#458;font-weight:bold">void&lt;/span> Renderer&lt;span style="color:#000;font-weight:bold">::&lt;/span>renderJS(&lt;span style="color:#000;font-weight:bold">const&lt;/span> emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span>bytecode) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> std&lt;span style="color:#000;font-weight:bold">::&lt;/span>vector&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>&lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span>&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span> bytes &lt;span style="color:#000;font-weight:bold">=&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>convertJSArrayToNumberVector&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>&lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span>&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>(bytecode);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (bytes.size() &lt;span style="color:#000;font-weight:bold">==&lt;/span> &lt;span style="color:#099">0&lt;/span> &lt;span style="color:#000;font-weight:bold">||&lt;/span> bytes.back() &lt;span style="color:#000;font-weight:bold">!=&lt;/span> OP_EOF) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> EM_ASM({&lt;span style="color:#000;font-weight:bold">throw&lt;/span> &lt;span style="color:#000;font-weight:bold">new&lt;/span> &lt;span style="color:#900;font-weight:bold">Error&lt;/span>(&lt;span style="color:#a61717;background-color:#e3d2d2">&amp;#39;&lt;/span>Bytecode must end with OP_EOF, but did not&lt;span style="color:#a61717;background-color:#e3d2d2">&amp;#39;&lt;/span>)});
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> render(bytes.data());
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>EMSCRIPTEN_BINDINGS(Renderer) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>class_&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>Renderer&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>(&lt;span style="color:#d14">&amp;#34;Renderer&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .constructor&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val, emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val, emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .function(&lt;span style="color:#d14">&amp;#34;render&amp;#34;&lt;/span>, &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span>Renderer&lt;span style="color:#000;font-weight:bold">::&lt;/span>renderJS);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">#else &lt;/span>&lt;span style="color:#998;font-style:italic">/* !__EMSCRIPTEN__ */&lt;/span>&lt;span style="color:#999;font-weight:bold;font-style:italic">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">&lt;/span>Renderer&lt;span style="color:#000;font-weight:bold">::&lt;/span>Renderer(Rect contentArea)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">:&lt;/span> x(contentArea.x),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> y(contentArea.y),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> w(contentArea.width),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> h(contentArea.height) {}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">#endif&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>To implement graphics in terms of the library functions used on a calculator,
I added more C++ code that maintains the same kind of global state that the
calculator has, and depends on the application passing in handles to the
requisite Javascript objects (the canvas rendering context notably):&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c++" data-lang="c++">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> std&lt;span style="color:#000;font-weight:bold">::&lt;/span>vector&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>RGB&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span> palette;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val ctx &lt;span style="color:#000;font-weight:bold">=&lt;/span> emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val&lt;span style="color:#000;font-weight:bold">::&lt;/span>undefined();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val fontBitmaps &lt;span style="color:#000;font-weight:bold">=&lt;/span> emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val&lt;span style="color:#000;font-weight:bold">::&lt;/span>undefined();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> RGBA textFGColor &lt;span style="color:#000;font-weight:bold">=&lt;/span> {.opaque &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">true&lt;/span>, .rgb &lt;span style="color:#000;font-weight:bold">=&lt;/span> {&lt;span style="color:#099">0&lt;/span>, &lt;span style="color:#099">0&lt;/span>, &lt;span style="color:#099">0&lt;/span>}};
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> RGBA textBGColor &lt;span style="color:#000;font-weight:bold">=&lt;/span> {.opaque &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">false&lt;/span>, .rgb &lt;span style="color:#000;font-weight:bold">=&lt;/span> {}};
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> &lt;span style="color:#458;font-weight:bold">int&lt;/span> textX;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">static&lt;/span> &lt;span style="color:#458;font-weight:bold">int&lt;/span> textY;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#458;font-weight:bold">void&lt;/span> &lt;span style="color:#900;font-weight:bold">gfx_Begin&lt;/span>(emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val canvasContext, emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val paletteRGB,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> emscripten&lt;span style="color:#000;font-weight:bold">::&lt;/span>val fontBitmapsIn) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ctx &lt;span style="color:#000;font-weight:bold">=&lt;/span> canvasContext;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fontBitmaps &lt;span style="color:#000;font-weight:bold">=&lt;/span> fontBitmapsIn;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> (size_t i &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>; i &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> paletteRGB[&lt;span style="color:#d14">&amp;#34;length&amp;#34;&lt;/span>].as&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>size_t&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>(); i&lt;span style="color:#000;font-weight:bold">++&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span> r &lt;span style="color:#000;font-weight:bold">=&lt;/span> paletteRGB[i][&lt;span style="color:#099">0&lt;/span>].as&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>&lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span>&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span> g &lt;span style="color:#000;font-weight:bold">=&lt;/span> paletteRGB[i][&lt;span style="color:#099">1&lt;/span>].as&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>&lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span>&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span> b &lt;span style="color:#000;font-weight:bold">=&lt;/span> paletteRGB[i][&lt;span style="color:#099">2&lt;/span>].as&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>&lt;span style="color:#458;font-weight:bold">uint8_t&lt;/span>&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> palette.push_back({.r &lt;span style="color:#000;font-weight:bold">=&lt;/span> r, .g &lt;span style="color:#000;font-weight:bold">=&lt;/span> g, .b &lt;span style="color:#000;font-weight:bold">=&lt;/span> b});
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#458;font-weight:bold">void&lt;/span> &lt;span style="color:#900;font-weight:bold">gfx_FillRectangle&lt;/span>(&lt;span style="color:#458;font-weight:bold">int&lt;/span> x, &lt;span style="color:#458;font-weight:bold">int&lt;/span> y, &lt;span style="color:#458;font-weight:bold">int&lt;/span> w, &lt;span style="color:#458;font-weight:bold">int&lt;/span> h) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ctx.call&lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span>&lt;span style="color:#458;font-weight:bold">void&lt;/span>&lt;span style="color:#000;font-weight:bold">&amp;gt;&lt;/span>(&lt;span style="color:#d14">&amp;#34;fillRect&amp;#34;&lt;/span>, x, y, w, h);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Since when running on a calculator a program needs to call &lt;code>gfx_Begin&lt;/code> to
switch display modes anyway, it&amp;rsquo;s easy to do canvas initialization in the same
function for &lt;code>w99bci&lt;/code>. Functions like &lt;code>gfx_FillRectangle&lt;/code> implement the actual
drawing commands, using the global state set up in &lt;code>gfx_Begin&lt;/code>.&lt;/p>
&lt;h3 id="limitations">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#limitations">Limitations&lt;/a>
&lt;/h3>
&lt;p>Currently the web-based editor can generate bytecode from an easy-to-write
Lua program, but can&amp;rsquo;t be saved as a program that can be loaded by WEB1999
running on a calculator. The main reason for this is that I&amp;rsquo;m not very satisfied
with the amount of memory they would require: when window definitions are built
into the main program, they automatically get compressed with the rest of the
code and data and decompressed when the program is run. When loading external bytecode
however, the program would either need to:&lt;/p>
&lt;ol>
&lt;li>Decompress bytecode on the fly whenever a window needs to be displayed,
which could be slow.&lt;/li>
&lt;li>Require that external bytecode not be compressed, potentially requiring
a lot of memory.&lt;/li>
&lt;li>Decompress all bytecode that will be used into RAM at startup.&lt;/li>
&lt;/ol>
&lt;p>None of those are very satisfactory to me. I don&amp;rsquo;t think on-the-fly
decompression will be fast enough, not allowing compression of external
bytecode seems like it would be too large, and decompressing bytecode at
startup wouldn&amp;rsquo;t solve the issue of the program currently being limited by the
amount of available RAM. Possibly a hybrid solution such as allowing a window
definition to be either compressed or uncompressed and decompressing them at
startup if needed would be acceptable, but I haven&amp;rsquo;t spent the time to explore
those ideas much.&lt;/p>
&lt;hr>
&lt;p>At the time of this writing, the latest revisions to WEB1999 have converted
all of the windows I created to be expressed in terms of bytecode. This is
integrated into the procedure for compiling the program, where &lt;code>w99bcc&lt;/code> is run
as needed to translate Lua source into data that gets compiled into the program
and interpreted using the &lt;code>w99bci&lt;/code> component. This represents useful steps toward
being able to load external bytecode as well, but since I haven&amp;rsquo;t developed a
satisfactory solution to the bytecode size-related concerns it&amp;rsquo;s unable to load
external bytecode at this time.&lt;/p>
&lt;h2 id="conclusions">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2025/web1999/#conclusions">Conclusions&lt;/a>
&lt;/h2>
&lt;p>I had fun writing WEB1999, and it was nice to have a calculator programming
challenge that caught my interest. It had been some years since I wrote any
software intended to run on a calculator, and I enjoyed working within those
limitations.&lt;/p>
&lt;p>If you own or have access to a TI-84 Plus CE calculator, I&amp;rsquo;d encourage you to
load a copy of WEB1999 onto it for on-demand entertainment. For everybody else,
I hope there were some interesting aspects to its implementation that I&amp;rsquo;ve
described or that the historical discussion around the windows I designed was
some combination of entertaining and informative!&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>Oops, it took me more than a year to write this post.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>Referring to sprites by name as in &lt;code>broken_image&lt;/code> also wouldn&amp;rsquo;t
work for images that weren&amp;rsquo;t built into the program, because in this example
they&amp;rsquo;re actually expressed as pointers to data compiled into the program.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Video Squisher: minimal command-line processing on the web</title><link>https://www.taricorp.net/2023/video-squisher/</link><pubDate>Fri, 29 Dec 2023 02:24:54 +0000</pubDate><guid>https://www.taricorp.net/2023/video-squisher/</guid><description>&lt;p>I&amp;rsquo;ve recently been asked to reduce the size of video files with some regularity, taking in a video file and generating something with reduced file size (&amp;ldquo;squishing&amp;rdquo; the video; hence &amp;ldquo;video squisher&amp;rdquo;). This is an easy task to accomplish with &lt;a href="https://handbrake.fr/">Handbrake&lt;/a>, and since the transcodes I was asked to do were consistent in their needs, I was able to set up a preset in Handbrake to make these conversions very simple.&lt;/p>
&lt;p>Unfortunately, there were a few steps that weren&amp;rsquo;t as easy to automate: namely, grabbing the original video and sharing the transcoded version later. Rather than need to do anything myself for each video, I sought to make my process available for &amp;ldquo;self-service&amp;rdquo;, probably as some kind of web-based tool instead. Since the imposition of receiving a file, running it through Handbrake, and sharing the result is fairly small though, I wanted to make this tool as simple as possible.&lt;/p>
&lt;p>I believe I succeeded and that the results are interesting enough to share because I discovered a few new tricks that made it easier, so in this post I will describe what I built to meet this need and how it was made.&lt;/p>
&lt;h2 id="requirements">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/video-squisher/#requirements">Requirements&lt;/a>
&lt;/h2>
&lt;p>Since manually processing a few videos is fairly easy, whatever I might build to automate video conversion would also need to be a simple tool requiring little effort to build and run. However, it also needs to be comprehensible to an unsophisticated user so I arrived at these requirements:&lt;/p>
&lt;ul>
&lt;li>There must be a single server component with no external dependencies such as a database server.&lt;/li>
&lt;li>All input and output must occur through a web browser.&lt;/li>
&lt;li>Video transcoding progress should be reported to the user in real time.&lt;/li>
&lt;/ul>
&lt;p>Given I had been using Handbrake to manually transcode videos, I expected that automation of the process would involve running Handbrake&amp;rsquo;s CLI in a subprocess and streaming its console output back to the client.&lt;/p>
&lt;h2 id="client-prototyping-enter-sse">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/video-squisher/#client-prototyping-enter-sse">Client prototyping: enter SSE&lt;/a>
&lt;/h2>
&lt;p>Of the requirements I had set, the goal of reporting real-time progress seemed most challenging so I investigated that first. I fairly quickly stumbled upon a web API that I wasn&amp;rsquo;t familiar with which seemed to meet my needs: &lt;a href="https://en.wikipedia.org/wiki/Server-sent_events">Server-sent events&lt;/a> (SSE). This API involves a client making a single HTTP request to a server, which then responds with a stream of events in a structured data format (using content type &lt;code>text/event-stream&lt;/code>). At a high level, the flow of &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events">using an &lt;code>EventSource&lt;/code>&lt;/a> in a browser is to:&lt;/p>
&lt;ol>
&lt;li>Send an HTTP request to a server&lt;/li>
&lt;li>Handle messages as they are sent from the server. Each message has up to four fields that the client can interpret:
&lt;ul>
&lt;li>&lt;code>data&lt;/code>: UTF-8 text of arbitrary length.&lt;/li>
&lt;li>&lt;code>event&lt;/code>: optional application-defined string identifying what kind of event this is.&lt;/li>
&lt;li>&lt;code>id&lt;/code>: optional application-defined ID for a message, allowing the client to resume from the last message it received if the connection is lost by sending a request containing this ID.&lt;/li>
&lt;li>&lt;code>retry&lt;/code>: a number indicating how long the server wishes the client to wait before attempting to reconnect, if the connection is lost.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>When satisfied with the received data, the client may close the connection.&lt;/li>
&lt;/ol>
&lt;p>Overall, SSE is similar to older technologies like &lt;a href="https://javascript.info/long-polling">HTTP long polling&lt;/a> or the newer &lt;a href="https://javascript.info/websocket">websockets&lt;/a>. Notably however, SSE operates over regular HTTP connections unlike websockets (which require that the server understand the websocket protocol and how to &amp;ldquo;upgrade&amp;rdquo; an HTTP request to a websocket), and SSE inherently presents a potentially-unbounded sequence of events to the client whereas long-polling may require the client to make a new request for each message to be received.&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>&lt;/p>
&lt;p>In the interest of making the server implementation simple, SSE seemed like a good choice because I would only need to ensure that the server returned a valid stream of events. SSE communication is unidirectional, excepting the initial request (messages are only streamed from the server back to the client) whereas websocket messages can be sent both ways (also from the client to the server) which introduces a small challenge because it needs to be possible to send data back to the server. For the application I&amp;rsquo;m interested in of receiving a file and sending back progress then a result, sending the file contents in an initial request ought to be sufficient.&lt;/p>
&lt;h3 id="sending-file-data">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/video-squisher/#sending-file-data">Sending file data&lt;/a>
&lt;/h3>
&lt;p>There&amp;rsquo;s one problem with the idea of using server-sent events: the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource">&lt;code>EventSource&lt;/code>&lt;/a> constructor provided by web browsers for the client to open a connection doesn&amp;rsquo;t offer any way to send data alongside the original request, which I need in order to upload a file to be processed. Conveniently, others have noticed the same limitation and worked around it in the form of the &lt;a href="https://www.npmjs.com/package/@microsoft/fetch-event-source">&lt;code>fetch-event-source&lt;/code>&lt;/a> package which also describes the limitations of the standard &lt;code>EventSource&lt;/code>, saving me some explanation:&lt;/p>
&lt;blockquote>
&lt;ul>
&lt;li>You cannot pass in a request body: you have to encode all the
information necessary to execute the request inside the URL, which is
limited to 2000 characters in most browsers.&lt;/li>
&lt;li>You cannot pass in custom request headers&lt;/li>
&lt;li>You can only make GET requests - there is no way to specify another method.&lt;/li>
&lt;li>If the connection is cut, you don&amp;rsquo;t have any control over the retry
strategy: the browser will silently retry for you a few times and
then stop, which is not good enough for any sort of robust application.&lt;/li>
&lt;/ul>
&lt;/blockquote>
&lt;p>With &lt;code>fetch-event-source&lt;/code> in hand, I prototyped a basic client allowing a user to select a file, which would then be uploaded to the server and events handled as sent back. The HTML is simple, substantially just an input field and some javascript to be run when the user confirms the selected file:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-html" data-lang="html">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">&amp;lt;!DOCTYPE html&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;&lt;span style="color:#000080">html&lt;/span> &lt;span style="color:#008080">lang&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;en&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#000080">head&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#000080">title&lt;/span>&amp;gt;SSE prototype&amp;lt;/&lt;span style="color:#000080">title&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#000080">head&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#000080">body&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#000080">form&lt;/span> &lt;span style="color:#008080">id&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;inputForm&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#000080">label&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Select a file to process:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#000080">input&lt;/span> &lt;span style="color:#008080">type&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;file&amp;#34;&lt;/span> &lt;span style="color:#008080">id&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;fileSelect&amp;#34;&lt;/span> &lt;span style="color:#008080">required&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#000080">label&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#000080">input&lt;/span> &lt;span style="color:#008080">type&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;submit&amp;#34;&lt;/span> &lt;span style="color:#008080">value&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;Go!&amp;#34;&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#000080">form&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;&lt;span style="color:#000080">script&lt;/span> &lt;span style="color:#008080">type&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;module&amp;#34;&lt;/span> &lt;span style="color:#008080">src&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;client.mjs&amp;#34;&lt;/span>&amp;gt;&amp;lt;/&lt;span style="color:#000080">script&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &amp;lt;/&lt;span style="color:#000080">body&lt;/span>&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;lt;/&lt;span style="color:#000080">html&lt;/span>&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Since I don&amp;rsquo;t care much about compatibility with old browsers that don&amp;rsquo;t support javascript modules, I was able to write &amp;ldquo;modern&amp;rdquo; javascript in &lt;code>client.mjs&lt;/code> to plumb data through:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// Use fetch-event-source, pulling from a CDN so I don&amp;#39;t need to bother
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// with serving a copy alongside my application.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>&lt;span style="color:#000;font-weight:bold">import&lt;/span> { fetchEventSource } from &lt;span style="color:#d14">&amp;#39;https://cdn.jsdelivr.net/npm/@microsoft/fetch-event-source@2.0.1/+esm&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">async&lt;/span> &lt;span style="color:#000;font-weight:bold">function&lt;/span> handleFile(evt) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Don&amp;#39;t do normal form submission; this function does something else.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> evt.preventDefault();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Get the File selected by the user.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">const&lt;/span> file &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">document&lt;/span>.getElementById(&lt;span style="color:#d14">&amp;#39;fileSelect&amp;#39;&lt;/span>).files[&lt;span style="color:#099">0&lt;/span>];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Open an event source, POSTing the file data to /process as a blob
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// of bytes and interpreting the response as an event stream.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">await&lt;/span> fetchEventSource(&lt;span style="color:#d14">&amp;#39;/process&amp;#39;&lt;/span>, {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> method&lt;span style="color:#000;font-weight:bold">:&lt;/span> &lt;span style="color:#d14">&amp;#39;POST&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> header&lt;span style="color:#000;font-weight:bold">:&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;Content-Type&amp;#39;&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span> &lt;span style="color:#d14">&amp;#39;application/octet-stream&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> body&lt;span style="color:#000;font-weight:bold">:&lt;/span> file,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// For this prototype, just log every message that&amp;#39;s received
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> onmessage(ev) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> console.log(ev);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">// Do the EventSource thing when the form is submitted.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>&lt;span style="color:#0086b3">document&lt;/span>.getElementById(&lt;span style="color:#d14">&amp;#39;inputForm&amp;#39;&lt;/span>).onsubmit &lt;span style="color:#000;font-weight:bold">=&lt;/span> handleFile;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;h2 id="server-implementation">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/video-squisher/#server-implementation">Server implementation&lt;/a>
&lt;/h2>
&lt;p>Without a server the above client prototype is useless, so I next had to write a server application that can accept the uploaded file and send back events. Since I&amp;rsquo;m familiar with the tools available in Python&amp;rsquo;s standard library, I opted to implement it in terms of &lt;a href="https://docs.python.org/3.12/library/http.server.html">&lt;code>http.server&lt;/code>&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">47
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">48
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">49
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">50
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">51
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">52
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">53
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">54
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">55
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">56
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">57
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">58
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">59
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">60
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">61
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">62
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">import&lt;/span> &lt;span style="color:#555">http.server&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>BUF_COPY_SZ &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">1&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> &lt;span style="color:#099">20&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> &lt;span style="color:#458;font-weight:bold">Handler&lt;/span>(http&lt;span style="color:#000;font-weight:bold">.&lt;/span>server&lt;span style="color:#000;font-weight:bold">.&lt;/span>SimpleHTTPRequestHandler):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">send_event&lt;/span>(&lt;span style="color:#999">self&lt;/span>, data: &lt;span style="color:#0086b3">str&lt;/span> &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>, ty: &lt;span style="color:#0086b3">str&lt;/span> &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#34;&amp;#34;&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14"> Send an event in a stream, with specified type (unset if None)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14"> and data (no data if None).
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14"> &amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> data &lt;span style="color:#000;font-weight:bold">is&lt;/span> &lt;span style="color:#000;font-weight:bold">not&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Send the event type if specified.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> ty &lt;span style="color:#000;font-weight:bold">is&lt;/span> &lt;span style="color:#000;font-weight:bold">not&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>wfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>write(&lt;span style="color:#d14">f&lt;/span>&lt;span style="color:#d14">&amp;#39;event: &lt;/span>&lt;span style="color:#d14">{&lt;/span>ty&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">\n&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>encode(&lt;span style="color:#d14">&amp;#39;utf-8&amp;#39;&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Line breaks are field separators in an event stream, so multiline&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># data must be split into multiple data: lines.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> line &lt;span style="color:#000;font-weight:bold">in&lt;/span> data&lt;span style="color:#000;font-weight:bold">.&lt;/span>splitlines():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>wfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>write(&lt;span style="color:#d14">f&lt;/span>&lt;span style="color:#d14">&amp;#39;data: &lt;/span>&lt;span style="color:#d14">{&lt;/span>line&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">\n&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>encode(&lt;span style="color:#d14">&amp;#39;utf-8&amp;#39;&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Messages are separated by blank lines&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>wfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>write(&lt;span style="color:#d14">b&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>&lt;span style="color:#d14">\n&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">else&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># No data sends a line with no meaning, useful to keep the&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># connection alive by sending some data.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>wfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>write(&lt;span style="color:#d14">b&lt;/span>&lt;span style="color:#d14">&amp;#39;:&lt;/span>&lt;span style="color:#d14">\n&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Ensure the whole message gets sent immediately.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>wfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>flush()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">do_POST&lt;/span>(&lt;span style="color:#999">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># SimpleHTTPRequestHandler will call this to handle POST&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># requests.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># To receive the uploaded file, we need to know how large it is.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> upload_size &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>headers&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#d14">&amp;#39;Content-Length&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> upload_size &lt;span style="color:#000;font-weight:bold">is&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_error(&lt;span style="color:#099">400&lt;/span>, &lt;span style="color:#d14">&amp;#39;Missing length&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;Content-Length must be set for POSTs&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> upload_size &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">int&lt;/span>(upload_size)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Input seems okay, so start sending an event stream.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_response(&lt;span style="color:#099">200&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_header(&lt;span style="color:#d14">&amp;#39;Content-Type&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;text/event-stream&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_header(&lt;span style="color:#d14">&amp;#39;Cache-Control&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;no-store&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>end_headers()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Save the uploaded data into a temporary file that can be&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># processed later, copying it in 1MB chunks. Send back&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># &amp;#39;uploadprogress&amp;#39; events as we go, to demonstrate sending&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># events.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">with&lt;/span> tempfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>NamedTemporaryFile(mode&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#39;wb&amp;#39;&lt;/span>, &lt;span style="color:#0086b3">dir&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#39;/var/tmp&amp;#39;&lt;/span>) &lt;span style="color:#000;font-weight:bold">as&lt;/span> infile:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> o &lt;span style="color:#000;font-weight:bold">in&lt;/span> &lt;span style="color:#0086b3">range&lt;/span>(&lt;span style="color:#099">0&lt;/span>, upload_size, BUF_COPY_SZ):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> buf &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>rfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>read(&lt;span style="color:#0086b3">min&lt;/span>(BUF_COPY_SZ, upload_size &lt;span style="color:#000;font-weight:bold">-&lt;/span> o))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> infile&lt;span style="color:#000;font-weight:bold">.&lt;/span>write(buf)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_event(&lt;span style="color:#d14">f&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>&lt;span style="color:#d14">{&lt;/span>o &lt;span style="color:#000;font-weight:bold">/&lt;/span> upload_size&lt;span style="color:#d14">:&lt;/span>&lt;span style="color:#d14">.02&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;uploadprogress&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_event(&lt;span style="color:#d14">&amp;#39;1&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;uploadprogress&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># When run as a script, serve HTTP on an arbitrary port.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">if&lt;/span> __name__ &lt;span style="color:#000;font-weight:bold">==&lt;/span> &lt;span style="color:#d14">&amp;#39;__main__&amp;#39;&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> PORT &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">9428&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> httpd &lt;span style="color:#000;font-weight:bold">=&lt;/span> http&lt;span style="color:#000;font-weight:bold">.&lt;/span>server&lt;span style="color:#000;font-weight:bold">.&lt;/span>HTTPServer((&lt;span style="color:#d14">&amp;#39;&amp;#39;&lt;/span>, PORT), Handler)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">print&lt;/span>(&lt;span style="color:#d14">&amp;#34;serving on port&amp;#34;&lt;/span>, PORT)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> httpd&lt;span style="color:#000;font-weight:bold">.&lt;/span>serve_forever()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>This server will respond to &lt;code>GET&lt;/code> requests by returning the contents of a file that exists (behavior provided by &lt;code>SimpleHTTPRequestHandler&lt;/code>), allowing it to serve my HTML and javascript files. Any &lt;code>POST&lt;/code> request expects to receive some uploaded file data and returns an event stream by calling &lt;code>send_event&lt;/code> from inside &lt;code>do_POST&lt;/code> for each request.&lt;/p>
&lt;p>Notably, no per-request information is saved anywhere: if the connection is interrupted there is no mechanism to resume, because managing the state of each active stream and reaping the ones that have completed would be more complex. Associating a stream directly with a connection by keeping only local state ensures resources will always be cleaned up when a connection closes.&lt;/p>
&lt;h3 id="testing-the-principle">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/video-squisher/#testing-the-principle">Testing the principle&lt;/a>
&lt;/h3>
&lt;p>Running the server (&lt;code>python server.py&lt;/code>) and loading it in a web browser, I was able to upload a file and see events come back as expected. However, when the server closed the connection after completing its work I found that the client would attempt to reconnect (as if the connection had been interrupted). This makes sense because in general it&amp;rsquo;s not possible to determine whether a given connection closure was intentional, but it is a little bit annoying.&lt;/p>
&lt;p>After spending some time studying the implementation of &lt;code>fetch-event-source&lt;/code> (not actually very complex!), I decided that the best way to cleanly close a stream in the browser was to throw an exception:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> Done &lt;span style="color:#000;font-weight:bold">extends&lt;/span> Exception {}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">try&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">await&lt;/span> fetchEventSource(&lt;span style="color:#d14">&amp;#39;foo&amp;#39;&lt;/span>, {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> onmessage(ev) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Decide if done..
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">throw&lt;/span> &lt;span style="color:#000;font-weight:bold">new&lt;/span> Done();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> onerror(err) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">throw&lt;/span> err;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>} &lt;span style="color:#000;font-weight:bold">catch&lt;/span> (e) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (e &lt;span style="color:#000;font-weight:bold">instanceof&lt;/span> Done) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Finished successfully
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> } &lt;span style="color:#000;font-weight:bold">else&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Unexpected error
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">throw&lt;/span> e;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>There might be a slightly better way to handle this, but I wasn&amp;rsquo;t able to quickly discern it. The &lt;code>Promise&lt;/code> returned by &lt;code>fetchEventSource()&lt;/code> resolves successfully in some cases, so there&amp;rsquo;s probably a detail that wasn&amp;rsquo;t obvious to me.&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup> In any case, depending on exceptions to close a connection works okay.&lt;/p>
&lt;p>It&amp;rsquo;s worth realizing here that my implementation of this application is completely unable to reconnect in case of connection loss because the server is entirely stateless: it is impossible to resume a stream because
any information about the transcode process will be lost when the connection to the server for a given request is closed. For personal use and limited applications I don&amp;rsquo;t mind, but a public tool might want to be more robust and pay the associated complexity costs.&lt;/p>
&lt;h2 id="actual-transcoding">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/video-squisher/#actual-transcoding">Actual transcoding&lt;/a>
&lt;/h2>
&lt;p>Having proven the concept of streaming events, the remaining piece of the server is to run something that transcodes the received video then returns the new file and streams progress output while it&amp;rsquo;s running. Given the input file is a &lt;code>NamedTemporaryFile&lt;/code> called &lt;code>infile&lt;/code>, running Handbrake&amp;rsquo;s CLI and getting access to the output isn&amp;rsquo;t hard:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">with&lt;/span> tempfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>NamedTemporaryFile(mode&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#39;rb&amp;#39;&lt;/span>, &lt;span style="color:#0086b3">dir&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#39;/var/tmp&amp;#39;&lt;/span>) &lt;span style="color:#000;font-weight:bold">as&lt;/span> outfile:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> args &lt;span style="color:#000;font-weight:bold">=&lt;/span> [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Output mp4 with leading MOOV&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;--format&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;av_mp4&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;--optimize&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># 5 megabits per second video, two-pass encoding&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;--multi-pass&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;--vb&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;5000&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;--crop-mode&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;none&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;--encoder&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;x264&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;--encoder-preset&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;slow&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;--turbo&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Don&amp;#39;t process audio, just copy&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;--aencoder&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;copy&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;--input&amp;#39;&lt;/span>, infile&lt;span style="color:#000;font-weight:bold">.&lt;/span>name,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;--output&amp;#39;&lt;/span>, outfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>name,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">with&lt;/span> subprocess&lt;span style="color:#000;font-weight:bold">.&lt;/span>Popen([&lt;span style="color:#d14">&amp;#39;HandBrakeCLI&amp;#39;&lt;/span>] &lt;span style="color:#000;font-weight:bold">+&lt;/span> args,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> stdin&lt;span style="color:#000;font-weight:bold">=&lt;/span>subprocess&lt;span style="color:#000;font-weight:bold">.&lt;/span>DEVNULL,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> stdout&lt;span style="color:#000;font-weight:bold">=&lt;/span>subprocess&lt;span style="color:#000;font-weight:bold">.&lt;/span>PIPE,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> stderr&lt;span style="color:#000;font-weight:bold">=&lt;/span>subprocess&lt;span style="color:#000;font-weight:bold">.&lt;/span>STDOUT,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> text&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>) &lt;span style="color:#000;font-weight:bold">as&lt;/span> proc:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>handle_subprocess(proc)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">assert&lt;/span> proc&lt;span style="color:#000;font-weight:bold">.&lt;/span>poll() &lt;span style="color:#000;font-weight:bold">is&lt;/span> &lt;span style="color:#000;font-weight:bold">not&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>, &lt;span style="color:#d14">&amp;#34;Subprocess should have exited&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_event(&lt;span style="color:#0086b3">str&lt;/span>(proc&lt;span style="color:#000;font-weight:bold">.&lt;/span>returncode), &lt;span style="color:#d14">&amp;#39;returncode&amp;#39;&lt;/span>)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Here I&amp;rsquo;ve simply created a second temporary file for Handbrake to write its output to, then run it in a subprocess. A &lt;code>handle_subprocess&lt;/code> method on the request handler (not yet implemented!) will be responsible for relaying output back to the client by calling &lt;code>send_event&lt;/code>, and once the subprocess exits it sends back a &lt;code>returncode&lt;/code> event to indicate whether the transcode process completed successfully.&lt;/p>
&lt;h3 id="real-time-output-streaming">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/video-squisher/#real-time-output-streaming">Real-time output streaming&lt;/a>
&lt;/h3>
&lt;p>Normally to capture the output from a subprocess using Python&amp;rsquo;s &lt;code>subprocess&lt;/code> module, you&amp;rsquo;d want to call &lt;code>communicate&lt;/code> on the process to wait for completion and return its output as a string. This application wants to return output immediately as it arrives rather than all at once after the subprocess exits, so that&amp;rsquo;s clearly not sufficient: we instead need to send a message whenever any new output appears, and ideally send empty messages rather than waiting for a long time without printing anything to ensure the stream&amp;rsquo;s connection doesn&amp;rsquo;t time out due to inactivity.&lt;/p>
&lt;p>I know that &lt;code>communicate&lt;/code> must do something similar to what I want to do by collecting output as it arrives, because that method ensures processes which take input on standard input won&amp;rsquo;t get stuck by doing the same thing: waiting for output while a subprocess is waiting for input would deadlock the entire thing, so it must be able to opportunistically grab output from a subprocess and pass input in. Looking at how the Python standard library implements &lt;code>communicate&lt;/code>, I found it worked similarly to how I expected it would (using something like the &lt;code>select()&lt;/code> system call) and implemented something similar myself in &lt;code>handle_subprocess&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">handle_subprocess&lt;/span>(&lt;span style="color:#999">self&lt;/span>, proc: subprocess&lt;span style="color:#000;font-weight:bold">.&lt;/span>Popen):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">with&lt;/span> selectors&lt;span style="color:#000;font-weight:bold">.&lt;/span>DefaultSelector() &lt;span style="color:#000;font-weight:bold">as&lt;/span> sel:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># If not set to nonblocking, the read() of stdout can block&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># when we try to read more data than is present. When nonblocking,&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># it only returns whatever data is available.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> os&lt;span style="color:#000;font-weight:bold">.&lt;/span>set_blocking(proc&lt;span style="color:#000;font-weight:bold">.&lt;/span>stdout&lt;span style="color:#000;font-weight:bold">.&lt;/span>fileno(), &lt;span style="color:#000;font-weight:bold">False&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sel&lt;span style="color:#000;font-weight:bold">.&lt;/span>register(proc&lt;span style="color:#000;font-weight:bold">.&lt;/span>stdout, selectors&lt;span style="color:#000;font-weight:bold">.&lt;/span>EVENT_READ)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">while&lt;/span> &lt;span style="color:#000;font-weight:bold">True&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Wait up to 1 second for some data&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ready &lt;span style="color:#000;font-weight:bold">=&lt;/span> sel&lt;span style="color:#000;font-weight:bold">.&lt;/span>select(timeout&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">1&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> ready:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> key, events &lt;span style="color:#000;font-weight:bold">in&lt;/span> ready:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># If there&amp;#39;s data available, read it.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> key&lt;span style="color:#000;font-weight:bold">.&lt;/span>fileobj &lt;span style="color:#000;font-weight:bold">==&lt;/span> proc&lt;span style="color:#000;font-weight:bold">.&lt;/span>stdout:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> data &lt;span style="color:#000;font-weight:bold">=&lt;/span> proc&lt;span style="color:#000;font-weight:bold">.&lt;/span>stdout&lt;span style="color:#000;font-weight:bold">.&lt;/span>read(&lt;span style="color:#099">16384&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># We were told there&amp;#39;s data ready, but if we read&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># nothing that means the output has been closed&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># (the subprocess exited) and we reached the end.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#000;font-weight:bold">not&lt;/span> data:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_event(data, ty&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#39;stdout&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">else&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># select() timed out; send an empty event&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_event()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>This assumes the subprocess&amp;rsquo; standard output is opened in text mode (we passed &lt;code>text=True&lt;/code> to &lt;code>subprocess.Popen()&lt;/code>), and thus sends chunks of text from its standard output out as messages by calling &lt;code>send_event&lt;/code>.&lt;/p>
&lt;p>The main tricks here are:&lt;/p>
&lt;ul>
&lt;li>Setting &lt;code>proc.stdout&lt;/code> to non-blocking mode so we won&amp;rsquo;t ever wait for new data to arrive.&lt;/li>
&lt;li>Using &lt;code>read(n)&lt;/code> to read only up to &lt;code>n&lt;/code> bytes of data, rather than reading to the end of the stream.&lt;/li>
&lt;li>Polling with &lt;code>selectors&lt;/code> to respond immediately when data becomes available to read, or give up after a timeout.&lt;/li>
&lt;/ul>
&lt;p>To test this, I used &lt;a href="https://curl.se/">curl&lt;/a> to manually send requests to the server because that was somewhat easier than clicking several things in a web browser for every test and it directly prints out the results:&lt;/p>
&lt;pre tabindex="0">&lt;code>$ curl -X POST \
--data-binary @myvideo.mp4 \
--header &amp;#34;Content-Type: application/octet-stream&amp;#34; \
http://localhost:9429/squish
event: uploadprogress
data: 0.0
event: uploadprogress
data: 0.99
event: uploadprogress
data: 1
event: stdout
data: [13:23:57] Compile-time hardening features are enabled
event: stdout
data: Cannot load libnvidia-encode.so.1
event: stdout
data: [13:23:57] hb_display_init: attempting VA driver &amp;#39;iHD&amp;#39;
...&lt;/code>&lt;/pre>
&lt;p>Somewhat interestingly, I had originally expected that calling &lt;code>read&lt;/code> with a parameter (to limit the number of bytes read) should prevent it from blocking, but I found that my server was emitting large chunks of data much less frequently than expected, and wasn&amp;rsquo;t generating empty (keepalive) messages at all. It turned out that the chunks were 16384 bytes (the length which was being passed to &lt;code>read&lt;/code>) and the &lt;code>read&lt;/code>s were actually blocking, which I fixed by calling &lt;code>os.set_blocking&lt;/code>. It might be possible to make this logic a little bit simpler after that discovery, but I&amp;rsquo;ve found it to work okay.&lt;/p>
&lt;p>This implementation ended up working nicely, so the remaining piece is to return the output file to the client and save it in the web browser.&lt;/p>
&lt;h3 id="sending-files-back">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/video-squisher/#sending-files-back">Sending files back&lt;/a>
&lt;/h3>
&lt;p>The output from Handbrake is created as a &lt;code>NamedTemporaryFile&lt;/code>, which can be read like a normal file once the Handbrake subprocess exits. In order to let the client do some progress reporting for the download, I first send a &lt;code>resultsize&lt;/code> message indicating how many bytes of output there are:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>outfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>seek(&lt;span style="color:#099">0&lt;/span>, os&lt;span style="color:#000;font-weight:bold">.&lt;/span>SEEK_END)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>output_len &lt;span style="color:#000;font-weight:bold">=&lt;/span> outfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>tell()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>outfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>seek(&lt;span style="color:#099">0&lt;/span>, os&lt;span style="color:#000;font-weight:bold">.&lt;/span>SEEK_SET)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_event(&lt;span style="color:#d14">f&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>&lt;span style="color:#d14">{&lt;/span>output_len&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;resultsize&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Read chunks of the output file and send back to the client&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">while&lt;/span> chunk &lt;span style="color:#000;font-weight:bold">:=&lt;/span> outfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>read(BUF_COPY_SZ):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_event(base64&lt;span style="color:#000;font-weight:bold">.&lt;/span>b64encode(chunk)&lt;span style="color:#000;font-weight:bold">.&lt;/span>decode(&lt;span style="color:#d14">&amp;#39;ascii&amp;#39;&lt;/span>), &lt;span style="color:#d14">&amp;#39;result&amp;#39;&lt;/span>)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The data in each &lt;code>result&lt;/code> message is encoded with base64 because event streams only accept text, not binary data. To ensure the video data being returned is valid in an event stream, I&amp;rsquo;ve chosen to base64-encode it because that&amp;rsquo;s easy to decode in the browser.&lt;/p>
&lt;hr>
&lt;p>With all the server parts implemented, I had to extend the javascript running on the client to handle all of the event types in the &lt;code>onmessage&lt;/code> function. I added an element to the HTML with ID &lt;code>outputBox&lt;/code> to contain the encoder&amp;rsquo;s output, which will be streamed, and added code to handle each event kind as they arrive on the stream (inside the event source&amp;rsquo;s &lt;code>onmessage&lt;/code> function):&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">47
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">48
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">49
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">50
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">51
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">52
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">53
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">54
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">55
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">56
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">57
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">58
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">59
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">60
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">61
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">62
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">63
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">64
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">65
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">66
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">67
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">68
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">69
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">70
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">71
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">72
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">73
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">74
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">75
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">76
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">77
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">const&lt;/span> outputBox &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">document&lt;/span>.getElementById(&lt;span style="color:#d14">&amp;#39;outputBox&amp;#39;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">let&lt;/span> resultData &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">null&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">let&lt;/span> resultDataOffset &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">null&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">switch&lt;/span> (ev.event) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">case&lt;/span> &lt;span style="color:#d14">&amp;#39;uploadprogress&amp;#39;&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Report progress of initial upload
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">break&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">case&lt;/span> &lt;span style="color:#d14">&amp;#39;stdout&amp;#39;&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Add the new line of data to the output box
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> outputBox.append(ev.data, &lt;span style="color:#d14">&amp;#39;\n&amp;#39;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Scroll it to the bottom so the new output is visible
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> outputBox.scrollTo(&lt;span style="color:#099">0&lt;/span>, outputBox.scrollHeight);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">break&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">case&lt;/span> &lt;span style="color:#d14">&amp;#39;returncode&amp;#39;&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Interpret the return code as a number
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">const&lt;/span> code &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">Number&lt;/span>(ev.data);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Abort if it wasn&amp;#39;t successful
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (code &lt;span style="color:#000;font-weight:bold">!==&lt;/span> &lt;span style="color:#099">0&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">throw&lt;/span> &lt;span style="color:#000;font-weight:bold">new&lt;/span> &lt;span style="color:#0086b3">Error&lt;/span>(&lt;span style="color:#d14">&amp;#39;Encode failed&amp;#39;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">break&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">case&lt;/span> &lt;span style="color:#d14">&amp;#39;resultsize&amp;#39;&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Interpret data as a number, allocating storage to contain
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// all the data that will be returned. These values will be
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// build up in following &amp;#39;result&amp;#39; messages.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> resultData &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">new&lt;/span> Uint8Array(&lt;span style="color:#0086b3">Number&lt;/span>(ev.data));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> resultDataOffset &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">break&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">case&lt;/span> &lt;span style="color:#d14">&amp;#39;result&amp;#39;&lt;/span>&lt;span style="color:#000;font-weight:bold">:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// base64-decode the data
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">const&lt;/span> chunk &lt;span style="color:#000;font-weight:bold">=&lt;/span> atob(ev.data);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Append bytes of the data chunk to resultData (using codePointAt()
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// because the &amp;#34;binary&amp;#34; string returned by atob() is a weird kind of
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// string).
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> (&lt;span style="color:#000;font-weight:bold">let&lt;/span> i &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>; i &lt;span style="color:#000;font-weight:bold">&amp;lt;&lt;/span> chunk.length; i&lt;span style="color:#000;font-weight:bold">++&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> resultData[resultDataOffset&lt;span style="color:#000;font-weight:bold">++&lt;/span>] &lt;span style="color:#000;font-weight:bold">=&lt;/span> chunk[i].codePointAt(&lt;span style="color:#099">0&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// If all the expected data arrived, save the data as if it
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// were a regular downloaded file.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (resultDataOffset &lt;span style="color:#000;font-weight:bold">===&lt;/span> resultData.length) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Put the bytes (resultData) into a File
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">const&lt;/span> f &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">new&lt;/span> File([resultData], file.name &lt;span style="color:#000;font-weight:bold">+&lt;/span> &lt;span style="color:#d14">&amp;#39;.mp4&amp;#39;&lt;/span>, {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> type&lt;span style="color:#000;font-weight:bold">:&lt;/span> &lt;span style="color:#d14">&amp;#39;video/mp4&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Create an HTML anchor which downloads the created file when
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// clicked.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">const&lt;/span> link &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">document&lt;/span>.createElement(&lt;span style="color:#d14">&amp;#39;a&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> link.download &lt;span style="color:#000;font-weight:bold">=&lt;/span> f.name;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> link.href &lt;span style="color:#000;font-weight:bold">=&lt;/span> URL.createObjectURL(f);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Free the resultData, since the created object URL
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// now contains all the data that matters.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> resultData &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">null&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> resultDataOffset &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">null&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// Click the generated link to trigger download, and free the
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// object URL of data after that. This needs to use setTimeout()
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// because the download won&amp;#39;t actually start until no scripts are
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// executing, so we delay calling revokeObjectURL() until after
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// the download has actually begun so the data is still present
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// to save.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">const&lt;/span> clickHandler &lt;span style="color:#000;font-weight:bold">=&lt;/span> () =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> setTimeout(() =&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> URL.revokeObjectURL(link.href);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> link.removeEventListener(&lt;span style="color:#d14">&amp;#39;click&amp;#39;&lt;/span>, clickHandler);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }, &lt;span style="color:#099">150&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> link.addEventListener(&lt;span style="color:#d14">&amp;#39;click&amp;#39;&lt;/span>, clickHandler);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> link.click();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic">// All done; close the event stream by throwing an exception
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// which we know means everything is done.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">throw&lt;/span> &lt;span style="color:#000;font-weight:bold">new&lt;/span> Done();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The handling of binary data and base64-decoding here is somewhat awkward, but works well enough. For very large videos it could be rather inefficient, but at least for videos with size around 100 megabytes I found the performance to be acceptable.&lt;/p>
&lt;p>In the actual code I also added a &lt;code>&amp;lt;progress&amp;gt;&lt;/code> element to the HTML, which gets updated for each &lt;code>uploadprogress&lt;/code> event (indicating how much data has been uploaded) and each &lt;code>result&lt;/code> event (indicating what fraction of the total result has been received). I haven&amp;rsquo;t included that code here, just because it&amp;rsquo;s not important to the more interesting concepts of how the system works.&lt;/p>
&lt;h2 id="results">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/video-squisher/#results">Results&lt;/a>
&lt;/h2>
&lt;p>The result in a browser looks like this, featuring the same file input field to choose a file, a progress bar that shows upload progress and later download progress, with all of the status output from the encoder appearing in real time beneath:&lt;/p>
&lt;p>&lt;video controls src="squish.webm" playsinline width="649" height="356" preload="metadata">&lt;/video>&lt;/p>
&lt;p>As already noted, I added a progress bar for both upload and download progress which wasn&amp;rsquo;t included in the code samples above. This could probably also be hooked up to the progress reporting that Handbrake does itself (especially in its JSON output mode), but that would require more than zero parsing of its output so I didn&amp;rsquo;t bother; it&amp;rsquo;s okay to &amp;ldquo;manually&amp;rdquo; read the status from text rather than only look at a progress bar.&lt;/p>
&lt;p>The other particularly useful-seeming improvement that I&amp;rsquo;ve considered but not been interested in implementing could be to load the input video in a &lt;code>&amp;lt;video&amp;gt;&lt;/code> element on the client before uploading it. The information from the browser could be used to determine its length, which could be used as an input to a simple algorithm that computes a required bitrate for the video output given a target file size. Being able to estimate the input bitrate in that way would solve the current problems where a low-bitrate input could be uselessly transcoded to a higher bitrate!&lt;/p>
&lt;hr>
&lt;p>I&amp;rsquo;ve published the complete source code to this tool at &lt;strong>&lt;a href="https://gitlab.com/taricorp/videosquisher">https://gitlab.com/taricorp/videosquisher&lt;/a>&lt;/strong>, which might be useful to others. Beyond that, I don&amp;rsquo;t anticipate doing any further work on this because it meets my needs. The options passed to the video encoder might be changed at some point in the future if I decide they&amp;rsquo;re worth changing, and there&amp;rsquo;s some possibility I might later add the features noted in the previous paragraph; however right now I have no plans to make further improvements.&lt;/p>
&lt;p>I chose to write about this tool simply because I thought its implementation used some interesting techniques that seem worth thinking about: certainly it could be adapted to other applications where a user might run a command-line tool with a fairly fixed set of options to consume one file and generate another, so this could be a convenient base on which to build similar tools!&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>Today it should be possible to implement SSE in terms of other APIs available in web browsers, particularly streaming responses with the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">Fetch API&lt;/a>: this blurs the lines between long-polling and SSE significantly, and makes clearer that SSE is essentially a form of long-polling using a particular data format. When server-sent events were introduced as an API around 2004 however, techniques for streaming responses in web browsers in the same way were still young and uncommon in practice.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>&lt;code>http.server&lt;/code> speaks HTTP 1.0 and closes the connection when it&amp;rsquo;s
done responding to a request by default, which might confuse event source clients and
cause this awkwardness. Using a chunked transfer encoding and speaking
HTTP 1.1 might prevent that (by making it clear when the stream ends),
but is somewhat more complex to handle on the server.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Doing what Nintendon't with the Hero's Path</title><link>https://www.taricorp.net/2023/totk-footprints/</link><pubDate>Fri, 03 Nov 2023 21:00:00 +1100</pubDate><guid>https://www.taricorp.net/2023/totk-footprints/</guid><description>&lt;style>
th.bl { border-left: 1px solid var(--border-color); }
th.br { border-right: 1px solid var(--border-color); }
&lt;/style>
&lt;p>&lt;em>The Legend of Zelda: Tears of the Kingdom&lt;/em> is an excellent video game, which I&amp;rsquo;ve only recently (about three weeks ago,
at the time of writing) played to a satisfactory conclusion; it took me around 90 hours of gameplay over nearly 150
real-time days to reach that point, representing a pretty significant amount of my free time. I
previously &lt;a href="https://www.taricorp.net/2017/breath-of-the-wild/">wrote about what I liked about Breath of the Wild, its
predecessor&lt;/a> and although I found a few aspects of Tears of
the Kingdom&amp;rsquo;s (henceforth &amp;ldquo;TOTK&amp;rdquo;)
plot less compelling than Breath of the Wild (&amp;ldquo;BOTW&amp;rdquo;), the scale of the newer game and its overall presentation left me
extremely satisfied on its completion. Between them, I&amp;rsquo;d say these most recent Zelda games are strong contenders for the
title of &amp;ldquo;best videogame&amp;rdquo; with no qualifiers.&lt;/p>
&lt;p>&lt;img src="overview.webp" alt="Link looking over a cloud-studded landscape in a screenshot captured in Tears of the Kingdom.">&lt;/p>
&lt;p>Other people have written much more eloquently about what makes TOTK such an exquisite game, so I won&amp;rsquo;t spend more words
on that. My reason for writing is a thought I had on completion of the game relating the Hero&amp;rsquo;s Path feature and what I
might be able to do with its underlying data beyond the scope of the game&amp;rsquo;s features.&lt;/p>
&lt;!-- more -->
&lt;aside class="information">If you only want the technical details of the footprint.sav data file
without any of the narrative, jump to the &lt;a href="#complete-data-format">complete data format description&lt;/a>.&lt;/aside>
&lt;aside class="attention">Although I do not discuss the plot of TOTK nor BOTW in this post, much of the discussion may
represent &lt;b>gameplay spoilers&lt;/b>. Be warned.&lt;/aside>
&lt;h2 id="heros-path">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#heros-path">Hero&amp;rsquo;s Path&lt;/a>
&lt;/h2>
&lt;p>The Hero&amp;rsquo;s Path is a feature of both BOTW and TOTK that records the player&amp;rsquo;s position in the game world at intervals and allows it to be viewed on the map, recording up to around 250 hours of gameplay. This is often a convenient feature during gameplay because it becomes easier to explore the world by being able to see areas that have or have not been visited, and especially in being able to play it back (animating the player position over the world) can be a neat tool for reminiscence.&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/herospath.webp"
alt="A screenshot of Tears of the Kingdom showing its map screen in Hero&amp;#39;s Path mode. A green line is superimposed on the map, moving across the entire thing in no particular pattern.">&lt;figcaption>
&lt;p>The Hero&amp;rsquo;s Path displayed on my TOTK save&amp;rsquo;s map at the time I completed the game.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Since the Hero&amp;rsquo;s Path captures such a large amount of gameplay, it seems like it may be interesting to mine for data or simply view in different ways. With those concepts in mind, I set out to learn how it&amp;rsquo;s stored in the game&amp;rsquo;s save data in order to extract the data and do new things with it.&lt;/p>
&lt;h2 id="file-format-investigation">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#file-format-investigation">File format investigation&lt;/a>
&lt;/h2>
&lt;!-- banner: looking over the world from the sky ("overview") -->
&lt;p>To even begin investigating the data format, I first needed to get some save data. Nintendo would seemingly rather you never have access to the actual data stored on a Switch and &lt;a href="https://www.tomsguide.com/us/back-up-nintendo-switch-saves,review-6483.html">only be able to make copies on their servers to back up your saves&lt;/a>. Fortunately for me, independent programmers building &lt;a href="https://switch.homebrew.guide/">homebrew software&lt;/a> have been at it for years now and I have a Switch that&amp;rsquo;s vulnerable to &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2018-6242">CVE-2018-6242&lt;/a> (&amp;quot;&lt;a href="https://github.com/Qyriad/fusee-launcher/blob/master/report/fusee_gelee.md">Fusée Gelée&lt;/a>&amp;quot;), so it is fairly straightforward to load up &lt;a href="https://github.com/J-D-K/JKSV">JKSV&lt;/a> on my game system and make a copy of my save game for TOTK.&lt;/p>
&lt;h3 id="first-look">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#first-look">First look&lt;/a>
&lt;/h3>
&lt;p>With data in hand, the first thing to do is look at the files that make up the save. It turns out to be structured with a few independent slots and some additional data that seems shared across every slot:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Directory&lt;/th>
&lt;th>File(s)&lt;/th>
&lt;th>Size&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td rowspan="7">album&lt;br>&lt;/td>
&lt;td>000_Photo.jpg&lt;/td>
&lt;td>63 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>000_Thumb.jpg&lt;/td>
&lt;td>8 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>001_Photo.jpg&lt;/td>
&lt;td>63 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td colspan="2">&lt;i>Pattern continues with increasing numbers.&lt;br>Some photos have accompanying .figi files (039_FigureInfo.figi).&lt;/i>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DeathMountainHatago.jpg&lt;/td>
&lt;td>61 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td colspan="2">&lt;i>Additional "Hatago" images follow, with names seemingly corresponding to locations in the game world.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LinkHousePicture_1.jpg&lt;/td>
&lt;td>64 KB&lt;/td>
&lt;tr>
&lt;td rowspan="3">picturebook&lt;/td>
&lt;td>Animal_Bear_A_Detail.jpg&lt;/td>
&lt;td>62 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Animal_Bear_A_Icon.jpg&lt;/td>
&lt;td>6.5 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td colspan="2">&lt;i>Many other detail and icon pairs follow, in categories like Animal, Enemy, Item and Weapon.&lt;/i>&lt;/td>
&lt;tr>
&lt;td rowspan="4">slot_00&lt;br>&lt;/td>
&lt;td>caption.sav&lt;/td>
&lt;td>11 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>direct_file_save_related.sav&lt;/td>
&lt;td>1 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>footprint.sav&lt;/td>
&lt;td>600 KB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>progress.sav&lt;/td>
&lt;td>2.2 MB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>slot_01&lt;/td>
&lt;td colspan="2">&lt;i>same files as slot_00&lt;/i>&lt;td>
&lt;/tr>
&lt;tr>
&lt;td>slot_0*&lt;/td>
&lt;td colspan="2">&lt;i>pattern continues up to slot_05&lt;/i>&lt;/td>
&lt;tr>
&lt;td>storage&lt;/td>
&lt;td>CacheStorageKey.dat&lt;/td>
&lt;td>9 bytes&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>At only 9 bytes, it doesn&amp;rsquo;t seem like the CacheStorageKey file contains anything interesting; I haven&amp;rsquo;t investigated it at all. The other directories look more interesting.&lt;/p>
&lt;h3 id="album">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#album">album&lt;/a>
&lt;/h3>
&lt;p>The album directory is pretty clearly the in-game album, which allows the player to take photos at almost any time during gameplay; this can be identified easily simply by looking at the JPEG files. A full-resolution image (1280x720) is stored alongside a thumbnail (256x144).&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/000_Photo.jpg"
alt="Two statues atop plinths bracketing either side of a doorway inside an aged-looking stone construction lit by torchlight from out of frame.">&lt;figcaption>
&lt;p>000_Photo.jpg in my save is recognizable as one of the default photos created when starting a new game, meant to be a photo taken by Princess Zelda during the prologue.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Not every photo has a corresponding FigureInfo file, but I infer that the .figi files contain information about the photo&amp;rsquo;s subject and its pose at the time the photo was taken. This information must be used for the ability to make monster sculptures by speaking with Kilton in Tarrey Town, since in the game context it converts a photo of a monster back into a 3D model in the same pose. Regenerating that information only from an image would be exceptionally difficult, so I expect the .figi files contain the relevant information captured at the same time as the photo.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/2023081421491300_s.jpg"
alt="Two people standing in front of a slightly raised diorama outdoors in daylight. There are six monsters in the diorama: a Bokoblin, Horriblin, Battle Talus, Frox, Thunder Gleeok and Colgera.">&lt;figcaption>
&lt;p>Monster sculptures arrayed in their designated space at Tarrey Town. From this angle it&amp;rsquo;s not obvious, but some of the poses are a bit awkward which demonstrates that each monster probably does not have curated poses and they instead take the same pose as they had at the time an image was captured.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>The &amp;ldquo;Hatago&amp;rdquo; and &amp;ldquo;LinkHouse&amp;rdquo; images are presumably the images which can be seen in each stable&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup> (filled by completing the various &amp;ldquo;A Picture for the &lt;em>name&lt;/em> Stable&amp;rdquo; side quests) and in certain rooms available for the player-built &amp;ldquo;dream home&amp;rdquo;, both of which accept pictures taken with the in-game camera.&lt;/p>
&lt;h3 id="picturebook">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#picturebook">picturebook&lt;/a>
&lt;/h3>
&lt;p>Similar to the &lt;code>album&lt;/code>, the picturebook directory appears to be the contents of the Hyrule Compendium, where after a
photo is taken of an object of a particular type a few sentences of additional information can be viewed at any time
alongside the original photo of that object. Cropped versions of the photos are visible when browsing the Compendium in
the game, so it&amp;rsquo;s logical that each object has Detail and Icon images.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/compendium.webp"
alt="A menu displaying &amp;#39;No. 187 Blue-White Frox&amp;#39; and featuring an image of the named monster alongside some flavor text, locations it can be found and crafting materials it drops when defeated.">&lt;figcaption>
&lt;p>A sample compendium entry for one of the several hundred items and objects that can be viewed therein. The image at the center of the screen is used with reduced resolution as the icon, and the detailed image fills the entire screen behind the rest of the UI.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>As with the album, the full image for each entry is 1280x720 pixels but the icons are slightly smaller; only 168x168 pixels.&lt;/p>
&lt;h3 id="slots">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#slots">slots&lt;/a>
&lt;/h3>
&lt;p>Having looked at everything else, the &lt;code>slot_&lt;/code> directories look like the real meat of a save. With up to six of them present, each corresponds to the slots available to a game from where one is always the last manual save (made by selecting the Save option in menus) whereas the other five are autosaves that the game makes periodically.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/2023101020292200_s.jpg"
alt="A menu saying &amp;#39;Select save data to load&amp;#39; and listing six saves in reverse chronological order with a small image depicting the game state of each. All but the second have &amp;#39;Autosave&amp;#39; markers on them.">&lt;figcaption>
&lt;p>A sample of six save slots in the menu to load a game. Note that all but one are marked as autosaves.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>The assignment of saves to slots doesn&amp;rsquo;t follow any obvious pattern, but it seems like the slots are probably allocated round-robin when autosaving (cycling from 0 to 5 and back to 0) while skipping the slot that has a manual save in it. Marc Robledo &lt;a href="https://github.com/marcrobledo/savegame-editors/wiki/TOTK-known-issues-&amp;amp;-FAQ#which-is-the-latest-progresssav-of-the-six-available-slot_00-slot_05">suggests examining caption.sav&lt;/a> to get the meta-information for each slot which should correspond to the information shown in the loading menu.&lt;/p>
&lt;p>Saving some work for me, Marc&amp;rsquo;s &lt;a href="https://www.marcrobledo.com/savegame-editors/zelda-totk/">savegame editor&lt;/a> understands a lot of structure of the game&amp;rsquo;s save data and its source code is available so it can act as a form of documentation. That tool only supports loading caption.sav (the aforementioned metadata) and progress.sav which seems like it contains all of the core gameplay state like the player&amp;rsquo;s location and owned items.&lt;/p>
&lt;p>There is no indication that either of these files contains the Hero&amp;rsquo;s Path data, so to continue the investigation that I wanted to do, &lt;code>footprint.sav&lt;/code> was the clear choice; certainly it&amp;rsquo;s reasonable to assume that the game developers would have referred to it as a collection of footprints indicating where the player has been. &lt;code>direct_file_save_related.sav&lt;/code> is left with an unknown purpose, but its small size probably indicates that its contents are uninteresting to me.&lt;/p>
&lt;h2 id="footprintsav">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#footprintsav">footprint.sav&lt;/a>
&lt;/h2>
&lt;p>The first thing I looked for regarding footprint.sav was whether anybody else
had already documented the format. As expected (because I had also looked to see
if a tool along the lines of what I wanted to create existed before I started),
I didn&amp;rsquo;t find any useful documentation on its format. I &lt;em>did&lt;/em> find a &lt;a href="https://github.com/marcrobledo/savegame-editors/issues/291">question
relating to the TOTK save editor&lt;/a> where the author claimed that
footprint.sav contained the Hero&amp;rsquo;s Path data but further stated that nobody had
documented its format.&lt;/p>
&lt;p>Recognizing that TOTK is built in very similar ways to BOTW, I also looked to
see if anybody had documented the data format used for the Hero&amp;rsquo;s Path in Breath
of the Wild- there&amp;rsquo;s a chance they would use exactly the same format if the
developers didn&amp;rsquo;t feel any need to change it between the two games. In this
respect I found that &lt;a href="https://gist.github.com/zephenryus/e46c797ccecf6134d4245bb9f2e5e2a5">Kevin Jensen had shared an informal
specification&lt;/a>, though it wasn&amp;rsquo;t immediately useful. It
turns out that BOTW creates multiple &lt;code>trackblock.sav&lt;/code> files each of which covers
around 8 hours of gameplay. Since TOTK clearly doesn&amp;rsquo;t split the footprint files
in this way, it&amp;rsquo;s unlikely to have the same overall structure.&lt;/p>
&lt;h3 id="diffing">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#diffing">Diffing&lt;/a>
&lt;/h3>
&lt;p>With nowhere else to start, I chose to begin by seeing how each of the save
slots differed in their contents. Starting with the idea that it would be
useful to explore how a tool like &lt;a href="https://github.com/jmacd/xdelta">xdelta3&lt;/a>
would express the differences between two of the slots which probably
represented a short time difference. Using an optimized tool turned out to
be difficult because I wanted something that I could interact with from Python
(which I was planning to do any required programming work in) and I failed
to get any of the libraries I found for binary diffing in Python to work.&lt;/p>
&lt;p>Fortunately, a &lt;a href="https://stackoverflow.com/a/1736358">Stack Overflow commentor&lt;/a>
noted that Python&amp;rsquo;s built-in &lt;code>difflib&lt;/code> can also be used for binary diffs.
Arbitrarily choosing to start with slots 0 and 1, I printed out how &lt;code>difflib&lt;/code>
thinks the footprint files differed:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">import&lt;/span> &lt;span style="color:#555">difflib&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>matcher &lt;span style="color:#000;font-weight:bold">=&lt;/span> difflib&lt;span style="color:#000;font-weight:bold">.&lt;/span>SequenceMatcher(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> a&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#0086b3">open&lt;/span>(&lt;span style="color:#d14">&amp;#39;slot_00/footprint.sav&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;rb&amp;#39;&lt;/span>)&lt;span style="color:#000;font-weight:bold">.&lt;/span>read(),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> b&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#0086b3">open&lt;/span>(&lt;span style="color:#d14">&amp;#39;slot_01/footprint.sav&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;rb&amp;#39;&lt;/span>)&lt;span style="color:#000;font-weight:bold">.&lt;/span>read(),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>matcher&lt;span style="color:#000;font-weight:bold">.&lt;/span>get_opcodes()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>&lt;code>get_opcodes&lt;/code> took a long time to run (several minutes or more), but emitted
only four opcodes:&lt;/p>
&lt;pre tabindex="0">&lt;code>[(&amp;#39;equal&amp;#39;, 0, 76, 0, 76),
(&amp;#39;replace&amp;#39;, 76, 77, 76, 77),
(&amp;#39;equal&amp;#39;, 77, 272796, 77, 272796),
(&amp;#39;replace&amp;#39;, 272796, 614760, 272796, 614760)]&lt;/code>&lt;/pre>
&lt;p>To make sense of this output, I the &lt;code>get_opcodes&lt;/code> documentation says that these
constitute a set of instructions to convert the &lt;code>a&lt;/code> input into &lt;code>b&lt;/code>. They&amp;rsquo;re the
same (&lt;code>equal&lt;/code>) for the first 76 bytes, and from bytes 77 through 272796, then
seemingly differ from there to the end of the file.&lt;/p>
&lt;p>Since I didn&amp;rsquo;t know exactly how these two slots correlated with each other
(which one is newer, in particular), I did the same thing to compare slots
1 and 2 rather than 0 and 1:&lt;/p>
&lt;pre tabindex="0">&lt;code>[(&amp;#39;equal&amp;#39;, 0, 76, 0, 76),
(&amp;#39;replace&amp;#39;, 76, 77, 76, 77),
(&amp;#39;equal&amp;#39;, 77, 272868, 77, 272868),
(&amp;#39;replace&amp;#39;, 272868, 614760, 272868, 614760)]&lt;/code>&lt;/pre>
&lt;p>Based on the lengths of the &lt;code>equal&lt;/code> segments, it looks like slot 1 has 272868 -
272796 = 72 bytes more in it than slot 0.&lt;/p>
&lt;hr>
&lt;p>With a little bit more of an idea of what happens to a save over time (it looks
like data simply gets appended), I then visually inspected the first 80 bytes
of each slot&amp;rsquo;s footprint file to see both what the overall structure looks like
and investigate what changes at offsets 76 and 77 which changed in each
pair of slots that I compared:&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;gt;&amp;gt;&amp;gt; for slot in range(0, 6):
... name = f&amp;#39;slot_{slot:02}&amp;#39;
... with open(f&amp;#39;{name}/footprint.sav&amp;#39;, &amp;#39;rb&amp;#39;) as f:
... print(name, f.read(80).hex(sep=&amp;#39; &amp;#39;))
slot_00 04 03 02 01 f4 e0 47 00 58 01 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 59 68 a0 c5 01 00 00 00
6b 6f dc 37 00 00 00 00 00 00 00 00 01 00 00 00
00 00 00 00 02 00 00 00 43 c2 e8 08 0c 0a 01 00
slot_01 ...
00 00 00 00 02 00 00 00 43 c2 e8 08 1e 0a 01 00
slot_02 ...
00 00 00 00 02 00 00 00 43 c2 e8 08 69 0a 01 00
slot_03 ...
00 00 00 00 02 00 00 00 43 c2 e8 08 c7 0a 01 00
slot_04 ...
00 00 00 00 02 00 00 00 43 c2 e8 08 e5 09 01 00
slot_05 ...
00 00 00 00 02 00 00 00 43 c2 e8 08 90 0a 01 00&lt;/code>&lt;/pre>
&lt;p>Not paying much attention to the other bytes, it looked like the bytes at offsets
76 and 77 were part of some kind of count that increases over time. I guessed
that it could be a multibyte integer and might be a number indicating how many
data points are stored, and found that these values look like an incrementing
counter if treated as little-endian:&lt;/p>
&lt;ol start="0">
&lt;li>0x09e5&lt;/li>
&lt;li>0x0a90&lt;/li>
&lt;li>0x0a0c&lt;/li>
&lt;li>0x0a1e&lt;/li>
&lt;li>0x0a69&lt;/li>
&lt;li>0x0ac7&lt;/li>
&lt;/ol>
&lt;p>Since I didn&amp;rsquo;t think this would be a 16-bit integer, I also guessed that it would
be 32 bits. That would include the following bytes with value 1 and 0, yielding
values like 0x000109e5.&lt;/p>
&lt;p>Further noting that it looked like slot 1 had 72 bytes more of data than slot 0,
the difference of the two slots&amp;rsquo; counter values was 0x010a1e - 0x010a0c = 18. 72 bytes
divided by 18 is 4, which could indicate that this counter measures a quantity
of 32-bit data points. Recalling the documentation for BOTW&amp;rsquo;s trackblock data,
it stored a single 32-bit datum for each player location so this seemed like
a solid guess.&lt;/p>
&lt;p>Trying to also understand what changes where data gets added to a slot, I printed
out the data from slots 0 and 1 beginning shortly before they differ and going to
where slot 1 appeared to end. This turned out to be unremarkable: the longer slot
had nonzero values past offset 272796 (where the shorter one ended) while the
shorter slot&amp;rsquo;s data was all zero. This supported the idea that new data simply
gets appended to the end of a file and a counter increments to track where the
data ends.&lt;/p>
&lt;pre tabindex="0">&lt;code>slot_00 b0 39 78 25 30 3d 98 25 30 3f 78 25 f0 3e 48 25
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
slot_01 b0 39 78 25 30 3d 98 25 30 3f 78 25 f0 3e 48 25
30 41 48 27 70 41 58 27 30 44 28 27 f0 44 48 27
70 44 08 27 f0 44 48 27 70 3f 88 26 30 38 d8 26
70 35 d8 26 b0 35 e8 26 f0 34 38 27 30 3c 28 27
b0 3e c8 25 30 45 58 27 70 47 b8 26 70 45 48 27
f0 43 f8 26 b0 3e 38 28&lt;/code>&lt;/pre>
&lt;p>With this initial look, I had enough to start trying to make sense of individual
points: the 32-bit little-endian value at file offset 76 indicates how many 32-bit
values are present, beginning at offset 364. Presumably the remaining 360 bytes
of header are useful in some way, but are uninteresting at this time.&lt;/p>
&lt;h3 id="point-data">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#point-data">Point data&lt;/a>
&lt;/h3>
&lt;p>With an idea that the points were simply a list of 32-bit values, I then needed to start looking for patterns in each value to understand their meaning. Glancing at the values from slot 1 I had printed out to compare with slot 0, I noticed a few values that looked like they differed only slightly and were in sequence. The bytes and a possible little-endian interpretation of each:&lt;/p>
&lt;table>
&lt;tr>
&lt;th>Bytes&lt;/th>
&lt;th>Little-endian value&lt;/th>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>f0 44 48 27&lt;/code>&lt;/td>
&lt;td>0x274844f0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>70 44 08 27&lt;/code>&lt;/td>
&lt;td>0x27084470&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>f0 44 48 27&lt;/code>&lt;/td>
&lt;td>0x274844f0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>70 3f 88 26&lt;/code>&lt;/td>
&lt;td>0x26883f70&lt;/td>
&lt;/tr>
&lt;/table>
&lt;p>The &lt;code>f0 44&lt;/code> value appears twice here, possibly indicating two points in the track at the same location or that each
value is actually 64 bits wide and the intervening values capture some difference. The second value (&lt;code>70 44 ..&lt;/code>) also
has a very short &lt;a href="https://en.wikipedia.org/wiki/Hamming_distance">Hamming distance&lt;/a> from the first and third, differing
in only two bits: &lt;code>f0&lt;/code> to &lt;code>70&lt;/code> clears bit 7 and &lt;code>48&lt;/code> to &lt;code>08&lt;/code> clears bit 6 of the corresponding byte. The small Hamming
distance suggests to me that each point probably is 32 bits of data, and any two points will tend to have small
differences because the player won&amp;rsquo;t move very far between each point.&lt;/p>
&lt;hr>
&lt;p>Being pretty confident that each point is stored in 4 bytes, I then needed to look for actual map coordinates in each point. Based on the data length for each slot and being able to tell that older saves will have shorter data length, I determined that in this instance my &lt;code>slot_02&lt;/code> save was the second-oldest entry in the &lt;a href="#slots">game loading menu&lt;/a>. I loaded that game and had a look at the map:&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/2023101020403000_s.jpg"
alt="An in-game map menu, with a reticule displayed at the center labelled &amp;#39;Current Location&amp;#39;. At the lower right of the map display there are three numbers: -0248, 0648, -1225.">&lt;figcaption>
&lt;p>The map state as seen when loading my save in slot 1. Note that the current X, Y and Z coordinates are displayed in the lower right.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Also looking at the Hero&amp;rsquo;s Path in that save slot, the most recent movements were all in a fairly small area near the current location (-248, 648, -1225).&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/2023101020412300_s.jpg"
alt="The same map screen in Hero&amp;#39;s Path mode, now displaying a bright green line snaking around. A dense series of loops are near to the player&amp;#39;s position, marked with a yellow arrow slightly offset from a green person-shaped icon.">&lt;figcaption>
&lt;p>I know that at the time of this save I had been moving downward in a small area, so the somewhat circular pattern immediately south of the current location here is the most recent movements. Note that the marker for the most recent location in the path (the green person icon) is not at exactly the same location as the yellow triangle marking the current player location, though they are very close together.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Knowing that the recent footprint points stored in this save slot should be near to the position (-248, 648, -1225), my next step was to search for bit patterns in each 32-bit value that are similar to each component of those coordinates. I simplified this search somewhat by assuming that the game would store only a layer indicator (Sky, Surface, or Depths) rather than a full Z-coordinate for each point for two reasons:&lt;/p>
&lt;ol>
&lt;li>Jensen&amp;rsquo;s documentation says BOTW used 13-bit sign-and-magnitude representation for X and Y coordinates, which wouldn&amp;rsquo;t fit in 32 bits if it were extended to store a similar Z coordinate.&lt;/li>
&lt;li>Only an indication of which layer is relevant would be needed to display the path in the game, since TOTK only needs to dim parts of the track which are on layers not currently being viewed. In the image above, the dimmer portions of the track are from player movement on the surface or in the sky because the map is currently showing the depths and there is no way to visualize historical elevation changes.&lt;/li>
&lt;/ol>
&lt;h3 id="pattern-matching">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#pattern-matching">Pattern-matching&lt;/a>
&lt;/h3>
&lt;p>Taking the last 32-bit value stored in this slot&amp;rsquo;s point data, the bytes are &lt;code>f0 3e 48 25&lt;/code>. Assuming TOTK still used a 12-bit sign-and-magnitude format for X and Y coordinates like BOTW did, I started looking for bit patterns similar to the X coordinate; 249 (&lt;code>000011111001&lt;/code>), and the Y coordinate; 601 (&lt;code>001001011001&lt;/code>).&lt;/p>
&lt;p>Not knowing whether each point should be interpreted as a little- or big-endian value, I started with big-endian and laid the bits out in a graphical way that should make it easier to see patterns and keeping in mind that the value in this point is probably not exactly the same as the coordinates that I know.&lt;/p>
&lt;table>
&lt;tr>
&lt;th>Big-endian&lt;/th>
&lt;td colspan="8">0xf0&lt;/td>
&lt;td colspan="8">0x3e&lt;/td>
&lt;td colspan="8">0x48&lt;/td>
&lt;td colspan="8">0x25&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>&lt;/th>
&lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>X (249)&lt;/th>
&lt;td colspan="6">&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th> Y (601)?&lt;/th>
&lt;/tr>
&lt;/table>
&lt;p>In the big-endian interpretation we see an exact match for the X coordinate, but none for the Y.&lt;/p>
&lt;table>
&lt;tr>
&lt;th>Little-endian&lt;/th>
&lt;td colspan="8">0x25&lt;/td>
&lt;td colspan="8">0x48&lt;/td>
&lt;td colspan="8">0x3e&lt;/td>
&lt;td colspan="8">0xf0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>&lt;/th>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>X (249)&lt;/th>
&lt;td colspan="14">&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th> Y (601)&lt;/th>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td>
&lt;/tr>
&lt;/table>
&lt;p>Little-endian seems better; these 12-bit fields from bits 20-32 and 6-18 have values 251 and 596 which are very close to the known X and Y coordinates (offset by 2 and -5 units, respectively).&lt;/p>
&lt;p>I next attempted the same interpretation an another slot that had player coordinates (-248, 648) and bytes
&lt;code>b0 3e 38 28&lt;/code> to verify that it looked reasonable:&lt;/p>
&lt;table>
&lt;tr>
&lt;th>Little-endian&lt;/th>
&lt;td colspan="8">0x28&lt;/td>
&lt;td colspan="8">0x38&lt;/td>
&lt;td colspan="8">0x3e&lt;/td>
&lt;td colspan="8">0xb0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>&lt;/th>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>X (248)&lt;/th>
&lt;td colspan="14">&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th> Y (648)&lt;/th>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;/table>
&lt;p>The Hamming distances in this instance are somewhat larger, but the differences in the supposed
numbers are similar; it represents a shift of only (2, 5) units from the known player position.&lt;/p>
&lt;hr>
&lt;p>With reasonable confidence in this basic layout of bits, I generated some more samples for myself
by loading up a game and taking known movements in order to try to identify the sign of each
coordinate and validate that they seem to be 12-bit values:&lt;/p>
&lt;ol>
&lt;li>Start at position (-155, 1155)&lt;/li>
&lt;li>Teleport to (222, 1085)&lt;/li>
&lt;li>Teleport to (4632, -3712) and stay there for a while&lt;/li>
&lt;/ol>
&lt;p>By inspecting the values written into the Hero&amp;rsquo;s Path after doing this, I found the following
4-byte values:&lt;/p>
&lt;ol>
&lt;li>0x483826ec&lt;/li>
&lt;li>0x43d83788&lt;/li>
&lt;li>0x43d8378c&lt;/li>
&lt;li>0xe8048608, repeated multiple times&lt;/li>
&lt;/ol>
&lt;p>I put these four values into the same visual form to inspect them:&lt;/p>
&lt;table>
&lt;tr>
&lt;th>Bit number&lt;/th>
&lt;th>31&lt;/th>
&lt;th colspan="10">&lt;/th>
&lt;th>20&lt;/th>
&lt;th>19&lt;/th>
&lt;th>18&lt;/th>
&lt;th>17&lt;/th>
&lt;th colspan="10">&lt;/th>
&lt;th>6&lt;/th>
&lt;th>5&lt;/th>
&lt;th>4&lt;/th>
&lt;th>3&lt;/th>
&lt;th>2&lt;/th>
&lt;th>1&lt;/th>
&lt;th>0&lt;/th>
&lt;/tr>
&lt;tr>
&lt;th rowspan="2">0x483826ec&lt;/th>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td colspan="12">Y = 1155&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td colspan="12">X = -155&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th rowspan="2">0x43d83788&lt;/th>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td>
&lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td colspan="12">Y = 1085&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td colspan="12">X = 222&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th rowspan="2">0x43d8378c&lt;/th>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td>
&lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td colspan="12">Y = 1085&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td colspan="12">X = 222&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th rowspan="2">0xe8048608&lt;/th>
&lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td colspan="12">Y = -3712&lt;/td>
&lt;td>0&lt;/td> &lt;td>1&lt;/td>
&lt;td colspan="12">X = 4632&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;/table>
&lt;p>For the second and all subsequent values, I notice that bit 5 is cleared. Since only the first point has a negative X coordinate, I assume that &lt;strong>bit 5 is set to negate the X coordinate&lt;/strong>; it is the sign bit for that value. Similarly, &lt;strong>bit 19&lt;/strong> changes from 1 to 0 when the Y coordinate becomes negative so that&amp;rsquo;s
probably the &lt;strong>sign of the Y coordinate&lt;/strong> although it&amp;rsquo;s odd that its meaning seems reversed from the X coordinate sign
(being set to indicate a positive coordinate).&lt;/p>
&lt;p>Only the first and third values have bit 2 set, but it&amp;rsquo;s also interesting that the point (222, 1085)
appears twice (the second and third values) where the second appearance differs only in setting bit 2.
The version of this data from BOTW seems to use one bit to indicate when the player teleports away
from a location, which is probably what &lt;strong>bit 2&lt;/strong> is indicating; it&amp;rsquo;s a &lt;strong>discontinuity in the track&lt;/strong>,
where the next point shouldn&amp;rsquo;t be connected to this one with a line.&lt;/p>
&lt;p>In the last point, the value of the 12-bit field for X coordinate is wrong, but making it 13 bits
by including bit 18 as its most-significant bit yields the correct value: &lt;strong>the X coordinate is actually
13 bits (plus sign), not 12&lt;/strong>!&lt;/p>
&lt;p>In summary, the current theorized structure is:&lt;/p>
&lt;ul>
&lt;li>Bits 20 through 31 are the Y coordinate.&lt;/li>
&lt;li>Bit 19 is cleared if the Y coordinate is negative, or set if positive.&lt;/li>
&lt;li>Bits 6 though 18 are the X coordinate.&lt;/li>
&lt;li>Bit 5 is set if the X coordinate is negative, or clear if positive.&lt;/li>
&lt;li>Bit s it set at the beginning of a path discontinuity such as when the player teleports.&lt;/li>
&lt;/ul>
&lt;p>There are only 4 bits left with unknown meaning, assuming this is all correct.&lt;/p>
&lt;h3 id="layer-indication">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#layer-indication">Layer indication&lt;/a>
&lt;/h3>
&lt;p>As noted earlier, I believed these values would store only an indication of which layer of the
world the player was on rather than a whole Z coordinate. Since after looking at the X and Y coordinates
in more detail there are only four unknown bits which aren&amp;rsquo;t even all contiguous, this seemed like
a correct assumption.&lt;/p>
&lt;p>Given there are three layers, I guessed a two-bit field would be used to store the layer and the
fourth value for that field would either be unused or have special meaning. To investigate where
that might be and whether such an assumption was correct, I looked at the same six points again (the
two depths points from &lt;a href="#pattern-matching">initial pattern matching&lt;/a> and four from extended experiments)
while noting what layer they corresponded to:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Value&lt;/th>
&lt;th colspan="13">Y coordinate&lt;/th>
&lt;th colspan="14">X coordinate&lt;/th>
&lt;th colspan="2">Unknown bits&lt;/th>
&lt;th>Discontinuity&lt;/th>
&lt;th colspan="2">Unknown bits&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tr>
&lt;td>Depths&lt;/td>
&lt;td>0x25483ef0&lt;/td>
&lt;td colspan="13">601&lt;/td>
&lt;td colspan="14">-249&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>No&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Depths&lt;/td>
&lt;td>0x28383eb0&lt;/td>
&lt;td colspan="13">633&lt;/td>
&lt;td colspan="14">-250&lt;/td>
&lt;td>1&lt;/td> &lt;td>0&lt;/td> &lt;td>No&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Surface&lt;/td>
&lt;td>0x483826ec&lt;/td>
&lt;td colspan="13">1155&lt;/td>
&lt;td colspan="14">-155&lt;/td>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>Yes&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Surface&lt;/td>
&lt;td>0x43d83788&lt;/td>
&lt;td colspan="13">1085&lt;/td>
&lt;td colspan="14">222&lt;/td>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>No&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Surface&lt;/td>
&lt;td>0x43d8378c&lt;/td>
&lt;td colspan="13">1085&lt;/td>
&lt;td colspan="14">222&lt;/td>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>Yes&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Surface&lt;/td>
&lt;td>0xe8048608&lt;/td>
&lt;td colspan="13">-3712&lt;/td>
&lt;td colspan="14">4632&lt;/td>
&lt;td>0&lt;/td> &lt;td>1&lt;/td> &lt;td>No&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;/table>
&lt;p>Since the value of bits 3 and 4 changes from 2 to 1 when moving from the Depths to Surface,
it&amp;rsquo;s a safe guess that those two bits indicate which layer a given point is on.&lt;/p>
&lt;p>To determine
what value corresponds to the Sky, I then loaded a save and travelled into the sky, walked around
some and saved the game. Inspecting the resulting data however, I didn&amp;rsquo;t see any of the expected
points! There were only two more recorded locations, and both were near the last position before
I went into the sky. By walking around for a bit longer and saving again, I was able to get the
expected points to appear in the footprints file: it seems &lt;strong>the game buffers footprints for
a time before writing them to &lt;code>footprint.sav&lt;/code>&lt;/strong>, so by walking around for a bit more time
I caused it to buffer enough that the points I expected to see eventually got saved.&lt;/p>
&lt;p>Looking at the points once I eventually got them, it seems the value 0 for bits 3-4 indicates the
player is in the sky:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Value&lt;/th>
&lt;th colspan="13">Y coordinate&lt;/th>
&lt;th colspan="14">X coordinate&lt;/th>
&lt;th colspan="2">Layer value&lt;/th>
&lt;th>Discontinuity&lt;/th>
&lt;th colspan="2">Unknown bits&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tr>
&lt;td>Sky&lt;/td>
&lt;td>0x67405dc0&lt;/td>
&lt;td colspan="13">-1652&lt;/td>
&lt;td colspan="14">375&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>No&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sky&lt;/td>
&lt;td>0x67105ac0&lt;/td>
&lt;td colspan="13">-1649&lt;/td>
&lt;td colspan="14">363&lt;/td>
&lt;td>0&lt;/td> &lt;td>0&lt;/td> &lt;td>No&lt;/td> &lt;td>0&lt;/td> &lt;td>0&lt;/td>
&lt;/tr>
&lt;/table>
&lt;p>This leaves it unknown what layer 3 might refer to. In BOTW one of the bits is
described as indicating whether a point is &amp;ldquo;MainField or Dungeon / AocField&amp;rdquo;, but
it doesn&amp;rsquo;t seem like that&amp;rsquo;s a meaningful distinction in TOTK. For one, the dungeons
in the newer game are properly within the game world rather than behaving more like
pocket universes as they do in Breath of the Wild, where each of the dungeons fills
a space in the world but are larger inside than the space they fill and the
player is prevented from getting too near to them on the overworld. Second,
&lt;code>AocField&lt;/code> appears to be an internal term &lt;a href="https://zeldamods.org/wiki/Content/Map/AocField">referring to the completely separate
world containing the Trial of the Sword&lt;/a>
in BOTW- TOTK has no equivalent; again, everything occurs in real space on the
overworld in the newer game. For lack of any sensible options, I&amp;rsquo;ve assumed that the
value 3 is unused for this field.&lt;/p>
&lt;p>Before going on, I&amp;rsquo;ll summarize my understanding of the fields again:&lt;/p>
&lt;table>
&lt;thead>&lt;tr>
&lt;th>Bit number&lt;/th>
&lt;th class="bl">31&lt;/th>
&lt;th colspan="10">…&lt;/th>
&lt;th class="br">20&lt;/th>
&lt;th class="bl br">19&lt;/th>
&lt;th class="bl">18&lt;/th>
&lt;th colspan="11">…&lt;/th>
&lt;th class="br">6&lt;/th>
&lt;th class="bl br">5&lt;/th>
&lt;th class="bl">4&lt;/th>
&lt;th class="br">3&lt;/th>
&lt;th class="bl br">2&lt;/th>
&lt;th class="bl">1&lt;/th>
&lt;th class="br">0&lt;/th>
&lt;/tr>&lt;/thead>
&lt;tr>
&lt;th>Meaning&lt;/th>
&lt;td colspan="12">Y-coordinate magnitude&lt;/td>
&lt;td>Y-coordinate sign (negative when clear)&lt;/td>
&lt;td colspan="13">X-coordinate magnitude&lt;/td>
&lt;td>X coordinate sign (negative when set)&lt;/td>
&lt;td colspan="2">
&lt;table>
&lt;tr>&lt;th>0&lt;/th>&lt;td>Sky&lt;/td>&lt;/tr>
&lt;tr>&lt;th>1&lt;/th>&lt;td>Surface&lt;/td>&lt;/tr>
&lt;tr>&lt;th>2&lt;/th>&lt;td>Depths&lt;/td>&lt;/tr>
&lt;tr>&lt;th>3&lt;/th>&lt;td>Unused?&lt;/td>&lt;/tr>
&lt;/table>
&lt;/td>
&lt;td>Set for discontinuity (warping away from this location)&lt;/td>
&lt;td colspan="2">Unknown&lt;/td>
&lt;/tr>
&lt;/table>
&lt;h3 id="the-final-mystery-bits">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#the-final-mystery-bits">The final mystery bits&lt;/a>
&lt;/h3>
&lt;p>With a good understanding of nearly every bit of data, to decode the remaining
two unknown data bits I chose to mine a save for any points that set either of them
(because every sample I&amp;rsquo;ve looked at so far has both bits clear).&lt;/p>
&lt;p>I wrote a little bit of code to iterate through a &lt;code>footprint.sav&lt;/code> file and print out
every point (binary and hex values) that set bit 0 or 1, alongside its location in the
file and the coordinates represented, yielding the following list:&lt;/p>
&lt;pre tabindex="0">&lt;code>01001100010000000111010111000010 0x4c4075c2 47 ( 471,-1220)
00110111111100000110011011000010 0x37f066c2 86 ( 411, -895)
01011101111100000000011011000010 0x5df006c2 299 ( 27,-1503)
01101011000000001000101100000010 0x6b008b02 661 ( 556,-1712)
01010110101100001100000111000010 0x56b0c1c2 737 ( 775,-1387)
01001010011000001011100000000010 0x4a60b802 918 ( 736,-1190)
01001101111000001011101111000010 0x4de0bbc2 934 ( 751,-1246)
01001110000000001011001100000010 0x4e00b302 956 ( 716,-1248)
01001101110000001011110111000010 0x4dc0bdc2 982 ( 759,-1244)
01101001100100000110101110000010 0x69906b82 1040 ( 430,-1689)
01100011110000000110001001000010 0x63c06242 1069 ( 393,-1596)
01100100111100000100100010000010 0x64f04882 1178 ( 290,-1615)
01100001001000000111001000000010 0x61207202 1188 ( 456,-1554)
00110000111110000101010110101010 0x30f855aa 1575 ( -342, 783)
00110001001110000101010011101010 0x313854ea 1579 ( -339, 787)
01000001011000001011001001101010 0x4160b26a 2598 ( -713,-1046)
01100000000100100110000011101010 0x601260ea 2723 (-2435,-1537)
01001010011100011111011001101010 0x4a71f66a 2729 (-2009,-1191)
01001010101000011111010010101010 0x4aa1f4aa 2734 (-2002,-1194)
01000101101000011111100001101010 0x45a1f86a 2776 (-2017,-1114)
10011010100000110000100001101010 0x9a83086a 2964 (-3105,-2472)
10100101111100110101111111101010 0xa5f35fea 3071 (-3455,-2655)
11000100100000111111001111101010 0xc483f3ea 3163 (-4047,-3144)
11001111001101000101100001101010 0xcf34586a 3224 (-4449,-3315)
11000001011000111110111100101010 0xc163ef2a 3566 (-4028,-3094)
11010010010101000011101011101010 0xd2543aea 3692 (-4331,-3365)
11011111101001000111011000101010 0xdfa4762a 3741 (-4568,-3578)
11100110000001000111101101101010 0xe6047b6a 3764 (-4589,-3680)
11100110001001000111110111101010 0xe6247dea 3771 (-4599,-3682)
11011111110001000110011010101010 0xdfc466aa 4240 (-4506,-3580)
11100001101101000110100101101010 0xe1b4696a 4367 (-4517,-3611)
11100001101101000110011000101010 0xe1b4662a 4413 (-4504,-3611)
11011111001001000110101110101010 0xdf246baa 4442 (-4526,-3570)
11100010111001000110111001101010 0xe2e46e6a 4457 (-4537,-3630)
01100010010110101101110010101010 0x625adcaa 4850 (-2930, 1573)
00111000101110100011111101100010 0x38ba3f62 5987 (-2301, 907)
01000100101010101001010001110010 0x44aa9472 6084 (-2641, 1098)
00000100110100101010100011110010 0x04d2a8f2 6279 (-2723, -77)
00000001100100101010100010110010 0x0192a8b2 6283 (-2722, -25)
00000110011100101010100011110010 0x0672a8f2 6297 (-2723, -103)
00000011010100101010000110110010 0x0352a1b2 6316 (-2694, -53)
01110000100100110000011111101010 0x709307ea 6632 (-3103,-1801)
01111011010010000010100011000010 0x7b4828c2 11255 ( 163, 1972)
11001101001000010101010101000010 0xcd215542 15872 ( 1365,-3282)
01110001111110101111110101001010 0x71fafd4a 18074 ( 3061, 1823)
01101111011000100100101110000010 0x6f624b82 27029 ( 2350,-1782)&lt;/code>&lt;/pre>
&lt;p>At a glance it&amp;rsquo;s easy to see that in this save bit 0 is never set so it might be
unused. Bit 1 is set somewhat unpredictably, and not very often. I found the values
between offset 3741 and 4457 interesting because they had fairly high density (with
bit 1 being set with fairly high frequency given the number of points) and are all
fairly close together, so I pulled up
the map and went looking for what was in that area of the map.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/lightningtemple.webp"
alt="The game map screen showing a player location at coordinates (-4532, -3645, 27), with the triangle representing player location placed on top of a large blue icon.">&lt;figcaption>
&lt;p>The area around (-4500, -3500) on the Surface is in and around the Lightning Temple (indicated by the larger blue icon) at the far Southwest corner of the world inside the Gerudo Desert.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>I thought this bit could indicate when the player is inside a dungeon, but that seemed
unlikely because the Hero&amp;rsquo;s Path is recorded normally when inside dungeons and there are
still gaps between the points with it set. Perhaps it instead indicates where the player
died, as one of the flags in BOTW did?&lt;/p>
&lt;p>The game conveniently displays an icon on the map to mark the last place the player died,
and in this case I found it was near (2350, -1782) in the Sky: exactly the coordinates
of the last point I found that has bit 1 set! These coordinates are
the entrance to a shrine, and I found that a number of the points surrounding this
one in the file (around point 27029) are exactly the same except for bit 1. I suppose
that this period of the track represents time spent inside this shrine (since shrines
do behave like pocket universes by being much larger within than without), so the point
at index 27029 is a time when the player died inside this shrine.&lt;/p>
&lt;h4 id="complete-data-format">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#complete-data-format">Complete data format&lt;/a>
&lt;/h4>
&lt;p>Having located no points that set bit 0, I was confident that it was either unused or
mostly unimportant so the following table summarizes the meanings of each field.
The values are 32-bit little-endian integers beginning at byte offset 364 in the
&lt;code>footprint.sav&lt;/code> file, with the 32-bit little-endian integer at byte offset 76
indicating how many points are present.&lt;/p>
&lt;table>
&lt;thead>&lt;tr>
&lt;th>Bit number&lt;/th>
&lt;th class="bl">31&lt;/th>
&lt;th colspan="10">…&lt;/th>
&lt;th class="br">20&lt;/th>
&lt;th class="bl br">19&lt;/th>
&lt;th class="bl">18&lt;/th>
&lt;th colspan="11">…&lt;/th>
&lt;th class="br">6&lt;/th>
&lt;th class="bl br">5&lt;/th>
&lt;th class="bl">4&lt;/th>
&lt;th class="br">3&lt;/th>
&lt;th class="bl br">2&lt;/th>
&lt;th class="bl br">1&lt;/th>
&lt;th class="bl br">0&lt;/th>
&lt;/tr>&lt;/thead>
&lt;tr>
&lt;th>Meaning&lt;/th>
&lt;td colspan="12">Y-coordinate magnitude&lt;/td>
&lt;td>Y-coordinate sign (negative when clear)&lt;/td>
&lt;td colspan="13">X-coordinate magnitude&lt;/td>
&lt;td>X coordinate sign (negative when set)&lt;/td>
&lt;td colspan="2">
&lt;table>
&lt;tr>&lt;th>0&lt;/th>&lt;td>Sky&lt;/td>&lt;/tr>
&lt;tr>&lt;th>1&lt;/th>&lt;td>Surface&lt;/td>&lt;/tr>
&lt;tr>&lt;th>2&lt;/th>&lt;td>Depths&lt;/td>&lt;/tr>
&lt;tr>&lt;th>3&lt;/th>&lt;td>Unused?&lt;/td>&lt;/tr>
&lt;/table>
&lt;/td>
&lt;td>Set for discontinuity (warping away from this location)&lt;/td>
&lt;td>Set for player death at this location (a different kind of discontinuity)&lt;/td>
&lt;td>Unused?&lt;/td>
&lt;/tr>
&lt;/table>
&lt;p>To experiment with the tracks in my save game, I also developed some Python code that
can load &lt;code>footprint.sav&lt;/code> and collect the points, leaving them in a convenient format
to inspect.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 28
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 29
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 30
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 31
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 32
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 33
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 34
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 35
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 36
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 37
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 38
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 39
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 40
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 41
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 42
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 43
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 44
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 45
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 46
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 47
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 48
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 49
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 50
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 51
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 52
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 53
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 54
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 55
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 56
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 57
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 58
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 59
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 60
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 61
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 62
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 63
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 64
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 65
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 66
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 67
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 68
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 69
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 70
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 71
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 72
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 73
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 74
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 75
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 76
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 77
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 78
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 79
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 80
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 81
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 82
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 83
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 84
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 85
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 86
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 87
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 88
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 89
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 90
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 91
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 92
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 93
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 94
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 95
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 96
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 97
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 98
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 99
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">100
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">101
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">102
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">103
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">dataclasses&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> dataclass
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">enum&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> Enum
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> &lt;span style="color:#458;font-weight:bold">Layer&lt;/span>(Enum):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> SKY &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> SURFACE &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DEPTHS &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> UNKNOWN &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">3&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3c5d5d;font-weight:bold">@dataclass&lt;/span>(frozen&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> &lt;span style="color:#458;font-weight:bold">Footprint&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#34;&amp;#34;&amp;#34;A single point of the Hero&amp;#39;s Path.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> layer: Layer
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> x: &lt;span style="color:#0086b3">int&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> y: &lt;span style="color:#0086b3">int&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> warp: &lt;span style="color:#0086b3">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> death: &lt;span style="color:#0086b3">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> unknown_flag: &lt;span style="color:#0086b3">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _Y_COORD_SHIFT &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">20&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _Y_COORD_MASK &lt;span style="color:#000;font-weight:bold">=&lt;/span> ((&lt;span style="color:#099">1&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> &lt;span style="color:#099">12&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&lt;/span> &lt;span style="color:#099">1&lt;/span>) &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> _Y_COORD_SHIFT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _Y_COORD_POSITIVE_MASK &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">1&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> &lt;span style="color:#099">19&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _X_COORD_SHIFT &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">6&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _X_COORD_MASK &lt;span style="color:#000;font-weight:bold">=&lt;/span> ((&lt;span style="color:#099">1&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> &lt;span style="color:#099">13&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&lt;/span> &lt;span style="color:#099">1&lt;/span>) &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> _X_COORD_SHIFT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _X_COORD_NEGATIVE_MASK &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">1&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> &lt;span style="color:#099">5&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _LAYER_SHIFT &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">3&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _LAYER_MASK &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0b11&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> _LAYER_SHIFT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _WARP_FLAG &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">1&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> &lt;span style="color:#099">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _DEATH_FLAG &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">1&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> &lt;span style="color:#099">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _UNKNOWN_FLAG &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">1&lt;/span> &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> &lt;span style="color:#099">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3c5d5d;font-weight:bold">@classmethod&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">from_word&lt;/span>(&lt;span style="color:#999">cls&lt;/span>, value: &lt;span style="color:#0086b3">int&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&amp;gt;&lt;/span> &lt;span style="color:#d14">&amp;#39;Footprint&amp;#39;&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> y &lt;span style="color:#000;font-weight:bold">=&lt;/span> (value &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_Y_COORD_MASK) &lt;span style="color:#000;font-weight:bold">&amp;gt;&amp;gt;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_Y_COORD_SHIFT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (value &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_Y_COORD_POSITIVE_MASK) &lt;span style="color:#000;font-weight:bold">==&lt;/span> &lt;span style="color:#099">0&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> y &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">-&lt;/span>y
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> x &lt;span style="color:#000;font-weight:bold">=&lt;/span> (value &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_X_COORD_MASK) &lt;span style="color:#000;font-weight:bold">&amp;gt;&amp;gt;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_X_COORD_SHIFT
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> (value &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_X_COORD_NEGATIVE_MASK) &lt;span style="color:#000;font-weight:bold">!=&lt;/span> &lt;span style="color:#099">0&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> x &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">-&lt;/span>x
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> layer &lt;span style="color:#000;font-weight:bold">=&lt;/span> Layer((value &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_LAYER_MASK) &lt;span style="color:#000;font-weight:bold">&amp;gt;&amp;gt;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_LAYER_SHIFT)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> warp &lt;span style="color:#000;font-weight:bold">=&lt;/span> (value &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_WARP_FLAG) &lt;span style="color:#000;font-weight:bold">!=&lt;/span> &lt;span style="color:#099">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> death &lt;span style="color:#000;font-weight:bold">=&lt;/span> (value &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_DEATH_FLAG) &lt;span style="color:#000;font-weight:bold">!=&lt;/span> &lt;span style="color:#099">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> unknown_flag &lt;span style="color:#000;font-weight:bold">=&lt;/span> (value &lt;span style="color:#000;font-weight:bold">&amp;amp;&lt;/span> &lt;span style="color:#999">cls&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_UNKNOWN_FLAG) &lt;span style="color:#000;font-weight:bold">!=&lt;/span> &lt;span style="color:#099">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#999">cls&lt;/span>(layer, x, y, warp, death, unknown_flag)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3c5d5d;font-weight:bold">@staticmethod&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">_format_flag&lt;/span>(value: &lt;span style="color:#0086b3">bool&lt;/span>, name: &lt;span style="color:#0086b3">str&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&amp;gt;&lt;/span> &lt;span style="color:#0086b3">str&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> value:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> name
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">else&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#d14">&amp;#39; &amp;#39;&lt;/span> &lt;span style="color:#000;font-weight:bold">*&lt;/span> &lt;span style="color:#0086b3">len&lt;/span>(name)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> __str__(&lt;span style="color:#999">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flags &lt;span style="color:#000;font-weight:bold">=&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_format_flag(&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>warp, &lt;span style="color:#d14">&amp;#39;W&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">+&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_format_flag(&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>death, &lt;span style="color:#d14">&amp;#39;D&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">+&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_format_flag(&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>unknown_flag, &lt;span style="color:#d14">&amp;#39;U&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> layer_tag &lt;span style="color:#000;font-weight:bold">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Layer&lt;span style="color:#000;font-weight:bold">.&lt;/span>SKY: &lt;span style="color:#d14">&amp;#39;Sky&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Layer&lt;span style="color:#000;font-weight:bold">.&lt;/span>SURFACE: &lt;span style="color:#d14">&amp;#39;Sur&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Layer&lt;span style="color:#000;font-weight:bold">.&lt;/span>DEPTHS: &lt;span style="color:#d14">&amp;#39;Dep&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>layer, &lt;span style="color:#d14">&amp;#39;Unk&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#d14">f&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>&lt;span style="color:#d14">{&lt;/span>layer_tag&lt;span style="color:#d14">:&lt;/span>&lt;span style="color:#d14">3&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">(&lt;/span>&lt;span style="color:#d14">{&lt;/span>&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>x&lt;span style="color:#d14">:&lt;/span>&lt;span style="color:#d14">5&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">,&lt;/span>&lt;span style="color:#d14">{&lt;/span>&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>y&lt;span style="color:#d14">:&lt;/span>&lt;span style="color:#d14">5&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">)&lt;/span>&lt;span style="color:#d14">{&lt;/span>flags&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#3c5d5d;font-weight:bold">@dataclass&lt;/span>(frozen&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> &lt;span style="color:#458;font-weight:bold">FootprintSav&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> footprints: &lt;span style="color:#0086b3">tuple&lt;/span>[&lt;span style="color:#0086b3">int&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#3c5d5d;font-weight:bold">@classmethod&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">from_file&lt;/span>(&lt;span style="color:#999">cls&lt;/span>, path):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">with&lt;/span> &lt;span style="color:#0086b3">open&lt;/span>(path, &lt;span style="color:#d14">&amp;#39;rb&amp;#39;&lt;/span>) &lt;span style="color:#000;font-weight:bold">as&lt;/span> f:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> f&lt;span style="color:#000;font-weight:bold">.&lt;/span>seek(&lt;span style="color:#099">76&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> count &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">int&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>from_bytes(f&lt;span style="color:#000;font-weight:bold">.&lt;/span>read(&lt;span style="color:#099">4&lt;/span>), byteorder&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#39;little&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> f&lt;span style="color:#000;font-weight:bold">.&lt;/span>seek(&lt;span style="color:#099">364&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> points &lt;span style="color:#000;font-weight:bold">=&lt;/span> []
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> _ &lt;span style="color:#000;font-weight:bold">in&lt;/span> &lt;span style="color:#0086b3">range&lt;/span>(count):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> points&lt;span style="color:#000;font-weight:bold">.&lt;/span>append(&lt;span style="color:#0086b3">int&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>from_bytes(f&lt;span style="color:#000;font-weight:bold">.&lt;/span>read(&lt;span style="color:#099">4&lt;/span>), byteorder&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#39;little&amp;#39;&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#999">cls&lt;/span>(points)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> __repr__(&lt;span style="color:#999">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#d14">f&lt;/span>&lt;span style="color:#d14">&amp;#39;&amp;lt;&lt;/span>&lt;span style="color:#d14">{&lt;/span>&lt;span style="color:#0086b3">type&lt;/span>(&lt;span style="color:#999">self&lt;/span>)&lt;span style="color:#000;font-weight:bold">.&lt;/span>__name__&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">; &lt;/span>&lt;span style="color:#d14">{&lt;/span>&lt;span style="color:#0086b3">len&lt;/span>(&lt;span style="color:#999">self&lt;/span>)&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14"> points&amp;gt;&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> __len__(&lt;span style="color:#999">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#0086b3">len&lt;/span>(&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>footprints)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> __getitem__(&lt;span style="color:#999">self&lt;/span>, idx):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#0086b3">isinstance&lt;/span>(idx, &lt;span style="color:#0086b3">slice&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#0086b3">tuple&lt;/span>(Footprint&lt;span style="color:#000;font-weight:bold">.&lt;/span>from_word(w) &lt;span style="color:#000;font-weight:bold">for&lt;/span> w &lt;span style="color:#000;font-weight:bold">in&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>footprints[idx])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> Footprint&lt;span style="color:#000;font-weight:bold">.&lt;/span>from_word(&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>footprints[idx])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> __iter__(&lt;span style="color:#999">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> (Footprint&lt;span style="color:#000;font-weight:bold">.&lt;/span>from_word(p) &lt;span style="color:#000;font-weight:bold">for&lt;/span> p &lt;span style="color:#000;font-weight:bold">in&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>footprints)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>This can be used for quick inspection of data or as a building block for additional
data analysis, for instance to show the locations of every death in a save:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>MY_NICE_SAVE &lt;span style="color:#000;font-weight:bold">=&lt;/span> FootprintSav&lt;span style="color:#000;font-weight:bold">.&lt;/span>from_file(&lt;span style="color:#d14">&amp;#39;slot_05/footprint.sav&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>MY_NICE_DEATHS &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">tuple&lt;/span>(p &lt;span style="color:#000;font-weight:bold">for&lt;/span> p &lt;span style="color:#000;font-weight:bold">in&lt;/span> MY_NICE_SAVE &lt;span style="color:#000;font-weight:bold">if&lt;/span> p&lt;span style="color:#000;font-weight:bold">.&lt;/span>death)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">print&lt;/span>(&lt;span style="color:#d14">&amp;#39;Found&amp;#39;&lt;/span>, &lt;span style="color:#0086b3">len&lt;/span>(MY_NICE_DEATHS), &lt;span style="color:#d14">&amp;#39;deaths&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">for&lt;/span> p &lt;span style="color:#000;font-weight:bold">in&lt;/span> MY_NICE_DEATHS:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">print&lt;/span>(p)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Running this on my save reveals that I died 67 times before completing the game,
and shows the locations:&lt;/p>
&lt;pre tabindex="0">&lt;code>Found 67 deaths
Sky( 285,-1622) D
Sky( 473,-1598) D
Sky( 690,-1429) D
Sky( 748,-1416) D
Sky( 824,-1457) D
Sky( 833,-1451) D
Sky( 820,-1364) D
Sur( 392,-1087) D
Sur( 649, 873) D
Sur( 651, 873) D
...&lt;/code>&lt;/pre>
&lt;h2 id="mapping-the-path">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#mapping-the-path">Mapping the path&lt;/a>
&lt;/h2>
&lt;p>Having figured out the data format to my satisfaction, I needed to be able to visualize the
data to really be satisfied with the results. Fortunately, others had already done much of
the work in making map data available in a way that I can reuse it easily. A person going
by the handle Slluxx &lt;a href="https://gbatemp.net/threads/tears-of-the-kingdom-interactive-map.632644/">published a browser-based game map&lt;/a> several days before the official
release date of Tears of the Kingdom, which was later superceded by a very similar
&lt;a href="https://www.zeldadungeon.net/tears-of-the-kingdom-interactive-map/">map on the Zelda Dungeon web site&lt;/a>.&lt;/p>
&lt;p>The Zelda Dungeon (ZD) map is more complete at this point, so I wanted to use it as a reference-
fortunately its source code is available &lt;a href="https://github.com/zeldadungeon/maps">on GitHub&lt;/a>
even though I had to guess that its source was available and where rather than having it
easily-discovered from the map on its own. I found by inspecting the sources that the
ZD map uses the &lt;a href="https://leafletjs.com/">Leaflet library&lt;/a> to display maps in the browser
and that the tiles&lt;sup id="fnref:3">&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref">3&lt;/a>&lt;/sup> making up the map exist in their repository on GitHub.&lt;/p>
&lt;p>Since I had been doing my investigation in a &lt;a href="https://jupyter.org/">Jupyter notebook&lt;/a>,
it was convenient that the &lt;a href="https://ipyleaflet.readthedocs.io/en/latest/index.html">&lt;code>ipyleaflet&lt;/code>&lt;/a>
library allows Leaflet maps to be embedded in a notebook. I was able to write a little bit
of code to display a zoomable map of Hyrule with only a little bit of reading documentation
and looking at the tile images in the ZD repository (which I found only provide zoom levels
0 through 6 and use an unusual tile size of 564 pixels square):&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">ipyleaflet&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> leaflet, LayersControl, Map
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">zeldadungeon_layer&lt;/span>(name):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#d14">f&lt;/span>&lt;span style="color:#d14">&amp;#39;https://raw.githubusercontent.com/zeldadungeon/maps/develop/public/totk/tiles/&lt;/span>&lt;span style="color:#d14">{&lt;/span>name&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">/&lt;/span>&lt;span style="color:#d14">{{&lt;/span>&lt;span style="color:#d14">z&lt;/span>&lt;span style="color:#d14">}}&lt;/span>&lt;span style="color:#d14">/&lt;/span>&lt;span style="color:#d14">{{&lt;/span>&lt;span style="color:#d14">x&lt;/span>&lt;span style="color:#d14">}}&lt;/span>&lt;span style="color:#d14">_&lt;/span>&lt;span style="color:#d14">{{&lt;/span>&lt;span style="color:#d14">y&lt;/span>&lt;span style="color:#d14">}}&lt;/span>&lt;span style="color:#d14">.jpg&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> leaflet&lt;span style="color:#000;font-weight:bold">.&lt;/span>TileLayer(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> url&lt;span style="color:#000;font-weight:bold">=&lt;/span>url,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> max_zoom&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">6&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tile_size&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">564&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> no_wrap&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> name&lt;span style="color:#000;font-weight:bold">=&lt;/span>name&lt;span style="color:#000;font-weight:bold">.&lt;/span>title(),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> base&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">totk_map&lt;/span>():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">map&lt;/span> &lt;span style="color:#000;font-weight:bold">=&lt;/span> Map(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> layers&lt;span style="color:#000;font-weight:bold">=&lt;/span>[
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zeldadungeon_layer(&lt;span style="color:#d14">&amp;#39;sky&amp;#39;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zeldadungeon_layer(&lt;span style="color:#d14">&amp;#39;depths&amp;#39;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zeldadungeon_layer(&lt;span style="color:#d14">&amp;#39;surface&amp;#39;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> crs&lt;span style="color:#000;font-weight:bold">=&lt;/span>leaflet&lt;span style="color:#000;font-weight:bold">.&lt;/span>projections&lt;span style="color:#000;font-weight:bold">.&lt;/span>Simple,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zoom&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">2&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> center&lt;span style="color:#000;font-weight:bold">=&lt;/span>(&lt;span style="color:#000;font-weight:bold">-&lt;/span>&lt;span style="color:#099">564&lt;/span>&lt;span style="color:#000;font-weight:bold">/&lt;/span>&lt;span style="color:#099">2&lt;/span>, &lt;span style="color:#099">564&lt;/span>&lt;span style="color:#000;font-weight:bold">/&lt;/span>&lt;span style="color:#099">2&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">map&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>add_control(LayersControl())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#0086b3">map&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>totk_map()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Since this was intended only as a prototype, I chose to directly access the tile images
from the ZD map repository on GitHub. I would make a copy and serve them myself for a real
application, but it was very convenient to use somebody else&amp;rsquo;s GitHub repository as a tile
source while prototyping.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/ipyleaflet-basic.webp"
alt="A map of the game&amp;#39;s Hyrule showing the surface, with buttons to zoom in or out in the upper left and radio buttons allowing display of the Depths, Surface or Sky. A box in the lower right includes the legend &amp;#39;ipyleaflet&amp;#39;.">&lt;figcaption>
&lt;p>The basic interactive map as displayed in my notebook, using tiles from Zelda Dungeon and capable of displaying any of the three layers.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;h3 id="coordinate-transformations">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#coordinate-transformations">Coordinate transformations&lt;/a>
&lt;/h3>
&lt;p>In order to plot points on the unadorned map that I was now able to display, I also needed to figure
out how to translate from game coordinates (from around -5000 to 5000 on both axes) to map
coordinates. Mapping tools like Leaflet are usually used to display maps of the Earth or at
least other spherical bodies and consequently usually take coordinates as pairs of latitude and
longitude, but the game world is much simpler because it&amp;rsquo;s a flat&lt;sup id="fnref:4">&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref">4&lt;/a>&lt;/sup> rectangle.&lt;/p>
&lt;p>This kind of application is not unheard of however, so Leaflet provides a &lt;a href="https://leafletjs.com/examples/crs-simple/crs-simple.html">&lt;code>Simple&lt;/code> coordinate
reference system&lt;/a>
(CRS) that doesn&amp;rsquo;t do any of the clever spherical geometry required for
handling maps of the Earth exemplified by CRSes like &lt;a href="https://en.wikipedia.org/wiki/World_Geodetic_System">WGS 84&lt;/a> and
instead one unit on either axis is mapped to one pixel at zoom
level zero. This means that the coordinates on the map I&amp;rsquo;ve created are from 0 to 564 on both
axes.&lt;/p>
&lt;div class="Math">
&lt;p>Although I could have located some points in the game with known coordinates and
manually found their corresponding image coordinates on the map tiles, that would have been
somewhat tedious and error-prone work. I instead looked at the Zelda Dungeon map application&amp;rsquo;s
source code again and found
that (after some computations) the transformation from game coordinates to tile coordinates
(recalling that tiles are 564 pixels square) is best done by multiplying the game coordinate
by 0.046875 and adding 282. Of if you like math notation, the following function $M(p)$
converts a game coordinate $p$ to a map coordinate $M(p)$:&lt;/p>
&lt;p>$$
M(p) = \frac{564}{2} + \left( \frac{564}{12032} \times p\right)
$$&lt;/p>
&lt;/div>
&lt;p>The magic number 564 is the base tile size, and 12032 is a scale factor defined by the
total size of the tiles in relation to the game coordinates.
Any application using different tiles might have a different CRS, but it&amp;rsquo;s nice that I
was able to reuse the ZD CRS alongside the map tiles.&lt;/p>
&lt;p>I wrote a little bit more code to do this coordinate transformation:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">collections&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> namedtuple
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> &lt;span style="color:#458;font-weight:bold">Transformation&lt;/span>(namedtuple(&lt;span style="color:#d14">&amp;#39;Transformation&amp;#39;&lt;/span>, (&lt;span style="color:#d14">&amp;#39;a&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;b&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;c&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;d&amp;#39;&lt;/span>))):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">transform&lt;/span>(&lt;span style="color:#999">self&lt;/span>, x: &lt;span style="color:#0086b3">float&lt;/span>, y: &lt;span style="color:#0086b3">float&lt;/span>, scale&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">1.0&lt;/span>) &lt;span style="color:#000;font-weight:bold">-&amp;gt;&lt;/span> &lt;span style="color:#0086b3">tuple&lt;/span>[&lt;span style="color:#0086b3">float&lt;/span>, &lt;span style="color:#0086b3">float&lt;/span>]:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> scale &lt;span style="color:#000;font-weight:bold">*&lt;/span> (&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>c &lt;span style="color:#000;font-weight:bold">*&lt;/span> y &lt;span style="color:#000;font-weight:bold">+&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>d),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> scale &lt;span style="color:#000;font-weight:bold">*&lt;/span> (&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>a &lt;span style="color:#000;font-weight:bold">*&lt;/span> x &lt;span style="color:#000;font-weight:bold">+&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>b),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>scale &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">564&lt;/span> &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">12032&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>offset &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">564&lt;/span> &lt;span style="color:#000;font-weight:bold">/&lt;/span> &lt;span style="color:#099">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>MAP_TRANSFORM &lt;span style="color:#000;font-weight:bold">=&lt;/span> Transformation(scale, offset, scale, &lt;span style="color:#000;font-weight:bold">-&lt;/span>offset)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>..and then to test the transformation, I plotted from the beginning of my save&amp;rsquo;s Hero&amp;rsquo;s
Path until the first time I teleported.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">ipyleaflet&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> AntPath
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">map&lt;/span> &lt;span style="color:#000;font-weight:bold">=&lt;/span> totk_map()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>points &lt;span style="color:#000;font-weight:bold">=&lt;/span> []
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">for&lt;/span> p &lt;span style="color:#000;font-weight:bold">in&lt;/span> MY_NICE_SAVE:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> points&lt;span style="color:#000;font-weight:bold">.&lt;/span>append(MAP_TRANSFORM&lt;span style="color:#000;font-weight:bold">.&lt;/span>transform(p&lt;span style="color:#000;font-weight:bold">.&lt;/span>x, p&lt;span style="color:#000;font-weight:bold">.&lt;/span>y))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> p&lt;span style="color:#000;font-weight:bold">.&lt;/span>warp:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">break&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">map&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>add_layer(AntPath(locations&lt;span style="color:#000;font-weight:bold">=&lt;/span>points))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">map&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/skyisland.webp"
alt="A dashed light- and dark blue line circles around half of the Great Sky Island, largely following lines of topography.">&lt;figcaption>
&lt;p>I knew that I had spent time circling the Great Sky Island during the tutorial sections of the game, so that this track follows logical borders in that area is an encouraging sign that the CRS is correct.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>The first couple attempts at this didn&amp;rsquo;t have a correct CRS; it was either shifted from the correct
location (in a few instances far outside the actual map bounds),
flipped or rotated. I experimented by switching the &lt;code>x&lt;/code> and &lt;code>y&lt;/code> coordinate orders in a few
places (Leaflet takes Y coordinates first by convention&lt;sup id="fnref:5">&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref">5&lt;/a>&lt;/sup>) and changing the sign of some of
the factors (&lt;code>scale&lt;/code>, &lt;code>offset&lt;/code>) until I ended up with the above code and image which looks correct.&lt;/p>
&lt;h3 id="plotting-deaths-and-life">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#plotting-deaths-and-life">Plotting deaths and life&lt;/a>
&lt;/h3>
&lt;p>That made for all the information and code I needed to make use of footprint data, so as
another experiment I plotted every location I died on the map.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">ipyleaflet&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> Marker
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">map&lt;/span> &lt;span style="color:#000;font-weight:bold">=&lt;/span> totk_map()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">for&lt;/span> p &lt;span style="color:#000;font-weight:bold">in&lt;/span> MY_NICE_SAVE:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> p&lt;span style="color:#000;font-weight:bold">.&lt;/span>death:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> m &lt;span style="color:#000;font-weight:bold">=&lt;/span> Marker(location&lt;span style="color:#000;font-weight:bold">=&lt;/span>MAP_TRANSFORM&lt;span style="color:#000;font-weight:bold">.&lt;/span>transform(p&lt;span style="color:#000;font-weight:bold">.&lt;/span>x, p&lt;span style="color:#000;font-weight:bold">.&lt;/span>y),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> title&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">f&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>&lt;span style="color:#d14">{&lt;/span>p&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14"> &lt;/span>&lt;span style="color:#d14">{&lt;/span>MAP_TRANSFORM&lt;span style="color:#000;font-weight:bold">.&lt;/span>transform(p&lt;span style="color:#000;font-weight:bold">.&lt;/span>x, p&lt;span style="color:#000;font-weight:bold">.&lt;/span>y)&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>, draggable&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">False&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">map&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>add(m)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">map&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/deathplot.webp"
alt="The map of Hyrule with 67 blue pins at scattered locations across it. They tend to appear in clusters, densely placed in some areas and completely absent in others.">&lt;figcaption>
&lt;p>It&amp;rsquo;s fairly clear for looking at this map that my deaths tended to be clustered, rather than scattered; I must have a tendency to treat a death in the game as a challenge and try again until I find a winning strategy, rather than avoiding the area in the name of safety.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>This plot is somewhat misleading however because it always displays every point, regardless of
which layer is shown behind them. It would certainly be possible to automatically show only the
points corresponding to the visible layer, but it may not be possible with ipyleaflet.&lt;/p>
&lt;p>I also had a go at drawing the same kind of line that the game displays for the Hero&amp;rsquo;s Path.
As with deaths, this displays activity on every layer in the same way so it&amp;rsquo;s not possible
to tell which layer of the world I was on at any given point; that would be a worthwhile
improvement that I haven&amp;rsquo;t tried to make.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">itertools&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> pairwise
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">ipyleaflet&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> Polyline
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">map&lt;/span> &lt;span style="color:#000;font-weight:bold">=&lt;/span> totk_map()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>chunks &lt;span style="color:#000;font-weight:bold">=&lt;/span> []
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>chunk &lt;span style="color:#000;font-weight:bold">=&lt;/span> []
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">for&lt;/span> p1, p2 &lt;span style="color:#000;font-weight:bold">in&lt;/span> pairwise(MY_NICE_SAVE):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> p1&lt;span style="color:#000;font-weight:bold">.&lt;/span>warp &lt;span style="color:#000;font-weight:bold">or&lt;/span> p1&lt;span style="color:#000;font-weight:bold">.&lt;/span>death:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> chunks&lt;span style="color:#000;font-weight:bold">.&lt;/span>append(chunk)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> chunk &lt;span style="color:#000;font-weight:bold">=&lt;/span> []
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> chunk&lt;span style="color:#000;font-weight:bold">.&lt;/span>append(MAP_TRANSFORM&lt;span style="color:#000;font-weight:bold">.&lt;/span>transform(p1&lt;span style="color:#000;font-weight:bold">.&lt;/span>x, p1&lt;span style="color:#000;font-weight:bold">.&lt;/span>y))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> chunk&lt;span style="color:#000;font-weight:bold">.&lt;/span>append(MAP_TRANSFORM&lt;span style="color:#000;font-weight:bold">.&lt;/span>transform(p2&lt;span style="color:#000;font-weight:bold">.&lt;/span>x, p2&lt;span style="color:#000;font-weight:bold">.&lt;/span>y))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">if&lt;/span> chunk:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> chunks&lt;span style="color:#000;font-weight:bold">.&lt;/span>append(chunk)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">map&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>add(Polyline(locations&lt;span style="color:#000;font-weight:bold">=&lt;/span>chunks, name&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;Hero&amp;#39;s Path&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> color&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#39;rgba(0, 255, 255, 0.5)&amp;#39;&lt;/span>, weight&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">3&lt;/span>, fill&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">False&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">map&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;figure>&lt;img src="https://www.taricorp.net/2023/totk-footprints/fullpath.webp"
alt="The map of Hyrule with a cyan line snaking all around it, at times circling around itself in small areas and at others moving in lone straight or nearly-straight lines.">&lt;figcaption>
&lt;p>Even with no indication of which layer a given point is on, this track provides a decent sense of where I spent more or less time and illustrates how geography guides the way players move through the world.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>This proved that I could do what I originally wanted to do, and it&amp;rsquo;s where I&amp;rsquo;m leaving
this write-up.&lt;/p>
&lt;h2 id="conclusions">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#conclusions">Conclusions&lt;/a>
&lt;/h2>
&lt;p>I was somewhat surprised by how easy it was to learn the aspects of the &lt;code>footprint.sav&lt;/code> file
format that I cared about, though it was significantly simplified by the existence of documentation
for BOTW&amp;rsquo;s similar data. I hope my notes on the process are useful even to people who aren&amp;rsquo;t
interested in the Hero&amp;rsquo;s Path in particular, since it seems like when others do this kind of
work they tend to only share the results and nothing of the process. The result in those cases
tends to be that the process of reverse-engineering seems impossibly difficult to a beginner,
but I hope that the description of my process (which I developed in an ad-hoc way, never having
tried to do this kind of thing before) lifts the veil on at least one way to approach
this kind of challenge.&lt;/p>
&lt;p>In documenting this format I&amp;rsquo;ve filled in a knowledge gap that at least one other person wanted
to fill, and hopefully have enabled others to do interesting things with the data in the future.
Although I would like to create some tooling that allows others to visualize their own data
with ease, I found that the appeal of doing so had been lost after I prototyped enough to show
that it was possible. Perhaps I&amp;rsquo;ll revisit that tooling in the future, but right now I&amp;rsquo;m
more interested in doing other things.&lt;/p>
&lt;p>Since it might be interesting to view, I&amp;rsquo;m sharing the Jupyter notebook that I was working
in when doing the work described in this post. It&amp;rsquo;s formatted differently from the narrative
version here and is probably harder to read, but does offer interactive maps and complete
code: &lt;strong>&lt;a href="https://www.taricorp.net/2023/totk-footprints/TOTK%20Save%20Investigation.ipynb">TOTK Save Investigation.ipynb&lt;/a>&lt;/strong>.&lt;/p>
&lt;h3 id="extended-ideas">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/totk-footprints/#extended-ideas">Extended ideas&lt;/a>
&lt;/h3>
&lt;p>It would be neat to visualize where real players have died most often by collecting
a bunch of saves and generating a heatmap- I recall the time &lt;a href="http://web.archive.org/web/20110608170508/http://jimblackhurst.com/wp/?p=213">somebody with access
to player data for Just Cause 2 mapped 11 million player deaths&lt;/a>
with &lt;a href="https://www.youtube.com/watch?v=hEoxaGkNcrg">interesting results&lt;/a>, and although the
Hero&amp;rsquo;s Path doesn&amp;rsquo;t permit quite the same level of detail
I believe the results could be interesting.&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>It seems like Nintendo&amp;rsquo;s development teams have found that it&amp;rsquo;s nice to capture moments automatically for the player, since this year&amp;rsquo;s &lt;em>Super Mario Bros. Wonder&lt;/em> &lt;a href="https://www.polygon.com/23929459/super-mario-bros-wonder-end-of-level-screenshots">automatically captures screenshots of gameplay&lt;/a> that seem like they have similar attraction.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>&amp;ldquo;Hatago&amp;rdquo; is a Japanese word referring to &lt;a href="https://en.wikipedia.org/wiki/Hatago">inns located along national highways in the Edo period&lt;/a>, so it makes sense that this word would be used by the developers to refer to the game&amp;rsquo;s stables that function as a kind of inn.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:3">
&lt;p>For readers unfamiliar with how map-viewing applications are often implemented,
they tend to provide &amp;ldquo;tiles&amp;rdquo; (pictures) of map imagery on demand, each of which is fairly small
(often 256 pixels square) and has an associated &amp;ldquo;zoom level&amp;rdquo;; at any given zoom level
a tile can be retrieved that covers any chosen point on the map. This approach is in part a concession to
efficiency because a large map represented as a single huge image would often be too
large for anybody to view with acceptable performance, and it allows lower zoom levels
(more zoomed out) to omit small details that might otherwise make a map difficult to read
while still making them visible at higher zoom levels.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:4">
&lt;p>&amp;ldquo;Flat&amp;rdquo; meaning it&amp;rsquo;s a rectangle that exists in a purely two-dimensional space
rather than being projected onto the surface of a sphere (or a shape that approximates a sphere),
as real-world maps typically are.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:5">
&lt;p>Mapping software generally doesn&amp;rsquo;t agree on whether
&lt;code>(x,y)&lt;/code> or &lt;code>(latitude,longitude)&lt;/code> pairs
are the more correct way to express coordinates. Humans looking at maps usually talk about positions
with latitude and longitude in that order, but computer graphics usually uses Cartesian &lt;code>(x,y)&lt;/code> coordinates
instead (and even then with no particular consistency about whether Y=0 is at the top or bottom of the screen).
There&amp;rsquo;s no sensible way to split that difference, so different libraries often make different
choices about the order in which X and Y coordinates need to be given.&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Moving a Linux system's root without rebooting</title><link>https://www.taricorp.net/2023/lvm-switcheroo/</link><pubDate>Fri, 08 Sep 2023 01:00:00 +0000</pubDate><guid>https://www.taricorp.net/2023/lvm-switcheroo/</guid><description>&lt;p>On a long-lived Linux server I operate, it has periodically been desirable to
move its root filesystem (containing core OS files and the like) to a new drive
for various reasons. Over the years it&amp;rsquo;s migrated from a conveniently
inexpensive mechanical hard drive to a series of increasingly capable and larger
solid-state drives (SSDs), first connected over SATA and later in the form of
M.2 NVMe cards. I recently sought to do another upgrade, swapping the current
NVMe drive for a larger one. Making things more interesting, I&amp;rsquo;ve developed strategies to do this without rebooting except to physically plug or unplug devices!&lt;/p>
&lt;!-- more -->
&lt;p>Traditionally, migrating a system to a new root disk like this either involves
reinstalling completely and restoring data afterwards, or booting from some kind
of rescue media (a &amp;ldquo;&lt;a href="https://en.wikipedia.org/wiki/Live_CD">Live CD&lt;/a>&amp;rdquo;) and
manually copying everything from the old to the new disk. Both of these require
some system downtime and the former makes it easy to unintentionally leave
behind important aspects of system configuration, so I&amp;rsquo;ve usually done the
latter.&lt;/p>
&lt;p>During an earlier migration of this same system, I realized that Linux&amp;rsquo;s Logical
Volume Manager (LVM) could make these kinds of migration easier. Primarily LVM
makes it easier to resize disk partitions after moving them to a new disk,
allowing me to take advantage of progressively larger disks over time; but it also
turns out that LVM&amp;rsquo;s layers of abstraction make it possible to move filesystems
(including the root) without even turning the computer off, doing a kind of
&amp;ldquo;&lt;strong>switcheroo&lt;/strong>&amp;rdquo; that minimizes downtime.&lt;/p>
&lt;p>Depending on the hardware configuration, this approach allows disk migration
without any downtime, provided the storage devices involved can be hotplugged or
will both end up being connected! Since it&amp;rsquo;s been a while since I last did this
switcheroo and there have been cases since the last time where these techniques
were relevant in conversation with other professional computer people, this time
I&amp;rsquo;ve chosen to write up the process so it can both serve as notes for me the
next time I want to do it, and to demonstrate it to others.&lt;/p>
&lt;h2 id="setup">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#setup">Setup&lt;/a>
&lt;/h2>
&lt;p>Before diving into the details, I should recap what the system configuration
looks like. This is a fairly recent consumer-grade PC based around an Intel
processor, with the OS installed on a &lt;a href="https://en.wikipedia.org/wiki/M.2#Storage_interfaces">M.2 NVMe
SSD&lt;/a> installed in a slot
on the mainboard. The new drive I want to move to is another M.2 SSD but with
greater capacity.&lt;/p>
&lt;p>Because the mainboard does not have any spare M.2 slots where I could install
the new SSD, I&amp;rsquo;ve used a USB enclosure to connect the new drive over USB, with
expectation that after the migration is complete I can incur small downtime to
take out the old SSD and install the new one into the slot on the mainboard. Use
of a USB enclosure for the new drive probably causes some performance
degradation since running everything over USB will tend to be somewhat slower,
but that temporary loss of performance is unimportant to me.&lt;/p>
&lt;h2 id="discovering-the-current-state">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#discovering-the-current-state">Discovering the current state&lt;/a>
&lt;/h2>
&lt;p>Although I know this sytem uses LVM, it&amp;rsquo;s been a while since I configured it so
many of the details are fuzzy. Before beginning, I needed to investigate what
the current configuration was in order to understand what I should do to perform
the switcheroo.&lt;/p>
&lt;p>First, I used &lt;code>lsblk&lt;/code> to understand what storage devices were connected and in use (with irrelevant ones omitted here):&lt;/p>
&lt;pre tabindex="0">&lt;code># lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
sdf 8:80 0 953.9G 0 disk
nvme0n1 259:1 0 238.5G 0 disk
├─nvme0n1p1 259:2 0 512M 0 part /boot
└─nvme0n1p2 259:3 0 238G 0 part
├─myhost-root 254:0 0 40G 0 lvm /
├─myhost-home 254:1 0 60G 0 lvm /home
└─myhost-var 254:2 0 60G 0 lvm /var&lt;/code>&lt;/pre>
&lt;p>Or reformatted as a table, omitting columns that aren&amp;rsquo;t interesting and
translating a few items to more understandable versions:&lt;/p>
&lt;style type="text/css">
/* Add some column spacing */
#lsblk tr > td:not(:first-child),
#lsblk tr > th:not(:first-child) {
padding-left: 1.5em;
}
#lsblk tr[aria-level="2"] > td:first-child {
padding-left: 1ch;
}
#lsblk tr[aria-level="3"] > td:first-child {
padding-left: 2ch;
}
#lsblk tr[aria-expanded="true"] ~ tr > td:first-child::before {
content: '\21b3';
}
&lt;/style>
&lt;figure role="treegrid" style="overflow-x: auto">
&lt;table id="lsblk">
&lt;thead>
&lt;tr>
&lt;th>Device name&lt;/th>
&lt;th>Size&lt;/th>
&lt;th>Type&lt;/th>
&lt;th>Mountpoint&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr aria-level="1">
&lt;td>&lt;code>sdf&lt;/code>&lt;/td>
&lt;td>953.9G&lt;/td>
&lt;td>disk&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr aria-level="1" aria-expanded="true">
&lt;td>&lt;code>nvme0n1&lt;/code>&lt;/td>
&lt;td>238.5G&lt;/td>
&lt;td>disk&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr aria-level="2">
&lt;td>&lt;code>nvme0n1p1&lt;/code>&lt;/td>
&lt;td>512M&lt;/td>
&lt;td>partition&lt;/td>
&lt;td>&lt;code>/boot&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr aria-level="2" aria-expanded="true">
&lt;td>&lt;code>nvme0n1p2&lt;/code>&lt;/td>
&lt;td>238G&lt;/td>
&lt;td>partition&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr aria-level="3">
&lt;td>&lt;code>myhost-root&lt;/code>&lt;/td>
&lt;td>40G&lt;/td>
&lt;td>LVM LV&lt;/td>
&lt;td>&lt;code>/&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr aria-level="3">
&lt;td>&lt;code>myhost-home&lt;/code>&lt;/td>
&lt;td>60G&lt;/td>
&lt;td>LVM LV&lt;/td>
&lt;td>&lt;code>/home&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr aria-level="3">
&lt;td>&lt;code>myhost-var&lt;/code>&lt;/td>
&lt;td>60G&lt;/td>
&lt;td>LVM LV&lt;/td>
&lt;td>&lt;code>/var&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;/figure>
&lt;p>The disk &lt;code>sdf&lt;/code> is the new drive in the USB enclosure, and &lt;code>nvme0n1&lt;/code> is the
current boot disk containing the root filesystem as well as &lt;code>/home&lt;/code> and &lt;code>/var&lt;/code>
which need to be moved. Most of the data is backed by LVM (the &lt;code>type&lt;/code> column
reads &lt;code>lvm&lt;/code>, meaning it&amp;rsquo;s on a LVM LV), which enables this whole procedure.&lt;/p>
&lt;p>There&amp;rsquo;s also a small (512MB) partition at &lt;code>nvme0n1p1&lt;/code> mounted at &lt;code>/boot&lt;/code>. This
will be important to preserve. Looking at the disk&amp;rsquo;s partition table in detail
provides more useful information (with some things that are not relevant to this
discussion removed):&lt;/p>
&lt;pre tabindex="0">&lt;code># gdisk -l /dev/nvme0n1
Found valid GPT with protective MBR; using GPT.
Disk /dev/nvme0n1: 500118192 sectors, 238.5 GiB
Sector size (logical/physical): 512/512 bytes
Number Start (sector) End (sector) Size Code Name
1 2048 1050623 512.0 MiB EF00 EFI system partition
2 1050624 500117503 238.0 GiB 8E00 Linux LVM&lt;/code>&lt;/pre>
&lt;p>Knowing the exact sector size of the boot partition will be useful later (to
confirm the new one is exactly the same), and this confirms that there are only
two partitions on the old disk.&lt;/p>
&lt;h3 id="efi-configuration">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#efi-configuration">EFI configuration&lt;/a>
&lt;/h3>
&lt;p>The &lt;code>EFI system partition&lt;/code> type for the boot partition indicates that it is in
fact an &lt;em>EFI system partition&lt;/em> (ESP), telling us a few things:&lt;/p>
&lt;ul>
&lt;li>This machine uses &lt;a href="https://en.wikipedia.org/wiki/UEFI">UEFI&lt;/a> to boot, not legacy BIOS.&lt;/li>
&lt;li>It&amp;rsquo;s impossible to put this data on LVM, because the system firmware needs to be able to read the files stored therein. It&amp;rsquo;s formatted with a FAT32 filesystem, which is common for EFI systems; some bootloaders are capable of accessing data on LVM, but I doubt any UEFI firmwares out there can.&lt;/li>
&lt;li>The &lt;a href="https://en.wikipedia.org/wiki/Boot_sector">boot sector&lt;/a> and any data stored outside a partition does not need to be preserved, because only the data on the ESP is needed to boot. Compare to legacy BIOS, where the boot sector always contains bootloader code that must be preserved and sometimes bootloader code is stored in unpartitioned areas of the disk which must also be preserved.&lt;/li>
&lt;li>The firmware may have references to partition UUIDs that tell it what to boot from, which would need to either be preserved or updated later.&lt;/li>
&lt;/ul>
&lt;p>To understand what the firmware&amp;rsquo;s boot configuration is, &lt;code>efibootmgr&lt;/code> works
well:&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>&lt;/p>
&lt;pre tabindex="0">&lt;code># efibootmgr
BootCurrent: 0001
Timeout: 5 seconds
BootOrder: 0001,0000
Boot0000* Arch Linux VenHw(99e275e7-75a0-4b37-a2e6-c5385e6c00cb)72006f006f0074003d002f006400650076002f006d00610070007000650072002f006b006900720069007300680069006d0061005f006e00650077002d0072006f006f007400200072007700200069006e0069007400720064003d002f0069006e00740065006c002d00750063006f00640065002e0069006d006700200069006e0069007400720064003d002f0069006e0069007400720061006d00660073002d006c0069006e00750078002e0069006d006700
Boot0001* GRUB HD(1,GPT,abf0aa0a-b6ed-45a9-9c16-77727fef1538,0x800,0x100000)/File(\EFI\GRUB\grubx64.efi)&lt;/code>&lt;/pre>
&lt;p>&lt;code>efibootmgr&lt;/code> tells us here that the firmware is configured to prefer to boot
GRUB (entry &lt;code>Boot0001&lt;/code>, which comes first in &lt;code>BootOrder&lt;/code>), and it finds the GRUB
executable on &lt;code>HD&lt;/code> number &lt;code>1&lt;/code>, in a partition with UUID &lt;code>abf0aa0a…&lt;/code>. The other boot
entry contains a lot of text, which in this output is displayed as hexadecimal
bytes but I know that it&amp;rsquo;s actually meant to be text encoded with
&lt;a href="https://en.wikipedia.org/wiki/Universal_Coded_Character_Set">UCS-2&lt;/a> (similar to
UTF-16: two bytes per character, with the second zero for most Latin
characters).&lt;/p>
&lt;hr>
&lt;p>Seeing this slight mess of text summons memories from when I first configured
EFI boot on this system: I remember being unable to make the firmware &lt;a href="https://wiki.archlinux.org/title/EFISTUB">directly
boot the kernel&lt;/a>, then giving up and
using &lt;a href="https://www.gnu.org/software/grub/">GRUB&lt;/a> instead. While it seemed that
the system&amp;rsquo;s firmware was willing to boot a Linux kernel image directly, it
would not pass the required kernel command line options so the kernel was unable
to do anything useful like mount the root filesystem. Using GRUB instead means
the configuration is loaded from a file rather than any parameters passed from
the firmware, sidestepping that issue.&lt;/p>
&lt;hr>
&lt;p>It looks like the disk number (&lt;code>HD(1,…&lt;/code> for the &lt;code>Boot0001&lt;/code> GRUB entry that is of
interest here) is chosen by the firmware and perhaps correlates to a physical
port on the mainboard, so there should be no special considerations in making
the firmware select the correct disk to boot from. To select the correct
partition to load GRUB from however, either the new partition&amp;rsquo;s UUID must be set
to be the same as the old or the boot entry must be updated with a new UUID.&lt;/p>
&lt;p>To confirm that this UUID refers to the UEFI system partition, I looked at
&lt;code>/dev/disk/by-partuuid&lt;/code> and verified that the file &lt;code>abf0aa0a…&lt;/code> is a link to
&lt;code>nvme0n1p1&lt;/code>: the ESP. It seems plausible that there might be a way to tell the
firmware to load something from the ESP directly without specifying the
filesystem UUID, but I&amp;rsquo;m not sufficiently motivated to discover if that&amp;rsquo;s true
right now.&lt;/p>
&lt;h2 id="moving-boot">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#moving-boot">Moving /boot&lt;/a>
&lt;/h2>
&lt;p>To move the ESP, I&amp;rsquo;ve chosen to reuse the original partition UUID so I don&amp;rsquo;t
need to change firmware configuration at all. This should make it easier to
switch back to the old disk if I need to.&lt;/p>
&lt;p>First I used &lt;code>gdisk&lt;/code> to interactively create a partition on the new disk with
the same size, partition type, and UUID as the original:&lt;/p>
&lt;pre tabindex="0">&lt;code># gdisk /dev/sdf
GPT fdisk (gdisk) version 1.0.9.1
Partition table scan:
MBR: protective
BSD: not present
APM: not present
GPT: present
Found valid GPT with protective MBR; using GPT.
Command (? for help): p
Disk /dev/sdf: 2000409264 sectors, 953.9 GiB
Model: Generic
Sector size (logical/physical): 512/4096 bytes
Disk identifier (GUID): CE6A291B-12C9-4669-BF19-8696D81FBF4B
Partition table holds up to 128 entries
Main partition table begins at sector 2 and ends at sector 33
First usable sector is 34, last usable sector is 2000409230
Partitions will be aligned on 2048-sector boundaries
Total free space is 2000409197 sectors (953.9 GiB)
Number Start (sector) End (sector) Size Code Name
Command (? for help): n
Partition number (1-128, default 1):
First sector (34-2000409230, default = 2048) or {+-}size{KMGTP}:
Last sector (2048-2000409230, default = 2000408575) or {+-}size{KMGTP}: +512M
Current type is 8300 (Linux filesystem)
Hex code or GUID (L to show codes, Enter = 8300): EF00
Changed type of partition to &amp;#39;EFI system partition&amp;#39;
Command (? for help): x
Expert command (? for help): c
Using 1
Enter the partition&amp;#39;s new unique GUID (&amp;#39;R&amp;#39; to randomize): abf0aa0a-b6ed-45a9-9c16-77727fef1538
New GUID is ABF0AA0A-B6ED-45A9-9C16-77727FEF1538 &lt;/code>&lt;/pre>
&lt;p>After saving the new partition table and exiting &lt;code>gdisk&lt;/code>, &lt;code>gdisk -l /dev/sdf&lt;/code> verifies that the
new partition has the same size (start and end sectors) as the old one, so I&amp;rsquo;m
happy with this result.&lt;/p>
&lt;h3 id="copying-the-filesystem">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#copying-the-filesystem">Copying the filesystem&lt;/a>
&lt;/h3>
&lt;p>To copy the filesystem, I&amp;rsquo;d like to take a bit-exact copy (rather than copying the files and replicating the directory structure) to ensure there won&amp;rsquo;t
be any unexpected differences between the original and copy. Since I only rarely
need to write to this filesystem and certainly don&amp;rsquo;t need to right now (aside
from writing the copy to the new disk), I can remount it read-only then copy the
block device directly:&lt;/p>
&lt;pre tabindex="0">&lt;code># mount -o remount,ro /boot
# dd if=/dev/nvme0n1p1 of=/dev/sdf1 bs=1M
512+0 records in
512+0 records out
536870912 bytes (537 MB, 512 MiB) copied, 14.6401 s, 36.7 MB/s&lt;/code>&lt;/pre>
&lt;p>Making it read-only before copying ensures that its state on disk is consistent
and the system won&amp;rsquo;t make any changes that could leave it in an inconsistent
state while I&amp;rsquo;m making the copy.
To ensure the new copy remains the same as the old one, this filesystem should
remain read-only until I replace it with the new one. Otherwise changes
wouldn&amp;rsquo;t be propagated to the copy, and I&amp;rsquo;d need to repeat this step later.&lt;/p>
&lt;h2 id="lvm-switcheroo">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#lvm-switcheroo">LVM switcheroo&lt;/a>
&lt;/h2>
&lt;p>With the non-LVM data moved, it&amp;rsquo;s on to the rest. To recall how LVM works (or
provide a quick introduction to readers unfamiliar with it), the Linux Logical
Volume Manager (LVM) offers a variety of features to abstract away the details
of persistent storage devices.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Physical volumes&lt;/strong> (PVs) are created on physical storage (actual hard disks).&lt;/li>
&lt;li>Multiple PVs can be combined into a &lt;strong>volume group&lt;/strong> (VG), which acts like a storage pool made up of all of the PVs in the VG.&lt;/li>
&lt;li>&lt;strong>Logical volumes&lt;/strong> (LVs) are created on VGs and act like regular disk partitions, but given superpowers by the layers of VG and PV underneath.&lt;/li>
&lt;/ul>
&lt;p>By sitting between actual storage and the &amp;ldquo;partition&amp;rdquo; that typically has a
filesystem on it (a LV), it&amp;rsquo;s possible to configure the LV to behave entirely
unlike a real disk. A few of the interesting features include:&lt;/p>
&lt;ul>
&lt;li>A &amp;ldquo;thin&amp;rdquo; LV can be larger than the underlying PV, which might be useful in a
system that is actively managed and the administrator ensures there is always
enough storage available: they can create a very large filesystem that doesn&amp;rsquo;t
need to be resized as storage is added, without having enough storage for the
entire filesystem from the outset.&lt;/li>
&lt;li>&amp;ldquo;Snapshot&amp;rdquo; LVs capture changes to another LV, exactly capturing its contents
at the time the snapshot was created without blocking writes to the original.
This works a lot like the snapshot features of
&lt;a href="https://klarasystems.com/articles/basics-of-zfs-snapshot-management/">ZFS&lt;/a> and
&lt;a href="https://fedoramagazine.org/working-with-btrfs-snapshots/">btrfs&lt;/a>, but isn&amp;rsquo;t
tied to use of any particular filesystem.&lt;/li>
&lt;li>Allocation policy of a LV with regard to the PVs in its VG can be controlled,
often using the &lt;code>raid&lt;/code> LV type to use any of the common
&lt;a href="https://en.wikipedia.org/wiki/RAID">RAID&lt;/a> allocation policies to spread data
across disks to improve performance, provide redundancy against disk failure, or
both.&lt;/li>
&lt;/ul>
&lt;p>VGs and LVs are all given names, which is where the names like &lt;code>myhost-root&lt;/code>
seen earlier come from. &lt;code>myhost&lt;/code> is the name of the VG I&amp;rsquo;m using, and &lt;code>root&lt;/code> is
the name of the LV holding the filesystem that gets mounted at &lt;code>/&lt;/code> (the
system root).&lt;/p>
&lt;h3 id="desired-configuration">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#desired-configuration">Desired configuration&lt;/a>
&lt;/h3>
&lt;p>After doing some reading of the LVM-related documentation (in particular
&lt;a href="https://www.man7.org/linux/man-pages/man7/lvmraid.7.html">lvmraid(7)&lt;/a>, I
concluded that temporarily configuring my LVs as RAID1 across the two devices is
a sensible approach. By also setting the &lt;em>activation mode&lt;/em> to &lt;em>degraded&lt;/em>, the
system will allow the relevant LVs to be used as long as either PV it&amp;rsquo;s on
(assuming RAID1 with two devices) is present. By doing this, I can reconfigure
the LVs then wait for them to be synced, and reboot to swap in the new storage.
If something goes wrong with the new one, I can connect the old one and they
will still have the same contents so I can easily roll back and try again.&lt;/p>
&lt;aside class="attention">
&lt;b>Notice&lt;/b>: using RAID &lt;a href="#unexpected-misbehavior">didn't work out as I
hoped&lt;/a>. If you're following along with this narrative and attempting to do
something similar to a system of your own, read the following sections carefully
before continuing.
&lt;/aside>
&lt;p>First, create a PV on the new disk in a new partition that fills the rest of the
device (&lt;code>sdf2&lt;/code>) and add it to the VG (which is named &lt;code>myhost&lt;/code>):&lt;/p>
&lt;pre tabindex="0">&lt;code># pvcreate /dev/sdf2
Physical volume &amp;#34;/dev/sdf2&amp;#34; successfully created.
# vgextend myhost /dev/sdf2&lt;/code>&lt;/pre>
&lt;p>At this point &lt;code>vgdisplay&lt;/code> shows there are two active PVs in the VG, and its size
is much larger than it was previously. Now convert each of the LVs I care about
to RAID1, mirrored across both PVs:&lt;/p>
&lt;pre tabindex="0">&lt;code># for lv in root var home; do
for&amp;gt; lvconvert --type raid1 --mirrors 1 myhost/$lv
for&amp;gt; done
Are you sure you want to convert linear LV myhost/root to raid1 with 2 images enhancing resilience? [y/n]: y
Logical volume myhost/root successfully converted.
Are you sure you want to convert linear LV myhost/var to raid1 with 2 images enhancing resilience? [y/n]: y
Logical volume myhost/var successfully converted.
Are you sure you want to convert linear LV myhost/home to raid1 with 2 images enhancing resilience? [y/n]: y
Logical volume myhost/home successfully converted.&lt;/code>&lt;/pre>
&lt;p>After conversion, the LVs need to sync: make a copy of all their data on the new
PV. The &lt;code>lvmraid&lt;/code> manpage says to use &lt;code>lvs -a -o name,sync_percent&lt;/code> to monitor
this process:&lt;/p>
&lt;pre tabindex="0">&lt;code># lvs -a -o name,sync_percent
LV Cpy%Sync
root 3.71
[root_rimage_0]
[root_rimage_1]
[root_rmeta_0]
[root_rmeta_1]&lt;/code>&lt;/pre>
&lt;p>Before continuing, I waited until &lt;code>Cpy%Sync&lt;/code> was 100% for each of the volumes.
To ensure that the system will still try to boot with only one replica present,
I also verified that the current LVM configuration allowed degraded RAID LVs to
be activated:&lt;/p>
&lt;pre tabindex="0">&lt;code># lvmconfig --type current activation/activation_mode
activation_mode=&amp;#34;degraded&amp;#34;&lt;/code>&lt;/pre>
&lt;p>Since this was already set as I wanted, I didn&amp;rsquo;t need to make any further
changes, and proceeded to shut down the computer, swap out the old disk and install the new one.&lt;/p>
&lt;h2 id="unexpected-misbehavior">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#unexpected-misbehavior">Unexpected misbehavior&lt;/a>
&lt;/h2>
&lt;p>After swapping the two disks and disconnecting the one in the USB enclosure
(leaving only the new one installed in the machine), the firmware booted the
Linux kernel correctly, but the system didn&amp;rsquo;t boot successfully: it complained
that it couldn&amp;rsquo;t find the root filesystem. Using a rescue shell I was able to
determine that all my LVs were present, but inactive (unusable until activated)
and in the &lt;code>partial&lt;/code> state, seemingly indicating that some important metadata
was stored only on the original PV and LVM refused to allow the LVs to activate
because it didn&amp;rsquo;t have all of the data needed to manage them correctly.&lt;/p>
&lt;p>I suspect there may have been a way to repair this RAID issue, but I opted to
fall back to the approach I had used previously rather than embark into the
unknown while being stuck with a computer that&amp;rsquo;s offline until it&amp;rsquo;s fixed.&lt;/p>
&lt;h2 id="without-redundancy">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#without-redundancy">Without redundancy&lt;/a>
&lt;/h2>
&lt;p>Since I had already managed to break the system more by attempting to use RAID
than the old approach would have, I fell back at this point to the workflow I
had used previously that still allows everything to be moved without downtime
other than for physically moving hardware around but makes rollback to the
original state more difficult. Unfortunately because I had already broken the
system, I had to do this from a rescue shell after crawling around to connect
the requisite cables so I could use the rescue shell.&lt;/p>
&lt;p>The plan for this was:&lt;/p>
&lt;ol>
&lt;li>Convert the VG back to &lt;code>linear&lt;/code> allocation, rather than &lt;code>raid1&lt;/code>; keep a
single copy of each LV&amp;rsquo;s data, having no particular opinion where it&amp;rsquo;s
stored.&lt;/li>
&lt;li>Move all data off the old PV and onto the new one.&lt;/li>
&lt;li>Remove the old PV from the VG, guaranteeing that nothing will be stored on
it.&lt;/li>
&lt;li>(optional) Wipe the old PV so it can&amp;rsquo;t be accidentally re-added to the VG.&lt;/li>
&lt;/ol>
&lt;p>Each of these steps conveniently maps to a single command to run:&lt;/p>
&lt;pre tabindex="0">&lt;code># lvconvert --type linear myhost/root myhost/var myhost/home
# pvmove /dev/sdf2
# vgreduce myhost /dev/sdf2
# pvremove /dev/sdf2&lt;/code>&lt;/pre>
&lt;p>&lt;code>pvmove&lt;/code> takes a while to complete, because it moves all data on &lt;code>sdf2&lt;/code> to other
PVs in the VG. Since I moved the new drive to the mainboard slot when I was
ready to try the RAID, &lt;code>sdf&lt;/code> is now the &lt;em>old&lt;/em> drive. Then &lt;code>vgreduce&lt;/code> removes the
PV from the named VG (failing if there is any data still stored on the nominated PV), and &lt;code>pvremove&lt;/code> wipes the PV signature off the old disk.&lt;/p>
&lt;p>After doing that, I rebooted normally and the system came up as expected this
time, running entirely on the new disk. Meanwhile, the old disk has no data left
on it and can be reused for other purposes.&lt;/p>
&lt;h2 id="useful-tools-if-you-dont-mess-it-up">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2023/lvm-switcheroo/#useful-tools-if-you-dont-mess-it-up">Useful tools, if you don&amp;rsquo;t mess it up&lt;/a>
&lt;/h2>
&lt;p>I hope this writeup is useful to others, and expect it will be of use to myself
in the future as well. LVM provides some very useful capabilities to system
administrators that I believe many users are unaware of, and greater awareness
is the first step towards making use of these tools.&lt;/p>
&lt;p>My failed experiment with LVM RAID reinforces that when doing low-level computer
maintenance it&amp;rsquo;s often best to go with a proven approach to avoid nasty
surprises: this nasty surprise wasn&amp;rsquo;t too bad, but it did delay my dinner while
I sorted it out. Had the RAID worked how I&amp;rsquo;d expected then it would have become
another tool in my arsenal, and because this system isn&amp;rsquo;t very critical to
anybody, on balance the learning experience seems to have been worthwhile. For
the future however, I&amp;rsquo;d want to either do more experimentation before trying
that approach again, or simply follow the well-trod path (using &lt;code>pvmove&lt;/code>) for
safety.&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>It&amp;rsquo;s usually impossible to interrogate the firmware to learn
about boot configuration when using legacy BIOS; being able to do this is a
useful feature of UEFI.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Efficiently Capturing Time-Lapse video with a Raspberry Pi</title><link>https://www.taricorp.net/2022/timelapser/</link><pubDate>Fri, 02 Sep 2022 06:42:55 +0000</pubDate><guid>https://www.taricorp.net/2022/timelapser/</guid><description>&lt;p>Earlier this year, I had a desire to capture time-lapse video of some construction that would take an unknown amount of time and occurred mostly during short periods of activity separated by intervals of inactivity with varying time. Because the overall recording time was unknown, this represented an interesting set of challenges that I wrote some software to address, using a Raspberry Pi and a USB webcam. (See the end of this article for the complete source.)&lt;/p>
&lt;h2 id="existing-options">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#existing-options">Existing options&lt;/a>
&lt;/h2>
&lt;p>There is no shortage of articles around the web describing how to capture time lapse videos with a Raspberry Pi, such as &lt;a href="https://www.tomshardware.com/how-to/raspberry-pi-time-lapse-video">Caroline Dunn&amp;rsquo;s article in Tom&amp;rsquo;s Hardware&lt;/a>. I find that most of these involve some kind of script to take still images at intervals, and another component (usually using &lt;a href="https://ffmpeg.org/">ffmpeg&lt;/a>) to combine those images into a video.&lt;/p>
&lt;p>Although this approach works fine, it suffers from several obvious shortcomings:&lt;/p>
&lt;ul>
&lt;li>Frames stored as individual image files tend to require much more space to store them than an equivalent video file, since each frame will usually be very similar to the preceding ones: video codecs are designed to take advantage of this redundancy.&lt;/li>
&lt;li>Captured images and generated videos must be manually retrieved (or deleted) on capture completion. For long captures or when generating multiple sequences, this may require regular human intervention to ensure the system is still running and storage is still available.&lt;/li>
&lt;/ul>
&lt;p>Since the events I wished to capture would take an unknown amount of time (possibly months), it was important to me that the system should be reliable and not require regular (manual) maintenance as well as not use more storage space than necessary. To that end, I chose to write my own set of scripts that is described by the rest of this article.&lt;/p>
&lt;h3 id="available-hardware">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#available-hardware">Available hardware&lt;/a>
&lt;/h3>
&lt;p>The hardware I had available amounted to a Raspberry Pi 2 (and a USB WiFi adapter) with 32GB microSD card as boot volume, a Logitech C925e USB webcam, and extension cords and power supply suitable to place the system wherever I needed. The Pi 2 is a rather dated and slow machine by now, but ought to be sufficient for this application.&lt;/p>
&lt;h2 id="video-capture">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#video-capture">Video capture&lt;/a>
&lt;/h2>
&lt;p>The Logitech C925e advertises support for 1080p video at 30 frames per second, which is a much higher framerate than I need for time-lapse video capture (around one frame per second seems fine) but a respectable resolution. Investigating the camera&amp;rsquo;s actual capabilities shows it&amp;rsquo;s actually capable of higher resolution than that:&lt;/p>
&lt;pre tabindex="0">&lt;code>$ ffmpeg -f v4l2 -list_formats all -i /dev/video0
[video4linux2,v4l2 @ 0x1083cd0] Raw : yuyv422 : YUYV 4:2:2 : 640x480 160x90 160x120 176x144 320x180 320x240 352x288 432x240 640x360 800x448 800x600 864x480 960x720 1024x576 1280x720 1600x896 1920x1080 2304x1296 2304x1536
[video4linux2,v4l2 @ 0x1083cd0] Compressed: mjpeg : Motion-JPEG : 640x480 160x90 160x120 176x144 320x180 320x240 352x288 432x240 640x360 800x448 800x600 864x480 960x720 1024x576 1280x720 1600x896 1920x1080&lt;/code>&lt;/pre>
&lt;p>This output indicates that the camera supports output as either raw video or Motion JPEG in a variety of resolutions up to 1920x1080 pixels. Raw video can also be output at higher resolutions up to 2304x1536, but at a lower framerate (around 12 fps, it turns out). Since I didn&amp;rsquo;t care about any framerate higher than about 1 fps, it was a logical choice to run at that higher resolution.&lt;/p>
&lt;hr>
&lt;p>The basic use of ffmpeg for this application uses a V4L2 device as input (the webcam) and outputs at a very low framerate to &lt;code>timelapse.mp4&lt;/code>:&lt;/p>
&lt;pre tabindex="0">&lt;code>ffmpeg -i v4l2 -video_size 2304x1536 -i /dev/video0 \
-vf fps=0.1 -t 60 timelapse.mp4&lt;/code>&lt;/pre>
&lt;p>In this instance I&amp;rsquo;ve captured video at one frame per 10 seconds (&lt;code>-vf fps=0.1&lt;/code>) and chosen to capture one minute of video (&lt;code>-t 60&lt;/code>). In the final script these are configurable, but this illustrates the concept nicely.&lt;/p>
&lt;p>Although the required framerate for video encoding is low in this application, video encode performance remains a concern because the Raspberry Pi 2 is not a very powerful computer. I wanted to use a video codec that achieves good compression, and it needed to do so with enough speed that frames can be encoded at least as frequently as they are captured.&lt;/p>
&lt;p>Some versions of the Raspberry Pi support hardware-accelerated H.264 encoding (in a quick search, it&amp;rsquo;s unclear if that includes the Pi 2), but I didn&amp;rsquo;t try to make that work: there doesn&amp;rsquo;t seem to be support for the relevant hardware in ffmpeg so I would have needed to use some other software to do the encoding, and it&amp;rsquo;s unclear what the hardware encoder&amp;rsquo;s limitations are (such as maximum resolution). I instead did some manual experimentation by doing a live video capture with assorted codecs:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.videolan.org/developers/x264.html">H.264&lt;/a> (&lt;code>x264&lt;/code>)&lt;/li>
&lt;li>&lt;a href="https://datatracker.ietf.org/doc/html/rfc6386">VP8&lt;/a> (&lt;code>libvpx&lt;/code>)&lt;/li>
&lt;li>&lt;a href="https://www.videolan.org/developers/x265.html">H.265&lt;/a> (&lt;code>x265&lt;/code>)&lt;/li>
&lt;li>&lt;a href="https://www.webmproject.org/vp9/">VP9&lt;/a> (&lt;code>libvpx-vp9&lt;/code>)&lt;/li>
&lt;/ul>
&lt;p>VP9 and H.265 are attractive choices because they are very good at efficiently compressing video, but I found performance to be too bad to be usable for this application (achieving less than 0.1 frames per second at the target resolution). Both x264 and VP8 perform acceptably and achieve similar compression, so I opted to use VP8 since it&amp;rsquo;s not legally encumbered by any patent licensing requirements (unlike H.264).&lt;/p>
&lt;p>I somewhat arbitrarily chose a maximum bitrate of 20 megabits per second and &lt;a href="https://trac.ffmpeg.org/wiki/Encode/VP8">CRF&lt;/a> of 4 to achieve a high-quality encode, and ended up with this &lt;code>ffmpeg&lt;/code> invocation:&lt;/p>
&lt;pre tabindex="0">&lt;code>ffmpeg -i v4l2 -video_size 2304x1536 -i /dev/video0 \
-an -c:v libvpx -b:v 20M -crf 4 \
-vf fps=0.1 -t 60 timelapse.mp4&lt;/code>&lt;/pre>
&lt;hr>
&lt;p>With a way to use ffmpeg to capture time-lapse video directly (no intermediate image files!), we can move to thinking about where video will be stored.&lt;/p>
&lt;h2 id="storage-considerations">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#storage-considerations">Storage considerations&lt;/a>
&lt;/h2>
&lt;p>As mentioned earlier, I only have a 32GB SD card at hand for this Raspberry Pi, and I don&amp;rsquo;t trust it not to corrupt data without warning (both the Pi itself and the card; I don&amp;rsquo;t really trust either with my data) so it seemed important to ensure that the Pi&amp;rsquo;s local storage would not fill up with video and prevent further recording, as well as to copy data off the Pi shortly after its creation. I chose to address this by having the system upload video to Google Cloud Storage (GCS). There&amp;rsquo;s nothing particularly special about GCS over one of the many other object storage systems available from many different service providers; it was just convenient for me to use GCS.&lt;/p>
&lt;p>I didn&amp;rsquo;t want to completely clean up video periodically (by deleting old files) in case of an upload failure, and it&amp;rsquo;s useful to get more frequent feedback on how video capture is going in the form of segments that can be viewed immediately so I also wanted to have the system incrementally upload video as it is captured rather than uploading larger chunks at long intervals (say, every day). Incremental upload also helps reduce the bandwidth needs of the system, since the total data transfer is spread over a longer interval.&lt;/p>
&lt;p>As discussed &lt;a href="#video-capture">above&lt;/a>, by capturing video at a low frame rate rather than individual images at the same rate we can save storage space, improve picture quality, or possibly both. Incrementally uploading video files is somewhat more challenging than handling a similar collection of still images however, since still images have a convenient 1:1 relation to the captured frames (so it&amp;rsquo;s easy to assume that a file&amp;rsquo;s existence implies a complete frame) whereas video files become larger over time as frames are added.&lt;/p>
&lt;h3 id="incremental-upload">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#incremental-upload">Incremental upload&lt;/a>
&lt;/h3>
&lt;p>To incrementally upload video, we need to choose a container that remains valid when more data is appended to it, then design a method to efficiently append new data to what&amp;rsquo;s already present in remote storage.&lt;/p>
&lt;h4 id="container-choice">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#container-choice">Container choice&lt;/a>
&lt;/h4>
&lt;p>ISO MPEG-4 containers (&lt;code>.mp4&lt;/code> files) are a common choice for video files and are supported by most software. However this container is not very well-suited to this application because by default some metadata (the &lt;code>MOOV&lt;/code> atom) gets placed at the end of the file. ffmpeg can put that at the beginning of a file to make a file &amp;ldquo;streamable&amp;rdquo; by using the &lt;code>-movflags faststart&lt;/code> option, but that doesn&amp;rsquo;t really solve the live capture problem because the metadata stored in the &lt;code>MOOV&lt;/code> atom that the &lt;code>faststart&lt;/code> option moves around needs to be derived from the entire encoded file: ffmpeg implements &lt;code>faststart&lt;/code> simply by outputting a file, then moving the &lt;code>MOOV&lt;/code> block to the beginning and copying the rest of the file to follow it. Since this requires the entire input be available first, it is not appropriate for a pseudo-live stream.&lt;/p>
&lt;p>The &lt;a href="https://www.matroska.org/index.html">Matroska&lt;/a> container (&lt;code>.mkv&lt;/code>, and also conventionally used for &lt;code>webm&lt;/code>) on the other hand turns out to work well for this application: I found that ffmpeg does update some headers at the beginning of a Matroska file when it stops encoding (similar to what it does for mp4 fast start), but the fields that get populated are not required to decode the video: they appear to only contain things like the total video length, which decoders do not require. In some experiments, I found that other programs were happy to play back a Matroska video that I had copied while ffmpeg was encoding it, even when capturing live video without a defined duration; they simply stopped playback on reaching the end of the data. In some situations players failed to report the overall video length or showed a wrong duration when asked to decode such a truncated file, but they were still able to play back everything that was present.&lt;/p>
&lt;h4 id="gcs-incremental">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#gcs-incremental">gcs-incremental&lt;/a>
&lt;/h4>
&lt;p>Recalling that I chose to use Google Cloud Storage to store captured video, &lt;a href="https://cloud.google.com/storage/docs/gsutil">&lt;code>gsutil&lt;/code>&lt;/a> is a convenient way to interface with the object storage system from shell scripts. Since &lt;code>ffmpeg&lt;/code> is also easily driven from a shell script, the default choice for implementing the entire system was also shell, rather than some other (perhaps less quirky) programming language.&lt;/p>
&lt;p>To implement incremental upload of files, the general algorithm for copying a &amp;lsquo;source&amp;rsquo; file on the local system to a &amp;lsquo;destination&amp;rsquo; file on remote storage can be expressed as follows (assuming, as we established in the previous section, that files are only appended to):&lt;/p>
&lt;ol>
&lt;li>Check whether &lt;code>destination file&lt;/code> exists
&lt;ul>
&lt;li>If no, copy entire &lt;code>source file&lt;/code> to &lt;code>destination file&lt;/code> and exit&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Get size of &lt;code>destination file&lt;/code>
&lt;ul>
&lt;li>If same size as &lt;code>source file&lt;/code>, do nothing and exit&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Append bytes from &lt;code>source file&lt;/code> starting at offset &lt;code>remote size&lt;/code> to &lt;code>destination file&lt;/code>&lt;/li>
&lt;/ol>
&lt;p>Somewhat problematically, in most object storage systems like Google Cloud Storage, &amp;ldquo;objects&amp;rdquo; (files in our abstraction) are immutable: it is not possible to modify an object in place. Making changes to an existing object will then usually involve making a copy of the object with the changes applied, and doing so is most obviously implemented by downloading the original and making changes, then uploading the changed version (possibly replacing the original object).&lt;/p>
&lt;p>It should be obvious that appending to a file stored on GCS by downloading it and re-uploading doesn&amp;rsquo;t achieve the goal of incremental upload, since in that case we could simply upload the entire local file. Fortunately, it&amp;rsquo;s possible to &lt;a href="https://cloud.google.com/storage/docs/composite-objects">&amp;ldquo;compose&amp;rdquo; an object from multiple pieces&lt;/a>: given two objects, &lt;code>gsutil compose&lt;/code> can be used to concatenate them into a single object without making a copy of either. With that operation, appending to a file for incremental upload is simply a matter of uploading the new data as a new object, then performing a &lt;code>compose&lt;/code> operation to add it to the original object.&lt;/p>
&lt;hr>
&lt;p>The following shell script implements this incremental upload; I call it &lt;code>gcs-incremental&lt;/code>. When passed the path to a local file and a location on Cloud Storage, it implements the algorithm described above.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">#!/bin/bash -e
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">SOURCE_FILE&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#008080">$1&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">GS_PATH&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#008080">$2&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">DEST_FILE&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">GS_PATH&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">/&lt;/span>&lt;span style="color:#000;font-weight:bold">$(&lt;/span>basename &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">SOURCE_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#000;font-weight:bold">)&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>info&lt;span style="color:#000;font-weight:bold">()&lt;/span> &lt;span style="color:#000;font-weight:bold">{&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">echo&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#008080">$@&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &amp;gt;&amp;amp;&lt;span style="color:#099">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">if&lt;/span> ! gsutil -q stat &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">DEST_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>; &lt;span style="color:#000;font-weight:bold">then&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> info &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">DEST_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14"> does not exist; uploading entire file&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gsutil cp &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">SOURCE_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">DEST_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">else&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#008080">SOURCE_SIZE&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">$(&lt;/span>stat --format&lt;span style="color:#000;font-weight:bold">=&lt;/span>%s &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#008080">$SOURCE_FILE&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#000;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#008080">DEST_SIZE&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">$(&lt;/span>gsutil stat &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">DEST_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> | awk &lt;span style="color:#d14">&amp;#39;$1 == &amp;#34;Content-Length:&amp;#34; { print $2 }&amp;#39;&lt;/span> &lt;span style="color:#000;font-weight:bold">||&lt;/span> &lt;span style="color:#0086b3">echo&lt;/span> 0&lt;span style="color:#000;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#008080">TO_UPLOAD_SIZE&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">$((&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">SOURCE_SIZE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#000;font-weight:bold">-&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">DEST_SIZE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#000;font-weight:bold">))&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#008080">PART_FILE&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">DEST_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">.part.&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">SOURCE_SIZE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#000;font-weight:bold">[&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">TO_UPLOAD_SIZE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#099">0&lt;/span> &lt;span style="color:#000;font-weight:bold">]&lt;/span>; &lt;span style="color:#000;font-weight:bold">then&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> info &lt;span style="color:#d14">&amp;#34;Nothing to upload; stopping&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">exit&lt;/span> &lt;span style="color:#099">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">fi&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> info &lt;span style="color:#d14">&amp;#34;Uploading &lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">TO_UPLOAD_SIZE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14"> bytes..&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> dd &lt;span style="color:#000;font-weight:bold">if&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">SOURCE_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#008080">skip&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">DEST_SIZE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#008080">count&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">TO_UPLOAD_SIZE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#008080">iflag&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>skip_bytes,count_bytes &lt;span style="color:#d14">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14">&lt;/span> | gsutil -q cp - &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">PART_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gsutil compose &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">DEST_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">PART_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">DEST_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gsutil rm &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">PART_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> fallocate --punch-hole --offset &lt;span style="color:#099">0&lt;/span> --length &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">SOURCE_SIZE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">SOURCE_FILE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">fi&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>There are several aspects of this implementation worth noting:&lt;/p>
&lt;ul>
&lt;li>Getting the size of a file on GCS is slightly tricky because &lt;code>gsutil stat&lt;/code> prints file properties in a format similar to HTTP headers. It&amp;rsquo;s not too hard to extract a number with &lt;code>awk&lt;/code>.&lt;/li>
&lt;li>&lt;code>gsutil&lt;/code> doesn&amp;rsquo;t provide a way to select only part of a file to upload, so we use &lt;code>dd&lt;/code> to read part of the file and pipe the data to &lt;code>gsutil&lt;/code>. Use of a pipe prevents &lt;a href="https://cloud.google.com/storage/docs/parallel-composite-uploads">parallelization of the upload&lt;/a> so performance is limited somewhat.&lt;/li>
&lt;li>In order to compose the old and new file parts, we need to write a temporary file. This script assumes that a suffix of &lt;code>.part&lt;/code> and an integer is sufficiently unique to avoid potential conflicts, but it would probably misbehave if multiple uploaders were trying to update the same file.&lt;/li>
&lt;li>After a chunk of a file is uploaded, that data is &lt;strong>erased from the local disk&lt;/strong> by using &lt;code>fallocate&lt;/code> to punch a hole in the file, replacing all of the data that&amp;rsquo;s been uploaded with zeroes and freeing any space on disk that it used.&lt;/li>
&lt;/ul>
&lt;p>The hole-punching in the source file is what allows the overall time-lapse capture system to assume that the amount of storage available is not a concern. Files that have been uploaded will remain on disk but consume essentially no space while retaining their original size, making it easy to tell whether a file has been uploaded in its entirety even after the fact. Failed uploads may cause increased disk usage (because holes will not be punched), but no loss of data so they can be retried later.&lt;/p>
&lt;p>Having implemented mechanisms to capture video (easily done with &lt;code>ffmpeg&lt;/code>) and to incrementally upload those videos to GCS (&lt;code>gcs-incremental&lt;/code>), what remains is to combine them into something that will capture video and incrementally upload it.&lt;/p>
&lt;h2 id="timelapser">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#timelapser">&lt;code>timelapser&lt;/code>&lt;/a>
&lt;/h2>
&lt;p>Putting everything together into one script, the intended function can be summarized as follows:&lt;/p>
&lt;ul>
&lt;li>Capture video with ffmpeg for a chosen duration
&lt;ul>
&lt;li>In a loop, run &lt;code>ffmpeg&lt;/code> and emit to a file with a chosen name with capture duration set to &lt;code>total duration&lt;/code> - &lt;code>elapsed duration&lt;/code>&lt;/li>
&lt;li>After the specified time is elapsed, ensure videos are uploaded and exit&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Periodically do an incremental upload of captured video files, stopping once video capture ends&lt;/li>
&lt;/ul>
&lt;h3 id="capture-task">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#capture-task">Capture task&lt;/a>
&lt;/h3>
&lt;p>The video capture itself is fairly easy to write. Assuming a few variables specifying things like how long to capture for, what video device to use as input, and what framerate to output, I ended up with these shell functions:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>now&lt;span style="color:#000;font-weight:bold">()&lt;/span> &lt;span style="color:#000;font-weight:bold">{&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> date +%s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>run_capture&lt;span style="color:#000;font-weight:bold">()&lt;/span> &lt;span style="color:#000;font-weight:bold">{&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">local&lt;/span> &lt;span style="color:#008080">framerate&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">$(&lt;/span>&lt;span style="color:#0086b3">echo&lt;/span> &lt;span style="color:#d14">&amp;#34;scale=4;1/&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">SECONDS_PER_FRAME&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> | bc&lt;span style="color:#000;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">local&lt;/span> &lt;span style="color:#008080">i&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">local&lt;/span> &lt;span style="color:#008080">current_time&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">$(&lt;/span>now&lt;span style="color:#000;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">local&lt;/span> &lt;span style="color:#008080">end_time&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">$((&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">current_time&lt;/span>&lt;span style="color:#d14">}&lt;/span> &lt;span style="color:#000;font-weight:bold">+&lt;/span> &lt;span style="color:#000;font-weight:bold">(&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">RUNTIME_MINUTES&lt;/span>&lt;span style="color:#d14">}&lt;/span> &lt;span style="color:#000;font-weight:bold">*&lt;/span> &lt;span style="color:#099">60&lt;/span>&lt;span style="color:#000;font-weight:bold">))&lt;/span>&lt;span style="color:#000;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Run ffmpeg in a loop in case of premature exit/failure, targeting&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># one segment that goes until the end time.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">echo&lt;/span> &lt;span style="color:#d14">&amp;#34;Starting capture for &lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">RUNTIME_MINUTES&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14"> minutes, until timestamp &lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">end_time&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">while&lt;/span> &lt;span style="color:#000;font-weight:bold">[&lt;/span> &lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">current_time&lt;/span>&lt;span style="color:#d14">}&lt;/span> -lt &lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">end_time&lt;/span>&lt;span style="color:#d14">}&lt;/span> &lt;span style="color:#000;font-weight:bold">]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ffmpeg -n -loglevel error &lt;span style="color:#d14">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14">&lt;/span> -f v4l2 -video_size &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">VIDEO_RESOLUTION&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> -i &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">VIDEO_DEVICE&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#d14">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14">&lt;/span> -t &lt;span style="color:#000;font-weight:bold">$((&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">end_time&lt;/span>&lt;span style="color:#d14">}&lt;/span> &lt;span style="color:#000;font-weight:bold">-&lt;/span> &lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">current_time&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#000;font-weight:bold">))&lt;/span> &lt;span style="color:#d14">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14">&lt;/span> -vf &lt;span style="color:#008080">fps&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">framerate&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> -an -c:v libvpx -b:v 20M -crf &lt;span style="color:#099">4&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">i&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">.mkv&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Increment file serial number to avoid overwriting old data&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">let&lt;/span> &lt;span style="color:#008080">i&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>i+1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#008080">current_time&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">$(&lt;/span>now&lt;span style="color:#000;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">echo&lt;/span> &lt;span style="color:#d14">&amp;#34;Running for &lt;/span>&lt;span style="color:#008080">$RUNTIME_MINUTES&lt;/span>&lt;span style="color:#d14"> minutes&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>run_capture &amp;amp; &lt;span style="color:#008080">capture_pid&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#008080">$!&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>&lt;code>now&lt;/code> provides the current UNIX time, which is convenient for computing the total amount of time capture has been running. &lt;code>run_capture&lt;/code> assumes its working directory is appropriate for storing video and captures a sequence of files &lt;code>0.mkv&lt;/code>, &lt;code>1.mkv&lt;/code> and so forth. Capturing a sequence of files ensures that if some transient error occurs (perhaps if the camera is accidentally unplugged) capture will resume without overwriting any older data.&lt;/p>
&lt;p>Because the script needs to also run periodic uploads, &lt;code>run_capture&lt;/code> is started in the background and its PID is saved so its status can be polled later, in particular for checking whether it has completed and exited.&lt;/p>
&lt;h3 id="upload-task">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#upload-task">Upload task&lt;/a>
&lt;/h3>
&lt;p>Periodically doing an incremental upload is slightly more interesting, because I wanted to upload video parts at a regular cadence without any particular dependence on how long the upload takes. A naive version might implement a simple algorithm:&lt;/p>
&lt;ol>
&lt;li>Wait for &lt;code>interval&lt;/code>&lt;/li>
&lt;li>Do incremental upload&lt;/li>
&lt;li>If capture is still running, goto 1&lt;/li>
&lt;/ol>
&lt;p>If it takes any meaningful amount of time to perform the upload however, the uploads will be separated by the chosen interval and occur less frequently than intended. This is probably actually fine, but I chose to get clever with it to achieve a regular cadence.&lt;/p>
&lt;h3 id="queueing">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#queueing">Queueing&lt;/a>
&lt;/h3>
&lt;p>If uploads should occur at intervals without regard for how long they take to complete, this implies there must be two concurrent processes: one that waits for intervals to expire (the &amp;ldquo;timer&amp;rdquo; task) and another that actually does the upload (the &amp;ldquo;uploader&amp;rdquo; task). This becomes difficult when we recall that &lt;code>gcs-incremental&lt;/code> cannot be expected to work correctly if invoked in parallel, since this implies there must be some mechanism to synchronize uploads both between incremental uploads and the final upload that runs once capture completes.&lt;/p>
&lt;p>A reasonably simple approach to this problem in more capable (than shell scripting) programming languages is to use a multi-producer queue: the uploader task can pull upload jobs out of a queue and execute them serially, while the timer and capture tasks place new jobs into the queue as appropriate (at intervals and once capture completes, respectively).&lt;/p>
&lt;p>In a shell script, I realized it&amp;rsquo;s possible to implement a queue with a pipe: if the receiver reads lines from a pipe in a loop until closed, other tasks can write lines to the pipe which will be processed in sequence. I ended up with this code for the receiver:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>segment_uploader&lt;span style="color:#000;font-weight:bold">()&lt;/span> &lt;span style="color:#000;font-weight:bold">{&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">while&lt;/span> &lt;span style="color:#0086b3">read&lt;/span> n; &lt;span style="color:#000;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">echo&lt;/span> Segment trigger &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#008080">$n&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> f in *.mkv; &lt;span style="color:#000;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> info &lt;span style="color:#d14">&amp;#34;Do incremental upload of file &lt;/span>&lt;span style="color:#008080">$f&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> gcs-incremental &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#008080">$f&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">GS_PATH&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> info &lt;span style="color:#d14">&amp;#34;Segment upload completed (for now)&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Run another task to scan and upload segments, which we signal by sending&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># a message through a named pipe.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">msgpipe&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">$(&lt;/span>mktemp -u&lt;span style="color:#000;font-weight:bold">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkfifo -m &lt;span style="color:#099">600&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">msgpipe&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Run a task that does nothing but holds the pipe open; killing this&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># pipe holder will terminate the uploader when it reaches EOF (having processed&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># anything that was already put into the FIFO).&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>sleep infinity &amp;gt;&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">msgpipe&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &amp;amp; &lt;span style="color:#008080">pipe_holder_pid&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#008080">$!&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>segment_uploader &amp;lt;&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">msgpipe&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &amp;amp; &lt;span style="color:#008080">uploader_pid&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#008080">$!&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>trigger_upload&lt;span style="color:#000;font-weight:bold">()&lt;/span> &lt;span style="color:#000;font-weight:bold">{&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> now &amp;gt; &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">msgpipe&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>We create a named pipe &lt;code>msgpipe&lt;/code> and direct data from that pipe to the input of &lt;code>segment_uploader&lt;/code>. The &lt;code>while read n&lt;/code> loop will read lines from the input and stop once the input is closed, running &lt;code>gcs-incremental&lt;/code> over all of the video files in its working directory.&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup> Invoking the &lt;code>trigger_upload&lt;/code> function will queue uploads of all current files.&lt;/p>
&lt;p>The &lt;code>pipe_holder&lt;/code> is an unusual component that is required only as a result of POSIX named pipe semantics: the read end of a pipe is closed once all writers disconnect, which in this application would be after the first segment upload is triggered because &lt;code>trigger_upload&lt;/code> opens the pipe, writes to it and closes it again. The presence of the &lt;code>pipe_holder&lt;/code> prevents the uploader task from exiting until the &lt;code>pipe_holder&lt;/code> itself exits.&lt;/p>
&lt;hr>
&lt;p>Because it may be possible for concurrent writes to the pipe to be accidentally interleaved, I forced invocations of &lt;code>trigger_upload&lt;/code> to be serialized through use of signals:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>interruptible_sleep&lt;span style="color:#000;font-weight:bold">()&lt;/span> &lt;span style="color:#000;font-weight:bold">{&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Sleep in the background to make the sleep interruptible; waiting on a&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># foreground process isn&amp;#39;t interruptible, but the wait builtin is.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sleep &lt;span style="color:#008080">$@&lt;/span> &amp;amp;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">wait&lt;/span> &lt;span style="color:#008080">$!&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">trap&lt;/span> trigger_upload USR1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">{&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">while&lt;/span> :; &lt;span style="color:#000;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> interruptible_sleep &lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">SEGMENTS_INTERVAL&lt;/span>&lt;span style="color:#d14">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">kill&lt;/span> -USR1 &lt;span style="color:#008080">$$&lt;/span> &amp;gt;/dev/null
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">}&lt;/span> &amp;amp; &lt;span style="color:#008080">waker_pid&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#008080">$!&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The loop represented by &lt;code>waker_pid&lt;/code> simply waits at intervals and sends &lt;code>SIGUSR1&lt;/code> to the main script. We &lt;code>trap&lt;/code> that signal and in response execute &lt;code>trigger_upload&lt;/code> that writes to the pipe. Reception of this signal can interrupt a &lt;code>wait&lt;/code> as embodied in the &lt;code>interruptible_sleep&lt;/code> function and asynchronously trigger an upload, but it is guaranteed by the system (through the general semantics of signals) that only one handler will execute at a time.&lt;/p>
&lt;h3 id="capture-completion">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#capture-completion">Capture completion&lt;/a>
&lt;/h3>
&lt;p>The final piece is to wait for capture to complete and trigger one final upload. Since the PID of the capture task was stored, this is as simple as &lt;code>wait&lt;/code>ing on it in a loop:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Wait for capture to finish&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">while&lt;/span> true; &lt;span style="color:#000;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">wait&lt;/span> &lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">capture_pid&lt;/span>&lt;span style="color:#d14">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#008080">wait_status&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#008080">$?&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#000;font-weight:bold">[&lt;/span> &lt;span style="color:#008080">$wait_status&lt;/span> -lt &lt;span style="color:#099">128&lt;/span> &lt;span style="color:#000;font-weight:bold">]&lt;/span>; &lt;span style="color:#000;font-weight:bold">then&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">echo&lt;/span> &lt;span style="color:#d14">&amp;#34;capture task exited with status &lt;/span>&lt;span style="color:#008080">$wait_status&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">break&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">fi&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">done&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The dance with &lt;code>wait_status&lt;/code> here is required because &lt;code>wait&lt;/code> can be interrupted by a signal, and in fact we expect it to be periodically interrupted by a &lt;code>SIGUSR1&lt;/code> when we want to trigger an upload (as described in the previous section). In this situation the return code of &lt;code>wait&lt;/code> is documented to be 128 or greater, so only when the return code is less than 128 is the capture task known to have exited.&lt;/p>
&lt;p>Once the capture task exits, all that remains is to clean up and ensure all data has been uploaded:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Terminate the waker&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">kill&lt;/span> &lt;span style="color:#008080">$waker_pid&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Run a final segment upload&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>trigger_upload
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Terminate the pipe holder to close the write end of msgpipe; wait for the&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># uploader to complete then exit.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">kill&lt;/span> &lt;span style="color:#008080">$pipe_holder_pid&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">wait&lt;/span> &lt;span style="color:#008080">$pipe_holder_pid&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">echo&lt;/span> &lt;span style="color:#d14">&amp;#34;Waiting up to 1 hour for uploader to finish processing..&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">(&lt;/span>sleep 1h; &lt;span style="color:#0086b3">kill&lt;/span> -HUP &lt;span style="color:#008080">$$&lt;/span>&lt;span style="color:#000;font-weight:bold">)&lt;/span> &amp;amp; &lt;span style="color:#0086b3">wait&lt;/span> &lt;span style="color:#008080">$uploader_pid&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">echo&lt;/span> &lt;span style="color:#d14">&amp;#34;Done!&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>We first terminate the waker task to prevent any more uploads from being triggered, and trigger a final upload. Since the capture task has exited by this point, this upload is guaranteed to see all the data that will ever exist.&lt;/p>
&lt;p>After triggering the upload we kill the &lt;code>pipe_holder&lt;/code>, closing the &lt;code>msgpipe&lt;/code> which will make the uploader exit once it processes everything remaining in the pipe. To avoid waiting forever if there&amp;rsquo;s a problem while uploading, I chose to wait only up to an hour for it to complete before exiting.&lt;/p>
&lt;h3 id="configuration">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#configuration">Configuration&lt;/a>
&lt;/h3>
&lt;p>As discussed earlier, there are a few variables set at the top of the script guiding script operation. These mostly configure how video should be captured, but also specify the location in GCS for data storage:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># The following variables may be overridden at runtime&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">RUNTIME_MINUTES&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">SECONDS_PER_FRAME&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">5&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">VIDEO_DEVICE&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>/dev/video0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Supported resolutions can be found interactively:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># ffmpeg -f v4l2 -list_formats all -i ${VIDEO_DEVICE}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">VIDEO_RESOLUTION&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;1280x720&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">SEGMENTS_INTERVAL&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>1m
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">GS_PATH&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;gs://test-videos-01/&lt;/span>&lt;span style="color:#000;font-weight:bold">$(&lt;/span>date --rfc-3339&lt;span style="color:#000;font-weight:bold">=&lt;/span>date&lt;span style="color:#000;font-weight:bold">)&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Because it&amp;rsquo;s useful to be able to change these without modifying the script, I opted to make it take paths on the command line which indicate files that &lt;code>timelapser&lt;/code> will execute during startup.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>info&lt;span style="color:#000;font-weight:bold">()&lt;/span> &lt;span style="color:#000;font-weight:bold">{&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">echo&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#008080">$@&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &amp;gt;&amp;amp;&lt;span style="color:#099">2&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">while&lt;/span> &lt;span style="color:#000;font-weight:bold">[&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#008080">$#&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> -gt &lt;span style="color:#099">0&lt;/span> &lt;span style="color:#000;font-weight:bold">]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> info &lt;span style="color:#d14">&amp;#34;Loading configuration from &lt;/span>&lt;span style="color:#008080">$1&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">source&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#008080">$1&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">shift&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">done&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>&lt;code>source&lt;/code>ing configuration files permits arbitrary configuration to be easily written and doesn&amp;rsquo;t require any special parsing, which is convenient. The configuration I ended up using for actual video capture looks like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">RUNTIME_MINUTES&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">$((&lt;/span>&lt;span style="color:#099">60&lt;/span> &lt;span style="color:#000;font-weight:bold">*&lt;/span> &lt;span style="color:#099">9&lt;/span>&lt;span style="color:#000;font-weight:bold">))&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">VIDEO_DEVICE&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>/dev/video0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">VIDEO_RESOLUTION&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>2304x1536
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">SECONDS_PER_FRAME&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">10&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">SEGMENTS_INTERVAL&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>10m
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">today&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#000;font-weight:bold">$(&lt;/span>date --rfc-3339&lt;span style="color:#000;font-weight:bold">=&lt;/span>date&lt;span style="color:#000;font-weight:bold">)&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">GS_PATH&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;gs://my-timelapse-video/&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">today&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mkdir -p &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">HOME&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">/&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">today&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">cd&lt;/span> &lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">HOME&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">/&lt;/span>&lt;span style="color:#d14">${&lt;/span>&lt;span style="color:#008080">today&lt;/span>&lt;span style="color:#d14">}&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>This runs captures for 9 hours at 0.1 fps, uploading video segments every 10 minutes. Because video is captured to the working directory, the configuration ensures that a directory named for the current time is created to store video, and the same directory name is used in the remote storage. Using a directory name based on the start time allows video segments to be easily correlated with when they were actually captured.&lt;/p>
&lt;h2 id="automation">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#automation">Automation&lt;/a>
&lt;/h2>
&lt;p>To run the system automatically, I set up some systemd units that will capture a video every day during working hours. A timer that triggers on weekdays:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ini" data-lang="ini">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Timer]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">OnCalendar&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">Mon..Fri 09:00:00&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Persistent&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">true&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Install]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">WantedBy&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">multi-user.target&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>And the matching service that is started by the timer:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ini" data-lang="ini">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Description&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">Capture time-lapse videos&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Service]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">User&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">timelapser&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Type&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">simple&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ExecStart&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">/usr/bin/timelapser /etc/timelapser.conf&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The service runs the script as its own user which is not really required, but is convenient for confining the effects of video capture to a well-defined space (mostly that user&amp;rsquo;s home directory).&lt;/p>
&lt;h3 id="packaging">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#packaging">Packaging&lt;/a>
&lt;/h3>
&lt;p>I wanted to make it easy to deploy and manage the scripts, in particular to be able to more easily handle changes and deploy the scripts to a fresh Pi in the future should I desire. The easy approach to deployment is to simply copy the scripts to the machine to run them, but it&amp;rsquo;s somewhat easier at deployment-time to use the OS package manager. Since Debian derivatives are usually used on Raspberry Pis, I spent some time learning how to create Debian packages and constructed a &lt;code>timelapser&lt;/code> package containing the scripts and configuration needed to run this system.&lt;/p>
&lt;p>The package does the following:&lt;/p>
&lt;ul>
&lt;li>Install &lt;code>timelapser&lt;/code> and &lt;code>gcs-incremental&lt;/code> scripts to &lt;code>/usr/bin&lt;/code>&lt;/li>
&lt;li>Install the systemd units to &lt;code>/usr/lib/systemd/system&lt;/code>&lt;/li>
&lt;li>Install the sample configuration to &lt;code>/etc/timelapser.conf&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>It does not currently create the user or attempt to configure &lt;code>gsutil&lt;/code>, so post-install operations should probably include:&lt;/p>
&lt;ul>
&lt;li>Create the user and allow access to video devices: &lt;code>adduser timelapser --ingroup video&lt;/code> (optionally choosing a non-default location for the home directory and so forth)&lt;/li>
&lt;li>Log in to a Google cloud account for storage access: &lt;code>sudo -u timelapser&lt;/code> &lt;a href="https://cloud.google.com/sdk/gcloud/reference/auth/login">&lt;code>gcloud auth login&lt;/code>&lt;/a>&lt;/li>
&lt;li>Edit &lt;code>/etc/timelapser.conf&lt;/code> to configure video capture options and storage location
&lt;ul>
&lt;li>If a bucket does not already exist, &lt;a href="https://cloud.google.com/storage/docs/creating-buckets">create a bucket&lt;/a> to store uploaded video&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>To change the time at which video is captured, &lt;a href="https://www.freedesktop.org/software/systemd/man/systemctl.html#edit%20UNIT%E2%80%A6">&lt;code>systemctl edit&lt;/code>&lt;/a> &lt;code>timelapser.timer&lt;/code> can be used to override the provided &lt;code>OnCalendar&lt;/code> clause.&lt;/p>
&lt;p>Finally, the usual &lt;code>systemd&lt;/code> commands can be used to start automatically running capture on a schedule:&lt;/p>
&lt;pre>&lt;code>systemctl enable --now timelapser.timer
&lt;/code>&lt;/pre>
&lt;p>Or to run it once then stop (useful for testing configuration):&lt;/p>
&lt;pre>&lt;code>systemctl start timelapser.service
&lt;/code>&lt;/pre>
&lt;h2 id="downloads">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#downloads">Downloads&lt;/a>
&lt;/h2>
&lt;p>Congratulations on making it to the end of this article. Here is the result all in one place, much easier to use as-is than trying to reassemble it from the code fragments in the article.&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://www.taricorp.net/2022/timelapser/timelapser_1.0.tar.xz">timelapser_1.0.tar.xz&lt;/a>&lt;/strong>: complete code and packaging information, buildable with &lt;code>debuild&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://gitlab.com/taricorp/timelapser">gitlab.com/taricorp/timelapser&lt;/a>&lt;/strong>: at time of this writing, the same as the above source tarball hosted on Gitlab. Easier to browse and may receive some updates.&lt;/p>
&lt;h2 id="discussion">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/timelapser/#discussion">Discussion&lt;/a>
&lt;/h2>
&lt;p>Although I&amp;rsquo;m happy with how this system works, some additional work is called for once a complete time-lapse has been captured.&lt;/p>
&lt;p>Because videos are captured such that they play back in real-time (with video duration being equal to the original amount of time over which it was captured), I first combine all the video segments for each day and add a time readout:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">for&lt;/span> f in *.mkv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">echo&lt;/span> &lt;span style="color:#d14">&amp;#34;file &lt;/span>&lt;span style="color:#008080">$f&lt;/span>&lt;span style="color:#d14">&amp;#34;&lt;/span> &amp;gt;&amp;gt; concat.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">done&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ffmpeg -f concat -i concat.txt &lt;span style="color:#d14">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14">&lt;/span> -vf &lt;span style="color:#d14">&amp;#39;drawtext=text=%{pts\\:hms}:fontsize=32:fontcolor=white:borderw=2:x=(w-tw)/2:y=lh,setpts=PTS/600.0&amp;#39;&lt;/span> &lt;span style="color:#d14">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14">&lt;/span> -r &lt;span style="color:#099">60&lt;/span> -f matroska dayfast.mkv&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Using the &lt;code>concat&lt;/code> input format allows days where video capture was interrupted and resumed later (writing to another file) to still result in a single video for the entire day.&lt;/p>
&lt;p>The &lt;code>drawtext&lt;/code> filter applied via the &lt;code>-vf&lt;/code> option takes the timestamp of each frame (starting at 0 at the beginning of the video) and formats it as hours, minutes and seconds then overlays the text on the video at the top-middle. &lt;code>setpts&lt;/code> then takes the same timestamp and divides by 600 (setting the output frame&amp;rsquo;s timestamp to that new value), so the video now plays back 600 times faster than the input.&lt;/p>
&lt;hr>
&lt;p>Since sometimes there are long stretches of &amp;ldquo;nothing&amp;rdquo;, it&amp;rsquo;s useful later to do some filtering of each day&amp;rsquo;s video to drop frames where there&amp;rsquo;s very little change, combining the videos for each day into a single longer video; again using the &lt;code>concat&lt;/code> input format to ffmpeg again and a different set of filters:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>ffmpeg -f concat -i ... &lt;span style="color:#d14">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14">&lt;/span> -vf &lt;span style="color:#d14">&amp;#39;select=gt(scene\,0.02),setpts=N/(30*TB)&amp;#39;&lt;/span> &lt;span style="color:#d14">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14">&lt;/span> out.mkv&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The &lt;code>select&lt;/code> filter will use or discard frames, and &lt;code>gt(scene,0.02)&lt;/code> will only select those frames that differ from the previous frame by more two percent (according to some unspecified &lt;code>scene&lt;/code> metric).&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup> To speed the resultant video up further, &lt;code>setpts=N/(30*TB)&lt;/code> increases speed by a further 30 times.&lt;/p>
&lt;p>Compared to use of &lt;code>PTS&lt;/code> in the earlier example, &lt;code>TB&lt;/code> is used here because input frames are dropped: the &lt;code>PTS&lt;/code> is based on the input frame&amp;rsquo;s time, so if &lt;code>PTS&lt;/code> were used then the time filled by dropped frames would still exist in the output video but the frame itself would not exist (the previous one would continue to be shown). Since the goal of dropping similar frames is to reduce the runtime of the final video while preserving interesting activity, &lt;code>TB&lt;/code> is the better choice.&lt;/p>
&lt;hr>
&lt;p>As I write this conclusion, I&amp;rsquo;ve used this set of scripts to capture two different sets of videos to good effect, each covering more than a month of real time. The results have been satisfactory, and the system has been entirely hands-off aside from initial configuration and disabling it again when I was done, which nicely fulfills the goal of having a system that requires minimal attention.&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>I notice while writing this that the uploader task could be made more efficient by receiving the name of a file to upload rather than a string that is otherwise ignored, which would allow it to inspect only the relevant file rather than all those that exist.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>I arrived at the 2% difference in scene metric by experiment, finding that value wasn&amp;rsquo;t too sensitive (changes in the display time didn&amp;rsquo;t cause frames to be retained, for instance) but also that it didn&amp;rsquo;t seem to drop interesting periods of action.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Marking token boundaries in TI-BASIC with Unicode magic</title><link>https://www.taricorp.net/2022/ti-basic-unicode-splits/</link><pubDate>Thu, 26 May 2022 03:57:39 +0000</pubDate><guid>https://www.taricorp.net/2022/ti-basic-unicode-splits/</guid><description>&lt;p>Users who are accustomed to writing TI-BASIC on computers in plain text like any other programming language are probably familiar with sometimes needing to explicitly mark where token boundaries occur. I&amp;rsquo;ve been doing some thinking about this lately, and have arrived at a proposal for a way to improve the situation for some uses.&lt;/p>
&lt;!-- more -->
&lt;h2 id="ti-basic-background">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/ti-basic-unicode-splits/#ti-basic-background">TI-BASIC Background&lt;/a>
&lt;/h2>
&lt;p>What enthusiasts often refer to as the TI-8x series of calculators is the TI-83+ and its variants, including the improved TI-84+ and color-screen CE (and happily abandoned CSE) versions. These are calculators sold by Texas Instruments and commonly used in middle- and high-school math instruction. The internal architecture of these calculators is based on a Zilog Z80 processor with 512 kB or so of Flash memory and 32 kB of RAM (with larger memories in the newer versions).&lt;/p>
&lt;p>Most of the TI graphing calculators support dialects of BASIC, which are usually referred to as TI-BASIC. The details differ between calculator families, but here I am concerned with the TI-BASIC dialect used on the 8x calculators.&lt;/p>
&lt;p>TI-BASIC is stored on calculators as a sequence of &lt;strong>tokens&lt;/strong>, each of which is one or two bytes long. Each token has a string that it is translated to for display: the token consisting of the bytes &lt;code>0xbb&lt;/code>,&lt;code>0x6d&lt;/code> is displayed as &lt;code>AsmComp&lt;/code>, for instance.&lt;/p>
&lt;p>When programming TI-BASIC on a calculator, code is entered directly as tokens either through select keystrokes or by selecting tokens from menus. While pressing the &lt;kbd>1&lt;/kbd> key enters a &lt;code>1&lt;/code> token, pressing the key sequence &lt;kbd>VARS&lt;/kbd>,&lt;kbd>7&lt;/kbd>,&lt;kbd>1&lt;/kbd> enters the token &lt;code>Str1&lt;/code>. Each syntatic element of the language is unambiguously specified to the calculator as a sequence of keystrokes, even if the representation of a set of tokens as plain text could be interpreted in multiple ways: although the string &amp;ldquo;sin(&amp;rdquo; could be displayed by pressing either the &lt;kbd>sin(&lt;/kbd> key or the sequence &lt;kbd>s&lt;/kbd>,&lt;kbd>i&lt;/kbd>,&lt;kbd>n&lt;/kbd>,&lt;kbd>(&lt;/kbd>, the program as stored on the calculator is unambiguous because it captures which sequence of keys was pressed (in the form of tokens).&lt;/p>
&lt;hr>
&lt;p>In more recent history, people have written tools allowing programmers to write TI-BASIC programs on general-purpose computers and translate them to program files that can be executed by calculators: the major examples of these tools are &lt;a href="https://www.cemetech.net/downloads/files/515">Token&lt;/a>&lt;a href="url=https://www.ticalc.org/archives/files/fileinfo/433/43315.html">IDE&lt;/a> and &lt;a href="https://www.cemetech.net/sc/">SourceCoder&lt;/a>.&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>&lt;/p>
&lt;p>These programming tools support both &lt;em>tokenizing&lt;/em>: translating plain text (which is easy to write on most computers) into tokens and packaging those into program files that can be placed on calculators, as well as the reverse &lt;em>detokenization&lt;/em> operation where a collection of tokens is converted back into text. Typically a program will be tokenized in order to run it, and detokenized if changes are to be made to it (provided a plain-text copy does not already exist). The programs or portions of programs that perform tokenization and detokenization are usually called the tokenizer and detokenizer.&lt;/p>
&lt;h2 id="the-need-for-breaks">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/ti-basic-unicode-splits/#the-need-for-breaks">The need for breaks&lt;/a>
&lt;/h2>
&lt;p>Sometimes a marker needs to be inserted in the plain-text source code that a person writes in order to indicate where a string that might be interpreted as a single token should instead be interpreted as multiple tokens. As discussed above, there is more than one way to write tokens that display as &amp;ldquo;sin(&amp;rdquo; for example: if a programmer is writing text on a computer rather than tokens on a calculator, how can a tool determine which sequence of tokens is desired?&lt;/p>
&lt;p>Perhaps the most common example of needing to do this is when the string &amp;ldquo;pi&amp;rdquo; appears in a program and it must be written as &lt;strong>&amp;ldquo;p\i&amp;rdquo;&lt;/strong> to prevent the tokenizer from converting it to &lt;strong>&amp;ldquo;π&amp;rdquo;&lt;/strong>. Although a calculator has a button for the π symbol, most computer keyboards do not: the tools allow a programmer to write &amp;ldquo;pi&amp;rdquo; instead as a convenience. This approach of offering easier-to-type aliases for tokens that contain unusual characters and inserting a backslash to indicate strings that should be broken into multiple tokens was first used by TokenIDE and later also supported by SourceCoder.&lt;/p>
&lt;hr>
&lt;p>It&amp;rsquo;s surprisingly tricky to detect when a break like this needs to be inserted when converting tokens back into plain text, as &lt;a href="https://www.cemetech.net/forum/viewtopic.php?p=296823">discussed previously on on Cemetech&lt;/a>: TI-BASIC (at least the 8x variant) was designed to only ever be written in tokens, so some way to mark token boundaries is required where a suffix of the concatenation of two or more valid tokens is also valid as another token.&lt;/p>
&lt;p>Since that formalism is a little confusing when written in words, an example: if we have tokens &amp;ldquo;&lt;code>a&lt;/code>&amp;rdquo;, &amp;ldquo;&lt;code>ab&lt;/code>&amp;rdquo;, &amp;ldquo;&lt;code>bc&lt;/code>&amp;rdquo;, and &amp;ldquo;&lt;code>c&lt;/code>&amp;rdquo; then without any token break indicator it is unclear how to tokenize the plaintext string &amp;ldquo;&lt;code>abc&lt;/code>&amp;rdquo;: it could be [&lt;code>a&lt;/code>, &lt;code>bc&lt;/code>] or [&lt;code>ab&lt;/code>, &lt;code>c&lt;/code>]. Inserting a break (&lt;code>\&lt;/code>) disambiguates &amp;ldquo;&lt;code>a\bc&lt;/code>&amp;rdquo; as [&lt;code>a&lt;/code>, &lt;code>bc&lt;/code>] and &amp;ldquo;&lt;code>ab\c&lt;/code>&amp;rdquo; as [&lt;code>ab&lt;/code>, &lt;code>c&lt;/code>]. If detokenizing [&lt;code>ab&lt;/code>,&lt;code>c&lt;/code>], the output suffix &amp;ldquo;&lt;code>bc&lt;/code>&amp;rdquo; when encountering the &lt;code>c&lt;/code> token is also a valid token so we know a break must be inserted to disambiguate.&lt;/p>
&lt;hr>
&lt;p>That&amp;rsquo;s all well and good, but backslashes are kind of ugly and break the flow when you&amp;rsquo;re reading code (even if they are necessary due to the language design). It occurs to me that Unicode has thousands of interesting characters, at least some of which could be used as explicit token breaks like we typically use backslash for while making code somewhat easier for humans to read.&lt;/p>
&lt;h2 id="exploring-unicode-alternatives">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/ti-basic-unicode-splits/#exploring-unicode-alternatives">Exploring Unicode alternatives&lt;/a>
&lt;/h2>
&lt;p>&lt;a href="https://www.unicode.org/reports/tr14/">Unicode TR14&lt;/a> describes the blessed Unicode line breaking algorithm that guides when it is permitted to split text across multiple lines inside a block of text. While not strictly useful for the desired application of splitting tokens with no or little effect on how the code looks to humans, it does provide some pointers to interesting characters, including:&lt;/p>
&lt;ul>
&lt;li>Glue characters like Zero Width Joiner (ZWJ), U+200D: an invisible character that prevents breaking the text on either side of it (of particular use in emoji sequences as described by &lt;a href="https://www.unicode.org/reports/tr51/">TR51&lt;/a>!). The opposite of what we want.&lt;/li>
&lt;li>Next Line (NEL), U+0085: forces the following text to appear on a new line. This behaves the same as most programmers would expect a Carriage Return or Line Feed character (or the combination of the two) to behave,&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup> and it turns out that section 5.8 of the Unicode standard spends many paragraphs on suggesting how applications treat each of the possible newline characters. The variety of options exist largely due to historical differences in how computer systems record line breaks, where &lt;code>NEL&lt;/code> in particular is probably unfamiliar to most because it was used by &lt;a href="https://en.wikipedia.org/wiki/EBCDIC">EBCDIC&lt;/a> computers which were mostly IBM machines and are very uncommon these days. Interesting, but not useful for this application.&lt;/li>
&lt;li>Assorted punctuation, such as &amp;lsquo;!&amp;rsquo;, &amp;lsquo;}&amp;rsquo; and &amp;lsquo;[&amp;rsquo;. These forbid line breaks before or after them depending on the orientation, such as &amp;lsquo;)&amp;rsquo; forbidding a break before it because a close parentheses logically binds to the text that precedes it. Getting closer, but line breaks aren&amp;rsquo;t really relevant to wanting to mark the boundaries between tokens.&lt;/li>
&lt;/ul>
&lt;p>Adjacent to ZWJ in the code space we find an interesting character: U+200D &lt;code>ZERO WIDTH NON-JOINER&lt;/code> (ZWNJ). Unicode Section 23.2 says this (and ZWJ for the opposite) is designed to mark where connections between characters are forbidden, as in cursive scripts or if a &lt;a href="https://en.wikipedia.org/wiki/Ligature_(writing)">ligature&lt;/a> might be used. Inserting a ZWNJ between two characters that might otherwise be joined forces them to be disconnected.&lt;/p>
&lt;h3 id="zwnj">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/ti-basic-unicode-splits/#zwnj">ZWNJ&lt;/a>
&lt;/h3>
&lt;p>If one figuratively squints at the intent of ZWNJ, it seems similar to the needs outlined for TI-BASIC: we want to prevent characters from running together in some situations. Where normally a tokenizer will eagerly join characters into tokens, a ZWNJ could be inserted as a break character that is otherwise invisible to human readers.&lt;/p>
&lt;p>With this in mind, we could label the use of a backslash to escape tokens as the traditional method and compare it to use of ZWNJ (call it &amp;ldquo;invisible&amp;rdquo; breaks) or doing nothing:&lt;/p>
&lt;table style="border: 1px solid black; width: 100%">
&lt;tr>&lt;th>Split mode&lt;/th>&lt;th>Plaintext&lt;/th>&lt;th>Tokenized&lt;/th>&lt;/tr>
&lt;tr>&lt;td>None&lt;/td>&lt;td>&lt;code>Disp "I like to eat pie&lt;/code>&lt;/td>&lt;td>&lt;code>Disp "I like to eat &amp;pi;e&lt;/code&lt;/td>&lt;/tr>
&lt;tr>&lt;td>Traditional&lt;/td>&lt;td>&lt;code>Disp "I like to eat p\ie&lt;/code>&lt;/td>&lt;td>&lt;code>Disp "I like to eat pie&lt;/code>&lt;/td>&lt;/tr>
&lt;tr>&lt;td>Invisible&lt;/td>&lt;td>&lt;code>Disp "I like to eat p&amp;zwnj;ie&lt;/code>&lt;/td>&lt;td>&lt;code>Disp "I like to eat pie&lt;/code&lt;/td>&lt;/tr>
&lt;/table>
&lt;p>As already established, not inserting a break is ambiguous and because tokenizers must take the longest prefix of a given input as a token,&lt;sup id="fnref:3">&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref">3&lt;/a>&lt;/sup> the &amp;ldquo;pi&amp;rdquo; ends up incorrectly transformed to the Greek letter pi. In the traditional break style, we insert a backslash to force &amp;ldquo;pi&amp;rdquo; to be interpreted as two Latin letters rather than being translated to the Greek pi token.&lt;/p>
&lt;p>In the invisible mode, there is still a break present but it is &lt;strong>not visible in the written text&lt;/strong>: I have inserted a ZWNJ character (HTML &lt;code>&amp;amp;zwnj;&lt;/code>) which can be interpreted by a tokenizer in the same way as a backslash (which is to say, ignored other than forcing a token split).&lt;/p>
&lt;h2 id="discussion">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/ti-basic-unicode-splits/#discussion">Discussion&lt;/a>
&lt;/h2>
&lt;p>Although I&amp;rsquo;m pleased with the idea to insert ZWNJs into human-readable BASIC programs, doing so in general seems limited by readers&amp;rsquo; needs to only some contexts. It does also offer some interesting possibilities, though.&lt;/p>
&lt;p>If a person might visually read out the plaintext source code and convert it to tokens (such as by typing it into a physical calculator), the loss of visual breaks means that the human must attempt to resolve any ambiguities that appear. While an experienced TI-BASIC programmer can probably discern intent from the program&amp;rsquo;s context in order to disambiguate, &lt;strong>depending on a reader&amp;rsquo;s skill seems like a suboptimal solution&lt;/strong>. As a counterpoint however, a novice programmer may not even be familiar with the backslash-as-break &amp;ldquo;traditional&amp;rdquo; convention either: in that case invisible breaks could be superior.&lt;/p>
&lt;p>If a user is expected to be able to copy and paste Unicode text to convert it into tokens (such as in source code published to the web, like this article), invisible splits are convenient and easy to read as long as tokenizers can be assumed to understand them. If a user might do visual transcription (such as manually typing code from a book into a calculator), traditional (visible) splits may be preferred.&lt;/p>
&lt;h3 id="increasing-break-frequency">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/ti-basic-unicode-splits/#increasing-break-frequency">Increasing break frequency&lt;/a>
&lt;/h3>
&lt;p>Invisible breaks also present an interesting opportunity to mark more token boundaries than only those required to disambiguate textual source code: what if a ZWNJ were inserted on &lt;strong>every token boundary&lt;/strong>? Doing so would make a given program&amp;rsquo;s source code forward-compatible with alternate token sets that have more tokens! Because breaks must be inserted where ambiguity is known, adding more tokens can introduce new ambiguities that might not be handled by an older detokenizer (which has a smaller set of known tokens).&lt;/p>
&lt;p>If it can be assumed that every pair of tokens has break character between them, then a tokenizer can simply split its input text on break characters and emit tokens matching exactly the strings that are separated by breaks.&lt;/p>
&lt;p>Unfortunately this would require a somewhat different mode of operation for tokenizers when compared with the traditional longest-prefix matching. It may be possible to support both modes concurrently however, if a tokenizer first attempted an exact match of the input up to the next break character and fell back to longest-prefix matching in case of no exact match in order to handle breaks between every token but also support minimal-break inputs.&lt;/p>
&lt;p>This break-every-token approach is possible with traditional breaks as well as invisible, which is easier to illustrate. Consider a program fragment written for a monochrome-display TI-83+: &amp;ldquo;&lt;code>Red cat&lt;/code>&amp;rdquo;. The string &amp;ldquo;Red&amp;rdquo; is a token in its own right on the CSE and CE 8x calculators (because they have color screens), so interpreting this as a program for color calculators would tokenize it differently. This becomes clear if we insert breaks around every token:&lt;/p>
&lt;ul>
&lt;li>Monochrome: &lt;code>R\e\d\ \c\a\t&lt;/code>&lt;/li>
&lt;li>Color: &lt;code>Red\ \c\a\t&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>A program written as the monochrome version with breaks around every token as illustrated here &lt;strong>cannot be mistaken&lt;/strong> for the color one, and the color one cannot be mistaken for the monochrome! While doing so with visible break characters makes it much more difficult to read the code, invisible breaks would not affect readability the same way visible ones do.&lt;/p>
&lt;h3 id="concluding">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/ti-basic-unicode-splits/#concluding">Concluding&lt;/a>
&lt;/h3>
&lt;p>I think it would be pretty cool if existing tools added support for these proposed invisible break characters. They&amp;rsquo;re not appropriate for all use cases (so perhaps shouldn&amp;rsquo;t be the default), but can be useful and would be even more useful with the proposed change to the usual tokenization algorithm.&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>The TI-83 uses essentially the same dialect of BASIC as the 8x series and was first sold in 1996. Some versions of TI&amp;rsquo;s GraphLink software included a program editor that I&amp;rsquo;ve never used, and &lt;a href="https://www.ticalc.org/archives/news/articles/13/132/132993.html">SourceCoder first appeared in 2005&lt;/a>. &lt;a href="https://www.ticalc.org/archives/news/articles/14/148/148184.html">TokenIDE appeared around 2011&lt;/a> and its use of XML files to describe tokens was adopted by later versions of SourceCoder. I imagine TokenIDE&amp;rsquo;s &lt;a href="https://bitbucket.org/merthsoft/tokenide/">open source nature&lt;/a> contributed to its success in influencing later work on SourceCoder.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>Typewriter users might also recognize these terms and intuit their meaning, since they were included in the ASCII character set in 1967 due to their importance to teletypes, where there is a physical distinction between simply advancing the feed by one line and moving the carriage back to the start of the line.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:3">
&lt;p>This requirement may not be obvious: if there are two tokens &amp;ldquo;Y&amp;rdquo; and &amp;ldquo;Yellow&amp;rdquo; for instance, given input &amp;ldquo;Yellow&amp;rdquo; a tokenizer must choose the longer of the tokens matching the input (namely, &amp;ldquo;Yellow&amp;rdquo;). If it did not, it would be impossible to reliably recognize the token &amp;ldquo;Yellow&amp;rdquo; because &amp;ldquo;Y&amp;rdquo; might be treated as a token instead, leaving &amp;ldquo;ellow&amp;rdquo; to be tokenized separately.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Managing Google Photos duplicates with Python</title><link>https://www.taricorp.net/2022/google-photos-dedupe-attempt/</link><pubDate>Sat, 30 Apr 2022 04:05:40 +0000</pubDate><guid>https://www.taricorp.net/2022/google-photos-dedupe-attempt/</guid><description>&lt;p>I recently had a bit of a problem with the files that had ended up in Google Photos on my account: the Google Drive desktop synchronization app seemed to have noticed the many (reasonably-high-resolution) thumbnails that my local photo management application (Lightroom) creates, and had uploaded many near-duplicate images.&lt;/p>
&lt;p>It seems this wasn&amp;rsquo;t a problem with the old &amp;ldquo;&lt;a href="https://support.google.com/drive/answer/7638428?hl=en">Backup and Sync&lt;/a>&amp;rdquo; application because it supported excluding some files from backup (so I could have it ignore the directory that thumbnails get put into), but the new Drive application lacks such a feature.&lt;/p>
&lt;p>While Google&amp;rsquo;s sync tool does know how to avoid uploading exact duplicates of photos, it doesn&amp;rsquo;t do any similarity matching on image content, so thumbnails (with the same content but lower resolution) and alternate formats (sidecar JPEGs that go along with camera raw files) end up duplicated in the Google Photos library. I have previously ignored the duplicates in alternate formats because they weren&amp;rsquo;t too annoying, but when the sync tool uploaded a few thousand duplicate thumbnails I felt the need to do something about it.&lt;/p>
&lt;h2 id="existing-programs">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/google-photos-dedupe-attempt/#existing-programs">Existing programs&lt;/a>
&lt;/h2>
&lt;p>There aren&amp;rsquo;t many existing options for managing duplicate items like this. Doing a web search for &amp;ldquo;Google Photos remove duplicates&amp;rdquo; mostly gives back content farm-style articles that talk about how Photos provides some duplicate-finding features (yes, but they&amp;rsquo;re limited) and usually go on to suggest some nonfree application for finding duplicates on your local computer. These are all unhelpful because:&lt;/p>
&lt;ul>
&lt;li>I don&amp;rsquo;t have duplicates on my local computer, and want to clean up the duplicates in the Google library so it&amp;rsquo;s easier to browse (and to save some space)&lt;/li>
&lt;li>The official duplicate finding tool doesn&amp;rsquo;t appear to look at visual similarity, so misses the thumbnails I want to remove&lt;/li>
&lt;li>They&amp;rsquo;re trying to sell me something that doesn&amp;rsquo;t do what I want&lt;/li>
&lt;/ul>
&lt;p>I did discover &lt;a href="https://duplicates-google-photos.remikel.fr/">Rémi Mikel&amp;rsquo;s duplicate finding tool&lt;/a> which seemed like a step in the right direction, but seemed to perform too badly to be very useful (probably because there are thousands of images that I want to get rid of, and it tries to display all of them in your browser). It did suggest an approach I could take to do this myself though, by using the public &lt;a href="https://developers.google.com/photos/library/guides/overview">Google Photos API&lt;/a>.&lt;/p>
&lt;aside class="information">Note from 2023: Mack Talcott alerted me to his &lt;a href="https://github.com/mtalcott/google-photos-deduper/">Google Photos Deduper&lt;/a>, which does the kinds of image comparisons that I wanted and achieves actual deletion of duplicates by doing UI automation of the web interface. I've found that it works pretty well for my needs, but deleting images with UI automation is fairly slow and maintaining the UI automation over time could be a tedious process. It's definitely worth looking into if you want to actually delete duplicate images and not only identify them.&lt;/aside>
&lt;h2 id="rolling-an-api-client">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/google-photos-dedupe-attempt/#rolling-an-api-client">Rolling an API client&lt;/a>
&lt;/h2>
&lt;p>With some ideas in mind, I wrote a little Python application to gather data. Using the public API is fairly straightforward; the most difficult part is probably in simply setting something up to be able to authenticate with my own credentials and access my photos.&lt;/p>
&lt;h3 id="configuring-authentication">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/google-photos-dedupe-attempt/#configuring-authentication">Configuring authentication&lt;/a>
&lt;/h3>
&lt;p>In the Google Cloud console, I created a project and enabled the &lt;a href="https://console.cloud.google.com/apis/api/photoslibrary.googleapis.com/">Photos Library API&lt;/a> for it, then created an OAuth client ID that I could use in my application. Since the steps are easier to illustrate than describe, here are screenshots of the process after having created a project.&lt;/p>
&lt;ul>
&lt;li>
&lt;p>Enable the Photos Library API in the API library.&lt;/p>
&lt;p>&lt;img src="https://www.taricorp.net/2022/google-photos-dedupe-attempt/01-enable-api.png" alt="The Photos Library API shown in the API library, with a blue &amp;ldquo;Enable&amp;rdquo; button">&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Create an OAuth consent screen for the application. This gets shown to users when providing the application access to the data in their Google account. It&amp;rsquo;s fine to make this an &amp;ldquo;External&amp;rdquo; application.&lt;/p>
&lt;ul>
&lt;li>This requires you to fill in an app name and some email addresses, but what exactly those are is up to you; they don&amp;rsquo;t really matter if this project is only going to be for personal use, as it was in my case.
&lt;img src="https://www.taricorp.net/2022/google-photos-dedupe-attempt/02-oauth-consent.png" alt="Select &amp;ldquo;Internal&amp;rdquo; or &amp;ldquo;External&amp;rdquo; users to begin creating an OAuth consent screen.">&lt;/li>
&lt;li>When asked to select scopes for the app, select the relevant ones for the Photos Library API. &lt;code>photoslibrary&lt;/code> and &lt;code>photoslibrary.sharing&lt;/code> are sufficient for this use case. Any scopes that aren&amp;rsquo;t enabled here won&amp;rsquo;t be available to the application (permission will be denied) when run.
&lt;img src="https://www.taricorp.net/2022/google-photos-dedupe-attempt/03-oauth-scopes.png" alt="">&lt;/li>
&lt;li>The third step adds users to the allowlist for a testing app, which is the default configuration. Non-testing apps are available to the public but may require review by Google before they can be published, but since this is for personal use on my own computer it&amp;rsquo;s fine to let it stay as a test app. I added my own email address to the list of test users and continued on.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>Having configured the consent screen, we can now create a new OAuth client ID, in the API credentials page&lt;/p>
&lt;ul>
&lt;li>
&lt;p>Choose to create new credentials, and select &amp;ldquo;OAuth client ID&amp;rdquo;.
&lt;img src="https://www.taricorp.net/2022/google-photos-dedupe-attempt/04-create-credentials.png" alt="Selecting &amp;ldquo;Create credentials&amp;rdquo; drops down a menu, where one option is to create an OAuth client ID">&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Set the Application type to Desktop and fill in a name. This program runs locally and is not a web application, so the type must be Desktop in order for the local authentication flow to be permitted.&lt;/p>
&lt;p>&lt;img src="https://www.taricorp.net/2022/google-photos-dedupe-attempt/05-client-id.png" alt="Creating a client ID requires the application type be specified and a name provided">&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Download the JSON for the created client details after the client is created. The program will need to provide this to Google when logging in.&lt;/p>
&lt;p>&lt;img src="https://www.taricorp.net/2022/google-photos-dedupe-attempt/06-client-created.png" alt="After creating a client ID, the ID and a client secret are shown, with a button available to download JSON">&lt;/p>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>I saved the downloaded file as &lt;code>client_secret.json&lt;/code> and then got to actually writing my program.&lt;/p>
&lt;h3 id="downloading-data">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/google-photos-dedupe-attempt/#downloading-data">Downloading data&lt;/a>
&lt;/h3>
&lt;p>Rather than walk through the process of writing the program I did, I&amp;rsquo;ll simply present the code after describing what I chose to have it do.&lt;/p>
&lt;p>Since I was taking inspiration from Mikel&amp;rsquo;s tool, I knew I only wanted to gather photo metadata to compare, which is largely returned when simply listing a user&amp;rsquo;s library. To play with that data interactively and figure out what I wanted to filter on to delete, I decided to store it in a SQLite database, since that would be easy to query later. With that in mind, I simply made the program call &lt;a href="https://developers.google.com/photos/library/reference/rest/v1/mediaItems/list">&lt;code>mediaItems.list&lt;/code>&lt;/a> in a loop, taking a page (100 items) at a time until there were no more pages. For each item, it extracts the metadata I care about and stores it in the database.&lt;/p>
&lt;p>To work with the database I opted to use &lt;a href="https://www.sqlalchemy.org/">SQLAlchemy&lt;/a> simply because it was a convenient way to manage types that the database might not directly understand and run queries in a slightly less cumbersome way. In total, these libraries are needed to run the below code (all installable from &lt;a href="https://pypi.org/">PyPI&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>&lt;code>google-api-python-client&lt;/code>&lt;/li>
&lt;li>&lt;code>google-auth-oauthlib&lt;/code>&lt;/li>
&lt;li>&lt;code>SQLAlchemy&lt;/code>&lt;/li>
&lt;li>&lt;code>python-dateutil&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>With that out of the way, here&amp;rsquo;s the actual code I wrote:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">47
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">48
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">49
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">50
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">51
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">52
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">53
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">54
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">55
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">56
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">57
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">58
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">59
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">60
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">61
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">62
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">63
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">64
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">65
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">66
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">67
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">68
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">69
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">70
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">71
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">72
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">73
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">74
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">75
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">76
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">77
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">78
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">79
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">80
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">81
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">82
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">83
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">84
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">85
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">86
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">import&lt;/span> &lt;span style="color:#555">datetime&lt;/span> &lt;span style="color:#000;font-weight:bold">as&lt;/span> &lt;span style="color:#555">dt&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">import&lt;/span> &lt;span style="color:#555">dateutil.parser&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">import&lt;/span> &lt;span style="color:#555">googleapiclient.discovery&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">google_auth_oauthlib.flow&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> InstalledAppFlow
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">sqlalchemy&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> Column, String, create_engine, DateTime, Integer, Float, Interval
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">sqlalchemy.orm&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> declarative_base, Session
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Base &lt;span style="color:#000;font-weight:bold">=&lt;/span> declarative_base()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> &lt;span style="color:#458;font-weight:bold">Photo&lt;/span>(Base):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> __tablename__ &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#d14">&amp;#39;photos&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">id&lt;/span> &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(String(&lt;span style="color:#099">256&lt;/span>), primary_key&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> filename &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(String(&lt;span style="color:#099">128&lt;/span>), nullable&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">False&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> mime_type &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(String(&lt;span style="color:#099">64&lt;/span>), nullable&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">False&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> creation_time &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(DateTime(timezone&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>), index&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> width &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(Integer())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> height &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(Integer())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> camera_make &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(String(&lt;span style="color:#099">64&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> camera_model &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(String(&lt;span style="color:#099">64&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> focal_length &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(Float())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> f_stop &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(Float())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> iso_equivalent &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(Integer())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> exposure_time &lt;span style="color:#000;font-weight:bold">=&lt;/span> Column(Interval())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>engine &lt;span style="color:#000;font-weight:bold">=&lt;/span> create_engine(&lt;span style="color:#d14">&amp;#34;sqlite:///photos.sqlite&amp;#34;&lt;/span>, future&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Base&lt;span style="color:#000;font-weight:bold">.&lt;/span>metadata&lt;span style="color:#000;font-weight:bold">.&lt;/span>create_all(engine)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># No idea how to restore saved credentials, so don&amp;#39;t try. Many examples use&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># oauth2client.file.Storage but that doesn&amp;#39;t work, at least in recent versions:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># https://github.com/googleapis/google-api-python-client/issues/491&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>credentials &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">if&lt;/span> credentials &lt;span style="color:#000;font-weight:bold">is&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span> &lt;span style="color:#000;font-weight:bold">or&lt;/span> credentials&lt;span style="color:#000;font-weight:bold">.&lt;/span>invalid:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> flow &lt;span style="color:#000;font-weight:bold">=&lt;/span> InstalledAppFlow&lt;span style="color:#000;font-weight:bold">.&lt;/span>from_client_secrets_file(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;client_secret.json&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> scopes&lt;span style="color:#000;font-weight:bold">=&lt;/span>[&lt;span style="color:#d14">&amp;#39;https://www.googleapis.com/auth/photoslibrary&amp;#39;&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> credentials &lt;span style="color:#000;font-weight:bold">=&lt;/span> flow&lt;span style="color:#000;font-weight:bold">.&lt;/span>run_local_server(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> host&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#39;localhost&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> port&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">8000&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> open_browser&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">True&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">print&lt;/span>(&lt;span style="color:#d14">&amp;#39;Authenticated OK&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>service &lt;span style="color:#000;font-weight:bold">=&lt;/span> googleapiclient&lt;span style="color:#000;font-weight:bold">.&lt;/span>discovery&lt;span style="color:#000;font-weight:bold">.&lt;/span>build(&lt;span style="color:#d14">&amp;#39;photoslibrary&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;v1&amp;#39;&lt;/span>, credentials&lt;span style="color:#000;font-weight:bold">=&lt;/span>credentials, static_discovery&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">False&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">with&lt;/span> Session(engine) &lt;span style="color:#000;font-weight:bold">as&lt;/span> session:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> page_token &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">while&lt;/span> &lt;span style="color:#000;font-weight:bold">True&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> session&lt;span style="color:#000;font-weight:bold">.&lt;/span>begin()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> response &lt;span style="color:#000;font-weight:bold">=&lt;/span> service&lt;span style="color:#000;font-weight:bold">.&lt;/span>mediaItems()&lt;span style="color:#000;font-weight:bold">.&lt;/span>list(pageSize&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">100&lt;/span>, pageToken&lt;span style="color:#000;font-weight:bold">=&lt;/span>page_token)&lt;span style="color:#000;font-weight:bold">.&lt;/span>execute()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> item &lt;span style="color:#000;font-weight:bold">in&lt;/span> response&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#d14">&amp;#39;mediaItems&amp;#39;&lt;/span>, []):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo &lt;span style="color:#000;font-weight:bold">=&lt;/span> Photo(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">id&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>item[&lt;span style="color:#d14">&amp;#39;id&amp;#39;&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> filename&lt;span style="color:#000;font-weight:bold">=&lt;/span>item[&lt;span style="color:#d14">&amp;#39;filename&amp;#39;&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> mime_type&lt;span style="color:#000;font-weight:bold">=&lt;/span>item[&lt;span style="color:#d14">&amp;#39;mimeType&amp;#39;&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> metadata &lt;span style="color:#000;font-weight:bold">=&lt;/span> item&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#d14">&amp;#39;mediaMetadata&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> metadata &lt;span style="color:#000;font-weight:bold">is&lt;/span> &lt;span style="color:#000;font-weight:bold">not&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#d14">&amp;#39;creationTime&amp;#39;&lt;/span> &lt;span style="color:#000;font-weight:bold">in&lt;/span> metadata:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>creation_time &lt;span style="color:#000;font-weight:bold">=&lt;/span> dateutil&lt;span style="color:#000;font-weight:bold">.&lt;/span>parser&lt;span style="color:#000;font-weight:bold">.&lt;/span>isoparse(metadata[&lt;span style="color:#d14">&amp;#39;creationTime&amp;#39;&lt;/span>])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#d14">&amp;#39;width&amp;#39;&lt;/span> &lt;span style="color:#000;font-weight:bold">in&lt;/span> metadata:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>width &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">int&lt;/span>(metadata[&lt;span style="color:#d14">&amp;#39;width&amp;#39;&lt;/span>])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#d14">&amp;#39;height&amp;#39;&lt;/span> &lt;span style="color:#000;font-weight:bold">in&lt;/span> metadata:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>height &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#0086b3">int&lt;/span>(metadata[&lt;span style="color:#d14">&amp;#39;height&amp;#39;&lt;/span>])
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#d14">&amp;#39;photo&amp;#39;&lt;/span> &lt;span style="color:#000;font-weight:bold">in&lt;/span> metadata:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo_meta &lt;span style="color:#000;font-weight:bold">=&lt;/span> metadata[&lt;span style="color:#d14">&amp;#39;photo&amp;#39;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>camera_make &lt;span style="color:#000;font-weight:bold">=&lt;/span> photo_meta&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#d14">&amp;#39;cameraMake&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>camera_model &lt;span style="color:#000;font-weight:bold">=&lt;/span> photo_meta&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#d14">&amp;#39;cameraModel&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>focal_length &lt;span style="color:#000;font-weight:bold">=&lt;/span> photo_meta&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#d14">&amp;#39;focalLength&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>f_stop &lt;span style="color:#000;font-weight:bold">=&lt;/span> photo_meta&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#d14">&amp;#39;apertureFNumber&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>iso_equivalent &lt;span style="color:#000;font-weight:bold">=&lt;/span> photo_meta&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#d14">&amp;#39;isoEquivalent&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#d14">&amp;#39;exposureTime&amp;#39;&lt;/span> &lt;span style="color:#000;font-weight:bold">in&lt;/span> photo_meta &lt;span style="color:#000;font-weight:bold">and&lt;/span> photo_meta[&lt;span style="color:#d14">&amp;#39;exposureTime&amp;#39;&lt;/span>]&lt;span style="color:#000;font-weight:bold">.&lt;/span>endswith(&lt;span style="color:#d14">&amp;#39;s&amp;#39;&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>exposure_time &lt;span style="color:#000;font-weight:bold">=&lt;/span> dt&lt;span style="color:#000;font-weight:bold">.&lt;/span>timedelta(seconds&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#0086b3">float&lt;/span>(photo_meta[&lt;span style="color:#d14">&amp;#39;exposureTime&amp;#39;&lt;/span>][:&lt;span style="color:#000;font-weight:bold">-&lt;/span>&lt;span style="color:#099">1&lt;/span>]))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#0086b3">print&lt;/span>(photo&lt;span style="color:#000;font-weight:bold">.&lt;/span>id)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> session&lt;span style="color:#000;font-weight:bold">.&lt;/span>merge(photo)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> session&lt;span style="color:#000;font-weight:bold">.&lt;/span>commit()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> page_token &lt;span style="color:#000;font-weight:bold">=&lt;/span> response&lt;span style="color:#000;font-weight:bold">.&lt;/span>get(&lt;span style="color:#d14">&amp;#39;nextPageToken&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> page_token &lt;span style="color:#000;font-weight:bold">is&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">break&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Running this opens a web browser to grant access to a Google account, and when authentication succeeds it then grabs metadata for every item in the Google Photos library, printing the ID of each item while it goes.&lt;/p>
&lt;h2 id="finding-unwanted-thumbnails">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/google-photos-dedupe-attempt/#finding-unwanted-thumbnails">Finding unwanted thumbnails&lt;/a>
&lt;/h2>
&lt;p>To find the thumbnails I don&amp;rsquo;t want, I can start by manually running queries against the generated SQLite database. It turns out the unwanted thumbnail files have a common filename format that appears to be a UUID, and they&amp;rsquo;re always &lt;code>.dng&lt;/code> files. It wasn&amp;rsquo;t too difficult to search for the files that have matching filenames and (to avoid deleting those that don&amp;rsquo;t seem to correspond to any other image) avoid matching those that don&amp;rsquo;t appear to have an original file (taken by the same camera at the same time) already known:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">SELECT&lt;/span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">*&lt;/span>&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">&lt;/span>&lt;span style="color:#000;font-weight:bold">FROM&lt;/span>&lt;span style="color:#bbb"> &lt;/span>photos&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">AS&lt;/span>&lt;span style="color:#bbb"> &lt;/span>main&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">&lt;/span>&lt;span style="color:#000;font-weight:bold">WHERE&lt;/span>&lt;span style="color:#bbb"> &lt;/span>filename&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">LIKE&lt;/span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#d14">&amp;#39;________-____-____-____-____________.dng&amp;#39;&lt;/span>&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">&lt;/span>&lt;span style="color:#000;font-weight:bold">AND&lt;/span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">EXISTS&lt;/span>(&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">SELECT&lt;/span>&lt;span style="color:#bbb"> &lt;/span>id&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">from&lt;/span>&lt;span style="color:#bbb"> &lt;/span>photos&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">WHERE&lt;/span>&lt;span style="color:#bbb"> &lt;/span>creation_time&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#bbb"> &lt;/span>main.creation_time&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">AND&lt;/span>&lt;span style="color:#bbb"> &lt;/span>camera_make&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#bbb"> &lt;/span>main.camera_make&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">AND&lt;/span>&lt;span style="color:#bbb"> &lt;/span>camera_model&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#bbb"> &lt;/span>main.camera_model&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">AND&lt;/span>&lt;span style="color:#bbb"> &lt;/span>id&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000;font-weight:bold">!=&lt;/span>&lt;span style="color:#bbb"> &lt;/span>main.id&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">&lt;/span>)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>This returns 3651 rows from my library, so then the problem is how to take those images (probably by ID) and remove them from Google Photos.&lt;/p>
&lt;h2 id="removing-unwanted-images">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/google-photos-dedupe-attempt/#removing-unwanted-images">Removing unwanted images&lt;/a>
&lt;/h2>
&lt;p>Unfortunately, the Google Photos API &lt;a href="https://issuetracker.google.com/issues/109759781#comment72">doesn&amp;rsquo;t provide any way to delete photos&lt;/a>. This seems to be largely because a malicious application could use it to delete all of a user&amp;rsquo;s photos (a reasonable concern!), but it makes what I want to do difficult. One user on the above bug suggests using the API to add photos to a new album, from which the user can delete photos with relative ease, which I think is a reasonable choice.&lt;/p>
&lt;p>I wrote another script (copying the authentication code from before into a new &lt;code>auth&lt;/code> module for reuse), taking the ID of an album on the command line, as well as the name of a file containing item IDs:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">import&lt;/span> &lt;span style="color:#555">sys&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">import&lt;/span> &lt;span style="color:#555">googleapiclient.discovery&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>album_id &lt;span style="color:#000;font-weight:bold">=&lt;/span> sys&lt;span style="color:#000;font-weight:bold">.&lt;/span>argv[&lt;span style="color:#099">1&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>item_ids_file &lt;span style="color:#000;font-weight:bold">=&lt;/span> sys&lt;span style="color:#000;font-weight:bold">.&lt;/span>argv[&lt;span style="color:#099">2&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">.auth&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> credentials
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>service &lt;span style="color:#000;font-weight:bold">=&lt;/span> googleapiclient&lt;span style="color:#000;font-weight:bold">.&lt;/span>discovery&lt;span style="color:#000;font-weight:bold">.&lt;/span>build(&lt;span style="color:#d14">&amp;#39;photoslibrary&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;v1&amp;#39;&lt;/span>, credentials&lt;span style="color:#000;font-weight:bold">=&lt;/span>credentials, static_discovery&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#000;font-weight:bold">False&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">with&lt;/span> &lt;span style="color:#0086b3">open&lt;/span>(item_ids_file, &lt;span style="color:#d14">&amp;#39;r&amp;#39;&lt;/span>) &lt;span style="color:#000;font-weight:bold">as&lt;/span> f:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> item_ids &lt;span style="color:#000;font-weight:bold">=&lt;/span> f&lt;span style="color:#000;font-weight:bold">.&lt;/span>readlines()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">chunks&lt;/span>():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># batchAddMediaItems takes no more than 50 items per call&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> i &lt;span style="color:#000;font-weight:bold">in&lt;/span> &lt;span style="color:#0086b3">range&lt;/span>(&lt;span style="color:#099">0&lt;/span>, &lt;span style="color:#0086b3">len&lt;/span>(item_ids), &lt;span style="color:#099">50&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">yield&lt;/span> item_ids[i:i&lt;span style="color:#000;font-weight:bold">+&lt;/span>&lt;span style="color:#099">50&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">for&lt;/span> chunk &lt;span style="color:#000;font-weight:bold">in&lt;/span> chunks():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> service&lt;span style="color:#000;font-weight:bold">.&lt;/span>albums()&lt;span style="color:#000;font-weight:bold">.&lt;/span>batchAddMediaItems(albumId&lt;span style="color:#000;font-weight:bold">=&lt;/span>album_id, body&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#0086b3">dict&lt;/span>(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> mediaItemIds&lt;span style="color:#000;font-weight:bold">=&lt;/span>chunk
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ))&lt;span style="color:#000;font-weight:bold">.&lt;/span>execute()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>I created an album manually via the API, and created a file with one ID per line by exporting the results of the above SQL query to a CSV file with only one column (the ID of the item). Unfortunately, this fails with an HTTP 400, complaining that &lt;code>Request contains an invalid media item id.&lt;/code> After some experimentation (manually using the API to get a single ID and add it to an album), I realized the problem was an unfortunate but intentional API limitation:&lt;/p>
&lt;blockquote>
&lt;p>Note that you can only add media items that have been uploaded by your application to albums that your application has created.&lt;/p>
&lt;/blockquote>
&lt;p>..so it turns out it&amp;rsquo;s impossible to do anything useful via the API alone. It might be possible to do this via browser automation like &lt;a href="https://github.com/mrishab/google-photos-delete-tool/">a &amp;ldquo;delete all photos&amp;rdquo; tool&lt;/a> I happened across, but that&amp;rsquo;s a bigger hack than I&amp;rsquo;m interested in right now, so I&amp;rsquo;m forced to give up on the goal for now.&lt;/p>
&lt;h2 id="other-uses-of-the-data">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2022/google-photos-dedupe-attempt/#other-uses-of-the-data">Other uses of the data&lt;/a>
&lt;/h2>
&lt;p>I had a few other ideas of interesting things to do with the database I collected in addition to the desired &amp;ldquo;remove unwanted thumbnails&amp;rdquo;:&lt;/p>
&lt;ul>
&lt;li>Locate other duplicates by finding images with similar metadata (camera, capture time, ISO equivalent, shutter speed, &amp;hellip;). This would probably match raw files that have matching sidecar JPEGs, where I would likely prefer to remove the raws since the JPEGs tend to be better suited to browsing and I don&amp;rsquo;t use Google Photos as a repository for high-quality originals.&lt;/li>
&lt;li>Find files that exist in Google Photos but not on my local storage. It&amp;rsquo;s possible that some files would have been accidentally deleted that I could recover a version of (not the originals, probably) from the web. It&amp;rsquo;s also possible there would be a lot of false positives of photos that I intentionally deleted and would want to remove from Google, though.&lt;/li>
&lt;/ul>
&lt;p>In general, there are some interesting possibilities for ways in which photos data in this form could be swizzled into a helpful outcome, but it&amp;rsquo;s unfortunate that the APIs provided for Google Photos mean there aren&amp;rsquo;t many ways the data can be used to any meaningful end.&lt;/p></description></item><item><title>Monitoring (and preventing) excessive hard drive head parking on Linux</title><link>https://www.taricorp.net/2021/hdd-parking-monitoring/</link><pubDate>Sat, 14 Aug 2021 05:15:10 +0000</pubDate><guid>https://www.taricorp.net/2021/hdd-parking-monitoring/</guid><description>&lt;p>It is &lt;a href="https://mobile.twitter.com/marcan42/status/1423974853955125250">fairly well-known among techies&lt;/a> that hard drives used in server-like workloads can suffer from poor configuration by default such that they &lt;a href="https://superuser.com/questions/840851/how-much-load-cycle-count-can-my-hard-drive-hypotethically-sustain">frequently load and unload their heads&lt;/a>, which can cause disks to fail much faster than they otherwise would. While I have been aware of this in my home server as well, it is easy to forget to ensure that disks are not silently killing themselves by cycling the heads. Since I use &lt;a href="https://prometheus.io/">Prometheus&lt;/a> to capture information on the server&amp;rsquo;s operation however, I can use that to monitor that my hard drives are doing well.&lt;/p>
&lt;!-- more -->
&lt;h2 id="collecting-smart-metrics">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/hdd-parking-monitoring/#collecting-smart-metrics">Collecting SMART metrics&lt;/a>
&lt;/h2>
&lt;p>The &lt;a href="https://github.com/prometheus/node_exporter/">Prometheus Node Exporter&lt;/a> is the canonical tool for capturing machine metrics like utilization and hardware information with Prometheus, but it alone does not support probing SMART data from storage drives. It does support reading arbitrary metrics from text files written by other programs with its &lt;code>textfile&lt;/code> collector however, which is fairly easy to integrate with arbitrary other tools.&lt;/p>
&lt;p>&lt;a href="https://www.smartmontools.org/">Smartmontools&lt;/a> is the typical package of tools for reading SMART information from drives on Linux, and conveniently the &lt;a href="https://github.com/prometheus-community/node-exporter-textfile-collector-scripts">community-maintained example scripts&lt;/a> for collecting system information with Prometheus includes both a Python and shell script (&lt;code>smartmon.sh&lt;/code> and &lt;code>smartmon.py&lt;/code>) that generate metrics for all devices on a system that support SMART reporting. I run the shell script every 5 minutes with a systemd service triggered by a timer:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ini" data-lang="ini">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Description&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">Export smartctl metrics to Prometheus Node Exporter&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Service]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Nice&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">-10&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ExecStart&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">/bin/sh -c &amp;#39;exec /usr/local/bin/node_exporter_text_smartmon &amp;gt; /var/state/prometheus/smartctl.prom&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Write nothing except the output file&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ProtectSystem&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">strict&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ReadWritePaths&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">/var/state/prometheus&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Shell needs a temp directory&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">PrivateTmp&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">true&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ProtectHome&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">tmpfs&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>My node exporter is then configured to read text files from &lt;code>/var/state/prometheus&lt;/code>, and Prometheus itself gathers metrics from the node exporter.&lt;/p>
&lt;h3 id="monitoring-metrics">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/hdd-parking-monitoring/#monitoring-metrics">Monitoring metrics&lt;/a>
&lt;/h3>
&lt;p>With the SMART metrics captured by Prometheus, it&amp;rsquo;s fairly easy to write a query that will show how often a given disk is parking its heads. The &lt;code>smartmon_load_cycle_count_value&lt;/code> metric seems like it would be the right one to query, but that actually expresses a percentage value (0-100) representing how many load cycles remain in the specified lifetime- on reaching 0 the disk has done a very large number of load cycles.&lt;/p>
&lt;p>Somewhat more useful for monitoring is the &lt;code>smartmon_load_cycle_count_raw_value&lt;/code>, which provides the actual number of load cycles that have been done. By taking the rate of those it becomes pretty easy to see which disks are loading and unloading most, so I choose to query with &lt;code>sum(rate(smartmon_load_cycle_count_raw_value[6h])) by (disk)&lt;/code> to summarize by disk and get a 6-hour rolling average rate in load cycles per second, pictured here:&lt;/p>
&lt;p>&lt;img src="prometheus-load-cycles-fs8.png" alt="A Prometheus console plotting load cycle rates per disk for five disks over a period of two weeks. Three of them have nonzero rates, with /dev/sde peaking at about 4 millicycles per second, /dev/sdc around 3 millicycles per second, and /dev/sdb at a much lower maximum rate of about 0.5 millicycles per second. The remaining two disks are at zero across the entire time range.">&lt;/p>
&lt;p>In this case, there are at least two disks that I probably need to configure, since &lt;code>/dev/sde&lt;/code> seems to be parking as often as about every 4 minutes (0.004 Hz) and &lt;code>/dev/sdc&lt;/code> is only parking slightly less often. &lt;code>/dev/sdb&lt;/code> also seems worth inspecting.&lt;/p>
&lt;h2 id="preventing-excessive-parking">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/hdd-parking-monitoring/#preventing-excessive-parking">Preventing excessive parking&lt;/a>
&lt;/h2>
&lt;p>To prevent parking more often that is useful (for a server, usually that choice would be &amp;ldquo;very rarely&amp;rdquo;), there are a couple ways to do it and which apply will depend on what the hard drive vendor&amp;rsquo;s firmware supports.&lt;/p>
&lt;p>Of the three disks that I decided need some attention, I have one Western Digital disk and two Seagate ones. From the SMART data again, they are specifically these models:&lt;/p>
&lt;ul>
&lt;li>&lt;code>/dev/sdc&lt;/code>: Seagate Archive HDD (SMR) ST8000AS0002-1NA17Z, firmware version RT17&lt;/li>
&lt;li>&lt;code>/dev/sde&lt;/code>: Seagate IronWolf ST8000VN004-2M2101, firmware version SC60&lt;/li>
&lt;li>&lt;code>/dev/sdb&lt;/code>: Western Digital Red WDC WD40EFRX-68N32N0, firmware version 82.00A82&lt;/li>
&lt;/ul>
&lt;h3 id="apm">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/hdd-parking-monitoring/#apm">APM&lt;/a>
&lt;/h3>
&lt;p>The &lt;a href="https://en.wikipedia.org/wiki/Advanced_Power_Management#In_ATA_drives">APM specification&lt;/a> dating from 1992 includes some controls for hard drives, allowing a host system to specify the desired performance level of a disk and whether standby is permitted by sending commands to a disk. On Linux, the &lt;a href="https://sourceforge.net/projects/hdparm/">&lt;code>hdparm&lt;/code>&lt;/a> tool is most typically used to manage APM- in particular &lt;code>hdparm -B&lt;/code> allows the user to specify a value to specify to the drive, corresponding to the table of Advanced Power Management levels in the ATA/ATAPI specification (Table 30 in &lt;a href="https://web.archive.org/web/20110728081452/http://www.t13.org/Documents/UploadedDocuments/project/d1321r3-ATA-ATAPI-5.pdf">T13/1321D revision 3&lt;/a>), which I also reproduce here:&lt;/p>
&lt;ul>
&lt;li>Maximum performance: FEh&lt;/li>
&lt;li>Intermediate power management levels without Standby: 81h-FDh&lt;/li>
&lt;li>Minimum power consumption without Standby: 80h&lt;/li>
&lt;li>Intermediate power management levels with Standby: 02h-7Fh&lt;/li>
&lt;li>Minimum power consumption with Standby: 01h&lt;/li>
&lt;li>Reserved: FFh&lt;/li>
&lt;li>Reserved: 00h&lt;/li>
&lt;/ul>
&lt;p>The specification text further elaborates on the meanings of these values:&lt;/p>
&lt;blockquote>
&lt;p>Device performance may increase with increasing power management levels. Device power consumption
may increase with increasing power management levels. The power management levels may contain discrete
bands. For example, a device may implement one power management method from 80h to A0h and a higher
performance, higher power consumption method from level A1h to FEh. Advanced power management levels
80h and higher do not permit the device to spin down to save power.&lt;/p>
&lt;/blockquote>
&lt;p>If we wanted to allow the disk to still park its heads but at minimum frequency, setting the APM value to 7Fh (&lt;code>hdparm -B 127&lt;/code>) seems to be the correct choice. To prevent parking the heads at all a value greater than 128 may do the job (254 is a common choice, as the highest-power setting available), but it&amp;rsquo;s possible that some disks won&amp;rsquo;t behave this way because the ATA specification refers only to spinning down the disk and does not specify anything about parking heads.&lt;/p>
&lt;p>Unfortunately, APM settings don&amp;rsquo;t persist between power cycles so if we wanted to change disk settings with APM they would need to be reapplied on every boot. On a Linux system this could be done with a udev rule matching a chosen drive, for instance matching the chosen disk&amp;rsquo;s serial number:&lt;/p>
&lt;pre tabindex="0">&lt;code>ACTION==&amp;#34;add&amp;#34;, SUBSYSTEM==&amp;#34;block&amp;#34;, KERNEL==&amp;#34;sd[a-z]&amp;#34;, \
ENV{ID_SERIAL_SHORT}==&amp;#34;ABCDEFGH&amp;#34;, \
RUN+=&amp;#34;/usr/bin/hdparm -B 127 -S 0 /dev/%k&amp;#34;&lt;/code>&lt;/pre>
&lt;p>(The properties like &lt;code>ID_SERIAL_SHORT&lt;/code> can be queried on a running system using &lt;code>udevadm info&lt;/code>, such as &lt;code>udevadm info /dev/sdd&lt;/code> to get the properties of the disk currently assigned ID &lt;code>sdd&lt;/code>.)&lt;/p>
&lt;p>Disk vendors typically provide their own vendor-specific ways to do persistent configuration of power management settings, so it&amp;rsquo;s worth trying to use those instead so the desired configuration doesn&amp;rsquo;t depend on the host system applying it, instead being configured in the drive (but in some cases it might be desirable to have the host configure that!).&lt;/p>
&lt;h3 id="western-digital-idle3">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/hdd-parking-monitoring/#western-digital-idle3">Western Digital &lt;code>idle3&lt;/code>&lt;/a>
&lt;/h3>
&lt;p>For drives made by Western Digital, the inactivity timer for parking the heads is called the &lt;code>idle3&lt;/code> timer. Of particular note, WD Green drives ship configured to park the heads after only 8 seconds of inactivity which could notionally wear out the disk in a matter of &lt;em>months&lt;/em> if the heads are cycling more-or-less continuously!&lt;/p>
&lt;p>The &lt;a href="http://idle3-tools.sourceforge.net/">&lt;code>idle3-tools&lt;/code>&lt;/a> package allows configuring the timer on Linux, though timer values are rather unintuitive- the tool sets a &amp;ldquo;raw&amp;rdquo; idle3 timer value, so a value like 232 (0xe8) actually means 3120 seconds according to &lt;code>idle3ctl -g105&lt;/code>, and it&amp;rsquo;s unclear which interpretation of a given value actually applies to a given drive- if important that would need to be determined by experiment.&lt;/p>
&lt;pre tabindex="0">&lt;code>idle3ctl -d /dev/sdb -s 254&lt;/code>&lt;/pre>
&lt;p>The other slight annoyance when setting the idle3 timer on WD drives is that changes only take effect when the drive is powered on, usually meaning the host computer must be fully shut down and started back up for any changes to be seen- this makes experimentation to determine how raw timer values are interpreted a slower and more tedious process.&lt;/p>
&lt;aside class="information">
&lt;b>A note from 2023&lt;/b>: I made some changes to the drives used by a server recently and needed to inspect their head parking settings, finding that I now have an 8TB WD Red drive that doesn't accept idle3 commands and shipped with its APM level set to 164. I had to use a udev rule (see &lt;a href="#APM">APM&lt;/a> above) to configure it to my liking.
&lt;/aside>
&lt;h3 id="seagate-epc">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/hdd-parking-monitoring/#seagate-epc">Seagate EPC&lt;/a>
&lt;/h3>
&lt;p>Most Seagate disks have configurable Extended Power Conditions (EPC) settings that include timers for how long the disk needs to stay idle before entering various low-power modes.&lt;/p>
&lt;p>According to &lt;a href="https://www.seagate.com/www-content/product-content/skyhawk/en-us/docs/100855892b.pdf">at least one manual&lt;/a>, the four low-power states are:&lt;/p>
&lt;ul>
&lt;li>&lt;code>idle_a&lt;/code>: power down some electronics&lt;/li>
&lt;li>&lt;code>idle_b&lt;/code>: park the heads (unloading them)&lt;/li>
&lt;li>&lt;code>idle_c&lt;/code>: reduce spindle speed, heads unloaded&lt;/li>
&lt;li>&lt;code>standby_z&lt;/code>: stop spindle completely&lt;/li>
&lt;/ul>
&lt;p>Seagate provide a &amp;ldquo;&lt;a href="https://www.seagate.com/au/en/support/software/seachest/">Seachest&lt;/a>&amp;rdquo; collection of tools for manipulating their drives, but rather more usefully to users of non-Windows operating systems like Linux they also offer an open-source &lt;a href="https://github.com/Seagate/openSeaChest">openSeaChest&lt;/a>. The tool to use there is &lt;code>openSeaChest_PowerControl&lt;/code> which allows each of the EPC timers to be configured, in an invocation like:&lt;/p>
&lt;pre tabindex="0">&lt;code>openSeaChest_PowerControl -d /dev/sdc --idle_a 6000 --idle_b 1800000 --idle_c 2400000&lt;/code>&lt;/pre>
&lt;p>The timer values specified are in milliseconds, so this example will park the disk heads after 30 minutes of inactivity. The current settings for a disk can be queried with the &lt;code>--showEPCSettings&lt;/code> flag.&lt;/p>
&lt;p>My Seagate Archive SMR disk (which began life as an external hard drive and was retired from that role when it became too small to hold as much as I wanted to back up to it) apparently doesn&amp;rsquo;t support reporting EPC settings (since asking for them says so), and initially didn&amp;rsquo;t accept new values for the idle timers either. After using the &lt;code>--EPCfeature enable&lt;/code> option however, it seems to have accepted custom idle timer values: I&amp;rsquo;ll have to watch the park counts on that to ensure it actually worked.&lt;/p>
&lt;h2 id="verifying-settings">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/hdd-parking-monitoring/#verifying-settings">Verifying settings&lt;/a>
&lt;/h2>
&lt;p>At a glance, changing idle3 and EPC settings seems to have done the job nicely; here is the same graph of head park rates per disk as before, but on a smaller timescale that makes individual head parks visible. The parking rate basically drops to zero at the time I updated the settings for the Seagate drives, and the Western Digital one hasn&amp;rsquo;t changed because it needs to be powered off to change that setting and I haven&amp;rsquo;t done so yet.&lt;/p>
&lt;p>&lt;img src="prometheus-load-cycles-post-update-fs8.png" alt="">&lt;/p>
&lt;h3 id="monitoring-ssds">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/hdd-parking-monitoring/#monitoring-ssds">Monitoring SSDs&lt;/a>
&lt;/h3>
&lt;p>This same setup can be used to monitor wear on SSDs, which is rather convenient. While SSDs don&amp;rsquo;t have any heads to park, most do report a &lt;code>media_wearout_indicator&lt;/code> that represents the amount of data written to the device in relation to the amount that it&amp;rsquo;s specified to accept before the Flash storage medium wears out.&lt;/p>
&lt;p>For the system I&amp;rsquo;m monitoring here, the SSD that it boots from has a wearout indicator sitting on 95 of 100 (only 5% of the rated life consumed), visibly unchanged for a long time so it&amp;rsquo;s not very interesting as an example.&lt;/p></description></item><item><title>Cross-compiling CMake projects for Windows</title><link>https://www.taricorp.net/2021/cmake-windows-cross/</link><pubDate>Wed, 20 Jan 2021 22:55:37 +0000</pubDate><guid>https://www.taricorp.net/2021/cmake-windows-cross/</guid><description>&lt;p>I sometimes find myself wanting to cross-compile programs with CMake on a Linux machine such that I get a standalone .exe that can be given to mostly non-technical Windows users to run. This isn&amp;rsquo;t hard, but finding the right options is a little bit of a challenge every time, so now I&amp;rsquo;m recording the procedure here; both as a reminder to myself, and to provide a quick recipe that future searchers can use.&lt;/p>
&lt;p>Cross-compiling for Windows will of course need an appropriate toolchain, which these days tends to be &lt;a href="http://mingw-w64.org/">mingw-w64&lt;/a>. Many Linux distributions provide packages for it: &lt;a href="https://packages.debian.org/sid/mingw-w64">&lt;code>mingw-w64&lt;/code>&lt;/a> on Debian (including Ubuntu and variants), &lt;a href="https://archlinux.org/packages/community/x86_64/mingw-w64-gcc/">&lt;code>mingw-w64-gcc&lt;/code>&lt;/a> on Arch and similar for other distributions.&lt;/p>
&lt;hr>
&lt;p>CMake &lt;a href="https://cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html#cross-compiling">documents how to specify cross-compilation options&lt;/a>, but it&amp;rsquo;s not terribly clear which settings are mandatory. For simple needs, only three variables must be set:&lt;/p>
&lt;ul>
&lt;li>&lt;code>CMAKE_SYSTEM_NAME&lt;/code> being set implies that you&amp;rsquo;re cross-compiling, which will prevent cmake from trying to do things like run the binaries it builds (unless they&amp;rsquo;re tool binaries being built for the host, rather than the target).&lt;/li>
&lt;li>&lt;code>CMAKE_C_COMPILER&lt;/code> is the name of the compiler to use&lt;/li>
&lt;li>&lt;code>CMAKE_CXX_COMPILER&lt;/code> is the name of the compiler to use for C++ sources&lt;/li>
&lt;/ul>
&lt;p>Targeting mingw-64 then, the cmake invocation looks like this:&lt;/p>
&lt;pre tabindex="0">&lt;code>cmake -DCMAKE_SYSTEM_NAME=Windows \
-DCMAKE_C_COMPILER=i686-w64-mingw32-gcc \
-DCMAKE_CXX_COMPILER=i686-w64-mingw32-g++ \
path_to_sources&lt;/code>&lt;/pre>
&lt;p>The &lt;code>i686-&lt;/code> prefixed compiler builds 32-bit binaries, which I usually prefer to build because they&amp;rsquo;ll work both on 32- and 64-bit Windows. If no support for 32-bit Windows is required, the &lt;code>x86_64-&lt;/code>-prefixed tools will build 64-bit binaries instead (eg, &lt;code>x86_64-w64-mingw32-gcc&lt;/code>).&lt;/p>
&lt;p>When building binaries to share it&amp;rsquo;s probably helpful to do a non-debug build by also setting &lt;code>CMAKE_BUILD_TYPE&lt;/code>, perhaps to &lt;code>MinSizeRel&lt;/code> or &lt;code>RelWithDebInfo&lt;/code>.&lt;/p>
&lt;h2 id="libraries">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/cmake-windows-cross/#libraries">Libraries&lt;/a>
&lt;/h2>
&lt;p>Libraries that you might depend on can be built in the same way, and installed to a chosen directory by setting &lt;code>CMAKE_INSTALL_PREFIX&lt;/code> then building the install target. For instance building a libpng static library
(which itself provides options to enable a shared library and tests which we turn off):&lt;/p>
&lt;pre tabindex="0">&lt;code>cmake -DCMAKE_SYSTEM_NAME=Windows \
-DCMAKE_C_COMPILER=i686-w64-mingw32-gcc \
-DCMAKE_CXX_COMPILER=i686-w64-mingw32-g++ \
-DCMAKE_INSTALL_PREFIX=$HOME/windows_binaries \
-DPNG_SHARED=OFF -DPNG_TESTS=OFF \
path_to_sources
cmake --build . --target install&lt;/code>&lt;/pre>
&lt;p>&lt;a href="https://www.taricorp.net/2012/locating-packages-with-cmake/">As I&amp;rsquo;ve observed previously&lt;/a>, you can then link against these libraries by setting &lt;code>CMAKE_PREFIX_PATH&lt;/code>. So if we want to build against the libpng that was just built and generate a &amp;ldquo;release&amp;rdquo; binary:&lt;/p>
&lt;pre tabindex="0">&lt;code>cmake -DCMAKE_SYSTEM_NAME=Windows \
-DCMAKE_C_COMPILER=i686-w64-mingw32-gcc \
-DCMAKE_CXX_COMPILER=i686-w64-mingw32-g++ \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_PREFIX_PATH=$HOME/windows_binaries \
path_to_sources&lt;/code>&lt;/pre>
&lt;h3 id="libpng-notes">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2021/cmake-windows-cross/#libpng-notes">libpng notes&lt;/a>
&lt;/h3>
&lt;p>While libpng (used as an example above) provides a nice CMake configuration that cross-compiles cleanly, it depends on zlib which does not. However zlib does distribute a Makefile intended for targeting GNU tools on Windows. So before building libpng I first compile zlib with a particular incantation:&lt;/p>
&lt;pre tabindex="0">&lt;code>make -f win32/Makefile.gcc \
BINARY_PATH=${INSTALL_DIR}/bin \
INCLUDE_PATH=${INSTALL_DIR}/include \
LIBRARY_PATH=${INSTALL_DIR}/lib \
SHARED_MODE=0 \
PREFIX=i686-w64-mingw32- \
install&lt;/code>&lt;/pre>
&lt;p>Because this is just a Makefile, sadly the appropriate options need to be discovered by reading the source: projects that natively use CMake are much easier to support!&lt;/p></description></item><item><title>Windows MIME type detection pitfalls</title><link>https://www.taricorp.net/2020/windows-mime-pitfalls/</link><pubDate>Thu, 24 Sep 2020 23:12:12 +0000</pubDate><guid>https://www.taricorp.net/2020/windows-mime-pitfalls/</guid><description>&lt;p>I&amp;rsquo;ve been doing some &lt;a href="https://djangoproject.com">Django&lt;/a> development lately, and was mystified why it seemed the &lt;a href="https://github.com/jazzband/django-debug-toolbar">debug toolbar&lt;/a> on my local development instance wasn&amp;rsquo;t showing up, though it had been in the past. It turns out to have been a surprising interaction between browsers sometimes enforcing that resources be served with correct MIME types and the way Windows provides system-wide MIME type configuration (which seems to have major flaws)!&lt;/p>
&lt;p>Investigating further, I found that the markup for the debug toolbar was in fact being generated and placed in the pages served by my development server, but not being shown. I guess that there may have been a recent update to &lt;code>django-debug-toolbar&lt;/code> that may have broken it, and found that &lt;a href="https://pypi.org/project/django-debug-toolbar/#history">version 3.0 (quickly followed by 3.1) had recently been released&lt;/a>. Downgrading to version 2.2 fixed the issue, so I was confident I hadn&amp;rsquo;t somehow misconfigured the debug toolbar.&lt;/p>
&lt;p>Looking more closely at the browser console, I noticed an important error message:&lt;/p>
&lt;blockquote>
&lt;p>Loading module from “http://localhost:8000/static/debug_toolbar/js/toolbar.js” was blocked because of a disallowed MIME type (“text/plain”).&lt;/p>
&lt;/blockquote>
&lt;p>It seems that &lt;code>django-debug-toolbar&lt;/code> 3.0 started loading the scripts that make it work as Javascript modules rather than plain scripts, because in the past I&amp;rsquo;ve noticed (and learned to ignore) similar warnings when using the Django development server:&lt;/p>
&lt;blockquote>
&lt;p>The script from “http://localhost:8000/static/myscript.js” was loaded even though its MIME type (“text/plain”) is not a valid JavaScript MIME type.&lt;/p>
&lt;/blockquote>
&lt;p>This change has thus broken the debug toolbar because the scripts that make it work are no longer being executed since my local server is serving javascript files with the wrong &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type">&lt;code>Content-Type&lt;/code>&lt;/a>. Why is that?&lt;/p>
&lt;h2 id="mime-type-guessing">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/windows-mime-pitfalls/#mime-type-guessing">MIME type guessing&lt;/a>
&lt;/h2>
&lt;p>Static files (including the Javascript files causing me problems) are served by Django when using the development server, using the &lt;code>django.view.static.serve&lt;/code> view. Of particular note here, it uses the &lt;code>mimetypes&lt;/code> module to guess the MIME type of files based on their name:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span> content_type, encoding &lt;span style="color:#000;font-weight:bold">=&lt;/span> mimetypes&lt;span style="color:#000;font-weight:bold">.&lt;/span>guess_type(&lt;span style="color:#0086b3">str&lt;/span>(fullpath))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> content_type &lt;span style="color:#000;font-weight:bold">=&lt;/span> content_type &lt;span style="color:#000;font-weight:bold">or&lt;/span> &lt;span style="color:#d14">&amp;#39;application/octet-stream&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Digging into what &lt;code>mimetypes&lt;/code> does, it&amp;rsquo;s documented to read mappings from the registry on Windows, and from a set of known paths on all other operating systems. The relevant code for loading mappings from the registry looks like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">with&lt;/span> _winreg&lt;span style="color:#000;font-weight:bold">.&lt;/span>OpenKey(_winreg&lt;span style="color:#000;font-weight:bold">.&lt;/span>HKEY_CLASSES_ROOT, &lt;span style="color:#d14">&amp;#39;&amp;#39;&lt;/span>) &lt;span style="color:#000;font-weight:bold">as&lt;/span> hkcr:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> subkeyname &lt;span style="color:#000;font-weight:bold">in&lt;/span> enum_types(hkcr):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">try&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">with&lt;/span> _winreg&lt;span style="color:#000;font-weight:bold">.&lt;/span>OpenKey(hkcr, subkeyname) &lt;span style="color:#000;font-weight:bold">as&lt;/span> subkey:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># Only check file extensions&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#000;font-weight:bold">not&lt;/span> subkeyname&lt;span style="color:#000;font-weight:bold">.&lt;/span>startswith(&lt;span style="color:#d14">&amp;#34;.&amp;#34;&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> mimetype, datatype &lt;span style="color:#000;font-weight:bold">=&lt;/span> _winreg&lt;span style="color:#000;font-weight:bold">.&lt;/span>QueryValueEx(subkey, &lt;span style="color:#d14">&amp;#39;Content Type&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> datatype &lt;span style="color:#000;font-weight:bold">!=&lt;/span> _winreg&lt;span style="color:#000;font-weight:bold">.&lt;/span>REG_SZ:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>add_type(mimetype, subkeyname, strict)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>In short: it enumerates keys in &lt;code>HKEY_CLASSES_ROOT&lt;/code>, ignoring any that don&amp;rsquo;t have a name starting with &amp;ldquo;.&amp;rdquo; and uses the value of a &amp;ldquo;Content Type&amp;rdquo; subkey (if present) as the MIME type for files with that extension.&lt;/p>
&lt;p>As far as this goes, it seems quite reasonable. Microsoft document &lt;code>HKEY_CLASSES_ROOT&lt;/code> as a &lt;a href="https://support.microsoft.com/en-us/help/256986/windows-registry-information-for-advanced-users">combined view of system-wide and per-user filetype associations&lt;/a>, and &lt;a href="https://docs.microsoft.com/en-us/windows/win32/shell/fa-file-types">the &amp;ldquo;Content Type&amp;rdquo; subkey may be set to a file type&amp;rsquo;s MIME type&lt;/a>.&lt;/p>
&lt;p>Opening up the registry editor on my system, I do see that the value of &lt;code>HKEY_CLASSES_ROOT\.js\Content Type&lt;/code> is &lt;code>text/plain&lt;/code>. So this is where Python got the incorrect MIME type from, and I can fix it by changing the value of &lt;code>Content Type&lt;/code>. For instance, this snippet in a .reg file (maybe &lt;code>js.reg&lt;/code>) can be imported to set a correct MIME time for .js files, changing a per-user setting if set or the global one otherwise:&lt;/p>
&lt;pre tabindex="0">&lt;code>Windows Registry Editor Version 5.00
[HKEY_CLASSES_ROOT\.js]
&amp;#34;Content Type&amp;#34;=&amp;#34;text/javascript&amp;#34;&lt;/code>&lt;/pre>
&lt;h2 id="problems-with-this-system">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/windows-mime-pitfalls/#problems-with-this-system">Problems with this system&lt;/a>
&lt;/h2>
&lt;p>While it seems abstractly reasonable for programs to use the registry-based mechanism for getting file MIME types, the other ways it is used in practice seems inappropriate for these purposes.&lt;/p>
&lt;p>As a Django-based example, I found &lt;a href="https://stackoverflow.com/q/16303098/2658436">a Stack Overflow question with similar incorrect MIME type&lt;/a> while investigating my initial problem. While the original question asker arrived at a workaround of manually configuring a correct MIME type in Python, after investigation it seems clear that the problem is better considered a system misconfiguration and I feel correcting the data in the registry is more appropriate.&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>&lt;/p>
&lt;p>Python isn&amp;rsquo;t even the only platform that suffers from certain prevalence of incorrect MIME types in the Windows registry. A &lt;a href="https://github.com/golang/go/issues/32350">fairly recent bug for the Go language&lt;/a> indicates that the standard library for Go uses the same approach for guessing MIME types, with the same tendency to get wrong data.&lt;/p>
&lt;p>On a fresh install of Windows, it even seems that many MIME types are unconfigured. When I checked what content type was set in the registry for .js files on a pristine copy of Windows (using a Windows Sandbox to have a quick and easy look), I found that there was none set. Being unset by default, it seems that in many cases (if nothing has set a mapping in the registry) Django will serve javascript as &lt;code>application/octet-stream&lt;/code> because Python won&amp;rsquo;t find any configuration, which seems incorrect.&lt;/p>
&lt;h3 id="fixing-the-problem-once-and-for-all">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/windows-mime-pitfalls/#fixing-the-problem-once-and-for-all">Fixing the problem once and for all&lt;/a>
&lt;/h3>
&lt;p>While it would be relatively easy to ship a reasonable MIME type mapping with Python or Django (indeed, Go seems to do this and use values from the registry instead if available), doing so would only fix the default case where no value is set.&lt;/p>
&lt;p>The root problem with MIME type guessing on Windows seems to be that incorrect types often get written to the registry. It&amp;rsquo;s not obvious where these incorrect values come from, but I suppose the bad data tends to be written by some programs when they are configured to open those files. It seems like these programs might be badly-behaved when they do this since associating a file type with a program doesn&amp;rsquo;t necessarily say anything about the file&amp;rsquo;s MIME type, but it seems widespread enough that fixing that is intractable.&lt;/p>
&lt;p>While it is possible to provide &amp;ldquo;correct&amp;rdquo; MIME type mappings with software (and this will often work around the problem with Windows), doing so is not correct in all cases because users may sometimes &lt;em>want&lt;/em> non-standard mappings and it&amp;rsquo;s not possible to tell the difference between a mapping that has intentionally been set to an unusual value and one that was accidentally set that way.&lt;/p>
&lt;p>Aspirationally, changes to the &lt;code>mimetypes&lt;/code> API in Python (or similar ones in Go, I suppose) might improve the situation for many users. An option could be added that would tell the system to either ignore or prefer user-configured mappings, allowing applications to choose their preferred mode. However, doing so pushes complexity onto application authors in ways that probably cannot be predicted in all situations, which would mean the option would need to be exposed to application users to be set as required.&lt;/p>
&lt;hr>
&lt;p>As best I can tell, the least-wrong solution to the situation of users often ending up with incorrect MIME type mappings on Windows is to make the problem more widely known so new software doesn&amp;rsquo;t misbehave in the same ways around writing bad mappings, and users have an easier time of correcting the problem if it does occur.&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup>&lt;/p>
&lt;p>&lt;span style="font-size: 200%">&lt;strong>Now you know!&lt;/strong>&lt;/span>&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>I &lt;a href="https://code.djangoproject.com/ticket/32041">filed a bug against Django&lt;/a> after doing this investigation myself, and found a proposal from 2008 that Django ship default MIME types for these purposes. It remains to be seen what the Django developers think of the situation.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>If APIs were changed to allow specifying the preferred source of mappings then better documentation would be needed anyway, so end users could be aware of the option if it were needed.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>tihle: a unique TI calculator emulator</title><link>https://www.taricorp.net/2020/introducing-tihle/</link><pubDate>Wed, 29 Jul 2020 09:00:00 +1000</pubDate><guid>https://www.taricorp.net/2020/introducing-tihle/</guid><description>&lt;p>Today I&amp;rsquo;m publishing &lt;a href="https://gitlab.com/taricorp/tihle">tihle&lt;/a>, a new emulator
targeting TI graphing calculators (currently only the 83+, but maybe others
later). There&amp;rsquo;s rather a lot to say about it, but here I will discuss the
&lt;a href="#motivation">motivation for a new emulator&lt;/a> and &lt;a href="#the-state-of-the-art">the state of
the art&lt;/a> followed by technical notes on
the &lt;a href="#design">design&lt;/a> and &lt;a href="#implementation">initial development process&lt;/a>.&lt;/p>
&lt;figure>
&lt;picture>
&lt;source type="image/webp" srcset="https://www.taricorp.net/2020/introducing-tihle/demo.webp">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/demo.gif" loading=lazy
alt="tihle running Phoenix">
&lt;/picture>
&lt;figcaption>Yes, it runs Phoenix!&lt;/figcaption>
&lt;/figure>
&lt;p>Read on for that discussion, or jump straight to the &lt;strong>&lt;a href="https://gitlab.com/taricorp/tihle">project
homepage&lt;/a>&lt;/strong> on GitLab which has a &lt;strong>live
demo&lt;/strong> that runs in your web browser and other resources including
source code.&lt;/p>
&lt;aside class="information">
&lt;h2 style="margin: 0;">Audio version&lt;/h2>
&lt;p>I&amp;rsquo;ve also recorded an audio version of this writing, embedded here, which you
might prefer to listen to rather than read these 7000 words.&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup> It mostly
matches the written version, but drops some of the footnotes and code that is
difficult to narrate sensibly. Runtime is approximately 35 minutes.&lt;/p>
&lt;audio controls preload=none style="display: block; width: 100%;">
&lt;source src="https://www.taricorp.net/2020/introducing-tihle/narrated.opus" type="audio/ogg; codecs=opus"/>
&lt;source src="https://www.taricorp.net/2020/introducing-tihle/narrated.mp3" type="audio/mpeg"/>
Failed to load audio.
&lt;/audio>
&lt;/aside>
&lt;aside>
&lt;nav id="TableOfContents">
&lt;ul>
&lt;li>&lt;a href="#motivation">Motivation&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#death-of-a-platform">Death of a platform&lt;/a>&lt;/li>
&lt;li>&lt;a href="#historical-interest">Historical interest&lt;/a>&lt;/li>
&lt;li>&lt;a href="#a-calling">A calling&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#the-state-of-the-art">The state of the art&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#os-implementations">OS implementations&lt;/a>&lt;/li>
&lt;li>&lt;a href="#boot-code">Boot code&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#design">Design&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#a-novel-emulator-thinking-like-a-gamer">A novel emulator: thinking like a gamer&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#implementation">Implementation, or: the development log&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#cores">Cores&lt;/a>&lt;/li>
&lt;li>&lt;a href="#taking-emulation-up-a-level">Taking emulation up a level&lt;/a>&lt;/li>
&lt;li>&lt;a href="#mirageos">MirageOS&lt;/a>&lt;/li>
&lt;li>&lt;a href="#hoisted-by-my-own-bugs-a-debugging-adventure">Hoisted by my own bugs: a debugging adventure&lt;/a>&lt;/li>
&lt;li>&lt;a href="#rethinking-traps">Rethinking traps&lt;/a>&lt;/li>
&lt;li>&lt;a href="#the-need-for-an-os">The need for an OS&lt;/a>&lt;/li>
&lt;li>&lt;a href="#debugging-in-pictures">Debugging in pictures&lt;/a>&lt;/li>
&lt;li>&lt;a href="#handling-input">Handling input&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#current-state-and-future">Current state and future&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#links-and-further-reading">Links and further reading&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;/nav>
&lt;/aside>
&lt;h2 id="motivation">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#motivation">Motivation&lt;/a>
&lt;/h2>
&lt;p>I&amp;rsquo;ve long been involved in the community of people who program for the &lt;a href="https://en.wikipedia.org/wiki/TI-83_series">TI-83+ series of graphing calculators&lt;/a>; it was on that platform that I got started programming in the first place. These days I play more of an advisory than active role in writing programs, by running much of &lt;a href="https://www.cemetech.net/">Cemetech&lt;/a> behind the scenes and providing the occasional input to others. I think calculators are an excellent way to introduce people to programming, since they are readily available devices and not too complex to get started on, while still having enough capability to support experienced developers in doing interesting things (because they are readily accessible embedded systems). I fully credit getting started with programming calculators for having ended up programming embedded systems profesionally.&lt;/p>
&lt;figure>
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/calcdcc.jpg" loading=lazy >
&lt;figcaption>Tim's workspace for
&lt;a href="https://www.cemetech.net/forum/viewtopic.php?p=262170#262170">CalcDCC&lt;/a>
looks like it could contain any embedded engineer's prototype.
&lt;/figcaption>
&lt;/figure>
&lt;p>Unfortunately, the future of programming for TI calculators seems to be in peril. About a month ago, the news came out that new versions of the OS for the TI-84+ CE (the most recent variant of the 83+ featuring a color screen and improved eZ80 processor) &lt;a href="https://www.cemetech.net/news/2020/5/949/_/ti-removes-asmc-programming-from-ti-83-premium-ce">will remove support for running native code&lt;/a>.&lt;/p>
&lt;h3 id="death-of-a-platform">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#death-of-a-platform">Death of a platform&lt;/a>
&lt;/h3>
&lt;p>While this doesn&amp;rsquo;t necessarily affect older calculators or programs, in the long
term it seems to spell doom for calculators as an inroad to developing embedded
software in particular. Programs written in the calculators&amp;rsquo; dialect of BASIC
continue to be accessible and a new Python implementation fills the void
somewhat, but they lack in depth- where a user could spend time and effort
developing native programs constrained only by the hardware they run on before, in
the future users will be &lt;strong>limited to only those capabilities provided by TI&amp;rsquo;s
software&lt;/strong>.&lt;/p>
&lt;p>In much the same way that I believe &lt;a href="https://en.wikipedia.org/wiki/Scratch_(programming_language)">Scratch&lt;/a> is a decent introduction to programming but completely hides interesting details and is not seriously used by anybody but those using it a learning tool, I also believe removal of support for running native code will ultimately mean people will no longer have calculators (which are often required school equipment!) as a useful entrypoint to serious programming.&lt;/p>
&lt;h3 id="historical-interest">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#historical-interest">Historical interest&lt;/a>
&lt;/h3>
&lt;p>In addition to the loss of a way to introduce people to programming, removing support for native code also effectively throws away a large existing library of programs that stretches back more than 20 years (nicely embodied in &lt;a href="https://www.ticalc.org/">ticalc.org&lt;/a>, which first came online in 1996). While the 84+ CE is a young platform relative to the TI-83+ series as a whole (and is incompatible with earlier software), it already has a rich library of programs created by users that will effectively be lost when they can no longer be run on the hardware they are designed for.&lt;/p>
&lt;figure>
&lt;picture>
&lt;source type="image/webp" srcset="https://www.taricorp.net/2020/introducing-tihle/calcuzap.webp">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/calcuzap.gif" loading=lazy
width=320
alt="Calcuzap, a top-down space shooter game">
&lt;/picture>
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/cca.png" loading=lazy
width=320
alt="A port of Colossal Cave Adventure, a very early text adventure">
&lt;picture>
&lt;source type="image/webp" srcset="https://www.taricorp.net/2020/introducing-tihle/tetrica.webp">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/tetrica.gif" loading=lazy
width=320
alt="Tetric A, a Tetris clone">
&lt;/picture>
&lt;figcaption>
A small selection of the games that are available for the TI-84+ CE today:
&lt;a href="https://www.ocf.berkeley.edu/~pad/game-ti8c-calcuzap.html">Calcuzap&lt;/a>,
&lt;a href="https://github.com/drdnar/open-adventure-ce">Colossal Cave Adventure&lt;/a> and
&lt;a href="https://www.cemetech.net/downloads/files/1347">Tetric A&lt;/a>.
&lt;/figcaption>
&lt;/figure>
&lt;p>The precedent of effectively &lt;strong>destroying the work of community members&lt;/strong> is
troubling, and I am motivated to look for ways to preserve it. In much the same
way that there still exist thriving communities around long-obsolete home
computers like the &lt;a href="https://apple2online.com/">Apple II&lt;/a> and &lt;a href="https://thec64community.online/">Commodore
64&lt;/a> today, I think it&amp;rsquo;s worthwhile to try to
provide a similar opportunity by preserving the platform into a hostile future
by working to give the systems and software ongoing life beyond what their
creators envisioned (or perhaps more pointedly, beyond what they decided they
could make money from).&lt;/p>
&lt;p>As a newer platform than those early home computers that still have active communities, information and resources may be rather easier to come by for these calculators because much of the information was born digital and has always been online. However this is also hazardous to preservation, because if items are readily available online there may not be any replacements available if the original goes away. For instance, TI used to &lt;a href="https://www.ticalc.org/archives/news/articles/1/19/19421.html">freely provide an SDK for the 83+&lt;/a>, but have more recently made it &lt;a href="https://education.ti.com/en/customer-support/sdk-request">much more difficult to access&lt;/a>. Perhaps even more concerningly, TI&amp;rsquo;s web site no longer seems to provide &lt;em>any&lt;/em> information about the &lt;a href="https://www.ticalc.org/basics/calculators/ti-84plus-cse.html">TI-84+ CSE&lt;/a>, seeming to deny that it ever existed (though manuals and software are still available if you know where to look).&lt;/p>
&lt;h3 id="a-calling">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#a-calling">A calling&lt;/a>
&lt;/h3>
&lt;p>Recognizing these concerns about the loss of a valuable resource for beginning programmers and loss of interesting history to the grind of the education-industrial complex,&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup> what are we to do?&lt;/p>
&lt;p>There exist a number of web sites that document the calculators and offer
resources related to them- I&amp;rsquo;m already involved in that, which is valuable and
&lt;a href="https://archive.org/details/ticalc-2014-08">generally well-preserved&lt;/a> on &lt;a href="https://web.archive.org/">the
Internet Archive&lt;/a>. But if the existing resources are
useless on current hardware as they largely become when large classes of
programs are not runnable, new tools become required: I believe this situation
calls for emulation of the calculators, to &lt;strong>make the platform accessible to
everybody&lt;/strong>.&lt;/p>
&lt;h2 id="the-state-of-the-art">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#the-state-of-the-art">The state of the art&lt;/a>
&lt;/h2>
&lt;p>There already exist a number of emulators for TI calculators, including:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="http://wabbitemu.org/">WabbitEmu&lt;/a>, which is open-source and generally good
quality.&lt;/li>
&lt;li>&lt;a href="https://www.cemetech.net/projects/jstified/">jsTIfied&lt;/a>, an emulator
available as a web application.&lt;/li>
&lt;li>&lt;a href="https://www.ticalc.org/archives/files/fileinfo/84/8442.html">Virtual TI&lt;/a>,
one of the earliest calculator emulators to be created.&lt;/li>
&lt;li>&lt;a href="https://wikiti.brandonw.net/index.php?title=Emulators:PindurTI">PindurTI&lt;/a>,
which is now defunct but offered some useful capabilities in its day.&lt;/li>
&lt;li>..and others&lt;/li>
&lt;/ul>
&lt;p>While most of these are accurate enough to run most programs, this is not the
whole story: all of them &lt;strong>require a ROM image to work&lt;/strong>. The ROM includes the
calculator&amp;rsquo;s operating system and boot code, which implement huge swaths of
functionality from keyboard and display handling through implementing
floating-point arithmetic and the TI-BASIC interpreter.&lt;/p>
&lt;p>TI jealously protect their OS and boot code because from the point of view of
their business, the entire value of the calculator is in its software. If an
emulator can run the same software on a more general machine, what reason is
there for a user to purchase a $100 device from TI?&lt;sup id="fnref:3">&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref">3&lt;/a>&lt;/sup> But if we want
to preserve &lt;em>community&lt;/em> software for emulation, we don&amp;rsquo;t really care about the
same functions that TI are interested in protecting.&lt;/p>
&lt;p>Though truly accurate emulation still requires all OS functions be available in
the same way, many applications only use a small subset of them. So what options
are available for emulating the system software?&lt;/p>
&lt;h3 id="os-implementations">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#os-implementations">OS implementations&lt;/a>
&lt;/h3>
&lt;ul>
&lt;li>TI&amp;rsquo;s EOS (TI-OS) is provided with the calculator, and can be downloaded
from TI in order to update a calculator. However, they added a &lt;a href="https://www.cemetech.net/forum/viewtopic.php?t=8819">clickwrap
agreement to the OS in
2013&lt;/a> that attempts
to forbid use of the OS for emulation. While that clause is questionably
enforceable, nobody (myself included) is very interested in challenging it-
mostly because TI has historically tended to turn a blind eye to the
distribution of ROMs. Any such distribution in a larger, more public forum
increases the risk of calling in the lawyers, so for these and the reasons
discussed above, I do not believe it is tenable to use TI&amp;rsquo;s OS for
general-availability emulation.&lt;/li>
&lt;li>Brandon Wilson&amp;rsquo;s &lt;a href="https://www.brandonw.net/calculators/OS2/">OS2&lt;/a> seems most
relevant, in that it wants to reimplement TI-OS &amp;ldquo;but better.&amp;rdquo; However,
it hasn&amp;rsquo;t been updated in 11 years at this point and seems dead. It&amp;rsquo;s also
dependent on the assembler and tools provided by Zilog, which only run on
Windows and are closed-source.&lt;/li>
&lt;li>&lt;a href="https://www.cemetech.net/forum/viewtopic.php?t=5686">GlassOS&lt;/a>
is implemented in C and actually seems useable, but lacks compatibility
with any existing software.&lt;/li>
&lt;li>&lt;a href="https://github.com/KnightOS/KnightOS">KnightOS&lt;/a> is UNIX-like and seems to
have a selection of useful software, but again is not compatible with any
existing software beyond a small library of ports targeting it.&lt;/li>
&lt;li>Many other people (myself included) have attempted to write OSes, but they
don&amp;rsquo;t merit mention here because they&amp;rsquo;re of even less practical use.&lt;/li>
&lt;/ul>
&lt;h3 id="boot-code">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#boot-code">Boot code&lt;/a>
&lt;/h3>
&lt;p>While the calculator OS implements most functionality, the boot code is also
relevant- TI&amp;rsquo;s OS uses some support code from the boot code, and of course the
boot code is responsible for handling the system power-up sequence and allowing
recovery from a no-OS situation.&lt;/p>
&lt;p>The boot code on these calculators is meant to be read-only,&lt;sup id="fnref:4">&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref">4&lt;/a>&lt;/sup> and is
programmed into the device at the factory. Thus, there is no official source for
boot code short of reading it from the memory of a physical calculator and the
community consensus tends to be that sharing copies of the boot code would
infringe on TI&amp;rsquo;s copyright.&lt;/p>
&lt;p>&lt;a href="https://www.ticalc.org/archives/files/authors/72/7233.html">Ben &amp;ldquo;FloppusMaximus&amp;rdquo;
Moody&lt;/a> at one time
published a reimplementation of the calculator boot code called &amp;ldquo;BootFree,&amp;rdquo;
which seems to have disappeared from the Internet except for the version
integrated with WabbitEmu. While it seems BootFree disappeared for reasons
related to the addition of the clickwrap agreement to the EOS downloads provided
by TI,&lt;sup id="fnref:5">&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref">5&lt;/a>&lt;/sup> &lt;sup id="fnref:6">&lt;a href="#fn:6" class="footnote-ref" role="doc-noteref">6&lt;/a>&lt;/sup> I don&amp;rsquo;t think the same
reasons apply to emulation of non-EOS software- thus, BootFree seems like a
fine resource in pursuit of making emulation available to all.&lt;/p>
&lt;h2 id="design">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#design">Design&lt;/a>
&lt;/h2>
&lt;p>If the goal is to freely emulate calculator programs, we recognize that while
the boot code situation seems compatible with accurate emulation, the current
available
&lt;strong>&lt;em>&lt;abbr title="Software allowing anybody to use or modify it for any reason; Open Source.">libre&lt;/abbr>&lt;/em>
OSes are insufficient&lt;/strong>. While it is technically feasible to
emulate TI&amp;rsquo;s EOS (the only reasonable choice for emulating the current software
library), legal forces make it untenable to use as a base for publicly-available
emulation with the goal of allowing anybody to run programs for the platform.&lt;/p>
&lt;p>It&amp;rsquo;s also worth noting that there are two major divisions in kinds of programs
that run on these calculators: they can be implemented in the provided BASIC
dialect (TI-BASIC), or distributed as machine code for the calculator. TI-OS
implements the interpreter for TI-BASIC so both of these can be run on the
existing emulators, but because using TI-OS is not an option for this project we
need to choose how to approach things. The possibility space of emulation
projects forks:&lt;/p>
&lt;ul>
&lt;li>Build a TI-BASIC interpreter, not necessarily one that runs on an
emulated calculator.&lt;/li>
&lt;li>Create a whole-machine emulator that works without TI-OS.&lt;/li>
&lt;/ul>
&lt;p>While I&amp;rsquo;ve explored the feasibility of creating a from-scratch TI-BASIC
interpreter in the past, it&amp;rsquo;s never made much progress and I got rather
bogged down in accurate emulation of the calculator&amp;rsquo;s rather unique decimal
floating-point format.&lt;sup id="fnref:7">&lt;a href="#fn:7" class="footnote-ref" role="doc-noteref">7&lt;/a>&lt;/sup> I have instead chosen to work on a
&lt;strong>whole-machine emulator that doesn&amp;rsquo;t require TI-OS&lt;/strong>.&lt;/p>
&lt;h3 id="a-novel-emulator-thinking-like-a-gamer">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#a-novel-emulator-thinking-like-a-gamer">A novel emulator: thinking like a gamer&lt;/a>
&lt;/h3>
&lt;p>It is interesting to compare emulation of calculator programs to that of game
systems (which are also common targets for emulation). In game systems the
interest in emulation is almost entirely on the programs (games) that are
separate from the system. Similarly, I am currently interested in providing
emulation of third-party programs that happen to be run on a calculator.&lt;/p>
&lt;p>Game systems tend to have small amounts of platform code that game software
might use, which emulators tend to hide the existence of: a tiny minority of
emulator users would have the capability to extract the firmware from a game
system they own, and though most users probably engage in copyright infringement
to obtain copies of game software to run on emulators, the emulators
owe their existence to the fact that they &lt;strong>don&amp;rsquo;t contain any
copyright-infringing code themselves&lt;/strong>.&lt;/p>
&lt;p>On calculators, emulation of interesting programs with existing emulators
requires a copy of the system software which notionally requires access to a physical
calculator to obtain. This means the emulators can reliably be considered not to fall afoul
of any laws, though the barrier to entry is raised. However, the programs themselves
are almost entirely made available freely by their authors- among the hobbyists who
write calculator software, the platform is largely open. If an emulator can do without
a ROM image of a calculator, it is feasible to freely provide the existing library of
software to all comers.&lt;/p>
&lt;p>Game system emulators tend to solve their inability to distribute the system
firmware through high-level emulation: various operations that may be taken by
software can be recognized, and the emulator can implement those operations
itself. This approach both avoids any dependency on system firmware for its
implementation and can often achieve better performance because the emulator
need not faithfully emulate the underlying process- only its side effects are
in scope.
Though high-level emulation tends to sacrifice accuracy in ways that can cause
some emulated software to behave incorrectly, this is often because the emulator
must take shortcuts to achieve acceptable performance. When emulating a
calculator with a 40-year-old CPU architecture that runs at a few Megahertz,
acceptable performance should be much easier to achieve without noticeable
shortcuts.&lt;sup id="fnref:8">&lt;a href="#fn:8" class="footnote-ref" role="doc-noteref">8&lt;/a>&lt;/sup>&lt;/p>
&lt;p>With the precedent of existing emulators that are very faithful to the known
hardware behavior and decision that no existing calculator OS is appropriate for
the application, &lt;strong>tihle&lt;/strong> is born. The name indicates how important that
feature is to its existence: it is a &lt;strong>h&lt;/strong>igh-&lt;strong>l&lt;/strong>evel-&lt;strong>e&lt;/strong>mulator for &lt;strong>TI&lt;/strong>
calculators.&lt;sup id="fnref:9">&lt;a href="#fn:9" class="footnote-ref" role="doc-noteref">9&lt;/a>&lt;/sup>&lt;/p>
&lt;h2 id="implementation">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#implementation">Implementation, or: the development log&lt;/a>
&lt;/h2>
&lt;p>With a plan in mind, I had to start building something. I could have started
with the core of one of the existing emulators, but had a few reasons
to instead start from (mostly) scratch:&lt;/p>
&lt;ul>
&lt;li>Code quality is highly variable, and I&amp;rsquo;m not familiar with their code.
I have no idea what kinds of bugs and pitfalls may exist in them.&lt;/li>
&lt;li>Support for high-level emulation would require an unknown amount of
modification.&lt;/li>
&lt;li>Portability is questionable, since they&amp;rsquo;re mostly implemented in various
flavors of C or C++ and typically have Windows as the primary target system.&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m a big fan of &lt;strong>Rust&lt;/strong> and know that portability of Rust programs is pretty good
because the language implementation&amp;rsquo;s standard library does a pretty good job
of abstracting away platform details. In addition, I know the compiler has
good support for WebAssembly as a platform, which should make it reasonably
easy to make a port that &lt;strong>runs in a web browser&lt;/strong>. Running in a web browser is
desirable because it is the most accessible way to allow people to emulate
games- see for instance &lt;a href="http://ascii.textfiles.com/archives/4546">the
Emularity&lt;/a>, which powers emulation
of thousands of programs on the Internet Archive.&lt;/p>
&lt;h3 id="cores">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#cores">Cores&lt;/a>
&lt;/h3>
&lt;p>Though I chose to start from scratch with an emulator written in Rust, I still
didn&amp;rsquo;t want to spend the effort to implement CPU emulation from scratch. Since
the Z80 is such an old CPU and so widely used, there are a number of existing
emulators that I might be able to use the core from. Rust is easy to link against
C code, so there are many options to consider. Among the most notable:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="http://www.mathematik.uni-ulm.de/users/ag/yaze-ag/">YAZE-AG&lt;/a> is very mature
and emulates a whole CP/M system.&lt;/li>
&lt;li>&lt;a href="https://en.wikipedia.org/wiki/Fuse_(emulator)">Fuse&lt;/a> is also very mature
and emulates the ZX Spectrum. It also has a reputation for high accuracy
thanks in part to its comprehensive test suite.&lt;/li>
&lt;/ul>
&lt;figure>
&lt;a href="http://visual6502.org/images/pages/Zilog_Z84C00_die_shots.html">
&lt;picture>
&lt;source type="image/webp" srcset="https://www.taricorp.net/2020/introducing-tihle/Z84C00_die_shot_20x_1b_640w.webp">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/Z84C00_die_shot_20x_1b_640w.jpg"
alt="A photo of the die of a CMOS Z80. Large rectangular features surround
a central rectangle of extremely fine detail.">
&lt;/picture>
&lt;/a>
&lt;figcaption>
The Visual 6502 project has a
&lt;a href="http://www.visual6502.org/JSSim/expert-z80.html">gate-level Z80 simulator&lt;/a>
that could yield ultimate accuracy at a "blistering" 5 Hz simulated clock speed!
It's not feasible to use.
&lt;/figcaption>
&lt;/figure>
&lt;p>I eventually settled on a core that&amp;rsquo;s not part of any major emulator:
&lt;a href="https://github.com/redcode/Z80">Manuel Sainz de Baranda y Goñi&amp;rsquo;s &amp;ldquo;redcode&amp;rdquo; core&lt;/a>. It&amp;rsquo;s
written in very portable C and designed to be used as a library so it takes very little
setup or modification to use in my application. It does depend on a large external
header-only library as provided, but I was able to adapt it to be more standalone
with some work.&lt;/p>
&lt;p>I tend to prefer to use permissive licenses on my software, but the redcode Z80 core
is &lt;strong>&lt;a href="https://en.wikipedia.org/wiki/GNU_General_Public_License">GPL&lt;/a>&lt;/strong>-licensed. Since linking it into my binaries would require my code to also be
GPL I would prefer to use a core with a more permissive license, but accepting
GPL in exchange for not needing to implement the core myself seems like a fair
and expedient trade on balance. In the future I might choose to implement my own core
and change the license for tihle, but for the time being it will be copyleft.&lt;/p>
&lt;h3 id="taking-emulation-up-a-level">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#taking-emulation-up-a-level">Taking emulation up a level&lt;/a>
&lt;/h3>
&lt;p>Building the CPU core and getting it to run some code wasn&amp;rsquo;t terribly hard,
which left the core issue of how to implement high-level emulation. The emulator
needs to be able to recognize when the CPU needs to &amp;ldquo;trap&amp;rdquo; into operations
provided by the emulator.&lt;/p>
&lt;p>I initially defined traps as simply taking some &lt;strong>action on instruction fetch&lt;/strong>.
When the CPU would attempt to read memory at chosen addresses, the emulator
would execute a trap handler based on the memory address being read, then
return data equivalent to instructions appropriate to the trap- usually
just the value &lt;code>C9&lt;/code> for a &lt;code>ret&lt;/code> instruction.&lt;/p>
&lt;p>This approach quickly turned out to be problematic as I was testing the emulator.
I chose &lt;strong>&lt;a href="https://www.ocf.berkeley.edu/~pad/game-ti83p-phoenixz.html">Phoenix&lt;/a>&lt;/strong> as the target program for initial development because
it&amp;rsquo;s a sort of classic calculator game and as a bonus shouldn&amp;rsquo;t depend too tightly
on emulation accuracy- Phoenix was designed to be portable across a number of calculators,
so hopefully doesn&amp;rsquo;t contain many assumptions about the system.&lt;/p>
&lt;figure>
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/phx80459.png" width=192 height=128 style="image-rendering: pixelated;">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/phx80460.png" width=192 height=128 style="image-rendering: pixelated;">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/phx80461.png" width=192 height=128 style="image-rendering: pixelated;">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/phx80462.png" width=192 height=128 style="image-rendering: pixelated;">
&lt;figcaption>A few screenshots of Phoenix.&lt;/figcaption>
&lt;/figure>
&lt;p>Unfortunately, I quickly ran into problems with the way Phoenix generates random numbers.
Its &lt;code>FAST_RANDOM&lt;/code> routine uses some self-modifying code to read values from semi-random
memory addresses, mixing the read values through a few shifts and &lt;code>xor&lt;/code>s. The initial
address it reads is the CPU reset vector; &lt;code>0x0000&lt;/code>, which I had also defined as the trap
that terminates emulation. To work around this I modified reset detection: instead of
simply trapping reads from that address, I made the &amp;ldquo;terminate&amp;rdquo; flag instead be controlled
by any &lt;strong>write to port 255&lt;/strong>, which is unused on the calculator. By putting code at the
reset vector to write to port 255, we can avoid spurious termination.&lt;/p>
&lt;p>It quickly becomes obvious that treating termination separately from other traps
doesn&amp;rsquo;t work well. If the program decides to read a value from &lt;code>0x0028&lt;/code> for
instance (where control flow jumps to execute a system routine), it would cause
incorrect actions to be taken which would almost certainly cause the program to
begin behaving incorrectly. What&amp;rsquo;s required is a way to ensure traps are only
taken when executing the target code, not if the system happens to read a given
memory address as data. It wasn&amp;rsquo;t an immediate problem, but this will be
revisited later.&lt;/p>
&lt;h3 id="mirageos">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#mirageos">MirageOS&lt;/a>
&lt;/h3>
&lt;p>The initial implementation of the memory subsystem only emulated RAM, not the
Flash memory in the calculator that holds the OS. Regular programs only execute
out of RAM, so it was easy enough to trap on any access to Flash. This became
problematic once emulation of Phoenix reached a certain point, because it turns
out I had chosen to use the &lt;strong>MirageOS&lt;/strong> version of Phoenix, meaning there was
another dependency on the contents of Flash that I had not expected.&lt;sup id="fnref:10">&lt;a href="#fn:10" class="footnote-ref" role="doc-noteref">10&lt;/a>&lt;/sup>
&lt;a href="http://www.detachedsolutions.com/mirageos/">MirageOS&lt;/a>&lt;sup id="fnref:11">&lt;a href="#fn:11" class="footnote-ref" role="doc-noteref">11&lt;/a>&lt;/sup> is a shell implemented as a Flash application; it runs directly from Flash,
and provides useful support routines to programs designed to take advantage of it.&lt;/p>
&lt;figure>
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/titlescreen.png" width=192 height=128 style="image-rendering: pixelated;">
&lt;/figure>
&lt;p>This version of Phoenix uses the &lt;code>setupint&lt;/code> routine provided by Mirage to implement
its timer interrupt that controls game speed. Since Mirage isn&amp;rsquo;t open-source, I would
have had to either reverse-engineer the details of how that works or simply map Mirage
into memory. I opted to do the latter, since Mirage is freely redistributable
and it will always be easier to emulate it than faithfully implement its
functionality in terms of emulator traps.&lt;/p>
&lt;h3 id="hoisted-by-my-own-bugs-a-debugging-adventure">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#hoisted-by-my-own-bugs-a-debugging-adventure">Hoisted by my own bugs: a debugging adventure&lt;/a>
&lt;/h3>
&lt;p>After adding Mirage to the system, things still weren&amp;rsquo;t working. I spend some time
reverse-engineering &lt;code>setupint&lt;/code> and by painstakingly comparing that with execution
traces from the emulator, the flow that routine was taking seemed reasonable. It looked
like it was jumping somewhere incorrect on servicing the first interrupt after
setting it up! Checking the value of the &lt;code>I&lt;/code> register that specifies the location of
the interrupt vector table to the core, it didn&amp;rsquo;t seem correct- meaning there was
a problem with the core. Let&amp;rsquo;s dive into that adventure (or skip ahead to
&lt;a href="#rethinking-traps">the next section&lt;/a> if you prefer not to learn
some about my debugging process):&lt;/p>
&lt;hr>
&lt;p>Mirage installs its interrupt vector table to the block of memory at &lt;code>0x8B00&lt;/code>, filling
it with the value &lt;code>0x8A&lt;/code>. Because custom interrupts on the 83+ must always use the
Z80&amp;rsquo;s interrupt mode 2 but the calculator is not designed to use this mode,
this ensures that all interrupts will vector to &lt;code>0x8A8A&lt;/code>. Breaking execution
at &lt;code>0x71D2&lt;/code> where it loads the &lt;code>I&lt;/code> register:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-z80" data-lang="z80">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">ROM:&lt;/span>&lt;span style="color:#099">71&lt;/span>&lt;span style="color:#000;font-weight:bold">C&lt;/span>E &lt;span style="color:#000;font-weight:bold">ld&lt;/span> hl, &lt;span style="color:#099">8B00h&lt;/span> &lt;span style="color:#998;font-style:italic">; Interrupt vectors go at 8B00&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">ROM:&lt;/span>&lt;span style="color:#099">71&lt;/span>D1 &lt;span style="color:#000;font-weight:bold">ld&lt;/span> a, h
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">ROM:&lt;/span>&lt;span style="color:#099">71&lt;/span>D2 &lt;span style="color:#000;font-weight:bold">ld&lt;/span> i, a
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">ROM:&lt;/span>&lt;span style="color:#099">71&lt;/span>D4 &lt;span style="color:#000;font-weight:bold">dec&lt;/span> a &lt;span style="color:#998;font-style:italic">; A = 8A&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">ROM:&lt;/span>&lt;span style="color:#099">71&lt;/span>D5 &lt;span style="color:#000;font-weight:bold">ld&lt;/span> bc, &lt;span style="color:#099">101h&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">ROM:&lt;/span>&lt;span style="color:#099">71&lt;/span>D8 &lt;span style="color:#000;font-weight:bold">rst&lt;/span> &lt;span style="color:#099">28h&lt;/span> &lt;span style="color:#998;font-style:italic">; Fill 256 bytes of 8A at 8B00&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">ROM:&lt;/span>&lt;span style="color:#099">71&lt;/span>D8 &lt;span style="color:#998;font-style:italic">; ---------------------------------------------------------------------------&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">ROM:&lt;/span>&lt;span style="color:#099">71&lt;/span>D9 dw &lt;span style="color:#099">4C33h&lt;/span> &lt;span style="color:#998;font-style:italic">; MemSet&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>As expected, &lt;code>HL&lt;/code> was &lt;code>0x8B00&lt;/code> and &lt;code>A&lt;/code> should have been &lt;code>8B&lt;/code>, but instead it
had value &lt;code>0x84&lt;/code> and after the &lt;code>ld i, a&lt;/code> instruction the value of &lt;code>I&lt;/code> hadn&amp;rsquo;t
changed!&lt;/p>
&lt;hr>
&lt;p>Digging deeper, I had to debug the emulator- not just inspect the emulated
CPU state. This was actually more difficult than expected, because I had been
developing with &lt;a href="https://www.jetbrains.com/clion/">CLion&lt;/a> on Windows, using a Rust toolchain targeting the MSVC
tools. CLion only supports debugging with GNU tools however, so I couldn&amp;rsquo;t use
the integrated debugger. I do have a copy of Visual Studio handy however, which
works just fine to debug MSVC binaries.&lt;/p>
&lt;p>I ended up adding a pause to the emulator
on startup so I could launch it through CLion, then switch to Visual Studio to
attach the debugger and set breakpoints before unpausing the emulator.
This worked quite tolerably, since Visual Studio was still able to make sense
of all the debug symbols in the binary so the only tedious part was in getting
the debugger attached.&lt;/p>
&lt;p>With everything hooked up, I saw the following state in my debugger:&lt;/p>
&lt;p>&lt;img src="https://www.taricorp.net/2020/introducing-tihle/debug-8bit-broken.png"
alt="A two-pane debugger, with disassembly above and local variables as defined
in the source code below. The local variables object, state and af are
expanded to show a value_uint16 of 0x8400 in af.">&lt;/p>
&lt;p>By inspecting the assembly and comparing with the debugger&amp;rsquo;s view of the locals,
I can tell that &lt;code>rcx&lt;/code> points to the emulator &lt;code>object&lt;/code>, and offset 0x40 from that
refers to the state of the CPU core. &lt;code>PC&lt;/code> of the core has already been incremented
to the next instruction, and the intent seems to be that the value of the emulated
&lt;code>A&lt;/code> register will be tansferred through &lt;code>ecx&lt;/code> into &lt;code>I&lt;/code>. However, the value
it&amp;rsquo;s reading is 0, not &lt;code>0x84&lt;/code> as it should be based on the debugger&amp;rsquo;s view
of the &lt;code>AF&lt;/code> register pair.&lt;/p>
&lt;p>The redcode emulator uses some utility types that I reimplemented when I didn&amp;rsquo;t
want to include the whole &amp;ldquo;Z&amp;rdquo; library that it wants to use, which include the
&lt;code>Z16Bit&lt;/code> union used to represent the 16-bit register pairs. It looks like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">typedef&lt;/span> &lt;span style="color:#000;font-weight:bold">union&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zuint16 value_uint16;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">#ifdef IS_BIG_ENDIAN
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">&lt;/span> &lt;span style="color:#998;font-style:italic">// 0 is the low-order byte of these aggregates
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zuint8 index1;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zuint8 index0;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } values_uint8;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">#else
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">&lt;/span> &lt;span style="color:#000;font-weight:bold">struct&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zuint8 index0;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zuint8 index1;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } values_uint8;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">#endif
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#999;font-weight:bold;font-style:italic">&lt;/span>} Z16Bit;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The intent of this type appears to be in allowing easy access to each byte of a 16-bit
value; this could be done just with shifting and masking that presumably the compiler
could optimize, but I suppose it was designed to not depend on compilers being
efficient. In this case, it seems the program is actually reading the wrong byte
of the value; when it contains &lt;code>0x8400&lt;/code> and we want to read the value of &lt;code>A&lt;/code>, it should
take the high byte of the value but is instead taking the low. The macro &lt;code>A&lt;/code> in the source
code for the core expands to an access to &lt;code>object.state.af.values_uint8.index0&lt;/code> which
should be the high byte of the &lt;code>AF&lt;/code> register pair.&lt;/p>
&lt;p>So it turns out I made a mistake in adapting that definition to my needs, and &lt;code>index0&lt;/code> is
meant to be the high-order byte of the aggregate value, not the low! Simply swapping
the locations of &lt;code>index1&lt;/code> and &lt;code>index0&lt;/code> then rebuilding, the state was then correct:&lt;/p>
&lt;p>&lt;img src="https://www.taricorp.net/2020/introducing-tihle/debug-8bit-fixed.png"
alt="A two pane debugger configuration again, this time with the value 0x8b05 in af.">&lt;/p>
&lt;p>Not only does &lt;code>A&lt;/code> now have the value we expect from reading the Mirage disassembly,
&lt;code>ecx&lt;/code> now does contains &lt;code>0x8B&lt;/code> and the value is correctly written to &lt;code>I&lt;/code>. Problem solved!&lt;/p>
&lt;h3 id="rethinking-traps">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#rethinking-traps">Rethinking traps&lt;/a>
&lt;/h3>
&lt;p>With improved confidence in the core&amp;rsquo;s correctness now, emulation can progress to the point
where it&amp;rsquo;s relevant to resume thinking about how to correctly implement traps such
that non-instruction reads from memory won&amp;rsquo;t spuriously trigger them. With the change
in reset handling to trap on writes to a particular port, it makes sense to explore
the ways in which traps could better be handled.&lt;/p>
&lt;p>It&amp;rsquo;s likely that there will need to be a number of different traps; certainly for
a handful of system routines. This means any trap method must be able to provide
a way to identify which trap to execute, but it must only be triggered when
executed by the CPU. Some ideas come to mind:&lt;/p>
&lt;ul>
&lt;li>Write a value to a selected port that&amp;rsquo;s not otherwise used on the calculator.&lt;/li>
&lt;li>Provide a flag from the core to the emulator for handling memory reads to
indicate an instruction fetch rather than any other data read.&lt;/li>
&lt;li>Implement a custom instruction that causes a trap.&lt;/li>
&lt;/ul>
&lt;p>Modifying the core to handle memory reads differently has unknown complexity
so I didn&amp;rsquo;t want to do that, and writing a value to a port didn&amp;rsquo;t seem like a
very good choice because it can only accomodate 256 values per chosen port.
While I don&amp;rsquo;t know how many traps will eventually be needed, 256 seems like
it may not be enough so I opted to look into creating a &lt;strong>custom instruction&lt;/strong>.&lt;/p>
&lt;hr>
&lt;p>Looking at what instructions are defined in both the Z80 and eZ80 documentation
(leaving the door open for eZ80 emulation later), there&amp;rsquo;s a lot of unused
space in the ED-prefixed instructions that isn&amp;rsquo;t used by either CPU and
isn&amp;rsquo;t known to have useful undocumented effects.&lt;/p>
&lt;p>&lt;a href="http://www.z80.info/z80undoc.htm">According to Jacco Bot&lt;/a>, &lt;code>ED00&lt;/code>-&lt;code>ED3F&lt;/code>
and &lt;code>EDC0&lt;/code>-&lt;code>EDFF&lt;/code> all have no discernable effect so are good candidates
for custom instructions. Cross referencing with the eZ80 CPU manual (Zilog
UM0077, table 109), much of the first block is filled in while the second
remains sparsely used.&lt;/p>
&lt;figure>
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/um0077-table109.png">
&lt;figcaption>The `ED`-prefixed instruction space for the eZ80 CPU.&lt;/figcaption>
&lt;/figure>
&lt;p>Choosing semi-arbitrarily, &lt;strong>&lt;code>ED25&lt;/code> seems like a nice &lt;code>TRAP&lt;/code> instruction&lt;/strong>,
since &lt;code>25&lt;/code> is an ASCII &amp;lsquo;%&amp;rsquo; which seems like it might &amp;ldquo;pop&amp;rdquo; out of a text
representation of memory a little bit.&lt;/p>
&lt;p>To indicate which trap should be taken, I chose to have the instruction
include a 16-bit value which indicates to the emulator which trap is desired.
This provides a very large number of possible traps that ought to
be sufficient for any future needs.&lt;/p>
&lt;hr>
&lt;p>Implementing this new instruction turned out to be very easy, and I learned
a little more about how the core is implemented. I simply needed to add a hook
for traps (to call from the C core into my Rust emulator) and add a new
function that calls that hook, then insert a pointer to that function
in the table of &lt;code>ED&lt;/code>-prefixed instruction handlers.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#900;font-weight:bold">INSTRUCTION&lt;/span>(ED_tihle_trap) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> PC &lt;span style="color:#000;font-weight:bold">+=&lt;/span> &lt;span style="color:#099">4&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#458;font-weight:bold">uint16_t&lt;/span> trap_no &lt;span style="color:#000;font-weight:bold">=&lt;/span> (&lt;span style="color:#900;font-weight:bold">READ_8&lt;/span>(PC &lt;span style="color:#000;font-weight:bold">-&lt;/span> &lt;span style="color:#099">2&lt;/span>) &lt;span style="color:#000;font-weight:bold">|&lt;/span> (&lt;span style="color:#900;font-weight:bold">READ_8&lt;/span>(PC &lt;span style="color:#000;font-weight:bold">-&lt;/span> &lt;span style="color:#099">1&lt;/span>) &lt;span style="color:#000;font-weight:bold">&amp;lt;&amp;lt;&lt;/span> &lt;span style="color:#099">8&lt;/span>));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> CYCLES &lt;span style="color:#000;font-weight:bold">+=&lt;/span> object&lt;span style="color:#000;font-weight:bold">-&amp;gt;&lt;/span>&lt;span style="color:#900;font-weight:bold">trap&lt;/span>(object&lt;span style="color:#000;font-weight:bold">-&amp;gt;&lt;/span>context, trap_no);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> &lt;span style="color:#099">0&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;h3 id="the-need-for-an-os">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#the-need-for-an-os">The need for an OS&lt;/a>
&lt;/h3>
&lt;p>Now that traps are actually a special instruction, it turns out the system needs
some kind of OS image! Without one, there&amp;rsquo;s no way to trigger traps on calls into
OS code. As a quick solution, I implemented a tiny OS image that traps on
the major OS entrypoints:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-asm" data-lang="asm">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">; Trap instruction: ED25 + 16 bit parameter
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>&lt;span style="color:#008080">.addinstr&lt;/span> &lt;span style="color:#008080">TRAP&lt;/span> * &lt;span style="color:#099">25&lt;/span>&lt;span style="color:#008080">ED&lt;/span> &lt;span style="color:#099">4&lt;/span> &lt;span style="color:#008080">NOP&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">#define TRAP_RESET 0
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">#define TRAP_BCALL 1
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">#define TRAP_OS_INTERRUPT 2
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">.seek&lt;/span> &lt;span style="color:#008080">$0000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#900;font-weight:bold">trap&lt;/span> &lt;span style="color:#008080">TRAP_RESET&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#900;font-weight:bold">rst&lt;/span> &lt;span style="color:#099">00&lt;/span>&lt;span style="color:#008080">h&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">.seek&lt;/span> &lt;span style="color:#008080">$0028&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#900;font-weight:bold">trap&lt;/span> &lt;span style="color:#008080">TRAP_BCALL&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#900;font-weight:bold">ret&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">.seek&lt;/span> &lt;span style="color:#008080">$0038&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#900;font-weight:bold">trap&lt;/span> &lt;span style="color:#008080">TRAP_OS_INTERRUPT&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#900;font-weight:bold">reti&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The important traps here are for calls to OS routines at &lt;code>0x0028&lt;/code>, and the
default interrupt handler at &lt;code>0x0038&lt;/code>. The handler for &lt;code>TRAP_BCALL&lt;/code> inspects
the CPU state to choose what system routine to emulate, so it provides
most of the OS functionality that any program needs.&lt;/p>
&lt;h3 id="debugging-in-pictures">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#debugging-in-pictures">Debugging in pictures&lt;/a>
&lt;/h3>
&lt;p>With this work, the general structure of the emulator seems sound. Although I
didn&amp;rsquo;t want to implement an OS to begin with, one is now present; however though
its complexity is strictly controlled according to whether I find it easier to
implement routines in Z80 or as traps, so this seems reasonable. It was then a
matter of &lt;strong>implementing and debugging the core functions&lt;/strong> that Phoenix needs.&lt;/p>
&lt;p>For some parts of debugging it was easier to write small programs that exercised
only the function(s) that needed debugging; these might be good to promote
to unit tests in the future.&lt;/p>
&lt;figure>
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/helloworld-broken.png"
alt="A line of nonsensical symbols is displayed on the emulated screen.">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/helloworld-fixed.png"
alt="The emulated screen says Hello, world!">
&lt;figcaption>Text rendering seemed partially correct; it turned out I was computing
an 8-bit index into the font bitmap rather than pointer-sized, so getting the character
to display was wrong for most characters.&lt;/figcaption>
&lt;/figure>
&lt;p>Displaying text is not particularly hard, though getting a copy of the bitmap
font that the calculator uses was a bit of work. I ended up getting each
character as an image, then writing some Python scripts to combine them into
a binary blob that can be embedded in the emulator. It nearly worked with only a
little work, but I did have to debug some bad computations stemming from getting
an 8-bit character value and integer rollover.&lt;/p>
&lt;figure>
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/too-many-titles.png"
loading=lazy
alt="The string 'Phoenix 4.3' is displayed all the way down the screen.">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/text-wonky-title.png"
loading=lazy
alt="Some menu entries appear under 'Phoenix 4.3', but slightly garbled and misaligned.">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/text-better-title.png"
loading=lazy
alt="The Phoenix menu appears, with some visual artifacts but legible text.">
&lt;figcaption>The title screen was a little too enthusiastic but tamed with some changes
to correctly emulate undocumented behaviors.&lt;/figcaption>
&lt;/figure>
&lt;p>My initial traps for the &lt;code>_PutS&lt;/code> system routine failed to emulate the &lt;strong>undocumented
behavior&lt;/strong> that it updates &lt;code>HL&lt;/code> to point past the string that is displayed. Text output
in the small font also had some problems with updating the screen coordinates to draw
at. The vertical lines in the small-font text were apparently an error in my font bitmap
data that was easily fixed, though the black line at the top of the screen was a
different problem.&lt;/p>
&lt;figure>
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/warped-title.png"
alt="The menu is now white text on black with a jagged left border, but the
text is now slanted and wraps from the bottom to the top of the screen.">
&lt;figcaption>
Fixing some of the LCD bugs shows Phoenix wants to display white on black, and
shows there remain a few bugs to be solved.
&lt;/figcaption>
&lt;/figure>
&lt;p>My LCD emulation&lt;sup id="fnref:12">&lt;a href="#fn:12" class="footnote-ref" role="doc-noteref">12&lt;/a>&lt;/sup> had some issues to fix around not being in the correct
mode initially and failing to update its addresses correctly in some situations. It turns
out Phoenix is somewhat unusual among games for the 83+ in that it directly accesses
the LCD for all its display operations, rather than delegating to a library function
provided by the OS (&lt;code>_GrBufCopy&lt;/code>) or a shell (&lt;code>fastcopy&lt;/code>) which makes it a good test
of basic LCD driver emulation. The display warping was a simple problem of having
the wrong display width parameter in the driver emulation.&lt;/p>
&lt;hr>
&lt;p>With the display looking largely correct, the final major hurdle to something that
looks like a game was in generating &lt;strong>timer interrupts&lt;/strong>, which Phoenix uses to control
the game speed. This required implementing a few control ports which the CPU uses to
enable or disable interrupts and ensuring that interrupts would be triggered at
the correct times accurately, but wasn&amp;rsquo;t too bad although I spent some time vexed by a bug
where IRQ flags never got reset so interrupts fired continuously.&lt;/p>
&lt;figure>
&lt;picture>
&lt;source type="image/webp" srcset="https://www.taricorp.net/2020/introducing-tihle/phoenix-title-mostlyworking.webp">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/phoenix-title-mostlyworking.gif"
alt="The game title and author are shown at the top of the screen, with
options to start the game, adjust settings or get more information.
Jagged shapes scroll down both sides of the screen.">
&lt;/picture>
&lt;figcaption>
With working interrupts, the menu looks as it should!
&lt;/figcaption>
&lt;/figure>
&lt;h3 id="handling-input">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#handling-input">Handling input&lt;/a>
&lt;/h3>
&lt;p>In order to make the game playable, the last piece of the puzzle is in allowing
it to receive input. On a physical calculator this is through a simple matrix keyboard,
interfaced directly to the CPU via I/O port 0. Where some complexity comes in
is in how the calculator OS provides its input abstractions, which turns
out to be important but not well documented.&lt;/p>
&lt;p>TI-OS provides a routine for scanning the keyboard, called &lt;code>_GetCSC&lt;/code>. It returns one
value, a scan code corresponding to the key (if any) that is currently pressed.
The documentation leaves it at that however, when the behavior around
multiple keys being pressed or keys being held is also important.&lt;/p>
&lt;p>It turns out that &lt;code>_GetCSC&lt;/code> is implemented largely via interrupts; while servicing
regular timer interrupts, the OS scans the keyboard for keys that are being pressed,
and if any are then stores that value in RAM. &lt;code>_GetCSC&lt;/code> reads that value and clears
it. If everything were that simple it would be very easy, but the interrupt
handler also debounces some keys; the directional arrows and &lt;kbd>del&lt;/kbd> key
can repeat if held, while others will not. The actual timing is that the first
repeat of a held key comes after about 48 interrupts, and every 10 thereafter
meaning the first repeat comes after about 250 milliseconds and subsequent ones
occur at 20 Hz.&lt;/p>
&lt;p>While OS2 contains an implementation of this logic (which seemed overcompilicated
until I actually understood how this key repeat works), I opted to implement
keyboard scanning as a trap instead. While the keyboard hardware is fully
emulated because Phoenix only uses &lt;code>_GetCSC&lt;/code> for a few things and directly
interfaces with the keyboard for the rest, the OS timer interrupt calls
into the emulator to do keyboard scanning because it&amp;rsquo;s easier to implement
and probably more performant.&lt;/p>
&lt;figure>
&lt;picture>
&lt;source type="image/webp" srcset="https://www.taricorp.net/2020/introducing-tihle/phoenix-with-input.webp">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/phoenix-with-input.gif" loading=lazy
alt="The Phoenix menu looks correct, but the menu pointer moves too fast to see.">
&lt;/picture>
&lt;picture>
&lt;source type="image/webp" srcset="https://www.taricorp.net/2020/introducing-tihle/phoenix-playable.webp">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/phoenix-playable.gif" loading=lazy
alt="Now the game menu is controllable, and gameplay is demonstrated.">
&lt;/picture>
&lt;figcaption>
Without correct key repeat control the menus work but are too fast to control;
afterwards, the game is perfectly playable!
&lt;/figcaption>
&lt;/figure>
&lt;p>With this, I&amp;rsquo;m rather surprised that Phoenix is playable! There&amp;rsquo;s certainly more
work to be done to improve everything, but &amp;ldquo;Phoenix is playable&amp;rdquo; was the goal I
set for myself that must be reached before I published the project; so.. here we
are.&lt;/p>
&lt;h2 id="current-state-and-future">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#current-state-and-future">Current state and future&lt;/a>
&lt;/h2>
&lt;p>Currently, I&amp;rsquo;ve really only tested Phoenix. It&amp;rsquo;s possible that some other games will
work in tihle as it stands, and others might work with only minor changes. There
are notable holes that I&amp;rsquo;d like to fill:&lt;/p>
&lt;ul>
&lt;li>Programs are loaded directly to the point in memory that they execute from.
Programs and data should be loaded into memory as if they were managed normally
by TI-OS, which will allow programs that want to be able to load external data
(such as level packs) to do so, and also enable &lt;strong>saving or loading other data&lt;/strong>,
such as high scores or generated programs.&lt;/li>
&lt;li>Rather than directly launching programs, &lt;strong>launch a shell&lt;/strong> and allow users to
interactively select a program to run. (Though a mode to directly launch
programs may still be desirable.)&lt;/li>
&lt;li>Improve LCD emulation to support &lt;strong>greyscale graphics&lt;/strong>. Programmers sometimes
implement rapid switching between black and white to approximate several
shades of grey (4-16 depending on how ambitious they are and how much
CPU time is worth dedicating to it for the application), which will require
LCD emulation to have some &amp;ldquo;memory&amp;rdquo; in order to simulate the slow response
time of the calculator LCD.&lt;/li>
&lt;li>Support an &lt;strong>on-screen keyboard&lt;/strong>, useful for touchscreen devices and easier
to navigate than pressing keys on a computer keyboard.&lt;/li>
&lt;li>Improved &lt;strong>debugging tools&lt;/strong>, to set breakpoints and more interactively explore
the system state. Very useful for debugging new programs that don&amp;rsquo;t yet
run correctly, or for developing new programs.&lt;/li>
&lt;li>&lt;a href="https://gitlab.com/taricorp/tihle/-/issues/new">Your suggestion here!&lt;/a>
(Suggestions and feature requests via Gitlab issues or other means are welcome.)&lt;/li>
&lt;/ul>
&lt;p>I&amp;rsquo;m quite happy with the ability to run the emulator in a web browser as it
is right now. While I can&amp;rsquo;t claim much credit for it beyond choosing tools that
would support that target,&lt;sup id="fnref:13">&lt;a href="#fn:13" class="footnote-ref" role="doc-noteref">13&lt;/a>&lt;/sup> it&amp;rsquo;s still very gratifying to see
everything come together to make emulation so accessible. In the longer term,
I&amp;rsquo;d like to try to get emulation via &lt;strong>tihle available on the Internet Archive&lt;/strong>,
like &lt;a href="https://blog.archive.org/2019/10/13/2500-more-ms-dos-games-playable-at-the-archive/">many DOS games are
today&lt;/a>.&lt;/p>
&lt;p>The &amp;ldquo;minimal&amp;rdquo; OS that&amp;rsquo;s included with the emulator is currently a pretty
significant hack that I&amp;rsquo;d like to clean up some, and in the long term it would
be cool to make it a much &lt;strong>more complete TI-OS reimplementation&lt;/strong>- possibly
taking in most of OS2, and building on top of Brandon&amp;rsquo;s work. In an ideal
world the high-level parts of tihle might eventually become obsolete (or
optional) if the OS implementation got good enough.&lt;/p>
&lt;h3 id="links-and-further-reading">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2020/introducing-tihle/#links-and-further-reading">Links and further reading&lt;/a>
&lt;/h3>
&lt;p>tihle lives on GitLab: &lt;strong>&lt;a href="https://gitlab.com/taricorp/tihle">https://gitlab.com/taricorp/tihle&lt;/a>&lt;/strong>&lt;/p>
&lt;a href="https://gitlab.com/taricorp/tihle">
&lt;img src="https://www.taricorp.net/2020/introducing-tihle/tihle.svg" width=360 style="margin: auto; display: block;">
&lt;/a>
&lt;p>There you can read more, download binaries to run tihle yourself (or do so
directly in your browser), read the source code as well as contribute:
I welcome contributions, suggestions and discussion, especially if they come
with code!&lt;/p>
&lt;p>You can learn more about the technical details of calculators and programming
them at &lt;a href="https://wikiti.brandonw.net/">WikiTI&lt;/a>, though many of the details can
be rather inscrutable without additional explanation or prior knowledge. The
official TI-83 Plus SDK documentation can be found &lt;a href="https://archive.org/details/83psdk">on the Internet
Archive&lt;/a>; much of its contents are omitted
from WikiTI, so it&amp;rsquo;s a valuable source of basic information. &lt;a href="https://gitlab.com/taricorp/83pa28d">Learn TI-83 Plus
Assembly in 28 Days&lt;/a> is a decent
introduction to the platform, intended for readers with no experience
programming in assembly but plenty usable by more advanced readers as well, with
its combination of simplified official documentation and community-sourced
information.&lt;/p>
&lt;p>If you&amp;rsquo;re looking for discussion or programs,
&lt;a href="https://www.cemetech.net/">Cemetech&lt;/a> is one forum where experienced calculator
programmers (and me!) hang out, and are happy to discuss things. There&amp;rsquo;s also
an area to find programs there, but
&lt;a href="http://ticalc.org/">ticalc.org&lt;/a> is one of the oldest calculator community
sites, and has a huge library of software to browse.&lt;/p>
&lt;script src="https://utteranc.es/client.js"
repo="tari/taricorp.net"
issue-term="pathname"
theme="github-light"
crossorigin="anonymous"
async>
&lt;/script>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>I thought I might give this a try after
&lt;a href="https://twitter.com/gravislizard/status/1287085874325250048">Gravis commented on doing something
similar&lt;/a>;
it can benefit readers who don&amp;rsquo;t use assistive
technology but are better able to consume the content in a different
format.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>Most users of these calculators have them because their schools
require them, and schools tend to require them because TI spend
significant effort in selling the calculators to schools and
positioning them as tools for standardized testing.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:3">
&lt;p>Most users only need a physical calculator because more capable
devices (phones and computers) are not permitted in standardized
testing!&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:4">
&lt;p>I understand the write-protection of the boot code on many
calculators is not as robust as expected from understanding the
hardware&amp;rsquo;s capabilities such that it can actually be modified from
software only, but the fact remains that it&amp;rsquo;s not &lt;em>intended&lt;/em> to ever
be updated.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:5">
&lt;p>WabbitEmu added support for automatically downloading an
EOS image from TI and combining it with a copy of BootFree to make a
fully-functional ROM without ever touching a real calculator &lt;a href="https://github.com/sputt/wabbitemu/commit/4a028a726a27d307fb25e5f607fd30118e9e765c">sometime in
2010&lt;/a>.
This seems to have flown under TI&amp;rsquo;s radar for a few years, possibly until the
release of jsTIfied in 2012 brought emulation more into the mainstream. Where
previously emulation had a somewhat higher barrier to entry, the availability
of an emulator running entirely in a web browser combined with the possibility
of automatically making a complete software image from freely-available
resources seems to have motivated TI both to begin more aggressively marketing
their own emulator (TI-SmartView) and place barriers in front of community
emulation in the form of clickwrap in order to extract licensing fees from
educators who might otherwise use free emulators.&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:6">
&lt;p>Anecdotally, Cemetech saw a number of new users
following the release of jsTIfied who were clearly in education because for a
time it required users to register accounts on the site to use it (a choice
related to a technical limitation that some operations required data
be sent to the server and bounced right back). I assume there were a number of
teachers who might have instructed their students to use jsTIfied in order to
avoid any extra costs in accessing the calculators required by their
curriculum.&amp;#160;&lt;a href="#fnref:6" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:7">
&lt;p>I now note that accurate emulation of that particular
aspect is likely unnecessary, but haven&amp;rsquo;t revisited that concept because I
chose to think about the whole-machine emulation.&amp;#160;&lt;a href="#fnref:7" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:8">
&lt;p>Concretely, the TI-83+ CPU runs at 6 MHz and a Z80
cannot execute any instructions in less than 4 clock cycles (and most take
more than 4, possibly as many as 23). A CPU in a general-purpose device today
might run at 3 GHz and achieve average throughput of two instructions per
cycle, meaning an emulating machine today is comfortably 1000 times faster
than the Z80 it emulates (and maybe more like 5000 times faster depending on
the workload!).&amp;#160;&lt;a href="#fnref:8" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:9">
&lt;p>Some readers might ask: &amp;ldquo;why not improve an existing &lt;em>libre&lt;/em>
OS like OS2?&amp;rdquo; My answer is that doing so is too hard: while the Z80 is a
well-understood CPU to program for, its age means much of the tooling
available to us today that makes programmers more productive is simply not
available. While a truly free (&lt;em>libre&lt;/em>) EOS replacement would be ideal, I do
not consider it to be immediately feasible.&amp;#160;&lt;a href="#fnref:9" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:10">
&lt;p>On the 83+, the Z80&amp;rsquo;s 64 KiB memory space is split into 4 banks.
Typically, page 0 of Flash (which contains OS code) is always mapped
into the low 16 KiB,
&amp;ldquo;bank A&amp;rdquo; from &lt;code>0x4000&lt;/code>-&lt;code>0x8000&lt;/code> can be swapped to contain different
parts of Flash, and the top 32 KiB is RAM. Flash applications like
Mirage execute of out bank A.&amp;#160;&lt;a href="#fnref:10" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:11">
&lt;p>Not to be confused with &lt;a href="https://mirage.io/">the library operating system of the same name&lt;/a>,
which I also have some experience with.&amp;#160;&lt;a href="#fnref:11" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:12">
&lt;p>On the 83+ the LCD driver is controlled through two ports from the CPU.
Other calculators like the TI-85 have memory-mapped displays, but the 83+
series requires the CPU to push data to and from the display and emulators
must understand most of the LCD driver&amp;rsquo;s command set in order to work
correctly.&amp;#160;&lt;a href="#fnref:12" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:13">
&lt;p>I chose to implement the frontend to the emulator with SDL
specifically because I knew emscripten has an SDL port that
allows software using it to run in a web browser without
major modification; other people have already done the heavy
lifting to make Rust+SDL applications work in browsers,
I just knew I&amp;rsquo;d be able to make use of it.&amp;#160;&lt;a href="#fnref:13" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>fb-hitler Updated</title><link>https://www.taricorp.net/2020/fb-hitler-updated/</link><pubDate>Tue, 28 Jul 2020 11:14:33 +1000</pubDate><guid>https://www.taricorp.net/2020/fb-hitler-updated/</guid><description>&lt;p>I updated &lt;a href="https://www.taricorp.net/projects/fb-hitler/">fb-hitler&lt;/a> a few years ago and never wrote
about it; today I spent some time to describe the new version, which is
implemented more robustly: now it&amp;rsquo;s a bootable image that runs in real mode, not
a Linux application.&lt;/p>
&lt;figure>
&lt;img alt="Adolf hitler in black and white; his eyes flash red occasionally."
src="https://www.taricorp.net/2020/fb-hitler-updated/demo-ng.gif">
&lt;figcaption>
QEMU looks like this when running the program.
&lt;/figcaption>
&lt;/figure>
&lt;p>Read more on the &lt;a href="https://www.taricorp.net/projects/fb-hitler/">project page&lt;/a> or
&lt;a href="https://gitlab.com/taricorp/fb-hitler-ng">GitLab&lt;/a>.&lt;/p></description></item><item><title>A Wayback Machine Bookmarklet</title><link>https://www.taricorp.net/2020/wayback-bookmarklet/</link><pubDate>Sat, 21 Mar 2020 09:37:39 +0000</pubDate><guid>https://www.taricorp.net/2020/wayback-bookmarklet/</guid><description>&lt;p>Sometimes I find it useful to be able to quickly save a page to the &lt;a href="https://en.wikipedia.org/wiki/Wayback_Machine">Wayback Machine&lt;/a>, often to be able to provide a stable link to a page that I don’t control- for instance if I’m pointing somebody to a document that describes something they’re asking about, then it’s nice to ensure that there will still be an archived copy if the original goes away.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/images/save-page-now.png"
alt="The &amp;#39;Save Page Now&amp;#39; field in the wayback machine. There is a &amp;#39;Save Page&amp;#39; button associated with a URL entry field.">
&lt;/figure>
&lt;p>While the landing page on web.archive.org has a “save now” form to quickly save a page given its URL, this is still more cumbersome than I’d like- it involves copying the desired URL, opening a new tab to web.archive.org, pasting the URL into the form and pressing the save button- in exactly that order.&lt;/p>
&lt;p>The concept of &lt;a href="https://en.wikipedia.org/wiki/Bookmarklet">bookmarklets&lt;/a> comes to the rescue: little snippets of Javascript in a bookmark to do some function- it’s easy enough to open a new tab to a given URL with some javascript, so by inspecting how the “Save page” button works, I can automate it:&lt;/p>
&lt;pre tabindex="0">&lt;code>javascript:void(window.open(&amp;#39;https://web.archive.org/save/&amp;#39;+location.href));&lt;/code>&lt;/pre>
&lt;p>I’ve put that string into a bookmark that sits on my browser’s bookmarks bar, so I can just click on that to open a new tab which will save the currently-shown page to the Wayback Machine in a new tab. Much easier!&lt;/p>
&lt;p>Of course this is still cumbersome to do with many URLs, such as if I want to archive all the links in a blog post. Fortunately &lt;a href="http://amberlink.org/">AMBER&lt;/a> automates that particular use case, and larger applications tend to be the realm of &lt;a href="https://www.archiveteam.org/index.php?title=Wget_with_WARC_output">fetching WARCs with wget&lt;/a>.&lt;/p></description></item><item><title>Fomu: a beginner's guide</title><link>https://www.taricorp.net/2019/fomu-beginners-guide/</link><pubDate>Wed, 06 Mar 2019 00:00:00 +0000</pubDate><guid>https://www.taricorp.net/2019/fomu-beginners-guide/</guid><description>&lt;p>FPGAs are pretty cool pieces of hardware for tinkering with, and have become
remarkably easy to approach as a hobbyist in recent years. Boards like the
&lt;a href="https://www.crowdsupply.com/tinyfpga/tinyfpga-bx">TinyFPGA BX&lt;/a> don&amp;rsquo;t require
any special hardware to use and can provide a simple platform for
modestly-scoped projects or just for learning.&lt;/p>
&lt;p>While historically the software tools for programming FPGAs are proprietary and
provided by the hardware manufacturer, &lt;a href="https://symbiflow.github.io/">Symbiflow&lt;/a>
(enabled and probably inspired by earlier work like &lt;a href="http://www.clifford.at/icestorm/">Project
IceStorm&lt;/a>) provides &lt;a href="https://www.youtube.com/watch?v=-xyAauPa__s">completely free and
open-source tooling&lt;/a> and
documentation for programming some FPGAs, significantly lowering the cost of
entry (most vendors provide some free version of their design software but
limited to lower-end devices; a license for the non-free version of the software
is well into the realm of &amp;ldquo;if you have to ask, you can&amp;rsquo;t afford it&amp;rdquo;) and
appearing to yield better results in many cases.&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>&lt;/p>
&lt;hr>
&lt;p>As somebody who finds it fun to learn new things and experiment with new kinds
of creations, FPGAs are quite interesting to me- they&amp;rsquo;re quite complex devices
that enable very powerful creations, with excellent depth for mastery. While I
did some course lab work with Altera FPGAs in university (and a little bit of
chip design/layout later), I&amp;rsquo;d call those mostly canned tasks with
easily-understood requirements and problem-solving approaches; it was sufficient
to familiarize myself with the systems, but not enough to be particularly
useful.&lt;/p>
&lt;p>The announcement of &lt;a href="https://www.crowdsupply.com/sutajio-kosagi/fomu">Fomu&lt;/a>
caught my interest because I was aware of the earlier &lt;a href="https://tomu.im/">Tomu&lt;/a>
but wasn&amp;rsquo;t sufficiently interested to try to acquire any hardware. With Fomu
however, I&amp;rsquo;m rather more interested because it enables interesting capabilities
for playing with hardware- others have already demonstrated small
&lt;a href="https://en.wikipedia.org/wiki/RISC-V">RISC-V&lt;/a> CPUs running in that FPGA
(despite its modest logic capacity), for instance.&lt;/p>
&lt;p>Even more conveniently for being able to play with Fomu, I&amp;rsquo;ve been in contact
with &lt;a href="https://twitter.com/mithro">Mithro&lt;/a> who is approximately half of the team
behind Fomu and gotten access to a stockpile of &amp;ldquo;hacker edition&amp;rdquo; boards that
have been hand-assembled but not programmed at all. With slightly early access
to hardware, I&amp;rsquo;ve been able to do some exploration and re-familiarize myself
with the world of digital logic design and figure out the hardware.&lt;/p>
&lt;h2 id="hardware">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2019/fomu-beginners-guide/#hardware">Hardware&lt;/a>
&lt;/h2>
&lt;p>In summary, Fomu is a small (9.4 by 13 by 0.6 millimeters) circuit board with a
&lt;a href="https://www.latticesemi.com/Products/FPGAandCPLD/iCE40UltraPlus">Lattice
ICE40UP5K-UWG30&lt;/a>
FPGA, a 16-megabit SPI Flash for configuration (and other data) storage, a
single RGB LED for blinkiness and a 48 MHz MEMS oscillator to provide a clock.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2019/fomu-0.0-front.jpg"
alt="A photo of the board component side. There are seven integrated circuits and bare copper pads labelled clockwise from the top left 4, 3, 2, 1, G, R, O, I, C, S and V.">&lt;figcaption>
&lt;p>Board component side. The other side is mostly just the USB pads.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>The whole thing is built so it can fit inside a standard USB port. Production
boards are meant to ship with a &lt;a href="https://github.com/im-tomu/foboot">USB
bootloader&lt;/a> that allows new configurations to
be uploaded to the board only via that USB connection, but hacker boards are
provided completely unprogrammed (and untested).&lt;/p>
&lt;h3 id="schematic">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2019/fomu-beginners-guide/#schematic">Schematic&lt;/a>
&lt;/h3>
&lt;p>Before we can make the hardware do something, we&amp;rsquo;ll need to understand how
everything is put together:&lt;/p>
&lt;a href="https://www.taricorp.net/2019/fomu-schematic.png">
&lt;figure>&lt;img src="https://www.taricorp.net/2019/fomu-schematic.png"
alt="A schematic for &amp;#39;TomuUltraPlus&amp;#39; created in Kicad. The schematic is separated into 7 logical blocks: power regulation and decoupling, SPI flash, MEMS clock, RGB LED, ICE40 power, ICE40 PLL power filter and ICE40 IO.">
&lt;/figure>
&lt;/a>
&lt;p>Unfortunately, this schematic leaves some things to be desired. While it does
allow us to see what parts are actually on the board and generally how they&amp;rsquo;re
connected, it fails to clearly mark the external connections- power and data
lines on the USB connector, test points and utility I/O pads.&lt;/p>
&lt;p>The USB connections are easy to figure out, however; it&amp;rsquo;s a standard pinout so
we can easily identify which physical pads on the board correspond to VUSB (5V
supply), ground and the two data lines (USBP, USBN). Rather trickier to work out
is the function of each of the test points on the board, though there is a
&lt;a href="https://github.com/im-tomu/fomu-hardware/tree/master/hacker">provided template for laser-cutting a programming
jig&lt;/a> which provides
some hints:&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2019/fomu-laser-jig.svg"
alt="Four rounded squares with lines and shapes marking where laser cuts or engraving should be done. There are seven holes that align on two of the squares to permit pins to pass through, and one of them has text denoting the purpose of each pin." width="320px">&lt;figcaption>
&lt;p>Here red and green lines are cuts, while black is raster engraving for marking.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>This jig is meant to be built up by stacking four layers of material, engraving
a small pocket in the bottom to hold the board to program and inserting pogo
pins in the small holes to contact with the test points on the board. This
template helps us in that it has labels for the test points, though! All of the
test points are clearly identified, except it&amp;rsquo;s unclear what voltage is expected
on the power supply.&lt;/p>
&lt;p>By inspecting the board myself, I eventually determined that the test point for
supplying power (marked VCC on the programming jig template) is downstream of
the 3.3V regulator (not connected to the USB power supply pad) so it expects
3.3 Volts for programming.&lt;/p>
&lt;h3 id="convenient-pinout-diagram">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2019/fomu-beginners-guide/#convenient-pinout-diagram">Convenient pinout diagram&lt;/a>
&lt;/h3>
&lt;p>By way of improving the schematic, here&amp;rsquo;s that same photo of the board with the
signal names from the schematic pointed out on each of the pads, and the
individual chips pointed out.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2019/fomu-0.0-annotated.jpg">
&lt;/figure>
&lt;p>And the same in tabular form for easy searching:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Silkscreen&lt;/th>
&lt;th>Schematic&lt;/th>
&lt;th>Description&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>V&lt;/td>
&lt;td>+3V3&lt;/td>
&lt;td>3.3V rail&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>S&lt;/td>
&lt;td>CS&lt;/td>
&lt;td>SPI chip select (active low)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C&lt;/td>
&lt;td>SCK&lt;/td>
&lt;td>SPI clock&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>I&lt;/td>
&lt;td>MISO&lt;/td>
&lt;td>SPI MISO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>O&lt;/td>
&lt;td>MOSI&lt;/td>
&lt;td>SPI MOSI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>R&lt;/td>
&lt;td>CRESET_B&lt;/td>
&lt;td>FPGA reset (active low)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>G&lt;/td>
&lt;td>GND&lt;/td>
&lt;td>Ground&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>PIN1&lt;/td>
&lt;td>User I/O 1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>PIN2&lt;/td>
&lt;td>User I/O 2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>PIN3&lt;/td>
&lt;td>User I/O 3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>PIN4&lt;/td>
&lt;td>User I/O 4&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="bootstrapping">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2019/fomu-beginners-guide/#bootstrapping">Bootstrapping&lt;/a>
&lt;/h2>
&lt;p>My first task in attempting to
bootstrap a board and load some configuration on it was building a programming
jig. Given there was already a template for a laser-cut acrylic one and I have
access to a benchtop laser cutter, this was easy:&lt;/p>
&lt;a href="https://www.taricorp.net/2019/fomu-laser-jig-assembled.jpg">
&lt;figure>&lt;img src="https://www.taricorp.net/2019/fomu-laser-jig-assembled-small.jpg">
&lt;/figure>
&lt;/a>
&lt;p>It&amp;rsquo;s a little bit ugly because the pogo pins I had ready access too are too
small to nicely fit in the laser-cut holes so I had to carefully glue them in
place.&lt;/p>
&lt;hr>
&lt;p>Actually programming a board can be done with the
&lt;a href="https://github.com/im-tomu/fomu-flash">fomu-flash&lt;/a> utility running on a
Raspberry Pi. I conveniently had a Raspberry Pi 2 to hand, so a little wiring to
the Pi&amp;rsquo;s GPIO header had a jig that should work. Unfortunately, it didn&amp;rsquo;t- all I
got out when trying to make it identify the on-board flash chip was 1s:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>$ fomu-flash -i
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Manufacturer ID: unknown (ff)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Memory model: unknown (ff)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Memory size: unknown (ff)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Device ID: ff
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Serial number: ff ff ff ff
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Status 1: ff
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Status 2: ff
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Status 3: ff&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>I gave up on that hardware after spending a while experimenting with it, and
decided to design a custom programming jig that might be a little easier to
ensure pin alignment is good. This is a little bit tricky because the minimum
pitch of the test points is just 1.8 mm, which is not large enough for the
0.1-inch (2.54 mm) DuPont connectors commonly used for prototyping and desirable
in this case because they&amp;rsquo;re very easy to connect to the Raspberry Pi&amp;rsquo;s GPIO
header.&lt;/p>
&lt;h3 id="a-better-jig">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2019/fomu-beginners-guide/#a-better-jig">A better jig&lt;/a>
&lt;/h3>
&lt;p>Fortunately, I also have access to rather sophisticated prototyping tools and
had some nice parts handy from other projects. In particular, a &lt;a href="https://formlabs.com/3d-printers/form-2/">Form 2
stereolithographic 3D printer&lt;/a> and
some good pogo pins, &lt;a href="https://www.digikey.com.au/product-detail/en/90155-AS/1212-1871-ND/5451894">Preci-dip
90155-AS&lt;/a>.
I computer-modeled a jig to be 3d-printed that should be both compact and
robust, pictured below (see the end of this post for downloadable resources):&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2019/fomu-jig-iso.png"
alt="Isometric view with all edges visible">&lt;figcaption>
&lt;p>Isometric view with all edges visible&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;div style="display: flex; align-items: baseline;">
&lt;figure>&lt;img src="https://www.taricorp.net/2019/fomu-jig-top.png"
alt="Top view">&lt;figcaption>
&lt;p>Top view&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;figure>&lt;img src="https://www.taricorp.net/2019/fomu-jig-bottom.png"
alt="Bottom view">&lt;figcaption>
&lt;p>Bottom view&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;/div>
&lt;h3 id="features">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2019/fomu-beginners-guide/#features">Features&lt;/a>
&lt;/h3>
&lt;p>The design takes great advantage of the flexibility of 3d printing for
fabrication: it is easy to install the pogo pins off-vertical by making the
(press-fit) holes at an arbitrary angle; this would be very difficult with
conventional fabrication, but it allows the spacing of the pins at the top of
the jig to be large enough that 2.54mm connectors can be used, despite the pad
spacing on the board being only 1.8mm.&lt;/p>
&lt;p>On the bottom side, there are several narrow features that act as a shelf to
support the board (which is 0.6mm thick) so its outside surface is flush with
the bottom surface of the jig. A semicircular boss on one side mates with the
cutout on the PCB to key the jig so it is obvious when the board is correctly
oriented in the jig. A small cutout on one edge allows a tool to be inserted to
pull the board out if needed, because the fit is close enough that it might
stick sometimes (or a tool could be pushed through from the top).&lt;/p>
&lt;p>As a manufacturability consideration, the top surface has a slant between
opposite corners. This improves the print quality on a Form 2- because
dimensions on that side are not critical the part is designed to be printed with
that side &amp;ldquo;down&amp;rdquo; (actually up, once in the printer) and supports attached to it
on that end. By allowing the printer to gradually build up a slope rather than
immediately build a plane, it can better produce the intended shape- an earlier
version of the design with a flat top had a very rough finish because large and
thin layers of material tend to warp until enough material is built up to be
self-supporting.&lt;/p>
&lt;hr>
&lt;p>The choice of pogo pins in particular is key, since they&amp;rsquo;re made with a small
shoulder and retaining barbs that allow them to be easily press-fit into a
connector shell:&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2019/90155-as-drawing.png"
alt="Mechanical drawing of a Preci-dip 90155-AS pogo pin. It is 10mm long, with 1.4mm stroke. The central area along its length has several barbs and a narrow shoulder with 10 micron tolerances.">
&lt;/figure>
&lt;p>The one downside of these pins is the short tail, intended for mounting to a
circuit board. While the aforementioned DuPont connectors can be mated to the
tail, they are not very secure and come off at the slightest force. A revised
design choosing parts for their function and not just immediate availability
might prefer to use a part like
&lt;a href="https://www.digikey.com.au/product-detail/en/preci-dip/90101-AS/1212-1860-ND/5451883">90101-AS&lt;/a>,
which is intended for wire termination rather than board mounting- then wires
can be securely attached to the pin rather than tenuously placed on it. My
workaround that didn&amp;rsquo;t involve buying more parts was carefully gluing the wires
in place, which seems to work okay.&lt;/p>
&lt;h2 id="programming">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2019/fomu-beginners-guide/#programming">Programming&lt;/a>
&lt;/h2>
&lt;p>Having built a jig that I could be confident would work correctly, we now return
to the problem of actually programming the board. Connecting the new jig to my
Raspberry Pi in the same way I did the first one, it failed in the same way-
reading all 1s.&lt;/p>
&lt;p>At this point I was rather stumped, with a few possible explanations for the
problems:&lt;/p>
&lt;ol>
&lt;li>Both jigs are unreliable&lt;/li>
&lt;li>I&amp;rsquo;m wiring the jigs up incorrectly&lt;/li>
&lt;li>Software on my Pi is configured incorrectly&lt;/li>
&lt;li>All of the Fomus I tried were faulty&lt;/li>
&lt;/ol>
&lt;p>To discount the first two possibilities, I was able to borrow Mithro&amp;rsquo;s
professionally-built jig that already had a Raspberry Pi 3 connected to it. I
didn&amp;rsquo;t have any credentials to log in to that Pi and use it interactively
however, so I was limited to checking its wiring and carefully ensuring I
connected my Pi to the jig in the same way, then try programming again. This
also failed.&lt;/p>
&lt;a href="https://www.taricorp.net/2019/mithro-programmer.jpg">
&lt;figure>&lt;img src="https://www.taricorp.net/2019/mithro-programmer-small.jpg"
alt="Mithro&amp;rsquo;s jig. It seems very cleverly built to me, clearly designed by somebody with a lot of experience designing these kinds of fixtures.">&lt;figcaption>
&lt;p>Mithro&amp;rsquo;s jig. It seems very cleverly built to me, clearly designed by somebody with a lot of experience designing these kinds of fixtures.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;/a>
&lt;p>Having tried that I had to assume my Pi was somehow misconfigured, since it
seemed increasingly unlikely that I was doing anything wrong and it seemed
implausible that all of my boards were faulty. I eventually took the SD card out
of the other jig&amp;rsquo;s Pi and inspected the software it would run by connecting it
to another computer. This amounted to the same &lt;code>fomu-flash&lt;/code> program I was using,
so I inspected the system configuration in &lt;code>/boot/config.txt&lt;/code> and found a
variety of non-default options that seemed plausibly useful. Ultimately, I found
some magic words:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>dtparam=spi=on&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>This option makes the kernel on the Pi expose a hardware-assisted SPI
peripheral, which seems like an obvious missing option until you realize that
&lt;code>fomu-flash&lt;/code> actually bit-bangs SPI because the hardware support is insufficient
for this application. In any case, I did find that turning that option on makes
everything work correctly:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>$ fomu-flash -i
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Manufacturer ID: Adesto (1f)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Memory model: AT25SF161 (86)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Memory size: 16 Mbit (01)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Device ID: 14
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Serial number: ff ff ff ff
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Status 1: 02
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Status 2: 00
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Status 3: ff&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>I &lt;a href="https://github.com/im-tomu/fomu-flash/pull/3">reported the bug&lt;/a> and made a
note of this in the documentation so hopefully nobody else has to deal with that
problem in the future, even if the root cause is mystifying.&lt;/p>
&lt;h2 id="success">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2019/fomu-beginners-guide/#success">Success!&lt;/a>
&lt;/h2>
&lt;p>With the ability to talk to the configuration flash, it&amp;rsquo;s then possible to write
an actual bitstream. To avoid needing to write one myself, it&amp;rsquo;s easy to take the
&lt;a href="https://github.com/im-tomu/fomu-tests/tree/master/blink">LED blinker example&lt;/a>
from the fomu-tests repository:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>fomu-tests/blink$ make FOMU_REV=hacker
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: constrained &amp;#39;rgb0&amp;#39; to bel &amp;#39;X4/Y31/io0&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: constrained &amp;#39;rgb1&amp;#39; to bel &amp;#39;X5/Y31/io0&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: constrained &amp;#39;rgb2&amp;#39; to bel &amp;#39;X6/Y31/io0&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: constrained &amp;#39;clki&amp;#39; to bel &amp;#39;X6/Y0/io1&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Warning: unmatched constraint &amp;#39;spi_mosi&amp;#39; (on line 5)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Warning: unmatched constraint &amp;#39;spi_miso&amp;#39; (on line 6)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Warning: unmatched constraint &amp;#39;spi_clk&amp;#39; (on line 7)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Warning: unmatched constraint &amp;#39;spi_cs&amp;#39; (on line 8)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: constrained &amp;#39;user_1&amp;#39; to bel &amp;#39;X12/Y0/io1&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: constrained &amp;#39;user_2&amp;#39; to bel &amp;#39;X5/Y0/io0&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: constrained &amp;#39;user_3&amp;#39; to bel &amp;#39;X9/Y0/io1&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: constrained &amp;#39;user_4&amp;#39; to bel &amp;#39;X19/Y0/io1&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Warning: unmatched constraint &amp;#39;usb_dn&amp;#39; (on line 13)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Warning: unmatched constraint &amp;#39;usb_dp&amp;#39; (on line 14)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: Device utilisation:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: ICESTORM_LC: 33/ 5280 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: ICESTORM_RAM: 0/ 30 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: SB_IO: 5/ 96 5%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: SB_GB: 1/ 8 12%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: ICESTORM_PLL: 0/ 1 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: SB_WARMBOOT: 0/ 1 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: ICESTORM_DSP: 0/ 8 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: ICESTORM_HFOSC: 0/ 1 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: ICESTORM_LFOSC: 0/ 1 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: SB_I2C: 0/ 2 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: SB_SPI: 0/ 2 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: IO_I3C: 0/ 2 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: SB_LEDDA_IP: 0/ 1 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: SB_RGBA_DRV: 1/ 1 100%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Info: ICESTORM_SPRAM: 0/ 4 0%
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Built &amp;#39;blink&amp;#39; for Fomu hacker
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ fomu-flash -w blink.bin
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Erasing @ 018000 / 01969a Done
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Programming @ 01959a / 01969a Done
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ fomu-flash -v blink.bin
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Reading @ 01969a / 01969a Done&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Programming that to the board yields a blinking LED as expected, so I&amp;rsquo;ve
achieved success in the basic form of this project by getting the FPGA to do
something. Further exploration will involve writing gateware with
&lt;a href="https://github.com/m-labs/migen">Migen&lt;/a> rather than straight Verilog (because I
find Verilog to be rather tedious to write) and trying to build a system around
a &lt;a href="https://en.wikipedia.org/wiki/RISC-V">RISC-V&lt;/a> CPU (because that sounds
interesting).&lt;/p>
&lt;h2 id="resources">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2019/fomu-beginners-guide/#resources">Resources&lt;/a>
&lt;/h2>
&lt;p>If you want to make your own copy of the programming jig or just explore it,
you&amp;rsquo;ve got several options:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://cad.onshape.com/documents/9f74d5e65249e3a7ac623708/w/687eaa8007de0437f2750c90/e/d8f52334f441612d76b97de0">View the model at OnShape&lt;/a>.
This will allow you to view and make changes to the parametric model, which is
what you&amp;rsquo;ll need to make most useful changes to it.&lt;/li>
&lt;li>&lt;a href="https://www.taricorp.net/2019/fomu-stl-viewer/">View the STL online&lt;/a>. A quick and dirty way to get
an interactive view of the model.&lt;/li>
&lt;li>&lt;a href="https://www.taricorp.net/2019/fomu-jig.stl">Download the STL&lt;/a>. If you just want to try to 3D print
your own, this is all you need. It may also be useful if you want to make
changes using a 3d modeling program (rather than a CAD program).&lt;/li>
&lt;li>&lt;a href="https://www.taricorp.net/2019/fomu-jig.sldprt">Download a Solidworks part file&lt;/a>. This was just
exported from OnShape, but you might prefer this if you want to use
SolidWorks to edit the model.&lt;/li>
&lt;/ul>
&lt;p>All of the official documentation for Fomu is &lt;a href="https://github.com/im-tomu">available on
Github&lt;/a>. For basic information (such as what I
referred to when writing up this project), that&amp;rsquo;s a great starting point.&lt;/p>
&lt;p>I designed the programming jig in &lt;a href="https://www.onshape.com/">OnShape&lt;/a> which is a
pretty good and very convenient CAD tool.&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>FPGA vendors don&amp;rsquo;t publish all the information required to
build configuration bitstreams for their hardware, possibly because they wish
to support their side business in selling design tool licenses- this despite the
fact that (anecdotally, since I can&amp;rsquo;t recall where I saw it) many FPGA
developers say that vendor tooling is one of the biggest annoyances in their
work. The open-source tools require a fair amount of painstaking
reverse-engineering of chips to create!&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Temperature Logging: Redux</title><link>https://www.taricorp.net/2018/temperature-logging-redux/</link><pubDate>Wed, 22 Aug 2018 00:00:00 +0000</pubDate><guid>https://www.taricorp.net/2018/temperature-logging-redux/</guid><description>&lt;p>Previously as I was &lt;a href="https://www.taricorp.net/2018/temperature-logger/">experimenting with logging the
temperature&lt;/a> using a Raspberry Pi (to monitor the
temperatures experienced by fermenting cider), I noted that the Pi was something
of a terrible hack, and it should be possible to do more efficiently with some
slightly less common hardware.&lt;/p>
&lt;p>I decided that improved version would be interesting to build for use at home,
since it&amp;rsquo;s both kind of fun to collect data like that, and actually knowing the
temperature in the house is useful at times. The end result of this project is that
I can generate graphs like the one below of conditions around the house:&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2018/zigbee-graph-sample.png"
alt="Three graphs of temperature, barometric presure and humidity spanning a week, where each graph has three lines; one each for the lounge, bedroom and entry. Temperature shows a diurnal cycle with mostly constant offsets between lines, presure is equal for each and varies slowly over the entire week, and humidity is broadly similar between the three lines and varies somewhat more randomly.">
&lt;/figure>
&lt;h2 id="software-requirements">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#software-requirements">Software requirements&lt;/a>
&lt;/h2>
&lt;p>My primary requirement for home monitoring of this sort is that it not depend on
a proprietary hub (especially not one that depends on an external service that
might go away without warning), and I&amp;rsquo;d also like something that can be
integrated with my existing (but minimal) home automation setup that&amp;rsquo;s based
around &lt;a href="https://www.home-assistant.io/">Home Assistant&lt;/a> running on my home
server.&lt;/p>
&lt;p>Given my main software is open source it should be possible to integrate an
arbitrary solution with it, with varying amount of reverse engineering and
implementation necessary. Because reverse-engineering services like that is not
my idea of fun, it&amp;rsquo;s much preferable to find something that&amp;rsquo;s already supported
and take advantage of others&amp;rsquo; work. While I don&amp;rsquo;t mind debugging, I don&amp;rsquo;t want
to build an integration from scratch if I don&amp;rsquo;t need to.&lt;/p>
&lt;h2 id="hardware-selection">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#hardware-selection">Hardware selection&lt;/a>
&lt;/h2>
&lt;p>As observed last time, the &amp;ldquo;hub&amp;rdquo; model for connecting &amp;ldquo;internet of things&amp;rdquo;
devices to a network seems to be the best choice from a security standpoint- the
only externally-visible network device is the hub, which can apply arbitrary
security policies to communications between devices and to public networks (in
the simplest case, forbidding all communications with public networks). Indeed,
&lt;a href="https://dl.acm.org/citation.cfm?id=3232774">recent scholarly work&lt;/a>
(&lt;a href="https://mickens.seas.harvard.edu/files/mickens/files/deadbolt.pdf">PDF&lt;/a>)
suggests systems that work on this model but apply more sophisticated policies
to communications passing through the hub.&lt;/p>
&lt;p>With that in mind, I decided a Zigbee network for my sensors would be
appropriate- the sensors themselves have no ability to talk to public networks
because they don&amp;rsquo;t even run an Internet Protocol stack, and it&amp;rsquo;s already a
fairly common standard for communication. Plus, I was able to get several of the
previously-mentioned &lt;a href="https://xiaomi-mi.com/sockets-and-sensors/xiaomi-mi-temperature-humidity-sensor/">Xiaomi temperature, humidity and barometric pressure
sensors&lt;/a>
for about $10 each; a quite reasonable cost, given they&amp;rsquo;re battery powered with
very long life and good wireless range.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2018/aqara-sensor.jpg"
alt="A small white square with rounded corners and a thermometer drawn on the front.">&lt;figcaption>
&lt;p>One of the Xiaomi temperature/humidity/pressure sensors.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;hr>
&lt;p>Home assistant already has some support for Zigbee devices; most relevant here
seems to be &lt;a href="https://www.home-assistant.io/components/zha/">its implementation&lt;/a>
of the &lt;a href="https://www.zigbee.org/zigbee-for-developers/applicationstandards/zigbeehomeautomation/">Zigbee Home
Automation&lt;/a>
application standard. Though the documentation isn&amp;rsquo;t very clear, it supports
(or, should support) any radio that communicates with a host processor over a
UART interface and speaks either the &lt;a href="https://www.digi.com/xbee">XBee&lt;/a> or
&lt;a href="https://www.silabs.com/documents/public/user-guides/ug100-ezsp-reference-guide.pdf">EZSP&lt;/a>
serial protocol.&lt;/p>
&lt;p>Since the documentation for Home Assistant specifically notes that the &lt;a href="https://www.tindie.com/products/Elelabs/elelabs-zigbee-usb-adapter/">Elelabs
Zigbee USB
adapter&lt;/a> is
compatible, I bought one of those. Its documentation includes a description of
how to configure Home Assistant with it and specifically mentions Xiaomi Aqara
devices (which includes the sensors I had selected), so I was confident that
radio would meet my needs, though unsure of exactly what protocol was actually
used to communicate with the radio over USB at the time I ordered it.&lt;/p>
&lt;h2 id="experimenting">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#experimenting">Experimenting&lt;/a>
&lt;/h2>
&lt;p>Once I received the Zigbee radio-on-a-usb-stick, I immediately tried to manually
drive it using whatever libraries I could use to set up a network and get one of
my sensors connected to it. This ended up not working, but I did learn a lot
about how the radio adapter is meant to work.&lt;/p>
&lt;p>For working with it in Python, the Elelabs documentation points to
&lt;a href="https://github.com/zigpy/bellows">bellows&lt;/a>, a library providing EZSP protocol
support for the &lt;a href="https://github.com/zigpy/zigpy">zigpy&lt;/a> Zigbee stack. It also
includes a command-line interface exposing some basic commands, perfect for the
sort of experimentation I wanted to do.&lt;/p>
&lt;p>Getting connected was easy; I plugged the USB stick into my Linux workstation
and it appeared right away as a
&lt;a href="http://www.prolific.com.tw/US/ShowProduct.aspx?pcid=41">PL2303&lt;/a> USB-to-serial
converter. Between this and noting that bellows implements the EZSP protocol, I
inferred that the Elelabs stick is a Silicon Labs &lt;a href="https://www.silabs.com/products/wireless/mesh-networking/em35x-zigbee-thread-soc-ncp">EM35x
microcontroller&lt;/a>
running the EmberZNet stack in a network coordinator mode, with a PL2303
exposing a UART over USB so the host can communicate with the microcontroller
(and the rest of the network) by speaking EZSP.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2018/emberznet-marketing.png"
alt="A network diagram showing multiple Zigbee routers and sleepy end devices. Text claims that the EmberZNet PRO stack delivers robust and reliable mesh networking, supporting all Zigbee device types.">&lt;figcaption>
&lt;p>SiLabs marketing does a pretty good job of selling their software stack.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Having worked that out and made sense of it, I printed out a label for the stick
that says what it is (&amp;ldquo;Elelabs Zigbee USB adapter&amp;rdquo;) and how to communicate with
it (EZSP at 57600 baud) since the stick is completely unmarked otherwise and
being able to tell what it does just by looking at it is very helpful.&lt;/p>
&lt;hr>
&lt;p>Trying to use the bellows CLI, the status output seemed okay and the NCP was
running. In order to connect one of my sensors, I then needed to figure out how
to make the sensor join the network after using &lt;code>bellows permit&lt;/code> to let new
devices join the network. The sensors each came with a little instruction
booklet, but it was all in Chinese. With the help of Google Translate, I was
able to take photos of it and find the important bit- holding the button on the
sensor for about 5 seconds until the LED blinks three times will reset it, at
which point it will attempt to join an open network.&lt;/p>
&lt;p>On trying to run &lt;code>bellows permit&lt;/code> prior to resetting a sensor to get it on the
network, I encountered an annoying bug- it didn&amp;rsquo;t seem to do anything, and
Python emitted a warning: &lt;code>RuntimeWarning: coroutine 'permit' was never awaited&lt;/code>. I dug into that a little more and found the libraries make heavy use
of &lt;a href="https://www.python.org/dev/peps/pep-0492/">PEP 492 coroutines&lt;/a>, and the
warning was fairly clear that a function was declared &lt;code>async&lt;/code> when it shouldn&amp;rsquo;t
have been (or its coroutine wasn&amp;rsquo;t then given to an event loop) so the function
actually implementing &lt;code>permit&lt;/code> never ran. I eventually tracked down the problem,
patched it locally and &lt;a href="https://github.com/zigpy/bellows/issues/128">filed a
bug&lt;/a> which has since been fixed.&lt;/p>
&lt;p>Having fixed that bug, I continued to try to get a sensor on my toy network but
was ultimately (apparently) unsuccessful. I could permit joins and reset the
sensor and see debug output indicating something was happening on the network,
but never any conclusive messages saying a new device had joined and rather a
lot of messages along the lines of &amp;ldquo;unrecognized message.&amp;rdquo; I couldn&amp;rsquo;t tell if it
was working or not, so moved on to hooking up Home Assistant.&lt;/p>
&lt;h3 id="setting-up-home-assistant">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#setting-up-home-assistant">Setting up Home Assistant&lt;/a>
&lt;/h3>
&lt;p>Getting set up with Home Assistant was mostly just a matter of following the
&lt;a href="https://www.taricorp.net/2018/EZBUSBA_UG_1_HomeAssistant.pdf">guide provided with the USB stick&lt;/a>, but
using my own knowledge of how to set up the software (not using &lt;code>hassio&lt;/code>).
Configuring the &lt;code>zha&lt;/code> component and pointing it at the right USB path is pretty
easy. I did discover that specifying the &lt;code>database_path&lt;/code> for the &lt;code>zha&lt;/code> component
alone is not enough to make it work; if the file doesn&amp;rsquo;t already exist setup
just fails. Simply creating an empty file at the configured path is enough-
apparently that file is an &lt;code>sqlite&lt;/code> database that &lt;code>zigpy&lt;/code> uses to track known
devices.&lt;/p>
&lt;p>Still following the Elelabs document, I spent a bit of time invoking
&lt;code>zha.permit&lt;/code> and trying to get a sensor online to no apparent success. After a
little more searching, I found discussion on the Home Assistant forums and in
particular one user suggesting that these particular sensors are somewhat
finicky when joining a network. They suggested (and my findings agree) that
holding the button on the sensor to reset it, then tapping the button
approximately every second for a little while (another 5-10 seconds) will keep
it awake long enough to successfully join the network.&lt;/p>
&lt;p>The keep-awake tapping approach did eventually work, though I also found that
Home Assistant sometimes didn&amp;rsquo;t show a new sensor (or parts of a new sensor,
like it might show the temperature but not humidity or pressure) until I
restarted it. This might be a bug or a misconfiguration on my part, but it&amp;rsquo;s
minor enough not to worry about.&lt;/p>
&lt;p>At this point I&amp;rsquo;ve verified that my software and hardware can all work, so it&amp;rsquo;s
time to set up the permanent configuration.&lt;/p>
&lt;h2 id="permanent-configuration">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#permanent-configuration">Permanent configuration&lt;/a>
&lt;/h2>
&lt;p>As mentioned above, I run Home Assistant on my Linux home server. Since I was
already experimenting on a Linux system, that configuration should be trivial to
transfer over, but for one additional desire I had: I want more freedom in where
I place the Zigbee radio, in particular not just plugged directly into a free
USB port on the server. Putting it in a reasonably central location with other
radios (say, near the WiFi router) would be nice.&lt;/p>
&lt;p>A simple solution might be a USB extension cable, but I didn&amp;rsquo;t have any of those
handy and strewing more wires about the place feels inelegant. My Internet
router (a &lt;a href="https://www.tp-link.com/us/products/details/cat-9_Archer-C7.html">TP-Link Archer
C7&lt;/a> &lt;a href="https://openwrt.org/toh/tp-link/archer-c5-c7-wdr7500">running
OpenWrt&lt;/a>) does have an
available USB port though, so I suspected it would be possible to connect the
Zigbee radio to the router and make it appear as a serial port on the server.
This turned out to be true!&lt;/p>
&lt;h3 id="serial-over-network">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#serial-over-network">Serial over network&lt;/a>
&lt;/h3>
&lt;p>To find the solution for running a serial port over the network, I first
searched for existing protocols; it turns out there&amp;rsquo;s a standard one that&amp;rsquo;s
somewhat commonly used in fancy networking equipment, specified by
&lt;a href="https://tools.ietf.org/html/rfc2217">RFC 2217&lt;/a>. RFC 2217 specifies a set of
extensions to Telnet allowing serial port configuration (bit rate, data bits,
parity, etc) and flow control over Telnet.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2018/ibm-rfc2217.png"
alt="A computer is connected via Ethernet to a Device Server which runs a RFC 2217 server and it connected to multiple external modems via RS/232.">&lt;figcaption>
&lt;p>A diagram of RFC 2217 application from some IBM documentation.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Having identified a protocol that does what I want, it&amp;rsquo;s then a matter of
finding software that works as a client (assuming I&amp;rsquo;ll be able to find or write
a suitable server). Suitable clients are somewhat tricky however, since from an
applicaton perspective UART use on Linux involves making &lt;a href="http://man7.org/linux/man-pages/man4/tty_ioctl.4.html">specialized
&lt;code>ioctl&lt;/code>s&lt;/a> to the device
to configure it, then reading and writing bytes as usual. Making an RFC2217
network serial device appear like a local device would seem to involve writing a
kernel driver that exports a new class of RFC2217 device nodes supporting the
relevant ioctls- none exists.&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>&lt;/p>
&lt;p>An alternate approach (not using RFC 2217) might be
&lt;a href="https://www.kernel.org/doc/readme/tools-usb-usbip-README">USB/IP&lt;/a>, which is
supported in mainline Linux and allows a server to bind USB devices physically
connected to it to a virtual USB controller that can then be remotely attached
to a different physical machine over a network. This seems like a more complex
and potentially fragile solution though, so I put that aside after learning of
it.&lt;/p>
&lt;p>Since Linux doesn&amp;rsquo;t have any kernel-level support for remote serial ports, I
needed to search for support at the application level. It turns out &lt;code>bellows&lt;/code>
uses &lt;code>pyserial&lt;/code> to communicate with radios, and pySerial is a quite featureful
library- while most users will only ever provide device names like &lt;code>COM1&lt;/code> or
&lt;code>/dev/ttyUSB0&lt;/code>, &lt;a href="https://pythonhosted.org/pyserial/url_handlers.html">it supports a range of more exotic
URLs&lt;/a> specifying
connections, including RFC 2217.&lt;/p>
&lt;p>So given a suitable server running on a remote machine, I should be able to
configure Home Assistant to use a URL like &lt;code>rfc2217://zigbee.local:25&lt;/code> to reach
the Zigbee radio.&lt;/p>
&lt;h3 id="serial-server">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#serial-server">Serial server&lt;/a>
&lt;/h3>
&lt;p>The next step in setting up the Zigbee radio plugged into the router is finding
an application that can expose a PL2303 over the network with the RFC 2217
protocol. That turned out to be a short search, where I quickly discovered
&lt;a href="http://ser2net.sourceforge.net/">&lt;code>ser2net&lt;/code>&lt;/a> which does the job &lt;em>and&lt;/em> is already
packaged for OpenWRT. Installing it on the router was trivial, though I also
needed to be sure the kernel module(s) required to expose the USB-serial port
were available:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># opkg install kmod-usb-serial-pl2303 ser2net&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Having installed &lt;code>ser2net&lt;/code>, I still had to figure out how to configure it. While
the documentation describes its configuration format, I know from experience
that configuring servers on OpenWRT is usually done differently (as something of
a concession to targeting embedded systems without much storage). I quickly
found that the package had installed a sample configuration file at
&lt;code>/etc/config/ser2net&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>config ser2net global
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option enabled 1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>config controlport
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option enabled 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option host localhost
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option port 2000
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>config default
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option speed 115200
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option databits 8
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option parity &amp;#39;none&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option stopbits 1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option rtscts false
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option local false
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option remctl true
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>config proxy
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option enabled 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option port 5000
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option protocol telnet
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option timeout 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option device &amp;#39;/dev/ttyAPP0&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option baudrate 115200
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option databits 8
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option parity &amp;#39;none&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option stopbits 1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span># option led_tx &amp;#39;tx&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span># option led_rx &amp;#39;rx&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option rtscts false
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option local false
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option xonxoff false&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Unfortunately, this configuration doesn&amp;rsquo;t include any comments so the reader is
force to guess the meaning of each option. They mostly correspond to words that
appear in the ser2net manual, but I didn&amp;rsquo;t trust guesses so went digging in the
OpenWRT packages source code and found &lt;a href="https://github.com/openwrt/packages/blob/openwrt-18.06/net/ser2net/files/ser2net.init">the script responsible for
converting&lt;/a>
&lt;code>/etc/config/ser2net&lt;/code> into an actual configuration file when starting ser2net.&lt;/p>
&lt;p>My initial guess at the configuration I wanted looked something like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-tex" data-lang="tex">&lt;span style="display:flex;">&lt;span>config proxy
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option enabled 1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option port 5000
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option protocol telnet
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option timeout 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option device &amp;#39;/dev/ttyUSB0&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option baudrate 57600
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option remctl true&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The protocol is specified as telnet because RFC 2217 is a layer on top of telnet
(my first guess was that I actually wanted &lt;code>raw&lt;/code> until actually reading the RFC
and seeing it was a set of telnet extensions), and the device is the device name
that I found the Zigbee stick appeared as when plugged into the
router.&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup> Unfortunately, this configuration didn&amp;rsquo;t work and pyserial
gave gack a somewhat perplexing error message:
&lt;code>serial.serialutil.SerialException: Remote does not seem to support RFC2217 or BINARY mode [we-BINARY:False(INACTIVE), we-RFC2217:False(REQUESTED)]&lt;/code>.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2018/elelabs-router-usb.jpg"
alt="The Elelabs stick plugged into my TP-link router, which is mounted on a wall.">
&lt;/figure>
&lt;p>Without much visibility into what the serial driver was trying to do, I opted to
examine the network traffic with Wireshark. I first attempted to use the
text-mode interface (&lt;code>tshark -d tcp.port==5000,telnet -f 'port 5000'&lt;/code>), but
quickly gave up and switched to the GUI instead. I captured the traffic passing
between the server and router, but there was almost nothing! The client
(pyserial) was sending some Telnet negotiation messages (DO ECHO, WILL suppress
go ahead and COM port control), then nothing happened for a few seconds and the
connection closed.&lt;/p>
&lt;p>Since restarting Home Assistant for every one of these serial tests was quite
cumbersome, at this point I checked if pyserial includes any programs suitable
for testing connectivity. It happily does, provided in my distribution&amp;rsquo;s package
as &lt;code>miniterm.py&lt;/code>. Running &lt;code>miniterm.py rfc2217://c7:5000&lt;/code> failed in the same
way, so I had a quicker debugging tool.&lt;/p>
&lt;hr>
&lt;p>At this point the problem seems like it&amp;rsquo;s at the server side, so I stopped the
ser2net server on the router and started one in the foreground, with a custom
configuration specified on the command line:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>$ /etc/init.d/ser2net stop
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ser2net -n -d -C &lt;span style="color:#d14">&amp;#39;5000:telnet:0:/dev/ttyUSB0:57600 remctl&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ser2net&lt;span style="color:#000;font-weight:bold">[&lt;/span>14914&lt;span style="color:#000;font-weight:bold">]&lt;/span>: Unable to create network socket&lt;span style="color:#000;font-weight:bold">(&lt;/span>s&lt;span style="color:#000;font-weight:bold">)&lt;/span> on line &lt;span style="color:#099">0&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>While &lt;code>ser2net&lt;/code> didn&amp;rsquo;t outright fail, it did print a concerning error message.
Does it work if I change the port it&amp;rsquo;s listening on?&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>$ ser2net -n -d -C &lt;span style="color:#d14">&amp;#39;1234:telnet:0:/dev/ttyUSB0:57600 remctl&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>And then running &lt;code>miniterm.py&lt;/code> succeeds, leaving me with a terminal I could type
into (but didn&amp;rsquo;t, since I don&amp;rsquo;t know how to speak EZSP with my keyboard).&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>$ miniterm.py rfc2217://c7:1234 &lt;span style="color:#099">57600&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>--- Miniterm on rfc2217://c7:1234 57600,8,N,1 ---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>--- Quit: Ctrl+&lt;span style="color:#000;font-weight:bold">]&lt;/span> | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H ---
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>--- &lt;span style="color:#0086b3">exit&lt;/span> ---&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>I discovered after a little digging (&lt;code>netstat -lnp&lt;/code>) that &lt;code>miniupnpd&lt;/code> was
already listening on port 5000 of the router, so changing the port fixes the
confusing problem. A different sample port in the &lt;code>ser2net&lt;/code> configuration would
have prevented such an issue, as would &lt;code>ser2net&lt;/code> giving up when it fails to bind
to a requested port instead of printing a message and pretending nothing
happened. But at least I didn&amp;rsquo;t have to patch anything to make it work.&lt;/p>
&lt;hr>
&lt;p>With ser2net listening on port 2525 instead, Home Assistant can connect to it
(hooray!). But it immediately throws a different error: &lt;code>NotImplementedError: write_timeout is currently not supported&lt;/code>. I&amp;rsquo;ve found another bug in a
rarely-exercised corner of this software stack, have I?&lt;/p>
&lt;p>Well, kind of. Finding &lt;a href="https://github.com/pyserial/pyserial/blob/v3.4/serial/rfc2217.py#L506">that error message in the pyserial
source&lt;/a>,
something is trying to set the write timeout to zero and it&amp;rsquo;s simply not
implemented in pyserial for RFC2217 connections. This is ultimately because Home
Assistant (as alluded to earlier with bellows and zigpy) is all coroutine-based
so it uses &lt;a href="https://github.com/pyserial/pyserial-asyncio">pyserial-asyncio&lt;/a> to
adapt the blocking APIs provided by pyserial to something that works nicely with
coroutines running on an event loop. When &lt;code>pyserial-asyncio&lt;/code> tries to set
non-blocking mode by making the timeout zero, we find it&amp;rsquo;s not supported.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">_reconfigure_port&lt;/span>(&lt;span style="color:#999">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#34;&amp;#34;&amp;#34;Set communication parameters on opened port.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_socket &lt;span style="color:#000;font-weight:bold">is&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">raise&lt;/span> SerialException(&lt;span style="color:#d14">&amp;#34;Can only operate on open ports&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># if self._timeout != 0 and self._interCharTimeout is not None:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># XXX&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">if&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>_write_timeout &lt;span style="color:#000;font-weight:bold">is&lt;/span> &lt;span style="color:#000;font-weight:bold">not&lt;/span> &lt;span style="color:#000;font-weight:bold">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">raise&lt;/span> &lt;span style="color:#900;font-weight:bold">NotImplementedError&lt;/span>(&lt;span style="color:#d14">&amp;#39;write_timeout is currently not supported&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># XXX&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>While I could probably implement non-blocking support for RFC 2217 in pyserial,
that seemed rather difficult and not my idea of fun. So instead I looked for a
workaround- if RFC 2217 won&amp;rsquo;t work, does pyserial support a protocol that will?&lt;/p>
&lt;p>The answer is of course yes: I can use &lt;code>socket://&lt;/code> for a raw socket connection
to the &lt;code>ser2net&lt;/code> server. This sacrifices the ability to change UART parameters
(format, baud rate, etc) on the fly, but since the USB stick doesn&amp;rsquo;t support
changing parameters on the fly anyway (as far as I can tell), this is no problem.&lt;/p>
&lt;h2 id="final-configuration">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#final-configuration">Final configuration&lt;/a>
&lt;/h2>
&lt;p>The ser2net configuration that I&amp;rsquo;m now using looks like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>config proxy
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option enabled 1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option port 2525
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option protocol raw
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option timeout 0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option device &amp;#39;/dev/ttyUSB0&amp;#39;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option baudrate 57600
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option remctl 0&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>And the relevant stanza in Home Assistant configuration: (The baud rate needs
to be specified, but pyserial ignores it for &lt;code>socket://&lt;/code> connections.)&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000080">zha&lt;/span>:&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000080">usb_path&lt;/span>:&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#d14">&amp;#39;socket://c7:2525&amp;#39;&lt;/span>&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000080">database_path&lt;/span>:&lt;span style="color:#bbb"> &lt;/span>/srv/homeassistant/.homeassistant/zigbee.db&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000080">baudrate&lt;/span>:&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#099">57600&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>After ensuring the &lt;code>zigbee.db&lt;/code> file exists and restarting Home Assistant to
reload the configuration, I was able to pair all three sensors by following the
procedure defined above: call the &lt;code>permit&lt;/code> service in Home Assistant, then
reset the sensor by holding the button until its LED blinks three times, then
tap the button every second or so for a bit.&lt;/p>
&lt;p>I did observe some strange behavior on pairing the sensors that made me think
they weren&amp;rsquo;t pairing correctly, like error messages in the log (&lt;code>ERROR (MainThread) [homeassistant.components.sensor] Setup of platform zha is taking longer than 60 seconds. Startup will proceed without waiting any longer.&lt;/code>) and
some parts of each sensor not appearing (the temperature might be shown, but not
humidity or pressure). Restarting Home Assistant after pairing the sensors made
everything appear as expected though, so there &lt;a href="https://github.com/zigpy/bellows/issues/120">may be a bug somewhere in
there&lt;/a> but I can&amp;rsquo;t be bothered to
debug it since there was a very easy workaround.&lt;/p>
&lt;h2 id="complaining-about-async-io">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#complaining-about-async-io">Complaining about async I/O&lt;/a>
&lt;/h2>
&lt;p>It&amp;rsquo;s rather interesting to me that the major bugs I encountered in trying to set
up this system in a slightly unusual configuration were related to asynchronous
I/O running in event loops- this is an issue that&amp;rsquo;s become something of my pet
problem, such that I will argue to just about anybody who will listen that
&lt;a href="https://docs.google.com/presentation/d/1xJShE9wMNc543GRyGmig_N8MvzbWWVhqLqJ3u7VYBII/edit">asynchronous I/O is usually unnecessary and more difficult to
program&lt;/a>.&lt;/p>
&lt;p>That I discovered two separate bugs in the tools that make this work relating to
running asynchronous I/O in event loops seems to support that conclusion. If
Home Assistant simply spawned threads for components I believe it would simplify
individual parts (perhaps at the cost of some slightly more complex low-level
communication primitives) and make the system easier to debug. Instead, it runs
all of its &lt;a href="https://github.com/pyserial/pyserial/pull/97/files">dependencies in a way they are not well-exercised
in&lt;/a>, presumably in
search of &amp;ldquo;maximum performance&amp;rdquo; that seems entirely irrelevant when considering
the program&amp;rsquo;s main function is acting as a hub for a variety of external
devices.&lt;/p>
&lt;p>I have (slowly) been working on distilling all these complaints into a series of
essays on the topic, but for now this is a fine opportunity to wave a finger at
something that I think is strictly worse because it&amp;rsquo;s evented.&lt;/p>
&lt;h2 id="conclusion">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logging-redux/#conclusion">Conclusion&lt;/a>
&lt;/h2>
&lt;p>I&amp;rsquo;m pretty happy with the sensors and software configuration I have now- the
sensors are tiny and unobtrusive, while the software does a fine job of logging
data and presenting live readings for my edification.&lt;/p>
&lt;p>I&amp;rsquo;d like to also configure a &amp;ldquo;real&amp;rdquo; database like
&lt;a href="https://www.influxdata.com/">InfluxDB&lt;/a> to store my sensor readings over
arbitrarily long time periods (since Home Assistant doesn&amp;rsquo;t remember data
forever, reasonably so), which shouldn&amp;rsquo;t be too difficult (it&amp;rsquo;s supported as a
module) but is somewhat unrelated to setting up Zigbee sensors in the first
place. Until then, I&amp;rsquo;m pretty happy with these results despite the fact that I
think the developers have made a terrible choice with evented I/O.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2018/live-readings.png"
alt="A line of circles, each labelled with a sensor name and the value. The state of the sun and moon are shown, as well as temperature, pressure and humidity for each of the bedroom, entry and lounge Zigbee sensors.">&lt;figcaption>
&lt;p>Live sensor readings from Home Assistant; nice at a glance.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>I did find somebody &lt;a href="https://lists.kernelnewbies.org/pipermail/kernelnewbies/2014-August/011524.html">asking for input on the implementation of
exactly that&lt;/a>,
but it looks like nothing ever came of it. A reply suggesting an application
at the master end of a pty (pseudoterminal) suggests an interesting alternate
option, but it doesn&amp;rsquo;t appear to be possible to receive parameter change
requests from a pty (though flow control is exposed when running in &amp;ldquo;packet
mode&amp;rdquo;).&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>I was concerned at the outset that the router might be completely
unable to see the Zigbee stick, since apparently the Archer C7 doesn&amp;rsquo;t include
a USB 1.1 OHCI or UHCI controller, so it&amp;rsquo;s incapable of communicating at all
with low-speed devices like keyboards! I&amp;rsquo;ve heard (but not verified myself)
that connecting a USB 2.0 hub will allow the router to communicate with
low-speed devices downstream of the hub as a workaround.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Building a terrible 'IoT' temperature logger</title><link>https://www.taricorp.net/2018/temperature-logger/</link><pubDate>Tue, 31 Jul 2018 00:00:00 +0000</pubDate><guid>https://www.taricorp.net/2018/temperature-logger/</guid><description>&lt;p>I had approximately the following exchange with a co-worker a few days ago:&lt;/p>
&lt;blockquote>
&lt;p>Them: &amp;ldquo;Hey, do you have a spare &lt;a href="https://www.raspberrypi.org/">Raspberry Pi&lt;/a>
lying around?&amp;rdquo; &lt;br>
Me: [thinks] &amp;ldquo;..yes, actually.&amp;rdquo; &lt;br>
T: &amp;ldquo;Do you want to build a temperature logger with
&lt;a href="https://prometheus.io">Prometheus&lt;/a> and a DS18B20+? &lt;br>
M: &amp;ldquo;Uh, okay?&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>It later turned out that that co-worker had been enlisted by yet another
individual to provide a temperature logger for their project of brewing cider,
to monitor the temperature during fermentation. Since I had all the hardware at
hand (to wit, a Raspberry Pi 2 that I wasn&amp;rsquo;t using for anything and temperature
sensors provided by the above co-worker), I threw something together. It also
turned out that the deadline was quite short (brewing began just two days after
this initial exchange), but I made it work in time.&lt;/p>
&lt;h2 id="interfacing-the-thermometer">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#interfacing-the-thermometer">Interfacing the thermometer&lt;/a>
&lt;/h2>
&lt;p>As noted above, the core of this temperature logger is a DS18B20 temperature
sensor. [Per the manufacturer]
(&lt;a href="https://www.maximintegrated.com/en/products/sensors/DS18B20.html)">https://www.maximintegrated.com/en/products/sensors/DS18B20.html)&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>The DS18B20 digital thermometer provides 9-bit to 12-bit Celsius temperature
measurements &amp;hellip; communicates over a 1-Wire bus that by definition requires
only one data line (and ground) for communication with a central
microprocessor. &amp;hellip; Each DS18B20 has a unique 64-bit serial code, which
allows multiple DS18B20s to function on the same 1-Wire bus. Thus, it is
simple to use one microprocessor to control many DS18B20s distributed over a
large area.&lt;/p>
&lt;/blockquote>
&lt;p>Indeed, this is a very easy device to interface with. But even given the svelte
hardware needs (power, data and ground signals), writing some code that speaks
&lt;a href="https://en.wikipedia.org/wiki/1-Wire">1-Wire&lt;/a> is not necessarily something I&amp;rsquo;m
interested in.
Fortunately, these sensors are very commonly used with the Raspberry Pi,
as illustrated by &lt;a href="https://learn.adafruit.com/adafruits-raspberry-pi-lesson-11-ds18b20-temperature-sensing?view=all">an Adafruit
tutorial&lt;/a>
published in 2013.&lt;/p>
&lt;hr>
&lt;p>The Linux kernel provided for the Pi in its default Raspbian (Debian-derived)
distribution supports bit-banging 1-Wire over its GPIOs by default, requiring
only a &lt;a href="https://www.raspberrypi.org/documentation/configuration/device-tree.md">device tree
overlay&lt;/a>
to activate it. This is as simple as adding a line to &lt;code>/boot/config.txt&lt;/code> to make
the machine&amp;rsquo;s boot loader instruct the kernel to apply a change to the hardware
configuration at boot time:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>dtoverlay=w1-gpio&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>With that configuration, one simply needs to wire the sensor up. The &lt;code>w1-gpio&lt;/code>
device tree configuration by default uses GPIO 4 on the Pi as the data line,
then power and grounds need to be connected and a pull-up resistor added to the
data line (since 1-Wire is an open-drain bus).&lt;/p>
&lt;p>&lt;a href="https://www.taricorp.net/2018/pitemp-schematic.svg">
&lt;figure>&lt;img src="https://www.taricorp.net/2018/pitemp-schematic.png"
alt="DS18B20 VDD and GND connect to Raspberry Pi 3V3 and GND respectively; sensor DQ connects to Pi GPIO4. There is a 4.7k resistor between VDD and DQ.">
&lt;/figure>
&lt;/a>&lt;/p>
&lt;p>The &lt;a href="https://www.kernel.org/doc/Documentation/w1/slaves/w1_therm">&lt;code>w1-therm&lt;/code> kernel
module&lt;/a> already
understands how to interface with these sensors- meaning I don&amp;rsquo;t need to write
any code to talk to the temperature sensor: Linux can do it all for me! For
instance, reading the temperature out in an interactive shell to test, after
booting with the 1-Wire overlay enabled:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>$ modprobe w1-gpio w1-therm
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ &lt;span style="color:#0086b3">cd&lt;/span> /sys/bus/w1/devices
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ ls
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>28-000004b926f1 w1_bus_master1
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ cat 28-000004b926f1/w1_slave
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>9b &lt;span style="color:#099">01&lt;/span> 4b &lt;span style="color:#099">46&lt;/span> 7f ff &lt;span style="color:#099">05&lt;/span> &lt;span style="color:#099">10&lt;/span> 6e : &lt;span style="color:#008080">crc&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>6e YES
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>9b &lt;span style="color:#099">01&lt;/span> 4b &lt;span style="color:#099">46&lt;/span> 7f ff &lt;span style="color:#099">05&lt;/span> &lt;span style="color:#099">10&lt;/span> 6e &lt;span style="color:#008080">t&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#099">25687&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The kernel periodically scans the 1-Wire bus for slaves and creates a directory
for each device it detects. In this instance, there is one slave on the bus (my
temperature sensor) and it has serial number 000004b926f1. Reading its
&lt;code>w1_slave&lt;/code> file (provided by the &lt;code>w1-therm&lt;/code> driver) returns the bytes that were
read on both lines, a summary of transmission integrity derived from the message
checksum on the first line, and &lt;code>t=x&lt;/code> on the second line, where &lt;code>x&lt;/code> is the
measured temperature in milli-degrees Celsius. Thus, the measured temperature
above was 25.687 degrees.&lt;/p>
&lt;p>While it&amp;rsquo;s fairly easy to locate and read these files in sysfs from a program, I
found a Python library that further simplifies the process:
&lt;a href="https://github.com/timofurrer/w1thermsensor">&lt;code>w1thermsensor&lt;/code>&lt;/a> provides a simple
API for detecting and reading 1-wire temperature sensors, which I used when
implementing the bridge for capturing temperature readings (detailed more
later).&lt;/p>
&lt;h3 id="1-wire-details">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#1-wire-details">1-Wire details&lt;/a>
&lt;/h3>
&lt;p>I wanted to verify for myself how the 1-wire interfacing worked so here are the
details of what I&amp;rsquo;ve discovered, presented because they may be interesting or
helpful to some readers. Most documentation of how to perform a given task with
a Raspberry Pi is limited to comments like &amp;ldquo;just add this line to some file and
do the other thing!&amp;rdquo; with no discussion of the mechanics involved, which I find
very unsatisfying.&lt;/p>
&lt;p>The line added to &lt;code>/boot/config.txt&lt;/code> tells the Rapberry Pi&amp;rsquo;s boot loader
(a version of &lt;a href="http://www.denx.de/wiki/U-Boot">Das U-Boot&lt;/a>) to pass the
&lt;code>w1-gpio.dtbo&lt;/code> device tree overlay description to the kernel. The details of
what&amp;rsquo;s in that overlay can be found in the kernel source tree at
&lt;a href="https://github.com/raspberrypi/linux/blob/rpi-4.18.y/arch/arm/boot/dts/overlays/w1-gpio-overlay.dts">&lt;code>arch/arm/boot/dts/overlays/w1-gpio-overlay.dts&lt;/code>&lt;/a>.&lt;/p>
&lt;p>This in turn pulls in the &lt;code>w1-gpio&lt;/code> kernel module, which is part of the
upstream kernel distribution- it&amp;rsquo;s very simple, setting or reading the value of
a GPIO port as requested by the Linux 1-wire subsystem.&lt;/p>
&lt;p>Confusingly, if we examine the dts file describing the device tree overlay, it
can take a &lt;code>pullup&lt;/code> option that controls a &lt;code>rpi,parasitic-power&lt;/code> parameter. The
documentation says this &amp;ldquo;enable(s) the parasitic power (2-wire, power-on-data)
feature&amp;rdquo;, which is confusing. 1-Wire is inherently capable of supplying
parasitic power to slaves with modest power requirements, with the slaves
charging capacitors off the data line when it&amp;rsquo;s idle (and being held high, since
it&amp;rsquo;s an open-collector bus). So, saying an option will enable parasitic power is
confusing at best and probably flat wrong.&lt;/p>
&lt;p>Further muddying the waters, there also exists a &lt;code>w1-gpio-pullup&lt;/code> overlay that
includes a second GPIO to drive an external pullup to provide more power, which
I believe allows implementation of the strong pull-up described in Figure 6 of
the DS18B20 datasheet (required because the device&amp;rsquo;s power draw while reading
the temperature exceeds the capacity of a typical parasitic power setup):&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2018/18b20-fig6.png"
alt="A secondary GPIO from a microprocessor provides a strong pull-up on the 1-Wire bus while power requirements exceed parasitic supply capabilities.">
&lt;/figure>
&lt;p>By also connecting the pullup GPIO to the data line (or putting a FET in there
like the datasheet suggests), the &lt;code>w1-gpio&lt;/code> driver will set the pullup line to
logic high for a requested time, then return it to Hi-Z where it will idle. But
for my needs (cobbling something together quickly), it&amp;rsquo;s much easier to not even
bother with parasite power.&lt;/p>
&lt;p>In conclusion for this section: I don&amp;rsquo;t know what the &lt;code>pullup&lt;/code> option for the
1-Wire GPIO overlay actually does, because enabling it and removing the external
pull-up resistor from my setup causes the bus to stop working. The documentation
is confusingly imprecise, so I gave up on further investigation since I already
had a configuration that worked.&lt;/p>
&lt;h2 id="prometheus-scraping">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#prometheus-scraping">Prometheus scraping&lt;/a>
&lt;/h2>
&lt;p>To capture store time-series data representing the temperature, per the
co-worker&amp;rsquo;s original suggestion I opted to use
&lt;a href="https://prometheus.io">Prometheus&lt;/a>. While it&amp;rsquo;s designed for monitoring the
state of computer systems, it&amp;rsquo;s plenty capable of storing temperature data as
well. Given I&amp;rsquo;ve used Prometheus before, it seemed like a fine option for this
application though on later consideration I think a more robust (and effortful)
system could be build with different technology choices (explored &lt;a href="https://www.taricorp.net/2018/temperature-logger#refinements">later in this
post&lt;/a>).&lt;/p>
&lt;p>The Raspberry Pi with temperature sensor in my application is expected to stay
within range of a WiFi network with internet connectivity, but this network does
not permit any incoming connections, nor does it permit connections between
wireless clients. Given I wanted to make the temperature data available to
anybody interested in the progress of brewing, there needs to be some bridge to
the outside world- thus Prometheus should run on a different machine from the
Pi.&lt;/p>
&lt;p>The easy solution I chose was to bring up a minimum-size virtual machine on
Google Cloud running Debian, then install Prometheus and
&lt;a href="https://www.influxdata.com/">InfluxDB&lt;/a> from the Debian repositories:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>$ apt-get install prometheus influxdb&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;h2 id="temperature-exporter">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#temperature-exporter">Temperature exporter&lt;/a>
&lt;/h2>
&lt;p>Having connected the thermometer to the Pi and set up Prometheus, we now need to
glue them together such that Prometheus can read the temperature. The usual way
is for Prometheus to make HTTP requests to its known data sources, where the
response is formatted such that Prometheus can make sense of the metrics. There
is some support for having metrics sources push their values to Prometheus
through a bridge (that basically just remembers the values it&amp;rsquo;s given until
they&amp;rsquo;re scraped), but that seems inelegant given it would require running
another program (the bridge) and goes against the how Prometheus is designed to
work.&lt;/p>
&lt;p>I&amp;rsquo;ve &lt;a href="https://bitbucket.org/tari/w1therm-prometheus/src/default/">published the
source&lt;/a> for the
metrics exporter I ended up writing, and will give it a quick description in the
remnants of this section.&lt;/p>
&lt;hr>
&lt;p>The easiest solution to providing a service over HTTP is using the
&lt;a href="https://docs.python.org/3.5/library/http.server.html">&lt;code>http.server&lt;/code>&lt;/a> module, so
that&amp;rsquo;s what I chose to use. When the program starts up it scans for temperature
sensors and stores them. This has a downside of never returning data if a sensor
is accidentally disconnected at startup, but detection is fairly slow and only
doing it at startup makes it clearer if sensors are accidentally disconnected
during operation, since reading them will fail at that point.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic">#!/usr/bin/env python3&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">import&lt;/span> &lt;span style="color:#555">socketserver&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">http.server&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> HTTPServer, BaseHTTPRequestHandler
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">from&lt;/span> &lt;span style="color:#555">w1thermsensor&lt;/span> &lt;span style="color:#000;font-weight:bold">import&lt;/span> W1ThermSensor
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>SENSORS &lt;span style="color:#000;font-weight:bold">=&lt;/span> W1ThermSensor&lt;span style="color:#000;font-weight:bold">.&lt;/span>get_available_sensors()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The request handler has a method that builds the whole response at once, which
is just plain text based on a simple template.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">class&lt;/span> &lt;span style="color:#458;font-weight:bold">Exporter&lt;/span>(BaseHTTPRequestHandler):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> METRIC_HEADER &lt;span style="color:#000;font-weight:bold">=&lt;/span> (&lt;span style="color:#d14">&amp;#39;# HELP w1therm_temperature Temperature in Kelvin of the sensor.&lt;/span>&lt;span style="color:#d14">\n&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#d14">&amp;#39;# TYPE w1therm_temperature gauge&lt;/span>&lt;span style="color:#d14">\n&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">build_exposition&lt;/span>(&lt;span style="color:#999">self&lt;/span>, sensor_states):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> out &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>METRIC_HEADER
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">for&lt;/span> sensor, temperature &lt;span style="color:#000;font-weight:bold">in&lt;/span> sensor_states&lt;span style="color:#000;font-weight:bold">.&lt;/span>items():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> out &lt;span style="color:#000;font-weight:bold">+=&lt;/span> &lt;span style="color:#d14">&amp;#39;w1therm_temperature{{id=&amp;#34;&lt;/span>&lt;span style="color:#d14">{}&lt;/span>&lt;span style="color:#d14">&amp;#34;}} &lt;/span>&lt;span style="color:#d14">{}&lt;/span>&lt;span style="color:#d14">\n&lt;/span>&lt;span style="color:#d14">&amp;#39;&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>format(sensor, temperature)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">return&lt;/span> out&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>&lt;code>do_GET&lt;/code> is called by &lt;code>BaseHTTPRequestHandler&lt;/code> for all HTTP GET requests to the
server. Since this server doesn&amp;rsquo;t really care what you want (it only exports one
thing- metrics), it completely ignores the request and sends back metrics.&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#000;font-weight:bold">def&lt;/span> &lt;span style="color:#900;font-weight:bold">do_GET&lt;/span>(&lt;span style="color:#999">self&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> response &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>build_exposition(&lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>get_sensor_states())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> response &lt;span style="color:#000;font-weight:bold">=&lt;/span> response&lt;span style="color:#000;font-weight:bold">.&lt;/span>encode(&lt;span style="color:#d14">&amp;#39;utf-8&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#998;font-style:italic"># We&amp;#39;re careful to send a content-length, so keepalive is allowed.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>protocol_version &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#d14">&amp;#39;HTTP/1.1&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>close_connection &lt;span style="color:#000;font-weight:bold">=&lt;/span> &lt;span style="color:#000;font-weight:bold">False&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_response(&lt;span style="color:#099">200&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_header(&lt;span style="color:#d14">&amp;#39;Content-Type&amp;#39;&lt;/span>, &lt;span style="color:#d14">&amp;#39;text/plain; version=0.0.4&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>send_header(&lt;span style="color:#d14">&amp;#39;Content-Length&amp;#39;&lt;/span>, &lt;span style="color:#0086b3">len&lt;/span>(response))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>end_headers()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#999">self&lt;/span>&lt;span style="color:#000;font-weight:bold">.&lt;/span>wfile&lt;span style="color:#000;font-weight:bold">.&lt;/span>write(response)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The &lt;code>http.server&lt;/code> API is somewhat cumbersome in that it doesn&amp;rsquo;t try to handle
setting Content-Length on responses to allow clients to keep connections open
between requests, but at least in this case it&amp;rsquo;s very easy to set the
Content-Length on the response and correctly implement HTTP 1.1. The
Content-Type used here is the one specified by the &lt;a href="https://prometheus.io/docs/instrumenting/exposition_formats/">Prometheus documentation for
exposition
formats&lt;/a>.&lt;/p>
&lt;p>The rest of the program is just glue, for the most part. The
&lt;code>console_entry_point&lt;/code> function is the entry point for the
&lt;code>w1therm_prometheus_exporter&lt;/code> script specified in &lt;code>setup.py&lt;/code>. The network address
and port to listen on are taken from the command line, then an HTTP server is
started and allowed to run forever.&lt;/p>
&lt;h3 id="as-a-server">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#as-a-server">As a server&lt;/a>
&lt;/h3>
&lt;p>As a Python program with a few non-standard dependencies, installation of this
server is not particularly easy. While I could &lt;code>sudo pip install&lt;/code> everything and
call it sufficient, that&amp;rsquo;s liable to break unexpectedly if other parts of the
system are automatically updated- in particular the Python interpreter itself
(though Debian as a matter of policy doesn&amp;rsquo;t update Python to a different
release except as a major update, so it shouldn&amp;rsquo;t happen without warning). What
I&amp;rsquo;d really like is the ability to build a single standalone program that
contains everything in a convenient single-file package, and that&amp;rsquo;s exactly what
&lt;a href="https://www.pyinstaller.org/">PyInstaller&lt;/a> can do.&lt;/p>
&lt;p>A little bit of wrestling with pyinstaller configuration later (included as the
&lt;code>.spec&lt;/code> file in the repository), I had successfully built a pretty heavy (5MB)
executable containing everything the server needs to run. I placed a copy in
&lt;code>/usr/local/bin&lt;/code>, for easy accessibility in running it.&lt;/p>
&lt;p>I then wrote a simple systemd unit for the temperature server to make it start
automatically, installed as
&lt;code>/etc/systemd/system/w1therm-prometheus-exporter.service&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ini" data-lang="ini">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Description&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">Exports 1-wire temperature sensor readings to Prometheus&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Documentation&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">https://bitbucket.org/tari/w1therm-prometheus&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Service]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ExecStart&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">/usr/local/bin/w1therm-prometheus-exporter localhost 9000&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Restart&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">always&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">StandardOutput&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">journal&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">StandardError&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">journal&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># Standalone binary doesn&amp;#39;t need any access beyond its own binary image and&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#998;font-style:italic"># a tmpfs to unpack itself in.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">DynamicUser&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">true&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ProtectSystem&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">strict&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ProtectHome&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">true&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">PrivateTmp&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">true&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Install]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">WantedBy&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">multi-user.target&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Enable the service, and it will start automatically when the system boots:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>systemctl &lt;span style="color:#0086b3">enable&lt;/span> w1therm-prometheus-exporter.service&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>This unit includes rather more protection than is probably very useful, given
the machine is single-purpose, but it seems like good practice to isolate the
server from the rest of the system as much as possible.&lt;/p>
&lt;ul>
&lt;li>&lt;code>DynamicUser&lt;/code> will make it run as a system user with ID semi-randomly
assigned each time it starts so it doesn&amp;rsquo;t look like anything else on the
system for purposes of resource (file) ownership.&lt;/li>
&lt;li>&lt;code>ProtectSystem&lt;/code> makes it impossible to write to most of the filesystem,
protecting against accidental or malicious changes to system files.&lt;/li>
&lt;li>&lt;code>ProtectHome&lt;/code> makes it impossible to read any user&amp;rsquo;s home directory,
preventing information leak from other users.&lt;/li>
&lt;li>&lt;code>PrivateTmp&lt;/code> give the server its own private &lt;code>/tmp&lt;/code> directory, so it can&amp;rsquo;t
interfere with temporary files created by other things, nor can its be
interfered with- preventing &lt;a href="https://www.cgsecurity.org/Articles/SecProg/Art5/index.html">possible
races&lt;/a> which
could be exploited.&lt;/li>
&lt;/ul>
&lt;h2 id="pi-connectivity">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#pi-connectivity">Pi connectivity&lt;/a>
&lt;/h2>
&lt;p>Having built the HTTP server, I needed a way to get data from it to Prometheus.
As discussed earlier, the Raspberry Pi with the sensor is on a WiFi network that
doesn&amp;rsquo;t permit any incoming connections, so how can Prometheus scrape metrics if
it can&amp;rsquo;t connect to the Pi?&lt;/p>
&lt;p>One option is to push metrics to Prometheus, using the &lt;a href="https://prometheus.io/docs/instrumenting/pushing/">push
gateway&lt;/a>. However, I don&amp;rsquo;t
like that option because the push gateway is intended mostly for jobs that run
unpredictably, in particular where they can exit without warning. This isn&amp;rsquo;t
true of my sensor server.
&lt;a href="https://github.com/RobustPerception/PushProx">PushProx&lt;/a> provides a rather
better solution, wherein clients connect to a proxy which forwards fetches from
Prometheus to the relevant client, though I think my ultimate solution is just
as effective and simpler.&lt;/p>
&lt;p>What I ended up doing is using
&lt;a href="http://www.harding.motd.ca/autossh/index.html">autossh&lt;/a> to open an SSH tunnel
at the Prometheus server which connects to the Raspberry Pi&amp;rsquo;s metrics server.
Autossh is responsible for keeping the connection alive, managed by systemd.
Code is going to be much more instructive here than a long-form description, so
here&amp;rsquo;s the unit file:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ini" data-lang="ini">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Description&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">SSH reverse tunnel from %I for Prometheus&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">After&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">network-online.target&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Wants&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">network-online.target&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Service]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">User&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">autossh&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ExecStart&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">/usr/bin/autossh -N -p 22 -l autossh -R 9000:localhost:9000 -i /home/autossh/id_rsa %i&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">Restart&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">always&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">RestartSec&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">10&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000;font-weight:bold">[Install]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">WantedBy&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">multi-user.target&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Installed as &lt;code>/etc/systemd/system/autossh-tunnel@.service&lt;/code>, this unit file tells
systemd that we want to start autossh when the network is online and try to
ensure it always stays online. I&amp;rsquo;ve increased &lt;code>RestartSec&lt;/code> from the default 100
milliseconds because I found that even with the dependency on
&lt;code>network-online.target&lt;/code>, ssh could fail when the system was booting up with DNS
lookup failures, then systemd would give up. Increasing the restart time means
it takes much longer for systemd to give up, and in the meantime the network
actually comes up.&lt;/p>
&lt;p>The autossh process itself runs as a system user I created just to run the
tunnels (&lt;code>useradd --system -m autossh&lt;/code>), and opens a reverse tunnel from port
9000 on the remote host to the same port on the Pi. Authentication is with an
SSH key I created on the Pi and added to the Prometheus machine in Google Cloud,
so it can log in to the server without any human intervention. Teaching systemd
that this should run automatically is a simple &lt;code>enable&lt;/code> command away&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>systemctl &lt;span style="color:#0086b3">enable&lt;/span> autossh-tunnel@pitemp.example.com&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Then it&amp;rsquo;s just a matter of configuring Prometheus to scrape the sensor exporter.
The entire Prometheus config looks like this:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#000080">global&lt;/span>:&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000080">scrape_interval&lt;/span>:&lt;span style="color:#bbb"> &lt;/span>15s&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#998;font-style:italic"># By default, scrape targets every 15 seconds.&lt;/span>&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000080">evaluation_interval&lt;/span>:&lt;span style="color:#bbb"> &lt;/span>15s&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb">&lt;/span>&lt;span style="color:#000080">scrape_configs&lt;/span>:&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>- &lt;span style="color:#000080">job_name&lt;/span>:&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#d14">&amp;#39;w1therm&amp;#39;&lt;/span>&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>&lt;span style="color:#000080">static_configs&lt;/span>:&lt;span style="color:#bbb">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#bbb"> &lt;/span>- &lt;span style="color:#000080">targets&lt;/span>:&lt;span style="color:#bbb"> &lt;/span>[&lt;span style="color:#d14">&amp;#39;localhost:9000&amp;#39;&lt;/span>]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>That&amp;rsquo;s pretty self-explanatory; Prometheus will fetch metrics from port 9000 on
the same machine (which is actually an SSH tunnel to the Raspberry Pi), and do
so every 15 seconds. When the Pi gets the request for metrics, it reads the
temperature sensors and returns their values.&lt;/p>
&lt;h2 id="data-retention">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#data-retention">Data retention&lt;/a>
&lt;/h2>
&lt;p>I included InfluxDB in the setup to get arbitrary retention of temperature data-
Prometheus is designed primarily for real-time monitoring of computer systems,
to alert human operators when things appear to be going wrong. Consequently, in
the default configuration Prometheus only retains captured data for a few weeks,
and doesn&amp;rsquo;t provide a convenient way to export data for archival or analysis.
While the default retention is probably sufficient for this project&amp;rsquo;s needs, I
wanted better control over how long that data was kept and the ability to save
it as long as I liked. So while Prometheus doesn&amp;rsquo;t offer that control itself, it
does support reading and writing data to and from various other databases,
including InfluxDB (which I chose only because a package for it is available in
Debian without any additional work).&lt;/p>
&lt;p>Unfortunately, the version of Prometheus available in Debian right now is fairly
old- &lt;a href="https://packages.debian.org/stretch/prometheus">1.5.2&lt;/a>, where the latest
release is 2.2. More problematic, while Prometheus now supports a generic remote
read/write API, this was added in version 2.0 and is not yet available in the
Debian package. Combined with the lack of documentation (as far as I could find)
for the old remote write feature, I was a little bit stuck.&lt;/p>
&lt;p>Things ended up working out nicely though- I happened to see flags relating to
InluxDB in the Prometheus web UI, which mostly have no default values:&lt;/p>
&lt;ul>
&lt;li>&lt;code>storage.remote.influxdb-url&lt;/code>&lt;/li>
&lt;li>&lt;code>storage.remote.influxdb.database = prometheus&lt;/code>&lt;/li>
&lt;li>&lt;code>storage.remote.influxdb.retention-policy&lt;/code>&lt;/li>
&lt;li>&lt;code>storage.remote.influxdb.username&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>These can be specified to Prometheus by editing &lt;code>/etc/defaults/prometheus&lt;/code>,
which is part of the Debian package for providing the command line arguments to
the server without requiring users to directly edit the file that tells the
system how to run Prometheus. I ended up with these options there:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#008080">ARGS&lt;/span>&lt;span style="color:#000;font-weight:bold">=&lt;/span>&lt;span style="color:#d14">&amp;#34;--storage.local.retention=720h \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14"> --storage.remote.influxdb-url=http://localhost:8086/ \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#d14"> --storage.remote.influxdb.retention-policy=autogen&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>The first option just makes Prometheus keep its data longer than the default,
whereas the others tell it how to write data to InfluxDB. I determined where
InfluxDB listens for connections by looking at its configuration file
&lt;code>/etc/influxdb/influxdb.conf&lt;/code> and making a few guesses: a comment in the &lt;code>http&lt;/code>
section there noted that &amp;ldquo;these (HTTP endpoints) are the primary mechanism for
getting data into and out of InfluxDB&amp;rdquo; and included the settings
&lt;code>bind-address=&amp;quot;:8086&amp;quot;&lt;/code> and &lt;code>auth-enabled=false&lt;/code>, so I guessed (correctly) that
telling Prometheus to find InfluxDB at &lt;code>http://localhost:8086/&lt;/code> should be
sufficient.&lt;/p>
&lt;p>Or, it was almost enough: setting the &lt;code>influxdb-url&lt;/code> and restarting Prometheus,
it was logging warnings periodically about getting errors back from InfluxDB.
Given the &lt;code>influxdb.database&lt;/code> settings defaults to &lt;code>prometheus&lt;/code>, I (correctly)
assumed I needed to create a database. A little browsing of the Influx
documentation and a few guesses later, I had done that:&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>$ apt-get install influxdb-client
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>$ influx
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Visit https://enterprise.influxdata.com to register &lt;span style="color:#000;font-weight:bold">for&lt;/span> updates, InfluxDB server management, and monitoring.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Connected to http://localhost:8086 version 1.0.2
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>InfluxDB shell version: 1.0.2
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;gt; CREATE DATABASE prometheus;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;p>Examining the Prometheus logs again, now it was failing and complaining that the
specified retention policy didn&amp;rsquo;t exist. Noting that the Influx documentation
for the &lt;code>CREATE DATABASE&lt;/code> command mentioned that the &lt;code>autogen&lt;/code> retention policy
will be used if no other is specified, setting the &lt;code>retention-policy&lt;/code> flag to
&lt;code>autogen&lt;/code> and restarting Prometheus made data start appearing, which I verified
by waiting a little while and making a query (guessing a little bit about how I
would query a particular metric):&lt;/p>
&lt;div class="highlight">&lt;div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-shell" data-lang="shell">&lt;span style="display:flex;">&lt;span>&amp;gt; USE prometheus;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&amp;gt; SELECT * FROM w1therm_temperature LIMIT 10;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>name: w1therm_temperature
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>-------------------------
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#0086b3">time&lt;/span> id instance job value
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423583303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423598303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423613303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423628303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423643303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423658303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423673303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423688303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423703303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#099">1532423718303000000&lt;/span> 000004b926f1 localhost:9000 w1therm 297.9&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>
&lt;h2 id="results">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#results">Results&lt;/a>
&lt;/h2>
&lt;p>A sample graph of the temperature over two days:&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2018/pi-temperature-graph.png"
alt="Temperature follows a diurnal cycle, starting at 23 degrees at 00:00, peaking around 24 at 06:00 and bottoming out near 22 at 21:00.">
&lt;/figure>
&lt;p>The fermentation temperature is quite stable, with daily variation of less than
one degree in either direction from the baseline.&lt;/p>
&lt;h2 id="refinements">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#refinements">Refinements&lt;/a>
&lt;/h2>
&lt;p>I later improved the temperature server to handle &lt;code>SIGHUP&lt;/code> as a trigger to scan
for sensors again, which is a slight improvement over restarting it, but not
very important because the server is already so simple (and fast to restart).&lt;/p>
&lt;hr>
&lt;p>On reflection, using Prometheus and scraping temperatures is a very strange way
to go about solving the problem of logging the temperature (though it has the
advantage of using only tools I was already familiar with so it was easy to do
quickly). Pushing temperature measurements from the Pi via
&lt;a href="http://mqtt.org/">MQTT&lt;/a> would be a much more sensible solution, since that&amp;rsquo;s a
protocol designed specifically for small sensors to report their states.
Indeed, there is no shortage of published projects that do exactly that more
efficiently than my Raspberry Pi, &lt;a href="http://www.jerome-bernard.com/blog/2015/10/04/wifi-temperature-sensor-with-nodemcu-esp8266/">most of them using ESP8266
microcontrollers&lt;/a>
which are much lower-power and can still connect to Wi-Fi networks.&lt;/p>
&lt;h3 id="rambling-about-iot-security">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2018/temperature-logger/#rambling-about-iot-security">Rambling about IoT security&lt;/a>
&lt;/h3>
&lt;p>Getting sensor readings through an MQTT broker and storing them to be able to
graph them is not quite as trivial as scraping them with Prometheus, but I
suspect there does exist a software package that does most of the work already.
If not, I expect a quick and dirty one could be implemented with relative ease.&lt;/p>
&lt;p>On the other hand, running a device like that which is internet-connected but is
unlikely to ever receive anything remotely looking like a security update seems
ill-advised if it&amp;rsquo;s meant to run for anything but a short amount of time. In
that case having the sensor be part of a
&lt;a href="https://en.wikipedia.org/wiki/Zigbee">Zigbee&lt;/a> network instead, which does not
permit direct internet connectivity and thus avoids the fraught terrain of
needing to protect both the device itself from attack and the data transmitted
by the device from unauthorized use (eavesdropping) by taking ownership of that
problem away from the sensor.&lt;/p>
&lt;p>It remains possible to forward messages out to an MQTT broker on the greater
internet using some kind of bridge (indeed, this is the system used by many
consumer &amp;ldquo;smart device&amp;rdquo; platforms, like Philips'
&lt;a href="https://en.wikipedia.org/wiki/Philips_Hue">Hue&lt;/a> though I don&amp;rsquo;t think they use
MQTT), where individual devices connect only to the Zigbee network, and a more
capable bridge is responsible for internet connectivity. The problem of keeping
the bridge secure remains, but is appreciably simpler than needing to maintain
the security of each individual device in what may be a heterogeneous network.&lt;/p>
&lt;p>It&amp;rsquo;s even possible to get inexpensive off-the-shelf temperature and humidity
sensors that connect to Zigbee networks like &lt;a href="https://xiaomi-mi.com/sockets-and-sensors/xiaomi-mi-temperature-humidity-sensor/">some sold by
Xiaomi&lt;/a>,
offering much better finish than a prototype-quality one I might be able to
build myself, very good battery life, and still capable of operating in a
heterogenous Zigbee network with arbitrary other devices (though you wouldn&amp;rsquo;t
know it from the manufacturer&amp;rsquo;s documentation, since they want consumers to
commit to their &amp;ldquo;platform&amp;rdquo; exclusively)!&lt;/p>
&lt;p>So while my solution is okay in that it works fine with hardware I already had
on hand, a much more robust solution is readily available with off-the-shelf
hardware and only a little bit of software to glue it together. If I needed to
do this again and wanted a solution that doesn&amp;rsquo;t require my expertise to
maintain it, I&amp;rsquo;d reach for those instead.&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>Hostname changed to an obviously fake one for anonymization purposes.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Considering my backup systems</title><link>https://www.taricorp.net/2017/considering-backup-systems/</link><pubDate>Tue, 07 Nov 2017 00:00:00 +0000</pubDate><guid>https://www.taricorp.net/2017/considering-backup-systems/</guid><description>&lt;p>With the recent news that &lt;a href="https://www.crashplan.com/en-us/consumer/nextsteps/">Crashplan were doing away with their &amp;ldquo;Home&amp;rdquo;
offering&lt;/a>, I had reason to
reconsider my choice of online backup backup provider. Since I haven&amp;rsquo;t written
anything here lately and the results of my exploration (plus description of
everything else I do to ensure data longevity) might be of interest to others
looking to set up backup systems for their own data, a version of my notes from
that process follows.&lt;/p>
&lt;h2 id="the-status-quo">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/considering-backup-systems/#the-status-quo">The status quo&lt;/a>
&lt;/h2>
&lt;p>I run a Linux-based home server for all of my long-term storage, currently 15
terabytes of raw storage with btrfs RAID on top. The choice of btrfs and RAID
allows me some degree of robustness against local disk failures and accidental
damage to data.&lt;/p>
&lt;p>If a disk fails I can replace it without losing data, and using btrfs&amp;rsquo; RAID
support it&amp;rsquo;s possible to use heterogenous disks, meaning when I need more
capacity it&amp;rsquo;s possible to remove one disk (putting the volume into a degraded
state) and add a new (larger) one and rebalance onto the new disk.&lt;/p>
&lt;p>btrfs&amp;rsquo; ability to take copy-on-write snapshots of subvolumes at any time makes it reasonable
to take regular snapshots of everything, providing a first line of defense against accidental
damage to data. I use &lt;a href="http://snapper.io/">Snapper&lt;/a> to automatically create rolling snapshots of each
of the major subvolumes:&lt;/p>
&lt;ul>
&lt;li>&lt;em>Synchronized files&lt;/em> (mounted to other machines over the network) have 8 hourly,
7 daily, 4 weekly and 3 monthly snapshots available at any time.&lt;/li>
&lt;li>&lt;em>Staging&lt;/em> items (for sorting into other locations) have a snapshot for each of the
last two hours only, because those items change frequently and are of low value until
considered further.&lt;/li>
&lt;li>Everything else keeps one snapshot from the last hour and each of the last 3 days.&lt;/li>
&lt;/ul>
&lt;p>This configuration strikes a balance according to my needs for accident
recovery and storage demands plus performance. The frequently-changed items
(synchronized with other machines and containing active projects) have a lot of
snapshots because most individual files are small but may change frequently, so
a large number of snapshots will tend to have modest storage needs. In
addition, the chances of accidental data destruction are highest there. The
other subvolumes are either more static or lower-value, so I feel little need
to keep many snapshots of them.&lt;/p>
&lt;p>I use Crashplan to back up the entire system to their &amp;ldquo;cloud&amp;rdquo;&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup> service for
$5 per month. The rate at which I add data to the system is usually lower than
the rate at which it can be uploaded back to Crashplan as a backup, so in most
cases new data is backed up remotely within hours of being created.&lt;/p>
&lt;p>Finally, I have a large USB-connected external hard drive as a local offline
backup. Also formatted with btrfs like the server (but with the entire disk
encrypted), I can use &lt;code>btrfs send&lt;/code> to send incremental backups to this external
disk, even without the ability to send information from the external disk back.
In practice, this means I can store the external disk somewhere else completely
(possibly without an Internet connection) and occasionally shuttle diffs to it
to update to a more recent version. I always unplug this disk from power and
its host computer when not being updated, so it should only be vulnerable to
physical damage and not accidental modification of its contents.&lt;/p>
&lt;h3 id="synchronization-and-remotes">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/considering-backup-systems/#synchronization-and-remotes">Synchronization and remotes&lt;/a>
&lt;/h3>
&lt;p>For synchronizing current projects between my home server (which I treat as the
canonical repository for everything), the tools vary according to the
constraints of the remote system. I mount volumes over NFS or SMB from systems
that rarely or never leave my network. For portable devices (laptop computers),
&lt;a href="https://syncthing.net/">Syncthing&lt;/a> (running on the server and portable device) makes
bidirectional synchronization easy without requiring that both machines always
be on the same network.&lt;/p>
&lt;p>I keep very little data on portable devices that is not synchronized back to the
server, but because it is (or, was) easy to set up, I used Crashplan&amp;rsquo;s
peer-to-peer backup feature to back up my portable computers to the server.
Because the Crashplan application is rather heavyweight (it&amp;rsquo;s implemented in
Java!) and it refuses to include peer-to-peer backups in uploads to their storage
service (reasonably so; I can&amp;rsquo;t really complain about that policy), my remote
servers back up to my home server with &lt;a href="https://www.borgbackup.org/">Borg&lt;/a>.&lt;/p>
&lt;p>I also have several Android devices that aren&amp;rsquo;t always on my home network-
these aren&amp;rsquo;t covered very well by backups, unfortunately. I use
&lt;a href="https://play.google.com/store/apps/details?id=dk.tacit.android.foldersync.full">FolderSync&lt;/a> to automatically upload things like photos to my
server which covers the extent of most data I create on those devices, but it
seems difficult to make a backup of an Android device that includes things like
preferences and per-app data without rooting the device (which I don&amp;rsquo;t wish to
do for various reasons).&lt;/p>
&lt;h3 id="summarizing-the-status-quo">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/considering-backup-systems/#summarizing-the-status-quo">Summarizing the status quo&lt;/a>
&lt;/h3>
&lt;ul>
&lt;li>btrfs snapshots offer quick access to recent versions of files.&lt;/li>
&lt;li>btrfs RAID provides resilience against single-disk failures and easy growth
of total storage in my server.&lt;/li>
&lt;li>Remote systems synchronize or back up most of their state to the server.&lt;/li>
&lt;li>Everything on the server is continuously backed up to Crashplan&amp;rsquo;s remote
servers.&lt;/li>
&lt;li>A local offline backup can be easily moved and is rarely even connected to a
computer so it should be robust against even catastrophic failures.&lt;/li>
&lt;/ul>
&lt;h2 id="evaluating-alternatives">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/considering-backup-systems/#evaluating-alternatives">Evaluating alternatives&lt;/a>
&lt;/h2>
&lt;p>Now that we know how things were, we can consider alternative approaches to
solve the problem of Crashplan&amp;rsquo;s $5-per-month service no longer being
available. The primary factors for me are cost and storage capacity. Because
most of my data changes rarely but none of it is strictly immutable, I want a
system that makes it possible to do incremental backups. This will of course
also depend on software support, but it means that I will tend to prefer
services with straightforward pricing because it is difficult to estimate how
many operations (read or write) are necessary to complete an incremental
backup.&lt;/p>
&lt;p>Some services like Dropbox or Google Drive as commonly-known examples might be
appropriate for some users, but I won&amp;rsquo;t consider them. As consumer-oriented
services positioned for the use case of &amp;ldquo;make these files available whenever
I have Internet access,&amp;rdquo; they&amp;rsquo;re optimized for applications very different
from the needs of my backups and tend to be significantly more expensive at
the volumes I need.&lt;/p>
&lt;p>So, the contenders:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://www.crashplan.com/en-us/business/">Crashplan for Small Business&lt;/a>&lt;/strong>:
just like Crashplan Home (which was going
away), but costs $10/mo for unlimited storage and doesn&amp;rsquo;t support
peer-to-peer backup. Can migrate existing Crashplan Home backup archives to
Small Business as long as they are smaller than 5 terabytes.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.backblaze.com/cloud-backup.html">Backblaze&lt;/a>&lt;/strong>: $50 per year for
unlimited storage, but their client only runs on Mac and Windows.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://cloud.google.com/storage/">Google Cloud Storage&lt;/a>&lt;/strong>: four flavors
available, where the interesting ones for backups are Nearline and Coldline.
Low cost per gigabyte stored, but costs are incurred for each operation and
transfer of data out.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.backblaze.com/cloud-storage">Backblaze B2&lt;/a>&lt;/strong>: very low
cost per gigabyte, but incurs costs for download.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.online.net/en/c14">Online.net C14&lt;/a>&lt;/strong>: very low cost per gigabyte,
no cost for operations or data transfer in the &amp;ldquo;intensive&amp;rdquo; flavor.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://aws.amazon.com/glacier/">AWS Glacier&lt;/a>&lt;/strong>: lowest cost for storage,
but very high latency and cost for data retrieval.&lt;/li>
&lt;/ul>
&lt;p>The pricing is difficult to consume in this form, so I&amp;rsquo;ll make some estimates
with an 8 terabyte backup archive. This somewhat exceeds my current needs, so
should be a useful if not strictly accurate guide. The following table
summarizes expected monthly costs for storage, addition of new data and the
hypothetical cost of recovering everything from a backup stored with that
service.&lt;/p>
&lt;table>
&lt;tr>
&lt;th>Service&lt;/th>
&lt;th>Storage cost&lt;/th>
&lt;th>Recovery cost&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;tr>
&lt;th>Crashplan&lt;/th>
&lt;td>$10&lt;/td>
&lt;td>0&lt;/td>
&lt;td>"Unlimited" storage, flat fee.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>Backblaze&lt;/th>
&lt;td>$4.17&lt;/td>
&lt;td>0&lt;/td>
&lt;td>"Unlimited" storage, flat fee.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>GCS Nearline&lt;/th>
&lt;td>$80&lt;/td>
&lt;td>~$80&lt;/td>
&lt;td>Negligible but nonzero cost per operation.
Download $0.08 to $0.23 per gigabyte depending on total monthly
volume and destination.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>GCS Coldline&lt;/th>
&lt;td>$56&lt;/td>
&lt;td>~$80&lt;/td>
&lt;td>Higher but still negligible cost per operation.
All items must be stored for at least 90 days (kind of).&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>B2&lt;/th>
&lt;td>$40&lt;/td>
&lt;td>$80&lt;/td>
&lt;td>Flat fee for storage and transfer per-gigabyte.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>C14&lt;/th>
&lt;td>€40&lt;/td>
&lt;td>0&lt;/td>
&lt;td>"Intensive" flavor. Other flavors incur per-operation costs.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;th>Glacier&lt;/th>
&lt;td>$32&lt;/td>
&lt;td>$740&lt;/td>
&lt;td>Per-gigabyte retrieval fees plus Internet egress. Reads may take up
to 12 hours for data to become available. Negligible cost per operation.
Minimum storage 90 days (like Coldline).&lt;/td>
&lt;/tr>
&lt;/table>
&lt;p>Note that for Google Cloud and AWS I&amp;rsquo;ve used the pricing quoted for the
cheapest regions; Iowa on GCP and US East on AWS.&lt;/p>
&lt;h3 id="analysis">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/considering-backup-systems/#analysis">Analysis&lt;/a>
&lt;/h3>
&lt;p>Backblaze is easily the most attractive option, but the availability restriction
for their client (which is required to use the service) to Windows and Mac makes
it difficult to use. It may be possible to run a Windows virtual machine on my
Linux server to make it work, but that sounds like a lot of work for something
that may not be reliable. &lt;strong>Backblaze is out.&lt;/strong>&lt;/p>
&lt;p>AWS Glacier is inexpensive for storage, but extremely expensive and slow when retrieving
data. The pricing structure is complex enough that I&amp;rsquo;m not comfortable depending
on this rough estimate for the costs, since actual costs for incremental backups
would depend strongly on the details of how they were implemented (since the
service incurs charges for reads and writes). The extremely high latency on bulk
retrievals (up to 12 hours) and higher cost for lower-latency reads makes it
questionable that it&amp;rsquo;s even reasonable to do incremental backups on Glacier.
&lt;strong>Not Glacier.&lt;/strong>&lt;/p>
&lt;p>C14 is attractively priced, but because they are not widely known I expect backup
packages will not (yet?) support it as a destination for data. Unfortunately, that
means &lt;strong>C14 won&amp;rsquo;t do.&lt;/strong>&lt;/p>
&lt;p>Google Cloud is fairly reasonably-priced, but Coldline&amp;rsquo;s storage pricing is confusing
in the same ways that Glacier is. Either flavor is better pricing-wise than
Glacier simply because the recovery cost is so much lower, but &lt;strong>there are still better
choices than GCS.&lt;/strong>&lt;/p>
&lt;p>B2&amp;rsquo;s pricing for storage is competitive and download rates are reasonable
(unlike Glacier!). It&amp;rsquo;s worth considering, but &lt;strong>Crashplan still wins in
cost.&lt;/strong> Plus I&amp;rsquo;m already familiar with software for doing incremental backups
on their service (their client!) and wouldn&amp;rsquo;t need to re-upload everything to a
new service.&lt;/p>
&lt;h2 id="fallout">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/considering-backup-systems/#fallout">Fallout&lt;/a>
&lt;/h2>
&lt;p>I conclude that the removal of Crashplan&amp;rsquo;s &amp;ldquo;Home&amp;rdquo; service effectively means a doubling
of the monthly cost to me, but little else. There are a few extra things to consider,
however.&lt;/p>
&lt;p>First, my backup archive at Crashplan was larger than 5 terabytes so could not
be migrated to their &amp;ldquo;Business&amp;rdquo; version. I worked around that by removing some
data from my backup set and waiting a while for those changes to translate to
&amp;ldquo;data is actually gone from the server including old versions,&amp;rdquo; then migrating
to the new service and adding the removed data back to the backup set. This
means I probably lost a few old versions of the items I removed and re-added,
but I don&amp;rsquo;t expect to ever need any of them.&lt;/p>
&lt;p>Second and more concerning in general is the newfound inability to do
peer-to-peer backups from portable (and otherwise) computers to my own server.
For Linux machines that are always Internet-connected Borg continues to do the
job, but I needed a new package that works on Windows. I&amp;rsquo;ve eventually chosen
&lt;a href="https://www.duplicati.com/">Duplicati&lt;/a>, which can connect to my server the
same way Borg does (over SSH/SFTP) and will in general work over
arbitrarily-restricted internet connections in the same way that Crashplan did.&lt;/p>
&lt;h2 id="concluding">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/considering-backup-systems/#concluding">Concluding&lt;/a>
&lt;/h2>
&lt;p>I&amp;rsquo;m still using Crashplan, but converting to their more-expensive service
was not quite trivial. It&amp;rsquo;s still much more inexpensive to back up to
their service compared to others, which means they still have some
significant freedom to raise the cost until I consider some other way
to back up my data remotely.&lt;/p>
&lt;p>As something of a corollary, it&amp;rsquo;s pretty clear that my high storage use on
Crashplan is subsidized by other customers who store much less on the service;
this is just something they must recognize when deciding how to price the
service!&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>&lt;a href="http://www.chriswatterston.com/blog/my-there-is-no-cloud-sticker">There is no cloud, it&amp;rsquo;s just someone else&amp;rsquo;s computer.&lt;/a>&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>Reflecting on Breath of the Wild</title><link>https://www.taricorp.net/2017/breath-of-the-wild/</link><pubDate>Sun, 11 Jun 2017 00:00:00 +0000</pubDate><guid>https://www.taricorp.net/2017/breath-of-the-wild/</guid><description>&lt;p>I&amp;rsquo;ve been enjoying &lt;a href="https://en.wikipedia.org/wiki/The_Legend_of_Zelda:_Breath_of_the_Wild">The Legend of Zelda: Breath of the Wild&lt;/a> recently, and
reflected some on what makes it interesting to me from a non-gameplay
perspective. This document is a version of those musings organized for
publication, though perhaps less well organized than my usual writings- I am by
no means a skilled critic, but spending longer in composing this would likely
just delay its completion to little benefit.&lt;/p>
&lt;p>Note that at the time of this writing I have not yet completed the game, but
there are still some minor spoilers for the early portions of the game and
general premise.&lt;/p>
&lt;hr>
&lt;figure>&lt;img src="https://www.taricorp.net/2017/botw/mgs3.jpg"
alt="The player climbing a long ladder in an otherwise featureless shaft">&lt;figcaption>
&lt;p>A bit of a callout to Metal Gear Solid 3, here.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>I haven&amp;rsquo;t really played any Zelda games before Breath of the Wild. Once (long
ago) I played a little bit of &lt;a href="https://en.wikipedia.org/wiki/The_Legend_of_Zelda:_A_Link_to_the_Past">A Link to the Past&lt;/a> but didn&amp;rsquo;t find it
interesting (and was bad at it). Similarly, some time later I tried &lt;a href="https://en.wikipedia.org/wiki/The_Legend_of_Zelda:_Ocarina_of_Time">Ocarina of
Time&lt;/a> and failed to find anything compelling about it. Claiming that those
games are simply not fun would be disingenuous, given both of them appear in
multiple lists of &amp;ldquo;greatest video games ever&amp;rdquo; compiled by various parties. The
correct question to answer here is then which of the differences between those earlier
games and Breath of the Wild make the latter interesting to me, but the
former not.&lt;/p>
&lt;!-- more -->
&lt;p>Having had extremely limited exposure to earlier Zelda games, I am in a poor
position to comment on gameplay differences. In principle I appreciate the
open design of Breath of the Wild that allows the player to go anywhere and do
just about anything following the completion of a short tutorial section, but
it seems to me that I have not been taking much advantage of that freedom.
My general impression of the older Zelda style is that there is usually only
one way to progress and that may be a frustrating factor that is not highly
ranked in my mind however, whereas Breath of the Wild has given me as a player
a sufficiently precise goal and the freedom to go after it at any time, but
also hints as to things I may do to make success in that quest more probable.&lt;/p>
&lt;p>It&amp;rsquo;s possible that merely offering more freedom to the player and applying
contemporary popular game design principles (and resources) to the general Zelda
formula is enough to capture the interest of the Present Me. Certainly when
I tried the other above-described games they would have been relatively old so
I may have been accustomed to games simply built with different style.&lt;/p>
&lt;p>On the other hand, I have opinions about how Zelda has previously approached
storytelling and how it does in Breath of the Wild which make for rather more
interesting thoughts, so I will turn to those considerations instead.&lt;/p>
&lt;h2 id="the-big-bad">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/breath-of-the-wild/#the-big-bad">The Big Bad&lt;/a>
&lt;/h2>
&lt;p>My general impression of the typical Zelda plot is &amp;ldquo;oh no stop the evil man from
doing the evil thing.&amp;rdquo; As far as it goes, that&amp;rsquo;s fine but I find it
uninteresting. Breath of the Wild takes a somewhat different approach to the
general concept that I find much more compelling.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2017/botw/ganon-devours.jpg"
alt="Malevolent light silhouettes a castle under the gaze of an elemental monster">&lt;figcaption>
&lt;p>Elemental evil infests the seat of power.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Ganon, the usual Big Bad, is depicted as an elemental evil in Breath of the
Wild, rather than some kind of dark wizard. If a mere dark wizard could be the
source of an existential threat of the sort that requires a Great Hero (the
player) to protect the world, it seems that there would be more frequent
existential threats. Given the presentation suggesting that the player is a
unique and noteworthy hero (for instance, collecting artifacts that are implied
to be unique and offer powerful abilities to a chosen wielder), putting them in
a world where the threat demanding heroism is as mundane as a dark wizard of
some kind is rather unconvincing.&lt;/p>
&lt;p>The ability of the player character to defeat this elemental evil is not (at
least from the start) taken as self-evident, in comparison to how I see other
Zelda games, which from the beginning seem to take it as given that the player
character is destined to become a great hero and save the world.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2017/botw/towers.jpg"
alt="Towers rise from the ground in a verdant but desolate landscape">&lt;figcaption>
&lt;p>A wild landscape with aspects of decaying grandeur.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Breath of the Wild starts with the player as the only sentient being in a wild
landscape with traces of former glory, quickly introducing a guide sort of
character who acts as a gently-guided tutorial before giving fuzzy backstory
about how the player character is essentialy one in a long line of heroes. The
details of this are revealed over hours of gameplay, largely leaving the player
alone in a vast world scattered with suggestions of a long history.&lt;/p>
&lt;p>I previously found the official canon for Zelda to be rather silly, because
every new game is basically the same structurally (Link the hero must do hero
things to assist/rescue Zelda the princess-heroine from the Big Bad). This has
been justified as that they are these recurring characters over very long
periods of time, but it struck me mostly as a convenient excuse for recycling
characters that are well-liked.&lt;/p>
&lt;h2 id="a-world-with-history">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/breath-of-the-wild/#a-world-with-history">A world with history&lt;/a>
&lt;/h2>
&lt;p>In Breath of the Wild the history of the world is used to good effect, including
the recurring nature of the major characters. Lore-keeping NPCs allude to the
distant past and what are effectively past incarnations of the main characters,
while side characters are more defined by their roles so it is plausible to
reuse them.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2017/botw/historylesson.jpg"
alt="Two figures stand near a monster surrounded by an army in the style of a hand-woven tapestry">&lt;figcaption>
&lt;p>The elemental evil is eternally opposed by the guardian souls.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>The world itself acts as a testament to the long history of the land, with
ruined structures dotting the landscape (often large stone constructions that
are impressive even as ruins) and even older structures (shrines) that serve as
a kind of waypoints in what might otherwise be aimless wandering for the player.
While the shrines are largely ignored by NPCs, they are not invisible- NPCs seem
aware of their presence, but because they are not useful go largely ignored. If
there is a weak point in this bit of world-building, the lack of interest or
elaboration around shrines seems to be it- but this seems fair when these
shrines are meant to be orders of magnitude older than any other relevant
features of the landscape.&lt;/p>
&lt;p>The art style might earn some credit for informing the feel of the world, but I
don&amp;rsquo;t feel qualified to comment on that. I&amp;rsquo;ll leave comments on the art of the
game to commentators better informed about art, leaving my thoughts at how it
seems to be a good use of the limited computing power available to the game&amp;rsquo;s
developers, recognizing that the Wii U and Switch are underpowered systems by
the standards of other contemporary gaming platforms.&lt;/p>
&lt;h2 id="characterization">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/breath-of-the-wild/#characterization">Characterization&lt;/a>
&lt;/h2>
&lt;p>I noted earlier that Breath of the Wild does not seem to take it as given that
the player&amp;rsquo;s character is destined to become a great hero and save the world,
but an alternate interpretation might be that the player already has hero
credentials. It is shown early on that Link was some kind of royally appointed
champion, and it is then implied that those selfsame credentials derive to some
extent from Link being an embodiment of a spirit that protects the world through
time immemorial.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2017/botw/mastersword.jpg"
alt="Link holding the Master Sword aloft triumphantly.">&lt;figcaption>
&lt;p>Only the Chosen Hero can wield the Sword that Seals the Darkness!&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Despite these heroic credentials, the player at the beginning of the game is
effectively powerless, having been idle (unconscious) for a century, following a
disastrous defeat at the hands of an encroaching Ganon.&lt;/p>
&lt;p>Much of the characterization is done through flashbacks, which I find pretty
effective. Putting this kind of backstory in-line might have been distracting
and taken away from the feel of a desolate and ruined world, which makes for
some of the most intriguing parts of the setting.&lt;/p>
&lt;p>It is a bit unusual among games (those with this kind of budget available at
least) that this one largely does without voice acting- most characters (the
player included) &amp;ldquo;speak&amp;rdquo; in what amounts to grunts, except in flashbacks and
with a few major characters who are fully voiced. This works well I find; they
are not particularly wordy, and suggest some kind of connection between Link and
the world&amp;rsquo;s past, making the player&amp;rsquo;s task something more resembling a quest to
regain the glory of the land, rather than just preventing a cataclysm (which has
already happened!).&lt;/p>
&lt;p>I appreciate the gravitas of the voiced instances in general as appropriate to
the events surrounding a kingdom under mortal threat. Despite that, there are
welcome comedic and otherwise lighthearted instances that seem appropriate when
considering that Link and Zelda are supposed to be relatively young. In
particular, Zelda as a character does not come across to me as a
nearly-omniscient plot device (&amp;ldquo;go here and do this, you must!&amp;rdquo;), but instead as
a typical person thrust into a position of power as royalty doing what they feel
is right in a difficult situation.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2017/botw/mastersword-flashback.jpg"
alt="A chipped and rusted Master Sword">&lt;figcaption>
&lt;p>Zelda accepts a difficult role when the best efforts of the people have failed.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Other characters help the game illustrate that in the past Link is coming from,
there were organized attempts to resist the impending doom embodied in Ganon.
Compare this to my impression of other Zeldas, where if anybody else is aware of
the existential hazard that must be defeated by the player they foolishly choose
to put their trust in the abilities of the player who begins with no credentials
to speak of.&lt;/p>
&lt;p>The most guidance any person gives to the player regarding how best to achieve
their ultimate goal of defeating the Great Evil hanging over the land amounts to
general information, stating what happened in the past and suggesting what might
be helpful. Not only does this support the feeling that nobody knows a magical
secret that will defeat Ganon, but it also meshes nicely with the feeling that
the player is responsible for restoring the former glory of the land (and
themselves), leaving it up to the player what level of achievement is
appropriate.&lt;/p>
&lt;p>If desired, a sufficiently skilled player can go directly from the game&amp;rsquo;s
introduction to defeating the Big Bad, in some fashion asserting that there is
nothing that need be reclaimed.&lt;/p>
&lt;h2 id="concluding">
&lt;a class="Heading-link u-clickable" href="https://www.taricorp.net/2017/breath-of-the-wild/#concluding">Concluding&lt;/a>
&lt;/h2>
&lt;p>With a largely ruined and wild world in the grip of an elemental evil, Breath of
the Wild suggests that there is a long and glorious forgotten history to be
rediscovered and reclaimed. Whereas the feeling I get from the narrative setup
of other Zelda games is &amp;ldquo;become a hero and save the land!&amp;rdquo;, this one is closer
to &amp;ldquo;reclaim the forgotten glory of the land by rediscovering your own heroic
nature.&amp;rdquo; Less a story of self-actualization, the player is fighting the universe
itself to build a better world for themselves and every other character in it-
but they are granted great freedom to decide what must be done and how.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2017/botw/sacredground.jpg"
alt="Link stands in the midst of some ruins facing away from the viewer, looking at Hyrule Castle in the distance surrounded by a malevolent aura.">&lt;figcaption>
&lt;p>Nice framing of a highly visible goal.&lt;/p>
&lt;/figcaption>
&lt;/figure>
&lt;p>Recognizing my preferences in fiction, it is hardly surprising that I find
Breath of the Wild&amp;rsquo;s approach to storytelling compelling. For instance, I quite
enjoy the works of Alastair Reynolds, which often have themes relating to
inhumanly long timescales and the inability of humans to comprehend all relevant
aspects. Taken slightly differently, I greatly appreciate &lt;a href="https://en.wikipedia.org/wiki/Lovecraftian_horror">Lovecraftian
horror&lt;/a> with its bleak outlook and Breath of the Wild hits some
similar notes (albeit in a much more positive fashion).&lt;/p>
&lt;p>So, yeah. I like Breath of the Wild and really appreciate how it diverges from
other Zelda games in presentation (though I lack the perspective to objectively
judge how much it actually differs!). Even if you, dear reader, have found other
Zelda iterations uninteresting, this one might be worth looking at.&lt;/p>
&lt;figure>&lt;img src="https://www.taricorp.net/2017/botw/ritovillage.jpg"
alt="The player character looking contemplatively at snow-capped mountains from a wooden platform">&lt;figcaption>
&lt;p>At this point I&amp;rsquo;m just appreciating the scenery.&lt;/p>
&lt;/figcaption>
&lt;/figure></description></item><item><title>An illustrated guide to LLVM</title><link>https://www.taricorp.net/2017/illustrated-llvm/</link><pubDate>Tue, 16 May 2017 10:20:43 +1000</pubDate><guid>https://www.taricorp.net/2017/illustrated-llvm/</guid><description>&lt;p>At the most recent &lt;a href="https://github.com/rustsydney">Rust Sydney&lt;/a> meetup (yesterday, &amp;ldquo;celebrating&amp;rdquo; Rust&amp;rsquo;s
&lt;a href="https://blog.rust-lang.org/2015/05/15/Rust-1.0.html">second birthday&lt;/a>) I gave a talk intended to provide an introduction
to using LLVM to build compilers, using Rust as the implementing language. The
presentations were not recorded which might have been neat, but I&amp;rsquo;m publishing
the slides and notes here for anybody who might find it interesting or useful.
It is however not as illustrated as the title may seem to suggest.&lt;/p>
&lt;p>It&amp;rsquo;s embedded below, or you can view standalone &lt;a href="https://www.taricorp.net/2017/illustrated-llvm-presentation/index.html">in your browser&lt;/a> or as
a PDF, available &lt;a href="https://www.taricorp.net/2017/illustrated-llvm-notes.pdf">with&lt;/a> or &lt;a href="https://www.taricorp.net/2017/illustrated-llvm.pdf">without&lt;/a> presenter
notes. Navigate with the
arrow keys on your keyboard or by swiping. Press &lt;kbd>?&lt;/kbd> to show additional
keys for controls; in particular, &lt;kbd>s&lt;/kbd> will open a presenter view that
includes the plentiful notes I&amp;rsquo;ve included.&lt;/p>
&lt;iframe style="max-width: 100%; width: 800px; max-height: 100%; height: 600px"
src="https://www.taricorp.net/2017/illustrated-llvm-presentation/index.html">
&lt;/iframe>
&lt;p>In case of curiousity, I built the presentation with
&lt;a href="https://github.com/hakimel/reveal.js">reveal.js&lt;/a> and its preparation consumed a
lot more time than I initially expected (though that&amp;rsquo;s not the fault of reveal).&lt;/p></description></item></channel></rss>