<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  
  <title>wonder</title>
  <subtitle>Notes and experiments by Nic Waller.</subtitle>
  <link href="https://wonder.nicwaller.com/feed.xml" rel="self" />
  <link href="https://wonder.nicwaller.com/" />
  <updated>2026-09-19T00:00:00Z</updated>
  <id>https://wonder.nicwaller.com/</id>
  <author>
    <name>Nic Waller</name>
  </author>
  <entry>
    <title>Flying Descent (1995) with a SpaceMouse on a Mac</title>
    <link href="https://wonder.nicwaller.com/posts/spacemouse-descent/" />
    <updated>2026-09-19T00:00:00Z</updated>
    <id>https://wonder.nicwaller.com/posts/spacemouse-descent/</id>
    <content type="html">&lt;h1&gt;Flying Descent (1995) with a SpaceMouse on a Mac&lt;/h1&gt;
&lt;p&gt;A 3Dconnexion &lt;a href=&quot;https://3dconnexion.com/br/product/spacemouse-compact/&quot;&gt;SpaceMouse&lt;/a&gt; is a CAD tool, a little puck you push and twist to move around a model, and on a Mac it doesn&#39;t do much else. Its driver also has a keyboard emulation mode, controlled by an XML file you write for each program. I wrote one for DOSBox, and now I fly &lt;a href=&quot;https://www.gog.com/en/game/descent&quot;&gt;Descent&lt;/a&gt;, the 1995 shooter, with one hand on the puck. Descent is a six-degrees-of-freedom (6DOF) game, and so is a SpaceMouse, so all six axes work at the same time: pitch, yaw, roll and all three slides.&lt;/p&gt;
&lt;h2&gt;Why Descent and a SpaceMouse&lt;/h2&gt;
&lt;p&gt;In Descent you fly a ship through mines, and the ship can move in any direction. That is six degrees of freedom, or 6DOF: three axes of translation (forward and back, left and right, up and down) and three of rotation (pitch, yaw and roll).&lt;/p&gt;
&lt;p&gt;A SpaceMouse reports the same six. Pushing the puck moves it along x, y and z, and tilting or twisting it rotates it around those axes, which are the rx, ry and rz in the reports below. So the puck&#39;s translation axes line up with Descent&#39;s slides and its rotation axes line up with pitch, yaw and roll. Forward and back on the puck becomes accelerate and reverse in the game. Because the puck reads all six axes at once, I can pitch, slide and roll at the same time.&lt;/p&gt;
&lt;p&gt;The problem is that the puck sends raw axis data, and a DOS game running inside an emulator only understands keystrokes.&lt;/p&gt;
&lt;h2&gt;What the puck sends&lt;/h2&gt;
&lt;p&gt;Before touching the driver I wanted to see what the device sends, so I wrote a small Swift tool using Apple&#39;s IOKit HID API. It finds 3Dconnexion devices by vendor ID &lt;code&gt;0x256F&lt;/code&gt;, prints the HID report descriptor when one connects, and decodes the reports as they arrive.&lt;/p&gt;
&lt;p&gt;There are three kinds of report. Report 1 carries translation (x, y, z) as three little-endian signed 16-bit numbers. Report 2 carries rotation (rx, ry, rz) in the same format. Report 3 carries the buttons as bits in a single byte, and my SpaceMouse Compact has two. If the tool can&#39;t open the device, the terminal probably needs Input Monitoring permission.&lt;/p&gt;
&lt;p&gt;The data is easy to read, but it doesn&#39;t help with the game. For that I needed the driver to translate it.&lt;/p&gt;
&lt;h2&gt;KMJ mode&lt;/h2&gt;
&lt;p&gt;3DxWare has a mode called KMJ, for Keyboard, Mouse, Joystick. Instead of handing an application 3D motion data, the driver generates ordinary input events from it. An XML file, one per process, controls what it generates.&lt;/p&gt;
&lt;p&gt;Each axis is split into two entries, one for pushing and one for pulling, and each entry has its own key. When the puck moves past a threshold in that direction, the driver holds the key down. DOSBox sees a keyboard.&lt;/p&gt;
&lt;h2&gt;The mapping&lt;/h2&gt;
&lt;p&gt;I worked out the axes and deadbands with Claude&#39;s help, then tuned signs and thresholds in a Notepad test profile before carrying them over. The file targets a process named &lt;code&gt;DOSBox.exe&lt;/code&gt;. This is the mapping I ended up with:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Motion&lt;/th&gt;
&lt;th&gt;Descent action&lt;/th&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;Deadband&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Push forward (Y−)&lt;/td&gt;
&lt;td&gt;Accelerate&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull back (Y+)&lt;/td&gt;
&lt;td&gt;Reverse&lt;/td&gt;
&lt;td&gt;Z&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slide right (X+)&lt;/td&gt;
&lt;td&gt;Slide right&lt;/td&gt;
&lt;td&gt;Keypad 9&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slide left (X−)&lt;/td&gt;
&lt;td&gt;Slide left&lt;/td&gt;
&lt;td&gt;Keypad 7&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lift up (Z−)&lt;/td&gt;
&lt;td&gt;Slide up&lt;/td&gt;
&lt;td&gt;Keypad 8&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Press down (Z+)&lt;/td&gt;
&lt;td&gt;Slide down&lt;/td&gt;
&lt;td&gt;Keypad 2&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pitch (Rx+)&lt;/td&gt;
&lt;td&gt;Pitch up&lt;/td&gt;
&lt;td&gt;Up arrow&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pitch (Rx−)&lt;/td&gt;
&lt;td&gt;Pitch down&lt;/td&gt;
&lt;td&gt;Down arrow&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yaw (Ry+)&lt;/td&gt;
&lt;td&gt;Turn left&lt;/td&gt;
&lt;td&gt;Left arrow&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;130&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yaw (Ry−)&lt;/td&gt;
&lt;td&gt;Turn right&lt;/td&gt;
&lt;td&gt;Right arrow&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;130&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roll (Rz+)&lt;/td&gt;
&lt;td&gt;Bank right&lt;/td&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roll (Rz−)&lt;/td&gt;
&lt;td&gt;Bank left&lt;/td&gt;
&lt;td&gt;Q&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;80&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The deadband is how far you have to move the puck before the key fires, in raw sensor units on a scale of plus or minus 512. I gave forward and yaw the largest values, because those are the axes your hand moves by accident while resting on the puck. In the file the keys are USB HID usage IDs (page 0x07), so Keypad 7 is &lt;code&gt;5F&lt;/code&gt; and Z is &lt;code&gt;1D&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Two settings in the file are worth knowing about. &lt;code&gt;AxisFilter&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;. The alternative, &lt;code&gt;Dominant&lt;/code&gt;, lets only the strongest axis through at a time, which makes it impossible to turn and slide together, and flying in Descent means combining axes. The repeat style is &lt;code&gt;PressAndHold&lt;/code&gt;, so holding the puck over holds the key down instead of firing it repeatedly.&lt;/p&gt;
&lt;p&gt;My file is here if you have a SpaceMouse of your own: &lt;a href=&quot;https://wonder.nicwaller.com/downloads/DOSBox-KMJ.xml&quot;&gt;DOSBox-KMJ.xml&lt;/a&gt;. It&#39;s heavily commented, with one entry per half-axis.&lt;/p&gt;
&lt;h2&gt;Caveats&lt;/h2&gt;
&lt;p&gt;The profile matches a process called &lt;code&gt;DOSBox.exe&lt;/code&gt;, so it fits how I run the game. Another emulator or build needs its own entry. It also ignores the two buttons, which my Swift tool can read and the XML doesn&#39;t map. And each axis is either on or off, since the driver is pressing keys, so there is no proportional control the way an analog joystick gives.&lt;/p&gt;
&lt;h2&gt;If you want to try this&lt;/h2&gt;
&lt;p&gt;Install 3DxWare, find its profile folder, put the XML in it, restart the driver and launch DOSBox.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Apple Image Capture &quot;lossless&quot; TIFF scans might secretly be JPEGs</title>
    <link href="https://wonder.nicwaller.com/posts/lide-400-airscan-jpeg-artifacts/" />
    <updated>2026-09-16T00:00:00Z</updated>
    <id>https://wonder.nicwaller.com/posts/lide-400-airscan-jpeg-artifacts/</id>
    <content type="html">&lt;h1&gt;Apple Image Capture &amp;quot;lossless&amp;quot; TIFF scans might secretly be JPEGs&lt;/h1&gt;
&lt;p&gt;If you scan with a Canon CanoScan LiDE 400 using Apple&#39;s Image Capture on macOS 27, you get JPEG compression artifacts in every file, even when you save in lossless formats like TIFF or BMP. Even though Image Capture is saving in a lossless format, the data it receives from the scanner has already been JPEG-compressed for transport.&lt;/p&gt;
&lt;p&gt;The fix is to skip AirScan and talk to the scanner over Canon&#39;s own USB protocol, which delivers uncompressed sensor data. That protocol is already implemented in open source by SANE.&lt;/p&gt;
&lt;h2&gt;What I saw&lt;/h2&gt;
&lt;p&gt;I bought a LiDE 400 for receipts, mail and old family photos. It has no macOS driver from Canon. It doesn&#39;t need one: macOS finds it over AirScan and Image Capture just works.&lt;/p&gt;
&lt;p&gt;Then I zoomed in on a 300 dpi scan saved as TIFF and saw DCT compression artifacts, the signature of JPEG. Saving as BMP showed the same artifacts. Picking a lossless format in Image Capture&#39;s own menu made no difference, and the app gave me no hint that it wouldn&#39;t.&lt;/p&gt;
&lt;figure&gt;
&lt;img src=&quot;https://wonder.nicwaller.com/img/image-capture-tiff-300dpi.png&quot; width=&quot;2060&quot; height=&quot;977&quot; alt=&quot;Heavily enlarged crop of the printed text &#39;90/8 MG&#39; from a 300 dpi scan saved as TIFF by Image Capture. The pixels form visible blocks, and the letter edges are smeared with speckled noise.&quot;&gt;
&lt;figcaption&gt;A 300 dpi scan saved as a &lt;strong&gt;TIFF&lt;/strong&gt; by Image Capture, enlarged. The blocky speckle around the letters is JPEG compression, in a file format that has no JPEG in it.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Two other programs on the same machine didn&#39;t produce them: VueScan and SANE&#39;s &lt;code&gt;scanimage&lt;/code&gt;. Both gave clean scans of the same page. So the scanner could do better, and Image Capture was getting something worse than the sensor produces. Nobody told me, and I only found out because I looked at pixels.&lt;/p&gt;
&lt;figure&gt;
&lt;img src=&quot;https://wonder.nicwaller.com/img/sane-scanimage-300dpi.png&quot; width=&quot;1780&quot; height=&quot;706&quot; alt=&quot;Heavily enlarged crop of the same printed text &#39;90/8 MG&#39; scanned with SANE scanimage. The letter edges are cleaner and the paper background is smooth.&quot;&gt;
&lt;figcaption&gt;The same kind of scan from SANE&#39;s &lt;code&gt;scanimage&lt;/code&gt;, enlarged. Cleaner edges and a smoother background. The two crops aren&#39;t at identical zoom.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;What AirScan is&lt;/h2&gt;
&lt;p&gt;AirScan is Apple&#39;s name for driverless scanning. Under the name is &lt;strong&gt;eSCL&lt;/strong&gt;, a protocol (from Mopria, based on HTTP and XML) that lets a client ask a scanner what it can do and then request a scan. Discovery uses Bonjour (&lt;code&gt;_uscan._tcp&lt;/code&gt;), the scanner publishes a capabilities document at &lt;code&gt;/eSCL/ScannerCapabilities&lt;/code&gt;, and you request a scan by posting a settings document. The pages come back as image data over HTTP.&lt;/p&gt;
&lt;p&gt;For a scanner on the network this is great. Nothing to install, and any client that speaks eSCL works. For a USB scanner it works through the same protocol carried over USB (the LiDE 400 has USB interfaces for this), and macOS exposes it as a local eSCL service.&lt;/p&gt;
&lt;p&gt;The catch is in that capabilities document. The scanner declares which document formats it can return, and the LiDE 400 lists exactly two: &lt;code&gt;image/jpeg&lt;/code&gt; and &lt;code&gt;application/pdf&lt;/code&gt;. There is no raw, no PNG, no TIFF. Whatever the client does with the data afterwards, the lossy step has already happened in the scanner&#39;s firmware. Image Capture, ImageCaptureCore and any custom eSCL client all get JPEG, so none of them can fix this. Canon chose to offer nothing else over AirScan, and Apple chose to present TIFF and BMP as save options anyway, without saying that the pixels are already degraded.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I checked this against the device when I started the project and recorded it in my notes. I haven&#39;t re-run the check for this post, because the scanner isn&#39;t plugged in right now.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;How open source already solved it&lt;/h2&gt;
&lt;p&gt;The scanner also speaks a second, older language. Its three USB interfaces are all vendor-specific, and interface 0 carries Canon&#39;s &amp;quot;pixma&amp;quot; protocol (generation 5). That&#39;s the protocol Canon&#39;s own drivers use, and it returns uncompressed image data.&lt;/p&gt;
&lt;p&gt;Canon doesn&#39;t publish a specification. The &lt;a href=&quot;https://www.sane-project.org/&quot;&gt;SANE&lt;/a&gt; project&#39;s &lt;code&gt;pixma&lt;/code&gt; backend (&lt;code&gt;backend/pixma/pixma_mp150.c&lt;/code&gt;) works it out and implements it. That&#39;s why &lt;code&gt;scanimage&lt;/code&gt; gives clean scans: it never touches AirScan. Everything I did next stands on that work, and I&#39;m grateful for it.&lt;/p&gt;
&lt;p&gt;SANE also has a debug mode that logs every USB transfer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;SANE_DEBUG_PIXMA=20 scanimage -d pixma:04A91912_… --resolution 300 --mode Color …
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That log turned out to be the most useful thing in the whole project.&lt;/p&gt;
&lt;h2&gt;What I did with it&lt;/h2&gt;
&lt;p&gt;I wanted a native macOS app: no Homebrew, no bundled C libraries, nothing to keep building. (I&#39;d started with a Python prototype, scanscan, and retired it in favour of the native rewrite, swiftscan.) So I ported the LiDE 400 path from &lt;code&gt;pixma_mp150.c&lt;/code&gt; to Swift on top of Apple&#39;s &lt;code&gt;IOUSBHost&lt;/code&gt; framework. It&#39;s about one scanner&#39;s worth of code, and I&#39;m not trying to support others.&lt;/p&gt;
&lt;p&gt;Then I checked it against SANE. A full-bed 300 dpi colour scan from my Swift driver and one from &lt;code&gt;scanimage&lt;/code&gt; with the same settings came out the same size, with a mean absolute difference of about one level, which is sensor noise. There&#39;s no JPEG block pattern. It was also a little faster: about 11 seconds against 14.6.&lt;/p&gt;
&lt;!-- TODO figure: difference image / histogram of |swift - sane|, if I still have the files. --&gt;
&lt;h2&gt;What I added&lt;/h2&gt;
&lt;p&gt;The driver is a translation, so what&#39;s mine is mostly around it.&lt;/p&gt;
&lt;p&gt;The biggest piece is the protocol itself, written down from evidence. I turned SANE&#39;s debug logs into a document describing the generation 5 protocol as it appears on the wire: the framing, the checksums, the XML job messages, the command sequence and the layout of the image data. It&#39;s written from captures rather than from SANE&#39;s source, and every claim says which capture backs it. Where I don&#39;t know what a field does, the document has an &amp;quot;unknowns&amp;quot; table with ideas for how to find out, rather than a guess.&lt;/p&gt;
&lt;p&gt;The packet layouts are also available in machine-readable form, as &lt;a href=&quot;https://kaitai.io/&quot;&gt;Kaitai Struct&lt;/a&gt; definitions. I wrote a small capture format (&lt;code&gt;.pxtrace&lt;/code&gt;) and a script that converts SANE&#39;s logs into it, so traces from SANE and from my driver can be compared directly.&lt;/p&gt;
&lt;p&gt;Those traces double as test fixtures. Twelve small scans at different resolutions and modes are stored as full USB traces next to the image SANE produced from them. The test suite replays each trace through the driver, requires the USB messages to be &lt;strong&gt;byte-identical&lt;/strong&gt;, and compares the resulting image to SANE&#39;s. That means the driver is tested without a scanner attached.&lt;/p&gt;
&lt;p&gt;I also explored a few things the logs didn&#39;t cover. I cancelled scans partway through to see how the scanner recovers (it refuses a new job briefly, then accepts one), and I captured the front-panel buttons on the interrupt endpoint so that a press can start a scan.&lt;/p&gt;
&lt;p&gt;On the Swift side, the API is built around &lt;code&gt;async&lt;/code&gt; scans with cancellation and progress, and rows are converted as they arrive instead of after the whole page. The image writer refuses to be lossy: it saves PNG or TIFF only, and rejects any other extension rather than quietly recompressing.&lt;/p&gt;
&lt;h2&gt;Caveats&lt;/h2&gt;
&lt;p&gt;SANE&#39;s backends are GPL, and my driver is a translation of one of them, so I don&#39;t plan to distribute the app. The code is also hardcoded to this one scanner, with its USB IDs, bed size and command sequence baked in. And getting the raw data removes the compression, but not the other limits of a cheap CIS scanner, such as a very shallow depth of field. That&#39;s a separate post.&lt;/p&gt;
&lt;h2&gt;If you have a LiDE 400 (or similar)&lt;/h2&gt;
&lt;p&gt;You don&#39;t need my app. Install SANE (&lt;code&gt;brew install sane-backends&lt;/code&gt;) and use &lt;code&gt;scanimage&lt;/code&gt;. If you&#39;re stuck with Image Capture, know that &amp;quot;save as TIFF&amp;quot; is a container change, not a quality change, whatever the menu implies. If you&#39;re curious whether a scanner you already own is affected, ask it what it can return: fetch &lt;code&gt;/eSCL/ScannerCapabilities&lt;/code&gt; from its eSCL endpoint and look at the document formats it lists.&lt;/p&gt;
</content>
  </entry>
</feed>