﻿<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>ericr.org</title>
  <subtitle>Eric R. writes about the tools he builds: backups, media pipelines, home automation, and small utilities, mostly in C# and .NET.</subtitle>
  <link href="https://www.ericr.org/" />
  <link rel="self" href="https://www.ericr.org/feed.xml" />
  <id>https://www.ericr.org/</id>
  <updated>2026-10-20T12:00:00Z</updated>
  <author>
    <name>Eric R.</name>
  </author>
  <entry>
    <title>Godot Town: Claude built a medieval town from one prompt</title>
    <link href="https://www.ericr.org/posts/godot-town/" />
    <id>https://www.ericr.org/posts/godot-town/</id>
    <published>2026-10-20T12:00:00Z</published>
    <updated>2026-10-20T12:00:00Z</updated>
    <summary>I gave Claude one prompt and Godot 4.7. About an hour later there was a medieval town with real terrain, a humpback stone bridge, a castle, a crowd of villagers, and chickens it modeled and animated itself.</summary>
    <content type="html">&lt;p&gt;The usual way to find out whether a game engine can handle a big scene is to read forum threads where people argue about it. This time I asked Claude to build one and find out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Godot Town&lt;/strong&gt; (the name took less time than anything else in the project) is a medieval town you can run around in, made with &lt;a href="https://godotengine.org"&gt;Godot&lt;/a&gt; 4.7. Claude built all of it: Claude Opus 5.5, running in Claude Code, from a single prompt, in about an hour. I didn't write a line of code, pick an asset or place a house. It has terrain with real elevation, a humpback stone bridge, a castle on a hill, about 140 houses, 1,500 trees, a crowd of villagers, farm animals and a river you can swim in. Here's a minute of running around it, including a trip through the time-of-day cycle:&lt;/p&gt;
&lt;figure class="video"&gt;
&lt;video controls preload="metadata" playsinline width="1596" height="900" poster="/images/godot-town-poster.webp"&gt;
&lt;source src="https://www.ericr.org/images/godot-town.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;figcaption&gt;Godot Town, recorded at 1600×900 with the HUD on. No sound, because the town doesn't make any.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id="one-prompt"&gt;One prompt&lt;/h2&gt;
&lt;p&gt;Before this, Claude had built a couple of small test games so I could compare Godot with Stride, and Godot came out ahead. The next question was how far Claude could take it. This is the whole prompt, sent along with a sample image of the look I wanted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Can you build another game in the folder &amp;lsquo;GodotTown&amp;rsquo; where I can run around through a medieval town? Are there stock assets of any kind similar to the Unity Asset Store? If so, some nice buildings and animals would be great. How much can you do - can you create 3D assets on your own and create 3D models that can be rigged and animated? I want to see how the game performs with elevation changes (like running over a curved bridge that bends upward) and I would love to have the ability to jump when pressing the spacebar.&lt;/p&gt;
&lt;p&gt;I would like to keep the graphics simple but pretty - similar to the image &amp;lsquo;sample.jpg&amp;rsquo; in the folder. I want to see how much we can push the engine and how the game performs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There were no follow-ups. What's interesting is less the result than how Claude got there, because it worked through the problem the way a developer would:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It went shopping for art.&lt;/strong&gt; It checked Poly Haven, Kenney, Quaternius, Poly Pizza and OpenGameArt, settled on &lt;a href="https://quaternius.com"&gt;Quaternius&lt;/a&gt;'s free CC0 packs, and scripted the downloads from itch.io itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It looked before it built.&lt;/strong&gt; It wrote throwaway Godot scripts that loaded the kits headlessly, dumped their structure, and laid the pieces and characters out into contact sheets, so it knew how the walls, roofs, outfits and animation clips actually fit together.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It ran the game, not just the compiler.&lt;/strong&gt; It built a benchmark into the game, ran it, saw that a big crowd was the bottleneck, fixed it, and measured again.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It tested its own work.&lt;/strong&gt; It added an autopilot that runs the player over the bridge and up the castle road, jumping as it goes, so it could check the physics without anyone at the keyboard.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It wrote the documentation.&lt;/strong&gt; The README covers the controls, the benchmark numbers, the architecture and the asset credits.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Over the hour that came to about a hundred shell commands and 28 files written. Everything below is a description of what it made.&lt;/p&gt;
&lt;h2 id="almost-nothing-is-hand-placed"&gt;Almost nothing is hand-placed&lt;/h2&gt;
&lt;p&gt;Nearly everything is generated at startup from a seed, so the town comes out the same every run and builds in about 2.5 seconds. &lt;code&gt;main.tscn&lt;/code&gt; holds only the environment, the sun, the player, the camera and a few managers. The one hand-designed part is the layout: the river's course, the roads, the two bridges, the square, the castle mound and the pasture.&lt;/p&gt;
&lt;p&gt;From there it's built in passes. The terrain is a 400 m heightmap on a 1 m grid, shaped by rolling hills, a ring of hills around the valley, the castle mound, the river channel, smoothed roads, a level square and flat pads for buildings. It ends up as 100 mesh chunks, a &lt;code&gt;HeightMapShape3D&lt;/code&gt; for collision, and a mask texture the terrain shader uses to paint cobbles, dirt, paving and muddy banks. Steep slopes turn to rock.&lt;/p&gt;
&lt;p&gt;The houses are assembled from the modular pieces of Quaternius's Medieval Village MegaKit: one to three storeys of stone, plaster or timber-frame walls on a 2 m grid, with windows, shutters, a door, corner posts, a tiled roof with the gables facing the street, and sometimes a chimney. The town builder walks along each road and places houses on both sides wherever they fit, flattens a pad for each one, and adds barrels, flowers, ivy and a lantern by the door. Roof, plaster and wood colors vary from house to house.&lt;/p&gt;
&lt;h2 id="twenty-thousand-pieces-a-few-thousand-draw-calls"&gt;Twenty thousand pieces, a few thousand draw calls&lt;/h2&gt;
&lt;p&gt;Building houses out of kit pieces means a lot of pieces: about 20,000 walls, roofs, trees, props and flowers. Drawn as separate nodes, that's at least a draw call each. Instead, Claude wrote a batcher that groups every placed copy by mesh and by 32 m chunk, then turns each group into one &lt;code&gt;MultiMeshInstance3D&lt;/code&gt;:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;town/town_batcher.gd&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-python"&gt;func add(scene_path: String, xf: Transform3D, category := Category.STRUCTURE, variant := 0) -&amp;gt; void:
	var chunk := Vector2i(floori(xf.origin.x / CHUNK_SIZE), floori(xf.origin.z / CHUNK_SIZE))
	for entry in meshes_of(scene_path):
		var mesh: Mesh = entry[0]
		if variant != 0:
			mesh = _variant_mesh(mesh, variant)
		var key := &amp;quot;%d,%d:%d:%d&amp;quot; % [chunk.x, chunk.y, mesh.get_instance_id(), category]
		if not _groups.has(key):
			_groups[key] = {&amp;quot;mesh&amp;quot;: mesh, &amp;quot;category&amp;quot;: category, &amp;quot;transforms&amp;quot;: []}
		_groups[key][&amp;quot;transforms&amp;quot;].append(xf * (entry[1] as Transform3D))
		instance_count += 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;Chunking keeps frustum culling working, since a whole town in one multimesh would never be off screen. The different roof and plaster colors become mesh variants that share vertex data, so tinting a house doesn't break its batch. Small things get a category that turns off their shadows and fades them out with distance: props at 140 m, flowers at 70 m.&lt;/p&gt;
&lt;p&gt;Collision skips the scene tree as well. Houses, trees, props and fences go straight to the physics server as about 2,200 shapes on a single body.&lt;/p&gt;
&lt;h2 id="the-bridge-is-real-geometry"&gt;The bridge is real geometry&lt;/h2&gt;
&lt;p&gt;I asked for a curved bridge that bends upward, mostly to see how the engine handles elevation. Claude didn't fake it with a model and a hidden ramp. Both bridges are procedural meshes with an arched cobbled deck (the main bridge rises 4 m), spandrel walls down to an elliptical arch, and parapets, all in the kit's own stone materials so they match the houses. The deck has exact trimesh collision, so the hump in the middle comes from the physics engine. The player is a &lt;code&gt;CharacterBody3D&lt;/code&gt;, so the slopes, the bridge deck and every wall are handled the same way.&lt;/p&gt;
&lt;p&gt;&lt;img loading="lazy" decoding="async" src="https://www.ericr.org/images/godot-town-bridge.webp" alt="The player running over the hump of the stone bridge, with the river below and red-roofed houses on the far bank" /&gt;&lt;/p&gt;
&lt;p&gt;That shot came from Claude's autopilot. Set &lt;code&gt;GODOTTOWN_AUTOPILOT=1&lt;/code&gt; and the bot runs the player from the square over the bridge, and &lt;code&gt;GODOTTOWN_SHOTS=4,7,10&lt;/code&gt; saves screenshots at those times.&lt;/p&gt;
&lt;p&gt;The jump got the same attention. It's buffered, it has a little coyote time (it still works 0.12 seconds after you run off an edge), it uses the animation library's start, loop and landing clips, and in the river you swim instead.&lt;/p&gt;
&lt;h2 id="the-crowd-is-what-costs"&gt;The crowd is what costs&lt;/h2&gt;
&lt;p&gt;I also asked to see how hard the engine could be pushed, so Claude built a benchmark: &lt;code&gt;B&lt;/code&gt; runs a 50-second camera flight over the square, the main street, the bridge, the castle and the river, with vsync off. Here are its numbers at 1600×900 on an RTX 5070:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Average&lt;/th&gt;
&lt;th&gt;1% low&lt;/th&gt;
&lt;th&gt;Draw calls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default: SSAO, 8K soft shadows, 4× MSAA, 60 walkers&lt;/td&gt;
&lt;td&gt;188 FPS&lt;/td&gt;
&lt;td&gt;135 FPS&lt;/td&gt;
&lt;td&gt;~4,600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default + 400 walking villagers&lt;/td&gt;
&lt;td&gt;134 FPS&lt;/td&gt;
&lt;td&gt;84 FPS&lt;/td&gt;
&lt;td&gt;~6,300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything: + SSIL, SDFGI, volumetric fog, dense grass, 400 walkers&lt;/td&gt;
&lt;td&gt;122 FPS&lt;/td&gt;
&lt;td&gt;80 FPS&lt;/td&gt;
&lt;td&gt;~6,500&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The GPU sits around 5 to 6 ms in all three. When Claude first ran the benchmark with 400 villagers, it got 77 FPS, and the time was going to the CPU, not the GPU. Moving a villager turned out to be expensive, because each new transform ripples through a skeleton and a dozen skinned meshes (a villager is an outfit, a head, hair and eyebrows, all skinned to one skeleton). So it added an update LOD: villagers further from the camera move and animate less often, and catch up on the time they skipped when they do:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;characters/villager.gd&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-python"&gt;## Distance (m) -&amp;gt; update the skeleton every this many frames.
const LOD_TIERS := [[25.0, 1], [50.0, 2], [90.0, 4], [INF, 8]]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;characters/villager.gd&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-python"&gt;	if _frame % every != 0:
		return
	var step := minf(_pending_delta, 0.5)
	_pending_delta = 0.0
	if mode == Mode.WALK:
		_walk(step)
	visible = distance &amp;lt; HIDE_DISTANCE
	if visible and (on_screen or not Population.animation_lod):
		model.animation.advance(step)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;Villagers off screen only update every tenth frame and don't animate at all. That took 400 villagers from 77 FPS to 134. The &lt;code&gt;9&lt;/code&gt; key turns it off and on, so you can watch the difference live.&lt;/p&gt;
&lt;p&gt;The villagers walk a graph made from the roads, the square crossings and the bridges, each keeping to their own side of the street and now and then stopping for a chat. Others stand talking in groups in the square or sit on its benches.&lt;/p&gt;
&lt;h2 id="the-chickens-are-generated-not-downloaded"&gt;The chickens are generated, not downloaded&lt;/h2&gt;
&lt;p&gt;My prompt asked whether Claude could create its own 3D models, rigged and animated. Its answer was the chickens. Every other creature in the town comes from an asset pack, but there were no chickens in it, so Claude wrote &lt;code&gt;tools/make_chicken.gd&lt;/code&gt;. The script builds a low-poly mesh from ellipsoids, cones and cylinders, skins it to a 13-bone skeleton, keys four animations (Idle with head turns, Walk with the head-bob, Peck, and a flapping hop), and exports the result as glTF:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;tools/make_chicken.gd&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-python"&gt;const BONES := [
	# name, parent, rest position relative to the parent
	[&amp;quot;root&amp;quot;, -1, Vector3(0, 0, 0)],
	[&amp;quot;body&amp;quot;, 0, Vector3(0, 0.27, 0)],
	[&amp;quot;neck&amp;quot;, 1, Vector3(0, 0.07, 0.07)],
	[&amp;quot;head&amp;quot;, 2, Vector3(0, 0.12, 0.03)],
	[&amp;quot;tail&amp;quot;, 1, Vector3(0, 0.05, -0.12)],
	[&amp;quot;wing_l&amp;quot;, 1, Vector3(0.1, 0.03, 0)],
	[&amp;quot;wing_r&amp;quot;, 1, Vector3(-0.1, 0.03, 0)],
	[&amp;quot;thigh_l&amp;quot;, 1, Vector3(0.05, -0.05, 0)],
	[&amp;quot;shin_l&amp;quot;, 7, Vector3(0, -0.1, 0.01)],
	[&amp;quot;foot_l&amp;quot;, 8, Vector3(0, -0.1, 0)],
	[&amp;quot;thigh_r&amp;quot;, 1, Vector3(-0.05, -0.05, 0)],
	[&amp;quot;shin_r&amp;quot;, 10, Vector3(0, -0.1, 0.01)],
	[&amp;quot;foot_r&amp;quot;, 11, Vector3(0, -0.1, 0)],
]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;It runs as a headless Godot script (&lt;code&gt;godot --path . -s res://tools/make_chicken.gd&lt;/code&gt;) and writes a white and a brown chicken. The files import like any other model, in Godot or in Blender. The chickens scratch around near some of the front doors, the cows, horses, sheep, pigs and llamas live in the pasture, and a pug follows you everywhere.&lt;/p&gt;
&lt;h2 id="knobs-for-everything"&gt;Knobs for everything&lt;/h2&gt;
&lt;p&gt;Since the point was to test the engine, Claude put almost every expensive feature on a key.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rendering toggles.&lt;/strong&gt; &lt;code&gt;1&lt;/code&gt; to &lt;code&gt;4&lt;/code&gt; switch SSAO, SSIL, SDFGI and volumetric fog. &lt;code&gt;5&lt;/code&gt; and &lt;code&gt;6&lt;/code&gt; cycle shadow quality and grass density, &lt;code&gt;0&lt;/code&gt; cycles MSAA, &lt;code&gt;R&lt;/code&gt; cycles the render scale with FSR2, and &lt;code&gt;V&lt;/code&gt; toggles vsync. Grass density changes how many of each chunk's instances are drawn, so changing it doesn't rebuild anything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;More villagers.&lt;/strong&gt; &lt;code&gt;7&lt;/code&gt; and &lt;code&gt;8&lt;/code&gt; add or remove 50 walkers, up to 800.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Time of day.&lt;/strong&gt; &lt;code&gt;T&lt;/code&gt; steps through morning, noon, golden hour and night. At night the lanterns by the doors light up, all 144 of them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Push it.&lt;/strong&gt; &lt;code&gt;P&lt;/code&gt; turns on SSAO, SSIL, SDFGI, volumetric fog, dense grass and 400 villagers at once.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A HUD that counts.&lt;/strong&gt; It shows FPS and frame, CPU and GPU times, draw calls, objects and triangles drawn, plus what's in the world, the player's altitude and the height of your last jump.&lt;/p&gt;
&lt;h2 id="what-it-doesnt-do-yet"&gt;What it doesn't do (yet)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;There's no game. No goals, no quests, no one to talk to. You explore, and that's it. That's what I asked for, but it's worth saying.&lt;/li&gt;
&lt;li&gt;There's no sound.&lt;/li&gt;
&lt;li&gt;The sheep, pigs and llamas only came with idle and jump animations, so they hop everywhere.&lt;/li&gt;
&lt;li&gt;It's the same town every time. The seed is an exported variable in the editor, not something you can change from the game.&lt;/li&gt;
&lt;li&gt;There's no export preset, so it runs from Godot itself, not as a standalone executable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-this-says-about-ai"&gt;What this says about AI&lt;/h2&gt;
&lt;p&gt;Game development is a lot more than code. There's art to find and license, kits whose pieces you have to figure out, physics to tune, animation clips to wire up and performance to measure. In this project Claude handled all of it, and when something was slow it found out why and fixed it, the same loop I'd go through myself. My part was one paragraph and a reference picture.&lt;/p&gt;
&lt;p&gt;The town isn't a finished game, and I wasn't asking for one. But a playable, benchmarked 3D world from one prompt and an hour of waiting says a lot about where these tools are now. They're no longer limited to finishing a function for you. They can take on whole projects, and that includes game engines.&lt;/p&gt;
&lt;h2 id="running-it"&gt;Running it&lt;/h2&gt;
&lt;p&gt;The whole project is here: &lt;strong&gt;&lt;a href="https://www.ericr.org/files/godot-town.zip"&gt;godot-town.zip&lt;/a&gt;&lt;/strong&gt; (289 MB). It's exactly what Claude produced, art included, minus Godot's import cache. Unzip it and run it with Godot 4.7:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-sh"&gt;cd GodotTown
godot --path .        # play
godot -e --path .     # open the editor
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;The first launch imports the assets, which takes about a minute. After that the town builds in about 2.5 seconds. Left-click to capture the mouse, WASD to move, Space to jump, and &lt;code&gt;F1&lt;/code&gt; shows the help.&lt;/p&gt;
&lt;p&gt;All the art except the chickens, terrain, bridges, castle, grass, water, sky and mountains comes from &lt;a href="https://quaternius.com"&gt;Quaternius&lt;/a&gt;'s free packs, which are CC0 (public domain): the Medieval Village, Stylized Nature and Fantasy Props MegaKits, the modular fantasy outfits and base characters, the Universal Animation Library and the farm animals. They're a big part of why a town like this is possible in an hour. If you use them, consider &lt;a href="https://www.patreon.com/quaternius"&gt;supporting Quaternius&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Markdownia: a Markdown reader with no browser inside</title>
    <link href="https://www.ericr.org/posts/markdownia/" />
    <id>https://www.ericr.org/posts/markdownia/</id>
    <published>2026-10-16T12:00:00Z</published>
    <updated>2026-10-16T12:00:00Z</updated>
    <summary>A Markdown editor and viewer in .NET 10 and Avalonia 12 that draws every heading, table, formula and code block with native controls, then ships as a single Native AOT executable.</summary>
    <content type="html">&lt;p&gt;Most Markdown apps are a web browser in a trench coat. The editor is a text box, the preview is an embedded Chromium or WebKit view, and the typography is whatever CSS the author wrote that afternoon. It's the sensible design, and it works. I wanted to see what happens if you don't do that.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Markdownia&lt;/strong&gt; (Markdown + Avalonia; the name was too good to pass up) is a Markdown editor and viewer built with .NET 10 and Avalonia 12. You write on the left and read on the right, and &lt;code&gt;Ctrl+\&lt;/code&gt; collapses the editor so it becomes a plain reader. There is no web view anywhere. Every paragraph, table, callout, formula and code block in the preview is a real Avalonia control.&lt;/p&gt;
&lt;p&gt;&lt;img loading="lazy" decoding="async" src="https://www.ericr.org/images/markdownia-split.webp" alt="Markdownia in split view, with the Markdown source on the left and the typeset preview on the right" /&gt;&lt;/p&gt;
&lt;h2 id="markdig-in-controls-out"&gt;Markdig in, controls out&lt;/h2&gt;
&lt;p&gt;Parsing is handled by &lt;a href="https://github.com/xoofx/markdig"&gt;Markdig&lt;/a&gt;, which already covers GitHub Flavored Markdown and a good share of Pandoc's extensions. The interesting half is the other one: a renderer that walks Markdig's syntax tree and builds controls instead of HTML. Paragraphs become selectable rich-text blocks. Lists, tables, GitHub alerts, Pandoc fenced divs, figures and footnotes become composed layout panels. Math is typeset natively by CSharpMath in Latin Modern Math, and inline formulas report their baseline, so &lt;code&gt;$ax^2$&lt;/code&gt; sits on the line with the words around it instead of floating above them.&lt;/p&gt;
&lt;p&gt;Parsing runs on a background thread and each render is tagged with a version number, so a burst of typing only ever paints the latest text. And if the renderer throws, the exception appears &lt;em&gt;in place of the preview&lt;/em&gt; instead of taking down the editor and your unsaved document with it.&lt;/p&gt;
&lt;h2 id="typography-is-the-point"&gt;Typography is the point&lt;/h2&gt;
&lt;p&gt;The whole reason to skip the browser is control over how text looks. Markdownia bundles Literata, Source Serif 4, Atkinson Hyperlegible, Inter and JetBrains Mono, keeps lines to a readable measure (narrow, medium, wide or full), and offers Smooth, Sharp or Subpixel text rendering. Smart punctuation turns straight quotes into curly ones and &lt;code&gt;--&lt;/code&gt; into dashes. Code fonts have their ligatures turned off, because &lt;code&gt;=&amp;gt;&lt;/code&gt; should look like the two characters you typed.&lt;/p&gt;
&lt;p&gt;The detail I'm proudest of is line height. Every line height is rounded to a whole pixel and the preview snaps baselines to the pixel grid, so text stays crisp instead of smearing across two rows of pixels. That exposed a quirk. When a heading's line height is tighter than the font's ascent plus descent, Avalonia keeps the baseline at the full ascent and lets the descenders hang out of the bottom of the control. The fix borrows CSS's idea of half-leading:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;MarkdownRenderer.cs&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-csharp"&gt;// When the line height is smaller than the font's ascent + descent (headings, titles), Avalonia keeps the
// baseline at the full ascent and the descenders hang below the control. Center the glyphs in the line box
// instead, like CSS half-leading.
var overflow = _glyphExtent * fontSize - lineHeight;
if (overflow &amp;gt; 1)
    text.RenderTransform = new TranslateTransform(0, -Math.Round(overflow / 2));
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;There are nine themes: Paper, GitHub Light, Sepia and Solarized Light, plus GitHub Dark, Nord, One Dark, Solarized Dark and Dracula. Each one colors the window chrome, the editor, the preview and the code together, so switching themes repaints the whole room, not just the page.&lt;/p&gt;
&lt;p&gt;&lt;img loading="lazy" decoding="async" src="https://www.ericr.org/images/markdownia-reader-nord.jpg" alt="Markdownia's reader layout in the Nord theme, showing a highlighted TypeScript block and typeset math including the quadratic formula" /&gt;&lt;/p&gt;
&lt;h2 id="a-ribbon-unironically"&gt;A ribbon, unironically&lt;/h2&gt;
&lt;p&gt;The toolbar is a Word-style ribbon with Home, Insert, Design and View tabs, a Styles gallery, a hover-grid table picker and a theme gallery. A ribbon is an odd choice for Markdown, a format that exists so you don't need a toolbar. But Markdown has picked up a lot of syntax nobody remembers: footnotes, definition lists, &lt;code&gt;{width=50%}&lt;/code&gt; attributes, &lt;code&gt;*[HTML]:&lt;/code&gt; abbreviations, &lt;code&gt;::: warning&lt;/code&gt; blocks. A ribbon is a good place to discover it. Every button writes ordinary Markdown into the editor, and the file stays yours.&lt;/p&gt;
&lt;p&gt;Two editing features turned out to be more fun than I expected.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Images resize in the preview.&lt;/strong&gt; Hover over an image, drag the handle in its corner, and the new width is written back to the Markdown as &lt;code&gt;{width=480}&lt;/code&gt; (or as a &lt;code&gt;width&lt;/code&gt; attribute on an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag). Writing from the preview back into the source can corrupt a document if the source has changed in the meantime, so each resize request carries the exact text it was rendered from and is dropped if that text no longer matches:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;MainWindow.axaml.cs&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-csharp"&gt;// Ignore the request if the text has changed since this preview was rendered.
if (resize.End &amp;gt; doc.TextLength || doc.GetText(resize.Start, resize.End - resize.Start) != resize.Original)
    return;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Scroll sync without the feedback loop.&lt;/strong&gt; Synchronized panes have a classic bug: the editor scrolls the preview, which scrolls the editor, which scrolls the preview. Markdownia tracks a &lt;em&gt;leader&lt;/em&gt; instead. Whichever pane you last pointed at, clicked, scrolled or typed in is in charge, and the other one only follows.&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;MainWindow.axaml.cs&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-csharp"&gt;// Scroll sync follows whichever pane the user is working in, so the panes never drive each other in a loop.
void Lead(Control pane, ScrollLeader leader)
{
    pane.AddHandler(PointerEnteredEvent, (_, _) =&amp;gt; _scrollLeader = leader, handledEventsToo: true);
    pane.AddHandler(PointerPressedEvent, (_, _) =&amp;gt; _scrollLeader = leader, RoutingStrategies.Tunnel, handledEventsToo: true);
    pane.AddHandler(PointerWheelChangedEvent, (_, _) =&amp;gt; _scrollLeader = leader, RoutingStrategies.Tunnel, handledEventsToo: true);
    pane.AddHandler(KeyDownEvent, (_, _) =&amp;gt; _scrollLeader = leader, RoutingStrategies.Tunnel, handledEventsToo: true);
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;h2 id="one-native-executable"&gt;One native executable&lt;/h2&gt;
&lt;p&gt;Like &lt;a href="https://www.ericr.org/posts/webshare/"&gt;webshare&lt;/a&gt;, Markdownia publishes with Native AOT, and that shaped a lot of decisions: compiled XAML bindings, source-generated JSON and regexes, editor highlighting rules built in code instead of loaded from XSHD files, and no reflection-based view location. Syntax highlighting for the preview's code blocks comes from a small hand-written tokenizer that covers about 25 languages, from C# and Rust to Dockerfile and Lua. It's deliberately approximate: pleasant, readable coloring with no regex engine, grammar files or reflection. &lt;code&gt;dotnet publish&lt;/code&gt; finishes with zero trim or AOT warnings.&lt;/p&gt;
&lt;p&gt;The result is a single &lt;code&gt;Markdownia&lt;/code&gt; executable of about 36 MB, fonts included, with the Skia and HarfBuzz native libraries next to it. It starts like a native app because it is one.&lt;/p&gt;
&lt;p&gt;Two helpers make a GUI app easier to check without clicking around:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Markdownia --smoke-test out.png&lt;/code&gt; renders the main window and the full preview offscreen, saves both as PNGs, and exits with status 0 if everything worked. It's a quick way to confirm an AOT build really runs.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tools/Snapshot&lt;/code&gt; renders the real window headlessly in any theme, layout or scroll position. The Nord screenshot above came from it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On Linux, &lt;code&gt;packaging/linux/build-deb.sh&lt;/code&gt; builds a &lt;code&gt;.deb&lt;/code&gt; that installs a &lt;code&gt;markdownia&lt;/code&gt; command, a desktop entry and icons, and registers Markdownia for &lt;code&gt;text/markdown&lt;/code&gt;, so it shows up under &amp;ldquo;Open With&amp;rdquo; for &lt;code&gt;.md&lt;/code&gt; files. It needs glibc 2.38 or newer (Ubuntu 24.04, Debian 13 or later).&lt;/p&gt;
&lt;h2 id="what-it-doesnt-do-yet"&gt;What it doesn't do (yet)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;SVG images aren't rendered; you get their alt text.&lt;/li&gt;
&lt;li&gt;Raw HTML blocks are reduced to their text and images, though common inline tags like &lt;code&gt;&amp;lt;kbd&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;sup&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;mark&amp;gt;&lt;/code&gt; work.&lt;/li&gt;
&lt;li&gt;Pandoc features Markdig doesn't implement (inline notes, citations, line blocks, example lists) aren't available.&lt;/li&gt;
&lt;li&gt;You can select text within one paragraph, list item or code block at a time, but not across several. This is the real price of skipping the browser: a web view gives you document-wide selection for free, and a stack of native controls doesn't.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="try-it"&gt;Try it&lt;/h2&gt;
&lt;p&gt;Version 1.0.0 is on the &lt;a href="https://github.com/dotericr/Markdownia/releases"&gt;releases page&lt;/a&gt; as a &lt;code&gt;.deb&lt;/code&gt; and a Linux x64 zip. To build it yourself you need the .NET 10 SDK:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-sh"&gt;git clone https://github.com/dotericr/Markdownia
cd Markdownia
dotnet run --project src/Markdownia -- README.md
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;It's MIT licensed, and the code is on &lt;a href="https://github.com/dotericr/Markdownia"&gt;GitHub&lt;/a&gt;. Point it at a README you've read a hundred times and see how it looks with real typography.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>webshare: one command to hand a file across the room</title>
    <link href="https://www.ericr.org/posts/webshare/" />
    <id>https://www.ericr.org/posts/webshare/</id>
    <published>2026-09-26T12:00:00Z</published>
    <updated>2026-09-26T12:00:00Z</updated>
    <summary>A tiny .NET 10 tool that turns any file or folder into a temporary web page on your LAN, then cleans up after itself, firewall rule included.</summary>
    <content type="html">&lt;p&gt;Every so often I need to move a file from the machine in front of me to a device that's &lt;em&gt;right there&lt;/em&gt;. A phone on the couch, a laptop on the kitchen table, a Windows box in the other room. The options are all a little annoying. Cloud drives round-trip my file through someone else's data center to travel eight feet. USB sticks are never where I left them. Setting up a Samba share for one ISO feels like building a bridge to cross a puddle.&lt;/p&gt;
&lt;p&gt;What I actually want is the old &lt;code&gt;python -m http.server&lt;/code&gt; trick, but polished. It should work on a single file, pick its own port, tell me the address to type, look decent on a phone, and &lt;strong&gt;go away on its own&lt;/strong&gt; when I forget about it.&lt;/p&gt;
&lt;p&gt;So I wrote &lt;code&gt;webshare&lt;/code&gt;.&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-sh"&gt;webshare ~/Downloads/firmware-2.4.1.bin
webshare --time 15 ~/Pictures/vacation
sudo webshare --open-firewall --port 8080 ./build
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;Run it with a file or a folder and it starts a temporary web server, prints the local and network URLs, and waits. Folders get a browsable listing. A single file gets a landing page with one big download button. By default the share shuts itself off after 60 minutes (&lt;code&gt;--time 0&lt;/code&gt; if you really want it to run forever).&lt;/p&gt;
&lt;p&gt;&lt;img loading="lazy" decoding="async" src="https://www.ericr.org/images/webshare-screenshot.jpg" alt="webshare showing a shared folder in the browser" /&gt;&lt;/p&gt;
&lt;h2 id="small-but-not-careless"&gt;Small, but not careless&lt;/h2&gt;
&lt;p&gt;It's a small tool, but anything that serves files to a network deserves some paranoia. A few decisions I'm happy with:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Paths can't escape the share.&lt;/strong&gt; Every request path is split into segments, any &lt;code&gt;.&lt;/code&gt; or &lt;code&gt;..&lt;/code&gt; is rejected outright, and then the canonical result has to still live under the root:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;Program.cs&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-csharp"&gt;// Reject traversal segments and verify the canonical target remains under the root.
if (segments.Any(segment =&amp;gt; segment is &amp;quot;.&amp;quot; or &amp;quot;..&amp;quot;))
{
    targetPath = string.Empty;
    return false;
}

targetPath = Path.GetFullPath(Path.Combine(rootPath, Path.Combine(segments)));
var comparison = OperatingSystem.IsWindows()
    ? StringComparison.OrdinalIgnoreCase
    : StringComparison.Ordinal;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Symlinks are ignored.&lt;/strong&gt; Every component of a requested path is checked for a reparse point (a symlink, or a junction on Windows). A link inside the shared folder that points at &lt;code&gt;/etc&lt;/code&gt; is neither listed nor served.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strict headers everywhere.&lt;/strong&gt; Every response gets &lt;code&gt;Cache-Control: no-store&lt;/code&gt;, &lt;code&gt;nosniff&lt;/code&gt;, &lt;code&gt;no-referrer&lt;/code&gt;, and a Content Security Policy that only allows inline styles. The pages have no JavaScript and nothing to inject into.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No authentication, stated plainly.&lt;/strong&gt; Anyone who can reach the port can download what you're sharing. The README says so, and the tool is meant for a trusted network. I'd rather be honest about that than bolt on a password prompt that gives a false sense of safety.&lt;/p&gt;
&lt;h2 id="the-firewall-problem"&gt;The firewall problem&lt;/h2&gt;
&lt;p&gt;The most common way a LAN share fails isn't a bug. The firewall on the sharing machine silently drops every connection from another device, and the server can't reliably detect that from its own side. The only real test is another device trying to connect.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;--open-firewall&lt;/code&gt; adds a &lt;strong&gt;temporary&lt;/strong&gt;, narrowly scoped exception and removes it on exit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;On &lt;strong&gt;Windows&lt;/strong&gt;, it's a &lt;code&gt;netsh&lt;/code&gt; rule limited to the Private profile and &lt;code&gt;LocalSubnet&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On &lt;strong&gt;Linux&lt;/strong&gt;, it adds &lt;code&gt;iptables&lt;/code&gt;/&lt;code&gt;ip6tables&lt;/code&gt; rules for each active local subnet it can find.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both need elevation, and on Linux that means &lt;code&gt;sudo&lt;/code&gt; runs the whole app as root, which I don't love and the docs spell out. A normal exit or Ctrl+C removes the rules. A power cut doesn't, so every rule is named &lt;code&gt;webshare-&amp;lt;guid&amp;gt;&lt;/code&gt; (as an &lt;code&gt;iptables&lt;/code&gt; comment on Linux) to make leftovers easy to find and delete by hand.&lt;/p&gt;
&lt;h2 id="why.net-for-a-tiny-tool"&gt;Why .NET for a tiny tool?&lt;/h2&gt;
&lt;p&gt;Honestly, because it keeps getting better at small things. &lt;code&gt;WebApplication.CreateSlimBuilder&lt;/code&gt; gives me Kestrel with almost nothing else. The stylesheet is an embedded resource, so there's no asset folder to lose, and the whole thing publishes as a &lt;strong&gt;Native AOT&lt;/strong&gt; single binary for Linux x64 and Windows x64. It starts instantly and has no runtime to install on the machine that's sharing.&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-sh"&gt;make publish-linux &amp;amp;&amp;amp; sudo make install
webshare .
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;That's the whole pitch: one command, a URL, and it cleans up after itself. The code is on &lt;a href="https://github.com/Vinny636/webshare"&gt;GitHub&lt;/a&gt; if you want the same thing.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Teaching Linux to speak passkey, in C#</title>
    <link href="https://www.ericr.org/posts/passkeys-in-csharp/" />
    <id>https://www.ericr.org/posts/passkeys-in-csharp/</id>
    <published>2026-09-22T12:00:00Z</published>
    <updated>2026-09-22T12:00:00Z</updated>
    <summary>Porting a Rust WebAuthn authenticator to .NET 10: a virtual USB security key, a TPM-backed signing key, and a PIN prompt that never trusts anyone.</summary>
    <content type="html">&lt;p&gt;Passkeys are great on phones and Macs. On a Linux desktop the story is thinner: you can use a hardware security key, but the laptop's own TPM (the chip that exists precisely to guard keys) usually just sits there.&lt;/p&gt;
&lt;p&gt;Then I found &lt;a href="https://github.com/bjn7/passkeyd"&gt;passkeyd&lt;/a&gt;, an opinionated Linux WebAuthn authenticator written in Rust. It pretends to be a USB security key, so every browser that supports hardware keys &amp;ldquo;just works,&amp;rdquo; and it keeps the credentials behind the TPM. It's a clever design, and I wanted to understand it all the way down. For me the best way to learn someone else's code has always been to port it, so &lt;strong&gt;cspasskeyd&lt;/strong&gt; is a staged C#/.NET 10 port. The Rust original stays untouched.&lt;/p&gt;
&lt;h2 id="pretending-to-be-a-usb-key"&gt;Pretending to be a USB key&lt;/h2&gt;
&lt;p&gt;The trick at the center is &lt;code&gt;/dev/uhid&lt;/code&gt;, the Linux interface that lets a user-space program create a virtual HID device. The daemon registers something that looks like a FIDO security key. The browser finds it, and from then on they talk CTAPHID over HID reports.&lt;/p&gt;
&lt;p&gt;Stage one covers exactly what's needed for a real registration and sign-in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CTAPHID &lt;code&gt;INIT&lt;/code&gt;, &lt;code&gt;PING&lt;/code&gt;, and &lt;code&gt;CANCEL&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;CTAP2 &lt;code&gt;GetInfo&lt;/code&gt;, &lt;code&gt;MakeCredential&lt;/code&gt;, and &lt;code&gt;GetAssertion&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Keepalives while waiting on the user, and cancellation that actually cancels&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CTAP2 requests run off the HID reader thread, one operation at a time per device. That sounds obvious, but getting it wrong is how you end up with a browser spinner that never stops.&lt;/p&gt;
&lt;h2 id="keys-that-never-leave-the-chip"&gt;Keys that never leave the chip&lt;/h2&gt;
&lt;p&gt;Every credential is an ES256 (P-256) key created inside the TPM, under a deterministic storage primary that's created for each operation and then flushed. There are no persistent TPM handles to leak. The C# side talks to the system TSS2 libraries through a small native C shim. It asks for a signature over a 32-byte SHA-256 digest and gets back the DER-encoded ECDSA signature WebAuthn expects.&lt;/p&gt;
&lt;p&gt;The rule I care most about: &lt;strong&gt;if there's no TPM, it fails.&lt;/strong&gt; There is no quiet fallback to software keys. A security tool that silently downgrades itself is worse than one that refuses to run.&lt;/p&gt;
&lt;h2 id="approval-not-decoration"&gt;Approval, not decoration&lt;/h2&gt;
&lt;p&gt;Every registration and every sign-in needs a fresh local approval. The root daemon doesn't draw UI. It launches a tiny, separate GTK4 prompt in the desktop user's session, and that prompt refuses to run as root. The two processes speak a deliberately boring, length-bounded binary protocol:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;PIN prompt frame protocol&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-text"&gt;request:   &amp;quot;CPPR&amp;quot; | version 1 | u16 rp_len | u16 account_len | rp_id | account
response:  &amp;quot;CPRS&amp;quot; | version 1 | status (1 submitted, 2 cancelled) | u16 pin_len | pin
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;The prompt only collects the PIN. It doesn't decide anything. The daemon checks the PIN through PAM, using a dedicated &lt;code&gt;cspasskeyd-pin&lt;/code&gt; service, so the machine's own authentication rules stay in charge. Relying-party names are validated and shown as plain lowercase labels. Account text is stripped of control and bidi characters so a hostile site can't dress up the dialog. And the prompt &lt;strong&gt;never fetches &lt;code&gt;user.icon&lt;/code&gt; URLs&lt;/strong&gt;: a site doesn't get to make my authenticator load a remote image.&lt;/p&gt;
&lt;h2 id="porting-as-a-way-of-reading"&gt;Porting as a way of reading&lt;/h2&gt;
&lt;p&gt;The most valuable part so far hasn't been the code. It's the &lt;code&gt;SECURITY.md&lt;/code&gt; that came out of reading the original line by line: findings I checked, mitigations the port must carry, and things that are explicitly out of scope, like the CTAP ClientPIN and extensions. Port, audit, and write it down turns out to be a very good way to learn a protocol.&lt;/p&gt;
&lt;p&gt;Next up is the moment of truth: validating a live registration and sign-in in Edge against real TPM hardware and the real PAM stack, which the unit tests deliberately fake. After that comes stage two, with the enrollment UI, a manager, migration tooling, and packaging. Watching a browser say &amp;ldquo;use your security key&amp;rdquo; and knowing the key is my laptop's own TPM is the finish line I'm aiming for.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>LinuxPass: the KeePass window I kept missing</title>
    <link href="https://www.ericr.org/posts/linuxpass/" />
    <id>https://www.ericr.org/posts/linuxpass/</id>
    <published>2026-09-17T12:00:00Z</published>
    <updated>2026-09-17T12:00:00Z</updated>
    <summary>A cross-platform Avalonia password manager that opens my existing .kdbx files and puts the classic KeePass layout back on the Linux desktop.</summary>
    <content type="html">&lt;p&gt;I've kept my passwords in a KeePass database for a very long time. On Windows, KeePass 2.x is a comfortable old chair: a menu bar, a toolbar, groups in a tree on the left, entries on the right, details at the bottom. It isn't pretty, but my hands know exactly where everything is.&lt;/p&gt;
&lt;p&gt;On Linux I've mostly used KeePassXC, and it's excellent. But this week of passkey work (more on that in the next post) had me thinking about how a password manager and an authenticator could share one database. I wanted a codebase I understand end to end, in the language I use every day. So I started &lt;strong&gt;LinuxPass&lt;/strong&gt;: a cross-platform Avalonia app on .NET 10, laid out like the KeePass I remember.&lt;/p&gt;
&lt;h2 id="what-it-does-today"&gt;What it does today&lt;/h2&gt;
&lt;p&gt;It's deliberately modest so far, and everything it does, it does against my real database:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Opens and unlocks KeePass &lt;code&gt;.kdbx&lt;/code&gt; files, and legacy &lt;code&gt;.kdb&lt;/code&gt; files too, with a master password&lt;/li&gt;
&lt;li&gt;Shows nested groups as a tree and the entries in a sortable list&lt;/li&gt;
&lt;li&gt;Searches titles, usernames, URLs, and notes all at once&lt;/li&gt;
&lt;li&gt;Edits username, password, URL, and notes in place, then saves back to the same file&lt;/li&gt;
&lt;li&gt;Copies a password from the detail panel or a right-click menu&lt;/li&gt;
&lt;li&gt;Has an A–Z index for jumping around a long entry list&lt;/li&gt;
&lt;li&gt;Locks the database and clears the decrypted data out of the view model&lt;/li&gt;
&lt;li&gt;Remembers the last database and window size, and asks for the master password on launch&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The file format work is handled by &lt;code&gt;KeePassLib.Standard&lt;/code&gt;, a .NET Standard build of the same library KeePass itself uses. That was a big reason to try this at all. I'm not reimplementing the KDBX crypto; I'm building a UI around a library that has read and written these files for years.&lt;/p&gt;
&lt;h2 id="mvvm-the-avalonia-way"&gt;MVVM, the Avalonia way&lt;/h2&gt;
&lt;p&gt;The app is a straightforward Avalonia MVVM layout: a &lt;code&gt;MainWindow&lt;/code&gt; with its &lt;code&gt;MainViewModel&lt;/code&gt;, and a separate password prompt window. What surprised me is how easy the &amp;ldquo;classic desktop app&amp;rdquo; look is in Avalonia. A &lt;code&gt;TreeView&lt;/code&gt;, a &lt;code&gt;DataGrid&lt;/code&gt;, a &lt;code&gt;GridSplitter&lt;/code&gt; or two, and a proper menu bar give you something that looks like it belongs to the late-2000s Windows era in the best way. It behaves the same on Windows and on my Linux desktop.&lt;/p&gt;
&lt;h2 id="where-its-headed-talking-to-the-browser"&gt;Where it's headed: talking to the browser&lt;/h2&gt;
&lt;p&gt;The part I'm most excited about is still a design doc. The KeePassXC-Browser extension talks to its desktop app over browser native messaging. If LinuxPass speaks that same protocol, I can reuse the existing extension instead of writing my own:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;docs/PluginProtocol.md&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-text"&gt;KeePassXC-Browser extension
             │  Browser Native Messaging
             ▼
      linuxpass-proxy
             │  Unix-domain socket
             ▼
      LinuxPass desktop process
             │
       ┌─────┴─────┐
       ▼           ▼
 Active database  Browser/passkey services
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;The one rule I wrote in bold: &lt;strong&gt;the desktop process owns the open database.&lt;/strong&gt; The small proxy the browser launches never opens the &lt;code&gt;.kdbx&lt;/code&gt; itself. Two processes with the same file open is how you get stale reads, lock fights, and eventually lost entries.&lt;/p&gt;
&lt;p&gt;A password manager is a strange project for a hobbyist, because the bar is &amp;ldquo;never lose anything, ever.&amp;rdquo; That's also why it's a good one. It forces the careful habits I want everywhere else.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>SmartCopy: copying files without copying history</title>
    <link href="https://www.ericr.org/posts/smartcopy/" />
    <id>https://www.ericr.org/posts/smartcopy/</id>
    <published>2026-09-13T12:00:00Z</published>
    <updated>2026-09-13T12:00:00Z</updated>
    <summary>Years of backups of backups left me with the same folders copied into five different places. SmartCopy compares by content, skips what's already there, and writes down every decision.</summary>
    <content type="html">&lt;p&gt;Every long-lived home network has a folder like this somewhere:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-text"&gt;Backup/
  OldLaptop/Projects/...
  OldLaptop-2/Projects/...
  From D/Projects/...
  Sep2026/FromD/Projects/...
  Projects (copy)/...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;Each copy made sense on the day it was made. Put together, they're a mess: mostly the same trees, a few genuinely different files, and timestamps that are useless because every copy tool has rewritten them at least once.&lt;/p&gt;
&lt;p&gt;I wanted to consolidate all of it onto one big drive without making things worse. The usual tools don't quite do it. &lt;code&gt;cp&lt;/code&gt; and &lt;code&gt;robocopy&lt;/code&gt; happily copy the fifth identical tree. Sync tools compare against &lt;em&gt;one&lt;/em&gt; destination path, not &amp;ldquo;does this already exist anywhere over there?&amp;rdquo; So I wrote &lt;strong&gt;SmartCopy&lt;/strong&gt;.&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-sh"&gt;smartcopy &amp;lt;source&amp;gt; &amp;lt;destination&amp;gt; [--ssd] [--skipdevbin]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;h2 id="content-not-timestamps"&gt;Content, not timestamps&lt;/h2&gt;
&lt;p&gt;The rules are small and strict:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;whole directory tree&lt;/strong&gt; is compared by relative paths, file lengths, and file contents. Timestamps and attributes are ignored, because I've learned not to trust them.&lt;/li&gt;
&lt;li&gt;If a complete matching tree already exists &lt;strong&gt;anywhere&lt;/strong&gt; in the destination, even under a different parent, it's skipped.&lt;/li&gt;
&lt;li&gt;If a tree has changed at all, it's copied normally. Individual files inside it are never skipped just because a matching file exists somewhere else. I decided to deduplicate at the directory level so the result still looks like the folders I remember.&lt;/li&gt;
&lt;li&gt;Large archives (anything over 100 MiB with a known extension like &lt;code&gt;.zip&lt;/code&gt;, &lt;code&gt;.7z&lt;/code&gt;, &lt;code&gt;.iso&lt;/code&gt;, or &lt;code&gt;.zst&lt;/code&gt;) are the exception. Those are compared globally, because five copies of the same 4 GB installer ISO are the whole reason the drive is full.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Copying keeps the source's top-level directory name, so &lt;code&gt;smartcopy ~/Restore/Projects /mnt/archive&lt;/code&gt; lands in &lt;code&gt;/mnt/archive/Projects&lt;/code&gt; instead of spraying files into the root.&lt;/p&gt;
&lt;h2 id="asking-instead-of-guessing"&gt;Asking instead of guessing&lt;/h2&gt;
&lt;p&gt;When a &lt;em&gt;different&lt;/em&gt; file is already sitting at the destination path, SmartCopy stops and shows both sides (name, size, last-write time) and lets me skip, overwrite, create a numbered conflict name, or cancel. The choice can apply to everything after it. If input is redirected and nobody's there to answer, an unresolved conflict is an error rather than a silent guess.&lt;/p&gt;
&lt;h2 id="a-paper-trail"&gt;A paper trail&lt;/h2&gt;
&lt;p&gt;The destination root gets three files that make the whole run auditable:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it records&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;manifest.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cached hashes, so re-runs don't re-read everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;duplicates.txt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every directory and archive that was skipped, and what it matched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;merge_conflicts.txt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every conflict decision that was made&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That last point matters more than it sounds. The scary part of deduplication isn't the copying, it's wondering later whether something got skipped that shouldn't have. With the paper trail I can check afterward instead of worrying.&lt;/p&gt;
&lt;h2 id="two-small-switches"&gt;Two small switches&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;--ssd&lt;/code&gt; turns on bounded four-way parallel hashing and copying. On spinning disks it's slower (the heads just thrash), so it's opt-in. &lt;code&gt;--skipdevbin&lt;/code&gt; skips the &lt;code&gt;bin&lt;/code&gt; and &lt;code&gt;obj&lt;/code&gt; folders next to recognized .NET project files, because nobody needs build output from 2014.&lt;/p&gt;
&lt;p&gt;The same flag, down to the &lt;code&gt;-sdb&lt;/code&gt; shorthand, is in my backup tool &lt;a href="https://www.ericr.org/posts/patternbuffer/"&gt;PatternBuffer.NET&lt;/a&gt;. Small utilities and big ones keep borrowing ideas from each other around here.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Transporter Room: giving a backup tool a bridge console</title>
    <link href="https://www.ericr.org/posts/transporter-room/" />
    <id>https://www.ericr.org/posts/transporter-room/</id>
    <published>2026-09-08T12:00:00Z</published>
    <updated>2026-09-08T12:00:00Z</updated>
    <summary>A command-line backup engine deserves a control panel with some personality. Transporter Room is an Avalonia desktop app with an amber-and-cyan status deck, and it has a web sibling for the server closet.</summary>
    <content type="html">&lt;p&gt;In my last post I wrote about &lt;a href="https://www.ericr.org/posts/patternbuffer/"&gt;PatternBuffer.NET&lt;/a&gt;, my Borg-inspired backup engine and its &lt;code&gt;beam&lt;/code&gt; command. The CLI is how I &lt;em&gt;build&lt;/em&gt; backups. But the backups I actually &lt;em&gt;run&lt;/em&gt; are the ones that are pleasant to start, and a terminal full of flags isn't that. Backups need a big friendly button.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;beam&lt;/code&gt; got a control room.&lt;/p&gt;
&lt;h2 id="a-console-not-a-form"&gt;A console, not a form&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Transporter Room&lt;/strong&gt; is an Avalonia desktop app, and I leaned into the theme on purpose. Instead of a gray dialog with a progress bar, you get a layered hull, illuminated &amp;ldquo;archive chambers,&amp;rdquo; console lights, and an amber-and-cyan status deck. All of it is drawn with Avalonia vector composition, with no bitmap assets, so it scales cleanly with the window.&lt;/p&gt;
&lt;p&gt;The centerpiece is the status beam: a custom-drawn transporter effect with a rising particle shimmer and a vertical &amp;ldquo;materialization field&amp;rdquo; that &lt;strong&gt;gets brighter as &lt;code&gt;beam&lt;/code&gt; reports progress&lt;/strong&gt;. It's silly, and it's also genuinely useful. You can read the state of a backup from across the room.&lt;/p&gt;
&lt;p&gt;A few practical decisions under the theming:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;BACKUP NOW&lt;/strong&gt; command deck is fixed directly above the status area. The repository, archive, and source panels scroll on their own, so the button never scrolls away.&lt;/li&gt;
&lt;li&gt;The archive name is optional. Leave it blank and &lt;code&gt;beam&lt;/code&gt; generates a stardate name like &lt;code&gt;stardate-2026.68&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;FILTERS&lt;/strong&gt; library has exclusion presets for .NET and Visual Studio output, Node, Python, JVM, Rust, Go, logs, and OS clutter, plus a box for hand-written globs. Each selection becomes an &lt;code&gt;--exclude&lt;/code&gt; argument.&lt;/li&gt;
&lt;li&gt;A circular &lt;strong&gt;?&lt;/strong&gt; button opens an operator's manual, because the stardate format and filters deserve an explanation somewhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="keeping-the-engine-separate"&gt;Keeping the engine separate&lt;/h2&gt;
&lt;p&gt;The most important design decision isn't visible at all. Transporter Room &lt;strong&gt;doesn't contain a backup engine&lt;/strong&gt;. It launches &lt;code&gt;beam&lt;/code&gt; and talks to it over a versioned JSON protocol: capabilities, progress, and results all come back as structured messages instead of scraped console text.&lt;/p&gt;
&lt;p&gt;That boundary means the CLI stays the single source of truth. Anything the GUI can do, I can script, and a GUI bug can't corrupt a repository. It also made the second front end easy.&lt;/p&gt;
&lt;h2 id="the-web-sibling"&gt;The web sibling&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Transporter Room .NET&lt;/strong&gt; is an ASP.NET Core 10 Blazor Server dashboard for the machine that &lt;em&gt;holds&lt;/em&gt; the repositories. It has a first-run setup flow, administrator and user accounts, host telemetry (CPU, memory, storage), deduplication and compression stats, and archive history per repository. It builds a client inventory from the host and OS metadata stored in each archive, and it generates copyable &lt;code&gt;beam up&lt;/code&gt;, &lt;code&gt;beam list&lt;/code&gt;, and &lt;code&gt;beam down&lt;/code&gt; commands for any client.&lt;/p&gt;
&lt;p&gt;It has one rule I'm strict about: &lt;strong&gt;the dashboard observes; it never starts a backup.&lt;/strong&gt; It runs &lt;code&gt;beam info&lt;/code&gt; and &lt;code&gt;beam list&lt;/code&gt; for telemetry, and &lt;code&gt;beam init&lt;/code&gt; only when an authenticated user creates a repository. Scheduling stays on the clients where it belongs. A web page that can quietly kick off backups on every machine in the house is a web page that will eventually do it at the worst possible moment.&lt;/p&gt;
&lt;p&gt;Passphrases for encrypted repositories are passed to &lt;code&gt;beam&lt;/code&gt; once during initialization and never stored. The dashboard simply reports those repositories as locked. I'm fine with that trade.&lt;/p&gt;
&lt;p&gt;Make it useful, then make it fun to use. The fun part is why I actually press the button.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>PatternBuffer.NET: beam up the bytes that matter</title>
    <link href="https://www.ericr.org/posts/patternbuffer/" />
    <id>https://www.ericr.org/posts/patternbuffer/</id>
    <published>2026-09-04T12:00:00Z</published>
    <updated>2026-09-04T12:00:00Z</updated>
    <summary>I wrote a backup engine from scratch: content-defined chunking, global deduplication, zstd packs, optional AES-256-GCM, and SSH remotes. It's Borg-inspired, not Borg-compatible, and very much Star Trek.</summary>
    <content type="html">&lt;p&gt;I've used &lt;a href="https://www.borgbackup.org/"&gt;Borg Backup&lt;/a&gt; for years, and I've admired its design for just as long. It splits files into content-defined chunks, stores each unique chunk once across every snapshot, and compresses everything. It's elegant. I also mostly live on Windows and .NET, where Borg is a bit awkward. So I did the thing every programmer eventually does with a tool they love: I wrote my own.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PatternBuffer.NET&lt;/strong&gt; is an independent, cross-platform backup CLI for .NET 10. The command is &lt;code&gt;beam&lt;/code&gt;, and the verbs are what you'd expect from a transporter:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-sh"&gt;beam init  ssh://eric@nas/var/lib/beam/repo --encryption=passphrase
beam up    ~/Projects --repository ssh://eric@nas/var/lib/beam/repo -sdb
beam list  --repository ssh://eric@nas/var/lib/beam/repo
beam down  stardate-2026.67 ~/restore --repository ssh://eric@nas/var/lib/beam/repo
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;(Yes, the Star Trek Borg theme is intentional. Every archive joins the repository's collective, and unchanged data is assimilated only once.)&lt;/p&gt;
&lt;p&gt;To be clear, it's &lt;strong&gt;not Borg&lt;/strong&gt;. It doesn't read Borg repositories, isn't compatible with them, and isn't affiliated with the project. It borrows ideas, not formats. It even got a logo, because a backup tool with a transporter theme deserves one:&lt;/p&gt;
&lt;p&gt;&lt;img loading="lazy" decoding="async" src="https://www.ericr.org/images/patternbuffer-logo.jpg" alt="PatternBuffer.NET logo" /&gt;&lt;/p&gt;
&lt;h2 id="chunks-that-survive-an-insert"&gt;Chunks that survive an insert&lt;/h2&gt;
&lt;p&gt;Fixed-size chunking breaks down the moment you insert a byte near the start of a file: every block after it shifts, and deduplication falls apart. Content-defined chunking puts boundaries where the &lt;em&gt;data&lt;/em&gt; says to, so an insert only disturbs the chunks around it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;beam&lt;/code&gt; uses FastCDC with a seeded Gear table. The heart of it is a rolling hash that's one shift and one add per byte:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;Chunking.cs&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-csharp"&gt;hash = (hash &amp;lt;&amp;lt; 1) + gear[value];
if (length &amp;gt;= minimum &amp;amp;&amp;amp; ((hash &amp;amp; mask) == 0 || length &amp;gt;= maximum))
{
    // cut here: this byte ends the chunk
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;The Gear table is generated from a per-repository seed with a SplitMix-style mixer. Chunk boundaries are stable within one repository but differ between repositories, which avoids leaking chunk-size patterns across them.&lt;/p&gt;
&lt;h2 id="the-rules-i-wrote-before-the-code"&gt;The rules I wrote before the code&lt;/h2&gt;
&lt;p&gt;The architecture doc opens with a list of principles, and the first is the one everything else hangs on: &lt;strong&gt;a backup program must never present a partially committed archive as complete.&lt;/strong&gt; Some of the others:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stored data is immutable.&lt;/strong&gt; Packs, index fragments, and archive records are written once and replaced, never edited in place.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every on-disk format is versioned&lt;/strong&gt;, with a defined upgrade or rejection path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memory is bounded.&lt;/strong&gt; Parallel compression is limited by a memory budget, not just the CPU count.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No silent metadata loss.&lt;/strong&gt; Metadata the platform can't store produces a warning or a failure, according to an explicit policy.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Compression is always zstd level 3. Encryption is opt-in AES-256-GCM with a passphrase. Every chunk is compressed independently, so the pipeline can do that work in parallel.&lt;/p&gt;
&lt;h2 id="remotes-over-plain-ssh"&gt;Remotes over plain SSH&lt;/h2&gt;
&lt;p&gt;The first remote backend needs nothing on the server except SSH. &lt;code&gt;beam&lt;/code&gt; scans and chunks locally, takes a lock over SSH, uploads new packs and index fragments with SCP, and &lt;strong&gt;publishes the archive record last&lt;/strong&gt;. That record is the commit point, so a dropped connection leaves orphaned packs that &lt;code&gt;compact&lt;/code&gt; can clean up, never a half-archive that &lt;code&gt;list&lt;/code&gt; would show you. A remote &lt;code&gt;down&lt;/code&gt; fetches only the packs the selected archive references.&lt;/p&gt;
&lt;p&gt;A future version will run a persistent protocol through SSH for batching, range reads, and resumable uploads. The design leaves room for it without needing it yet.&lt;/p&gt;
&lt;h2 id="stardates"&gt;Stardates&lt;/h2&gt;
&lt;p&gt;Archives you don't name get one automatically:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;BackupService.cs&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-csharp"&gt;var fraction = Math.Floor(local.DayOfYear / (double)daysInYear * 100) / 100;
return (local.Year + fraction).ToString(&amp;quot;F2&amp;quot;, CultureInfo.InvariantCulture);
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;It's the &amp;ldquo;Kelvin timeline&amp;rdquo; style of stardate: the year plus the fraction of it that's gone by. So &lt;code&gt;stardate-2026.67&lt;/code&gt; is early September. It sorts correctly and reads well in a list. I'll admit it's also just fun.&lt;/p&gt;
&lt;p&gt;Next up: a command line is great for building a backup tool, but it's not what makes people run backups. That's the &lt;a href="https://www.ericr.org/posts/transporter-room/"&gt;next post&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>A Jellyfin-to-AV1 production line</title>
    <link href="https://www.ericr.org/posts/mediaprocessor/" />
    <id>https://www.ericr.org/posts/mediaprocessor/</id>
    <published>2026-08-30T12:00:00Z</published>
    <updated>2026-08-30T12:00:00Z</updated>
    <summary>My media server was running out of room, so I built a two-part pipeline: one tool writes a plain-text index of the library, and the other works through it, re-encoding everything to AV1 with Intel Quick Sync.</summary>
    <content type="html">&lt;p&gt;Media libraries only grow. Mine lives on a Jellyfin server, and a lot of it was encoded years ago in whatever codec the ripper defaulted to at the time. AV1 can cut that down dramatically at the same visual quality, and modern Intel GPUs can encode it in hardware. The math was obvious. The logistics weren't: thousands of files, a server I didn't want to hammer, and a job that would take &lt;em&gt;weeks&lt;/em&gt; and had to survive every interruption.&lt;/p&gt;
&lt;p&gt;I split it into two small programs connected by the humblest interface there is, a text file.&lt;/p&gt;
&lt;h2 id="step-one-write-down-what-exists"&gt;Step one: write down what exists&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;jfdownload&lt;/strong&gt; walks the Jellyfin library and writes an index. Movies are one line each. Shows are a title, indented seasons, and further-indented episodes, with &lt;code&gt;|&lt;/code&gt; separating the fields:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;MediaIndex.txt&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-text"&gt;101 Dalmatians (1996)|https://…/Items/e255…/Download
12 Monkeys
  Season 1
    Episode 1|Splinter|https://…/Items/ad09…/Download
    Episode 2|Mentally Divergent|https://…/Items/1af2…/Download
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;That looks almost too simple. It's the best decision in the project. I can read it, grep it, sort it, delete lines from it, or hand-edit it to process one show first. The indexer doesn't need to know anything about encoding, and the encoder doesn't need to know anything about Jellyfin's API. When something goes wrong, I open a text file instead of a debugger.&lt;/p&gt;
&lt;h2 id="step-two-the-production-line"&gt;Step two: the production line&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;MediaProcessor&lt;/strong&gt; reads the index and handles one item at a time:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Download the movie or episode from Jellyfin.&lt;/li&gt;
&lt;li&gt;Check it with &lt;code&gt;ffprobe&lt;/code&gt;. If it's &lt;strong&gt;already AV1, skip the encode&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Otherwise, encode it with hardware-accelerated &lt;code&gt;ffmpeg&lt;/code&gt; on Intel Quick Sync, keeping the original container (&lt;code&gt;.mkv&lt;/code&gt; stays &lt;code&gt;.mkv&lt;/code&gt;, &lt;code&gt;.mp4&lt;/code&gt; stays &lt;code&gt;.mp4&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Upload the result into an organized movie or &lt;code&gt;Show/Season N&lt;/code&gt; folder.&lt;/li&gt;
&lt;li&gt;Clean up the temporary files, record progress, and move on.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The hardware path is set up explicitly so frames stay on the GPU from decode to encode:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-sh"&gt;ffmpeg -init_hw_device qsv=qs -hwaccel qsv -hwaccel_output_format qsv \
       -i input.mkv -c:v av1_qsv … output.mkv
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;h2 id="built-to-be-interrupted"&gt;Built to be interrupted&lt;/h2&gt;
&lt;p&gt;Batch jobs that run for weeks need to be defensive, not clever. Before starting, MediaProcessor &lt;strong&gt;loads an inventory of what's already on the destination server&lt;/strong&gt;, and anything already there is marked &amp;ldquo;Already on server&amp;rdquo; and skipped. That means there's no separate progress file to get out of sync: the output &lt;em&gt;is&lt;/em&gt; the checkpoint, and restarting after a crash just continues.&lt;/p&gt;
&lt;p&gt;It &lt;strong&gt;keeps going past individual failures&lt;/strong&gt;: one corrupt episode is logged as a non-fatal error rather than ending the run at 3 a.m. And it overlaps the slow parts. &lt;strong&gt;While one item is encoding, the next one is already downloading&lt;/strong&gt;, which keeps both the network and the GPU busy:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;MediaProcessing.cs&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-csharp"&gt;PreparedMediaItem? currentItem = await currentPreparationTask!;
currentPreparationTask = index + 1 &amp;lt; items.Count
    ? PrepareItemAsync(items[index + 1], sessionRoot, progress, token)
    : null;

await ProcessPreparedItemAsync(currentItem, progress, token);
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;It has a plain &lt;strong&gt;Start/Stop&lt;/strong&gt; UI with progress bars for download, encode, and upload, and the per-run temp folder is deleted however the run ends.&lt;/p&gt;
&lt;p&gt;There's a commented-out &lt;code&gt;vpp_qsv&lt;/code&gt; line in the source for denoising and downscaling to 720p. It's evidence of an evening spent convincing myself that I wanted to shrink the files, not the picture.&lt;/p&gt;
&lt;h2 id="the-part-id-reuse-everywhere"&gt;The part I'd reuse everywhere&lt;/h2&gt;
&lt;p&gt;Whenever a job has a &lt;em&gt;discovery&lt;/em&gt; phase and a &lt;em&gt;doing&lt;/em&gt; phase, I now reach for a plain intermediate file between them. It turns one big fragile program into two small, testable ones, and it gives me a place to step in by hand. It's the same staged thinking that shaped my earlier &lt;a href="https://www.ericr.org/posts/disc-ripper/"&gt;disc-ripping pipeline&lt;/a&gt;, just with fewer moving parts.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>From shiny discs to a searchable library</title>
    <link href="https://www.ericr.org/posts/disc-ripper/" />
    <id>https://www.ericr.org/posts/disc-ripper/</id>
    <published>2026-08-26T12:00:00Z</published>
    <updated>2026-08-26T12:00:00Z</updated>
    <summary>My DVD ripper grew from a HandBrake wrapper into a message-bus pipeline with a server of its own. Then I lost the source and got it back with a decompiler.</summary>
    <content type="html">&lt;p&gt;Somewhere in the house is a shelf, then a closet, then a few plastic tubs of DVDs. Ripping them isn't hard. HandBrake has made that easy for a long time. The hard part is the other 90% of the job: noticing a disc is in the drive, figuring out &lt;em&gt;which&lt;/em&gt; movie it actually is, naming the file so the media server recognizes it, not ripping something that's already in the collection, getting it onto the server, and cleaning up. Do that by hand two hundred times and you'll write a program too.&lt;/p&gt;
&lt;h2 id="a-pipeline-of-small-components"&gt;A pipeline of small components&lt;/h2&gt;
&lt;p&gt;The Windows app, &lt;strong&gt;DvdRipperGUI&lt;/strong&gt;, is a set of small components that never call each other directly. They communicate over an in-process &lt;strong&gt;message bus&lt;/strong&gt;, plus a request/response bus for when one component needs an answer from another. The message list reads like a checklist of the job:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;Messages.cs (a sampling)&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-text"&gt;LookForDVD   FindTitle   AskForTitle   CreateFilename   CheckExists
StartRipDVD  UploadFile  CleanupFile   ManualUpload     StatusUpdate
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DvdDetector&lt;/strong&gt; waits for a disc and announces it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LocalTitleSearch&lt;/strong&gt; looks up the title with TMDB, and &lt;strong&gt;AskForTitle&lt;/strong&gt; pops up a dialog when the disc label is something useless like &lt;code&gt;DVD_VIDEO&lt;/code&gt;. There are a lot of those.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FileNamer&lt;/strong&gt; turns the chosen title into &lt;code&gt;Title (Year).mp4&lt;/code&gt;, the format media servers match on the first try.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CheckInCollection&lt;/strong&gt; asks the server whether that movie already exists before anything gets ripped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HandbrakeInterface&lt;/strong&gt; drives &lt;code&gt;HandBrakeCLI&lt;/code&gt; and reports progress.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FileUploader&lt;/strong&gt; and &lt;strong&gt;FileCleanup&lt;/strong&gt; finish the job, and a &lt;strong&gt;RetryForm&lt;/strong&gt; handles the uploads that don't.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keeping everything on a message bus made the steps easy to reorder, skip, and test on their own. It also made a log window nearly free: every message is already a line of history.&lt;/p&gt;
&lt;h2 id="the-server-side"&gt;The server side&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;DvdRipperServer&lt;/strong&gt; is a tiny ASP.NET minimal API next to the movie folder. &lt;code&gt;/movie?movieName=…&amp;amp;year=…&lt;/code&gt; answers &amp;quot;do we already have it?&amp;quot;, &lt;code&gt;/movies&lt;/code&gt; renders the whole collection as an HTML table (with a regex that also understands &lt;code&gt;{tmdb-…}&lt;/code&gt; tags in names), and &lt;code&gt;/upload&lt;/code&gt; receives the finished file. A separate worker service, &lt;strong&gt;DvdServerFilesystem&lt;/strong&gt;, handles the file-management side on the server.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;/movie&lt;/code&gt; endpoint answers &amp;ldquo;yes&amp;rdquo; with &lt;strong&gt;302 Found&lt;/strong&gt; and &amp;ldquo;no&amp;rdquo; with &lt;strong&gt;404&lt;/strong&gt;. I'm not going to defend that choice of status codes, but it works.&lt;/p&gt;
&lt;h2 id="the-day-the-source-went-missing"&gt;The day the source went missing&lt;/h2&gt;
&lt;p&gt;This is the part I'm least proud of and most amused by. At some point the best copy of DvdRipperGUI I had was a compiled build sitting on a desktop, not the project that produced it.&lt;/p&gt;
&lt;p&gt;So I pointed a .NET decompiler at my own DLL. .NET assemblies keep a lot of structure, so what came back was very readable. Every file in the current source still starts with a &lt;code&gt;// Decompiled with JetBrains decompiler&lt;/code&gt; header, and I've left them there on purpose as a reminder. You can also see where the compiler got to my code first. This used to be a one-line interpolated string:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;Components/FileNamer.cs, as the decompiler sees it&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-csharp"&gt;DefaultInterpolatedStringHandler interpolatedStringHandler = new DefaultInterpolatedStringHandler(3, 1);
interpolatedStringHandler.AppendLiteral(&amp;quot; (&amp;quot;);
interpolatedStringHandler.AppendFormatted&amp;lt;int&amp;gt;(movie.Year);
interpolatedStringHandler.AppendLiteral(&amp;quot;)&amp;quot;);
string stringAndClear = interpolatedStringHandler.ToStringAndClear();
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;If you ever need an argument for good backups, that's one. It's also part of why the next experiment, &lt;strong&gt;DiscRipperNext&lt;/strong&gt;, is a fresh Avalonia project instead of more patches. Its first real design decision is a &lt;code&gt;LinuxDiscDetector&lt;/code&gt; class: put the one truly platform-specific piece (how do I know a disc was inserted?) behind its own boundary, and let everything else be portable.&lt;/p&gt;
&lt;p&gt;The tubs are getting emptier. The library is getting better organized. And the next stage of this pipeline became &lt;a href="https://www.ericr.org/posts/mediaprocessor/"&gt;its own project&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Lawn logic: a sprinkler controller from GPIO pins to my phone</title>
    <link href="https://www.ericr.org/posts/sprinkler/" />
    <id>https://www.ericr.org/posts/sprinkler/</id>
    <published>2026-08-21T12:00:00Z</published>
    <updated>2026-08-21T12:00:00Z</updated>
    <summary>The proprietary sprinkler timer couldn't give each zone its own watering time. A Raspberry Pi, a relay board, a REST API, and an Avalonia app later, my lawn also checks the National Weather Service.</summary>
    <content type="html">&lt;p&gt;The sprinkler controller that came with the house had one real job and did it badly. It could run all the zones, for the same amount of time, on the days I picked. That was about it. The shady side yard doesn't need what the full-sun front lawn needs, and the controller simply had no way to express that.&lt;/p&gt;
&lt;p&gt;The valves themselves are just 24-volt solenoids. So the controller went into a drawer. A Raspberry Pi and a GPIO relay board went into the valve box's wiring, and I started over.&lt;/p&gt;
&lt;h2 id="the-proof-of-concept"&gt;The proof of concept&lt;/h2&gt;
&lt;p&gt;The first version, preserved in the docs as &lt;code&gt;SimpleExample.md&lt;/code&gt;, is a single loop that fits on one screen:&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;SimpleExample.md&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-csharp"&gt;var controller = new GpioController();
var pins = new int[] { 21, 5, 6, 13, 16, 19, 20 };

// Turn off all the sprinkler valves
foreach (var pin in pins)
{
    controller.OpenPin(pin, PinMode.Output);
    controller.Write(pin, PinValue.High);
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;Notice that &amp;ldquo;off&amp;rdquo; is &lt;code&gt;High&lt;/code&gt;. The relay board is &lt;strong&gt;active-low&lt;/strong&gt;: pulling a pin low energizes the relay and opens the valve. That has one nice property. If the program crashes and the pins float high, the water shuts &lt;em&gt;off&lt;/em&gt;. If you're going to get a hardware detail backwards, get it backwards in the direction that doesn't flood the yard.&lt;/p&gt;
&lt;p&gt;Seven zones, seven BCM pins (5, 6, 13, 16, 19, 20, 21), with 26 left free on the board for an eighth someday.&lt;/p&gt;
&lt;h2 id="the-real-thing"&gt;The real thing&lt;/h2&gt;
&lt;p&gt;The proof of concept watered the lawn for a season. The real system is two projects.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SprinklerServer&lt;/strong&gt; runs on the Pi and exposes a REST API. Zones get friendly names (&amp;ldquo;Front Lawn&amp;rdquo; is much nicer than &amp;ldquo;GPIO 13&amp;rdquo;). Turning one zone on turns every other zone off, so only one valve is ever open. Schedules are a set of weekdays, an ordered list of zones, a duration for each, and a start time, and several schedules can share a day. New sod, for example, gets an extra midday schedule that I delete once it's established. If I turn a zone on by hand in the middle of a scheduled run, the schedule &lt;strong&gt;pauses and then resumes where it left off&lt;/strong&gt;. Hardware access sits behind an &lt;code&gt;IZoneHardwareController&lt;/code&gt; interface, with a no-op implementation so the whole thing runs on my desktop for testing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SprinklerApp&lt;/strong&gt; is an Avalonia client with desktop, browser, Android, and iOS targets from one codebase. The phone is the one I actually use: standing in the yard, turning on the zone with the broken head, and watching it.&lt;/p&gt;
&lt;h2 id="a-rain-sensor-made-of-weather-data"&gt;A rain sensor made of weather data&lt;/h2&gt;
&lt;p&gt;Instead of wiring in a physical rain sensor, the server asks the &lt;strong&gt;National Weather Service&lt;/strong&gt; API. With the Rain Sensor setting enabled, a scheduled run is skipped if either:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;measurable rain was &lt;strong&gt;observed in the two days&lt;/strong&gt; before the start time, or&lt;/li&gt;
&lt;li&gt;rain is &lt;strong&gt;forecast for three or more hours&lt;/strong&gt; on the watering day.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It checks once a day, and again right before each scheduled run. A skipped run is recorded as handled for that start time, so the scheduler doesn't keep asking the same question every minute of the watering window.&lt;/p&gt;
&lt;h2 id="mutual-tls-in-the-backyard"&gt;Mutual TLS in the backyard&lt;/h2&gt;
&lt;p&gt;This is a device that turns physical things on and off, on my network, with an app on my phone that leaves the house. So the server &lt;strong&gt;requires a client certificate&lt;/strong&gt;. Only clients holding a certificate from my own small certificate authority can talk to it, and in the other direction the app trusts the server only if its certificate chains to that same private root. It's more ceremony than a sprinkler seems to deserve, right up until you picture someone else running your zones at 3 a.m.  It's baffling to me that my little sprinkler controller is more paranoid about security than some &lt;a href="https://www.invicti.com/blog/web-security/how-equifax-data-breach-hack-happened"&gt;credit reporting agencies are&lt;/a&gt;, but this is the world we live in these days.&lt;/p&gt;
&lt;p&gt;The drawer still has the old controller in it. It's staying there.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>LOAD "ERICR.ORG",8,1</title>
    <link href="https://www.ericr.org/posts/load-ericr-org/" />
    <id>https://www.ericr.org/posts/load-ericr-org/</id>
    <published>2026-08-17T12:00:00Z</published>
    <updated>2026-08-17T12:00:00Z</updated>
    <summary>Forty-odd years after typing in my first BASIC listing, I'm finally starting the blog I've been meaning to write. Here's who I am and what to expect.</summary>
    <content type="html">&lt;p&gt;My first programs were LOGO and BASIC on a TRS-80, in 1983. A couple of years later it was a Commodore 128 and &lt;em&gt;RUN&lt;/em&gt; magazine, whose type-in listings were the best part of every issue. You typed a program in one line at a time, got the &lt;code&gt;?SYNTAX ERROR&lt;/code&gt; that meant you'd mistyped something, fixed it, and ran it again. When it finally worked, it felt like you'd built it yourself. I learned a lot from those pages, and from typing every character by hand. You can't skim a program you have to type.&lt;/p&gt;
&lt;p&gt;That's roughly the spirit of this site.&lt;/p&gt;
&lt;h2 id="whos-writing"&gt;Who's writing&lt;/h2&gt;
&lt;p&gt;I'm Eric. I've been building things with computers since the early '80s: BASIC and LOGO, then QuickBASIC, Turbo Pascal, Turbo C, and assembly with MASM. I spent a stretch in the demoscene writing audio code for the Gravis Ultrasound. I installed Slackware from a stack of floppies in 1993 and have run some flavor of Linux ever since. I earned a CS degree from the University of Nevada, Reno, and I've spent the decades since designing infrastructure, writing software across a lot of languages and platforms, and solving real problems for real people.&lt;/p&gt;
&lt;p&gt;These days I mostly write C# and .NET, with a detour into VR and Unity, and lately a lot of time figuring out how to work &lt;em&gt;with&lt;/em&gt; AI tools instead of just around them.&lt;/p&gt;
&lt;h2 id="what-to-expect-here"&gt;What to expect here&lt;/h2&gt;
&lt;p&gt;My &lt;code&gt;~/Projects&lt;/code&gt; folder is a museum of scratched itches: backup engines, media pipelines, a sprinkler controller, disc rippers, and plenty of small utilities that exist because something annoyed me one time too many. Most of them have never been written up anywhere. This is where that changes.&lt;/p&gt;
&lt;p&gt;Expect articles that are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;About real projects&lt;/strong&gt;, with the design decisions, the dead ends, and the code that matters&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Honest about trade-offs&lt;/strong&gt;, because the &lt;em&gt;why&lt;/em&gt; is usually more useful than the &lt;em&gt;what&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A little bit nerdy about it&lt;/strong&gt;, since a backup tool that names its archives with stardates is my kind of fun&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The site itself is a small project too. It's a static site built by a homemade C# generator from plain Markdown files, and the illustrations are painted by an AI image model running on a server in my house. The home page is a Commodore disk directory for a reason.&lt;/p&gt;
&lt;figure class="listing"&gt;&lt;figcaption&gt;HELLO.BAS&lt;/figcaption&gt;&lt;div class="listing-paper"&gt;&lt;pre&gt;&lt;code class="language-basic"&gt;10 PRINT &amp;quot;HELLO FROM ERICR.ORG&amp;quot;
20 GOTO 10
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;Welcome aboard. READY.&lt;/p&gt;
</content>
  </entry>
</feed>