<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Ronan Gaillard's Workshop</title>
    <link>https://ronangaillard.com/</link>
    <description>Ronan Gaillard&#39;s workshop: embedded systems, retro computers and sailing, from Saint-Malo.</description>
    <language>en-us</language>
    <lastBuildDate>Fri, 06 Nov 2026 22:00:00 +0100</lastBuildDate>
    <atom:link href="https://ronangaillard.com/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Teaching my Fujifilm X-T30 II to Print on an instax Link 3</title>
      <link>https://ronangaillard.com/posts/fujifilm-x-t30-ii-instax-link-3/</link>
      <pubDate>Fri, 06 Nov 2026 22:00:00 +0100</pubDate>
      <guid>https://ronangaillard.com/posts/fujifilm-x-t30-ii-instax-link-3/</guid>
      <description>&lt;p&gt;&lt;em&gt;Adding Bluetooth instax printing to a Fujifilm camera that never had it, and finding a bug in Fuji&amp;rsquo;s firmware along the way&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/setup.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/setup.jpg&#34; alt=&#34;The X-T30 II, the instax mini Link 3 and the first print&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-problem&#34;&gt;The Problem&lt;/h2&gt;&#xA;&lt;p&gt;I travel with a Fujifilm X-T30 II and a small instax mini Link 3 printer, which I bought during a trip to China. I like giving prints to people I meet, or slipping them into postcards. I also like to travel without my phone as much as I can. But today the phone is the middleman: copy the photo to it, open the instax app, print from there. It works, but it is slow and it kills the fun.&lt;/p&gt;&#xA;&lt;p&gt;Naively, I thought a fairly recent Fujifilm camera would print to it natively. I had even seen the &amp;ldquo;instax printer print&amp;rdquo; entry in the menus. It doesn&amp;rsquo;t. The X-T30 II only knows the old Wi-Fi printers (SP-1, SP-2, SP-3). Newer ones like the Link 3 use a different protocol over Bluetooth LE. Fujifilm added it to newer cameras like the X-S10, never to mine. And mine is already on its latest firmware, so no update is coming.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/01-goal.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/01-goal.svg?v=2&#34; alt=&#34;What the camera supports vs what I want&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;So the only way was to change the firmware. I had never looked at camera firmware before.&lt;/p&gt;&#xA;&lt;h2 id=&#34;rule-zero-dont-brick-the-camera&#34;&gt;Rule zero: don&amp;rsquo;t brick the camera&lt;/h2&gt;&#xA;&lt;p&gt;Before anything, I set a few rules. A bricked camera is an expensive paperweight, and people in the community have bricked bodies by poking at random memory addresses.&lt;/p&gt;&#xA;&lt;p&gt;I never write to the flash, the memory chip where the camera keeps its firmware. Everything runs in RAM, so pulling the battery brings back the stock camera. Every write to the camera needs my explicit OK, and the original bytes are checked first. I prefer reading files on the Mac over probing the camera. And I don&amp;rsquo;t waste film, because an instax sheet costs about a euro.&lt;/p&gt;&#xA;&lt;p&gt;These rules made the project slower, but I never had a moment of panic.&lt;/p&gt;&#xA;&lt;h2 id=&#34;step-1-opening-the-firmware&#34;&gt;Step 1: Opening the firmware&lt;/h2&gt;&#xA;&lt;p&gt;Fujifilm publishes firmware updates on its website, one &lt;code&gt;.DAT&lt;/code&gt; file per camera. The format isn&amp;rsquo;t documented, but &lt;a href=&#34;https://fujihack.org/&#34; target=&#34;_blank&#34;&gt;FujiHack&lt;/a&gt; had already done part of the work on older bodies.&lt;/p&gt;&#xA;&lt;p&gt;Unpacking it took three steps. First, cut the header. The first bytes are an ID card for the file: camera model, version, checksums. The code comes after. Then, flip every bit: every 0 becomes a 1 and every 1 a 0. That&amp;rsquo;s all the &amp;ldquo;encryption&amp;rdquo; there is. Finally, decompress. What&amp;rsquo;s left is four compressed blocks. Fuji uses its own flavour of LZ, the classic trick behind ZIP: when a sequence of bytes repeats, it isn&amp;rsquo;t stored again but replaced by &amp;ldquo;copy 20 bytes from 300 bytes back&amp;rdquo;.&lt;/p&gt;&#xA;&lt;p&gt;I wrote a small Python script, &lt;code&gt;fujifw.py&lt;/code&gt;, that does all three. To check it, I had a free test: the firmware contains some well-known data tables from zlib, a public compression library. If they come out byte for byte identical to the published ones, the decompression is right.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/02-unpack.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/02-unpack.svg?v=2&#34; alt=&#34;From the update file to Ghidra&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;Three of the blocks are machine code for the camera&amp;rsquo;s processor (an ARM Cortex-A7 running ThreadX, a small real-time OS). Machine code is just bytes. To read it I used &lt;a href=&#34;https://ghidra-sre.org/&#34; target=&#34;_blank&#34;&gt;Ghidra&lt;/a&gt;, a free reverse engineering tool released by the NSA. You give it a binary, it finds the functions and turns the bytes into assembly, then into C-like code. No names, no comments, but readable.&lt;/p&gt;&#xA;&lt;p&gt;Ghidra first needs to know where each block sits in the camera&amp;rsquo;s memory. The firmware doesn&amp;rsquo;t say, so I had to work it out. The code is full of pointers to text messages. If I guess the wrong starting address for a block, those pointers land in the middle of nowhere. There is only one starting address where they all land on the start of a real message. With that, Ghidra found about 59,000 functions. I ran it from scripts instead of its interface, so Claude could ask it for any function directly.&lt;/p&gt;&#xA;&lt;h2 id=&#34;step-2-the-code-was-already-there&#34;&gt;Step 2: The code was already there&lt;/h2&gt;&#xA;&lt;p&gt;59,000 functions with no names is a lot. But I had a trick: the X-S10 uses the same processor and &lt;strong&gt;does&lt;/strong&gt; print to Link printers. So I unpacked its firmware too and compared the two.&lt;/p&gt;&#xA;&lt;p&gt;First I had to find the print code in the X-S10, among its own tens of thousands of nameless functions. Strings gave it away. The instax Link service has a known Bluetooth ID (a 128-bit UUID), published by the people who reverse engineered the protocol, and it appears in the X-S10 firmware. The code that uses it sits next to a task called &lt;code&gt;instaxbt&lt;/code&gt; and debug markers like &lt;code&gt;INSXSe&lt;/code&gt; or &lt;code&gt;INSXPt&lt;/code&gt;, and it builds packets starting with the instax header. That&amp;rsquo;s the Bluetooth part. Then I listed every function that calls into it, and the functions around those: that&amp;rsquo;s the print app, 176 functions in one block.&lt;/p&gt;&#xA;&lt;p&gt;I wrote a fuzzy matcher. It compares whole functions and ignores the addresses inside them, which change from one build to another (more on that later). I ran it on the 176 functions of the X-S10 print code. 109 of them had a twin in my camera that was at least 90% identical.&lt;/p&gt;&#xA;&lt;p&gt;That was the big surprise: &lt;strong&gt;the X-T30 II already contains Fujifilm&amp;rsquo;s Bluetooth print app.&lt;/strong&gt; Cropping, rotation, JPEG encoding, progress screens, it&amp;rsquo;s all there. Only the bottom layer, the part that talks to the printer over Bluetooth, was left out when Fuji built this firmware. In its place are empty functions, stubs that just return 0.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/03-compare.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/03-compare.svg?v=2&#34; alt=&#34;X-S10 vs X-T30 II&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;Even better, the camera already knows how to drive a Bluetooth device. Bluetooth LE (Low Energy) is the low-power flavour of Bluetooth used by watches, sensors and printers like the Link 3. In every connection one side is the &lt;em&gt;central&lt;/em&gt;, which scans, connects and gives orders, and the other is the &lt;em&gt;peripheral&lt;/em&gt;, which waits and answers. Usually the camera is a peripheral: the phone app connects to it. To print, it has to be the central and go get the printer.&lt;/p&gt;&#xA;&lt;p&gt;The X-T30 II already plays that role in one case: Fujifilm&amp;rsquo;s Bluetooth shooting grip (most likely the TG-BT1), a small tripod handle with a shutter button that sends commands to the camera. The firmware has a module that looks for the grip and connects to it as a central. So I didn&amp;rsquo;t need to write a Bluetooth stack, just the missing glue.&lt;/p&gt;&#xA;&lt;p&gt;I found one more problem at this stage. Before printing, the app needs to know which film is loaded, because a mini and a wide print don&amp;rsquo;t have the same size or shape. It doesn&amp;rsquo;t ask the printer directly. It reads the printer&amp;rsquo;s serial number and looks at the first digit: &lt;code&gt;1&lt;/code&gt; means mini, &lt;code&gt;2&lt;/code&gt; means wide, anything else means &amp;ldquo;unknown printer, stop&amp;rdquo;. That list was written for the printers that existed at the time. The X-S10, a bit newer, also accepts &lt;code&gt;4&lt;/code&gt;. My Link 3&amp;rsquo;s serial starts with &lt;code&gt;7&lt;/code&gt;, which no firmware I looked at knows about, not even the X-T30 III. So even with perfect Bluetooth, the camera would have rejected it. The fix is simple: treat &lt;code&gt;7&lt;/code&gt; as a mini.&lt;/p&gt;&#xA;&lt;h2 id=&#34;step-3-writing-the-missing-piece&#34;&gt;Step 3: Writing the missing piece&lt;/h2&gt;&#xA;&lt;p&gt;The instax Link protocol is well known thanks to people who recorded the Bluetooth traffic between the phone app and the printer: &lt;a href=&#34;https://github.com/javl/InstaxBLE&#34; target=&#34;_blank&#34;&gt;javl/InstaxBLE&lt;/a&gt;, &lt;a href=&#34;https://github.com/linssenste/instax-link-web&#34; target=&#34;_blank&#34;&gt;linssenste/instax-link-web&lt;/a&gt; and others. It&amp;rsquo;s simple. Each packet starts with &lt;code&gt;Ab&lt;/code&gt;, then a length, an opcode, a payload and a checksum. The camera asks a few questions, sends the JPEG in 900-byte chunks and says &amp;ldquo;print&amp;rdquo;.&lt;/p&gt;&#xA;&lt;p&gt;I didn&amp;rsquo;t copy the X-S10 code. In a firmware like this, every function and every variable lives at a fixed address, chosen by the compiler when Fuji builds it. When a function calls another one, the call contains that address written in plain bytes. Change one line of code, recompile, and everything after it shifts. Two different cameras are two different builds, so the same function sits at a different address in each. Moving the X-S10 code into my camera would have meant finding and fixing hundreds of those addresses by hand. Instead, Claude and I wrote a small C module from scratch, &lt;strong&gt;linkxt&lt;/strong&gt;. It takes the place of the stubs: the Fuji app calls it, and it calls the Fuji Bluetooth stack.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/05-linkxt.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/05-linkxt.svg?v=2&#34; alt=&#34;Where linkxt sits&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;How do you plug new code into a firmware you can&amp;rsquo;t recompile? With those same fixed addresses.&lt;/p&gt;&#xA;&lt;p&gt;First, linkxt needs a place to live. I can&amp;rsquo;t push existing code aside to make room, since that would move addresses. So I looked for space that already exists but that nobody uses. In the middle of the code there is a run of 4048 bytes of zeros, at &lt;code&gt;0x01e12030&lt;/code&gt;. No function points to it, and it&amp;rsquo;s still all zeros in a dump of the running camera&amp;rsquo;s RAM, so the firmware doesn&amp;rsquo;t use it at runtime either. That&amp;rsquo;s where linkxt goes. It also set the hard limit of the project: linkxt has to fit in 4048 bytes, debug logging included. Most debugging sessions started with &amp;ldquo;what can I remove to make room for this new trace?&amp;rdquo;.&lt;/p&gt;&#xA;&lt;p&gt;Then the two pieces have to call each other. In one direction, Fuji calls linkxt. The print app still calls its 12 empty functions (&amp;ldquo;send the serial request&amp;rdquo;, &amp;ldquo;send a chunk&amp;rdquo;, &amp;ldquo;print&amp;rdquo;, &amp;ldquo;connect&amp;rdquo;…) at their usual addresses. I overwrite the first 8 bytes of each one with a tiny instruction, &amp;ldquo;jump to this address&amp;rdquo;, pointing to the matching function in linkxt. The app has no idea. It calls the stub as always and lands in my code.&lt;/p&gt;&#xA;&lt;p&gt;In the other direction, linkxt calls Fuji. It needs firmware functions too, to open a Bluetooth connection, send a packet or write a file. I give the compiler a list of their addresses, found in Ghidra, and it calls them directly.&lt;/p&gt;&#xA;&lt;p&gt;On top of that, a few single bytes change. I didn&amp;rsquo;t add a menu entry: the existing &amp;ldquo;instax printer print&amp;rdquo; entry passes a connection type to the print app, 0 for Wi-Fi. Two bytes turn that 0 into a 1, Bluetooth, so the same entry now takes the Link path (with this card, the old Wi-Fi printers are no longer reachable). And the serial check learns about &lt;code&gt;7&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/11-wiring.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/11-wiring.svg?v=2&#34; alt=&#34;How linkxt is plugged in&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;All these patches go into the copy of the firmware loaded from the SD card (more on that below), never into the camera itself.&lt;/p&gt;&#xA;&lt;p&gt;Before touching the camera, every build ran in an emulator. I used &lt;a href=&#34;https://www.unicorn-engine.org/&#34; target=&#34;_blank&#34;&gt;Unicorn&lt;/a&gt; to run the real ARM binary against a fake Link 3 printer written in Python. It caught a lot of bugs at zero cost.&lt;/p&gt;&#xA;&lt;h2 id=&#34;step-4-running-it-without-flashing&#34;&gt;Step 4: Running it without flashing&lt;/h2&gt;&#xA;&lt;p&gt;Now, how do you run modified firmware without writing it to the flash?&lt;/p&gt;&#xA;&lt;p&gt;The answer came from &lt;strong&gt;jitl&lt;/strong&gt; and the &lt;a href=&#34;https://fujimod.com/&#34; target=&#34;_blank&#34;&gt;FujiMod&lt;/a&gt; project, found on the FujiHack Discord. Fujifilm cameras can boot from the SD card if you hold a secret button combo at power-on (on mine: Delete + press the rear dial). The card holds two files. &lt;code&gt;ffinit.bin&lt;/code&gt; is a tiny loader. &lt;code&gt;ffboot.bin&lt;/code&gt; is an image of the whole RAM (72 MB), built from the official firmware. The camera loads that image into RAM and runs it. The flash is never touched.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/04-sdboot.svg?v=3&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/04-sdboot.svg?v=3&#34; alt=&#34;SD boot&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;This had been shown to work on other bodies, but nobody had tried it on an X-T30 II. Getting there took 30 trials.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Trial 1: nothing.&lt;/strong&gt; The camera started normally. I had pressed the front dial instead of the rear one. &lt;strong&gt;Trial 2:&lt;/strong&gt; green LED, black screen. That green LED was jitl&amp;rsquo;s test loader saying hello: the camera&amp;rsquo;s boot ROM had read my card. First win.&lt;/p&gt;&#xA;&lt;p&gt;Who reads the card at that point? Not the firmware, which isn&amp;rsquo;t running yet. It&amp;rsquo;s the &lt;strong&gt;boot ROM&lt;/strong&gt;, a small piece of code burned into the processor itself. It can&amp;rsquo;t be changed and it runs first at every power-on. Its job is to wake up the hardware, then load the firmware from flash, or from the SD card if you hold the button combo. For that it carries its own tiny SD card driver and a FAT reader. Since it&amp;rsquo;s burned into the chip, its functions always sit at the same addresses. The X-T30 II has the very same ROM as the X100V, and the community had already mapped where everything is in it. So my loaders didn&amp;rsquo;t need their own SD driver. They just called the ROM&amp;rsquo;s functions (&amp;ldquo;init the card&amp;rdquo;, &amp;ldquo;read sector N&amp;rdquo;) by their address. Writing was harder, because the ROM never writes. I had to drive the SD card hardware myself, copying the way the ROM sends its read command.&lt;/p&gt;&#xA;&lt;p&gt;Then came a long series of black screens. When you boot your own code this early, you have no screen, no debugger, no &lt;code&gt;printf&lt;/code&gt;. The only thing I could control was the small LED on the back. So it became my debugger: the loader blinks green once for each stage it reaches, and goes red when something fails. I counted blinks with a stopwatch in hand.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/09-led.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/09-led.svg?v=2&#34; alt=&#34;The LED as a debugger&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;The LED taught me a few things the hard way. First, patience. For several trials I thought the loader was stuck on red, but it wasn&amp;rsquo;t: the ROM reads the 72 MB image sector by sector, and that takes a minute and a half.&lt;/p&gt;&#xA;&lt;p&gt;Then, emulators lie a little. One of my loaders froze silently. The C compiler had turned a byte read into a 4-byte read at an address that isn&amp;rsquo;t a multiple of 4. Once the system is running, that&amp;rsquo;s fine. This early in boot, the processor refuses it and stops. Unicorn didn&amp;rsquo;t model that, so all my tests passed. One compiler flag fixed it.&lt;/p&gt;&#xA;&lt;p&gt;Finally, blinks don&amp;rsquo;t scale. So I created an empty file, &lt;code&gt;LOG.BIN&lt;/code&gt;, on the card from the Mac beforehand. The loader found it with the ROM&amp;rsquo;s file reader and wrote its log straight into the card sectors behind it. After each trial I opened &lt;code&gt;LOG.BIN&lt;/code&gt; on the Mac to see where the boot had stopped.&lt;/p&gt;&#xA;&lt;p&gt;The log said the kernel started, but the camera app never did. And most of my log points, the ones placed later in the boot, never wrote anything at all.&lt;/p&gt;&#xA;&lt;p&gt;So why did it take 30 trials? Because the bug was in none of the places I was looking. The card was fine, the loader was fine, the kernel started fine. I found the answer by reading the kernel code on the Mac, not with another trial.&lt;/p&gt;&#xA;&lt;p&gt;Remember that the 72 MB image is a copy of the RAM: every byte lands at the same address in memory. In that image there is a 128 KB area full of zeros. It looks unused, so jitl&amp;rsquo;s first boot recipe (&amp;ldquo;startup&amp;rdquo;) put some of its own code there, code the app needs later. I did the same with my debug code.&lt;/p&gt;&#xA;&lt;p&gt;But that area isn&amp;rsquo;t unused. It&amp;rsquo;s where the kernel writes its logs, and one of the first things the kernel does is clear it: a short title, then zeros everywhere. Our code was erased a split second before it was needed. When the app jumped to jitl&amp;rsquo;s code, the processor found only zeros and froze. No error, no log, just a black screen.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/10-wipe.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/10-wipe.svg?v=2&#34; alt=&#34;The log area wipe&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;jitl&amp;rsquo;s recipe had failed the same way on an X100V. His other recipe, &amp;ldquo;cold&amp;rdquo;, doesn&amp;rsquo;t put anything in that area, so I switched to it.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Trial 30:&lt;/strong&gt; no LED at all, then after a minute or two, a normal live view. But was it really running from the card, or had the camera given up and started from its flash? &lt;strong&gt;Trial 31:&lt;/strong&gt; same card with the big image renamed. Nothing, ever. So trial 30 had really booted from the SD card.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Trial 32:&lt;/strong&gt; my first mod. It turned the LED red for 10 seconds, then let the camera boot. Useless, and the best red LED I&amp;rsquo;ve ever seen.&lt;/p&gt;&#xA;&lt;p&gt;(I also spent a few evenings on a USB &amp;ldquo;service mode&amp;rdquo; route that can read and write RAM while the camera runs. It was great for checking my RAM image against the real thing, but useless for running code.)&lt;/p&gt;&#xA;&lt;h2 id=&#34;step-5-debugging-blind&#34;&gt;Step 5: Debugging blind&lt;/h2&gt;&#xA;&lt;p&gt;Once linkxt was on the card, the camera showed a new screen: &amp;ldquo;connecting&amp;rdquo;… then &amp;ldquo;CAN NOT BE CONNECTED&amp;rdquo;. No details.&lt;/p&gt;&#xA;&lt;p&gt;I needed logs. In the firmware, I found the function Fuji uses to write &lt;code&gt;A:\UILOG\COUNTER.TXT&lt;/code&gt; to the SD card, and I reused it. From then on, linkxt wrote everything it did to &lt;code&gt;A:\LINKXT.TXT&lt;/code&gt;. Each trial became the same loop:&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/07-loop.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/07-loop.svg?v=2&#34; alt=&#34;The debug loop&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;With logs, things moved fast.&lt;/p&gt;&#xA;&lt;p&gt;On trials 34 to 36, linkxt scans, connects, finds the instax service and turns on notifications. Bluetooth works.&lt;/p&gt;&#xA;&lt;p&gt;Trial 37 is the first real instax exchange. The camera&amp;rsquo;s first question is opcode &lt;code&gt;0x0000&lt;/code&gt;, a &amp;ldquo;who are you, what do you support?&amp;rdquo; request. The Link 3 answers, but the Fuji app rejects the answer. It expects a set of flags laid out like on the older printers, and the Link 3 sends &lt;code&gt;00 02 00 00 00 00&lt;/code&gt; instead. I still don&amp;rsquo;t know what those bytes mean. Since the app doesn&amp;rsquo;t use them for anything else, linkxt simply replaces them with the values the app wants.&lt;/p&gt;&#xA;&lt;p&gt;On trial 38, all the questions pass and the photo upload starts. On trial 39, the Link 3 accepts a print, and the film counter goes from 7 to 6!&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/06-sequence.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/06-sequence.svg?v=2&#34; alt=&#34;The full exchange&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-pink-ghost&#34;&gt;The pink ghost&lt;/h2&gt;&#xA;&lt;p&gt;Trial 39 printed. The sheet came out white and slowly developed into… an almost white picture with a faint pink boat. Not quite what I was hoping for.&lt;/p&gt;&#xA;&lt;p&gt;Since prints cost money, I switched to &amp;ldquo;no-print&amp;rdquo; cards. They do everything, but instead of sending the JPEG they save it to the SD card and stop. The JPEG was garbage: pink, zoomed 2×, with stripes on every other column.&lt;/p&gt;&#xA;&#xA;&#xA;&#xA;&#xA;&#xA;&#xA;&#xA;&lt;figure&gt;&#xA;&lt;div class=&#34;img-compare&#34; id=&#34;ic-758214936&#34; style=&#34;position:relative;overflow:hidden;cursor:col-resize;max-width:800px;margin:0 auto;user-select:none;-webkit-user-select:none;line-height:0&#34;&gt;&#xA;  &lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/jpeg-after-fix.jpg&#34; alt=&#34;Trial 45&#34; style=&#34;display:block;width:100%&#34; draggable=&#34;false&#34;&gt;&#xA;  &lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/jpeg-before-fix.jpg&#34; alt=&#34;Trial 44&#34; class=&#34;img-compare-before&#34; style=&#34;position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 50% 0 0)&#34; draggable=&#34;false&#34;&gt;&#xA;  &lt;div class=&#34;img-compare-slider&#34; style=&#34;position:absolute;top:0;left:50%;width:3px;height:100%;background:#fff;transform:translateX(-50%);pointer-events:none&#34;&gt;&#xA;    &lt;div style=&#34;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:36px;height:36px;border-radius:50%;background:#fff;box-shadow:0 0 6px rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;pointer-events:none&#34;&gt;&#xA;      &lt;svg width=&#34;20&#34; height=&#34;20&#34; viewBox=&#34;0 0 20 20&#34; fill=&#34;none&#34;&gt;&lt;path d=&#34;M6 10L2 10M2 10L5 7M2 10L5 13&#34; stroke=&#34;#333&#34; stroke-width=&#34;1.5&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34;/&gt;&lt;path d=&#34;M14 10L18 10M18 10L15 7M18 10L15 13&#34; stroke=&#34;#333&#34; stroke-width=&#34;1.5&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34;/&gt;&lt;/svg&gt;&#xA;    &lt;/div&gt;&#xA;  &lt;/div&gt;&#xA;  &lt;div style=&#34;position:absolute;top:10px;left:10px;background:rgba(0,0,0,.6);color:#fff;padding:3px 8px;border-radius:3px;font-size:12px;pointer-events:none&#34;&gt;Trial 44&lt;/div&gt;&#xA;  &lt;div style=&#34;position:absolute;top:10px;right:10px;background:rgba(0,0,0,.6);color:#fff;padding:3px 8px;border-radius:3px;font-size:12px;pointer-events:none&#34;&gt;Trial 45&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&lt;figcaption style=&#34;text-align:center;font-style:italic;margin-top:8px;font-size:0.9em;color:#666&#34;&gt;The JPEG the camera builds for the printer, before and after the fix. Drag the slider.&lt;/figcaption&gt;&#xA;&lt;/figure&gt;&#xA;&#xA;&lt;script&gt;&#xA;(function(){&#xA;  var container = document.getElementById(&#34;ic-758214936&#34;);&#xA;  var before = container.querySelector(&#34;.img-compare-before&#34;);&#xA;  var slider = container.querySelector(&#34;.img-compare-slider&#34;);&#xA;  var active = false;&#xA;&#xA;  function move(x) {&#xA;    var rect = container.getBoundingClientRect();&#xA;    var pos = Math.max(0, Math.min(1, (x - rect.left) / rect.width));&#xA;    before.style.clipPath = &#34;inset(0 &#34; + ((1 - pos) * 100) + &#34;% 0 0)&#34;;&#xA;    slider.style.left = (pos * 100) + &#34;%&#34;;&#xA;  }&#xA;&#xA;  container.addEventListener(&#34;mousedown&#34;, function(e) { active = true; move(e.clientX); e.preventDefault(); });&#xA;  window.addEventListener(&#34;mousemove&#34;, function(e) { if (active) move(e.clientX); });&#xA;  window.addEventListener(&#34;mouseup&#34;, function() { active = false; });&#xA;&#xA;  container.addEventListener(&#34;touchstart&#34;, function(e) { active = true; move(e.touches[0].clientX); e.preventDefault(); }, {passive:false});&#xA;  container.addEventListener(&#34;touchmove&#34;, function(e) { if (active) move(e.touches[0].clientX); }, {passive:false});&#xA;  container.addEventListener(&#34;touchend&#34;, function() { active = false; });&#xA;})();&#xA;&lt;/script&gt;&#xA;&#xA;&lt;p&gt;I first suspected my code, then the file size, then the source image (an S-size JPEG gave the same result). It was none of those. So I made linkxt dump the image buffers between each step of Fuji&amp;rsquo;s pipeline, and decoded them on the Mac.&lt;/p&gt;&#xA;&lt;p&gt;After the resize, the image was perfect, in YUV (&amp;ldquo;Y Y U V&amp;rdquo;). After the rotation, it was &lt;strong&gt;not YUV at all&lt;/strong&gt; but 32-bit ARGB pixels.&lt;/p&gt;&#xA;&lt;p&gt;Reading the code around it explained everything. In one of its answers, the printer lists its capability flags. When capability bit 3 is set, the app runs an extra colour pass: YUV to ARGB, colour tweak, back to YUV. Then a copy meant for printers &lt;strong&gt;without&lt;/strong&gt; that bit runs anyway, and overwrites the clean result with half of the ARGB buffer. The JPEG encoder reads ARGB as if it were YUV, and you get a pink ghost.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/08-pink-bug.svg?v=2&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/08-pink-bug.svg?v=2&#34; alt=&#34;The pink bug&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;It looks like a real Fujifilm bug. Maybe no printer ever set that bit for this code path, and the Link 3 is the first. The fix is one line in linkxt: hide bit 3 from the app. Trial 45, still without film, gave a clean JPEG.&lt;/p&gt;&#xA;&lt;h2 id=&#34;it-works&#34;&gt;It works!&lt;/h2&gt;&#xA;&lt;p&gt;Trial 46: real card, real film. I picked a photo in the playback menu, hit &amp;ldquo;Print&amp;rdquo;, got a progress bar, and the Link 3 started whirring. A few minutes later, the boat appeared.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/fujifilm-instax/print.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/fujifilm-instax/print.jpg&#34; alt=&#34;The first good print, straight from the X-T30 II&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;No phone in the middle, and nothing written to the camera. Pull the card and it&amp;rsquo;s stock again.&lt;/p&gt;&#xA;&lt;h2 id=&#34;tools-and-sources&#34;&gt;Tools and sources&lt;/h2&gt;&#xA;&lt;table&gt;&#xA;&#x9;&lt;thead&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;What&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;Used for&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/thead&gt;&#xA;&#x9;&lt;tbody&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;Python&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;firmware unpacker, function matcher, card builder, buffer decoders&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&lt;a href=&#34;https://ghidra-sre.org/&#34; target=&#34;_blank&#34;&gt;Ghidra&lt;/a&gt; (headless)&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;disassembly and decompilation of the X-T30 II and X-S10 firmwares&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&lt;a href=&#34;https://www.unicorn-engine.org/&#34; target=&#34;_blank&#34;&gt;Unicorn&lt;/a&gt;&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;running linkxt against a fake Link 3 before every trial&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;arm-none-eabi GCC&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;building linkxt (Thumb-2, under 4 KB)&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&lt;a href=&#34;https://claude.com/claude-code&#34; target=&#34;_blank&#34;&gt;Claude Code&lt;/a&gt;&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;reading thousands of decompiled functions, writing the tools and the module, keeping notes&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&lt;a href=&#34;https://fujihack.org/&#34; target=&#34;_blank&#34;&gt;FujiHack&lt;/a&gt; Discord, &lt;a href=&#34;https://fujimod.com/&#34; target=&#34;_blank&#34;&gt;FujiMod&lt;/a&gt;&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;SD boot, button combos, and a lot of advice from jitl&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&lt;a href=&#34;https://github.com/javl/InstaxBLE&#34; target=&#34;_blank&#34;&gt;javl/InstaxBLE&lt;/a&gt;, &lt;a href=&#34;https://github.com/linssenste/instax-link-web&#34; target=&#34;_blank&#34;&gt;linssenste/instax-link-web&lt;/a&gt;&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;the instax Link protocol&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;h2 id=&#34;whats-next&#34;&gt;What&amp;rsquo;s next&lt;/h2&gt;&#xA;&lt;p&gt;It&amp;rsquo;s still a debug build, and landscape photos haven&amp;rsquo;t been tested since the fix (before it, they froze on &amp;ldquo;Sending&amp;rdquo;). Next, I want to clean it up and turn linkxt into a proper FujiMod mod.&lt;/p&gt;&#xA;&lt;p&gt;The code is on GitHub: &lt;a href=&#34;https://github.com/ronangaillard/xt30ii-instax-link&#34; target=&#34;_blank&#34;&gt;ronangaillard/xt30ii-instax-link&lt;/a&gt;. It contains no Fujifilm code: you download the official firmware yourself, build jitl&amp;rsquo;s SD boot card (FujiMod&amp;rsquo;s &amp;ldquo;recovery&amp;rdquo; card) yourself, and a script patches it. If you have an X-T30 II on firmware 2.04 and a Link 3, try it, at your own risk, and tell me how it goes.&lt;/p&gt;&#xA;</description>
    </item>
    <item>
      <title>Factory DS: a Factorio-like on a 67 MHz Nintendo DS</title>
      <link>https://ronangaillard.com/posts/factory-ds-a-factory-game-for-the-nintendo-ds/</link>
      <pubDate>Tue, 13 Oct 2026 10:00:00 +0200</pubDate>
      <guid>https://ronangaillard.com/posts/factory-ds-a-factory-game-for-the-nintendo-ds/</guid>
      <description>&lt;p&gt;&lt;em&gt;Or how I ended up building a factory game for a 2004 handheld with 4 MB of RAM, with a lot of help from Claude Code&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/cartridge.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/cartridge.png&#34; alt=&#34;Factory DS game card&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;Yes, I even made a game card for it. No, it&amp;rsquo;s not for sale.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;&lt;a href=&#34;https://ronangaillard.com/files/factory-ds.nds&#34;&gt;Download Factory DS&lt;/a&gt;&lt;/strong&gt; (factory-ds.nds, 1.9 MB, in English and French). It runs on a real DS with a flashcart, or in an emulator like &lt;a href=&#34;https://melonds.kuribo64.net/&#34; target=&#34;_blank&#34;&gt;melonDS&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;h2 id=&#34;why-the-nintendo-ds&#34;&gt;Why the Nintendo DS?&lt;/h2&gt;&#xA;&lt;p&gt;I think the Nintendo DS is one of the best consoles ever made. It&amp;rsquo;s far from the most powerful, but I find it one of the best designed. Two screens, a real d-pad, four buttons and two shoulder buttons, and everything falls right under your thumbs. It&amp;rsquo;s a clamshell, so you can throw it in a backpack without being afraid of breaking it, and it&amp;rsquo;s simple: you open it and you play, you close it and it sleeps. And of course there is some nostalgia, I spent a lot of hours on it as a teenager.&lt;/p&gt;&#xA;&lt;p&gt;The problem is that I had run out of games that really kept me hooked on it. The DS library is huge and I have played a lot of great games on it, but the games I spend my evenings on today are more &amp;ldquo;modern&amp;rdquo; ones, and none of them exist on the DS.&lt;/p&gt;&#xA;&lt;p&gt;So I started wondering, why not the other way around? Why not bring a modern game to the DS? The first one that came to my mind was Factorio, the one I play way too much. If you don&amp;rsquo;t know it, you land on a planet and build a factory: drills mine the ore, conveyor belts carry it everywhere, robotic arms (called inserters) move items in and out of machines, and you spend your time fixing bottlenecks and optimizing ratios. It&amp;rsquo;s a game about systems more than graphics, so it should fit on a 2004 console, right?&lt;/p&gt;&#xA;&lt;h2 id=&#34;getting-started-on-a-console-i-had-never-touched&#34;&gt;Getting started on a console I had never touched&lt;/h2&gt;&#xA;&lt;p&gt;I had never written a single line of code for the DS. I didn&amp;rsquo;t know the SDKs, the memory map or how the screens work. So I did what I do now for most of my side projects: I opened &lt;a href=&#34;https://claude.com/claude-code&#34; target=&#34;_blank&#34;&gt;Claude Code&lt;/a&gt; and started from there.&lt;/p&gt;&#xA;&lt;p&gt;The first evening was only about the setup. We looked at the options together and I went for &lt;a href=&#34;https://blocksds.skylyrac.net/&#34; target=&#34;_blank&#34;&gt;BlocksDS&lt;/a&gt;, a modern SDK built on top of libnds (the historical C library to talk to the DS hardware). I didn&amp;rsquo;t want to install an ARM toolchain on my Mac, so I asked Claude to make everything run in a Docker container:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;docker run --rm -v &amp;#34;$PWD&amp;#34;:/work -w /work skylyrac/blocksds:slim-latest make&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You get a &lt;code&gt;.nds&lt;/code&gt; file, you open it in the &lt;a href=&#34;https://melonds.kuribo64.net/&#34; target=&#34;_blank&#34;&gt;melonDS&lt;/a&gt; emulator, and that&amp;rsquo;s it. A blank screen, but a blank screen that compiles!&lt;/p&gt;&#xA;&lt;p&gt;Before going further I read a bit about the hardware, because I knew it would drive a lot of decisions later:&lt;/p&gt;&#xA;&lt;table&gt;&#xA;&#x9;&lt;thead&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;Nintendo DS&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/thead&gt;&#xA;&#x9;&lt;tbody&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;Main CPU&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;ARM946E-S at 67 MHz, with no division instruction!&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;RAM&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;4 MB&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;Fast memory (ITCM)&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;32 KB for code, much faster than the main RAM&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;Screens&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;2 × 256×192&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;Saves&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;a small save chip in the cartridge, or files on the SD card of a flashcart (the cartridge you use to run homebrew games)&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;h2 id=&#34;the-first-specs&#34;&gt;The first specs&lt;/h2&gt;&#xA;&lt;p&gt;Then I wrote the first specs of the game. Nothing fancy, just a short list:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;a 2D world seen from above, with biomes (grass, forest, lakes, desert, snow)&lt;/li&gt;&#xA;&lt;li&gt;wood, stone, iron, copper and coal to mine by hand&lt;/li&gt;&#xA;&lt;li&gt;a player who walks around with the d-pad, with a cursor in front of him&lt;/li&gt;&#xA;&lt;li&gt;the world on the top screen and the interface on the bottom one&lt;/li&gt;&#xA;&lt;li&gt;drills, furnaces, belts and inserters, and that&amp;rsquo;s all for a start&lt;/li&gt;&#xA;&lt;li&gt;everything playable without the stylus&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;For the graphics I picked the CC0 &lt;a href=&#34;https://kenney.nl/&#34; target=&#34;_blank&#34;&gt;Kenney&lt;/a&gt; tilesets (Tiny Town and Tiny Factory). They are 16×16 pixels, which is exactly the right size for a 256×192 screen, and they still look good that small.&lt;/p&gt;&#xA;&lt;p&gt;I also decided to draw everything as a bitmap, where each pixel is one byte pointing to a palette of 256 colors, instead of using the tile engine of the DS. The tile engine is the classic way to make a 2D game on this console, but I wanted to be free to draw whatever I wanted: items sliding on belts, inserter arms rotating, smoke, and later trains. I think it was the right call, even if it&amp;rsquo;s also where most of the performance problems came from.&lt;/p&gt;&#xA;&lt;p&gt;The very first build was just colored squares. A red square for the drill, grey rectangles for the belts, orange dots for the items and some debug text on the bottom screen. It was ugly, but the belts were moving and that was enough to keep me going. Then real tiles came in, then a mini-map, then objectives&amp;hellip; Here is how it evolved:&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/evolution.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/evolution.png&#34; alt=&#34;Evolution of the game&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;From colored squares to the current version. These screenshots are in French, but the game is now also in English.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;a-world-that-does-not-fit-in-memory&#34;&gt;A world that does not fit in memory&lt;/h2&gt;&#xA;&lt;p&gt;Next I wanted a big world, the kind of world where you have to build trains to bring oil from far away. I set the target to 2048 × 2048 tiles, which is 4.2 million tiles. Even with one byte per tile, that&amp;rsquo;s already the whole RAM of the console.&lt;/p&gt;&#xA;&lt;p&gt;Just asking for &amp;ldquo;a bigger map&amp;rdquo; would have ended with a giant array and a crash, so Claude and I spent an evening on the design first, going back and forth until it fit. We ended up with this:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;the terrain is &lt;strong&gt;not stored&lt;/strong&gt; at all. Any tile is computed when needed from its coordinates, with a hash function and some value noise (a smooth kind of random) that decide where the forests, lakes, ore patches, deserts and snow are&lt;/li&gt;&#xA;&lt;li&gt;only what the player changes is stored, in a &lt;strong&gt;hash table of modified tiles&lt;/strong&gt; (an ore patch you mined, a tree you cut)&lt;/li&gt;&#xA;&lt;li&gt;buildings live in a &lt;strong&gt;pool indexed by position&lt;/strong&gt;, with another hash table&lt;/li&gt;&#xA;&lt;li&gt;the save file only contains the differences with the generated world, in a compact format (&lt;strong&gt;6 bytes per belt&lt;/strong&gt;)&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/world-memory.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/world-memory.png&#34; alt=&#34;How the world fits in memory&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;On the left, the real generated world, all of it (one pixel is 4×4 tiles). On the right, a small base. Only the tiles the player changed (orange) and the buildings (blue) take memory.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;Claude wrote the code and it worked as I hoped. A save slot is 64 KB, there are 3 of them (3 files on the SD card with a flashcart), and a big factory fits in one.&lt;/p&gt;&#xA;&lt;p&gt;I still wanted to know what happens when it doesn&amp;rsquo;t fit.&lt;/p&gt;&#xA;&lt;p&gt;In RAM, it can&amp;rsquo;t overflow. Every table is allocated once when the game starts, with a fixed size: room for 12,288 modified tiles and 12,288 buildings, 552 KB in total. There is no &lt;code&gt;malloc&lt;/code&gt; anywhere in the game. When a table is full the game just refuses, with &amp;ldquo;too many buildings&amp;rdquo; when you try to place one more or &amp;ldquo;map saturated&amp;rdquo; when you try to cut one more tree. It doesn&amp;rsquo;t crash.&lt;/p&gt;&#xA;&lt;p&gt;The save is another story. A building takes between 5 bytes (a rail) and 23 bytes (a machine with its state), a modified tile takes 6 bytes, and a slot is 64 KB. So I measured real saves of bigger and bigger factories:&lt;/p&gt;&#xA;&lt;table&gt;&#xA;&#x9;&lt;thead&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;Factory&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;Save size&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/thead&gt;&#xA;&#x9;&lt;tbody&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;The biggest save made by the test agents&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;under 8 KB&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;820 buildings&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;19 KB&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;1,830 buildings&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;34 KB&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;2,840 buildings&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;50 KB&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;around 3,500 buildings&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;doesn&amp;rsquo;t fit anymore&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;So no, it&amp;rsquo;s not guaranteed, and a player could build a factory too big to be saved. If that happens the game refuses to save and shows a message. The save is built in memory before anything is written, so the previous save is still there. You can&amp;rsquo;t save the latest version of your factory, but at least you don&amp;rsquo;t lose the old one.&lt;/p&gt;&#xA;&lt;p&gt;I decided to stop there for now. Placing 3,500 buildings one by one with a d-pad is quite extreme, and the biggest saves made by the test agents are 8 times smaller than the limit. If someone ever gets there, I could compress the save or warn the player before the limit.&lt;/p&gt;&#xA;&lt;h2 id=&#34;machines-recipes-and-ratios&#34;&gt;Machines, recipes and ratios&lt;/h2&gt;&#xA;&lt;p&gt;With the engine in place, the real game design could start, and it&amp;rsquo;s probably the part I enjoyed the most. I wrote down the machines, the recipes and their ratios. How many seconds for an iron plate, how many furnaces to feed one assembler, how much coal a steam engine burns&amp;hellip; Then came the tiers:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;electricity, with generators, steam engines, solar panels, batteries and finally a nuclear reactor&lt;/li&gt;&#xA;&lt;li&gt;oil, with pumpjacks, pipes, refineries, plastic, sulfur and rocket fuel&lt;/li&gt;&#xA;&lt;li&gt;science packs, labs and a research tree of 16 technologies, with recipes locked behind research&lt;/li&gt;&#xA;&lt;li&gt;trains (more on them below)&lt;/li&gt;&#xA;&lt;li&gt;logistic drones that carry items between chests&lt;/li&gt;&#xA;&lt;li&gt;a rocket silo, around 50 objectives to guide you from the first wooden log to space, and an infinite research to keep going after the rocket&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/pair-furnace-craft.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/pair-furnace-craft.png&#34; alt=&#34;Furnace panel and crafting menu&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;On the left the panel of a furnace, on the right the crafting menu.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;power-grids-and-pipes&#34;&gt;Power grids and pipes&lt;/h2&gt;&#xA;&lt;p&gt;Electricity and fluids can get very expensive in a factory game. If you simulate the current going through every cable, or the water moving from one pipe to the next, a 67 MHz CPU gives up quickly. So for these the game doesn&amp;rsquo;t think in buildings, it thinks in &lt;strong&gt;networks&lt;/strong&gt;.&lt;/p&gt;&#xA;&lt;p&gt;For electricity the rules are quite simple. Two power nodes (poles, generators, solar panels and batteries) within 7 tiles of each other are linked automatically, there is no cable to pull. A pole powers the machines up to 3 tiles around it, and a generator up to 6. Twice per second each network compares what it produces with what its machines need, and the batteries charge or discharge the difference. If there isn&amp;rsquo;t enough power, every machine in the network slows down by the same amount. At 70%, everything runs at 70% speed, like in Factorio.&lt;/p&gt;&#xA;&lt;p&gt;The expensive part is figuring out who is connected to whom, so it only runs when the factory changes. The nodes are sorted by x, so each one is only compared with the next ones up to 7 columns away. The groups are merged with a union-find, a classic little algorithm where each group points to a &amp;ldquo;leader&amp;rdquo; and merging two groups just means changing one pointer. The cables you see on screen are only the links that joined two groups, so you don&amp;rsquo;t get a spaghetti of wires. Step by step:&lt;/p&gt;&#xA;&#xA;&#xA;&#xA;&lt;figure class=&#34;fdsn&#34; id=&#34;fdsn-915367482&#34; data-mode=&#34;power&#34; style=&#34;margin:1.5em 0&#34;&gt;&#xA;  &lt;div class=&#34;fdsn-nav&#34; style=&#34;display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:6px&#34;&gt;&lt;/div&gt;&#xA;  &lt;div class=&#34;fdsn-step&#34; style=&#34;font-size:.9em;line-height:1.5;margin-bottom:8px&#34;&gt;&lt;/div&gt;&#xA;  &lt;canvas width=&#34;384&#34; height=&#34;160&#34; style=&#34;width:100%;max-width:768px;display:block;margin:0 auto;image-rendering:pixelated;image-rendering:crisp-edges;border-radius:8px;background:#1d1b24;border:6px solid #1d1b24;box-sizing:border-box;cursor:pointer&#34;&gt;&lt;/canvas&gt;&#xA;  &lt;div class=&#34;fdsn-panel&#34; style=&#34;font-size:.85em;margin-top:8px;line-height:1.6&#34;&gt;&lt;/div&gt;&#xA;  &lt;figcaption style=&#34;text-align:center;font-style:italic;margin-top:8px;font-size:0.9em;color:#666&#34;&gt;The same rules and numbers as in the game, replayed in your browser. Click the picture or a number to go step by step. The colored bar under a machine is its speed.&lt;/figcaption&gt;&#xA;&lt;/figure&gt;&#xA;&lt;script&gt;&#xA;(function () {&#xA;  var root = document.getElementById(&#34;fdsn-915367482&#34;);&#xA;  var MODE = root.getAttribute(&#34;data-mode&#34;);&#xA;  var cv = root.querySelector(&#34;canvas&#34;), g = cv.getContext(&#34;2d&#34;);&#xA;  var stepEl = root.querySelector(&#34;.fdsn-step&#34;), panel = root.querySelector(&#34;.fdsn-panel&#34;);&#xA;  var TS = 16, GW = 24, GH = 10;&#xA;  var SP = { pole: 3, gen0: 4, solar0: 7, solar1: 8, efur0: 13, asm0: 16, tank: 19, wpump: 20, pipe0: 21 };&#xA;  var sheet = new Image(); sheet.src = &#34;/img/factory-ds/sprites.png&#34;;&#xA;  var COLS = [&#34;#2a78d6&#34;, &#34;#eb6834&#34;, &#34;#1baf7a&#34;, &#34;#eda100&#34;, &#34;#e87ba4&#34;, &#34;#4a3aa7&#34;];&#xA;  &#xA;  var LINK = 7, RADIUS = { pole: 3, gen: 6 }, USE = { efur: 5, asm: 3 }, CAP = { pipe: 20, tank: 1000, wpump: 20 };&#xA;  function hash(x, y) { var h = (x * 374761393 + y * 668265263) &gt;&gt;&gt; 0; h = Math.imul(h ^ (h &gt;&gt;&gt; 13), 1274126177) &gt;&gt;&gt; 0; return (h ^ (h &gt;&gt;&gt; 16)) &gt;&gt;&gt; 0; }&#xA;  function rgba(hex, a) { var n = parseInt(hex.slice(1), 16); return &#34;rgba(&#34; + (n &gt;&gt; 16) + &#34;,&#34; + ((n &gt;&gt; 8) &amp; 255) + &#34;,&#34; + (n &amp; 255) + &#34;,&#34; + a + &#34;)&#34;; }&#xA;  function spr(i, x, y) { g.drawImage(sheet, i * 16, 0, 16, 16, x, y, 16, 16); }&#xA;  function ease(u) { u = Math.max(0, Math.min(1, u)); return u * u * (3 - 2 * u); }&#xA;  function ground(night, water) {&#xA;    for (var y = 0; y &lt; GH; y++) for (var x = 0; x &lt; GW; x++) { var h = hash(x, y) % 64; spr(h === 0 ? 2 : h &lt; 14 ? 1 : 0, x * TS, y * TS); }&#xA;    if (water) { g.fillStyle = &#34;#3b7fd9&#34;; g.fillRect(0, 0, TS, GH * TS); }&#xA;    if (night) { g.fillStyle = &#34;rgba(10,20,60,.5)&#34;; g.fillRect(0, 0, cv.width, cv.height); }&#xA;  }&#xA;  function label(txt, x, y, col) {&#xA;    g.font = &#34;bold 8px monospace&#34;; g.textAlign = &#34;center&#34;; g.textBaseline = &#34;middle&#34;;&#xA;    var w = g.measureText(txt).width + 4;&#xA;    g.fillStyle = col || &#34;#1d1b24&#34;; g.fillRect(Math.round(x - w / 2), y - 5, Math.ceil(w), 10);&#xA;    g.fillStyle = &#34;#fff&#34;; g.fillText(txt, x, y + .5);&#xA;  }&#xA;  function dot(col) { return &#39;&lt;span style=&#34;display:inline-block;width:10px;height:10px;border-radius:2px;background:&#39; + col + &#39;;margin-right:6px;vertical-align:middle&#34;&gt;&lt;/span&gt;&#39;; }&#xA;  function bar(col, v) { return &#39;&lt;span style=&#34;display:inline-block;vertical-align:middle;width:90px;height:8px;background:#ddd;border-radius:4px;overflow:hidden;margin:0 6px&#34;&gt;&lt;span style=&#34;display:block;height:100%;width:&#39; + Math.max(0, Math.min(100, v)) + &#39;%;background:&#39; + col + &#39;&#34;&gt;&lt;/span&gt;&lt;/span&gt;&#39;; }&#xA;  var lastStep = -1, lastPanel = &#34;&#34;;&#xA;  function setStep(i, n, title, text) {&#xA;    if (i === lastStep) return; lastStep = i;&#xA;    stepEl.innerHTML = &#39;&lt;b&gt;Step &#39; + (i + 1) + &#39;/&#39; + n + &#39;: &#39; + title + &#39;&lt;/b&gt;&lt;br&gt;&#39; + text;&#xA;  }&#xA;  function setPanel(h) { if (h !== lastPanel) { panel.innerHTML = h; lastPanel = h; } }&#xA;&#xA;  &#xA;  function powerScene() {&#xA;    var B = [&#xA;      { x: 2, y: 4, t: &#34;gen&#34; }, { x: 6, y: 4, t: &#34;pole&#34; }, { x: 12, y: 4, t: &#34;pole&#34; },&#xA;      { x: 20, y: 2, t: &#34;solar&#34; }, { x: 21, y: 5, t: &#34;pole&#34; }, { x: 22, y: 2, t: &#34;solar&#34; },&#xA;      { x: 5, y: 2, t: &#34;efur&#34; }, { x: 7, y: 2, t: &#34;efur&#34; }, { x: 5, y: 6, t: &#34;efur&#34; }, { x: 7, y: 6, t: &#34;efur&#34; },&#xA;      { x: 11, y: 2, t: &#34;asm&#34; }, { x: 13, y: 2, t: &#34;asm&#34; }, { x: 12, y: 6, t: &#34;asm&#34; },&#xA;      { x: 20, y: 7, t: &#34;asm&#34; }, { x: 22, y: 7, t: &#34;asm&#34; }&#xA;    ];&#xA;    var nodes = B.filter(function (b) { return b.t === &#34;gen&#34; || b.t === &#34;pole&#34; || b.t === &#34;solar&#34;; }).sort(function (a, b) { return a.x - b.x; });&#xA;    var machines = B.filter(function (b) { return USE[b.t]; });&#xA;    &#xA;    var uf = nodes.map(function (_, i) { return i; });&#xA;    function find(a) { while (uf[a] !== a) a = uf[a]; return a; }&#xA;    var events = [];&#xA;    for (var i = 0; i &lt; nodes.length; i++)&#xA;      for (var j = i + 1; j &lt; nodes.length &amp;&amp; nodes[j].x - nodes[i].x &lt;= LINK; j++) {&#xA;        if (Math.abs(nodes[j].y - nodes[i].y) &gt; LINK) continue;&#xA;        var a = find(i), c = find(j), m = a !== c; if (m) uf[a] = c;&#xA;        events.push({ i: i, j: j, merged: m });&#xA;      }&#xA;    var root = nodes.map(function (_, k) { return find(k); }), netId = {}, nn = 0;&#xA;    root.forEach(function (r) { if (netId[r] === undefined) netId[r] = nn++; });&#xA;    var nodeNet = root.map(function (r) { return netId[r]; });&#xA;    var cover = [], mNet = machines.map(function () { return -1; });&#xA;    nodes.forEach(function (n, k) {&#xA;      var r = RADIUS[n.t]; if (!r) return;&#xA;      var got = [];&#xA;      machines.forEach(function (m, q) { if (mNet[q] &lt; 0 &amp;&amp; Math.abs(m.x - n.x) &lt;= r &amp;&amp; Math.abs(m.y - n.y) &lt;= r) { mNet[q] = nodeNet[k]; got.push(q); } });&#xA;      cover.push({ k: k, got: got });&#xA;    });&#xA;    function balance(night) {&#xA;      var nets = [];&#xA;      for (var q = 0; q &lt; nn; q++) nets.push({ prod: 0, cons: 0 });&#xA;      nodes.forEach(function (n, k) { if (n.t === &#34;gen&#34;) nets[nodeNet[k]].prod += 20; if (n.t === &#34;solar&#34; &amp;&amp; !night) nets[nodeNet[k]].prod += 4; });&#xA;      machines.forEach(function (m, q) { if (mNet[q] &gt;= 0) nets[mNet[q]].cons += USE[m.t]; });&#xA;      nets.forEach(function (n) { n.sat = n.cons ? Math.min(100, Math.floor(n.prod * 100 / n.cons)) : 100; });&#xA;      return nets;&#xA;    }&#xA;    var T_SORT = 3.5, T_EV = 1.1, T_COV = 1.6;&#xA;    var STEPS = [&#xA;      { d: 3, title: &#34;the factory&#34;, text: &#34;A generator, some poles, two solar panels and machines. Nothing is connected yet: the game has to find out who powers what.&#34; },&#xA;      { d: T_SORT, title: &#34;sort the power nodes by x&#34;, text: &#34;Generators, poles and solar panels are sorted from left to right. Now each node only has to look at the next ones, until it finds one more than 7 columns away.&#34; },&#xA;      { d: events.length * T_EV + 1, title: &#34;link nodes at 7 tiles or less&#34;, text: &#34;Each node looks to its right, in a window of 7 tiles. When two nodes are close, their groups are merged (union-find). A cable is drawn only when two groups really join, so there is never a spaghetti of wires. Solar panels are linked without a visible cable.&#34; },&#xA;      { d: cover.length * T_COV + 1, title: &#34;who powers what&#34;, text: &#34;A pole powers the machines up to 3 tiles around it, a generator up to 6. Each machine takes the first network that reaches it.&#34; },&#xA;      { d: 4.5, title: &#34;the balance, twice per second&#34;, text: &#34;Each network adds what it produces and what its machines need. If it&#39;s short, all its machines slow down by the same amount: here, 20 for 29 means 68% speed.&#34; },&#xA;      { d: 4.5, title: &#34;night falls&#34;, text: &#34;The solar panels stop producing. Their network has no power left and its machines stop, while the generator&#39;s network keeps going.&#34; }&#xA;    ];&#xA;    var TOTAL = STEPS.reduce(function (s, x) { return s + x.d; }, 0);&#xA;    return { steps: STEPS, render: function (si, t, time) {&#xA;      var night = si === 5 &amp;&amp; t &gt; 0.6;&#xA;      ground(night);&#xA;      var shownEv = si &lt; 2 ? 0 : si &gt; 2 ? events.length : Math.min(events.length, Math.floor(t / T_EV));&#xA;      var shownCov = si &lt; 3 ? 0 : si &gt; 3 ? cover.length : Math.min(cover.length, Math.floor(t / T_COV) + 1);&#xA;      var uf2 = nodes.map(function (_, k) { return k; });&#xA;      function f2(a) { while (uf2[a] !== a) a = uf2[a]; return a; }&#xA;      for (var e = 0; e &lt; shownEv; e++) if (events[e].merged) uf2[f2(events[e].i)] = f2(events[e].j);&#xA;      var gcol = nodes.map(function (n, k) {&#xA;        var r = f2(k), size = 0; for (var q = 0; q &lt; nodes.length; q++) if (f2(q) === r) size++;&#xA;        return size &gt; 1 || si &gt; 2 ? COLS[nodeNet[k] % COLS.length] : null;&#xA;      });&#xA;      &#xA;      for (var cc = 0; cc &lt; shownCov; cc++) {&#xA;        var n = nodes[cover[cc].k], r = RADIUS[n.t], grow = cc === shownCov - 1 &amp;&amp; si === 3 ? ease((t - cc * T_COV) / 0.8) : 1, rr = r * grow, col = COLS[nodeNet[cover[cc].k] % COLS.length];&#xA;        g.fillStyle = rgba(col, .12); g.fillRect((n.x - rr) * TS, (n.y - rr) * TS, (2 * rr + 1) * TS, (2 * rr + 1) * TS);&#xA;        g.strokeStyle = rgba(col, .8); g.lineWidth = 1; g.strokeRect((n.x - rr) * TS + .5, (n.y - rr) * TS + .5, (2 * rr + 1) * TS - 1, (2 * rr + 1) * TS - 1);&#xA;      }&#xA;      var nets = balance(night), covered = {};&#xA;      for (cc = 0; cc &lt; shownCov; cc++) cover[cc].got.forEach(function (q) { covered[q] = 1; });&#xA;      &#xA;      machines.forEach(function (m, q) {&#xA;        var on = si &gt;= 4 &amp;&amp; mNet[q] &gt;= 0, sat = on ? nets[mNet[q]].sat : 0, fr = sat &gt; 0 ? 1 + (Math.floor(time * 8 * sat / 100) &amp; 1) : 0;&#xA;        spr(SP[m.t + &#34;0&#34;] + fr, m.x * TS, m.y * TS);&#xA;        if (covered[q]) { g.fillStyle = COLS[mNet[q] % COLS.length]; g.fillRect(m.x * TS + 1, m.y * TS + 14, si &gt;= 4 ? Math.max(1, Math.round(14 * sat / 100)) : 14, 2); }&#xA;        if (si &gt;= 4 &amp;&amp; sat === 0) { g.fillStyle = &#34;#e34948&#34;; g.fillRect(m.x * TS + 11, m.y * TS + 1, 4, 4); }&#xA;      });&#xA;      &#xA;      for (e = 0; e &lt; shownEv; e++) {&#xA;        var ev = events[e]; if (!ev.merged) continue;&#xA;        var A = nodes[ev.i], C = nodes[ev.j], wired = A.t !== &#34;solar&#34; &amp;&amp; C.t !== &#34;solar&#34;;&#xA;        g.strokeStyle = wired ? &#34;#3a2a22&#34; : rgba(gcol[ev.i] || &#34;#ffffff&#34;, .9); g.lineWidth = 1;&#xA;        if (!wired) g.setLineDash([2, 2]);&#xA;        g.beginPath(); g.moveTo(A.x * TS + 8, A.y * TS + 3); g.quadraticCurveTo((A.x + C.x) * TS / 2 + 8, (A.y + C.y) * TS / 2 + 9, C.x * TS + 8, C.y * TS + 3); g.stroke();&#xA;        g.setLineDash([]);&#xA;      }&#xA;      &#xA;      nodes.forEach(function (n, k) {&#xA;        spr(n.t === &#34;solar&#34; ? (night ? SP.solar0 : SP.solar1) : n.t === &#34;gen&#34; ? SP.gen0 + (si &gt;= 4 ? 1 + (Math.floor(time * 4) &amp; 1) : 0) : SP.pole, n.x * TS, n.y * TS);&#xA;        if (gcol[k]) { g.strokeStyle = gcol[k]; g.lineWidth = 2; g.strokeRect(n.x * TS - 1, n.y * TS - 1, 18, 18); }&#xA;      });&#xA;      &#xA;      if (si === 1 || si === 2) {&#xA;        var sx = si === 1 ? ease(t / (T_SORT - .5)) * GW * TS : 1e9;&#xA;        nodes.forEach(function (n, k) { if (n.x * TS + 8 &lt;= sx) label(String(k + 1), n.x * TS + 8, n.y * TS - 4); });&#xA;        if (si === 1) { g.strokeStyle = &#34;rgba(255,255,255,.8)&#34;; g.lineWidth = 1; g.setLineDash([3, 3]); g.beginPath(); g.moveTo(sx, 0); g.lineTo(sx, GH * TS); g.stroke(); g.setLineDash([]); }&#xA;      }&#xA;      &#xA;      if (si === 2 &amp;&amp; shownEv &lt; events.length) {&#xA;        var cur = events[shownEv], N = nodes[cur.i], M = nodes[cur.j], u = (t - shownEv * T_EV) / T_EV;&#xA;        g.strokeStyle = &#34;rgba(255,255,255,.95)&#34;; g.lineWidth = 1; g.setLineDash([3, 2]);&#xA;        g.strokeRect(N.x * TS + .5, Math.max(0, N.y - LINK) * TS + .5, (LINK + 1) * TS - 1, (Math.min(GH, N.y + LINK + 1) - Math.max(0, N.y - LINK)) * TS - 1); g.setLineDash([]);&#xA;        if (u &gt; .35) {&#xA;          g.strokeStyle = cur.merged ? &#34;#ffffff&#34; : &#34;rgba(255,255,255,.6)&#34;; g.setLineDash(cur.merged ? [] : [1, 3]);&#xA;          g.beginPath(); g.moveTo(N.x * TS + 8, N.y * TS + 8); g.lineTo(M.x * TS + 8, M.y * TS + 8); g.stroke(); g.setLineDash([]);&#xA;          label(cur.merged ? &#34;merge&#34; : &#34;same group&#34;, (N.x + M.x) * TS / 2 + 8, (N.y + M.y) * TS / 2 + 8, cur.merged ? &#34;#1baf7a&#34; : &#34;#52514e&#34;);&#xA;        }&#xA;      }&#xA;      var h = &#34;&#34;;&#xA;      if (si &gt;= 4) nets.forEach(function (n, q) { h += &#34;&lt;div&gt;&#34; + dot(COLS[q % COLS.length]) + &#34;&lt;b&gt;Network &#34; + (q + 1) + &#34;&lt;/b&gt;: produces &#34; + n.prod + &#34;, needs &#34; + n.cons + bar(COLS[q % COLS.length], n.sat) + &#34;&lt;b&gt;&#34; + n.sat + &#34;%&lt;/b&gt; speed&lt;/div&gt;&#34;; });&#xA;      else if (si &gt;= 2) { var groups = {}; for (var k = 0; k &lt; nodes.length; k++) groups[f2(k)] = 1; var ng = Object.keys(groups).length; h = &#34;&lt;div&gt;&#34; + nodes.length + &#34; nodes, &#34; + ng + &#34; group&#34; + (ng &gt; 1 ? &#34;s&#34; : &#34;&#34;) + &#34;&lt;/div&gt;&#34;; }&#xA;      setPanel(h);&#xA;    } };&#xA;  }&#xA;&#xA;  &#xA;  function fluidScene() {&#xA;    var P = [{ x: 1, y: 4, t: &#34;wpump&#34; }];&#xA;    for (var x = 2; x &lt;= 9; x++) P.push({ x: x, y: 4, t: &#34;pipe&#34; });&#xA;    P.push({ x: 10, y: 4, t: &#34;tank&#34; }); P.push({ x: 11, y: 4, t: &#34;pipe&#34; }); P.push({ x: 12, y: 4, t: &#34;pipe&#34; });&#xA;    for (x = 14; x &lt;= 19; x++) P.push({ x: x, y: 4, t: &#34;pipe&#34; });&#xA;    P.push({ x: 20, y: 4, t: &#34;tank&#34; });&#xA;    for (var y = 5; y &lt;= 7; y++) P.push({ x: 17, y: y, t: &#34;pipe&#34; });&#xA;    P.push({ x: 17, y: 8, t: &#34;tank&#34; });&#xA;    var GAP = { x: 13, y: 4, t: &#34;pipe&#34; }, CUT = { x: 6, y: 4 };&#xA;    function capOf(list) { return list.reduce(function (s, p) { return s + CAP[p.t]; }, 0); }&#xA;    var left = P.filter(function (p) { return p.x &lt;= 12 &amp;&amp; p.y === 4; }), right = P.filter(function (p) { return left.indexOf(p) &lt; 0; });&#xA;    var capL = capOf(left), capR = capOf(right), capAll = capL + capR + CAP.pipe;&#xA;    var FILL = 0.8 * capL;&#xA;    var STEPS = [&#xA;      { d: 3, title: &#34;pipes, tanks and a pump&#34;, text: &#34;Two separate groups of pipes and tanks. In the game, water never flows from one pipe to the next: that would cost far too much.&#34; },&#xA;      { d: 3.5, title: &#34;find the networks&#34;, text: &#34;Each piece is merged with its right and bottom neighbors (union-find). Every connected group becomes one network, and a network is one single tank: its capacity is the sum of its pieces.&#34; },&#xA;      { d: 4, title: &#34;the pump fills its network&#34;, text: &#34;The pump adds 2 units per frame to the amount of its network. There is one number per network, so every piece shows the same level.&#34; },&#xA;      { d: 5, title: &#34;close the gap&#34;, text: &#34;When a pipe is placed, each piece first gets back its share of its old network. Then the networks are rebuilt and their shares are added up: one bigger tank, one shared level. Nothing is created.&#34; },&#xA;      { d: 5, title: &#34;cut a pipe&#34;, text: &#34;The same thing the other way: each piece takes its share in proportion to its capacity, then two networks are rebuilt. Each side keeps the same level, and only the water of the removed pipe is lost.&#34; }&#xA;    ];&#xA;    var TOTAL = STEPS.reduce(function (s, x) { return s + x.d; }, 0);&#xA;    function pipeMask(p, set) {&#xA;      var m = 0;&#xA;      [[1, 0], [0, 1], [-1, 0], [0, -1]].forEach(function (d, k) { if (set.some(function (q) { return q.x === p.x + d[0] &amp;&amp; q.y === p.y + d[1]; })) m |= 1 &lt;&lt; k; });&#xA;      return m;&#xA;    }&#xA;    return { steps: STEPS, render: function (si, t, time) {&#xA;      ground(false, true);&#xA;      var set = P.slice(), nets;&#xA;      if (si &lt;= 1) nets = [{ list: left, amount: 0, cap: capL }, { list: right, amount: 0, cap: capR }];&#xA;      else if (si === 2) nets = [{ list: left, amount: FILL * ease(t / 3.5), cap: capL }, { list: right, amount: 0, cap: capR }];&#xA;      else if (si === 3) {&#xA;        if (t &lt; 1) nets = [{ list: left, amount: FILL, cap: capL }, { list: right, amount: 0, cap: capR }];&#xA;        else { set.push(GAP); nets = [{ list: left.concat([GAP], right), amount: FILL, cap: capAll, from: ease((t - 1) / 2) }]; }&#xA;      } else {&#xA;        set.push(GAP);&#xA;        var lvl = FILL / capAll;&#xA;        if (t &lt; 1.2) nets = [{ list: left.concat([GAP], right), amount: FILL, cap: capAll }];&#xA;        else {&#xA;          set = set.filter(function (p) { return !(p.x === CUT.x &amp;&amp; p.y === CUT.y); });&#xA;          var a = set.filter(function (p) { return p.y === 4 &amp;&amp; p.x &lt; CUT.x; }), b = set.filter(function (p) { return a.indexOf(p) &lt; 0; });&#xA;          nets = [{ list: a, amount: lvl * capOf(a), cap: capOf(a) }, { list: b, amount: lvl * capOf(b), cap: capOf(b) }];&#xA;        }&#xA;      }&#xA;      var reveal = si === 1 ? ease(t / 2.5) * (GW + 2) : 1e9;&#xA;      set.forEach(function (p) {&#xA;        var k = -1; nets.forEach(function (n, q) { if (n.list.indexOf(p) &gt;= 0) k = q; });&#xA;        if (p.t === &#34;pipe&#34;) spr(SP.pipe0 + pipeMask(p, set), p.x * TS, p.y * TS); else spr(SP[p.t], p.x * TS, p.y * TS);&#xA;        if (k &lt; 0 || si &lt; 1 || p.x &gt; reveal) return;&#xA;        var n = nets[k], col = COLS[k % COLS.length], lv = n.cap ? n.amount / n.cap : 0;&#xA;        if (n.from !== undefined &amp;&amp; n.from &lt; 1) { var before = left.indexOf(p) &gt;= 0 ? FILL / capL : 0; lv = before + (lv - before) * n.from; }&#xA;        g.strokeStyle = col; g.lineWidth = 1; g.strokeRect(p.x * TS + .5, p.y * TS + .5, 15, 15);&#xA;        g.fillStyle = &#34;rgba(20,30,40,.8)&#34;; g.fillRect(p.x * TS + 3, p.y * TS + 12, 10, 3);&#xA;        g.fillStyle = &#34;#3b9cf0&#34;; g.fillRect(p.x * TS + 3, p.y * TS + 12, Math.round(10 * lv), 3);&#xA;      });&#xA;      if (si === 1 &amp;&amp; reveal &lt; GW) { g.strokeStyle = &#34;rgba(255,255,255,.8)&#34;; g.lineWidth = 1; g.setLineDash([3, 3]); g.beginPath(); g.moveTo(reveal * TS, 0); g.lineTo(reveal * TS, GH * TS); g.stroke(); g.setLineDash([]); }&#xA;      if (si === 3 &amp;&amp; t &gt;= 1 &amp;&amp; t &lt; 2.5) { g.strokeStyle = &#34;#fff&#34;; g.lineWidth = 2; g.strokeRect(GAP.x * TS - 1, GAP.y * TS - 1, 18, 18); label(&#34;+ pipe&#34;, GAP.x * TS + 8, GAP.y * TS - 7, &#34;#1baf7a&#34;); }&#xA;      if (si === 4 &amp;&amp; t &gt;= 1.2 &amp;&amp; t &lt; 2.8) { g.strokeStyle = &#34;#e34948&#34;; g.lineWidth = 2; g.strokeRect(CUT.x * TS - 1, CUT.y * TS - 1, 18, 18); label(&#34;cut&#34;, CUT.x * TS + 8, CUT.y * TS - 7, &#34;#e34948&#34;); }&#xA;      var h = &#34;&#34;;&#xA;      if (si &gt;= 1) nets.forEach(function (n, q) { h += &#34;&lt;div&gt;&#34; + dot(COLS[q % COLS.length]) + &#34;&lt;b&gt;Network &#34; + (q + 1) + &#34;&lt;/b&gt;: &#34; + n.list.length + &#34; pieces, &#34; + Math.round(n.amount) + &#34; / &#34; + n.cap + &#34; water&#34; + bar(COLS[q % COLS.length], n.cap ? n.amount * 100 / n.cap : 0) + &#34;&lt;/div&gt;&#34;; });&#xA;      setPanel(h);&#xA;    } };&#xA;  }&#xA;&#xA;  &#xA;  var scene = MODE === &#34;power&#34; ? powerScene() : fluidScene(), STEPS = scene.steps, N = STEPS.length;&#xA;  var TOTAL = STEPS.reduce(function (s, x) { return s + x.d; }, 0), now = 0, auto = true, cur = 0, curStart = 0, t0 = null;&#xA;  var nav = root.querySelector(&#34;.fdsn-nav&#34;), navBtns = [];&#xA;  function mk(label, fn) {&#xA;    var b = document.createElement(&#34;button&#34;); b.type = &#34;button&#34;; b.textContent = label;&#xA;    b.style.cssText = &#34;font:inherit;font-size:.8em;min-width:2.2em;padding:3px 8px;border-radius:6px;border:1px solid #bbb;background:#f6f5f2;color:#222;cursor:pointer&#34;;&#xA;    b.onclick = fn; nav.appendChild(b); return b;&#xA;  }&#xA;  function go(i) { auto = false; cur = (i + N) % N; curStart = now; }&#xA;  mk(&#34;\u25C0&#34;, function () { go(cur - 1); });&#xA;  for (var k = 0; k &lt; N; k++) (function (k) { navBtns.push(mk(String(k + 1), function () { go(k); })); })(k);&#xA;  mk(&#34;\u25B6&#34;, function () { go(cur + 1); });&#xA;  var playBtn = mk(&#34;Play&#34;, function () { if (!auto) { auto = true; t0 = null; } });&#xA;  cv.addEventListener(&#34;click&#34;, function () { go(cur + 1); });&#xA;  var lastNav = &#34;&#34;;&#xA;  function paintNav() {&#xA;    var key = cur + (auto ? &#34;a&#34; : &#34;m&#34;); if (key === lastNav) return; lastNav = key;&#xA;    navBtns.forEach(function (b, k) { var on = k === cur; b.style.background = on ? &#34;#2a78d6&#34; : &#34;#f6f5f2&#34;; b.style.color = on ? &#34;#fff&#34; : &#34;#222&#34;; b.style.borderColor = on ? &#34;#2a78d6&#34; : &#34;#bbb&#34;; });&#xA;    playBtn.textContent = auto ? &#34;Pause&#34; : &#34;Play&#34;;&#xA;    playBtn.onclick = auto ? function () { go(cur); } : function () { auto = true; t0 = null; };&#xA;  }&#xA;  function loop(ts) {&#xA;    now = ts / 1000; g.imageSmoothingEnabled = false;&#xA;    var si, t;&#xA;    if (auto) {&#xA;      if (t0 === null) t0 = now - STEPS.slice(0, cur).reduce(function (s, x) { return s + x.d; }, 0);&#xA;      t = (now - t0) % TOTAL; si = 0;&#xA;      while (t &gt;= STEPS[si].d) { t -= STEPS[si].d; si++; }&#xA;      cur = si;&#xA;    } else { si = cur; t = Math.min(now - curStart, STEPS[si].d - 0.001); }&#xA;    setStep(si, N, STEPS[si].title, STEPS[si].text); paintNav();&#xA;    scene.render(si, t, now);&#xA;    requestAnimationFrame(loop);&#xA;  }&#xA;  &#xA;  function lockHeights() {&#xA;    var keep = stepEl.innerHTML, h = 0;&#xA;    stepEl.style.minHeight = &#34;0&#34;;&#xA;    for (var k = 0; k &lt; N; k++) {&#xA;      stepEl.innerHTML = &#39;&lt;b&gt;Step &#39; + (k + 1) + &#39;/&#39; + N + &#39;: &#39; + STEPS[k].title + &#39;&lt;/b&gt;&lt;br&gt;&#39; + STEPS[k].text;&#xA;      h = Math.max(h, stepEl.offsetHeight);&#xA;    }&#xA;    stepEl.innerHTML = keep; stepEl.style.minHeight = h + &#34;px&#34;;&#xA;    var row = MODE === &#34;power&#34;                                         &#xA;      ? &#34;&lt;div&gt;&#34; + dot(&#34;#000&#34;) + &#34;&lt;b&gt;Network 1&lt;/b&gt;: produces 20, needs 29&#34; + bar(&#34;#000&#34;, 50) + &#34;&lt;b&gt;100%&lt;/b&gt; speed&lt;/div&gt;&#34;&#xA;      : &#34;&lt;div&gt;&#34; + dot(&#34;#000&#34;) + &#34;&lt;b&gt;Network 1&lt;/b&gt;: 18 pieces, 3300 / 3420 water&#34; + bar(&#34;#000&#34;, 50) + &#34;&lt;/div&gt;&#34;;&#xA;    var two = row + row, keepP = panel.innerHTML;&#xA;    panel.style.minHeight = &#34;0&#34;; panel.innerHTML = two; var hp = panel.offsetHeight; panel.innerHTML = keepP;&#xA;    panel.style.minHeight = hp + &#34;px&#34;;&#xA;  }&#xA;  lockHeights();&#xA;  window.addEventListener(&#34;resize&#34;, lockHeights);&#xA;  var started = false;&#xA;  function start() { if (!started) { started = true; requestAnimationFrame(loop); } }&#xA;  sheet.onload = start;&#xA;  if (sheet.complete) start();&#xA;})();&#xA;&lt;/script&gt;&#xA;&#xA;&lt;p&gt;Fluids work the same way. A pipe network is &lt;strong&gt;one single tank&lt;/strong&gt;. Its capacity is the sum of all its pipes and tanks, and it holds one amount of fluid. A pump adds to it, a machine takes from it, and nothing actually flows from one pipe to the next. When you cut or connect a pipe, each piece first gets back its share of the old network (in proportion to its capacity), then the networks are rebuilt and their shares added up. Only the fluid that was inside the removed pipe is lost, and as long as the pipes don&amp;rsquo;t change it costs nothing. Here is what happens when you connect and cut pipes:&lt;/p&gt;&#xA;&#xA;&#xA;&#xA;&lt;figure class=&#34;fdsn&#34; id=&#34;fdsn-196324587&#34; data-mode=&#34;fluid&#34; style=&#34;margin:1.5em 0&#34;&gt;&#xA;  &lt;div class=&#34;fdsn-nav&#34; style=&#34;display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:6px&#34;&gt;&lt;/div&gt;&#xA;  &lt;div class=&#34;fdsn-step&#34; style=&#34;font-size:.9em;line-height:1.5;margin-bottom:8px&#34;&gt;&lt;/div&gt;&#xA;  &lt;canvas width=&#34;384&#34; height=&#34;160&#34; style=&#34;width:100%;max-width:768px;display:block;margin:0 auto;image-rendering:pixelated;image-rendering:crisp-edges;border-radius:8px;background:#1d1b24;border:6px solid #1d1b24;box-sizing:border-box;cursor:pointer&#34;&gt;&lt;/canvas&gt;&#xA;  &lt;div class=&#34;fdsn-panel&#34; style=&#34;font-size:.85em;margin-top:8px;line-height:1.6&#34;&gt;&lt;/div&gt;&#xA;  &lt;figcaption style=&#34;text-align:center;font-style:italic;margin-top:8px;font-size:0.9em;color:#666&#34;&gt;One number per network instead of one per pipe. Click to go step by step.&lt;/figcaption&gt;&#xA;&lt;/figure&gt;&#xA;&lt;script&gt;&#xA;(function () {&#xA;  var root = document.getElementById(&#34;fdsn-196324587&#34;);&#xA;  var MODE = root.getAttribute(&#34;data-mode&#34;);&#xA;  var cv = root.querySelector(&#34;canvas&#34;), g = cv.getContext(&#34;2d&#34;);&#xA;  var stepEl = root.querySelector(&#34;.fdsn-step&#34;), panel = root.querySelector(&#34;.fdsn-panel&#34;);&#xA;  var TS = 16, GW = 24, GH = 10;&#xA;  var SP = { pole: 3, gen0: 4, solar0: 7, solar1: 8, efur0: 13, asm0: 16, tank: 19, wpump: 20, pipe0: 21 };&#xA;  var sheet = new Image(); sheet.src = &#34;/img/factory-ds/sprites.png&#34;;&#xA;  var COLS = [&#34;#2a78d6&#34;, &#34;#eb6834&#34;, &#34;#1baf7a&#34;, &#34;#eda100&#34;, &#34;#e87ba4&#34;, &#34;#4a3aa7&#34;];&#xA;  &#xA;  var LINK = 7, RADIUS = { pole: 3, gen: 6 }, USE = { efur: 5, asm: 3 }, CAP = { pipe: 20, tank: 1000, wpump: 20 };&#xA;  function hash(x, y) { var h = (x * 374761393 + y * 668265263) &gt;&gt;&gt; 0; h = Math.imul(h ^ (h &gt;&gt;&gt; 13), 1274126177) &gt;&gt;&gt; 0; return (h ^ (h &gt;&gt;&gt; 16)) &gt;&gt;&gt; 0; }&#xA;  function rgba(hex, a) { var n = parseInt(hex.slice(1), 16); return &#34;rgba(&#34; + (n &gt;&gt; 16) + &#34;,&#34; + ((n &gt;&gt; 8) &amp; 255) + &#34;,&#34; + (n &amp; 255) + &#34;,&#34; + a + &#34;)&#34;; }&#xA;  function spr(i, x, y) { g.drawImage(sheet, i * 16, 0, 16, 16, x, y, 16, 16); }&#xA;  function ease(u) { u = Math.max(0, Math.min(1, u)); return u * u * (3 - 2 * u); }&#xA;  function ground(night, water) {&#xA;    for (var y = 0; y &lt; GH; y++) for (var x = 0; x &lt; GW; x++) { var h = hash(x, y) % 64; spr(h === 0 ? 2 : h &lt; 14 ? 1 : 0, x * TS, y * TS); }&#xA;    if (water) { g.fillStyle = &#34;#3b7fd9&#34;; g.fillRect(0, 0, TS, GH * TS); }&#xA;    if (night) { g.fillStyle = &#34;rgba(10,20,60,.5)&#34;; g.fillRect(0, 0, cv.width, cv.height); }&#xA;  }&#xA;  function label(txt, x, y, col) {&#xA;    g.font = &#34;bold 8px monospace&#34;; g.textAlign = &#34;center&#34;; g.textBaseline = &#34;middle&#34;;&#xA;    var w = g.measureText(txt).width + 4;&#xA;    g.fillStyle = col || &#34;#1d1b24&#34;; g.fillRect(Math.round(x - w / 2), y - 5, Math.ceil(w), 10);&#xA;    g.fillStyle = &#34;#fff&#34;; g.fillText(txt, x, y + .5);&#xA;  }&#xA;  function dot(col) { return &#39;&lt;span style=&#34;display:inline-block;width:10px;height:10px;border-radius:2px;background:&#39; + col + &#39;;margin-right:6px;vertical-align:middle&#34;&gt;&lt;/span&gt;&#39;; }&#xA;  function bar(col, v) { return &#39;&lt;span style=&#34;display:inline-block;vertical-align:middle;width:90px;height:8px;background:#ddd;border-radius:4px;overflow:hidden;margin:0 6px&#34;&gt;&lt;span style=&#34;display:block;height:100%;width:&#39; + Math.max(0, Math.min(100, v)) + &#39;%;background:&#39; + col + &#39;&#34;&gt;&lt;/span&gt;&lt;/span&gt;&#39;; }&#xA;  var lastStep = -1, lastPanel = &#34;&#34;;&#xA;  function setStep(i, n, title, text) {&#xA;    if (i === lastStep) return; lastStep = i;&#xA;    stepEl.innerHTML = &#39;&lt;b&gt;Step &#39; + (i + 1) + &#39;/&#39; + n + &#39;: &#39; + title + &#39;&lt;/b&gt;&lt;br&gt;&#39; + text;&#xA;  }&#xA;  function setPanel(h) { if (h !== lastPanel) { panel.innerHTML = h; lastPanel = h; } }&#xA;&#xA;  &#xA;  function powerScene() {&#xA;    var B = [&#xA;      { x: 2, y: 4, t: &#34;gen&#34; }, { x: 6, y: 4, t: &#34;pole&#34; }, { x: 12, y: 4, t: &#34;pole&#34; },&#xA;      { x: 20, y: 2, t: &#34;solar&#34; }, { x: 21, y: 5, t: &#34;pole&#34; }, { x: 22, y: 2, t: &#34;solar&#34; },&#xA;      { x: 5, y: 2, t: &#34;efur&#34; }, { x: 7, y: 2, t: &#34;efur&#34; }, { x: 5, y: 6, t: &#34;efur&#34; }, { x: 7, y: 6, t: &#34;efur&#34; },&#xA;      { x: 11, y: 2, t: &#34;asm&#34; }, { x: 13, y: 2, t: &#34;asm&#34; }, { x: 12, y: 6, t: &#34;asm&#34; },&#xA;      { x: 20, y: 7, t: &#34;asm&#34; }, { x: 22, y: 7, t: &#34;asm&#34; }&#xA;    ];&#xA;    var nodes = B.filter(function (b) { return b.t === &#34;gen&#34; || b.t === &#34;pole&#34; || b.t === &#34;solar&#34;; }).sort(function (a, b) { return a.x - b.x; });&#xA;    var machines = B.filter(function (b) { return USE[b.t]; });&#xA;    &#xA;    var uf = nodes.map(function (_, i) { return i; });&#xA;    function find(a) { while (uf[a] !== a) a = uf[a]; return a; }&#xA;    var events = [];&#xA;    for (var i = 0; i &lt; nodes.length; i++)&#xA;      for (var j = i + 1; j &lt; nodes.length &amp;&amp; nodes[j].x - nodes[i].x &lt;= LINK; j++) {&#xA;        if (Math.abs(nodes[j].y - nodes[i].y) &gt; LINK) continue;&#xA;        var a = find(i), c = find(j), m = a !== c; if (m) uf[a] = c;&#xA;        events.push({ i: i, j: j, merged: m });&#xA;      }&#xA;    var root = nodes.map(function (_, k) { return find(k); }), netId = {}, nn = 0;&#xA;    root.forEach(function (r) { if (netId[r] === undefined) netId[r] = nn++; });&#xA;    var nodeNet = root.map(function (r) { return netId[r]; });&#xA;    var cover = [], mNet = machines.map(function () { return -1; });&#xA;    nodes.forEach(function (n, k) {&#xA;      var r = RADIUS[n.t]; if (!r) return;&#xA;      var got = [];&#xA;      machines.forEach(function (m, q) { if (mNet[q] &lt; 0 &amp;&amp; Math.abs(m.x - n.x) &lt;= r &amp;&amp; Math.abs(m.y - n.y) &lt;= r) { mNet[q] = nodeNet[k]; got.push(q); } });&#xA;      cover.push({ k: k, got: got });&#xA;    });&#xA;    function balance(night) {&#xA;      var nets = [];&#xA;      for (var q = 0; q &lt; nn; q++) nets.push({ prod: 0, cons: 0 });&#xA;      nodes.forEach(function (n, k) { if (n.t === &#34;gen&#34;) nets[nodeNet[k]].prod += 20; if (n.t === &#34;solar&#34; &amp;&amp; !night) nets[nodeNet[k]].prod += 4; });&#xA;      machines.forEach(function (m, q) { if (mNet[q] &gt;= 0) nets[mNet[q]].cons += USE[m.t]; });&#xA;      nets.forEach(function (n) { n.sat = n.cons ? Math.min(100, Math.floor(n.prod * 100 / n.cons)) : 100; });&#xA;      return nets;&#xA;    }&#xA;    var T_SORT = 3.5, T_EV = 1.1, T_COV = 1.6;&#xA;    var STEPS = [&#xA;      { d: 3, title: &#34;the factory&#34;, text: &#34;A generator, some poles, two solar panels and machines. Nothing is connected yet: the game has to find out who powers what.&#34; },&#xA;      { d: T_SORT, title: &#34;sort the power nodes by x&#34;, text: &#34;Generators, poles and solar panels are sorted from left to right. Now each node only has to look at the next ones, until it finds one more than 7 columns away.&#34; },&#xA;      { d: events.length * T_EV + 1, title: &#34;link nodes at 7 tiles or less&#34;, text: &#34;Each node looks to its right, in a window of 7 tiles. When two nodes are close, their groups are merged (union-find). A cable is drawn only when two groups really join, so there is never a spaghetti of wires. Solar panels are linked without a visible cable.&#34; },&#xA;      { d: cover.length * T_COV + 1, title: &#34;who powers what&#34;, text: &#34;A pole powers the machines up to 3 tiles around it, a generator up to 6. Each machine takes the first network that reaches it.&#34; },&#xA;      { d: 4.5, title: &#34;the balance, twice per second&#34;, text: &#34;Each network adds what it produces and what its machines need. If it&#39;s short, all its machines slow down by the same amount: here, 20 for 29 means 68% speed.&#34; },&#xA;      { d: 4.5, title: &#34;night falls&#34;, text: &#34;The solar panels stop producing. Their network has no power left and its machines stop, while the generator&#39;s network keeps going.&#34; }&#xA;    ];&#xA;    var TOTAL = STEPS.reduce(function (s, x) { return s + x.d; }, 0);&#xA;    return { steps: STEPS, render: function (si, t, time) {&#xA;      var night = si === 5 &amp;&amp; t &gt; 0.6;&#xA;      ground(night);&#xA;      var shownEv = si &lt; 2 ? 0 : si &gt; 2 ? events.length : Math.min(events.length, Math.floor(t / T_EV));&#xA;      var shownCov = si &lt; 3 ? 0 : si &gt; 3 ? cover.length : Math.min(cover.length, Math.floor(t / T_COV) + 1);&#xA;      var uf2 = nodes.map(function (_, k) { return k; });&#xA;      function f2(a) { while (uf2[a] !== a) a = uf2[a]; return a; }&#xA;      for (var e = 0; e &lt; shownEv; e++) if (events[e].merged) uf2[f2(events[e].i)] = f2(events[e].j);&#xA;      var gcol = nodes.map(function (n, k) {&#xA;        var r = f2(k), size = 0; for (var q = 0; q &lt; nodes.length; q++) if (f2(q) === r) size++;&#xA;        return size &gt; 1 || si &gt; 2 ? COLS[nodeNet[k] % COLS.length] : null;&#xA;      });&#xA;      &#xA;      for (var cc = 0; cc &lt; shownCov; cc++) {&#xA;        var n = nodes[cover[cc].k], r = RADIUS[n.t], grow = cc === shownCov - 1 &amp;&amp; si === 3 ? ease((t - cc * T_COV) / 0.8) : 1, rr = r * grow, col = COLS[nodeNet[cover[cc].k] % COLS.length];&#xA;        g.fillStyle = rgba(col, .12); g.fillRect((n.x - rr) * TS, (n.y - rr) * TS, (2 * rr + 1) * TS, (2 * rr + 1) * TS);&#xA;        g.strokeStyle = rgba(col, .8); g.lineWidth = 1; g.strokeRect((n.x - rr) * TS + .5, (n.y - rr) * TS + .5, (2 * rr + 1) * TS - 1, (2 * rr + 1) * TS - 1);&#xA;      }&#xA;      var nets = balance(night), covered = {};&#xA;      for (cc = 0; cc &lt; shownCov; cc++) cover[cc].got.forEach(function (q) { covered[q] = 1; });&#xA;      &#xA;      machines.forEach(function (m, q) {&#xA;        var on = si &gt;= 4 &amp;&amp; mNet[q] &gt;= 0, sat = on ? nets[mNet[q]].sat : 0, fr = sat &gt; 0 ? 1 + (Math.floor(time * 8 * sat / 100) &amp; 1) : 0;&#xA;        spr(SP[m.t + &#34;0&#34;] + fr, m.x * TS, m.y * TS);&#xA;        if (covered[q]) { g.fillStyle = COLS[mNet[q] % COLS.length]; g.fillRect(m.x * TS + 1, m.y * TS + 14, si &gt;= 4 ? Math.max(1, Math.round(14 * sat / 100)) : 14, 2); }&#xA;        if (si &gt;= 4 &amp;&amp; sat === 0) { g.fillStyle = &#34;#e34948&#34;; g.fillRect(m.x * TS + 11, m.y * TS + 1, 4, 4); }&#xA;      });&#xA;      &#xA;      for (e = 0; e &lt; shownEv; e++) {&#xA;        var ev = events[e]; if (!ev.merged) continue;&#xA;        var A = nodes[ev.i], C = nodes[ev.j], wired = A.t !== &#34;solar&#34; &amp;&amp; C.t !== &#34;solar&#34;;&#xA;        g.strokeStyle = wired ? &#34;#3a2a22&#34; : rgba(gcol[ev.i] || &#34;#ffffff&#34;, .9); g.lineWidth = 1;&#xA;        if (!wired) g.setLineDash([2, 2]);&#xA;        g.beginPath(); g.moveTo(A.x * TS + 8, A.y * TS + 3); g.quadraticCurveTo((A.x + C.x) * TS / 2 + 8, (A.y + C.y) * TS / 2 + 9, C.x * TS + 8, C.y * TS + 3); g.stroke();&#xA;        g.setLineDash([]);&#xA;      }&#xA;      &#xA;      nodes.forEach(function (n, k) {&#xA;        spr(n.t === &#34;solar&#34; ? (night ? SP.solar0 : SP.solar1) : n.t === &#34;gen&#34; ? SP.gen0 + (si &gt;= 4 ? 1 + (Math.floor(time * 4) &amp; 1) : 0) : SP.pole, n.x * TS, n.y * TS);&#xA;        if (gcol[k]) { g.strokeStyle = gcol[k]; g.lineWidth = 2; g.strokeRect(n.x * TS - 1, n.y * TS - 1, 18, 18); }&#xA;      });&#xA;      &#xA;      if (si === 1 || si === 2) {&#xA;        var sx = si === 1 ? ease(t / (T_SORT - .5)) * GW * TS : 1e9;&#xA;        nodes.forEach(function (n, k) { if (n.x * TS + 8 &lt;= sx) label(String(k + 1), n.x * TS + 8, n.y * TS - 4); });&#xA;        if (si === 1) { g.strokeStyle = &#34;rgba(255,255,255,.8)&#34;; g.lineWidth = 1; g.setLineDash([3, 3]); g.beginPath(); g.moveTo(sx, 0); g.lineTo(sx, GH * TS); g.stroke(); g.setLineDash([]); }&#xA;      }&#xA;      &#xA;      if (si === 2 &amp;&amp; shownEv &lt; events.length) {&#xA;        var cur = events[shownEv], N = nodes[cur.i], M = nodes[cur.j], u = (t - shownEv * T_EV) / T_EV;&#xA;        g.strokeStyle = &#34;rgba(255,255,255,.95)&#34;; g.lineWidth = 1; g.setLineDash([3, 2]);&#xA;        g.strokeRect(N.x * TS + .5, Math.max(0, N.y - LINK) * TS + .5, (LINK + 1) * TS - 1, (Math.min(GH, N.y + LINK + 1) - Math.max(0, N.y - LINK)) * TS - 1); g.setLineDash([]);&#xA;        if (u &gt; .35) {&#xA;          g.strokeStyle = cur.merged ? &#34;#ffffff&#34; : &#34;rgba(255,255,255,.6)&#34;; g.setLineDash(cur.merged ? [] : [1, 3]);&#xA;          g.beginPath(); g.moveTo(N.x * TS + 8, N.y * TS + 8); g.lineTo(M.x * TS + 8, M.y * TS + 8); g.stroke(); g.setLineDash([]);&#xA;          label(cur.merged ? &#34;merge&#34; : &#34;same group&#34;, (N.x + M.x) * TS / 2 + 8, (N.y + M.y) * TS / 2 + 8, cur.merged ? &#34;#1baf7a&#34; : &#34;#52514e&#34;);&#xA;        }&#xA;      }&#xA;      var h = &#34;&#34;;&#xA;      if (si &gt;= 4) nets.forEach(function (n, q) { h += &#34;&lt;div&gt;&#34; + dot(COLS[q % COLS.length]) + &#34;&lt;b&gt;Network &#34; + (q + 1) + &#34;&lt;/b&gt;: produces &#34; + n.prod + &#34;, needs &#34; + n.cons + bar(COLS[q % COLS.length], n.sat) + &#34;&lt;b&gt;&#34; + n.sat + &#34;%&lt;/b&gt; speed&lt;/div&gt;&#34;; });&#xA;      else if (si &gt;= 2) { var groups = {}; for (var k = 0; k &lt; nodes.length; k++) groups[f2(k)] = 1; var ng = Object.keys(groups).length; h = &#34;&lt;div&gt;&#34; + nodes.length + &#34; nodes, &#34; + ng + &#34; group&#34; + (ng &gt; 1 ? &#34;s&#34; : &#34;&#34;) + &#34;&lt;/div&gt;&#34;; }&#xA;      setPanel(h);&#xA;    } };&#xA;  }&#xA;&#xA;  &#xA;  function fluidScene() {&#xA;    var P = [{ x: 1, y: 4, t: &#34;wpump&#34; }];&#xA;    for (var x = 2; x &lt;= 9; x++) P.push({ x: x, y: 4, t: &#34;pipe&#34; });&#xA;    P.push({ x: 10, y: 4, t: &#34;tank&#34; }); P.push({ x: 11, y: 4, t: &#34;pipe&#34; }); P.push({ x: 12, y: 4, t: &#34;pipe&#34; });&#xA;    for (x = 14; x &lt;= 19; x++) P.push({ x: x, y: 4, t: &#34;pipe&#34; });&#xA;    P.push({ x: 20, y: 4, t: &#34;tank&#34; });&#xA;    for (var y = 5; y &lt;= 7; y++) P.push({ x: 17, y: y, t: &#34;pipe&#34; });&#xA;    P.push({ x: 17, y: 8, t: &#34;tank&#34; });&#xA;    var GAP = { x: 13, y: 4, t: &#34;pipe&#34; }, CUT = { x: 6, y: 4 };&#xA;    function capOf(list) { return list.reduce(function (s, p) { return s + CAP[p.t]; }, 0); }&#xA;    var left = P.filter(function (p) { return p.x &lt;= 12 &amp;&amp; p.y === 4; }), right = P.filter(function (p) { return left.indexOf(p) &lt; 0; });&#xA;    var capL = capOf(left), capR = capOf(right), capAll = capL + capR + CAP.pipe;&#xA;    var FILL = 0.8 * capL;&#xA;    var STEPS = [&#xA;      { d: 3, title: &#34;pipes, tanks and a pump&#34;, text: &#34;Two separate groups of pipes and tanks. In the game, water never flows from one pipe to the next: that would cost far too much.&#34; },&#xA;      { d: 3.5, title: &#34;find the networks&#34;, text: &#34;Each piece is merged with its right and bottom neighbors (union-find). Every connected group becomes one network, and a network is one single tank: its capacity is the sum of its pieces.&#34; },&#xA;      { d: 4, title: &#34;the pump fills its network&#34;, text: &#34;The pump adds 2 units per frame to the amount of its network. There is one number per network, so every piece shows the same level.&#34; },&#xA;      { d: 5, title: &#34;close the gap&#34;, text: &#34;When a pipe is placed, each piece first gets back its share of its old network. Then the networks are rebuilt and their shares are added up: one bigger tank, one shared level. Nothing is created.&#34; },&#xA;      { d: 5, title: &#34;cut a pipe&#34;, text: &#34;The same thing the other way: each piece takes its share in proportion to its capacity, then two networks are rebuilt. Each side keeps the same level, and only the water of the removed pipe is lost.&#34; }&#xA;    ];&#xA;    var TOTAL = STEPS.reduce(function (s, x) { return s + x.d; }, 0);&#xA;    function pipeMask(p, set) {&#xA;      var m = 0;&#xA;      [[1, 0], [0, 1], [-1, 0], [0, -1]].forEach(function (d, k) { if (set.some(function (q) { return q.x === p.x + d[0] &amp;&amp; q.y === p.y + d[1]; })) m |= 1 &lt;&lt; k; });&#xA;      return m;&#xA;    }&#xA;    return { steps: STEPS, render: function (si, t, time) {&#xA;      ground(false, true);&#xA;      var set = P.slice(), nets;&#xA;      if (si &lt;= 1) nets = [{ list: left, amount: 0, cap: capL }, { list: right, amount: 0, cap: capR }];&#xA;      else if (si === 2) nets = [{ list: left, amount: FILL * ease(t / 3.5), cap: capL }, { list: right, amount: 0, cap: capR }];&#xA;      else if (si === 3) {&#xA;        if (t &lt; 1) nets = [{ list: left, amount: FILL, cap: capL }, { list: right, amount: 0, cap: capR }];&#xA;        else { set.push(GAP); nets = [{ list: left.concat([GAP], right), amount: FILL, cap: capAll, from: ease((t - 1) / 2) }]; }&#xA;      } else {&#xA;        set.push(GAP);&#xA;        var lvl = FILL / capAll;&#xA;        if (t &lt; 1.2) nets = [{ list: left.concat([GAP], right), amount: FILL, cap: capAll }];&#xA;        else {&#xA;          set = set.filter(function (p) { return !(p.x === CUT.x &amp;&amp; p.y === CUT.y); });&#xA;          var a = set.filter(function (p) { return p.y === 4 &amp;&amp; p.x &lt; CUT.x; }), b = set.filter(function (p) { return a.indexOf(p) &lt; 0; });&#xA;          nets = [{ list: a, amount: lvl * capOf(a), cap: capOf(a) }, { list: b, amount: lvl * capOf(b), cap: capOf(b) }];&#xA;        }&#xA;      }&#xA;      var reveal = si === 1 ? ease(t / 2.5) * (GW + 2) : 1e9;&#xA;      set.forEach(function (p) {&#xA;        var k = -1; nets.forEach(function (n, q) { if (n.list.indexOf(p) &gt;= 0) k = q; });&#xA;        if (p.t === &#34;pipe&#34;) spr(SP.pipe0 + pipeMask(p, set), p.x * TS, p.y * TS); else spr(SP[p.t], p.x * TS, p.y * TS);&#xA;        if (k &lt; 0 || si &lt; 1 || p.x &gt; reveal) return;&#xA;        var n = nets[k], col = COLS[k % COLS.length], lv = n.cap ? n.amount / n.cap : 0;&#xA;        if (n.from !== undefined &amp;&amp; n.from &lt; 1) { var before = left.indexOf(p) &gt;= 0 ? FILL / capL : 0; lv = before + (lv - before) * n.from; }&#xA;        g.strokeStyle = col; g.lineWidth = 1; g.strokeRect(p.x * TS + .5, p.y * TS + .5, 15, 15);&#xA;        g.fillStyle = &#34;rgba(20,30,40,.8)&#34;; g.fillRect(p.x * TS + 3, p.y * TS + 12, 10, 3);&#xA;        g.fillStyle = &#34;#3b9cf0&#34;; g.fillRect(p.x * TS + 3, p.y * TS + 12, Math.round(10 * lv), 3);&#xA;      });&#xA;      if (si === 1 &amp;&amp; reveal &lt; GW) { g.strokeStyle = &#34;rgba(255,255,255,.8)&#34;; g.lineWidth = 1; g.setLineDash([3, 3]); g.beginPath(); g.moveTo(reveal * TS, 0); g.lineTo(reveal * TS, GH * TS); g.stroke(); g.setLineDash([]); }&#xA;      if (si === 3 &amp;&amp; t &gt;= 1 &amp;&amp; t &lt; 2.5) { g.strokeStyle = &#34;#fff&#34;; g.lineWidth = 2; g.strokeRect(GAP.x * TS - 1, GAP.y * TS - 1, 18, 18); label(&#34;+ pipe&#34;, GAP.x * TS + 8, GAP.y * TS - 7, &#34;#1baf7a&#34;); }&#xA;      if (si === 4 &amp;&amp; t &gt;= 1.2 &amp;&amp; t &lt; 2.8) { g.strokeStyle = &#34;#e34948&#34;; g.lineWidth = 2; g.strokeRect(CUT.x * TS - 1, CUT.y * TS - 1, 18, 18); label(&#34;cut&#34;, CUT.x * TS + 8, CUT.y * TS - 7, &#34;#e34948&#34;); }&#xA;      var h = &#34;&#34;;&#xA;      if (si &gt;= 1) nets.forEach(function (n, q) { h += &#34;&lt;div&gt;&#34; + dot(COLS[q % COLS.length]) + &#34;&lt;b&gt;Network &#34; + (q + 1) + &#34;&lt;/b&gt;: &#34; + n.list.length + &#34; pieces, &#34; + Math.round(n.amount) + &#34; / &#34; + n.cap + &#34; water&#34; + bar(COLS[q % COLS.length], n.cap ? n.amount * 100 / n.cap : 0) + &#34;&lt;/div&gt;&#34;; });&#xA;      setPanel(h);&#xA;    } };&#xA;  }&#xA;&#xA;  &#xA;  var scene = MODE === &#34;power&#34; ? powerScene() : fluidScene(), STEPS = scene.steps, N = STEPS.length;&#xA;  var TOTAL = STEPS.reduce(function (s, x) { return s + x.d; }, 0), now = 0, auto = true, cur = 0, curStart = 0, t0 = null;&#xA;  var nav = root.querySelector(&#34;.fdsn-nav&#34;), navBtns = [];&#xA;  function mk(label, fn) {&#xA;    var b = document.createElement(&#34;button&#34;); b.type = &#34;button&#34;; b.textContent = label;&#xA;    b.style.cssText = &#34;font:inherit;font-size:.8em;min-width:2.2em;padding:3px 8px;border-radius:6px;border:1px solid #bbb;background:#f6f5f2;color:#222;cursor:pointer&#34;;&#xA;    b.onclick = fn; nav.appendChild(b); return b;&#xA;  }&#xA;  function go(i) { auto = false; cur = (i + N) % N; curStart = now; }&#xA;  mk(&#34;\u25C0&#34;, function () { go(cur - 1); });&#xA;  for (var k = 0; k &lt; N; k++) (function (k) { navBtns.push(mk(String(k + 1), function () { go(k); })); })(k);&#xA;  mk(&#34;\u25B6&#34;, function () { go(cur + 1); });&#xA;  var playBtn = mk(&#34;Play&#34;, function () { if (!auto) { auto = true; t0 = null; } });&#xA;  cv.addEventListener(&#34;click&#34;, function () { go(cur + 1); });&#xA;  var lastNav = &#34;&#34;;&#xA;  function paintNav() {&#xA;    var key = cur + (auto ? &#34;a&#34; : &#34;m&#34;); if (key === lastNav) return; lastNav = key;&#xA;    navBtns.forEach(function (b, k) { var on = k === cur; b.style.background = on ? &#34;#2a78d6&#34; : &#34;#f6f5f2&#34;; b.style.color = on ? &#34;#fff&#34; : &#34;#222&#34;; b.style.borderColor = on ? &#34;#2a78d6&#34; : &#34;#bbb&#34;; });&#xA;    playBtn.textContent = auto ? &#34;Pause&#34; : &#34;Play&#34;;&#xA;    playBtn.onclick = auto ? function () { go(cur); } : function () { auto = true; t0 = null; };&#xA;  }&#xA;  function loop(ts) {&#xA;    now = ts / 1000; g.imageSmoothingEnabled = false;&#xA;    var si, t;&#xA;    if (auto) {&#xA;      if (t0 === null) t0 = now - STEPS.slice(0, cur).reduce(function (s, x) { return s + x.d; }, 0);&#xA;      t = (now - t0) % TOTAL; si = 0;&#xA;      while (t &gt;= STEPS[si].d) { t -= STEPS[si].d; si++; }&#xA;      cur = si;&#xA;    } else { si = cur; t = Math.min(now - curStart, STEPS[si].d - 0.001); }&#xA;    setStep(si, N, STEPS[si].title, STEPS[si].text); paintNav();&#xA;    scene.render(si, t, now);&#xA;    requestAnimationFrame(loop);&#xA;  }&#xA;  &#xA;  function lockHeights() {&#xA;    var keep = stepEl.innerHTML, h = 0;&#xA;    stepEl.style.minHeight = &#34;0&#34;;&#xA;    for (var k = 0; k &lt; N; k++) {&#xA;      stepEl.innerHTML = &#39;&lt;b&gt;Step &#39; + (k + 1) + &#39;/&#39; + N + &#39;: &#39; + STEPS[k].title + &#39;&lt;/b&gt;&lt;br&gt;&#39; + STEPS[k].text;&#xA;      h = Math.max(h, stepEl.offsetHeight);&#xA;    }&#xA;    stepEl.innerHTML = keep; stepEl.style.minHeight = h + &#34;px&#34;;&#xA;    var row = MODE === &#34;power&#34;                                         &#xA;      ? &#34;&lt;div&gt;&#34; + dot(&#34;#000&#34;) + &#34;&lt;b&gt;Network 1&lt;/b&gt;: produces 20, needs 29&#34; + bar(&#34;#000&#34;, 50) + &#34;&lt;b&gt;100%&lt;/b&gt; speed&lt;/div&gt;&#34;&#xA;      : &#34;&lt;div&gt;&#34; + dot(&#34;#000&#34;) + &#34;&lt;b&gt;Network 1&lt;/b&gt;: 18 pieces, 3300 / 3420 water&#34; + bar(&#34;#000&#34;, 50) + &#34;&lt;/div&gt;&#34;;&#xA;    var two = row + row, keepP = panel.innerHTML;&#xA;    panel.style.minHeight = &#34;0&#34;; panel.innerHTML = two; var hp = panel.offsetHeight; panel.innerHTML = keepP;&#xA;    panel.style.minHeight = hp + &#34;px&#34;;&#xA;  }&#xA;  lockHeights();&#xA;  window.addEventListener(&#34;resize&#34;, lockHeights);&#xA;  var started = false;&#xA;  function start() { if (!started) { started = true; requestAnimationFrame(loop); } }&#xA;  sheet.onload = start;&#xA;  if (sheet.complete) start();&#xA;})();&#xA;&lt;/script&gt;&#xA;&#xA;&lt;h2 id=&#34;all-aboard&#34;&gt;All aboard&lt;/h2&gt;&#xA;&lt;p&gt;If you have played Factorio you know the moment. Your factory is running, the ore patch next to it is almost empty and the next one is far away. You place your first rails, your first station, your first locomotive, and that&amp;rsquo;s when the game gets really good. So a Factorio-like without trains was not an option for me.&lt;/p&gt;&#xA;&lt;p&gt;They were also the hardest feature to get right. I wanted real trains and not just a cart on a rail:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;rails, stations and locomotives that you place by hand, with up to three wagons behind each locomotive (cargo wagons for items, tank wagons for oil)&lt;/li&gt;&#xA;&lt;li&gt;wagons that really follow the locomotive. Each train remembers its last positions, every 2 pixels, and the wagons are drawn along that path, so they bend in the turns exactly like the locomotive did&lt;/li&gt;&#xA;&lt;li&gt;schedules with up to four stops, and a condition for each stop: wait 8 seconds, wait until full, wait until empty or refuel. Inserters next to a station load and unload the wagons. The classic &amp;ldquo;iron mine full, then smelter empty, then coal station&amp;rdquo; loop works! At every junction the train looks for its way to the next station with a breadth-first search on the rail network, in a buffer of fixed size, so even a big network can&amp;rsquo;t make it run out of memory&lt;/li&gt;&#xA;&lt;li&gt;block signals. A signal cuts the track into blocks and only one train is allowed in each block. When a train passes a signal the next block is reserved and the light turns red for the others. Trains also keep a safety distance of two tiles, so they never run into each other&lt;/li&gt;&#xA;&lt;li&gt;and you can ride them! Press A next to a locomotive and you hop in, and the camera follows the train across the map&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Getting several trains to share a network without deadlocks took a lot of evenings and a lot of scripted test sessions. I had two trains face to face on the same track, each one politely waiting for the other forever (now one of them backs off after a while), trains without a path because a rail was rotated the wrong way, and a train save format that had to change three times&amp;hellip; But once it worked, I could watch three trains share the same loop, stop at the red lights and wait for their turn for ten minutes.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/trains.gif&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/trains.gif&#34; alt=&#34;Three trains sharing a loop with block signals&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;Three trains with cargo and tank wagons on the same loop. The little red flags are block signals, a train waits until the block in front of it is free. Captured from the game itself, at 30 fps.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;wires-clocks-and-logic-gates&#34;&gt;Wires, clocks and logic gates&lt;/h2&gt;&#xA;&lt;p&gt;The other thing Factorio players love is automation that thinks by itself, like &amp;ldquo;stop the drills when the chest is full&amp;rdquo; or &amp;ldquo;only let the train leave when the wagons are loaded&amp;rdquo;. I didn&amp;rsquo;t want to go as far as the Factorio circuit network, with numbers on every wire, so I went for something closer to Minecraft redstone, with on/off signals only.&lt;/p&gt;&#xA;&lt;p&gt;There are sources (a lever, a button that sends a 2-second pulse, a clock that switches every 1, 2, 4 or 8 seconds, and a sensor that watches a belt, a chest, a machine or a station), wires that carry the signal from tile to tile, and NOT, AND and delay gates. Chain a few delays and you get timers. At the end of the chain, a machine next to a signal pauses, a lamp turns on, a belt gate opens, or a station keeps its train at the platform.&lt;/p&gt;&#xA;&lt;p&gt;With that you can build the classic &amp;ldquo;sensor on the chest, the drills stop when it&amp;rsquo;s full&amp;rdquo;, or keep a train at the station until a sensor says the chest next to it is empty.&lt;/p&gt;&#xA;&lt;p&gt;The simulation runs every 4 frames, in three passes. First every source and every gate computes its new output from the state of the &lt;em&gt;previous&lt;/em&gt; pass. Then the wires are flooded from the active sources with a breadth-first search. And finally each building reads the signal of its neighbors. Because the gates only look at the previous state, a loop of gates can&amp;rsquo;t get the game stuck in an infinite loop, it just oscillates like in real electronics. And if you haven&amp;rsquo;t placed a single logic piece, the whole thing is skipped.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/logic.gif&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/logic.gif&#34; alt=&#34;A clock, delays, lamps, a NOT gate and two belt gates&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;A clock switches on and off every 2 seconds and the signal travels through a chain of delays, so the lamps light up one after the other. The vertical wire on the left opens the gate of the bottom belt while the wave passes, and the plates pile up behind it the rest of the time. The top belt is behind a NOT gate, so its copper plates go through when the bottom gate is closed.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;testing-and-balancing-a-ds-game-without-a-ds&#34;&gt;Testing (and balancing) a DS game without a DS&lt;/h2&gt;&#xA;&lt;p&gt;Testing was a problem from the very beginning. Every time Claude wanted to check something, an emulator window popped up on my screen. And I had no way to know if a change broke something three hours of play later.&lt;/p&gt;&#xA;&lt;p&gt;So I asked Claude to prepare an environment where an agent could test the game on its own, by building it locally on my Mac and running it headless (without any window). The game now also compiles for macOS, against a tiny fake version of libnds. Instead of drawing on a screen, it writes PNG files of both screens. On top of that, a small text interface lets the agent drive the real game code with commands like &lt;code&gt;press A&lt;/code&gt;, &lt;code&gt;hold RIGHT 30&lt;/code&gt;, &lt;code&gt;step 600&lt;/code&gt;, &lt;code&gt;shot factory.png&lt;/code&gt; or &lt;code&gt;state&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;With that, play sessions become text files that can be replayed after every change. We ended up with around 470 of them, from &amp;ldquo;cut a tree&amp;rdquo; to &amp;ldquo;build a train network with signals&amp;rdquo;. They all run with the compiler&amp;rsquo;s sanitizers turned on, which stop the game as soon as it reads the wrong memory or does something undefined in C. And since Claude can play the game and look at the screenshots, it can check its own work before telling me it&amp;rsquo;s done.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/dev-loop.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/dev-loop.png&#34; alt=&#34;The development loop&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;A real session: the script, the screenshot it produced, and the state of the game it printed.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;Some bugs were really sneaky. My favorite one: if the game auto-saved during the 8 seconds of the rocket countdown, the silo stayed full forever and the game could never be finished. The odds were around 2.7% per rocket. In another one, a pumpjack connected to a full pipe network kept its last barrel of oil waiting to be pushed out, and never pumped again. Without the scripted sessions I would have found these after hours of play, or never.&lt;/p&gt;&#xA;&lt;p&gt;The same setup gave me an idea for the balancing. Instead of playing 10 hours after every change in the ratios, I asked Claude to launch several agents that play the game through the text interface, each one on a different part of it. One played the beginning (the first 15 objectives) with the real buttons, without any cheat, and timed every objective. Another one measured the production rates of the mid- and late-game factories, a third one played the end game up to the rocket, and a last one tested the infinite research. There was also an &amp;ldquo;analyst&amp;rdquo; that read the code to list every rate and recipe, and computed how long the whole game would take. At the end each agent wrote a report about where it got stuck, how long each step took and what was too slow or too fast.&lt;/p&gt;&#xA;&lt;p&gt;Then I read the reports and decided what to change. Cheaper labs here, a shorter objective there, an oil patch closer to the start&amp;hellip; The agents play badly, and where they get stuck a new player probably will too.&lt;/p&gt;&#xA;&lt;h2 id=&#34;hitting-the-performance-wall&#34;&gt;Hitting the performance wall&lt;/h2&gt;&#xA;&lt;p&gt;Then my first big factory arrived, and it could not hold 60 frames per second. Walking around stuttered. A 67 MHz CPU is not much when you want to simulate thousands of machines &lt;em&gt;and&lt;/em&gt; redraw a full screen 60 times per second.&lt;/p&gt;&#xA;&lt;p&gt;Before changing anything I wanted numbers, but &lt;code&gt;printf&lt;/code&gt; does not go anywhere on a DS! So I asked for a benchmark mode in the game. It loads several test factories and writes its timings into a buffer in RAM, and a small Python script reads that buffer through the debugger connection of melonDS (its GDB stub). melonDS runs in the background with the sound off and its JIT disabled (the JIT makes the emulator faster by translating the DS code for my Mac, but then the timings mean nothing). melonDS is very accurate, but it&amp;rsquo;s still an emulator, and I&amp;rsquo;ll come back to that at the end.&lt;/p&gt;&#xA;&lt;p&gt;Then we went through the problems one by one. Measure, fix the worst one, measure again.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;The ground.&lt;/strong&gt; Redrawing the whole ground every frame was way too slow. The DS video hardware can stack several layers and scroll each of them by itself, so I split the top screen into two layers. A large 512×256 ground layer lives in video memory, and only the new tiles entering the view are drawn into it. The hardware scrolls it during the vertical blank (the short pause between two frames when the screen is not being drawn), so scrolling is free. Buildings, items and the player are drawn on a second layer on top, where color 0 is transparent. This alone removed more than 5 ms per frame and the stuttering disappeared.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/render-layers.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/render-layers.png&#34; alt=&#34;Rendering layers of the top screen&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;The two real layers of the top screen, pulled out of the game. The ground layer is bigger than the screen, so the tiles around the view are already there when you walk.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Night for free.&lt;/strong&gt; The game has a day and night cycle, and my first thought was that at dusk I would have to redraw everything darker, including the big ground layer. But each pixel is not really a color, it&amp;rsquo;s an index in a palette of 256 colors. So night is not drawn at all! When the light changes, the game recomputes the 256 colors of the palette, a bit darker and a bit bluer each time, and the hardware applies them to every pixel of the screen at once. That&amp;rsquo;s 256 values to write instead of 49,152 pixels. The few colors used by the interface and the cursor are never darkened, so they stay readable at night. The only things actually drawn are the lights: the glow of the lamps and furnaces, and the fireflies.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/day-night-palette.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/day-night-palette.png&#34; alt=&#34;The same frame with the day palette and the night palette&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;The same frame, rendered once with the day palette and once with the night palette. 97% of the pixels have exactly the same value in both images (the other 3% are the lights), only the colors below change.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Fast memory.&lt;/strong&gt; The ITCM is 32 KB of memory right next to the CPU, much faster than the main RAM. The hot loops go there: the simulation of the machines, the belts and the drawing of the buildings. 32 KB is not much, so I only put there what runs every frame, and every new feature in one of these loops means checking the size of the &lt;code&gt;.itcm&lt;/code&gt; section again (22 KB are used today).&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;No division.&lt;/strong&gt; The ARM9 has no division instruction. The DS has a separate division unit, but a plain &lt;code&gt;/&lt;/code&gt; in C becomes a slow software routine. When infinite research arrived, it added a harmless-looking &lt;code&gt;time * 20 / (20 + level)&lt;/code&gt; per machine and per frame. The benchmark caught it, the number of slow frames in the stress test almost doubled. We replaced it with a multiplication by a precomputed fixed-point factor, and the slow frames went from 581 back to 312.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;The interface.&lt;/strong&gt; Even the bottom screen became a problem when I redesigned the menus (more on that below), a full redraw took 14 ms. The rectangle function was checking every single pixel, and every letter of text went through it. Clipping once and writing whole rows at a time brought it down to 4 ms.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;The intro.&lt;/strong&gt; The splash screen was stuttering too. A nebula was computed pixel by pixel every frame (105 ms!). Computed once at startup, it takes 6 ms.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/frame-budget.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/frame-budget.png&#34; alt=&#34;Time per frame on a DS&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;Average time per frame, measured on melonDS with the JIT off. The DS screens refresh at about 60 Hz (59.8 to be exact), so a frame has 16.7 ms. Below that, the game runs at full speed. A lot of 3D DS games actually run at 30 fps, but for a 2D game with belts everywhere I really wanted 60.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;On melonDS, a factory of almost 900 buildings runs at a steady 60 fps. The stress test with 3,630 buildings is under 16.7 ms on average, but about 300 of its 1,200 frames are still too slow. That&amp;rsquo;s way bigger than what you can reasonably build with a d-pad though (and more than what a save can hold anyway).&lt;/p&gt;&#xA;&lt;p&gt;These are melonDS numbers. The real console is a bit slower, more on that at the end.&lt;/p&gt;&#xA;&lt;h2 id=&#34;playing-it-again-and-again&#34;&gt;Playing it, again and again&lt;/h2&gt;&#xA;&lt;p&gt;Once everything was there, I played. A lot. And every session ended with a list of things I didn&amp;rsquo;t like, that I sent to Claude, often with a screenshot.&lt;/p&gt;&#xA;&lt;p&gt;Designing an interface for a d-pad and 256×192 pixels is hard, and the bottom screen was rewritten many times. A selection frame was covering the number of items, two lines of the same description were in two different whites, some texts were too long for their box (they now scroll back and forth)&amp;hellip; Removing a building used to need a &amp;ldquo;remove&amp;rdquo; tool, which I found painful, so now it&amp;rsquo;s a long press on B and the tool is gone. And START and SELECT were the other way around compared to most DS games.&lt;/p&gt;&#xA;&lt;p&gt;Then I went through every screen, one by one, for a complete redesign. The first menus were plain text on a purple background, and every screen got rebuilt with cards, drawn buttons and icons, so you always know which key does what.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/menu-before-after.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/menu-before-after.png&#34; alt=&#34;Options menu, before and after&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;The options menu, before and after the redesign.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/pair-map-research.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/pair-map-research.png&#34; alt=&#34;World map and research screen&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;On the left the world map, on the right the research tree, with the infinite research in the top right corner.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-finishing-touches&#34;&gt;The finishing touches&lt;/h2&gt;&#xA;&lt;p&gt;I spent (too much) time on graphic styles. I asked Claude for several palettes and tile variants and kept the ones I liked. The Kenney one stayed the default, and there is also a blueprint style, a flat one and an industrial one, that you can switch at any time from the menu. There was a Game Boy palette too, but I killed it.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/styles.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/styles.png&#34; alt=&#34;Graphic styles&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;The four styles of the game.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;For the sounds, I asked Claude to find a few CC0 candidates on Freesound for each one, and I picked them by ear. Then they were all normalized to the same loudness, because the DS speakers are tiny and some sounds were inaudible next to the music. I also added rain, snow, sand wind in the desert, butterflies and fireflies (there were birds at some point, but they were distracting).&lt;/p&gt;&#xA;&lt;p&gt;The game was first written in French, so at the end I asked Claude to translate it into English too, around 1,300 texts in total. They had to stay as short as the French ones to fit in the little boxes of the interface. The language follows the setting of your console, and you can switch it at any time from the title screen or the options menu.&lt;/p&gt;&#xA;&lt;p&gt;And finally a small cinematic when you start a new game. Your ship drifts between the stars, the engine dies, and you wake up in the grass next to it. The first version was very &amp;ldquo;epic&amp;rdquo;, with big flashes and shaking, but I wanted something calmer and more dreamy, so we toned it down until I liked it. Everything is drawn with code, there is no video in the ROM.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/factory-ds/splash.gif&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/factory-ds/splash.gif&#34; alt=&#34;Splash screen&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;next-the-real-console&#34;&gt;Next: the real console&lt;/h2&gt;&#xA;&lt;p&gt;You can now play Factory DS from start to finish, from cutting your first tree to launching rockets, and keep going after that. The ROM is under 2 MB.&lt;/p&gt;&#xA;&lt;p&gt;But there is one thing melonDS didn&amp;rsquo;t tell me. On my own DS, with a flashcart, the game&amp;rsquo;s frame counter showed around 30 fps instead of 60, all the time. So I ran the benchmark ROM on the real console, it writes its results to the SD card, and compared them with melonDS line by line.&lt;/p&gt;&#xA;&lt;p&gt;The real DS is about a third slower than the emulator for this game. The factory of 890 buildings takes 12.2 ms per frame instead of 9.1 ms, and clearing the big buffer of the top screen takes 4 times longer, writing to the main memory is much slower on the real hardware than in melonDS. That&amp;rsquo;s still under the 16.7 ms of a frame most of the time.&lt;/p&gt;&#xA;&lt;p&gt;The 30 fps came from somewhere else, and it&amp;rsquo;s a bit funny. The frame counter is shown in the statistics screen, and this screen was fully redrawn on every frame. On the real console that alone took almost 16 ms, so as soon as you opened the statistics, the game dropped to 30 fps&amp;hellip; and showed you 30 fps. The screen now refreshes 8 times per second and the counter shows the real speed. Small and medium factories run at 60 fps on the real console, and only very big ones slow down a bit.&lt;/p&gt;&#xA;&lt;p&gt;There is still some margin to win on the real hardware, starting with that buffer that is cleared on every frame, and I&amp;rsquo;ll probably write a second part about it. In the meantime you can already try the game on your own DS or in melonDS. And maybe one day I&amp;rsquo;ll try local wireless multiplayer, with two DS building the same factory!&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;&lt;a href=&#34;https://ronangaillard.com/files/factory-ds.nds&#34;&gt;Download Factory DS&lt;/a&gt;&lt;/strong&gt; (factory-ds.nds, 1.9 MB, in English and French). It runs on a real DS with a flashcart, or in an emulator like &lt;a href=&#34;https://melonds.kuribo64.net/&#34; target=&#34;_blank&#34;&gt;melonDS&lt;/a&gt;.&lt;/p&gt;&#xA;</description>
    </item>
    <item>
      <title>Reverse Engineering the Epson FilmScan 200 for Classic Mac</title>
      <link>https://ronangaillard.com/posts/reverse-engineering-epson-filmscan-200/</link>
      <pubDate>Tue, 30 Dec 2025 22:00:00 +0100</pubDate>
      <guid>https://ronangaillard.com/posts/reverse-engineering-epson-filmscan-200/</guid>
      <description>&lt;p&gt;&lt;em&gt;How I wrote a SCSI driver from scratch to scan 35mm film on a Mac SE/30 running System 7&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-problem&#34;&gt;The Problem&lt;/h2&gt;&#xA;&lt;p&gt;I shoot and develop 35mm film at home. It&amp;rsquo;s a nice hobby, and I do my own prints in a small darkroom. But sometimes I want digital copies to share online or just to archive.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/filmscan/cameras.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/filmscan/cameras.jpg&#34; alt=&#34;My analog cameras&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;A while ago I found an Epson FilmScan 200 on leboncoin (the French Craigslist) for 10 euros. It&amp;rsquo;s a dedicated 35mm film scanner from 1997 that scans at 1200 DPI optical. At that price, worth a try.&lt;/p&gt;&#xA;&lt;p&gt;The catch? This scanner only has a SCSI interface and the official drivers only work on Mac System 7/8 or Windows 95/98. My main machines are modern Macs. I could install a virtual machine, but SCSI passthrough is basically impossible. I could use a USB-to-SCSI adapter, but those are expensive and often don&amp;rsquo;t work well with scanners.&lt;/p&gt;&#xA;&lt;p&gt;But wait. I have a Mac SE/30 sitting on my desk. The same one I &lt;a href=&#34;https://ronangaillard.com/posts/my-mac-se30-is-back-alive/&#34;&gt;brought back to life&lt;/a&gt; a few years ago. It has SCSI. It runs System 7. Could I scan directly on it?&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/filmscan/scanner-mac.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/filmscan/scanner-mac.jpg&#34; alt=&#34;The FilmScan 200 next to my Mac SE/30&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;The official Epson TWAIN driver exists, but it&amp;rsquo;s a complex plugin designed for Photoshop. I wanted something simpler: a standalone app that scans frames and saves them as files I can transfer via FTP.&lt;/p&gt;&#xA;&lt;p&gt;Time to write my own driver.&lt;/p&gt;&#xA;&lt;h2 id=&#34;understanding-the-hardware&#34;&gt;Understanding the Hardware&lt;/h2&gt;&#xA;&lt;p&gt;The first step was understanding how this scanner actually works. I found a service manual PDF online, which gave me crucial information:&lt;/p&gt;&#xA;&lt;table&gt;&#xA;&#x9;&lt;thead&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;Spec&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;Value&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/thead&gt;&#xA;&#x9;&lt;tbody&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;SCSI device type&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&amp;ldquo;Processor&amp;rdquo; (0x03), not a standard scanner&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;Protocol&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;ESC/I commands encapsulated in SCSI SEND/RECEIVE&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;Resolution&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;Up to 1200 DPI optical&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;Frame size&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;24x36mm = 1120x1680 pixels at max resolution&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;The &amp;ldquo;Processor&amp;rdquo; device type was interesting. Unlike standard SCSI scanners that use vendor-specific commands, this one uses generic SEND (0x0A) and RECEIVE (0x08) commands to exchange ESC/I protocol data. The ESC/I protocol is Epson&amp;rsquo;s standard scanner command language, same family as what their flatbed scanners use.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/filmscan/manual-extract.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/filmscan/manual-extract.png&#34; alt=&#34;Extract from the service manual&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;I was surprised by how technical the service manual is. It details SCSI timings, command sequences, data exchange formats&amp;hellip; Did technicians really use this to repair people&amp;rsquo;s scanners back in the day? I guess they did. Either way, it gave me a solid foundation, even if it stayed a bit high-level and didn&amp;rsquo;t detail every command parameter.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;I also extracted the original 68k driver from the Epson TWAIN plugin (a 748KB resource fork) and started disassembling it to understand the command flow. Why not just use SANE on Linux? Because SANE doesn&amp;rsquo;t actually support the FilmScan 200 out of the box. The mainline epson backend doesn&amp;rsquo;t include it. So I was on my own.&lt;/p&gt;&#xA;&lt;h2 id=&#34;setting-up-the-development-environment&#34;&gt;Setting Up the Development Environment&lt;/h2&gt;&#xA;&lt;p&gt;For development, I used THINK C 5.0 on the SE/30. Yes, I&amp;rsquo;m writing 68k code in 2025. There&amp;rsquo;s something satisfying about it.&lt;/p&gt;&#xA;&lt;p&gt;The project setup is minimal. You just link against &lt;code&gt;ANSI&lt;/code&gt; for printf/FILE functions and set partition size to 8MB (color scans need ~5.6MB buffer).&lt;/p&gt;&#xA;&lt;p&gt;The SCSI Manager API on classic Mac OS is&amp;hellip; interesting. You call &lt;code&gt;SCSIGet()&lt;/code&gt; to acquire the bus, &lt;code&gt;SCSISelect()&lt;/code&gt; to select a device, then &lt;code&gt;SCSICmd()&lt;/code&gt;, &lt;code&gt;SCSIRead()&lt;/code&gt;/&lt;code&gt;SCSIWrite()&lt;/code&gt; with Transfer Instruction Blocks (TIBs), and finally &lt;code&gt;SCSIComplete()&lt;/code&gt;. Every step can fail, and you need to handle cleanup properly.&lt;/p&gt;&#xA;&lt;h2 id=&#34;first-success-single-frame-scan&#34;&gt;First Success: Single Frame Scan&lt;/h2&gt;&#xA;&lt;p&gt;After a few evenings of coding, I got my first successful scan! The basic flow is:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;SCSIGet / SCSISelect&#xA;SEND: ESC @ (Init)&#xA;RECEIVE: ACK (0x06)&#xA;SEND: ESC C + 0x00 (Set mono mode)&#xA;RECEIVE: ACK&#xA;SEND: ESC R + resolution (Set 1200 DPI)&#xA;RECEIVE: ACK&#xA;SEND: ESC A + area (Set scan area)&#xA;RECEIVE: ACK&#xA;SEND: ESC G (Start scan)&#xA;Loop: RECEIVE header + data, SEND ACK until done&#xA;Save as PGM file&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The data comes back line by line, each with a 4-byte header containing status flags and byte count. You keep reading until the &amp;ldquo;end of data&amp;rdquo; status bit is set.&lt;/p&gt;&#xA;&lt;p&gt;I saved the raw data as a PGM (Portable GrayMap) file, a dead simple format that&amp;rsquo;s just a header followed by raw pixel bytes. Preview on modern macOS opens them directly.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/filmscan/scan-bw.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/filmscan/scan-bw.jpg&#34; alt=&#34;First successful scan&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;The scratches on the film? One of my first home developments. I had a hard time getting the film out of the reel. Image inverted with &lt;a href=&#34;https://github.com/montoyatim01/Filmvert&#34; target=&#34;_blank&#34;&gt;Filmvert&lt;/a&gt; for viewing.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-frame-selection-problem&#34;&gt;The Frame Selection Problem&lt;/h2&gt;&#xA;&lt;p&gt;Here&amp;rsquo;s where things got frustrating. The FilmScan 200 has a 6-frame carrier. You load a strip of film and can scan frames 1 through 6. My first scan worked, but I couldn&amp;rsquo;t figure out how to select different frames.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/filmscan/carrier.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/filmscan/carrier.jpg&#34; alt=&#34;The film carrier with a strip of 35mm film&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;The service manual mentioned an ESC P (SetBay) command with 2 parameter bytes. I tried everything:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;[frame-1, 0]    what the disassembled 68k driver seemed to use&#xA;[frame, 0]&#xA;[0, frame]&#xA;[frame-1, frame-1]&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Nothing worked. The scanner would just scan frame 1 every time, or worse, return an error.&lt;/p&gt;&#xA;&lt;p&gt;I spent hours in the disassembler, tracing through the original driver. I found a function at offset 0x47080 that handled ESC P, and it seemed to use 0-indexed values. But when I replicated the exact sequence, it still didn&amp;rsquo;t work.&lt;/p&gt;&#xA;&lt;p&gt;Then I noticed something odd: some commands in the driver called a function that checked for a handle called &amp;ldquo;OnionCommsRef&amp;rdquo; with magic bytes &amp;lsquo;Comm&amp;rsquo;. Without this handle, the commands were blocked &lt;em&gt;on the driver side&lt;/em&gt; before even reaching the scanner. Was the ESC P format I found never actually used?&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-breakthrough-a-hidden-website&#34;&gt;The Breakthrough: A Hidden Website&lt;/h2&gt;&#xA;&lt;p&gt;After much frustration, I stumbled upon a hidden gem: &lt;a href=&#34;http://www.vjet.f2s.com/linux/scanner/download.html&#34; target=&#34;_blank&#34;&gt;http://www.vjet.f2s.com/linux/scanner/download.html&lt;/a&gt;. This site, maintained by someone named Clive, contains a patched SANE driver specifically for the FilmScan 200. It&amp;rsquo;s not in the mainline SANE project, just sitting there on this old website that somehow still works in 2025.&lt;/p&gt;&#xA;&lt;p&gt;I downloaded &lt;code&gt;epson_17062002.c&lt;/code&gt; and started reading. And there it was, on line 2447:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-c&#34; data-lang=&#34;c&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;simplecommand&lt;/span&gt;(SET_BAY, s, s&lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt;val[OPT_BAY]&lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt;&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;, s&lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt;val[OPT_BAY]&lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt;&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;);&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The format is &lt;code&gt;[frame, frame]&lt;/code&gt;, both bytes identical, 1-indexed!&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-c&#34; data-lang=&#34;c&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;/* This is what works */&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;gCmd[&lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; frame;  &lt;span style=&#34;color:#75715e&#34;&gt;/* 1 for frame 1, 2 for frame 2, etc. */&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;gCmd[&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; frame;  &lt;span style=&#34;color:#75715e&#34;&gt;/* same value repeated! */&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I still don&amp;rsquo;t know why both bytes need to be the same. Maybe it&amp;rsquo;s a confirmation/validation thing. Maybe it&amp;rsquo;s a quirk of this specific scanner model. But it works!&lt;/p&gt;&#xA;&lt;p&gt;Another tip from the SANE source (in a comment): &amp;ldquo;It wastes a lot of time on the filmscan moving the slide around if you send a reset every scan..&amp;rdquo; So you should only send ESC @ (Init) once at startup, not before each frame. This saves about 30 seconds of recalibration per frame.&lt;/p&gt;&#xA;&lt;p&gt;Thank you Clive for writing this driver back in 2002, and thank you for keeping your website online all these years. It saved my project.&lt;/p&gt;&#xA;&lt;h2 id=&#34;adding-color-support&#34;&gt;Adding Color Support&lt;/h2&gt;&#xA;&lt;p&gt;Mono scans were working great, but I wanted color. According to the documentation, you just set ESC C to 0x02 instead of 0x00.&lt;/p&gt;&#xA;&lt;p&gt;Easy, right? Not quite.&lt;/p&gt;&#xA;&lt;p&gt;My first color scan showed three columns side by side, each column was a grayscale version of the image. Classic sign of getting RGB data in the wrong format.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/filmscan/three-columns.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/filmscan/three-columns.jpg&#34; alt=&#34;The three columns issue&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;Each column is one color channel. Not quite what I was hoping for.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;After more digging in the SANE driver, I found that in color mode, the scanner sends 3 separate data blocks per line, one for each color channel. And they come in G, R, B order (not RGB!).&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;For each scan line:&#xA;  RECEIVE: header + G data (1120 bytes)&#xA;  SEND: ACK&#xA;  RECEIVE: header + R data (1120 bytes)&#xA;  SEND: ACK&#xA;  RECEIVE: header + B data (1120 bytes)&#xA;  SEND: ACK (unless end of frame)&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I had to buffer each color channel separately, then merge them into interleaved RGB for the output file:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-c&#34; data-lang=&#34;c&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;for&lt;/span&gt; (i &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;; i &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt; width; i&lt;span style=&#34;color:#f92672&#34;&gt;++&lt;/span&gt;) {&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    output[i &lt;span style=&#34;color:#f92672&#34;&gt;*&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;3&lt;/span&gt;]     &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; rBuf[i];&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    output[i &lt;span style=&#34;color:#f92672&#34;&gt;*&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;3&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; gBuf[i];&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    output[i &lt;span style=&#34;color:#f92672&#34;&gt;*&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;3&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;2&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; bBuf[i];&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first version came out way too green because I had G and R swapped. Once I got the order right, beautiful color scans!&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/filmscan/scan-color-1.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/filmscan/scan-color-1.jpg&#34; alt=&#34;Color scan example 1&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/filmscan/scan-color-2.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/filmscan/scan-color-2.jpg&#34; alt=&#34;Color scan example 2&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;Quick inversion with &lt;a href=&#34;https://github.com/montoyatim01/Filmvert&#34; target=&#34;_blank&#34;&gt;Filmvert&lt;/a&gt;. The colors aren&amp;rsquo;t perfect, but it&amp;rsquo;s just for preview. You can always fine-tune later manually or with something like &lt;a href=&#34;https://www.negativelabpro.com/&#34; target=&#34;_blank&#34;&gt;Negative Lab Pro&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-final-result&#34;&gt;The Final Result&lt;/h2&gt;&#xA;&lt;p&gt;The finished driver is about 450 lines of C code. It initializes the scanner once at startup, lets you specify a folder name and starting pose number, scans 1 to 6 frames in a batch, saves as PPM (color) or PGM (mono) files, and asks if you want to continue with more frames.&lt;/p&gt;&#xA;&lt;p&gt;A full 6-frame color scan at 1200 DPI takes about 10 minutes on the SE/30. Not fast, but not bad for a 35-year-old computer talking to a 27-year-old scanner.&lt;/p&gt;&#xA;&lt;p&gt;To get the files off the SE/30, I run a Python FTP server on my modern Mac and use Fetch on the SE/30 to upload. Works great.&lt;/p&gt;&#xA;&lt;p&gt;And honestly? There&amp;rsquo;s something nice about this whole setup. Shooting film, developing it myself, then scanning on hardware from the same era. Staying in the 90s, before the digital revolution took over. Full nostalgia, and I love it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;lessons-learned&#34;&gt;Lessons Learned&lt;/h2&gt;&#xA;&lt;p&gt;&lt;strong&gt;Old hardware documentation is gold.&lt;/strong&gt; The service manual and SANE source code were essential. Without them, I&amp;rsquo;d still be guessing at protocol details.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Disassembly isn&amp;rsquo;t always the answer.&lt;/strong&gt; I spent hours in the 68k disassembler when the SANE source had all the answers in readable C.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Don&amp;rsquo;t trust 0-indexed vs 1-indexed assumptions.&lt;/strong&gt; The frame numbering mismatch cost me a whole evening of debugging.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Old computers are still useful.&lt;/strong&gt; My SE/30 now has a real job: digitizing my negatives.&lt;/p&gt;&#xA;&lt;p&gt;The code is available on GitHub: &lt;a href=&#34;https://github.com/ronangaillard/fs200-classic-mac&#34; target=&#34;_blank&#34;&gt;fs200-classic-mac&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;Now if you&amp;rsquo;ll excuse me, I have about 50 rolls of film to scan&amp;hellip;&lt;/p&gt;&#xA;</description>
    </item>
    <item>
      <title>From Frustration to App Store: The Story of Workspace Profiles</title>
      <link>https://ronangaillard.com/posts/workspace-profiles-blog-post-en/</link>
      <pubDate>Wed, 03 Sep 2025 15:30:00 +0200</pubDate>
      <guid>https://ronangaillard.com/posts/workspace-profiles-blog-post-en/</guid>
      <description>&lt;p&gt;&lt;em&gt;Or, how I turned an annoying daily routine into a Mac app.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-problem-the-tyranny-of-manual-settings&#34;&gt;The Problem: The Tyranny of Manual Settings&lt;/h2&gt;&#xA;&lt;p&gt;Like many developers, my day is punctuated by context switches. I plug my MacBook into a docking station at the office, connect it to an external monitor at home, put on a headset for a call&amp;hellip; and each time, it&amp;rsquo;s the same ritual: open System Settings, find the right Display panel, rearrange my screens, then go to the Sound panel to select the correct microphone and speakers. It&amp;rsquo;s a waste of a few seconds, but repeated multiple times a day, it became a constant source of frustration.&lt;/p&gt;&#xA;&lt;p&gt;It was this small, daily annoyance that sparked the idea for a solution. A generic solution. A simple, lightweight app that would live in the menu bar and let me switch between my configurations with a single click. Today, I&amp;rsquo;m incredibly proud to announce that this idea has become a reality: &lt;a href=&#34;https://apps.apple.com/fr/app/workspace-profiles/id6751802172?l=en-GB&amp;amp;mt=12&#34; target=&#34;_blank&#34;&gt;Workspace Profiles is now available on the Mac App Store!&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/wokspace%20profiles/profiles.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/wokspace%20profiles/profiles.png&#34; alt=&#34;Image of the Workspace Profiles app icon in the macOS menu bar&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;em&gt;The Workspace Profiles settings window where you can configure your different profiles.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;The core of the problem is simple: macOS remembers some configurations, but it&amp;rsquo;s far from perfect. If you use multiple external monitors or various audio devices at different times, the system can get confused.&lt;/p&gt;&#xA;&lt;p&gt;My typical use cases:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;At the Office:&lt;/strong&gt; My MacBook is connected to two Dell monitors. I want the left monitor to be my primary display, and I need the audio to come out of the monitor&amp;rsquo;s built-in speakers.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;At Home:&lt;/strong&gt; I plug it into my Studio Display. This screen should become the primary one, and the sound needs to go through my external USB speakers.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;On the Go / Podcasting:&lt;/strong&gt; Just the MacBook, but I want my external Rode NT-USE mic to be the default audio input as soon as it&amp;rsquo;s connected.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Each transition required manual reconfiguration. I wanted a solution that would understand the context and adapt automatically.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-solution-profiles-for-every-workspace&#34;&gt;The Solution: Profiles for Every Workspace&lt;/h2&gt;&#xA;&lt;p&gt;Workspace Profiles is built on a simple concept: &lt;strong&gt;Profiles&lt;/strong&gt;. A profile is a snapshot of a desired configuration. For each profile, you can define:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Display Arrangement:&lt;/strong&gt; Whether you want an extended desktop or mirrored displays&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Primary Display:&lt;/strong&gt; Which of your monitors should have the menu bar and the Dock&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Audio Devices:&lt;/strong&gt; Which microphone (input) and speakers/headphones (output) to use by default&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Once you&amp;rsquo;ve created your profiles (e.g., &amp;ldquo;Office&amp;rdquo;, &amp;ldquo;Home&amp;rdquo;, &amp;ldquo;Presentation&amp;rdquo;), you can switch between them in three ways:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Manually&lt;/strong&gt; from the menu bar icon.&lt;/li&gt;&#xA;&lt;li&gt;With a &lt;strong&gt;global keyboard shortcut&lt;/strong&gt; that you can set for each profile&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Automatically&lt;/strong&gt; using &amp;ldquo;Triggers&amp;rdquo;.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;This last feature is, in my opinion, the most powerful. You can link a profile to the connection of a specific device. For example: &amp;ldquo;When my Dell monitor is connected, automatically activate the &amp;lsquo;Office&amp;rsquo; profile.&amp;rdquo; And just like that, the magic happens!&lt;/p&gt;&#xA;&lt;h2 id=&#34;a-technical-look-under-the-hood&#34;&gt;A Technical Look Under the Hood&lt;/h2&gt;&#xA;&lt;p&gt;Developing this app was an interesting journey into the low-level APIs of macOS, far from the usual comfort of SwiftUI. Here are a few of the most interesting technical challenges I tackled.&lt;/p&gt;&#xA;&lt;h3 id=&#34;detecting-hardware-changes&#34;&gt;Detecting Hardware Changes&lt;/h3&gt;&#xA;&lt;p&gt;The first essential building block was knowing when a display or audio device was connected or disconnected. To do this, I had to dive into macOS&amp;rsquo;s C-based frameworks.&lt;/p&gt;&#xA;&lt;p&gt;For displays, I used the &lt;code&gt;CGDisplayRegisterReconfigurationCallback&lt;/code&gt; function from the &lt;strong&gt;Core Graphics&lt;/strong&gt; framework. This allows you to register a callback function that the system will call whenever the display configuration changes.&lt;/p&gt;&#xA;&lt;p&gt;For audio, it&amp;rsquo;s a similar approach with &lt;code&gt;AudioObjectAddPropertyListener&lt;/code&gt; from the &lt;strong&gt;Core Audio&lt;/strong&gt; framework. This lets you listen for changes to the &lt;code&gt;kAudioHardwarePropertyDevices&lt;/code&gt; property on the global system object.&lt;/p&gt;&#xA;&lt;p&gt;In Swift, the code to bridge these C calls looks like this:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-swift&#34; data-lang=&#34;swift&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// In HardwareManager.swift&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;func&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;startMonitoringDisplayChanges&lt;/span&gt;() {&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    CGDisplayRegisterReconfigurationCallback(displayReconfigurationCallBack, &lt;span style=&#34;color:#66d9ef&#34;&gt;nil&lt;/span&gt;)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;func&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;startMonitoringAudioChanges&lt;/span&gt;() {&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;var&lt;/span&gt; propertyAddress = AudioObjectPropertyAddress(&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        mSelector: kAudioHardwarePropertyDevices,&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        mScope: kAudioObjectPropertyScopeGlobal,&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        mElement: kAudioObjectPropertyElementMain&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    )&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    AudioObjectAddPropertyListener(&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        AudioObjectID(kAudioObjectSystemObject), &amp;amp;propertyAddress, audioDevicesPropertyListener, &lt;span style=&#34;color:#66d9ef&#34;&gt;nil&lt;/span&gt;)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;These callbacks then post a notification via Swift&amp;rsquo;s &lt;code&gt;NotificationCenter&lt;/code&gt; so the rest of the app can react cleanly.&lt;/p&gt;&#xA;&lt;h3 id=&#34;handling-notification-bursts&#34;&gt;Handling Notification &amp;ldquo;Bursts&amp;rdquo;&lt;/h3&gt;&#xA;&lt;p&gt;A gotcha I quickly encountered, especially with audio devices, is that the system sometimes sends a burst of notifications for a single event (e.g., a device initializing). If I applied the profile on every single notification, the app would perform redundant actions.&lt;/p&gt;&#xA;&lt;p&gt;The solution is a classic technique called &amp;ldquo;debouncing&amp;rdquo;. Instead of handling the event immediately, I schedule it to run after a one-second delay using a &lt;code&gt;DispatchWorkItem&lt;/code&gt;. If a new notification comes in during that short window, I cancel the previously scheduled task and plan a new one. This ensures that only the last event in a rapid series is actually processed.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-swift&#34; data-lang=&#34;swift&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// In ProfileManager.swift&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;var&lt;/span&gt; audioChangeWorkItem: DispatchWorkItem?&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;@objc&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;func&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;scheduleAudioChangeHandler&lt;/span&gt;() {&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;self&lt;/span&gt;.audioChangeWorkItem?.cancel() &lt;span style=&#34;color:#75715e&#34;&gt;// Cancel the previous one&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;let&lt;/span&gt; newWorkItem = DispatchWorkItem { [&lt;span style=&#34;color:#66d9ef&#34;&gt;weak&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;self&lt;/span&gt;] &lt;span style=&#34;color:#66d9ef&#34;&gt;in&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;self&lt;/span&gt;?.handleAudioChange()  &lt;span style=&#34;color:#75715e&#34;&gt;// Run the actual logic&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#75715e&#34;&gt;// Schedule the new one&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    DispatchQueue.main.asyncAfter(deadline: .now() &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;1.0&lt;/span&gt;, execute: newWorkItem)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;self&lt;/span&gt;.audioChangeWorkItem = newWorkItem&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id=&#34;implementing-global-keyboard-shortcuts&#34;&gt;Implementing Global Keyboard Shortcuts&lt;/h3&gt;&#xA;&lt;p&gt;To allow users to trigger profiles from anywhere in macOS, I needed to implement system-wide (or &amp;ldquo;global&amp;rdquo;) hotkeys. Modern Swift and AppKit APIs are sandboxed and don&amp;rsquo;t make this easy. The solution was to reach back to an older, but incredibly powerful, C-based framework: &lt;strong&gt;Carbon&lt;/strong&gt;.&lt;/p&gt;&#xA;&lt;p&gt;By using the &lt;code&gt;RegisterEventHotKey&lt;/code&gt; function, the app can tell the system to listen for a specific key combination, no matter which application is currently active. The system then sends an event to my app when the hotkey is pressed. It feels a bit like archeology, but it&amp;rsquo;s the most reliable way to get this done on macOS.&lt;/p&gt;&#xA;&lt;h2 id=&#34;whats-next&#34;&gt;What&amp;rsquo;s Next&lt;/h2&gt;&#xA;&lt;p&gt;Building Workspace Profiles has been a fantastic learning experience, taking me from a simple idea born out of personal frustration to a polished app on the Mac App Store. It was built natively with SwiftUI to feel right at home on your Mac.&lt;/p&gt;&#xA;&lt;p&gt;I invite you to &lt;a href=&#34;https://apps.apple.com/fr/app/workspace-profiles/id6751802172?l=en-GB&amp;amp;mt=12&#34; target=&#34;_blank&#34;&gt;check it out on the Mac App Store&lt;/a&gt;. Your feedback is invaluable, so please don&amp;rsquo;t hesitate to report bugs or request new features on the project&amp;rsquo;s GitHub repository. Thank you for your support!&lt;/p&gt;&#xA;</description>
    </item>
    <item>
      <title>A Cheap and Simple ADB to USB Converter</title>
      <link>https://ronangaillard.com/posts/cheap-adb-to-usb-converter/</link>
      <pubDate>Fri, 05 Nov 2021 10:44:00 +0100</pubDate>
      <guid>https://ronangaillard.com/posts/cheap-adb-to-usb-converter/</guid>
      <description>&lt;p&gt;&lt;em&gt;A Pro Micro, a resistor, a mini-DIN connector and a tiny PCB to use my Apple keyboard on a modern Mac&lt;/em&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;&lt;em&gt;Updates&lt;/em&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;[April 2022] Added the tips from the 68kMLA forum thread at the end of this post.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;I discovered the Apple Keyboard M0116 with my Mac SE/30 and I fell in love with it. The Control key is where it should be, next to the A. The Alps switches feel great, a bit noisy but not too much. And the style : the Apple font of the time on the keycaps, the little rainbow Apple logo in the corner&amp;hellip; This keyboard is incredible, so I really wanted to use it every day on my modern Mac. And I have been doing so for more than a year now.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/adb2usb/m0116.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/adb2usb/m0116.jpg&#34; alt=&#34;The Apple Keyboard M0116&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&#xA;&lt;em&gt;Photo : &lt;a href=&#34;https://commons.wikimedia.org/wiki/File:Apple_%28Standard%29_Keyboard_M0116.jpg&#34; target=&#34;_blank&#34;&gt;baku13, Wikimedia Commons&lt;/a&gt;, CC BY-SA 3.0&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;The only problem is that it speaks ADB, the bus Apple used before USB.&lt;/p&gt;&#xA;&lt;p&gt;When I looked for an ADB to USB converter I was shocked by the price. Then I found out that &lt;a href=&#34;https://qmk.fm&#34; target=&#34;_blank&#34;&gt;QMK&lt;/a&gt;, the firmware a lot of people use on their DIY mechanical keyboards, supports ADB keyboards. All you need is a microcontroller with native USB like the ATmega32U4.&lt;/p&gt;&#xA;&lt;p&gt;So I bought an Arduino Pro Micro, flashed it with QMK and I was good to go. Except that for more than a year I had three wires flying between the Pro Micro and the ADB connector, and they kept disconnecting. So I finally drew a small PCB to clean this up.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/adb2usb/keyboard.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/adb2usb/keyboard.jpg&#34; alt=&#34;The converter on my keyboard&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-board&#34;&gt;The board&lt;/h2&gt;&#xA;&lt;p&gt;There is not much on it : the footprint of the Pro Micro, a 4 pin mini-DIN connector for the ADB cable, and a resistor. The resistor is a pull-up on the ADB data line, anything between 1k and 10k works. Everything is through hole so anyone can solder it.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/adb2usb/render.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/adb2usb/render.jpg&#34; alt=&#34;The PCB in KiCad&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;The ADB connector is a standard 4 pin mini-DIN, the same one used for S-Video, so it is easy to find.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/adb2usb/pro-micro.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/adb2usb/pro-micro.jpg&#34; alt=&#34;The Pro Micro side&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/adb2usb/back.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/adb2usb/back.jpg&#34; alt=&#34;The back of the board&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;The KiCad files are on GitHub : &lt;a href=&#34;https://github.com/ronangaillard/adb2usb&#34; target=&#34;_blank&#34;&gt;ronangaillard/adb2usb&lt;/a&gt;. I ordered mine from JLCPCB, it was around 4.50$ for 5 boards shipping included. Add a Pro Micro (around 10$) and the converter costs a fraction of the commercial ones.&lt;/p&gt;&#xA;&lt;h2 id=&#34;flashing&#34;&gt;Flashing&lt;/h2&gt;&#xA;&lt;p&gt;The first time, you need to flash the DFU bootloader on the Pro Micro with an ISP programmer, following the &lt;a href=&#34;https://github.com/qmk/qmk_firmware/blob/master/docs/isp_flashing_guide.md&#34; target=&#34;_blank&#34;&gt;QMK guide&lt;/a&gt;. Then on macOS install QMK :&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;brew install qmk/qmk/qmk&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Build the firmware :&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;qmk compile -kb converter/adb_usb -km default&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And flash it :&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;qmk flash -kb converter/adb_usb -km default&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And you&amp;rsquo;re good to go !&lt;/p&gt;&#xA;&lt;p&gt;A really cool thing with QMK is that you can change the key mapping very easily. For instance I remapped the Power key at the top of the keyboard to Play/Pause.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-happened-next&#34;&gt;What happened next&lt;/h2&gt;&#xA;&lt;p&gt;I posted it on the &lt;a href=&#34;https://68kmla.org/bb/threads/cheap-and-simple-adb-to-usb-converter.39402/&#34; target=&#34;_blank&#34;&gt;68kMLA forum&lt;/a&gt; and it got a lot more attention than I expected. People from the forum ordered boards, built converters and helped each other with the flashing. A few useful tips came out of the thread :&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;any Pro Micro compatible board with an ATmega32U4 works, like the Elite-C. Boards with a USB-C port are a good idea since the micro USB port of the Pro Micro tends to rip off the board&lt;/li&gt;&#xA;&lt;li&gt;you can solder the Pro Micro on the underside of the PCB, so the board supports its USB port&lt;/li&gt;&#xA;&lt;li&gt;boards like the Elite-C already come with a bootloader that works with QMK Toolbox, no ISP programmer needed&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</description>
    </item>
    <item>
      <title>68net : A Modern SCSI2Ethernet Board - Part 1</title>
      <link>https://ronangaillard.com/posts/68net-a-modern-scsi2ethernet-board-part1/</link>
      <pubDate>Tue, 10 Nov 2020 10:30:29 +0100</pubDate>
      <guid>https://ronangaillard.com/posts/68net-a-modern-scsi2ethernet-board-part1/</guid>
      <description>&lt;p&gt;&lt;em&gt;Want to discuss about this project ? &lt;a href=&#34;https://68kmla.org/forums/topic/61287-68net-a-modern-scsi-to-ethernet-board/&#34; target=&#34;_blank&#34;&gt;Go here !&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;introduction&#34;&gt;Introduction&lt;/h2&gt;&#xA;&lt;p&gt;I had been looking to connect my SE/30 to the internet but I struggled to find PDS boards that would fit in it. It is particularly difficult as such boards are rare, but there is also very little space available in the SE/30, so most cards won&amp;rsquo;t fit in. Apart from the internal PDS port the SE/30 has another expansion port : SCSI, which is an improved parallel port. It is available at the back of the computer as a DB25 port. But all SCSI to Ethernet devices from back in the days that I found were missing floppies with drivers. Moreover most of this stuff was in the US so shipping would be really expensive. Then I found about &lt;a href=&#34;https://68kmla.org/forums/topic/58413-scsi-to-ethernet-adapter-on-new-hardware/&#34; target=&#34;_blank&#34;&gt;scuznet&lt;/a&gt;, a modern design by @saybur. It was really interesting because it was made to work with an unpatched Nuvotech driver. But there was a few things I did want to change/improve to the design, including the fact that it would be really cool to be able to upgrade the firmware by USB so that the community could easily develop and upgrade the firmware.&lt;/p&gt;&#xA;&lt;p&gt;The goals of this project is to make a cheap, open source, community driven, and reliable board.&lt;/p&gt;&#xA;&lt;p&gt;This is the original design by @saybur :&#xA;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/scuznet.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/scuznet.jpg&#34; alt=&#34;scuznet&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;So I decided to create a new design based on this. Here are the main things I changed/added to the original design :&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;The microcontroller is replaced by a STM32 (ARM chip) that will be upgradable by USB. In the future it also means a firmware could handle Ethernet over USB, and you could share files between your modern computer just by connecting to you vintage mac by USB;&lt;/li&gt;&#xA;&lt;li&gt;The board is self powered by the SCSI&lt;/li&gt;&#xA;&lt;li&gt;In case external power is needed (such as on the Macintosh Plus I guess) it can be provided by USB (instead of a power jack on the original design)&lt;/li&gt;&#xA;&lt;li&gt;I added mouting holes to the PCB so that we can design an acrylic case later;&lt;/li&gt;&#xA;&lt;li&gt;I removed the SD Card slot because I like things that does a simple task but does it well (Good design is as little design as possible)&lt;/li&gt;&#xA;&lt;li&gt;Designed in Kicad 5 (instead of Kicad 4)&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;On my design, power can be provided by the SCSI bus or/and the USB port. The board has some circuitry that automatically switches between these two sources, so for the end user it is really convenient. On a Mac that has a stable SCSI 5V you just have to plug the board, on other Macs plug the board and connect the USB port to a USB wall charger and that&amp;rsquo;s it. It also means the board can be connected to the turned on Mac while updating the firmware, which will also be super convenient while developing a new firmware.&lt;/p&gt;&#xA;&lt;p&gt;SCSI Termination is for the moment always enabled, I do not see this as an issue. If I discover that it should be software controlled I will tweak the design in the future.&lt;/p&gt;&#xA;&lt;p&gt;Here is what the final board could look like :&#xA;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/68net-render.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/68net-render.png&#34; alt=&#34;68net render&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;That design is available on my &lt;a href=&#34;https://github.com/ronangaillard/68net&#34; target=&#34;_blank&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;h3 id=&#34;next-steps&#34;&gt;Next steps&lt;/h3&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;Wait for some feedbacks and eventually tweak a bit the current design &lt;em&gt;(~1 week)&lt;/em&gt;&lt;/li&gt;&#xA;&lt;li&gt;Manufacture some boards and assemble them &lt;em&gt;(~1 month as boards will get manufactured in China)&lt;/em&gt;&lt;/li&gt;&#xA;&lt;li&gt;Write an initial firmware and make sure design works as expected &lt;em&gt;(~1 week to get basic stuff running)&lt;/em&gt;&lt;/li&gt;&#xA;&lt;li&gt;Port the Nuvotech protocol to the board (and find a way to make it work with A/UX if time is found) &lt;em&gt;(timing depends on so much things)&lt;/em&gt;&lt;/li&gt;&#xA;&lt;li&gt;Try to distribute the board as a kit or pre-assembled &lt;em&gt;(timing depends on so much things)&lt;/em&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;on-the-technical-side&#34;&gt;On The Technical Side&lt;/h2&gt;&#xA;&lt;p&gt;Schematics are available &lt;a href=&#34;https://ronangaillard.com/68net-schematics-rev-0.pdf&#34;&gt;here&lt;/a&gt; (may not be up to date, check on my &lt;a href=&#34;https://github.com/ronangaillard/68net&#34; target=&#34;_blank&#34;&gt;GitHub&lt;/a&gt; for latest content).&lt;/p&gt;&#xA;&lt;p&gt;The design is based around an STM32F072R8, for three main reasons :&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;it&amp;rsquo;s an ARM chip, so development tools are open source and powerful (and I have experience with STM32 as well)&lt;/li&gt;&#xA;&lt;li&gt;it has an USB DFU bootloader, so any end-user will be able to upgrade the board without expensive hardware flashing tool&lt;/li&gt;&#xA;&lt;li&gt;it has a lot of GPIOs that is necessary to talk using SCSI&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The signals between the STM32 and the SCSI are divided into three main categories :&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;SCSI -&amp;gt; STM32 (ATN, ACK, &amp;hellip;) : these are unidirectional signals from initiator, I use a 74LVC14 chip to do the level conversion. Indeed the SCSI bus communicates at 2.85V but some peripherals can talk at 5.5V, so we need to protect the STM32 which does not have a lot of 5V tolerant GPIOS.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/68net-74lvc14-small.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/68net-74lvc14-small.png&#34; alt=&#34;74lvc14&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;STM32 -&amp;gt; SCSI (CD, IO, &amp;hellip;) : these are unidirectional signals from target, I use multiple 74LVTH126 to drive these control pins in Open Drain, this make sure no device on the bus can be damaged in case of multiple devices talking at the same time. This also make sure the board talks at the 2.85V SCSI voltage.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/68net-74lvth126.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/68net-74lvth126.png&#34; alt=&#34;74lvth126&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;STM32 &amp;lt;-&amp;gt; SCSI : bidirectional signals that will use the 74LVTH126 and the 74LVC14. One particularity is the data lines. You may have noticed that the 74LVC14 inverts the signal logic. This is not an issue for control lines, as software can handle inverted signal with simple &lt;code&gt;not&lt;/code&gt;. The problem for the data lines is that you often want to transfer the data received as quickly as possible to your peripheral (here the Ethernet chip). If you want to be fast you don&amp;rsquo;t want to &lt;code&gt;not&lt;/code&gt; all received data before transfering, that&amp;rsquo;s why you&amp;rsquo;ll see the 74LVC245 chip handling the received data line that does the level convert without inverting the data.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/68net-74lvc245.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/68net-74lvc245.png&#34; alt=&#34;74lvc245&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;You may now wonder where does the 2.85V comes from. I use a simple adjustable LM1117 to generate it, but you may notice that it produces something more like 2.63V. It is due to the fact that termination resistors (&lt;code&gt;RN1&lt;/code&gt; and &lt;code&gt;RN2&lt;/code&gt;) are supposed to be 110ohms, but 110ohms resistor networks are not available at the time of writing on DigiKey. So I chose the nearest available value which is 100ohms, and for that value the voltage should be 2.63V. (and yes SCSI devices need a termination to avoid reflection).&lt;/p&gt;&#xA;&lt;p&gt;The rest is relatively classical design, the STM32 is connected to the Ethernet chip &lt;code&gt;ENC28J60&lt;/code&gt; by SPI. You can also notice the two schottky diodes managing the input voltage (USB and SCSI TRMPWRP) with a low lost voltage.&lt;/p&gt;&#xA;&lt;p&gt;Don&amp;rsquo;t hesitate to give some feedback &lt;a href=&#34;%28https://68kmla.org/forums/topic/61287-68net-a-modern-scsi-to-ethernet-board/%29&#34;&gt;here&lt;/a&gt; !&lt;/p&gt;&#xA;</description>
    </item>
    <item>
      <title>My Mac SE/30 Is Back Alive</title>
      <link>https://ronangaillard.com/posts/my-mac-se30-is-back-alive/</link>
      <pubDate>Sun, 25 Oct 2020 23:02:46 +0100</pubDate>
      <guid>https://ronangaillard.com/posts/my-mac-se30-is-back-alive/</guid>
      <description>&lt;p&gt;&lt;em&gt;Or how I fixed my Mac SE/30 that was not booting unexpectedly&lt;/em&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;&lt;em&gt;Updates&lt;/em&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;[02 November 2020] This article was also featured on Hackaday : &lt;a href=&#34;https://hackaday.com/2020/11/01/bodge-wire-saves-a-vintage-mac-se-30-from-the-heap/&#34; target=&#34;_blank&#34;&gt;BODGE WIRE SAVES A VINTAGE MAC SE/30 FROM THE HEAP&lt;/a&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;One day around a year ago I was playing with my SE/30 (the best looking computer ever) and I was trying to install Debian on it for fun. But after inserting some floppies and trying to boot from them the computer wouldn&amp;rsquo;t turn back on. I mean it would turn on but with weird static graphics and no sound at startup. I already had a hard time figuring out how I would boot Linux on that machine (which I succeeded at doing) that I did not want to spend more time fixing it.&lt;/p&gt;&#xA;&lt;p&gt;Here is what I was left with :&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/simasimac.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/simasimac.jpg&#34; alt=&#34;simasimac&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;A few weeks ago I really wanted to play again with my old Macintosh and experiment with A/UX, the first Apple unix OS ! So I started looking on the internet to see if the problem I had was common. And indeed it was ! I learnt it was called Simasimac and often happened because old electrolytic capacitors would leak. It could lead to two main things : traces could be connected together because this leak could be conductive, or it could corrode traces and make them not continuous anymore.&lt;/p&gt;&#xA;&lt;p&gt;So I had to open my Mac SE/30 for the first time. Hopefully I had bought a few years ago the very long screwdrivers you need to open these vintage Macs. And after taking the logic board out I did notice some minor leakage but no sign of &amp;ldquo;cracks&amp;rdquo; on the capacitors. An easy solution I found on the internet was to wash the logic board in the dishwasher (crazy !?), and it happened to often solve the first problem of capacitor leaks : the short circuit between traces. As I had no dishwasher I took a tooth brush and wash the logic board under clear water insisting on areas with leakage. I let it dry for one big day, finished cleaning it with alcohol and tried to boot the SE/30 again. But it would still display those weird zebra stripes on the screen and would not boot.&lt;/p&gt;&#xA;&lt;p&gt;The logic board before cleaning :&#xA;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/dirty-mobo.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/dirty-mobo.jpg&#34; alt=&#34;dirty mobo&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;I also saw on the internet that a bad/discharged battery (the red one you see on the previous picture) could lead to boot problems and simasimac. I did not really trust this as I am convinced this is only used to keep time and some user settings (such as mouse speed) in a volatile RAM. And I knew it was discharged from a long time ago (even when the Mac did boot normally) because the Mac would not keep time when it was turned off. Anyway I would still need a new battery when the Mac would be back to normal so I bought one off Amazon, put it in and tried to boot again. But no surprise it still did not want to boot !&lt;/p&gt;&#xA;&lt;p&gt;Later readings also taught me that if the simasimac stayed after Logic Board cleaning one solution could be to recap the all board. Thanks to this excellent article : &lt;a href=&#34;https://recapamac.com.au/macintosh-se-30/&#34; target=&#34;_blank&#34;&gt;https://recapamac.com.au/macintosh-se-30/&lt;/a&gt; I was able to quickly buy the needed caps off DigiKey and receive them in less than 48 hours (even in France !).&lt;/p&gt;&#xA;&lt;p&gt;For reference here are the parts I ordered :&lt;/p&gt;&#xA;&lt;table&gt;&#xA;&#x9;&lt;thead&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;Capacitance&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;DK Part Number&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;Qty&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/thead&gt;&#xA;&#x9;&lt;tbody&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;1uF&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;399-3730-1-ND&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;1&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;220uF&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;1572-1103-ND&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;1&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;470uF&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;1572-1494-ND&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;1&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;47uF&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;399-18075-1-ND&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;10&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;I would highly advise to recap your vintage Mac as soon as possible to avoid such issues I had (and that many others have). It could destroy your Logic Board and you could never boot you mac again. It&amp;rsquo;s quite easy to do if you have some equipment, patience and some little skills.&lt;/p&gt;&#xA;&lt;p&gt;So I used my hot air gun to remove all these caps and most of the little one exploded. They do really smell awful when they do so, I would advise to do it in a well ventilated area. I have to say I was a bit scared to damage the PCB because some traces looked really fragile. I had some issues removing the axial capacitors because the original soldering tin was hard to melt. I&amp;rsquo;m not sure why, I tried to change temperature from 300 degrees Celsius to 400, but it was not really easy.&lt;/p&gt;&#xA;&lt;p&gt;I then soldered back every caps, again axial caps where hard to put back because even with my desoldering needles I had a hard time putting the capacitors legs through the holes. Anyway, everything was soldered back :&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/recapped-mobo.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/recapped-mobo.jpg&#34; alt=&#34;recapped mobo&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;I&amp;rsquo;m not super happy about the result, contacts are well made and well soldered but capacitors are not always well aligned.&lt;/p&gt;&#xA;&lt;p&gt;But I was super excited because I thought my problem was be solved. Most sites and articles I read said recapping was the ultimate fixing step. So I confidently plugged everything back and turned on my Mac. Nothing was fixed at all, it was still in the same state as the beginning.&lt;/p&gt;&#xA;&lt;p&gt;So I was back to reading articles, blog posts, forums and reddit to find people having the same issues. I found out bad RAM chips could lead to my issue, so I started removing RAM bars or swapping them but nothing changed. So I posted to Reddit to get some feedback and advice : &lt;a href=&#34;https://www.reddit.com/r/VintageApple/comments/jh5oo1/simasimac_even_after_recapping_any_hint_or_advice/&#34; target=&#34;_blank&#34;&gt;https://www.reddit.com/r/VintageApple/comments/jh5oo1/simasimac_even_after_recapping_any_hint_or_advice/&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;I learnt that it could be a problem with the ROM : either the chip itself or the traces between the CPU and the ROM. I did not really know where to start, so I searched for the SE/30 schematics and found them here : &lt;a href=&#34;https://museo.freaknet.org/gallery/apple/stuff/mac/andreas.kann/schemat.html&#34; target=&#34;_blank&#34;&gt;https://museo.freaknet.org/gallery/apple/stuff/mac/andreas.kann/schemat.html&lt;/a&gt; I noticed the ROM is directly connected to the CPU (which is in fact of no surprise and classical design as it is the first boot device). So I searched for the 68030 (CPU) pinout on Google and found it at the end of the NXP datasheet : &lt;a href=&#34;https://www.nxp.com/docs/en/reference-manual/MC68030UM.pdf&#34; target=&#34;_blank&#34;&gt;https://www.nxp.com/docs/en/reference-manual/MC68030UM.pdf&lt;/a&gt; The next thing I had to do is to make sure each address trace, and each data trace was well connected between the CPU and the ROM socket. I used my multimeter in continuous mode and got the following pin mapping on the Logic Board :&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/rom-socket-matching.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/rom-socket-matching.jpg&#34; alt=&#34;rom socket mapping&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;So I knew my CPU was well connected to my ROM. My ROM itself could have defects but I wanted to make sure every peripherals worked before buying a new ROM because they are quite expensive on eBay.&lt;/p&gt;&#xA;&lt;p&gt;After some more discussion on Reddit I decided to do the same thing I did with ROM but for the RAM. If you look at the schematics you&amp;rsquo;ll notice that the connections between the RAM and the CPU is a bit more complex because it involves many more logic (for paging for instance). So I started to check the traces between the CPU and the Glue Logic Chip (which is the chip between the CPU and the RAM). Still using the schematics I noticed that not all address bus bits were connected between these two chips. So I tested the one that were supposed to be connected with my multimeter, and I found out that the A24 trace seemed to be not continuous :&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/a24-trace.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/a24-trace.jpg&#34; alt=&#34;A24 trace&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;I was half surprised because the A24 trace goes below the C11 capacitor. And that non continuous trace could be explained by the fact that the old C11 capacitor leaked, and that the leakage corroded or destroyed the trace. So I did a hot fix with a stripping wire going directly from the Glue Chip to the CPU :&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/a24-fix.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/a24-fix.jpg&#34; alt=&#34;A24 fix&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;I then plugged everything back, and success !! It&amp;rsquo;s alive ! The weird zebra stripes had disappeared and it was showing the mouse cursor !&lt;/p&gt;&#xA;&lt;p&gt;Unfortunately it does not boot from the internal hard drive, I&amp;rsquo;m not sure if I wiped it when trying to install Linux or not. So I created a new floppy boot disk with System 7.5. But unfortunately again the SE/30 does not want to boot from that floppy disk. I am still a bit confused as the floppy drive used to work really well. So I removed it from the Mac case and cleaned the drive heads with alcohol. But still, it doesn&amp;rsquo;t want to boot from my floppy. I guess the next step is to dismantle the whole floppy drive and give it a good clean. In the mean time I bought a SCSI2SD which will make everything easier and more reliable.&lt;/p&gt;&#xA;&lt;p&gt;Thanks again to everyone on Reddit who did advise me well on how to spot the issue on my Mac SE/30. Today the floppy drive problem does not frighten me as I&amp;rsquo;m confident to fix it easily, and even if I don&amp;rsquo;t fix it I will be able to use the SCSI2SD to boot and use my SE/30.&lt;/p&gt;&#xA;</description>
    </item>
    <item>
      <title>Turning an Arduino into a Logitech Wireless Mouse</title>
      <link>https://ronangaillard.com/posts/arduino-logitech-wireless-mouse/</link>
      <pubDate>Thu, 02 Feb 2017 21:00:00 +0100</pubDate>
      <guid>https://ronangaillard.com/posts/arduino-logitech-wireless-mouse/</guid>
      <description>&lt;p&gt;&lt;em&gt;A 2 euros radio module, a Logitech Unifying dongle and a mouse you wear on your wrist : our last year project at Supélec&lt;/em&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;&lt;em&gt;Updates&lt;/em&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;[10 March 2018] This project was featured on Hackaday : &lt;a href=&#34;https://hackaday.com/2018/03/10/wireless-protocol-reverse-engineered-to-create-wrist-wearable-mouse/&#34; target=&#34;_blank&#34;&gt;WIRELESS PROTOCOL REVERSE ENGINEERED TO CREATE WRIST WEARABLE MOUSE&lt;/a&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;This winter, for our last year project at Supélec, Guilhem and I had to build a wireless 3D mouse : you move your arm, the cursor moves on the screen. I took care of the wireless part and Guilhem of the motion sensing.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/logitech-mouse/wrist-mouse.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/logitech-mouse/wrist-mouse.jpg&#34; alt=&#34;The wrist mouse&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;why-not-just-make-our-own-dongle-&#34;&gt;Why not just make our own dongle ?&lt;/h2&gt;&#xA;&lt;p&gt;The obvious way to do it is to make two boards : one in the mouse and one plugged into the computer. Our first version did exactly that, with an mbed board and an nRF24L01 radio module acting as a USB mouse. It worked, but it was boring. Everybody already has a Logitech dongle plugged into their computer, so why not talk to it directly ?&lt;/p&gt;&#xA;&lt;h2 id=&#34;opening-a-logitech-keyboard&#34;&gt;Opening a Logitech keyboard&lt;/h2&gt;&#xA;&lt;p&gt;I had a Logitech wireless keyboard (the one with the touchpad) at home, so I opened it to see which radio it used. The chip is an nRF24LE1 from Nordic : basically an nRF24L01, the 2 euros module everybody uses with Arduinos, with a microcontroller in the same package. That was great news : it meant we could speak the same &amp;ldquo;language&amp;rdquo; as the dongle with cheap off the shelf modules.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/logitech-mouse/teardown.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/logitech-mouse/teardown.jpg&#34; alt=&#34;Inside the Logitech keyboard&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;listening-to-the-pairing&#34;&gt;Listening to the pairing&lt;/h2&gt;&#xA;&lt;p&gt;With an nRF24 in sniffing mode we found the address the dongle listens to when it is in pairing mode, and the channels it uses : 25 channels, 3 MHz apart starting at 2402 MHz.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/logitech-mouse/address-channel.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/logitech-mouse/address-channel.jpg&#34; alt=&#34;Pairing address and channels&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;Then we put the dongle in pairing mode with the Unifying software, paired the keyboard and recorded everything. The pairing is made of 4 packets sent by the device. Each one starts with a packet type and the step number of the pairing, and ends with a checksum. The checksum turned out to be simple : add up all the bytes and negate the result.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/logitech-mouse/pairing-packet.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/logitech-mouse/pairing-packet.jpg&#34; alt=&#34;First pairing packet&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;The tricky part was the answers. The dongle never sends packets of its own : it replies inside the acknowledgement of our packets (the nRF24 &amp;ldquo;ACK payload&amp;rdquo; feature). So the device sends a packet, then a short one just to give the dongle a chance to reply, and reads the answer in the ACK. In the first answer the dongle gives the new address the device must use from now on.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/logitech-mouse/pairing-ack.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/logitech-mouse/pairing-ack.jpg&#34; alt=&#34;The dongle answer with the new address&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;Once we replayed these 4 packets from an Arduino, with the new address and the right checksums, the Unifying software was happy. It even told us our Arduino was a keyboard, since we had copied the keyboard&amp;rsquo;s packets !&lt;/p&gt;&#xA;&lt;h2 id=&#34;moving-the-mouse&#34;&gt;Moving the mouse&lt;/h2&gt;&#xA;&lt;p&gt;Once paired, moving the mouse is easy. A movement packet holds the state of the buttons, the X and Y velocity, the vertical and horizontal scroll and, of course, the checksum.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/logitech-mouse/move-packet.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/logitech-mouse/move-packet.jpg&#34; alt=&#34;Movement packet&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;We wrapped all of this in a small Arduino library : &lt;code&gt;begin()&lt;/code&gt;, &lt;code&gt;pair()&lt;/code&gt;, &lt;code&gt;reconnect()&lt;/code&gt; to reuse a previous pairing, then &lt;code&gt;move()&lt;/code&gt;, &lt;code&gt;scroll()&lt;/code&gt; and &lt;code&gt;click()&lt;/code&gt;. The code is on GitHub : &lt;a href=&#34;https://github.com/ronangaillard/logitech-mouse&#34; target=&#34;_blank&#34;&gt;ronangaillard/logitech-mouse&lt;/a&gt;. The example makes the cursor draw circles on the screen.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-3d-mouse&#34;&gt;The 3D mouse&lt;/h2&gt;&#xA;&lt;p&gt;For the motion part Guilhem used an MPU6050, a 6 axis IMU that costs less than 2 dollars. The first idea was to integrate the acceleration to get the speed of the arm. It did not work : the small error on the acceleration adds up and the speed drifts away, even when the sensor does not move.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/logitech-mouse/drift.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/logitech-mouse/drift.jpg&#34; alt=&#34;Speed drift&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;So we switched to the orientation of the sensor, computed by the MPU6050 itself. The yaw moves the cursor horizontally, the pitch vertically, and the roll changes the behaviour (right click, scrolling). A click is detected when you tap your fingers : it makes a very characteristic high frequency signal on the 3 axes.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/logitech-mouse/yaw-pitch-roll.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/logitech-mouse/yaw-pitch-roll.jpg&#34; alt=&#34;Yaw, pitch and roll&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;Everything fits in a 3D printed case : an Arduino Nano, the nRF24L01, the MPU6050 and 4 CR2025 coin cells.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/logitech-mouse/case.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/logitech-mouse/case.jpg&#34; alt=&#34;The case&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;And here it is in action :&lt;/p&gt;&#xA;&#xA;&#x9;&#x9;&#x9;&lt;div&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;style=&#34;position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;&#34;&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&lt;iframe&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;src=&#34;https://player.vimeo.com/video/202268800?dnt=0&#34;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&#x9;style=&#34;position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;&#34; allow=&#34;fullscreen&#34;&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&lt;/iframe&gt;&#xA;&#x9;&#x9;&#x9;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;Of course Bluetooth would have been simpler, but the point of the project was to build the whole communication chain ourselves. And having an Arduino that can pair with any Logitech dongle is quite fun.&lt;/p&gt;&#xA;&lt;p&gt;The presentation we gave at the end of the project (in French) is &lt;a href=&#34;https://docs.google.com/presentation/d/11bzzGMaTR-Z7rp0n-jKFRVuT4hbaj_FmaKMywX97s8k/edit&#34; target=&#34;_blank&#34;&gt;here&lt;/a&gt;.&lt;/p&gt;&#xA;</description>
    </item>
    <item>
      <title>XTrem Kernel : my Own RTOS for ARM Cortex M3</title>
      <link>https://ronangaillard.com/posts/xtrem-kernel-an-rtos-for-arm-cortex-m3/</link>
      <pubDate>Thu, 14 Jul 2016 22:20:00 +0200</pubDate>
      <guid>https://ronangaillard.com/posts/xtrem-kernel-an-rtos-for-arm-cortex-m3/</guid>
      <description>&lt;p&gt;&lt;em&gt;Tasks, priorities, semaphores, mutexes and a little shell, written from scratch for the mbed LPC1768&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;I wrote XTrem Kernel in 2015 and it has been sitting on my hard drive since then, so I just put it on GitHub : &lt;a href=&#34;https://github.com/ronangaillard/xtremkernel&#34; target=&#34;_blank&#34;&gt;ronangaillard/xtremkernel&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;I wanted to know what really happens inside a real time operating system, and the best way to know was to write one. In less than two months I wrote a multitask kernel for ARM Cortex M3 processors : thousands of lines of low level C and a few hundred lines of assembly. It runs on the NXP LPC17XX chips, so on the original &lt;a href=&#34;https://os.mbed.com/platforms/mbed-LPC1768/&#34; target=&#34;_blank&#34;&gt;mbed LPC1768&lt;/a&gt; board.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/xtremkernel/boot.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/xtremkernel/boot.png&#34; alt=&#34;XTrem Kernel booting&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-kernel&#34;&gt;The kernel&lt;/h2&gt;&#xA;&lt;p&gt;A SysTick interrupt every millisecond gives the kernel its heartbeat. The scheduler keeps one queue of runnable tasks per priority level, always runs the highest priority one, and does round robin between tasks of the same priority.&lt;/p&gt;&#xA;&lt;p&gt;The context switch itself is a few lines of assembly in the PendSV handler, the lowest priority interrupt of the Cortex M3 : it saves the registers of the current task on its stack, switches the stack pointer, and restores the registers of the next one. The Cortex M3 port of &lt;a href=&#34;http://www.tnkernel.com/&#34; target=&#34;_blank&#34;&gt;TNKernel&lt;/a&gt; was a great help to understand how to do it.&lt;/p&gt;&#xA;&lt;p&gt;On top of that the kernel provides what you expect from an RTOS :&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;tasks with priorities, created with their own stack, and &lt;code&gt;sleep()&lt;/code&gt;&lt;/li&gt;&#xA;&lt;li&gt;timers and timeouts&lt;/li&gt;&#xA;&lt;li&gt;semaphores&lt;/li&gt;&#xA;&lt;li&gt;mutexes, with priority inheritance : when a high priority task waits for a mutex, the task holding it temporarily gets its priority, so it can&amp;rsquo;t be blocked by a medium priority task&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The screenshot above is the classic first test : two tasks printing on the serial port, task 1 more often than task 2.&lt;/p&gt;&#xA;&lt;h2 id=&#34;a-small-computer&#34;&gt;A small computer&lt;/h2&gt;&#xA;&lt;p&gt;Once the kernel worked, I wanted to use it for something. So I added :&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;a basic shell (&lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;mount&lt;/code&gt;, &lt;code&gt;whoami&lt;/code&gt;, &lt;code&gt;reboot&lt;/code&gt;&amp;hellip;)&lt;/li&gt;&#xA;&lt;li&gt;a PS/2 keyboard driver&lt;/li&gt;&#xA;&lt;li&gt;a VGA output through a Gameduino board on the SPI bus, with an 8x8 font&lt;/li&gt;&#xA;&lt;li&gt;SD card support, with the &lt;a href=&#34;http://elm-chan.org/fsw/ff/00index_e.html&#34; target=&#34;_blank&#34;&gt;FatFs&lt;/a&gt; module to read files and navigate in folders&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;With the keyboard and the screen, the mbed became a small standalone computer, running my own OS.&lt;/p&gt;&#xA;&lt;p&gt;The next steps were to load binaries from the SD card and run them as tasks, and to move the shell and the FAT support out of the kernel and load them as modules, UNIX style. The hardware specific code is well separated from the kernel core, so porting it to other ARM processors should be easy too.&lt;/p&gt;&#xA;</description>
    </item>
    <item>
      <title>A Lamp That Reacts to Music</title>
      <link>https://ronangaillard.com/posts/a-lamp-that-reacts-to-music/</link>
      <pubDate>Thu, 19 Mar 2015 16:15:00 +0100</pubDate>
      <guid>https://ronangaillard.com/posts/a-lamp-that-reacts-to-music/</guid>
      <description>&lt;p&gt;&lt;em&gt;A CD spindle, RGB LEDs, an Arduino, and a beat detection algorithm running on my computer&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;I wanted a lamp that changes color on the beat of the music, whatever the song style and whatever the volume. Here it is :&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/music-light/lamp.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/music-light/lamp.jpg&#34; alt=&#34;The lamp&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-lamp&#34;&gt;The lamp&lt;/h2&gt;&#xA;&lt;p&gt;The lamp is an old CD spindle case, with RGB LEDs I soldered in bands inside. An Arduino in the lamp drives the LEDs, and listens to the computer on the USB port.&lt;/p&gt;&#xA;&lt;h2 id=&#34;detecting-the-beat&#34;&gt;Detecting the beat&lt;/h2&gt;&#xA;&lt;p&gt;The hard part is on the computer side, in &lt;a href=&#34;https://processing.org&#34; target=&#34;_blank&#34;&gt;Processing&lt;/a&gt;. The program does an FFT of the sound to split it into frequency bands. For each band it computes two averages of the energy : a long term one and a short term one. When the short term average jumps above the long term one, that&amp;rsquo;s a beat. Since everything is relative to the long term average, it works with a quiet song as well as with a loud one, and with any style of music.&lt;/p&gt;&#xA;&lt;p&gt;At each beat the computer sends a random color to the Arduino, and the lamp changes color.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-videos&#34;&gt;The videos&lt;/h2&gt;&#xA;&lt;p&gt;A first version in October 2014, in a tall column lamp :&lt;/p&gt;&#xA;&lt;div style=&#34;position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;&#34;&gt;&#xA;&#x9;&#x9;&#x9;&lt;iframe allow=&#34;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen&#34; loading=&#34;eager&#34; referrerpolicy=&#34;strict-origin-when-cross-origin&#34; src=&#34;https://www.youtube.com/embed/IFvY5JlcUlU?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0&#34; style=&#34;position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;&#34; title=&#34;YouTube video&#34;&gt;&lt;/iframe&gt;&#xA;&#x9;&#x9;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;The new lamp in its CD case, with the Processing program on the laptop :&lt;/p&gt;&#xA;&lt;div style=&#34;position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;&#34;&gt;&#xA;&#x9;&#x9;&#x9;&lt;iframe allow=&#34;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen&#34; loading=&#34;eager&#34; referrerpolicy=&#34;strict-origin-when-cross-origin&#34; src=&#34;https://www.youtube.com/embed/O7FlwIoSKgs?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0&#34; style=&#34;position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;&#34; title=&#34;YouTube video&#34;&gt;&lt;/iframe&gt;&#xA;&#x9;&#x9;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;And the final version :&lt;/p&gt;&#xA;&lt;div style=&#34;position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;&#34;&gt;&#xA;&#x9;&#x9;&#x9;&lt;iframe allow=&#34;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen&#34; loading=&#34;eager&#34; referrerpolicy=&#34;strict-origin-when-cross-origin&#34; src=&#34;https://www.youtube.com/embed/3VuRS4iNBno?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0&#34; style=&#34;position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;&#34; title=&#34;YouTube video&#34;&gt;&lt;/iframe&gt;&#xA;&#x9;&#x9;&lt;/div&gt;&#xA;&#xA;</description>
    </item>
    <item>
      <title>Arduino as a PIC Programmer</title>
      <link>https://ronangaillard.com/posts/arduino-as-a-pic-programmer/</link>
      <pubDate>Mon, 20 Feb 2012 19:12:00 +0100</pubDate>
      <guid>https://ronangaillard.com/posts/arduino-as-a-pic-programmer/</guid>
      <description>&lt;p&gt;&lt;em&gt;Burning Microchip PICs from USB with an Arduino Uno, a resistor and a 12V supply&lt;/em&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;&lt;em&gt;Updates&lt;/em&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;[3 March 2012] New version of the C# program and of the Arduino firmware, it now works with many 16F PICs.&lt;/li&gt;&#xA;&lt;li&gt;[May 2012] Other people built on it : a &lt;a href=&#34;https://sites.google.com/site/thehighspark/arduino-pic18f&#34; target=&#34;_blank&#34;&gt;version for the PIC18F family&lt;/a&gt; and a programmer for PIC32. Each PIC family has its own programming protocol and I am a student with a lot of homework, so I can&amp;rsquo;t maintain it anymore.&lt;/li&gt;&#xA;&lt;li&gt;[2016] More than 20,000 downloads !&lt;/li&gt;&#xA;&lt;li&gt;[2020] Eight years later, people are still posting their PIC references in the &lt;a href=&#34;https://forum.arduino.cc/t/arduino-as-a-pic-programmer/90967&#34; target=&#34;_blank&#34;&gt;forum thread&lt;/a&gt;, hoping theirs is supported.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;PIC microcontrollers are everywhere, but programming them needs a dedicated programmer. The cheap ones use a serial or parallel port that no modern computer has anymore, and the USB ones are expensive. I had an Arduino, so I wondered : why not make it do the job ?&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-circuit&#34;&gt;The circuit&lt;/h2&gt;&#xA;&lt;p&gt;A PIC is programmed through ICSP (In-Circuit Serial Programming) : a clock line, a data line, and 12V on the MCLR pin to put the chip in programming mode. The Arduino handles the clock and data, and the 12V comes from an external supply.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/arduino-pic/schematic.png&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/arduino-pic/schematic.png&#34; alt=&#34;The circuit&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;That&amp;rsquo;s all : pin 8 is the data line through a 330 Ω resistor, pin 9 the clock, and pins 4, 5 and 6 are tied together to power the PIC. I used a PIC 16F628, but it should work with most 16F PICs. One safety rule : always reset the Arduino before putting the 12V.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-firmware&#34;&gt;The firmware&lt;/h2&gt;&#xA;&lt;p&gt;The Arduino sketch bit-bangs the ICSP protocol described in the Microchip datasheets : a 6 bit command, then a 16 bit word for the data (a start bit, 14 bits of data and a stop bit). On the USB side it accepts simple one letter commands on the serial port :&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;s : start in High Voltage Programming mode (S for Low Voltage)&#xA;wXXXXXXXXXXXXXX : write a word at the location pointed by the program counter&#xA;i : increment the program counter&#xA;r : read the current location&#xA;e : erase the full program memory&#xA;R : reset the PIC (program counter back to 0)&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Every command answers &lt;code&gt;D&lt;/code&gt; when it is done.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-software&#34;&gt;The software&lt;/h2&gt;&#xA;&lt;p&gt;Typing words one by one in the serial monitor gets old fast, so I wrote a small C# program for Windows. It reads a &lt;code&gt;.hex&lt;/code&gt; file, sends it word by word to the Arduino, writes the configuration word, and can read the program memory back to check it.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/arduino-pic/gui.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/arduino-pic/gui.jpg&#34; alt=&#34;The Arduino PIC Programmer&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&#xA;&lt;em&gt;Screenshot : Nishant_Sood, the first one to try it on the forum&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;It&amp;rsquo;s still a first version, so check your PIC with a regular programmer if you can. The code, the C# program and the list of tested PICs are on the &lt;a href=&#34;https://forum.arduino.cc/t/arduino-as-a-pic-programmer/90967&#34; target=&#34;_blank&#34;&gt;Arduino forum&lt;/a&gt; : if you try it with another PIC, tell me there so I can add it to the list !&lt;/p&gt;&#xA;</description>
    </item>
    <item>
      <title>MiniOS : an Arduino Talking to a Minitel</title>
      <link>https://ronangaillard.com/posts/minios-arduino-minitel/</link>
      <pubDate>Thu, 15 Apr 2010 18:54:00 +0200</pubDate>
      <guid>https://ronangaillard.com/posts/minios-arduino-minitel/</guid>
      <description>&lt;p&gt;&lt;em&gt;A DIN plug, a resistor and an Arduino Mega to bring the good old French Minitel back to life&lt;/em&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;&lt;em&gt;Updates&lt;/em&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;[16 April 2010] This project was featured on the Arduino blog : &lt;a href=&#34;https://blog.arduino.cc/2010/04/16/minitel-is-not-dead/&#34; target=&#34;_blank&#34;&gt;Minitel is not dead!&lt;/a&gt;&lt;/li&gt;&#xA;&lt;li&gt;[20 December 2013] The photos were featured on &lt;a href=&#34;https://text-mode.tumblr.com/post/70528088514/le-minitel-nest-pas-mort-sorannefr-used&#34; target=&#34;_blank&#34;&gt;TEXT-MODE&lt;/a&gt;, a blog about text graphics through the ages (ASCII, PETSCII, Teletext, mosaics&amp;hellip;)&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;The Minitel is the French terminal that brought online services to millions of homes long before the Internet. There are still plenty of them sleeping in attics, and they have a nice feature : a &amp;ldquo;péri-informatique&amp;rdquo; DIN socket at the back, which is just a serial port. So I plugged an Arduino into one and wrote MiniOS, a tiny OS for the Minitel.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/minitel/boot.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/minitel/boot.jpg&#34; alt=&#34;The MiniOS boot screen&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-wiring&#34;&gt;The wiring&lt;/h2&gt;&#xA;&lt;p&gt;There is not much to it : a male DIN plug wired to the second serial port of an Arduino Mega, and a 4.7 kΩ resistor. The two LEDs are only there to show what is going on.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/minitel/arduino-mega.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/minitel/arduino-mega.jpg&#34; alt=&#34;The Arduino Mega with its prototype shield&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;The Minitel speaks at 1200 bauds, with 7 data bits and an even parity bit. The Arduino serial library could not do that at the time, so I set the UART register directly before starting the port :&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-cpp&#34; data-lang=&#34;cpp&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;UCSR1C &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; B00100100; &lt;span style=&#34;color:#75715e&#34;&gt;// 7 bits + parity bit&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Serial1.begin(&lt;span style=&#34;color:#ae81ff&#34;&gt;1200&lt;/span&gt;);&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id=&#34;drawing-on-the-screen&#34;&gt;Drawing on the screen&lt;/h2&gt;&#xA;&lt;p&gt;Once connected, the Minitel behaves like a simple terminal : you send it text, and control codes to move the cursor, clear the screen or change the font. For example &lt;code&gt;ESC&lt;/code&gt; followed by &lt;code&gt;O&lt;/code&gt; makes the text double size, &lt;code&gt;ESC H&lt;/code&gt; makes it blink and &lt;code&gt;ESC ]&lt;/code&gt; inverts the background. The whole OS is just a few functions that print screens and wait for a key :&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;a boot screen, with a blinking &amp;ldquo;Appuyez sur une touche&amp;hellip;&amp;rdquo; (press any key)&lt;/li&gt;&#xA;&lt;li&gt;a menu, quite empty I admit&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;Fakebook&amp;rdquo;, for fun&lt;/li&gt;&#xA;&lt;li&gt;the credits&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/minitel/menu.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/minitel/menu.jpg&#34; alt=&#34;The menu&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;a class=&#34;agrandir&#34; href=&#34;https://ronangaillard.com/img/minitel/fakebook.jpg&#34; title=&#34;Open the full size image&#34;&gt;&lt;img src=&#34;https://ronangaillard.com/img/minitel/fakebook.jpg&#34; alt=&#34;3615 FACEBOOK&#34; loading=&#34;lazy&#34;&gt;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;p&gt;All the code (a single file) is in the &lt;a href=&#34;https://forum.arduino.cc/t/le-minitel-nest-pas-mort/33619&#34; target=&#34;_blank&#34;&gt;forum thread&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-happened-next&#34;&gt;What happened next&lt;/h2&gt;&#xA;&lt;p&gt;I posted it on the French Arduino forum and the Arduino blog picked it up the next day. Other people rebuilt it, ported it to the Duemilanove (replace &lt;code&gt;Serial1&lt;/code&gt; with &lt;code&gt;Serial&lt;/code&gt; and &lt;code&gt;UCSR1C&lt;/code&gt; with &lt;code&gt;UCSR0C&lt;/code&gt;), mapped the keys of the keyboard, and even added a speaker to play music. Not bad for a menu with two entries !&lt;/p&gt;&#xA;</description>
    </item>
  </channel>
</rss>
