<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>AkitaOnRails.com</title><link>https://akitaonrails.github.io/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 11 May 2026 23:00:49 GMT</lastBuildDate><atom:link href="https://akitaonrails.github.io/index.xml" rel="self" type="application/rss+xml"/><item><title>LLM Benchmarks: DeepSeek Unlocked! Use DeepClaude</title><link>https://akitaonrails.github.io/en/2026/05/04/llm-benchmarks-deepseek-unlocked-deepclaude/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/05/04/llm-benchmarks-deepseek-unlocked-deepclaude/</guid><pubDate>Mon, 04 May 2026 16:00:00 GMT</pubDate><description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (May 11, 2026)&lt;/strong&gt;: at least two more paths beyond DeepClaude are worth flagging.&lt;/p&gt;
&lt;p&gt;The first is &lt;a href="https://github.com/Hmbown/DeepSeek-TUI"target="_blank" rel="noopener"&gt;DeepSeek-TUI&lt;/a&gt; (repo at &lt;a href="https://github.com/Hmbown/DeepSeek-TUI"target="_blank" rel="noopener"&gt;Hmbown/DeepSeek-TUI&lt;/a&gt;). A Rust-based coding agent with a Codex-style 13-crate workspace, Plan / Agent / YOLO modes, MCP integration, full 1M-token context, streaming reasoning blocks. It talks straight to the DeepSeek API with proper &lt;code&gt;reasoning_content&lt;/code&gt; handling by design, so opencode&amp;rsquo;s bug never shows up. On provenance, the author is Hunter Bown and the project is listed in &lt;a href="https://github.com/deepseek-ai/awesome-deepseek-agent/blob/main/docs/deepseek-tui.md"target="_blank" rel="noopener"&gt;awesome-deepseek-agent&lt;/a&gt; curated by DeepSeek themselves, which counts as endorsement. Install via npm, cargo, homebrew, binary, or docker.&lt;/p&gt;</description><content:encoded><![CDATA[<blockquote>
  <p><strong>Update (May 11, 2026)</strong>: at least two more paths beyond DeepClaude are worth flagging.</p>
<p>The first is <a href="https://github.com/Hmbown/DeepSeek-TUI"target="_blank" rel="noopener">DeepSeek-TUI</a> (repo at <a href="https://github.com/Hmbown/DeepSeek-TUI"target="_blank" rel="noopener">Hmbown/DeepSeek-TUI</a>). A Rust-based coding agent with a Codex-style 13-crate workspace, Plan / Agent / YOLO modes, MCP integration, full 1M-token context, streaming reasoning blocks. It talks straight to the DeepSeek API with proper <code>reasoning_content</code> handling by design, so opencode&rsquo;s bug never shows up. On provenance, the author is Hunter Bown and the project is listed in <a href="https://github.com/deepseek-ai/awesome-deepseek-agent/blob/main/docs/deepseek-tui.md"target="_blank" rel="noopener">awesome-deepseek-agent</a> curated by DeepSeek themselves, which counts as endorsement. Install via npm, cargo, homebrew, binary, or docker.</p>
<p>The second is that opencode now supports DeepSeek thinking mode via <a href="https://github.com/anomalyco/opencode/pull/24146"target="_blank" rel="noopener">PR #24146</a>, merged April 24, 2026. The fix preserves <code>reasoning_content</code> in turn history, but it needs manual configuration in <code>opencode.json</code>: add <code>interleaved: { field: &quot;reasoning_content&quot; }</code> to the DeepSeek V4 model config. Without that the bug stays, and the new names (<code>deepseek-v4-pro</code>, <code>deepseek-v4-flash</code>) aren&rsquo;t pre-configured with <code>interleaved</code> by default. When I ran Round 3 of the benchmark, that fix either hadn&rsquo;t landed yet or my config didn&rsquo;t have the flag set. For a future benchmark, worth re-testing via opencode with <code>interleaved</code> properly configured.</p>
<p>The rest of the post below still holds. DeepClaude remains the most ergonomic option for anyone using Claude Code as their primary harness. If you&rsquo;re already on opencode or prefer a dedicated TUI, alternatives are on the menu now.</p>

</blockquote>
<p>DeepSeek V4 Pro stopped being a lost cause in my coding benchmark. It used to be solo Tier B (69/100) and literally unmeasurable in any multi-agent scenario, because it kept hitting a protocol bug I documented across the last two posts. The good news: I found a path that unblocks the model, and it jumped out of limbo straight into <strong>Tier A at 89/100</strong>, sitting only behind Opus 4.7, GPT 5.4/5.5 and Kimi K2.6. I&rsquo;ll walk through how I got there, what <strong>DeepClaude</strong> is, how you can use it, and where this puts DeepSeek in the updated ranking.</p>
<h2>Recapping the previous posts<span class="hx:absolute hx:-mt-20" id="recapping-the-previous-posts"></span>
    <a href="#recapping-the-previous-posts" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you just got here, the context matters. This benchmark experiment kicked off in April, and the DeepSeek case has been unfolding across four articles:</p>
<ol>
<li>
<p><a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">Testing Open Source and Commercial LLMs — Who Can Beat Claude Opus?</a> (April 5). First cut, comparing ~20 models running a Rails 8 + RubyLLM + Hotwire + Tailwind + Minitest task. It defined the scenario and the base task I&rsquo;m still using today.</p>
</li>
<li>
<p><a href="/en/2026/04/18/llm-benchmarks-part-2-multi-model/">LLM Benchmarks Part 2: Worth Combining Multiple Models in the Same Project? Claude + GLM??</a> (April 18). First orchestration attempt: have a strong planner (Opus) call cheaper subagents (Kimi, Qwen, GLM, DeepSeek). Result: zero delegations across seven free-choice variants. Strong models would rather do everything themselves.</p>
</li>
<li>
<p><a href="/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/">LLM Coding Benchmark (May 2026): DeepSeek v4, Kimi v2.6, Grok 4.3, GPT 5.5</a> (April 24, updated in May). The canonical version with 24 models, standardized 0-100 rubric across 8 dimensions, A/B/C/D tiers. It&rsquo;s the current &ldquo;who&rsquo;s what&rdquo; reference.</p>
</li>
<li>
<p><a href="/en/2026/04/25/llm-benchmarks-vale-a-pena-misturar-2-modelos/">LLM Benchmarks: Is it worth ($) mixing 2 Models? (Planner + Executor)</a> (April 25). Three rounds of multi-model orchestration: free-choice, forced delegation, and manual cross-process orchestration. Short answer: for a cohesive Rails greenfield task, solo Opus 4.7 in opencode (97/100, $4, 18m) beats every combination. This was the post where I documented DeepSeek V4 Pro&rsquo;s protocol incompatibility with any ai-sdk-based harness.</p>
</li>
</ol>
<p>The DeepSeek story up to that point looked like this: solo in opencode delivers 69/100 Tier B (correct RubyLLM code, but with a stock README, no <code>docker-compose.yml</code>, no bundle-audit). In any multi-turn opencode scenario, the API rejects turn 2 with <code>&quot;reasoning_content must be passed back to the API&quot;</code>. The ai-sdk that opencode uses underneath strips <code>reasoning_content</code> while building the next request, and DeepSeek returns 400. Worse: opencode buries the error in the event stream and falls back to the <code>general</code> agent, which is Opus 4.7. The runs &ldquo;complete&rdquo; with files written, but most of the output came from Opus masquerading as DeepSeek. Score 69 reflects mixed authorship, not V4 Pro for real.</p>
<p>The takeaway that landed: DeepSeek V4 Pro was fundamentally incompatible with any ai-sdk harness (opencode included). To use it for real, you&rsquo;d need direct API with thinking-mode handling or a custom harness.</p>
<p>The discovery this week is that a custom harness already exists. It&rsquo;s called <strong>DeepClaude</strong>.</p>
<h2>What DeepClaude is<span class="hx:absolute hx:-mt-20" id="what-deepclaude-is"></span>
    <a href="#what-deepclaude-is" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/aattaran/deepclaude"target="_blank" rel="noopener">DeepClaude</a> is a shell shim for Claude Code (Anthropic&rsquo;s CLI) that swaps the endpoint it queries. Claude Code, under the hood, talks to <code>api.anthropic.com</code> and expects the Anthropic format (Messages API with <code>system</code>, <code>messages</code>, <code>tools</code>, etc.). DeepClaude sets a few environment variables before invoking Claude Code:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ANTHROPIC_BASE_URL          <span class="c1"># alternative endpoint</span>
</span></span><span class="line"><span class="cl">ANTHROPIC_AUTH_TOKEN        <span class="c1"># token for the alternative endpoint</span>
</span></span><span class="line"><span class="cl">ANTHROPIC_DEFAULT_OPUS_MODEL    <span class="c1"># model to invoke instead of Opus</span>
</span></span><span class="line"><span class="cl">ANTHROPIC_DEFAULT_SONNET_MODEL  <span class="c1"># same for Sonnet</span>
</span></span><span class="line"><span class="cl">ANTHROPIC_DEFAULT_HAIKU_MODEL   <span class="c1"># same for Haiku</span>
</span></span><span class="line"><span class="cl">CLAUDE_CODE_SUBAGENT_MODEL  <span class="c1"># subagent model</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Supported backends:</p>
<ul>
<li><strong>DeepSeek directly</strong> via <code>api.deepseek.com/anthropic</code> (needs <code>DEEPSEEK_API_KEY</code>)</li>
<li><strong>OpenRouter</strong> via <code>openrouter.ai/api</code> (needs <code>OPENROUTER_API_KEY</code>)</li>
<li><strong>Fireworks AI</strong> via <code>api.fireworks.ai/inference</code> (needs <code>FIREWORKS_API_KEY</code>)</li>
<li><strong>Anthropic</strong> (no override, regular Claude Code)</li>
</ul>
<p>The clever bit is that Claude Code never realizes the swap. Its full agent loop (file editing, bash execution, subagent spawning, multi-step tool use, Anthropic prompt caching) runs the same way. It&rsquo;s just that every model call goes out via OpenRouter (or DeepSeek direct, or Fireworks) and lands on a model you picked. In our benchmark&rsquo;s case, DeepSeek V4 Pro now receives the traffic that would normally go to Opus 4.7.</p>
<p>Why does this fix the <code>reasoning_content</code> bug? Because OpenRouter&rsquo;s Anthropic-compatible endpoint (<code>/anthropic</code>) handles thinking content correctly in the format Claude Code emits. The ai-sdk opencode uses didn&rsquo;t. Different harness, no bug.</p>
<p>Install:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/aattaran/deepclaude ~/Projects/deepclaude
</span></span><span class="line"><span class="cl">ln -sf ~/Projects/deepclaude/deepclaude.sh ~/.local/bin/deepclaude
</span></span><span class="line"><span class="cl">chmod +x ~/Projects/deepclaude/deepclaude.sh
</span></span><span class="line"><span class="cl">deepclaude --status   <span class="c1"># confirms which backend keys it detected</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then run:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">OPENROUTER_API_KEY</span><span class="o">=</span>sk-...
</span></span><span class="line"><span class="cl">deepclaude --provider openrouter --model deepseek/deepseek-v4-pro
</span></span><span class="line"><span class="cl"><span class="c1"># starts a Claude Code session with DeepSeek V4 Pro answering instead of Opus</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>To exit and go back to regular Claude Code, just <code>exit</code> the session. The environment variables are restored at the end of the script.</p>
<h2>How I extended the benchmark suite to support DeepClaude<span class="hx:absolute hx:-mt-20" id="how-i-extended-the-benchmark-suite-to-support-deepclaude"></span>
    <a href="#how-i-extended-the-benchmark-suite-to-support-deepclaude" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <code>claude_code_runner.py</code> I already use to run Claude Code variants (<code>claude_opus_alone</code>, <code>claude_opus_sonnet</code>, <code>claude_opus_haiku</code>) needed a way to inject env vars per variant, without breaking the existing pipeline. I added an optional <code>env_overrides</code> field to the JSON config of each variant. The shape:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;env_overrides&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;ANTHROPIC_BASE_URL&#34;</span><span class="p">:</span> <span class="s2">&#34;https://openrouter.ai/api&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;ANTHROPIC_AUTH_TOKEN&#34;</span><span class="p">:</span> <span class="s2">&#34;$OPENROUTER_API_KEY&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;ANTHROPIC_DEFAULT_OPUS_MODEL&#34;</span><span class="p">:</span> <span class="s2">&#34;deepseek/deepseek-v4-pro&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;ANTHROPIC_DEFAULT_SONNET_MODEL&#34;</span><span class="p">:</span> <span class="s2">&#34;deepseek/deepseek-v4-pro&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;ANTHROPIC_DEFAULT_HAIKU_MODEL&#34;</span><span class="p">:</span> <span class="s2">&#34;deepseek/deepseek-v4-pro&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;CLAUDE_CODE_SUBAGENT_MODEL&#34;</span><span class="p">:</span> <span class="s2">&#34;deepseek/deepseek-v4-pro&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;UNSET:ANTHROPIC_API_KEY&#34;</span><span class="p">:</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Conventions:</p>
<ul>
<li>A value starting with <code>$</code> is an indirect lookup against the parent shell env (<code>$OPENROUTER_API_KEY</code> resolves to whatever&rsquo;s in the user&rsquo;s shell). Keeps secrets out of the version-controlled <code>config/*.json</code>.</li>
<li>A key starting with <code>UNSET:</code> removes the variable from the subprocess env. I use this to drop <code>ANTHROPIC_API_KEY</code> when swapping to a non-Anthropic backend, otherwise the SDK might prefer it over <code>ANTHROPIC_AUTH_TOKEN</code>.</li>
<li>Everything else is literal.</li>
</ul>
<p>The runner logs which overrides got applied (with masked secrets) at the start of each variant, so the benchmark output captures the full setup. That gave me two new variants in the benchmark:</p>
<table>
  <thead>
      <tr>
          <th>Slug</th>
          <th>Setup</th>
          <th>Subagent registered?</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>claude_code_deepseek_v4_pro_or</code></td>
          <td>Claude Code via DeepClaude → DeepSeek V4 Pro for the entire loop</td>
          <td>none</td>
      </tr>
      <tr>
          <td><code>claude_code_deepseek_v4_pro_or_sonnet</code></td>
          <td>Same, but with <code>sonnet-coder</code> registered (Sonnet 4.6 via Anthropic API)</td>
          <td>yes, but zero delegations observed</td>
      </tr>
  </tbody>
</table>
<p>Both ran with <code>--no-progress-minutes 15</code> (the standardized watchdog from Round 2.5).</p>
<h2>Results: DeepSeek V4 Pro in Tier A<span class="hx:absolute hx:-mt-20" id="results-deepseek-v4-pro-in-tier-a"></span>
    <a href="#results-deepseek-v4-pro-in-tier-a" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Both variants ran end-to-end with no <code>reasoning_content</code> errors. Multi-turn works. Registered subagent works (even if not invoked). Tool calls, file editing, bash, all of it works. The numbers:</p>
<table>
  <thead>
      <tr>
          <th>Variant</th>
          <th>Status</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Files</th>
          <th style="text-align: right">Turns</th>
          <th style="text-align: right">Delegations</th>
          <th style="text-align: right">Cost</th>
          <th style="text-align: right">Score</th>
          <th>Tier</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>..._or</code></td>
          <td>completed</td>
          <td style="text-align: right"><strong>21m</strong></td>
          <td style="text-align: right">1544</td>
          <td style="text-align: right">106</td>
          <td style="text-align: right">0 (no subagent)</td>
          <td style="text-align: right"><strong>$3.38</strong></td>
          <td style="text-align: right"><strong>84</strong></td>
          <td>A</td>
      </tr>
      <tr>
          <td><code>..._or_sonnet</code></td>
          <td>completed</td>
          <td style="text-align: right"><strong>18m</strong></td>
          <td style="text-align: right">1348</td>
          <td style="text-align: right">109</td>
          <td style="text-align: right"><strong>0</strong> (Sonnet ignored)</td>
          <td style="text-align: right"><strong>$3.14</strong></td>
          <td style="text-align: right"><strong>89</strong></td>
          <td>A</td>
      </tr>
  </tbody>
</table>
<p>Both 100% billed against <code>deepseek/deepseek-v4-pro</code>. Zero Sonnet tokens despite being registered. The subagent was there, the DeepSeek planner just never invoked it.</p>
<p>And where does this put DeepSeek in the canonical ranking? Here:</p>
<table>
  <thead>
      <tr>
          <th style="text-align: right">Rank</th>
          <th>Model</th>
          <th style="text-align: right">Score</th>
          <th style="text-align: center">Tier</th>
          <th>Time</th>
          <th>Cost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td style="text-align: right">1</td>
          <td>Claude Opus 4.7 (opencode)</td>
          <td style="text-align: right"><strong>97</strong></td>
          <td style="text-align: center">A</td>
          <td>18m</td>
          <td>~$1.10</td>
      </tr>
      <tr>
          <td style="text-align: right">1</td>
          <td>GPT 5.4 xHigh (Codex)</td>
          <td style="text-align: right"><strong>97</strong></td>
          <td style="text-align: center">A</td>
          <td>22m</td>
          <td>~$16</td>
      </tr>
      <tr>
          <td style="text-align: right">3</td>
          <td>GPT 5.5 xHigh (Codex)</td>
          <td style="text-align: right"><strong>96</strong></td>
          <td style="text-align: center">A</td>
          <td>18m</td>
          <td>~$10</td>
      </tr>
      <tr>
          <td style="text-align: right"><strong>4</strong></td>
          <td><strong>DeepSeek V4 Pro (DeepClaude + sonnet registered)</strong></td>
          <td style="text-align: right"><strong>89</strong></td>
          <td style="text-align: center"><strong>A</strong></td>
          <td><strong>18m</strong></td>
          <td><strong>$3.14</strong></td>
      </tr>
      <tr>
          <td style="text-align: right">5</td>
          <td>Kimi K2.6</td>
          <td style="text-align: right">87</td>
          <td style="text-align: center">A</td>
          <td>20m</td>
          <td>~$0.30</td>
      </tr>
      <tr>
          <td style="text-align: right">6</td>
          <td>DeepSeek V4 Pro (DeepClaude solo)</td>
          <td style="text-align: right">84</td>
          <td style="text-align: center">A</td>
          <td>21m</td>
          <td>$3.38</td>
      </tr>
      <tr>
          <td style="text-align: right">7</td>
          <td>Claude Opus 4.6</td>
          <td style="text-align: right">83</td>
          <td style="text-align: center">A</td>
          <td>16m</td>
          <td>~$1.10</td>
      </tr>
      <tr>
          <td style="text-align: right">8</td>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: right">82</td>
          <td style="text-align: center">A</td>
          <td>14m</td>
          <td>~$0.40</td>
      </tr>
      <tr>
          <td style="text-align: right">9</td>
          <td>Claude Sonnet 4.6</td>
          <td style="text-align: right">78</td>
          <td style="text-align: center">B</td>
          <td>16m</td>
          <td>~$0.63</td>
      </tr>
      <tr>
          <td style="text-align: right">9</td>
          <td>DeepSeek V4 Flash</td>
          <td style="text-align: right">78</td>
          <td style="text-align: center">B</td>
          <td>3m</td>
          <td>~$0.01</td>
      </tr>
      <tr>
          <td style="text-align: right">11</td>
          <td>Qwen 3.6 Plus</td>
          <td style="text-align: right">71</td>
          <td style="text-align: center">B</td>
          <td>17m</td>
          <td>~$0.15</td>
      </tr>
      <tr>
          <td style="text-align: right">&hellip;</td>
          <td>DeepSeek V4 Pro (opencode solo)</td>
          <td style="text-align: right">69</td>
          <td style="text-align: center">B</td>
          <td>~25m</td>
          <td>~$1</td>
      </tr>
  </tbody>
</table>
<p>In one round V4 Pro delivers Tier A, leaves the &ldquo;unmeasurable&rdquo; limbo behind, lands next to Kimi K2.6, and beats Opus 4.6 / Gemini 3.1 Pro. <strong>A 15-to-20-point lift from a harness change alone.</strong> Same model, same prompt, no orchestration, just a different agent loop.</p>
<h2>What this round proved (and what it didn&rsquo;t)<span class="hx:absolute hx:-mt-20" id="what-this-round-proved-and-what-it-didnt"></span>
    <a href="#what-this-round-proved-and-what-it-didnt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>The Claude Code regression with Opus is Opus-specific, not a harness property<span class="hx:absolute hx:-mt-20" id="the-claude-code-regression-with-opus-is-opus-specific-not-a-harness-property"></span>
    <a href="#the-claude-code-regression-with-opus-is-opus-specific-not-a-harness-property" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the biggest finding. In Round 1 I&rsquo;d documented that Opus 4.7 inside Claude Code hallucinated <code>chat.complete</code> (Tier 3), while the same Opus 4.7 in opencode delivered Tier A. The hypothesis was that the context Claude Code injects (system prompt, tool schemas, agent registry, with 6-11M cache-read tokens) was nudging Opus toward an OpenAI-SDK mental model instead of the specific RubyLLM one.</p>
<p>DeepSeek V4 Pro inside the same Claude Code harness used the correct <code>chat.ask</code> path (<code>chat_service.rb:17</code> in both variants). No <code>chat.complete</code>, no invented fluent DSL, no batch-form invention. Cross-grep on both project trees:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>grep -nE &#34;RubyLLM::Client|chat\.complete|chat\.send_message|chat\.user|chat\.assistant&#34;
→ 0 hits (both variants)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The regression is Opus-specific. Opus&rsquo;s training has a particular vulnerability to how Claude Code&rsquo;s system prompt and schema-chatter prime the model. DeepSeek is robust to it. Other reasoning models with correct RubyLLM training would probably make it through Claude Code without regressing the same way.</p>
<h3>A registered subagent, even when never invoked, lifts quality<span class="hx:absolute hx:-mt-20" id="a-registered-subagent-even-when-never-invoked-lifts-quality"></span>
    <a href="#a-registered-subagent-even-when-never-invoked-lifts-quality" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The more subtle finding. The <code>..._or_sonnet</code> variant has <code>sonnet-coder</code> registered, but DeepSeek never invoked it (zero delegations, 100% of tokens on <code>deepseek/deepseek-v4-pro</code>). And yet it scored +5 over the sister variant with no subagent (89 vs 84). Auditor attribution: with a subagent available &ldquo;in case I need it&rdquo;, the DeepSeek planner produced measurably more delegable decomposition. Smaller seams, cleaner DI, system prompt used via <code>with_instructions</code>, controller test that mocks the real service API shape instead of fudging it.</p>
<blockquote>
  <p>Knowing a subagent might execute the work pushes toward smaller, more contractable units — even when nothing actually delegates. Weak signal, single sample, but visible.</p>

</blockquote>
<p>This lines up with prior findings that the structured CONVERGE phase (planner forced to articulate the interface before executing) was the actual driver of quality lifts in some forced runs, more than the delegation itself. Just having a subagent available makes the planner think more like an architect.</p>
<h3>The multi-turn controller bug persists<span class="hx:absolute hx:-mt-20" id="the-multi-turn-controller-bug-persists"></span>
    <a href="#the-multi-turn-controller-bug-persists" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A specific defect that survives the harness change: both DeepClaude variants have multi-turn problems at the controller layer.</p>
<ul>
<li>Variant 1 (<code>..._or</code>): outright single-shot. <code>chats_controller.rb:10</code> builds a 1-element messages array on every POST, throwing history away. The <code>ChatService</code> supports history, but the controller never sends it.</li>
<li>Variant 2 (<code>..._or_sonnet</code>): correct multi-turn via <code>session[:messages]</code>, but the cookie-store overflows around 10 turns with <code>CookieOverflow</code>.</li>
</ul>
<p>The solo DeepSeek run in opencode (69/100) had the same single-shot issue. Different harness, different agent loop, same model-level mistake. Multi-turn architecture for stateless Rails is genuinely hard for DeepSeek and a harness change doesn&rsquo;t help with that specific gap.</p>
<h3>DeepSeek as planner also ignores subagents on cohesive tasks<span class="hx:absolute hx:-mt-20" id="deepseek-as-planner-also-ignores-subagents-on-cohesive-tasks"></span>
    <a href="#deepseek-as-planner-also-ignores-subagents-on-cohesive-tasks" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I&rsquo;d already documented in earlier posts that every frontier LLM (Opus, GPT 5.4, Codex) ignored their registered subagents on the cohesive Rails task. That got documented as planner rationality: smart planners correctly intuit that coordination cost exceeds execution savings on a tightly coupled task.</p>
<p>Round 4 adds DeepSeek V4 Pro to the list: Sonnet 4.6 registered, zero delegations. It generalizes the finding to &ldquo;strong reasoning model facing greenfield Rails with an optional delegate&rdquo;, instead of being a quirk of Opus. DeepSeek&rsquo;s no-delegate behavior matches what every other strong planner has done.</p>
<h2>Updated answer to &ldquo;is orchestrating worth it?&rdquo;<span class="hx:absolute hx:-mt-20" id="updated-answer-to-is-orchestrating-worth-it"></span>
    <a href="#updated-answer-to-is-orchestrating-worth-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Round 3 verdict was: for one-off greenfield Rails, solo Opus in opencode wins. Round 4 adds three cases:</p>
<p>For users who don&rsquo;t have access to Anthropic Claude Opus (or want to avoid Anthropic-direct cost), <code>claude_code_deepseek_v4_pro_or_sonnet</code> is the closest substitute: 89/100 Tier A, 18m, $3.14, running entirely through OpenRouter using a key you already have. It&rsquo;s the first meaningful &ldquo;Tier A coding without an Anthropic subscription&rdquo; answer in the benchmark.</p>
<p>For users who do have Opus, DeepClaude is still slightly worse on quality (-8) for marginally better cost (-$0.66). Not worth the trade.</p>
<p>For comparing models inside the Claude Code harness directly, DeepClaude makes the comparison possible: DeepSeek V4 Pro at 84-89 vs Opus 4.7 at the regressed Tier-3 level in the same harness. DeepSeek V4 Pro through DeepClaude beats Opus 4.7 inside Claude Code on quality, cost, and multi-turn. Worth remembering this only happens because Opus regresses in this harness; against Opus in opencode (97), DeepSeek is clearly behind.</p>
<h2>The cross-harness picture for DeepSeek V4 Pro<span class="hx:absolute hx:-mt-20" id="the-cross-harness-picture-for-deepseek-v4-pro"></span>
    <a href="#the-cross-harness-picture-for-deepseek-v4-pro" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It looks like this:</p>
<table>
  <thead>
      <tr>
          <th>Harness</th>
          <th>Outcome</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>opencode solo (single agent)</td>
          <td>69/100 Tier B — works, lacks deliverable polish</td>
      </tr>
      <tr>
          <td>opencode multi-agent forced (executor)</td>
          <td>UNMEASURABLE — <code>reasoning_content</code> interop bug fails at turn 2</td>
      </tr>
      <tr>
          <td>opencode + manual cross-process orchestration (executor)</td>
          <td>UNMEASURABLE — same bug</td>
      </tr>
      <tr>
          <td>Claude Code via DeepClaude OR (solo)</td>
          <td><strong>84/100 Tier A</strong> — works, harness fills the polish gap</td>
      </tr>
      <tr>
          <td>Claude Code via DeepClaude OR (with registered subagent)</td>
          <td><strong>89/100 Tier A</strong> — modest planner-availability bonus</td>
      </tr>
      <tr>
          <td>Claude Code direct (Anthropic API)</td>
          <td>not tested — would need <code>DEEPSEEK_API_KEY</code> for the native endpoint</td>
      </tr>
  </tbody>
</table>
<p>Bottom line, DeepSeek V4 Pro is a Tier-A coder when delivered through a strong autonomous loop (Claude Code) and Tier B when delivered through a thinner harness (opencode). The RubyLLM API correctness is the same in both cases. The score difference is entirely in deliverable completeness (README, compose, CI tooling) which Claude Code&rsquo;s loop fills in.</p>
<h2>What to use this for<span class="hx:absolute hx:-mt-20" id="what-to-use-this-for"></span>
    <a href="#what-to-use-this-for" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Three scenarios where DeepClaude becomes the right tool.</p>
<p>The first is Tier-A coding on a budget, with no Anthropic subscription: the <code>..._or_sonnet</code> variant is the new default. $3.14, 18m, 89/100, Tier A, running entirely through OpenRouter.</p>
<p>The second is validating whether the Claude Code regression hits other models. It hits Opus, doesn&rsquo;t hit DeepSeek. The <code>chat.complete</code> regression is Opus-specific. DeepSeek (and presumably other reasoning models with correct RubyLLM training) cross the harness without regressing.</p>
<p>The third is future experiments. DeepClaude opens the door to running any OpenRouter model through Claude Code&rsquo;s full agent loop. Worth re-testing Kimi K2.6 and Qwen 3.6 Plus through DeepClaude to compare against the manual-orchestration Round 3 results. Same model, different harness, see where the lift lands. There&rsquo;s a real chance Kimi K2.6 (already Tier A at 87) climbs to 90+ and ties V4 Pro deepclaude. That&rsquo;s the next round of the benchmark.</p>
<h2>Caveats from DeepClaude<span class="hx:absolute hx:-mt-20" id="caveats-from-deepclaude"></span>
    <a href="#caveats-from-deepclaude" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Things the project README warns about, worth repeating here.</p>
<p>No image input: the DeepSeek Anthropic endpoint doesn&rsquo;t support vision. For this benchmark it&rsquo;s text-only, so no impact. For other use cases it can be a real limitation.</p>
<p>No MCP server tools: the compatibility layer doesn&rsquo;t support MCP. If you use MCPs in your normal Claude Code workflow, they won&rsquo;t work inside DeepClaude.</p>
<p>Anthropic prompt caching is ignored. The <code>cache_control</code> markers Claude Code emits get dropped on the floor. DeepSeek has its own automatic caching (which shows up in the <code>cache_read</code> of the token usage), but the explicit cache markers get dropped. Per-turn cost ends up slightly higher than a comparable Anthropic-direct run because of this. Relevant for apples-to-apples cost comparisons.</p>
<h2>Wrapping up<span class="hx:absolute hx:-mt-20" id="wrapping-up"></span>
    <a href="#wrapping-up" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>DeepSeek V4 Pro is out of limbo. It was Tier B in opencode solo and unmeasurable in any multi-agent scenario. Through DeepClaude (Claude Code with env vars pointed at OpenRouter) it delivers Tier A at 89/100, beats Opus 4.6 and Gemini 3.1 Pro, lands next to Kimi K2.6, costs $3.14 in 18 minutes. The lift didn&rsquo;t come from orchestration or a better model: it came purely from swapping the harness for an agent loop DeepSeek can drive well. For anyone without Opus access but with an <code>OPENROUTER_API_KEY</code>, this is the first real Tier-A coding option without an Anthropic tether.</p>
<p>The next round is already cooked: run Kimi K2.6 and Qwen 3.6 Plus through DeepClaude to see whether the &ldquo;strong harness lifts a Tier-B/A model to the top&rdquo; effect reproduces. If it does, that opens up a whole family of budget variants that can compete with solo Opus in the benchmark. If it doesn&rsquo;t, it becomes clear that DeepSeek has a specific affinity with the Claude Code loop the others don&rsquo;t share. Both outcomes are interesting.</p>
<h2>References<span class="hx:absolute hx:-mt-20" id="references"></span>
    <a href="#references" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><a href="https://github.com/aattaran/deepclaude"target="_blank" rel="noopener">DeepClaude on GitHub</a> — the shell shim itself, README with the full list of supported backends</li>
<li><a href="https://github.com/akitaonrails/llm-coding-benchmark/blob/master/docs/success_report.deepclaude.md"target="_blank" rel="noopener">Round 4 success report</a> — the full technical report this post comes from</li>
<li><a href="https://github.com/akitaonrails/llm-coding-benchmark/blob/master/docs/deepclaude-integration.md"target="_blank" rel="noopener">DeepClaude integration in the benchmark</a> — runner patch, <code>env_overrides</code> shape, smoke test</li>
<li><a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">Benchmark repo</a> — code, configs, results from every round</li>
</ul>
]]></content:encoded><category>ai</category><category>llm</category><category>benchmark</category><category>deepseek</category><category>claudecode</category><category>ruby</category></item><item><title>NW-Omarchy: Bringing Omarchy to X11 with XLibre</title><link>https://akitaonrails.github.io/en/2026/05/01/nw-omarchy-xlibre-inaugural/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/05/01/nw-omarchy-xlibre-inaugural/</guid><pubDate>Fri, 01 May 2026 18:00:00 GMT</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/01/nw-omarchy/launcher-drun.png" alt="NW-Omarchy launcher: super&amp;#43;space opens rofi with every .desktop on the system, icons rendered through the Papirus theme" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;This post inaugurates an experimental project I&amp;rsquo;ve been hacking on for the past few weeks: &lt;a href="https://github.com/akitaonrails/NW-Omarchy"target="_blank" rel="noopener"&gt;NW-Omarchy&lt;/a&gt;. The pitch is easy to explain and stubborn enough to be worth defending: take the opinionated, pretty look-and-feel of &lt;a href="https://omarchy.org/"target="_blank" rel="noopener"&gt;Omarchy&lt;/a&gt; (which is Hyprland, meaning Wayland) and bring all of that to the X11 world, as a parallel session you pick at SDDM. Your Hyprland session stays untouched. When you want to try X11, pick &lt;code&gt;nw-bspwm&lt;/code&gt; at login and land in something that looks like the same Omarchy with different parts underneath.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/01/nw-omarchy/launcher-drun.png" alt="NW-Omarchy launcher: super&#43;space opens rofi with every .desktop on the system, icons rendered through the Papirus theme"  loading="lazy" /></p>
<p>This post inaugurates an experimental project I&rsquo;ve been hacking on for the past few weeks: <a href="https://github.com/akitaonrails/NW-Omarchy"target="_blank" rel="noopener">NW-Omarchy</a>. The pitch is easy to explain and stubborn enough to be worth defending: take the opinionated, pretty look-and-feel of <a href="https://omarchy.org/"target="_blank" rel="noopener">Omarchy</a> (which is Hyprland, meaning Wayland) and bring all of that to the X11 world, as a parallel session you pick at SDDM. Your Hyprland session stays untouched. When you want to try X11, pick <code>nw-bspwm</code> at login and land in something that looks like the same Omarchy with different parts underneath.</p>
<p>The obvious question is &ldquo;why bother&rdquo;. Red Hat already announced in 2025 that upstream <code>xorg-server</code> is gone from RHEL 10, and the corporate Linux narrative shifted to &ldquo;use Wayland and move on&rdquo;. As usual, in open source we don&rsquo;t have to agree. There&rsquo;s XLibre, a fork of xorg-server picking up active maintenance, and there are plenty of people with old hardware, legacy drivers, or legacy software for which Wayland still isn&rsquo;t a viable path. NW-Omarchy is for that scenario: give that gear a graceful second wind and, while you&rsquo;re at it, make it pretty.</p>
<h2>What XLibre is and why it matters<span class="hx:absolute hx:-mt-20" id="what-xlibre-is-and-why-it-matters"></span>
    <a href="#what-xlibre-is-and-why-it-matters" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>XLibre is a fork of xorg-server that started in 2025, after it became clear upstream had no energy left to evolve. X.Org itself was only taking critical security patches, and the historical primary funder of new development (Red Hat) <a href="https://www.redhat.com/en/blog/rhel-10-plans-wayland-and-xorg-server"target="_blank" rel="noopener">announced it was stepping out</a> of new X-server work as part of the RHEL 10 plans. XLibre took the codebase and went back to cleanups, security fixes, modernization and driver-ABI bumps. The first public release was <a href="https://www.phoronix.com/news/XLibre-25.0-Released"target="_blank" rel="noopener">XLibre 25.0</a>, and the project lives at <a href="https://github.com/X11Libre/xserver"target="_blank" rel="noopener">github.com/X11Libre/xserver</a>.</p>
<p>XLibre&rsquo;s technical pitch is being a drop-in replacement for xorg-server. Same X11 protocol, same client API (libxcb/libx11), same dependency graph in pacman. Your xterm, your Firefox, your Steam, your favorite emulator for arcade machines from the 90s, none of them know the X server changed. Distros already shipping XLibre as default or tier-one:</p>
<ul>
<li><strong>Artix Linux</strong> (<a href="https://linuxiac.com/artix-linux-2026-04-released-with-xlibre-as-default-x-serve/"target="_blank" rel="noopener">2026.04+</a>) ships it as the default X server.</li>
<li><strong>Fedora</strong> has an <a href="https://fedoraproject.org/wiki/Changes"target="_blank" rel="noopener">open Change proposal</a> to migrate.</li>
<li><strong>Arch Linux</strong> has an official binary repo at <code>x11libre.net/repo/arch_based/</code>. That&rsquo;s exactly what NW-Omarchy uses.</li>
</ul>
<p>Comparing what&rsquo;s still alive:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>xorg-server</th>
          <th>XLibre</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Last non-security release</td>
          <td>21.1 (2022)</td>
          <td>25.0 (2025)</td>
      </tr>
      <tr>
          <td>Active codebase work</td>
          <td>none</td>
          <td>yes</td>
      </tr>
      <tr>
          <td>Driver ABI</td>
          <td>frozen</td>
          <td>bumped (<code>X-ABI-VIDEODRV_VERSION=28.0</code> in 25.0)</td>
      </tr>
      <tr>
          <td>Stated future</td>
          <td>&ldquo;use Wayland&rdquo;</td>
          <td>continued X server evolution</td>
      </tr>
      <tr>
          <td>Coexists with <code>xorg-xwayland</code></td>
          <td>yes</td>
          <td>yes</td>
      </tr>
  </tbody>
</table>
<p>If your reason for staying on X11 is any of the classics (legacy proprietary Nvidia driver, vintage Intel GPU, Optimus laptop with quirky muxing, dependency on <code>xdotool</code>/<code>wmctrl</code>/<code>xprop</code>/<code>xinput</code> for accessibility or remap tools, <code>ssh -X</code> to run a remote app, clipboard with no permission prompt, screen reader that needs to read input from any window), XLibre is the path that keeps that stack under active maintenance.</p>
<h2>Why this project exists<span class="hx:absolute hx:-mt-20" id="why-this-project-exists"></span>
    <a href="#why-this-project-exists" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I run Omarchy on my Thinkpad T14 Gen 6 (<a href="/en/2026/04/18/omarchy-on-thinkpad-t14-gen-6/">wrote about it here</a>) and I genuinely like it. DHH did serious curation work: a single coherent theme across everything (terminal, launcher, top bar, lockscreen), consistent shortcuts, visual choices that don&rsquo;t tire your eyes. It&rsquo;s opinionated the right way, and it spares you the fatigue of configuring everything from scratch.</p>
<p>The catch is that Omarchy is Hyprland, Hyprland is Wayland-only, and Wayland still has scenarios where it&rsquo;s the wrong tool for the job. Older machines with legacy Nvidia drivers, environments leaning on software with deep X11 integration, situations where you want <code>ssh -X</code> and don&rsquo;t want to learn <code>waypipe</code>, or just machines you want to keep running without swapping half the stack. For those cases, I wanted Omarchy&rsquo;s aesthetic and workflow on top of X11. It didn&rsquo;t exist. So I built it.</p>
<p>The name NW-Omarchy is short for &ldquo;Not Wayland Omarchy&rdquo;. It&rsquo;s deliberately a sidecar: runs alongside, doesn&rsquo;t replace. You install it on top of an existing Omarchy install, it creates a separate SDDM session called <code>nw-bspwm</code>, and nothing in your Hyprland setup gets touched. Want to go back to Hyprland? Pick Hyprland at SDDM next login. Want to uninstall? There&rsquo;s an uninstall script that reverses exactly what got installed, based on a manifest that tracks every action.</p>
<h2>What changes in the stack (Wayland → X11)<span class="hx:absolute hx:-mt-20" id="what-changes-in-the-stack-wayland--x11"></span>
    <a href="#what-changes-in-the-stack-wayland--x11" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Hyprland is compositor + WM in the same binary. In the X11 world, those two functions come as separate pieces, and each has a well-known substitute. The translation work was mapping every Omarchy component to its X11 equivalent, then making sure the keyboard shortcuts, theming, and behavior stayed as close as possible.</p>
<table>
  <thead>
      <tr>
          <th>Function</th>
          <th>Omarchy/Wayland</th>
          <th>NW-Omarchy/X11</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Window manager</td>
          <td>Hyprland</td>
          <td><a href="https://github.com/baskerville/bspwm"target="_blank" rel="noopener">bspwm</a></td>
      </tr>
      <tr>
          <td>Hotkey daemon</td>
          <td>Hyprland built-in</td>
          <td><a href="https://github.com/baskerville/sxhkd"target="_blank" rel="noopener">sxhkd</a></td>
      </tr>
      <tr>
          <td>Compositor</td>
          <td>Hyprland built-in</td>
          <td><a href="https://github.com/yshui/picom"target="_blank" rel="noopener">picom</a> (upstream v13)</td>
      </tr>
      <tr>
          <td>Top bar</td>
          <td>Waybar</td>
          <td><a href="https://github.com/polybar/polybar"target="_blank" rel="noopener">polybar</a></td>
      </tr>
      <tr>
          <td>App launcher</td>
          <td>Walker</td>
          <td><a href="https://github.com/davatorium/rofi"target="_blank" rel="noopener">rofi</a></td>
      </tr>
      <tr>
          <td>Notifications</td>
          <td>Mako</td>
          <td><a href="https://github.com/dunst-project/dunst"target="_blank" rel="noopener">dunst</a></td>
      </tr>
      <tr>
          <td>Idle/lock</td>
          <td>hypridle / hyprlock</td>
          <td>xidlehook + xss-lock + i3lock-color</td>
      </tr>
      <tr>
          <td>Nightlight</td>
          <td>hyprsunset</td>
          <td>redshift</td>
      </tr>
      <tr>
          <td>Clipboard history</td>
          <td>walker <code>-m clipboard</code></td>
          <td><a href="https://github.com/cdown/clipmenu"target="_blank" rel="noopener">clipmenu</a> (rofi-backed)</td>
      </tr>
      <tr>
          <td>Screenshots</td>
          <td>hyprshot</td>
          <td>maim + slop + xclip</td>
      </tr>
      <tr>
          <td>Color picker</td>
          <td>hyprpicker</td>
          <td>xcolor</td>
      </tr>
  </tbody>
</table>
<p><strong>bspwm</strong> is a minimalist tiling window manager. The WM itself only responds to commands over a socket, and <code>bspwmrc</code> is literally a shell script. Keybindings live in a separate daemon, <strong>sxhkd</strong>, which reads a plain text file. That separation is classic X11: each piece does one thing.</p>
<p><strong>picom</strong> is the compositor that gives you blur, shadows, rounded corners, fade-in/out, and per-window opacity rules. NW-Omarchy uses upstream v13 (released February 2026), not the old <code>picom-ftlabs-git</code> fork, <a href="https://github.com/yshui/picom/issues"target="_blank" rel="noopener">abandoned since 2024</a>. The visual effects Omarchy has under Hyprland (rounded corners, fade on open/close, blur on rofi and dunst) all come from picom in NW-Omarchy.</p>
<p><strong>polybar</strong> replaces waybar. Same visual layout: Omarchy logo on the left, date in the middle, audio/wifi/bluetooth/battery indicators on the right. Same nerd-font icons. Left click opens the same TUIs (wiremix, impala, bluetui), right click opens the GUI variants. Scroll on audio adjusts volume, middle click mutes. Practical parity.</p>
<p><strong>rofi</strong> replaces walker. <code>super + space</code> opens the full launcher with every <code>.desktop</code> on the system plus icons. <code>super + shift + space</code> opens a cheat-sheet of the pinned apps, with the chord shown next to each name. Useful for remembering &ldquo;what was the chord for Signal again&rdquo;.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/01/nw-omarchy/launcher-cheatsheet.png" alt="Pinned-apps cheat-sheet: super&#43;shift&#43;space opens rofi with each app and its chord, parsed straight from sxhkdrc"  loading="lazy" /></p>
<p>For a user who isn&rsquo;t going to crack open <code>~/.config</code> to customize anything, the difference between the two sessions is cosmetic at homeopathic levels. The same Omarchy shortcuts (over 70 bindings) are wired up, the same menus pop up, the same logo sits in the bar, the same nerd-font icons.</p>
<h2>The theme system is the same<span class="hx:absolute hx:-mt-20" id="the-theme-system-is-the-same"></span>
    <a href="#the-theme-system-is-the-same" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This was the most fun part to build, and the part that surprises people most. Omarchy has a centralized theme system: each theme lives at <code>~/.local/share/omarchy/themes/&lt;name&gt;/</code>, ships a <code>colors.toml</code> with a universal palette (<code>accent</code>, <code>background</code>, <code>foreground</code>, <code>color0..15</code>), and <code>omarchy-theme-set &lt;name&gt;</code> re-renders each app&rsquo;s config to match. The active theme lives at <code>~/.config/omarchy/current/theme/</code>, and every app imports its colors from there.</p>
<p>NW-Omarchy plugs into that same pipeline. I added <code>.tpl</code> templates for bspwm, polybar, rofi and dunst that follow the Omarchy convention, and <code>omarchy-theme-set-templates</code> regenerates everything on every theme change. The result is that all 19 themes Omarchy ships work as-is in NW-Omarchy, with zero porting work. Catppuccin, gruvbox, kanagawa, nord, tokyo-night, all of them. The wallpaper changes along with the theme via an inotify daemon (<code>nw-omarchy-bg-watch</code>).</p>
<p>A few examples in action:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/01/nw-omarchy/theme-ristretto.png" alt="Ristretto theme: warm color curves on the wallpaper, polybar and bspwm border picking up the same palette"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/01/nw-omarchy/theme-lumon.png" alt="Lumon theme: Severance reference, cold corporate palette, “United in Severance”"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/01/nw-omarchy/theme-everforest.png" alt="Everforest theme: moss-green tones, wallpaper of misty tree-tops"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/01/nw-omarchy/theme-retro-82.png" alt="Retro-82 theme: synthwave, pink grid on dark background, neon details"  loading="lazy" /></p>
<p>When Omarchy adds a new theme via <code>omarchy-theme-update</code>, NW-Omarchy gets it for free. No extra work, no rebuild, nothing for me to republish.</p>
<h2>System menu and TUIs<span class="hx:absolute hx:-mt-20" id="system-menu-and-tuis"></span>
    <a href="#system-menu-and-tuis" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Omarchy&rsquo;s system menu (the <code>super + alt + space</code> one that opens the hierarchical menu with Hardware, Setup, Update, etc.) has a 1-to-1 port in NW-Omarchy. Same tree, same navigation. Where Omarchy calls <code>walker --dmenu</code>, I call <code>rofi -dmenu</code>. The rest of the code is identical, and most of the helpers the menu invokes (<code>omarchy-pkg-add</code>, <code>omarchy-theme-set</code>, <code>omarchy-update</code>, etc.) have nothing Wayland-specific in them and run straight on X11.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/01/nw-omarchy/system-menu.png" alt="nw-omarchy-menu open: the Omarchy system menu tree, arrow-key navigation, same look"  loading="lazy" /></p>
<p>The TUIs too: wiremix for audio, impala for wifi, bluetui for bluetooth, btop for system resources. They all open as a centered floating window, same as Omarchy under Hyprland. bspwm has a rule (<code>bspc rule -a</code>) that recognizes the <code>org.nw-omarchy.&lt;cmd&gt;</code> class and applies <code>state=floating center=on rectangle=900x600</code> automatically.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/01/nw-omarchy/wifi-tui.png" alt="Impala TUI running as a centered floating window via super&#43;ctrl&#43;w, palette following the active theme"  loading="lazy" /></p>
<h2>What&rsquo;s there and what isn&rsquo;t<span class="hx:absolute hx:-mt-20" id="whats-there-and-what-isnt"></span>
    <a href="#whats-there-and-what-isnt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Honesty first: there are Hyprland features that are Wayland-only and don&rsquo;t come back. Per-monitor fractional scaling is gone, X11 only does integer scaling per output, so mixed-DPI setups look off. HDR doesn&rsquo;t exist on X11. Tear-free rendering by design needs Wayland; on X11 you need picom + vsync and a cooperative GPU, and even then you get close to Wayland without quite matching it. Workspace swipe with live preview during the gesture is also gone, because bspwm has no in-progress switch state, so the gesture fires a discrete command only on completion (you go to the next workspace, but you don&rsquo;t see the preview slide). And the Wayland-specific daemons (voxtype, walker preview pane, hyprsunset, hypridle) become <code>redshift</code> and <code>xss-lock</code>, with no preview pane in rofi.</p>
<p>If any of those points is a deal-breaker for you, stay on Omarchy/Hyprland. SDDM picks between the two sessions every login, so they coexist without resentment.</p>
<p>On the other hand, what X11 has going for it still applies. <code>xdotool</code>, <code>wmctrl</code>, <code>xprop</code> and <code>xinput</code> keep working for automation, key remapping, screen readers, AutoKey, kanata, xkeysnail. <code>xclip</code>, <code>xsel</code> and clipmenu do straightforward clipboard plumbing, no permission dialog. <code>ssh -X</code> keeps working: you run Firefox on a remote box and the window pops up locally. Legacy Nvidia, vintage Intel and quirky Optimus drivers are all first-class on X11. The tiling WM ecosystem is mature (bspwm, i3, awesome, openbox, xmonad, dwm) and configs port between them with little pain. And the predictable input model, where any client can read any event, is bad for security but excellent for accessibility and productivity tooling.</p>
<p>For older hardware, that&rsquo;s the difference between &ldquo;live machine&rdquo; and &ldquo;machine sitting in a corner&rdquo;.</p>
<h2>Install<span class="hx:absolute hx:-mt-20" id="install"></span>
    <a href="#install" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Prerequisite: Arch Linux with Omarchy already installed. NW-Omarchy runs on top of it, doesn&rsquo;t replace it.</p>
<p>One-liner install:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -fsSL https://raw.githubusercontent.com/akitaonrails/NW-Omarchy/master/boot.sh <span class="p">|</span> bash</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>boot.sh</code> runs preflight (checks Arch, Omarchy, git), clones the repo to <code>~/.local/share/nw-omarchy</code>, and runs the full install pipeline: packages, the <code>nw-bspwm</code> session, configs in <code>~/.config</code>, and the XLibre swap as the last step. It asks for confirmation once before touching anything. Skip the prompt with:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -fsSL https://raw.githubusercontent.com/akitaonrails/NW-Omarchy/master/boot.sh <span class="p">|</span> bash -s -- --yes</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When it finishes, reboot. The X server swap (xorg-server → XLibre) only takes effect on the next session. At SDDM you&rsquo;ll see a session picker. We also swapped Omarchy&rsquo;s SDDM theme for one with a selector, because the vanilla Omarchy theme has the session name hard-coded in the QML with no UI to change it. Pick <code>nw-bspwm</code> and you&rsquo;re in.</p>
<p>If you&rsquo;d rather clone and run by hand to inspect first:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/NW-Omarchy.git ~/.local/share/nw-omarchy
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ~/.local/share/nw-omarchy
</span></span><span class="line"><span class="cl">./install.sh           <span class="c1"># dry-run, prints what it would do</span>
</span></span><span class="line"><span class="cl">./install.sh --apply   <span class="c1"># actually run it</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>Upgrade<span class="hx:absolute hx:-mt-20" id="upgrade"></span>
    <a href="#upgrade" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When a new release ships, the path is:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">nw-omarchy-upgrade --check    <span class="c1"># checks current vs latest, exits</span>
</span></span><span class="line"><span class="cl">nw-omarchy-upgrade            <span class="c1"># yay -Syu, fetch latest tag, migrations, install</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The script runs <code>yay -Syu</code> (or <code>pacman -Syu</code> if you don&rsquo;t have yay), compares the local tag to the remote, and if there&rsquo;s a new release does <code>git fetch &amp;&amp; git checkout v&lt;latest&gt;</code>, runs the migrations between the current and new versions, and re-applies <code>install.sh --apply</code>. All idempotent. Re-running on an already-current machine is a no-op.</p>
<h2>Uninstall<span class="hx:absolute hx:-mt-20" id="uninstall"></span>
    <a href="#uninstall" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you tried it and didn&rsquo;t like it, uninstall this way:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">~/.local/share/nw-omarchy/uninstall.sh --apply</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The uninstaller reads <code>~/.local/state/nw-omarchy/manifest.tsv</code> and replays it in reverse: removes only the packages it installed (<code>pkg-skip</code> rows are left alone; packages that were already on the system stay on the system), restores original configs from <code>~/.local/state/nw-omarchy/backups/</code>, deletes the <code>nw-bspwm</code> session entry, wipes the state dir. Your Hyprland session keeps working as before. Nothing under <code>~/.config/hypr/</code> or <code>~/.local/share/omarchy/</code> gets touched at any point during install or uninstall.</p>
<p>To revert specifically XLibre back to xorg-server (without uninstalling anything else), pacman handles it on its own:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pacman -S xorg-server xorg-server-common xf86-input-libinput</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The package <code>provides</code>/<code>conflicts</code> graph handles the rest of the swap atomically.</p>
<h2>Status: experimental, feedback welcome<span class="hx:absolute hx:-mt-20" id="status-experimental-feedback-welcome"></span>
    <a href="#status-experimental-feedback-welcome" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The current version is at 1.0 but the project is still young, in polish phase. The big items are in place: binding parity, menu parity, theme parity, TUI parity, doctor for health-checking the install, manifest for clean uninstall. What&rsquo;s left is polish, edge cases, working through varied hardware, listening to feedback from anyone running it on a different machine than mine (Thinkpad T14 Gen 6).</p>
<p>If you try it and hit something (something that doesn&rsquo;t work, configuration that&rsquo;s missing, behavior that differs from Omarchy in a way that breaks flow), open an issue here:</p>
<p><a href="https://github.com/akitaonrails/NW-Omarchy/issues"target="_blank" rel="noopener">github.com/akitaonrails/NW-Omarchy/issues</a></p>
<p>Repros, stack traces, output of <code>nw-omarchy-doctor</code>, anything helps. PRs are welcome too.</p>
<p>Defending X11 in 2026 is a pragmatic argument. The transition to Wayland takes time, plenty of hardware and plenty of software still live comfortably on this side of the fence, and the open source community can support more than one path at a time. XLibre is the bet on keeping the X server alive a few more years. NW-Omarchy is my bet that you can make this older stack look as pretty as the newer one, without compromising any of what makes X11 still worth the trip.</p>
<p>If you&rsquo;re on a new machine, modern GPU, 4K HiDPI display, wanting HDR, stay on Omarchy/Hyprland. If you&rsquo;re on an older box, or you simply prefer the X11 ecosystem for any of the reasons above, give NW-Omarchy a shot.</p>
]]></content:encoded><category>omarchy</category><category>xlibre</category><category>archlinux</category><category>bspwm</category><category>linux</category><category>x11</category></item><item><title>LLM Benchmarks: Is It Worth ($$) Mixing 2 Models? (Planner + Executor)</title><link>https://akitaonrails.github.io/en/2026/04/25/llm-benchmarks-vale-a-pena-misturar-2-modelos/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/25/llm-benchmarks-vale-a-pena-misturar-2-modelos/</guid><pubDate>Sat, 25 Apr 2026 13:00:00 GMT</pubDate><description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; No. Across all three rounds of experiments I ran, mixing &amp;ldquo;strong frontier planner + cheap executor&amp;rdquo; loses to just using Opus 4.7 alone in a mature harness. &lt;strong&gt;Solo Opus 4.7 in opencode delivers Tier A (97/100) in 18 minutes for ~$4 pay-as-you-go.&lt;/strong&gt; No multi-agent combination beats that on quality, and no combination is cheaper at the same time. The exception is Codex GPT 5.4 xHigh + &lt;code&gt;medium&lt;/code&gt; executor, which drops from ~$16/run to ~$1-3/run while losing 3 quality points. Useful if you only have GPT in your provider stack. For everything else, &lt;strong&gt;let the frontier model decide when to delegate on its own&lt;/strong&gt;, especially if you&amp;rsquo;re on a Plus/Pro/Max subscription.&lt;/p&gt;</description><content:encoded><![CDATA[<p><strong>TL;DR:</strong> No. Across all three rounds of experiments I ran, mixing &ldquo;strong frontier planner + cheap executor&rdquo; loses to just using Opus 4.7 alone in a mature harness. <strong>Solo Opus 4.7 in opencode delivers Tier A (97/100) in 18 minutes for ~$4 pay-as-you-go.</strong> No multi-agent combination beats that on quality, and no combination is cheaper at the same time. The exception is Codex GPT 5.4 xHigh + <code>medium</code> executor, which drops from ~$16/run to ~$1-3/run while losing 3 quality points. Useful if you only have GPT in your provider stack. For everything else, <strong>let the frontier model decide when to delegate on its own</strong>, especially if you&rsquo;re on a Plus/Pro/Max subscription.</p>
<hr>
<h2>Before we start: the five wrong premises<span class="hx:absolute hx:-mt-20" id="before-we-start-the-five-wrong-premises"></span>
    <a href="#before-we-start-the-five-wrong-premises" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a narrative that shows up in my feed every week: &ldquo;I&rsquo;m saving money mixing Opus for planning with Kimi/Qwen/GLM/DeepSeek for execution.&rdquo; The premise is that the frontier model is too expensive to use for continuous coding, so you split the task: the expensive one thinks, the cheap one writes. Sounds reasonable. In practice, it&rsquo;s wrong on five fronts.</p>
<p><strong>First: most of the people saying this don&rsquo;t show their results.</strong> Plenty of folks brag about orchestrating dozens of agents in parallel, fancy dashboards, elaborate flows. Ask them to show the app that came out of it, in production, generating value. Almost nobody delivers. <strong>Ask for the result.</strong> If they can&rsquo;t show real production, they&rsquo;re snake-oil sellers. My benchmark, with everything open, is the opposite of that: <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">the repo is public</a>, the logs are auditable, the generated code is right there for inspection. That&rsquo;s the minimum bar for honest discussion about LLMs in coding.</p>
<p><strong>Second: the use case changes everything.</strong> If you&rsquo;re doing a legitimate one-shot (Opus plans the architecture once, a cheap model executes many parallel pieces with well-defined scope, like generating 50 UI components following the same pattern), then it can make sense. But most people complaining about token cost are doing continuous coding-agent work, not massive one-shots. For continuous coding the math is completely different.</p>
<p><strong>Third: pay-as-you-go vs subscription completely changes the math.</strong> If you pay per token directly on the API, every extra orchestrator prompt counts. If you pay $20 or $200 a month for Plus/Pro/Max and use Claude Code, Codex CLI or similar inside the subscription quota, the &ldquo;cost per run&rdquo; question disappears. It becomes &ldquo;I&rsquo;m consuming part of my monthly quota.&rdquo; Under subscription, the marginal cost of an extra call is zero until you saturate the cap. Coordinating two models to save tokens you&rsquo;re not being charged for is optimization against a non-existent cost.</p>
<p><strong>Fourth, and most important: you only realize the maturity of solo frontier models when you do full vibe coding.</strong> I&rsquo;ve written about this in several posts (<a href="/en/2026/02/16/vibe-code-do-zero-a-producao-em-6-dias-the-m-akita-chronicles/">six-day immersion from zero to production</a>, <a href="/en/2026/04/15/como-falar-com-o-claude-code-efetivamente/">how to talk to Claude Code effectively</a>, <a href="/en/2026/04/20/clean-code-para-agentes-de-ia/">Clean Code for AI agents</a>). Serious vibe coding is: <strong>turn off the IDE, don&rsquo;t edit code by hand, role-play as product manager + QA + tech lead mentor, let the model write</strong>. When you do that, it becomes immediately obvious that mixing two models creates absurd coordination overhead. It&rsquo;s like outsourcing development to an offshore team but hand-editing every delivery because you nitpick everything. A plan that needs to spell out every line of code becomes micromanagement: if the plan already contains the code, why are you delegating? That&rsquo;s the extreme, but it&rsquo;s where most multi-agent setups land. Balance is what matters, and removing overhead is the main technique. For that, <strong>a good frontier model inside a mature harness (Claude Code, Codex, opencode) is the path</strong>.</p>
<p><strong>Fifth, and the most technical: premature optimization.</strong> Donald Knuth warned us decades ago, &ldquo;premature optimization is the root of all evil.&rdquo; The original rule: 97% of the time you don&rsquo;t have data to justify optimizing; when you do, it&rsquo;s only in the critical 3%. The 2026 version of that is spending a weekend wiring up an orchestration of five parallel agents to save $30/month in tokens, before you&rsquo;ve validated whether the product you&rsquo;re building is even worth shipping. You&rsquo;re optimizing the cost of a pipeline that hasn&rsquo;t delivered anything yet. Focus on shipping. $20-200/month subscriptions are trivial compared to any other professional learning cost. People spend more on bad online courses or weekend partying. Once the product ships and token cost becomes a measurable problem, then yes, optimize. Today, focus on shipping.</p>
<p>OK, sermon over. Let&rsquo;s go to the numbers.</p>
<hr>
<h2>Methodology in one line<span class="hx:absolute hx:-mt-20" id="methodology-in-one-line"></span>
    <a href="#methodology-in-one-line" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Same benchmark as the <a href="/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/">canonical version</a>: build a Rails 8 app with RubyLLM, Tailwind, Hotwire, Minitest tests, Brakeman, Docker, docker-compose. Same 8-dimension rubric with 0-100 scoring, same A/B/C/D tiers. The difference is that here every variant has <strong>two models</strong>: a &ldquo;planner&rdquo; (strong) and an &ldquo;executor&rdquo; (cheaper), in different harnesses (Claude Code, opencode, Codex). Everything in the <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">repo</a> with docs, dispatches, traces, audits.</p>
<p>Solo Opus 4.7 in opencode is the comparison baseline: <strong>97/100 Tier A, 18 minutes, ~$4 pay-as-you-go.</strong> Every multi-agent variant has to beat this (in quality, time OR cost) to justify the added complexity.</p>
<hr>
<h2>What this benchmark does NOT prove (and what good delegation actually means)<span class="hx:absolute hx:-mt-20" id="what-this-benchmark-does-not-prove-and-what-good-delegation-actually-means"></span>
    <a href="#what-this-benchmark-does-not-prove-and-what-good-delegation-actually-means" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before diving into the three rounds, it&rsquo;s worth circling what this benchmark covers and what it doesn&rsquo;t. This matters because I&rsquo;m going to draw strong conclusions about delegation, and those conclusions don&rsquo;t generalize to every kind of work.</p>
<h3>Limit 1: the project is a simple greenfield Rails build<span class="hx:absolute hx:-mt-20" id="limit-1-the-project-is-a-simple-greenfield-rails-build"></span>
    <a href="#limit-1-the-project-is-a-simple-greenfield-rails-build" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The benchmark builds a Rails 8 chat app with RubyLLM. It&rsquo;s real code, with Tailwind, Hotwire, Docker and tests, but it&rsquo;s <strong>a greenfield project with well-defined scope on a popular stack</strong>. Practically every Tier A model nails it. In complexity terms, it&rsquo;s early-junior territory: there&rsquo;s no legacy code to understand, no technical debt to work around, no distributed system with 50 microservices to orchestrate.</p>
<p>For anyone who wants conclusive data about <strong>their own</strong> use case, the honest answer is: <strong>adapt the benchmark harness to mimic your project&rsquo;s conditions and compare models there</strong>. The <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">repo</a> is open source for exactly this reason. Take the prompt, swap in something that reflects your reality (50K-line legacy codebase, integration with 3 external systems, custom company DSL, whatever it is), and run the models the same way. The numbers I report here give direction, but any serious engineer should validate models against the work they actually do, not against an example chat app.</p>
<h3>Limit 2: parallelizable tasks vs dependency-laden tasks<span class="hx:absolute hx:-mt-20" id="limit-2-parallelizable-tasks-vs-dependency-laden-tasks"></span>
    <a href="#limit-2-parallelizable-tasks-vs-dependency-laden-tasks" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The question &ldquo;is mixing two models worth it?&rdquo; depends critically on <strong>what kind of work you&rsquo;re delegating</strong>. There are two extremes.</p>
<p><strong>Work where frontier models delegate happily, and that will work beautifully:</strong> numerous, simple tasks with little or no coordination needed. &ldquo;Translate these 100 documents to English.&rdquo; &ldquo;Summarize these 50 spreadsheets in bullet points.&rdquo; &ldquo;Convert these 200 images to WebP at max 800px.&rdquo; Each item is independent. No revision is needed between them. The result of one doesn&rsquo;t change the input to the next. It&rsquo;s exactly the kind of work Amazon&rsquo;s Mechanical Turk was designed to distribute. Frontier models already do this on their own without explicit orchestration: ask Opus 4.7 to translate 100 documents and it&rsquo;ll fan out to parallel batches via the <code>Task</code> tool, scaling up or down as needed.</p>
<p><strong>Work where frontier models won&rsquo;t delegate, because it can&rsquo;t be delegated well:</strong> tasks with many dependencies, requiring constant revision and mutual adjustment. Building a Rails app is exactly that. The <code>Chat</code> model depends on the <code>LlmClient</code> decision, which depends on the RubyLLM config, which depends on the <code>OPENROUTER_API_KEY</code> in env, which depends on <code>ENV</code> being loaded before the initializer, which depends on the Gemfile having the right gem. Touching one piece forces revising several others. This isn&rsquo;t Mechanical Turk work. It&rsquo;s cohesive engineering work, and Tier A models recognize that and keep everything inside one reasoning session. <strong>That&rsquo;s why zero of the 7 runs in Round 1 delegated: the task wasn&rsquo;t delegable.</strong></p>
<h3>The async programming analogy<span class="hx:absolute hx:-mt-20" id="the-async-programming-analogy"></span>
    <a href="#the-async-programming-analogy" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Anyone who learned async/await in a modern language went through the same disillusionment. You discover <code>Promise.all</code>, <code>asyncio.gather</code>, or similar, and start eyeing every slow piece of work looking for ways to parallelize. Then you write something like this and get angry:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// Sequential chain: each step depends on the previous result
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">userData</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetchUser</span><span class="p">(</span><span class="nx">userId</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">orders</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetchOrdersFor</span><span class="p">(</span><span class="nx">userData</span><span class="p">);</span>          <span class="c1">// ← needs userData
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">recommendations</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">analyzeOrders</span><span class="p">(</span><span class="nx">orders</span><span class="p">);</span>    <span class="c1">// ← needs orders
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Total latency = latency(fetchUser) + latency(fetchOrders) + latency(analyze)
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Three <code>await</code>s. Three I/O operations. You expected 3× faster from going async. But it&rsquo;s the same as synchronous because <strong>each call depends on the previous one&rsquo;s result</strong>. Trying to wrap this in <code>Promise.all</code> doesn&rsquo;t even compile: you can&rsquo;t pass <code>userData</code> to <code>fetchOrdersFor</code> before you have <code>userData</code>.</p>
<p>Promise.all only helps when the calls are <strong>independent of one another</strong>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// Independent calls: none uses another&#39;s result
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="p">[</span><span class="nx">user</span><span class="p">,</span> <span class="nx">allProducts</span><span class="p">,</span> <span class="nx">categoryTree</span><span class="p">]</span> <span class="o">=</span> <span class="kr">await</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">all</span><span class="p">([</span>
</span></span><span class="line"><span class="cl">  <span class="nx">fetchUser</span><span class="p">(</span><span class="nx">userId</span><span class="p">),</span>       <span class="c1">// ← doesn&#39;t need products or categories
</span></span></span><span class="line"><span class="cl">  <span class="nx">fetchAllProducts</span><span class="p">(),</span>      <span class="c1">// ← doesn&#39;t need user or categories
</span></span></span><span class="line"><span class="cl">  <span class="nx">fetchCategoryTree</span><span class="p">()</span>      <span class="c1">// ← doesn&#39;t need user or products
</span></span></span><span class="line"><span class="cl"><span class="p">]);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Total latency = max(latency of the three), not the sum
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>The difference is not syntax; it&rsquo;s dependency structure.</strong> In the first case, A → B → C: three operations in series, each waiting on the previous. In the second case, A | B | C: three independent operations that fire at the same time and the total latency is that of the slowest one. Async/Promise.all doesn&rsquo;t create parallelism, it only <strong>exposes</strong> the parallelism that was already there in the structure of the problem.</p>
<p>Multi-agent in coding is the same. If you give two models a cohesive task with internal dependencies (build a Rails chat app), the &ldquo;planner&rdquo; has to read every output from the &ldquo;executor&rdquo; before deciding the next dispatch, and the executor needs the context the planner built. You sequentialized the two. <strong>Worse</strong>: you added coordination overhead (prompt format, response parsing, watchdog, retry logic) on top of a pipeline that was supposed to have low overhead. It&rsquo;s like taking the three-sequential-<code>await</code> code and dropping a Redis queue in the middle: now you have 3× the latency <strong>plus</strong> the queue cost.</p>
<p>Parallelizable task = &ldquo;apply this same refactor to 50 different files.&rdquo; There orchestration makes sense: one model plans the refactor once, describes it in reusable form, and dispatches 50 sub-agents in parallel to execute on each file. Real time savings. <strong>Sequential task with dependencies</strong> = building a cohesive app. There&rsquo;s no parallelization possible, and adding agents only adds overhead.</p>
<p>This benchmark tests the <strong>second scenario</strong>. If your daily work is the first (large batches of independent tasks), the conclusions here may invert. Adapt the harness, measure, decide.</p>
<hr>
<h2>Segment 1: the initial round, models didn&rsquo;t delegate<span class="hx:absolute hx:-mt-20" id="segment-1-the-initial-round-models-didnt-delegate"></span>
    <a href="#segment-1-the-initial-round-models-didnt-delegate" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first round, in mid-April, configured 7 variants where the planner had a registered subagent available and aggressive prompt language pushing delegation (&ldquo;Use PROACTIVELY&rdquo;, &ldquo;ALWAYS delegate to this agent for code implementation&rdquo;). The question: given that the subagent exists and is painted as the preferred path, will the main model delegate?</p>
<table>
  <thead>
      <tr>
          <th>Variant</th>
          <th>Harness</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Cost</th>
          <th style="text-align: right">Delegations</th>
          <th style="text-align: right">Tier</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>claude_opus_alone</code></td>
          <td>Claude Code</td>
          <td style="text-align: right">11m</td>
          <td style="text-align: right">$6.74</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">3</td>
      </tr>
      <tr>
          <td><code>claude_opus_sonnet</code></td>
          <td>Claude Code</td>
          <td style="text-align: right">10m</td>
          <td style="text-align: right">$5.13</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">2</td>
      </tr>
      <tr>
          <td><code>claude_opus_haiku</code></td>
          <td>Claude Code</td>
          <td style="text-align: right">15m</td>
          <td style="text-align: right">$7.83</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">3</td>
      </tr>
      <tr>
          <td><code>opencode_opus_glm</code></td>
          <td>opencode</td>
          <td style="text-align: right">19m</td>
          <td style="text-align: right">~$1.10</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">1</td>
      </tr>
      <tr>
          <td><code>opencode_opus_qwen</code></td>
          <td>opencode</td>
          <td style="text-align: right">30m</td>
          <td style="text-align: right">~$1.10</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">1</td>
      </tr>
      <tr>
          <td><code>gpt_5_4_multi_balanced</code></td>
          <td>Codex</td>
          <td style="text-align: right">21m</td>
          <td style="text-align: right">~$11</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">1</td>
      </tr>
      <tr>
          <td><code>gpt_5_4_multi_faster</code></td>
          <td>Codex</td>
          <td style="text-align: right">20m</td>
          <td style="text-align: right">~$10</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">2</td>
      </tr>
      <tr>
          <td><strong>baseline</strong> <code>claude_opus_4_7</code></td>
          <td>opencode</td>
          <td style="text-align: right">18m</td>
          <td style="text-align: right">~$1.10</td>
          <td style="text-align: right">n/a</td>
          <td style="text-align: right"><strong>1</strong></td>
      </tr>
  </tbody>
</table>
<p><strong>Zero delegations in zero of the 7 variants.</strong> Claude Code&rsquo;s <code>Task</code> tool, opencode&rsquo;s task dispatcher, and Codex&rsquo;s <code>spawn_agent</code> were all completely ignored. In every case, the main model did 100% of the work.</p>
<p>Two things worth logging from this round.</p>
<p><strong>First:</strong> aggressive prompt language doesn&rsquo;t persuade a model to delegate. &ldquo;Use PROACTIVELY&rdquo; is a weak word against the model&rsquo;s internal &ldquo;this task is cohesive, I&rsquo;ll do it myself&rdquo; instinct. In greenfield Rails, plan and implementation don&rsquo;t have a clean line. There&rsquo;s no atomically-delegable subtask.</p>
<p><strong>Second, and most surprising:</strong> the same Opus 4.7 wrote <strong>worse code in Claude Code than in opencode</strong>, and cost <strong>4 to 7× more</strong> ($6.74 vs $1.10). Two of three Claude Code variants hallucinated a RubyLLM method that doesn&rsquo;t exist (Tier 3 hallucination); opencode with the same model got the API right and landed Tier 1. The harness itself influences the output. Claude Code resends much more context per turn, and that seems to nudge the model toward generic patterns while also inflating the bill.</p>
<p>Round 1 conclusion: <strong>if the model is already delivering Tier A solo in the right harness (opencode), adding a subagent isn&rsquo;t even used.</strong> The extra configuration is pure overhead. And if you force it inside Claude Code, you pay 4-7× more. The &ldquo;savings&rdquo; of multi-agency here is negative.</p>
<hr>
<h2>Segment 2: the forced round, when the planner is forbidden from coding<span class="hx:absolute hx:-mt-20" id="segment-2-the-forced-round-when-the-planner-is-forbidden-from-coding"></span>
    <a href="#segment-2-the-forced-round-when-the-planner-is-forbidden-from-coding" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The community pushed back on Round 1: &ldquo;the subagent description was weak&rdquo;, &ldquo;models don&rsquo;t trust unfamiliar subagents.&rdquo; Fair pushback. Round 2: explicit planner-executor prompt. <strong>The planner is literally forbidden</strong> from using <code>Write</code>/<code>Edit</code>/<code>Bash</code>. Every code change has to flow through the subagent via <code>Task</code> or <code>spawn_agent</code>. No fallback.</p>
<p>The full prompt is at <a href="https://github.com/akitaonrails/llm-coding-benchmark/blob/master/prompts/benchmark_prompt_forced_delegation.txt"target="_blank" rel="noopener"><code>prompts/benchmark_prompt_forced_delegation.txt</code></a>. Mandatory workflow: <code>plan → delegate → converge → validate</code>. Seven variants:</p>
<table>
  <thead>
      <tr>
          <th>Slug</th>
          <th>Planner</th>
          <th>Subagent</th>
          <th>Harness</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>claude_opus_sonnet_forced</code></td>
          <td>Opus 4.7</td>
          <td>Sonnet 4.6</td>
          <td>Claude Code</td>
      </tr>
      <tr>
          <td><code>claude_opus_haiku_forced</code></td>
          <td>Opus 4.7</td>
          <td>Haiku 4.5</td>
          <td>Claude Code</td>
      </tr>
      <tr>
          <td><code>opencode_opus_kimi_forced</code></td>
          <td>Opus 4.7</td>
          <td>Kimi K2.6</td>
          <td>opencode</td>
      </tr>
      <tr>
          <td><code>opencode_opus_glm_forced</code></td>
          <td>Opus 4.7</td>
          <td>GLM 5.1 (Z.ai)</td>
          <td>opencode</td>
      </tr>
      <tr>
          <td><code>opencode_opus_qwen_forced</code></td>
          <td>Opus 4.7</td>
          <td>Qwen 3.6 Plus</td>
          <td>opencode</td>
      </tr>
      <tr>
          <td><code>gpt_5_4_multi_balanced_forced</code></td>
          <td>GPT 5.4 xHigh</td>
          <td>GPT 5.4 medium</td>
          <td>Codex</td>
      </tr>
      <tr>
          <td><code>gpt_5_4_multi_faster_forced</code></td>
          <td>GPT 5.4 xHigh</td>
          <td>GPT 5.4 low</td>
          <td>Codex</td>
      </tr>
  </tbody>
</table>
<p>Results:</p>
<table>
  <thead>
      <tr>
          <th>Variant</th>
          <th style="text-align: right">Score</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Cost</th>
          <th style="text-align: center">Tier</th>
          <th style="text-align: right">vs solo Opus (97)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>claude_opus_sonnet_forced</code></td>
          <td style="text-align: right">92</td>
          <td style="text-align: right">25m</td>
          <td style="text-align: right">$5.77</td>
          <td style="text-align: center">A</td>
          <td style="text-align: right">-5</td>
      </tr>
      <tr>
          <td><code>claude_opus_haiku_forced</code></td>
          <td style="text-align: right">90</td>
          <td style="text-align: right">19m</td>
          <td style="text-align: right">$3.49</td>
          <td style="text-align: center">A</td>
          <td style="text-align: right">-7</td>
      </tr>
      <tr>
          <td><code>opencode_opus_kimi_forced</code></td>
          <td style="text-align: right">95</td>
          <td style="text-align: right">25m</td>
          <td style="text-align: right">~$2-3</td>
          <td style="text-align: center">A</td>
          <td style="text-align: right">-2</td>
      </tr>
      <tr>
          <td><code>opencode_opus_glm_forced</code></td>
          <td style="text-align: right">93</td>
          <td style="text-align: right">13m</td>
          <td style="text-align: right">~$0.50</td>
          <td style="text-align: center">A</td>
          <td style="text-align: right">-4</td>
      </tr>
      <tr>
          <td><code>opencode_opus_qwen_forced</code></td>
          <td style="text-align: right">92</td>
          <td style="text-align: right">(variable)</td>
          <td style="text-align: right">~$0.50</td>
          <td style="text-align: center">A</td>
          <td style="text-align: right">-5</td>
      </tr>
      <tr>
          <td><code>gpt_5_4_multi_balanced_forced</code></td>
          <td style="text-align: right">94</td>
          <td style="text-align: right">30m</td>
          <td style="text-align: right">~$1-3</td>
          <td style="text-align: center">A</td>
          <td style="text-align: right">-3</td>
      </tr>
      <tr>
          <td><code>gpt_5_4_multi_faster_forced</code></td>
          <td style="text-align: right">94</td>
          <td style="text-align: right">53m</td>
          <td style="text-align: right">~$3-6</td>
          <td style="text-align: center">A</td>
          <td style="text-align: right">-3</td>
      </tr>
  </tbody>
</table>
<p>When forced, <strong>delegation actually happens</strong> (5 to 15 dispatches per run). All Tier A on code quality (90-95). But the important point is: <strong>none beats solo Opus 4.7 on quality.</strong> All cost extra time (minimum +7 minutes, up to +35 minutes for <code>multi_faster</code>). And on raw cost (pay-as-you-go), only two come in below solo: GLM (Z.ai subscription, basically free) and the GPT 5.4 multi (much cheaper than GPT 5.4 solo, but still in the same ballpark as Opus solo).</p>
<p>This round had two embarrassing harness findings. One was a watchdog firing too early, killing the benchmark before the cross-provider subagent finished spinning up (Z.ai and llama-swap took longer than expected to come online). I bumped the timeout from 6 to 15 minutes and GLM/Qwen came back at Tier A. The other: several models appeared to be &ldquo;executing silently&rdquo; (empty result envelopes). Only in Round 3 did I figure out that most of those were hidden errors — the <code>qwen3.6-plus:free</code> endpoint had been deprecated and was returning 404, and DeepSeek was 400-erroring with a protocol bug (details in its dedicated section below). The &ldquo;1900 files&rdquo; of <code>opencode_opus_deepseek_forced</code> were <strong>entirely</strong> written by Opus&rsquo;s <code>general</code> fallback, not DeepSeek.</p>
<p>OK, so accounting for the harness failures, the story becomes:</p>
<ul>
<li><strong>Sonnet/Haiku coders in Claude Code:</strong> 90-92 vs solo Opus 97. Costs +1 minute to +7 minutes. Costs ~equal or more.</li>
<li><strong>Kimi K2.6 in opencode:</strong> 95 vs 97. Costs $2-3 vs $4, so 25-50% savings. +7 minutes.</li>
<li><strong>GLM 5.1 in opencode:</strong> 93 vs 97. Costs ~$0.50 with Z.ai subscription (basically free) vs $4 for solo Opus. -5 minutes.</li>
<li><strong>GPT 5.4 + medium:</strong> 94 vs 97. Costs ~$1-3 vs $16 for GPT 5.4 solo (80-85% cheaper). +12 minutes.</li>
</ul>
<p>The last is the only configuration where forced delegation <strong>actually pays off in absolute cost</strong>: if you have to be on GPT 5.4, forced multi-agent is real savings. In every other case, you pay in quality and/or time to use a cheaper subagent. On Anthropic Pro monthly, solo Opus is $0 marginal. There&rsquo;s zero reason to use Sonnet/Haiku as a sub. Forced multi-agent there is theater.</p>
<p>And one last thing from this round: the &ldquo;exception lesson&rdquo;, when forced multi-agent <strong>repairs</strong> Claude Code. Solo Opus in Claude Code came out Tier 3 (hallucinated <code>chat.complete</code>) at $6.74. Forcing Sonnet/Haiku as executor inside Claude Code repaired it to Tier A at $3.49-5.77. But the cleaner fix is to <strong>switch harness</strong>, not orchestrate. Solo Opus in opencode delivers Tier A at $4 with no fuss. The &ldquo;orchestration repairs Claude Code&rdquo; line is a workaround for a bug elsewhere.</p>
<hr>
<h2>Segment 3: manual cross-process orchestration, Opus driving opencode<span class="hx:absolute hx:-mt-20" id="segment-3-manual-cross-process-orchestration-opus-driving-opencode"></span>
    <a href="#segment-3-manual-cross-process-orchestration-opus-driving-opencode" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The third round was the most elaborate. Premise: take the subagent out of the planner&rsquo;s process (where the <code>task</code> envelope bug lived) and use opencode in single-agent mode invoked via subprocess for each subtask. The setup was a Claude Code session with Opus 4.7 in the orchestrator role. For each subtask, Opus wrote the prompt to a file, invoked opencode via Bash with the cheap model as sole primary, read the output, and decided the next dispatch.</p>
<p>No fallback. No <code>general</code> agent available for Opus to escape to. <strong>Either the named executor writes, or nothing gets written.</strong></p>
<p>Three variants attempted:</p>
<h3>Opus + Qwen 3.6 Plus (Variant 1): 94/100 Tier A<span class="hx:absolute hx:-mt-20" id="opus--qwen-36-plus-variant-1-94100-tier-a"></span>
    <a href="#opus--qwen-36-plus-variant-1-94100-tier-a" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Setup: 8 dispatches + 1 fix-up = 9 dispatches total. ~$0.74 executor cost. ~12 minutes cumulative wall time <strong>for the executor only</strong>. Plus the planner overhead (Opus in Claude Code orchestrating) which isn&rsquo;t directly measured but is estimated at $11 (more on this below).</p>
<p>Qwen 3.6 Plus behavior as executor: truncates the final summary in 3 of 9 dispatches, sometimes emits zero text but does many tool calls (dispatch 8: 14 tool calls, 0 text turns), made smart adaptations on its own (manually created <code>app/javascript/</code> after Rails 8.1 didn&rsquo;t generate it, swapped <code>root_url</code> for <code>get &quot;/&quot;</code>, created tailwind.css placeholder). But needed 2 fix-up dispatches for issues caused by Rails 8.1 generator behavior changes.</p>
<p>Auditor&rsquo;s read: <em>&ldquo;Qwen wrote the lines, Opus decided the boundaries, and the boundaries are most of what lifts this from B to A.&rdquo;</em></p>
<p>Result: <strong>94/100 Tier A. +23 points over Qwen 3.6 Plus solo (71/100, Tier B).</strong> But the entire lift comes from Opus&rsquo;s detailed plan, not from autonomous Qwen capability.</p>
<h3>Opus + Kimi K2.6 (Variant 2): 97/100 Tier A, TIES SOLO OPUS<span class="hx:absolute hx:-mt-20" id="opus--kimi-k26-variant-2-97100-tier-a-ties-solo-opus"></span>
    <a href="#opus--kimi-k26-variant-2-97100-tier-a-ties-solo-opus" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Setup: 5 dispatches, <strong>zero fix-ups</strong>. ~$0.37 executor cost. ~10 minutes cumulative executor wall time. Full end-to-end validation (local boot, docker compose up + curl + clean teardown).</p>
<p>Kimi K2.6 behavior: coherent text response per dispatch with no truncation, strong autonomous adaptation (caught Stimulus + Tailwind install gaps without explicit prompting, caught the layout-wrapping side effect of <code>tailwindcss:install</code>), zero fix-up dispatches.</p>
<p>Result: <strong>97/100 Tier A. TIES Opus 4.7 solo.</strong> Auditor&rsquo;s read: <em>&ldquo;Kimi wrote every line, but Opus&rsquo;s planning prompts shaped what to ask for (better test fixtures, error-path coverage, persistence design), pushing Kimi from 87 → ~97.&rdquo;</em></p>
<h3>Opus + DeepSeek V4 Pro (Variant 3): FAILED<span class="hx:absolute hx:-mt-20" id="opus--deepseek-v4-pro-variant-3-failed"></span>
    <a href="#opus--deepseek-v4-pro-variant-3-failed" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Structural harness incompatibility bug. DeepSeek V4 Pro returns <code>reasoning_content</code> on every response, and opencode strips that field when constructing the next request. The DeepSeek API then rejects turn 2 with <code>&quot;reasoning_content must be passed back to the API&quot;</code>. Tested three <code>reasoning</code> configurations (<code>true</code>, <code>false</code>, absent). All failed identically at turn 2 of dispatch 1. There&rsquo;s no opencode flag that fixes it. Workarounds (custom OpenRouter provider params, single-bash-per-dispatch protocol, switching to V4 Flash) were not pursued.</p>
<p>Retroactive implication: the supposed &ldquo;completions&rdquo; of DeepSeek in rounds 2 and 2.5 were <strong>entirely</strong> written by Opus through the <code>general</code> fallback. DeepSeek V4 Pro contributed zero lines of code in any configuration of this benchmark to date.</p>
<h3>The hidden planner cost<span class="hx:absolute hx:-mt-20" id="the-hidden-planner-cost"></span>
    <a href="#the-hidden-planner-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Here&rsquo;s where things get ugly. The two manual variants that actually ran (Qwen and Kimi) had <strong>~14 successful dispatches combined</strong>. Each dispatch consumed ~3-5 turns of the Opus orchestrator (read previous dispatch output, plan next, write prompt file, monitor execution, verify filesystem). Each turn costs <del>$0.15-0.25 in Anthropic tokens. Total: **</del>$11 of hidden planner cost**, not logged in the executor JSON.</p>
<p>Real total cost of the two manual variants: ~$1.11 executor + <del>$11 planner = **</del>$12 combined**. Compared to solo Opus opencode at $4. <strong>Manual orchestration costs 3× more than solo.</strong></p>
<h3>What this round actually proves<span class="hx:absolute hx:-mt-20" id="what-this-round-actually-proves"></span>
    <a href="#what-this-round-actually-proves" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Executor</th>
          <th style="text-align: right">Solo score</th>
          <th style="text-align: right">Lift under Opus orchestration</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GLM 5.1</td>
          <td style="text-align: right">46 (Tier C)</td>
          <td style="text-align: right">+47 → 93 (Tier A) [via in-process forced 2.5]</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td style="text-align: right">71 (Tier B)</td>
          <td style="text-align: right">+23 → 94 (Tier A) [via manual orchestration]</td>
      </tr>
      <tr>
          <td>Kimi K2.6</td>
          <td style="text-align: right">87 (Tier A)</td>
          <td style="text-align: right">+10 → 97 (Tier A, ties Opus) [via manual orchestration]</td>
      </tr>
  </tbody>
</table>
<p><strong>The lift scales inversely with the executor&rsquo;s solo capability.</strong> The further from Tier A solo, the more orchestration adds. Kimi solo is already Tier A, so the +10 is polish (better test fixtures, error coverage, persistence). GLM solo is Tier C because of one specific hallucination (invented fluent DSL), and prescriptive prompts that name the real API remove the hallucination entirely.</p>
<p><strong>But the cost of that lift is dominated by the planner.</strong> If you only have access to GLM (not Opus), this finding doesn&rsquo;t help you. If you have Opus AND GLM, just use Opus solo for less money and time.</p>
<p>The realistic use case for this pattern is: <strong>multi-tenant deployment where the planner runs once and is amortized across many similar subtasks</strong> (like &ldquo;apply this same refactor to 50 different files&rdquo;). Greenfield Rails benchmark doesn&rsquo;t capture that.</p>
<hr>
<h2>Final comparison: the 3 rounds vs solo<span class="hx:absolute hx:-mt-20" id="final-comparison-the-3-rounds-vs-solo"></span>
    <a href="#final-comparison-the-3-rounds-vs-solo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>All costs below are <strong>total (Opus planner + executor)</strong> in pay-as-you-go. End-to-end wall times:</p>
<table>
  <thead>
      <tr>
          <th>Variant</th>
          <th style="text-align: right">Score</th>
          <th style="text-align: right">Wall time</th>
          <th style="text-align: right">Total cost</th>
          <th style="text-align: right">Δ quality vs solo (97)</th>
          <th style="text-align: right">Δ cost vs solo ($4)</th>
          <th style="text-align: right">Δ time vs solo (18m)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Opus 4.7 solo (opencode)</strong></td>
          <td style="text-align: right"><strong>97</strong></td>
          <td style="text-align: right"><strong>18m</strong></td>
          <td style="text-align: right"><strong>$4.04</strong></td>
          <td style="text-align: right">baseline</td>
          <td style="text-align: right">baseline</td>
          <td style="text-align: right">baseline</td>
      </tr>
      <tr>
          <td>Opus + Kimi (manual)</td>
          <td style="text-align: right">97</td>
          <td style="text-align: right">30-40m</td>
          <td style="text-align: right">~$5-7 (planner ~$5 + executor $0.37)</td>
          <td style="text-align: right">=0</td>
          <td style="text-align: right">+$1 to +$3</td>
          <td style="text-align: right">+12-22m</td>
      </tr>
      <tr>
          <td>Opus + Sonnet 4.6 (CC, forced)</td>
          <td style="text-align: right">92</td>
          <td style="text-align: right">25m</td>
          <td style="text-align: right">$5.77 (Claude Code log)</td>
          <td style="text-align: right">-5</td>
          <td style="text-align: right">+$1.73</td>
          <td style="text-align: right">+7m</td>
      </tr>
      <tr>
          <td>Opus + Haiku 4.5 (CC, forced)</td>
          <td style="text-align: right">90</td>
          <td style="text-align: right">19m</td>
          <td style="text-align: right">$3.49 (Claude Code log)</td>
          <td style="text-align: right">-7</td>
          <td style="text-align: right">-$0.55</td>
          <td style="text-align: right">+1m</td>
      </tr>
      <tr>
          <td>Opus + Kimi (in-process forced)</td>
          <td style="text-align: right">95</td>
          <td style="text-align: right">25m</td>
          <td style="text-align: right">~$3-4 (planner ~$2-3 + executor ~$0.50)</td>
          <td style="text-align: right">-2</td>
          <td style="text-align: right">-$1 to 0</td>
          <td style="text-align: right">+7m</td>
      </tr>
      <tr>
          <td>Opus + GLM 5.1 (forced, watchdog fix)</td>
          <td style="text-align: right">93</td>
          <td style="text-align: right">13m</td>
          <td style="text-align: right">~$0.50 + Z.ai sub</td>
          <td style="text-align: right">-4</td>
          <td style="text-align: right">-$3.50 + sub</td>
          <td style="text-align: right">-5m</td>
      </tr>
      <tr>
          <td>Opus + Qwen 3.6 (manual)</td>
          <td style="text-align: right">94</td>
          <td style="text-align: right">~40m</td>
          <td style="text-align: right">~$6-7 (planner ~$5 + executor $0.74)</td>
          <td style="text-align: right">-3</td>
          <td style="text-align: right">+$2 to +$3</td>
          <td style="text-align: right">+22m</td>
      </tr>
      <tr>
          <td>GPT 5.4 xHigh + medium (Codex forced)</td>
          <td style="text-align: right">94</td>
          <td style="text-align: right">30m</td>
          <td style="text-align: right">~$1-3 (Codex log, both GPT)</td>
          <td style="text-align: right">-3</td>
          <td style="text-align: right">-$1 to -$3</td>
          <td style="text-align: right">+12m</td>
      </tr>
      <tr>
          <td>GPT 5.4 xHigh + low (Codex forced)</td>
          <td style="text-align: right">94</td>
          <td style="text-align: right">53m</td>
          <td style="text-align: right">~$3-6 (Codex log, both GPT)</td>
          <td style="text-align: right">-3</td>
          <td style="text-align: right">-$1 to +$2</td>
          <td style="text-align: right">+35m</td>
      </tr>
  </tbody>
</table>
<p>(Tier D / 0-file failures omitted.)</p>
<p>The orchestrator-Opus planner cost on the &ldquo;Opus + Kimi (manual)&rdquo; and &ldquo;Opus + Qwen (manual)&rdquo; rows is a <strong>hidden</strong> cost: it shows up in the Claude Code session driving the experiment, not in the executor log. The ~14 successful dispatches from the two manual variants combined consumed ~$11 of Opus, split between them, landing at ~$5-6 each.</p>
<p><strong>Solo Opus 4.7 in opencode is the best on every metric:</strong> it ties or beats every other variant on quality, cost OR time, and ties or beats most on all three simultaneously.</p>
<p>The real exception is <strong>Codex GPT 5.4 xHigh + medium executor</strong>: 94/100 vs 97/100 (loses 3 points), but costs $1-3 instead of $16 for GPT 5.4 solo (80-85% cheaper). Useful if you only have OpenAI credentials and need cheap Tier A.</p>
<p>Every other variant loses on at least one important dimension. Most lose on two (quality AND time, or quality AND cost).</p>
<hr>
<h2>The subscription question<span class="hx:absolute hx:-mt-20" id="the-subscription-question"></span>
    <a href="#the-subscription-question" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The numbers above are <strong>pay-as-you-go</strong>. Let&rsquo;s add subscription to the picture.</p>
<p>Anthropic Pro at $200/month includes Opus 4.7 with <a href="https://help.openai.com/en/articles/20001106-codex-rate-card"target="_blank" rel="noopener">20× the Plus quota</a>. In practice, that&rsquo;s many full benchmark runs per day without touching the cap. OpenAI Plus at $20/month or Pro at $200/month includes Codex CLI with similar economics. Under subscription, <strong>a benchmark run adds zero marginal cost</strong> until you saturate the quota.</p>
<p>What does that change?</p>
<ul>
<li>Solo Opus 4.7: $4 pay-as-you-go becomes $0 marginal on Anthropic Pro. <strong>Still the best.</strong></li>
<li>Multi-agent with Sonnet/Haiku via Claude Code: $3.49-5.77 becomes $0 marginal on Anthropic Pro. Still loses 5-7 quality points. <strong>Not worth it.</strong></li>
<li>Multi-agent with Kimi/GLM/Qwen via opencode (which charges separately via OpenRouter): adds $0.50-3 in OpenRouter cost on top of the subscription. Loses 2-7 quality points. Equal or longer time. <strong>Not worth it.</strong></li>
<li>Codex GPT 5.4 + medium: $1-3 becomes $0 marginal on ChatGPT Pro. But solo GPT 5.4 also becomes $0 marginal. <strong>Multi isn&rsquo;t worth it.</strong></li>
<li>Manual cross-process (Opus driving opencode with Kimi): $0 marginal (planner on Anthropic Pro) + $0.37 (executor on paid OpenRouter) = $0.37. Costs 30-40 minutes vs 18 for solo. <strong>Ties on quality. Not worth it.</strong></li>
</ul>
<p>Under subscription, <strong>no multi-agent configuration beats solo frontier.</strong> Marginal cost of an extra call is zero for someone already paying the monthly subscription. Extra coordination doesn&rsquo;t compensate when the &ldquo;savings&rdquo; is zero.</p>
<hr>
<h2>The least-bad case: Opus + Kimi K2.6 under heavy use<span class="hx:absolute hx:-mt-20" id="the-least-bad-case-opus--kimi-k26-under-heavy-use"></span>
    <a href="#the-least-bad-case-opus--kimi-k26-under-heavy-use" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since I&rsquo;m being skeptical on every conclusion, it&rsquo;s worth noting separately the one non-obvious configuration where Opus + Kimi K2.6 might actually make sense in practice: <strong>continuous heavy use that already saturated the Anthropic Pro monthly quota.</strong></p>
<p>In forced in-process mode (Round 2.5 with adjusted watchdog), Kimi K2.6 delivers 95/100 vs Opus solo&rsquo;s 97. The 2-point difference is usually polish in test fixtures and error handling, things you can patch later. In exchange, you pay ~$2-3 of OpenRouter on Kimi instead of burning Pro quota on Opus. For power users hitting the $200/month Pro cap regularly (multiple times a week, heavy parallel projects), redirecting some work to Kimi can free up Pro quota for tasks that really need Opus.</p>
<p>It&rsquo;s a narrow exception. For most people who haven&rsquo;t saturated Pro, just using solo Opus is still better. And for those without Pro yet, the savings from orchestrating Kimi + Opus will be equal to or less than just subscribing to Pro. But for the &ldquo;I&rsquo;m using a coding agent professionally and heavily and I&rsquo;m blowing the monthly cap&rdquo; scenario, the Opus planner + Kimi executor configuration is the best option I measured in this benchmark.</p>
<p>Worth mentioning also is the <strong>multi-tenant case where the planner is amortized</strong>: if you&rsquo;re running a pipeline that applies the same change pattern to many similar projects (refactor 50 repos to use a new convention, generate 30 microservices with the same skeleton), Opus plans once, you save the plan, and Kimi executes against each project. The planner cost dilutes across volume and the pairing makes real economic sense.</p>
<hr>
<h2>Why DeepSeek was the hardest model to test<span class="hx:absolute hx:-mt-20" id="why-deepseek-was-the-hardest-model-to-test"></span>
    <a href="#why-deepseek-was-the-hardest-model-to-test" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Worth logging separately, because it&rsquo;s the most frustrating case in the whole benchmark. DeepSeek V4 Pro <strong>never contributed a single line of code</strong> in any of the three multi-agent rounds, despite showing up in several experiments as &ldquo;completed&rdquo;.</p>
<p>The root cause is a protocol incompatibility between the DeepSeek API and the ai-sdk that opencode uses underneath. DeepSeek V4 Pro runs in thinking mode by default. Every response it returns includes a <code>reasoning_content</code> field with the model&rsquo;s internal chain of thought. <strong>The DeepSeek API then requires that <code>reasoning_content</code> to be echoed back in the message history of the next request.</strong> Without it, the server responds with 400 and this specific message:</p>
<blockquote>
  <p><code>The &quot;reasoning_content&quot; in the thinking mode must be passed back to the API.</code></p>

</blockquote>
<p>Opencode&rsquo;s ai-sdk, when constructing the next request, <strong>strips <code>reasoning_content</code></strong> from the message history. Every multi-turn call to DeepSeek V4 Pro via opencode fails on turn 2.</p>
<p>What makes this invisible on the surface is that opencode doesn&rsquo;t bubble that 400 to the user. It buries the error in the event stream and keeps going. When you look at the run result, you see files being written and tasks &ldquo;completing.&rdquo; But if you inspect the trace, you find that most (or all) came from the <code>general</code> fallback agent, which is Opus 4.7 itself. The DeepSeek that was supposed to be doing the work wrote nothing.</p>
<p>I tested three <code>reasoning</code> configurations in opencode (<code>true</code>, <code>false</code>, absent). All failed identically at turn 2 of dispatch 1. No config flag fixes it. Workarounds that were out of scope:</p>
<ul>
<li>Custom OpenRouter provider params via header</li>
<li>Single-bash-per-dispatch protocol (which would dodge multi-turn)</li>
<li>Switch to DeepSeek V4 Flash, which doesn&rsquo;t use thinking mode</li>
</ul>
<p>This forces a retroactive correction of earlier conclusions. In <a href="/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/">an earlier post</a> I described DeepSeek V4 Pro as having &ldquo;Tier 1 code, but DNF in the harness.&rdquo; That description was incomplete. <strong>DeepSeek V4 Pro is fundamentally incompatible with any ai-sdk-based harness</strong> (which includes opencode, and probably several other tools that use the same SDK underneath). The model can write good code solo if you invoke the API directly with proper thinking-mode handling. But in any real coding-agent pipeline that uses ai-sdk, it doesn&rsquo;t work in multi-turn.</p>
<p>Practical result: DeepSeek V4 Pro is unmeasurable in this benchmark. The only configurations where it appeared as &ldquo;successful&rdquo; had Opus writing in its place. For future benchmarks, I&rsquo;ll either swap to V4 Flash (which avoids thinking mode) or build a custom harness that echoes <code>reasoning_content</code> correctly.</p>
<p>The broader lesson: <strong>the maturity of tooling around a model matters as much as the model&rsquo;s quality.</strong> DeepSeek V4 Pro might have excellent solo code, but if you can&rsquo;t use it without writing your own harness, it loses to Kimi K2.6 which works out of the box.</p>
<hr>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Multi-agent on continuous coding agent work is <strong>premature optimization in disguise</strong>. You spend a weekend wiring up an orchestration of five models, five harnesses, five different token counters, only to land at a result that&rsquo;s equal or worse than solo Opus in opencode. And worse: while you&rsquo;re orchestrating, you&rsquo;re not shipping product.</p>
<p>The data:</p>
<ul>
<li>Solo frontier model (Opus 4.7 in opencode) delivers <strong>Tier A 97/100 in 18 minutes for $4 pay-as-you-go</strong>.</li>
<li>Trying multi-agent without forcing: the model doesn&rsquo;t delegate, you pay harness overhead for nothing.</li>
<li>Forcing multi-agent: equivalent quality at more time and equal or higher cost. The one real exception is Codex GPT 5.4 + medium for cutting GPT cost.</li>
<li>Manual cross-process orchestration: ties on quality at best (Opus + Kimi → 97), but doubles wall time and triples total cost when you account for the planner.</li>
<li>Under monthly subscription: the marginal cost of an extra call is zero for solo frontier. Multi-agent has no economic advantage.</li>
</ul>
<p>Practical rule: <strong>pick a good frontier model (Opus 4.7, GPT 5.5, GPT 5.4 xHigh), use it in a mature harness (opencode respects the model the most, Codex is the official one for GPT, Claude Code if you accept the context pollution), and optimize the prompt instead of the orchestration.</strong> Current models already decide on their own when to split a task into parallel subtasks (Claude Code has the <code>Task</code> tool, Opus uses it when it needs to). No need to force it.</p>
<p>And to close the subscription argument: <strong>$20-200 per month is trivial.</strong> It&rsquo;s less than what most people spend on bad online courses, streaming subscriptions, or weekend partying. In serious professional use, that pays back 5-10× on the first real project. If &ldquo;$200/month&rdquo; feels like a lot, the problem isn&rsquo;t the LLM, it&rsquo;s the ROI of what you&rsquo;re building. Which is exactly where you should be focusing before orchestrating agents to cut $30 in tokens.</p>
<p>Focus on shipping. Token optimization comes later.</p>
<hr>
<h2>Sources<span class="hx:absolute hx:-mt-20" id="sources"></span>
    <a href="#sources" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">Benchmark repo</a> — code, prompts, configs, all the logs</li>
<li><a href="https://github.com/akitaonrails/llm-coding-benchmark/blob/master/docs/success_report.multi_model.md"target="_blank" rel="noopener">Round 1 multi-model report</a> — 7 free-choice variants, zero delegations</li>
<li><a href="https://github.com/akitaonrails/llm-coding-benchmark/blob/master/docs/success_report.multi_model_forced.md"target="_blank" rel="noopener">Round 2 forced-delegation audit</a> — 7 forced variants, 0-100 rubric</li>
<li><a href="https://github.com/akitaonrails/llm-coding-benchmark/blob/master/docs/success_report.manual_orchestration.md"target="_blank" rel="noopener">Round 3 manual orchestration</a> — Opus driving opencode in subprocess, Kimi/Qwen/DeepSeek</li>
<li><a href="https://github.com/akitaonrails/llm-coding-benchmark/blob/master/docs/orchestration_traces.md"target="_blank" rel="noopener">Orchestration traces</a> — per-variant forensic walkthroughs</li>
<li><a href="https://github.com/akitaonrails/llm-coding-benchmark/blob/master/prompts/benchmark_prompt_forced_delegation.txt"target="_blank" rel="noopener">Forced-delegation prompt template</a></li>
<li><a href="/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/">Canonical benchmark (April 2026)</a> — solo runs of 23 models, 8-dimension rubric</li>
<li><a href="/en/2026/02/16/vibe-code-do-zero-a-producao-em-6-dias-the-m-akita-chronicles/">Vibe code: from zero to production in 6 days</a> — full immersion in practice</li>
<li><a href="/en/2026/04/15/como-falar-com-o-claude-code-efetivamente/">How to talk to Claude Code effectively</a> — role-play as PM/QA</li>
<li><a href="/en/2026/04/20/clean-code-para-agentes-de-ia/">Clean Code for AI agents</a> — instruction guide for the model</li>
</ul>
]]></content:encoded><category>llm</category><category>benchmark</category><category>claude</category><category>ai</category><category>vibecoding</category></item><item><title>LLM Coding Benchmark (May 2026): DeepSeek v4, Kimi v2.6, Grok 4.3, GPT 5.5</title><link>https://akitaonrails.github.io/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/</guid><pubDate>Fri, 24 Apr 2026 13:00:00 GMT</pubDate><description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (May 6, 2026)&lt;/strong&gt;: two post-publication adjustments that reshuffle the ranking. &lt;strong&gt;DeepSeek V4 Pro got unblocked&lt;/strong&gt; — what was listed here as &amp;ldquo;unmeasurable in opencode&amp;rdquo; climbed to &lt;strong&gt;Tier A at 89/100&lt;/strong&gt; running through DeepClaude, a shim that swaps the endpoint Claude Code talks to. Technical details in the &lt;a href="https://akitaonrails.github.io/en/2026/05/04/llm-benchmarks-deepseek-unlocked-deepclaude/"&gt;dedicated May 4 post&lt;/a&gt;. And &lt;strong&gt;Grok 4.3 entered the benchmark&lt;/strong&gt; at 72/100 Tier B, a big jump over Grok 4.20&amp;rsquo;s 25/100 (which still sits at the bottom of the list). The ranking, comparisons, and DeepSeek/Grok dedicated sections have all been updated below.&lt;/p&gt;</description><content:encoded><![CDATA[<blockquote>
  <p><strong>Update (May 6, 2026)</strong>: two post-publication adjustments that reshuffle the ranking. <strong>DeepSeek V4 Pro got unblocked</strong> — what was listed here as &ldquo;unmeasurable in opencode&rdquo; climbed to <strong>Tier A at 89/100</strong> running through DeepClaude, a shim that swaps the endpoint Claude Code talks to. Technical details in the <a href="/en/2026/05/04/llm-benchmarks-deepseek-unlocked-deepclaude/">dedicated May 4 post</a>. And <strong>Grok 4.3 entered the benchmark</strong> at 72/100 Tier B, a big jump over Grok 4.20&rsquo;s 25/100 (which still sits at the bottom of the list). The ranking, comparisons, and DeepSeek/Grok dedicated sections have all been updated below.</p>

</blockquote>
<p><strong>TL;DR:</strong> This is the canonical version of my LLM coding benchmark. It supersedes the <a href="https://github.com/akitaonrails/akitaonrails.github.io"target="_blank" rel="noopener">earlier April posts</a> which are now deprecated. I re-audited 24 models against the <code>ruby_llm</code> gem source instead of memory, and several models I had flagged for &ldquo;inventing API&rdquo; actually write correct code. Kimi K2.6 moved to Tier A. Gemini 3.1 Pro too. DeepSeek V4 Pro reaches Tier A only via DeepClaude (89/100); in opencode it stays unmeasurable. Grok 4.3 debuts at Tier B (72/100). GLM 5.1 dropped to Tier C. MiMo V2.5 Pro fell from &ldquo;first non-Anthropic Tier 1&rdquo; to Tier B. Opus 4.7 and GPT 5.4 xHigh tie at the top (97/100), GPT 5.5 lands third at 96/100 (40% cheaper than 5.4 at the same quality). Opus 4.6 is still my daily pick on <strong>behavior</strong>, not code. Long details below.</p>
<p><strong>Important disclaimer</strong>: all the rankings, tiers and conclusions here only hold <strong>within this specific benchmark methodology</strong>, which is building a Rails + RubyLLM + Hotwire + Docker app from a fixed prompt. Models that fall to Tier B or C here can shine on other kinds of task (isolated function completion, short snippet generation, mathematical reasoning). Nobody should read this as a universal capability judgment.</p>
<hr>
<h2>What this benchmark tests<span class="hx:absolute hx:-mt-20" id="what-this-benchmark-tests"></span>
    <a href="#what-this-benchmark-tests" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Each model gets the same prompt to autonomously build a ChatGPT-style chat app in Rails. The prompt asks for 15 things:</p>
<ol>
<li>Rails with latest Ruby + Rails via mise</li>
<li>No ActiveRecord, Action Mailer, or Active Job</li>
<li>SPA mimicking ChatGPT&rsquo;s interface</li>
<li>Tailwind CSS</li>
<li>Hotwire + Stimulus + Turbo Streams</li>
<li>Rails partials (no single-file CSS/JS dumps)</li>
<li><code>OPENROUTER_API_KEY</code> via env var</li>
<li>No secrets in files</li>
<li>RubyLLM (<code>ruby_llm</code> gem) configured for OpenRouter + Claude Sonnet</li>
<li>Minitest tests for each component</li>
<li>Brakeman, RuboCop, SimpleCov, bundle-audit for CI</li>
<li>Functional Dockerfile (not a placeholder)</li>
<li>docker-compose</li>
<li>README with setup</li>
<li>Everything in workspace root (no nested subdir)</li>
</ol>
<p>Cloud models run two phases (build + boot/Docker validation). Local models run phase 1 only. Harness is <code>opencode run --agent build --format json</code>, except GPT 5.4 which runs via <code>codex exec --json</code> directly.</p>
<h2>Methodology: the 8-dimension rubric<span class="hx:absolute hx:-mt-20" id="methodology-the-8-dimension-rubric"></span>
    <a href="#methodology-the-8-dimension-rubric" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This part changed this week. The first version of this benchmark weighted RubyLLM API correctness too heavily, so a model that wrote the correct call but delivered an incomplete project (no docker-compose, stock README, bundle-audit missing) came across as more qualified than it should. The new rubric distributes across 8 dimensions:</p>
<table>
  <thead>
      <tr>
          <th>Dimension</th>
          <th style="text-align: right">Weight</th>
          <th>What it measures</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Deliverable completeness</td>
          <td style="text-align: right">25%</td>
          <td>Dockerfile + compose + README + Gemfile + all checklist artifacts</td>
      </tr>
      <tr>
          <td>RubyLLM correctness</td>
          <td style="text-align: right">20%</td>
          <td>Calls verified against gem 1.14.1 source</td>
      </tr>
      <tr>
          <td>Test quality</td>
          <td style="text-align: right">15%</td>
          <td>Tests exercise the LLM path with mocks that match the real signature</td>
      </tr>
      <tr>
          <td>Error handling</td>
          <td style="text-align: right">10%</td>
          <td>Rescue blocks around LLM calls, degraded UI for the user</td>
      </tr>
      <tr>
          <td>Persistence / multi-turn</td>
          <td style="text-align: right">10%</td>
          <td>Session cookie / cache good; singleton / class-var / none bad</td>
      </tr>
      <tr>
          <td>Hotwire / Turbo / Stimulus</td>
          <td style="text-align: right">10%</td>
          <td>Real Turbo Streams, decomposed partials, Stimulus controllers</td>
      </tr>
      <tr>
          <td>Architecture</td>
          <td style="text-align: right">5%</td>
          <td>Service/model separation, no logic dumps in controllers</td>
      </tr>
      <tr>
          <td>Production readiness</td>
          <td style="text-align: right">5%</td>
          <td>Multi-worker safe, no XSS, no committed <code>.env</code>, CSRF intact</td>
      </tr>
  </tbody>
</table>
<p>Score 0-100 → Tier A (80+) / B (60-79) / C (40-59) / D (&lt;40).</p>
<ul>
<li><strong>Tier A</strong>: ship as-is, or with a patch under 30 minutes</li>
<li><strong>Tier B</strong>: 1 to 2 hours to ship; architecture is sound, minor gaps</li>
<li><strong>Tier C</strong>: major rework. Core bugs or missing deliverables</li>
<li><strong>Tier D</strong>: throw away, useful only for architectural inspiration</li>
</ul>
<h2>Final ranking (24 models)<span class="hx:absolute hx:-mt-20" id="final-ranking-24-models"></span>
    <a href="#final-ranking-24-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th style="text-align: right">Rank</th>
          <th>Model</th>
          <th style="text-align: right">Score</th>
          <th style="text-align: center">Tier</th>
          <th style="text-align: center">RubyLLM OK</th>
          <th>Time</th>
          <th>Cost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td style="text-align: right">1</td>
          <td>Claude Opus 4.7</td>
          <td style="text-align: right"><strong>97</strong></td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">✅</td>
          <td>18m</td>
          <td>~$1.10</td>
      </tr>
      <tr>
          <td style="text-align: right">1</td>
          <td>GPT 5.4 xHigh (Codex)</td>
          <td style="text-align: right"><strong>97</strong></td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">✅</td>
          <td>22m</td>
          <td>~$16</td>
      </tr>
      <tr>
          <td style="text-align: right">3</td>
          <td><strong>GPT 5.5 xHigh (Codex)</strong></td>
          <td style="text-align: right"><strong>96</strong></td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">✅</td>
          <td>18m</td>
          <td>~$10</td>
      </tr>
      <tr>
          <td style="text-align: right">4*</td>
          <td>DeepSeek V4 Pro (DeepClaude)</td>
          <td style="text-align: right"><strong>89</strong></td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">✅</td>
          <td>18m</td>
          <td>~$3.14</td>
      </tr>
      <tr>
          <td style="text-align: right">5</td>
          <td>Kimi K2.6</td>
          <td style="text-align: right"><strong>87</strong></td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">✅</td>
          <td>20m</td>
          <td>~$0.30</td>
      </tr>
      <tr>
          <td style="text-align: right">6</td>
          <td>Claude Opus 4.6</td>
          <td style="text-align: right">83</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">✅</td>
          <td>16m</td>
          <td>~$1.10</td>
      </tr>
      <tr>
          <td style="text-align: right">7</td>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: right">82</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">✅</td>
          <td>14m</td>
          <td>~$0.40</td>
      </tr>
      <tr>
          <td style="text-align: right">8</td>
          <td>Claude Sonnet 4.6</td>
          <td style="text-align: right">78</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">✅</td>
          <td>16m</td>
          <td>~$0.63</td>
      </tr>
      <tr>
          <td style="text-align: right">8</td>
          <td>DeepSeek V4 Flash</td>
          <td style="text-align: right">78</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">✅</td>
          <td>3m</td>
          <td>~$0.01</td>
      </tr>
      <tr>
          <td style="text-align: right">10</td>
          <td>Grok 4.3</td>
          <td style="text-align: right">72</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">✅</td>
          <td>15m</td>
          <td>~$1.74</td>
      </tr>
      <tr>
          <td style="text-align: right">11</td>
          <td>Qwen 3.6 Plus</td>
          <td style="text-align: right">71</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">✅</td>
          <td>17m</td>
          <td>~$0.15</td>
      </tr>
      <tr>
          <td style="text-align: right">12</td>
          <td>Kimi K2.5</td>
          <td style="text-align: right">69</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">✅</td>
          <td>29m</td>
          <td>~$0.10</td>
      </tr>
      <tr>
          <td style="text-align: right">13</td>
          <td>Xiaomi MiMo V2.5 Pro</td>
          <td style="text-align: right">67</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">✅</td>
          <td>11m</td>
          <td>~$0.14</td>
      </tr>
      <tr>
          <td style="text-align: right">14</td>
          <td>GLM 5</td>
          <td style="text-align: right">64</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">✅</td>
          <td>17m</td>
          <td>~$0.11</td>
      </tr>
      <tr>
          <td style="text-align: right">15</td>
          <td>Step 3.5 Flash</td>
          <td style="text-align: right">56</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">⚠️ bypass</td>
          <td>38m</td>
          <td>~$0.02</td>
      </tr>
      <tr>
          <td style="text-align: right">16</td>
          <td>Qwen 3.5 35B (local)</td>
          <td style="text-align: right">55</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">✅</td>
          <td>28m</td>
          <td>free</td>
      </tr>
      <tr>
          <td style="text-align: right">17</td>
          <td>GLM 4.7 Flash bf16 (local)</td>
          <td style="text-align: right">52</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">✅</td>
          <td>failed</td>
          <td>free</td>
      </tr>
      <tr>
          <td style="text-align: right">18</td>
          <td>GLM 5.1 (Z.ai)</td>
          <td style="text-align: right">46</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">❌</td>
          <td>22m</td>
          <td>subscription</td>
      </tr>
      <tr>
          <td style="text-align: right">19</td>
          <td>DeepSeek V3.2</td>
          <td style="text-align: right">43</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">❌</td>
          <td>60m</td>
          <td>~$0.07</td>
      </tr>
      <tr>
          <td style="text-align: right">20</td>
          <td>MiniMax M2.7</td>
          <td style="text-align: right">41</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">❌</td>
          <td>14m</td>
          <td>~$0.30</td>
      </tr>
      <tr>
          <td style="text-align: right">21</td>
          <td>Qwen 3.5 122B (local)</td>
          <td style="text-align: right">37</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">❌</td>
          <td>43m</td>
          <td>free</td>
      </tr>
      <tr>
          <td style="text-align: right">22</td>
          <td>Qwen 3 Coder Next (local)</td>
          <td style="text-align: right">32</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">❌</td>
          <td>17m</td>
          <td>free</td>
      </tr>
      <tr>
          <td style="text-align: right">23</td>
          <td>Grok 4.20</td>
          <td style="text-align: right">25</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">❌</td>
          <td>8m</td>
          <td>~$0.60</td>
      </tr>
      <tr>
          <td style="text-align: right">24</td>
          <td>GPT OSS 20B (local)</td>
          <td style="text-align: right">11</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">❌</td>
          <td>failed</td>
          <td>free</td>
      </tr>
  </tbody>
</table>
<p>*DeepSeek V4 Pro only reaches that score via DeepClaude (Claude Code with env vars pointed at OpenRouter). In opencode (and any ai-sdk-based harness) the model stays unmeasurable due to the <code>reasoning_content</code> protocol bug. The 89 above is the <code>claude_code_deepseek_v4_pro_or_sonnet</code> variant, with <code>sonnet-coder</code> registered but never invoked; the solo variant (no subagent registered) lands at 84/100, still Tier A. Full coverage in the <a href="/en/2026/05/04/llm-benchmarks-deepseek-unlocked-deepclaude/">May 4 post on the unlock</a>.</p>
<h2>Course correction: what changed in the criteria<span class="hx:absolute hx:-mt-20" id="course-correction-what-changed-in-the-criteria"></span>
    <a href="#course-correction-what-changed-in-the-criteria" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Worth logging what changed between the previous iteration and this one, because it&rsquo;s quite a bit.</p>
<h3>Mistake 1: I was cataloging real APIs as &ldquo;hallucinations&rdquo;<span class="hx:absolute hx:-mt-20" id="mistake-1-i-was-cataloging-real-apis-as-hallucinations"></span>
    <a href="#mistake-1-i-was-cataloging-real-apis-as-hallucinations" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Embarrassing. I went to verify directly against the gem source at <code>~/.local/share/mise/installs/ruby/4.0.2/lib/ruby/gems/4.0.0/gems/ruby_llm-1.14.1/</code> and two things I had been calling invented are actually legitimate public API:</p>
<p><strong><code>chat.add_message(role: :user, content: &quot;x&quot;)</code> is not a kwargs bug</strong>. I asserted this crashed with <code>ArgumentError</code> because the real signature would be a positional hash. Looking at the gem, <code>Chat#add_message(message_or_attributes)</code> accepts either a <code>Message</code> or a hash. Ruby&rsquo;s parser treats <code>add_message(role: :user, content: &quot;x&quot;)</code> as <code>add_message({role: :user, content: &quot;x&quot;})</code>, a single positional hash. <strong>It works.</strong></p>
<p><strong><code>chat.complete(&amp;block)</code> is a real public method</strong> in RubyLLM 1.14.1.</p>
<p>Consequence: several models I had tagged as Tier 3 were actually using valid API.</p>
<h3>Mistake 2: RubyLLM correctness alone isn&rsquo;t enough<span class="hx:absolute hx:-mt-20" id="mistake-2-rubyllm-correctness-alone-isnt-enough"></span>
    <a href="#mistake-2-rubyllm-correctness-alone-isnt-enough" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Even after the API correction, weight was missing on deliverable completeness. If a model writes perfect RubyLLM but forgets docker-compose, leaves the README as a stock template, or omits bundle-audit, the project isn&rsquo;t done.</p>
<p>The new rubric distributes weight differently and that shifted the ranking:</p>
<ul>
<li><strong>Kimi K2.6</strong> moved from &ldquo;half-fix Tier 2&rdquo; up to Tier A (84). The only non-Western model with real tests mocking RubyLLM + rescue + multi-worker-safe session cookie.</li>
<li><strong>Kimi K2.5</strong> came back to Tier B (66) from Tier 3. The API I called invented is real. It drops for another reason: 37 tests that never mock RubyLLM.</li>
<li><strong>Gemini 3.1 Pro</strong> jumped to Tier A (82). Was previously misclassified as Tier 3.</li>
<li><strong>GPT 5.4 xHigh</strong> ties for first with Opus 4.7 (97/100). Impeccable architecture + complete deliverables.</li>
<li><strong>DeepSeek V4 Pro</strong> has two readings. In opencode (and any ai-sdk harness) it stays unmeasurable: it hits the thinking-mode protocol incompatibility and the output falls back to Opus. Through DeepClaude (Claude Code with the endpoint swapped to OpenRouter), it delivers Tier A at 89/100 — the model is capable, the prior harness just didn&rsquo;t support the protocol. May update, see the dedicated post.</li>
<li><strong>MiMo V2.5 Pro</strong> dropped from &ldquo;first non-Anthropic Tier 1&rdquo; to Tier B (64). Tests don&rsquo;t exercise LLM + process-local Singleton + no rescue + no system prompt.</li>
<li><strong>GLM 5.1</strong> dropped hard to Tier C (43). Its fluent DSL (<code>c.user()</code>, <code>c.assistant()</code>) really is invented — grep confirms. And every request reconstructs <code>ChatSession.new</code>, discarding history. Two compound bugs.</li>
</ul>
<p>Lesson: file count and test count measure zero if the code is wrong underneath. But RubyLLM correct on its own also isn&rsquo;t enough. You need both.</p>
<h2>Tier A: what makes them work<span class="hx:absolute hx:-mt-20" id="tier-a-what-makes-them-work"></span>
    <a href="#tier-a-what-makes-them-work" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To understand what separates Tier A from Tier B, look at what Opus 4.7, Kimi K2.6 and Gemini 3.1 Pro do that MiMo, Sonnet 4.6 and Grok 4.3 don&rsquo;t.</p>
<p><strong>All Tier A models have these 4 things:</strong></p>
<ol>
<li><strong>Tests that mock RubyLLM with the correct signature.</strong> A <code>FakeChat</code> that implements <code>with_instructions</code>, <code>add_message</code>, <code>ask</code>. Tests that exercise happy path AND error path. WebMock blocking real calls to OpenRouter.</li>
<li><strong>Rescue around <code>chat.ask</code></strong> with typed error (<code>LlmClient::Error</code> or similar) and degraded UI for the user.</li>
<li><strong>Persistence that survives restart</strong> and works multi-worker. Session cookie (Opus, K2.6) or Rails.cache with TTL (Gemini, GPT 5.4).</li>
<li><strong>System prompt via <code>with_instructions</code>.</strong> The model knows its role.</li>
</ol>
<p>Tier B usually fails 2-3 of these. MiMo fails all 4. Sonnet 4.6 has ambitious architecture (multi-conversation sidebar) with a subtle control-flow bug that the tests rubber-stamp. Grok 4.3 writes the cleanest controller in the benchmark but its Stimulus pipeline is dead at runtime, so half the UI doesn&rsquo;t react to events. DeepSeek V4 Pro has the cleanest RubyLLM code (persistent <code>@chat</code>) in the snippet it produces; in opencode the run falls back to Opus before covering the checklist, but through DeepClaude the same model covers the checklist and lands in Tier A.</p>
<p>Tier C usually has at least one structural bug: invented fluent DSL, history discarded every request, <code>ruby_llm</code> gem in the <code>:test</code> group with <code>require: false</code>, or bypassing the gem entirely with raw <code>Net::HTTP</code>.</p>
<h2>Claude family: Opus 4.6 vs 4.7, Sonnet 4.6<span class="hx:absolute hx:-mt-20" id="claude-family-opus-46-vs-47-sonnet-46"></span>
    <a href="#claude-family-opus-46-vs-47-sonnet-46" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Opus 4.7 leads at 97/100. Textbook-correct code:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">chat</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:,</span> <span class="ss">provider</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl"><span class="n">chat</span><span class="o">.</span><span class="n">with_instructions</span><span class="p">(</span><span class="vi">@system_prompt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">previous_messages</span><span class="o">.</span><span class="n">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span> <span class="n">chat</span><span class="o">.</span><span class="n">add_message</span><span class="p">({</span><span class="ss">role</span><span class="p">:</span> <span class="n">msg</span><span class="o">.</span><span class="n">role</span><span class="o">.</span><span class="n">to_sym</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">msg</span><span class="o">.</span><span class="n">content</span><span class="p">})</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">user_message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span><span class="o">.</span><span class="n">content</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>FakeChat</code> with real signature. Tests verify history replay, error wrapping, model/provider override, system prompt. Session cookie with <code>to_a</code>/<code>from_session</code> is multi-worker safe. Rescue of <code>RubyLLM::Error + StandardError</code> → friendly error bubble.</p>
<p>No relevant deductions this round.</p>
<p>Opus 4.6 has correct code too (83/100 Tier A) but less disciplined. Controller without rescue around <code>chat_service.ask</code>: a transient 5xx becomes a stack trace page. Service reaches into <code>Chat#messages</code> via <code>attr_reader</code> directly, Demeter violation. Material difference between 4.6 (low Tier A) and 4.7 (high Tier A).</p>
<p>Sonnet 4.6 is Tier B (78). Richest UI of the whole benchmark (multi-conversation sidebar). But <code>LlmChatService#call</code> only calls <code>ask</code> if the last history message is from the user, otherwise it silently returns <code>&quot;&quot;</code>. The tests rubber-stamp the bug. And the whole conversation fits in a 4KB cookie, which overflows after ~10 turns.</p>
<h3>The 4.7 behavior downgrade<span class="hx:absolute hx:-mt-20" id="the-47-behavior-downgrade"></span>
    <a href="#the-47-behavior-downgrade" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The community complaint on <a href="https://dev.to/vibeagentmaking/why-we-switched-back-from-claude-opus-47-to-46-47f9"target="_blank" rel="noopener">Reddit and DEV.to</a> that &ldquo;4.7 got worse for coding&rdquo; isn&rsquo;t about code quality. On objective benchmark it&rsquo;s at the top. What 4.7 made worse is <strong>behavior</strong>:</p>
<ul>
<li>New tokenizer <a href="https://platform.claude.com/docs/en/about-claude/models/migration-guide"target="_blank" rel="noopener">consumes 1x to 1.35x more tokens</a> for the same text</li>
<li>Tries to optimize resources (tokens, tool calls, steps) more aggressively, sometimes cutting corners</li>
<li><a href="https://github.com/anthropics/claude-code/issues/52368"target="_blank" rel="noopener">GitHub issues</a> report Max Plan running out in minutes where it used to last hours</li>
</ul>
<p>I have hundreds of hours with 4.6 and have been testing 4.7 since launch. The code 4.7 produces is Tier A, equal to or better than 4.6. But in daily use, 4.6&rsquo;s more direct behavior is more productive. 4.6 is still my Claude Code default when Claude Code lets me pick (since 4.7 launched, <a href="https://code.claude.com/docs/en/model-config"target="_blank" rel="noopener">Claude Code changed the default to xHigh reasoning</a> and is more restrictive about downgrading to 4.6).</p>
<h2>GPT via Codex: 5.4 and 5.5 at the top<span class="hx:absolute hx:-mt-20" id="gpt-via-codex-54-and-55-at-the-top"></span>
    <a href="#gpt-via-codex-54-and-55-at-the-top" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>GPT 5.4 xHigh via Codex CLI ties with Opus 4.7 at first (97/100). Uses <code>RubyLLM.chat(model:, provider: :openrouter, assume_model_exists: true)</code> + <code>with_instructions</code> + <code>add_message(role:, content:)</code> + <code>chat.ask</code> + <code>response.content</code>. Textbook, with provider pinning and registry-skip.</p>
<p>It&rsquo;s the only model with:</p>
<ul>
<li><strong>Explicit API-key preflight</strong> (<code>ensure_api_key!</code> raises <code>MissingConfigurationError</code>)</li>
<li><strong>Differentiated HTTP status codes</strong>: 503 for config error, 502 for runtime error</li>
<li><strong>Rails cache persistence with TTL + cap</strong> (24 messages × 12h)</li>
<li><strong>Dedicated form object</strong> (<code>PromptSubmission</code>) separate from the domain model (<code>ChatMessage</code>)</li>
</ul>
<p>10 test files including partial render tests. <code>FakeChat</code>/<code>FakeClient</code> match real API.</p>
<p><strong>Critical weakness</strong>: 7.6M total tokens → ~$16/run. 15× Opus&rsquo;s cost for essentially tied quality. Hard to justify unless you can&rsquo;t iterate on the first try.</p>
<h3>GPT 5.5 xHigh (Codex): 96/100, cheaper and faster than 5.4<span class="hx:absolute hx:-mt-20" id="gpt-55-xhigh-codex-96100-cheaper-and-faster-than-54"></span>
    <a href="#gpt-55-xhigh-codex-96100-cheaper-and-faster-than-54" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>OpenAI <a href="https://openai.com/index/introducing-gpt-5-5/"target="_blank" rel="noopener">released GPT 5.5 yesterday (April 23)</a>, already <a href="https://community.openai.com/t/gpt-5-5-is-here-available-in-codex-and-chatgpt-today/1379630"target="_blank" rel="noopener">available in Codex</a>. I ran the benchmark today. Result: 96/100, Tier A, rank #3.</p>
<p>Good news is 5.5 delivers <strong>the same code quality as 5.4</strong> on this benchmark, with real time and token savings.</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>GPT 5.4 xHigh</th>
          <th>GPT 5.5 xHigh</th>
          <th>Δ</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Score</td>
          <td>97/100</td>
          <td>96/100</td>
          <td>tie (1-point noise)</td>
      </tr>
      <tr>
          <td>Elapsed</td>
          <td>22m</td>
          <td>18m</td>
          <td><strong>20% faster</strong></td>
      </tr>
      <tr>
          <td>Total tokens</td>
          <td>7.6M</td>
          <td>4.9M</td>
          <td><strong>35% fewer</strong></td>
      </tr>
      <tr>
          <td>Output tokens</td>
          <td>63K</td>
          <td>29K</td>
          <td><strong>54% fewer</strong></td>
      </tr>
      <tr>
          <td>Est. cost</td>
          <td>~$16</td>
          <td>~$10</td>
          <td><strong>40% cheaper</strong></td>
      </tr>
  </tbody>
</table>
<p>The RubyLLM integration is structurally identical to 5.4:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:,</span> <span class="ss">provider</span><span class="p">:</span> <span class="ss">:openrouter</span><span class="p">,</span> <span class="ss">assume_model_exists</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">chat</span><span class="o">.</span><span class="n">with_instructions</span><span class="p">(</span><span class="no">SYSTEM_PROMPT</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">history</span><span class="o">.</span><span class="n">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">m</span><span class="o">|</span> <span class="n">chat</span><span class="o">.</span><span class="n">add_message</span><span class="p">(</span><span class="ss">role</span><span class="p">:</span> <span class="n">m</span><span class="o">.</span><span class="n">role</span><span class="o">.</span><span class="n">to_sym</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">m</span><span class="o">.</span><span class="n">content</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span><span class="o">.</span><span class="n">content</span><span class="o">.</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Ships with the same defensive patterns 5.4 has:</p>
<ul>
<li><strong>Dependency-injected <code>client_factory:</code></strong>: lets tests exercise the full seed-history-then-ask path via <code>FakeClient</code>, no WebMock required</li>
<li><strong><code>rescue_from RubyLLM::Error, RubyLLM::ConfigurationError</code></strong>: both real error classes, rescued separately</li>
<li><strong>Session cookie with 20-message cap</strong></li>
<li><strong>Real Turbo Streams</strong> (<code>turbo_stream.replace &quot;chat-thread&quot;</code> + composer)</li>
<li><strong>Stimulus composer controller</strong> with proper lifecycle (disable-on-submit, reset, auto-scroll)</li>
</ul>
<h3>What changes for the user: nothing in quality, everything in cost/speed<span class="hx:absolute hx:-mt-20" id="what-changes-for-the-user-nothing-in-quality-everything-in-costspeed"></span>
    <a href="#what-changes-for-the-user-nothing-in-quality-everything-in-costspeed" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is worth logging. On synthetic capability benchmark, 5.5 and 5.4 tie. Same architectural shape delivered, same defensive patterns, same error rescue, same persistence. Side-by-side code review shows no material difference.</p>
<p>Where 5.5 really wins is <strong>generation efficiency</strong>: 35% fewer total tokens, 54% fewer output tokens, 20% less time. For what used to cost $16/run on 5.4, you pay $10. On continuous use the savings compound.</p>
<p><strong>It&rsquo;s not a new capability generation.</strong> It&rsquo;s cost optimization on top of architecture that was already at the top. For folks already on Codex, 5.5 replaces 5.4 with no regression. For folks who aren&rsquo;t, at 15× (and now 10×) the cost of Opus for tied quality, it&rsquo;s still hard to justify for continuous use.</p>
<p><strong>Critical weakness</strong>: no significant defect on this run. Same shape as 5.4 at lower cost. The DI-injected pattern + real error class rescue + session cookie = <strong>best defensive patterns in the whole benchmark</strong>.</p>
<h3>A caveat on price<span class="hx:absolute hx:-mt-20" id="a-caveat-on-price"></span>
    <a href="#a-caveat-on-price" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The ~$16/run for GPT 5.4 and ~$10/run for GPT 5.5 in the table are direct API token costs (pay-as-you-go). In April 2026, OpenAI <a href="https://chatgpt.com/codex/pricing/"target="_blank" rel="noopener">switched Codex to use the same API token accounting</a> inside the Plus, Pro, Business and Enterprise subscriptions. In practice, most Codex CLI users access it via <a href="https://chatgpt.com/pricing/"target="_blank" rel="noopener">ChatGPT Plus ($20/mo) or Pro ($200/mo)</a>, where Codex usage falls under the subscription quota (Pro gets <a href="https://help.openai.com/en/articles/20001106-codex-rate-card"target="_blank" rel="noopener">20× Plus&rsquo;s limit</a>). For a Pro subscriber already paying $200/month, a benchmark run adds no marginal cost until they saturate their monthly quota.</p>
<p>So &ldquo;GPT 5.4 is the most expensive in the ranking&rdquo; is true under pay-as-you-go and changes under subscription. Anyone already on Pro for everything probably isn&rsquo;t thinking of this in &ldquo;cost per run&rdquo; terms. That said, in terms of tokens spent per delivered quality, 5.5 is simply more efficient than 5.4. Even inside the subscription it burns 35% less of the quota.</p>
<h2>DeepSeek: the overhype pattern<span class="hx:absolute hx:-mt-20" id="deepseek-the-overhype-pattern"></span>
    <a href="#deepseek-the-overhype-pattern" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every DeepSeek generation ships with heavy marketing (&ldquo;competitive with Claude Opus&rdquo;) and ends with the same pattern: <strong>tool support lags behind</strong>.</p>
<h3>V4 Pro: clean code in snippet, harness incompatible in opencode<span class="hx:absolute hx:-mt-20" id="v4-pro-clean-code-in-snippet-harness-incompatible-in-opencode"></span>
    <a href="#v4-pro-clean-code-in-snippet-harness-incompatible-in-opencode" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The RubyLLM snippet it produces, in isolation, is Tier A quality:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">initialize</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@messages</span> <span class="o">=</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">ask</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">result</span> <span class="o">=</span> <span class="vi">@chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@messages</span> <span class="o">&lt;&lt;</span> <span class="no">Message</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@messages</span> <span class="o">&lt;&lt;</span> <span class="no">Message</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;assistant&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">result</span><span class="o">.</span><span class="n">content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Persistent <code>@chat</code> instance, delegates multi-turn to RubyLLM. Correct pattern.</p>
<p>In opencode, V4 Pro hits a protocol incompatibility. The model uses thinking mode by default and returns <code>reasoning_content</code> on every response. <strong>The DeepSeek API requires the client to echo that <code>reasoning_content</code> back in the next request&rsquo;s message history</strong>, or it answers 400 with <code>&quot;reasoning_content must be passed back to the API&quot;</code>. The ai-sdk that opencode uses underneath strips that field while building the next request. Every multi-turn call to V4 Pro via opencode fails on turn 2.</p>
<p>What makes this treacherous: opencode doesn&rsquo;t surface that 400 to the user. It buries the error in the event stream and falls back to the <code>general</code> fallback agent — which is Opus 4.7. The run &ldquo;completes,&rdquo; files get written, tasks get marked ok. But if you inspect the trace, you discover that <strong>most of the files were written by Opus, not by DeepSeek</strong>. The half-baked deliverables that drag the score down (stock README, no <code>docker-compose.yml</code>, missing bundle-audit) are output from Opus in fallback mode acting without the main agent&rsquo;s context. The 69/100 score on the old record reflects mixed authorship, not V4 Pro for real.</p>
<p>This isn&rsquo;t opencode-specific. <strong>Any ai-sdk-based harness</strong> has the same bug, because the SDK is the one that strips <code>reasoning_content</code>.</p>
<h3>V4 Pro unblocked: DeepClaude delivers Tier A at 89/100<span class="hx:absolute hx:-mt-20" id="v4-pro-unblocked-deepclaude-delivers-tier-a-at-89100"></span>
    <a href="#v4-pro-unblocked-deepclaude-delivers-tier-a-at-89100" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In May I ran Round 4 of the orchestration benchmark and found a way to actually measure V4 Pro. There&rsquo;s a shell shim called <a href="https://github.com/aattaran/deepclaude"target="_blank" rel="noopener">DeepClaude</a> that swaps the endpoint Claude Code talks to. Instead of hitting <code>api.anthropic.com</code>, Claude Code now calls <code>openrouter.ai/api/anthropic</code>, which is OpenRouter&rsquo;s Anthropic-compatible route. That route handles thinking content correctly in the format Claude Code emits (opencode&rsquo;s ai-sdk doesn&rsquo;t). The result: Claude Code&rsquo;s full autonomous loop runs on top of DeepSeek V4 Pro with no protocol bug.</p>
<p>Numbers from the two variants I ran:</p>
<table>
  <thead>
      <tr>
          <th>Variant</th>
          <th style="text-align: right">Score</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Cost</th>
          <th>Note</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Code via DeepClaude → V4 Pro (solo)</td>
          <td style="text-align: right">84</td>
          <td style="text-align: right">21m</td>
          <td style="text-align: right">$3.38</td>
          <td>No subagent registered</td>
      </tr>
      <tr>
          <td>Claude Code via DeepClaude → V4 Pro + Sonnet registered</td>
          <td style="text-align: right"><strong>89</strong></td>
          <td style="text-align: right">18m</td>
          <td style="text-align: right">$3.14</td>
          <td>Sonnet registered but never invoked</td>
      </tr>
  </tbody>
</table>
<p>The 15-20 point lift over the opencode result is purely a harness-change effect. Same model, same prompt, no new orchestration, just a different agent loop. And the variant with Sonnet registered but never invoked scored +5 over its sister with no subagent. Mere availability of a delegate made the DeepSeek planner decompose better (smaller seams, cleaner DI, system prompt via <code>with_instructions</code>). Full technical details in the <a href="/en/2026/05/04/llm-benchmarks-deepseek-unlocked-deepclaude/">dedicated post</a>.</p>
<p>Where this leaves V4 Pro:</p>
<ul>
<li>In opencode (and any ai-sdk): unmeasurable. What I wrote above still applies.</li>
<li>In Claude Code with DeepClaude: Tier A at 89/100, $3.14 in 18 minutes.</li>
<li>In Claude Code direct against the Anthropic API with <code>DEEPSEEK_API_KEY</code>: not tested, but the native route also handles thinking content properly.</li>
</ul>
<p>For anyone without an Anthropic subscription who wants Tier A coding, the DeepClaude variant running entirely on <code>OPENROUTER_API_KEY</code> is the first real answer the benchmark has produced.</p>
<p>This is the pattern that shows up every time DeepSeek ships: tool support lags. The community takes weeks to figure out integration, the thinking-mode protocol is more complex at runtime, and support in open-source tooling accumulates gaps the provider doesn&rsquo;t fix. If you need a production pipeline today that integrates DeepSeek, you&rsquo;ll probably have to maintain your own patches — or use DeepClaude.</p>
<h3>V4 Flash: cheapest viable option (78/100 Tier B)<span class="hx:absolute hx:-mt-20" id="v4-flash-cheapest-viable-option-78100-tier-b"></span>
    <a href="#v4-flash-cheapest-viable-option-78100-tier-b" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>$0.01/run, 2m 35s, fixes V3.2&rsquo;s critical bug (invented <code>RubyLLM::Client</code> class). API all correct (now that I know <code>add_message(role:, content:)</code> is valid). Session-replay multi-turn via <code>session[:messages]</code>. WebMock tests the real OpenRouter endpoint and exercises the LLM path. All of this pushes it to 78/100 Tier B.</p>
<p><strong>Gotcha:</strong> the model slug is <code>&quot;claude-sonnet-4&quot;</code> missing the <code>anthropic/</code> prefix. 404 at OpenRouter at runtime. One-character fix, but fatal if you deploy blind.</p>
<p>At Tier B, $0.01/run, V4 Flash is the cheapest model that gets close to &ldquo;code that works with one manual patch&rdquo;.</p>
<h3>V4 vs V3.2: real generational upgrade<span class="hx:absolute hx:-mt-20" id="v4-vs-v32-real-generational-upgrade"></span>
    <a href="#v4-vs-v32-real-generational-upgrade" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th></th>
          <th>V4 Flash</th>
          <th>V4 Pro (DeepClaude)</th>
          <th>V4 Pro (opencode)</th>
          <th>V3.2 (previous)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Score</td>
          <td>78</td>
          <td><strong>89</strong></td>
          <td>69 (mixed authorship)</td>
          <td>43</td>
      </tr>
      <tr>
          <td>Tier</td>
          <td>B</td>
          <td><strong>A</strong></td>
          <td>unmeasurable</td>
          <td>C</td>
      </tr>
      <tr>
          <td>Harness</td>
          <td>completes</td>
          <td>completes</td>
          <td>reroute to Opus fallback</td>
          <td>completes</td>
      </tr>
      <tr>
          <td>Time</td>
          <td>2m 35s</td>
          <td>18m</td>
          <td>22m</td>
          <td>60m</td>
      </tr>
      <tr>
          <td>Cost/run</td>
          <td>~$0.01</td>
          <td>~$3.14</td>
          <td>~$0.50 (Opus underneath)</td>
          <td>~$0.07</td>
      </tr>
      <tr>
          <td>RubyLLM API</td>
          <td>correct</td>
          <td>correct</td>
          <td>correct (snippet only)</td>
          <td>invented <code>RubyLLM::Client</code></td>
      </tr>
      <tr>
          <td>Deliverables</td>
          <td>mostly present</td>
          <td>full</td>
          <td>written by Opus fallback</td>
          <td>decent</td>
      </tr>
  </tbody>
</table>
<p>V4 Flash is the cheap option that works. V4 Pro needs DeepClaude (Claude Code with env vars pointed at OpenRouter) to deliver its full potential; in opencode it stays unmeasurable.</p>
<h2>Kimi: K2.5 → K2.6<span class="hx:absolute hx:-mt-20" id="kimi-k25--k26"></span>
    <a href="#kimi-k25--k26" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>K2.6 (87/100, Tier A)<span class="hx:absolute hx:-mt-20" id="k26-87100-tier-a"></span>
    <a href="#k26-87100-tier-a" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Positive surprise. Only non-Western model at Tier A. Textbook code:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span>
</span></span><span class="line"><span class="cl"><span class="n">chat</span><span class="o">.</span><span class="n">with_instructions</span><span class="p">(</span><span class="no">SYSTEM_INSTRUCTION</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">historical_messages</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span><span class="o">.</span><span class="n">add_message</span><span class="p">(</span><span class="ss">role</span><span class="p">:</span> <span class="n">msg</span><span class="o">[</span><span class="ss">:role</span><span class="o">]</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">msg</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">user_message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span><span class="o">.</span><span class="n">content</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Unique combination among Chinese models: <code>FakeChat</code> matching real API, rescue of <code>RubyLLM::Error</code> (with flash via turbo_stream), session cookie with <code>MAX_MESSAGES = 50</code> cap. Complete Gemfile. Multi-worker safe.</p>
<p>Only deduction is the full history replay on each turn (uses more tokens than MiMo&rsquo;s persistent pattern).</p>
<p>At $0.30/run, Kimi K2.6 is the <strong>cheapest Tier A in the benchmark</strong>. 3 to 50× cheaper than Opus 4.7 and GPT 5.4 xHigh.</p>
<h3>K2.5 (69/100 Tier B, not Tier 3 as I had claimed)<span class="hx:absolute hx:-mt-20" id="k25-69100-tier-b-not-tier-3-as-i-had-claimed"></span>
    <a href="#k25-69100-tier-b-not-tier-3-as-i-had-claimed" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In the first version I cataloged K2.5 as Tier 3 for supposedly inventing <code>chat.complete</code> and using kwargs in <code>add_message</code>. Both are real public API. K2.5 comes back to Tier B.</p>
<p>It drops from K2.6 to K2.5 because: 37 tests (most in the benchmark) never mock RubyLLM. They only test PORO CRUD and <code>respond_to?</code>. Test coverage without mock fidelity measures nothing. And it uses class-var storage (<code>Chat.storage = @storage ||= {}</code>), worse than Singleton because there&rsquo;s no mutex.</p>
<p>K2.6 adds what K2.5 didn&rsquo;t have: <code>FakeChat</code> with the right signature, rescue, session cookie. Real evolution in the dimensions that matter.</p>
<h2>Xiaomi MiMo V2.5 Pro: looked like Tier 1, but the gaps are real<span class="hx:absolute hx:-mt-20" id="xiaomi-mimo-v25-pro-looked-like-tier-1-but-the-gaps-are-real"></span>
    <a href="#xiaomi-mimo-v25-pro-looked-like-tier-1-but-the-gaps-are-real" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>MiMo V2.5 Pro (April 2026) generated the most hype of this round. In the first analysis I promoted it as &ldquo;first non-Anthropic Tier 1&rdquo;. After the new rubric, it drops to Tier B (67/100).</p>
<p>RubyLLM code is still clean:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="vi">@llm_chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Chat</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="no">MODEL</span><span class="p">,</span> <span class="ss">provider</span><span class="p">:</span> <span class="no">PROVIDER</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="vi">@llm_chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="o">&amp;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="vi">@messages</span> <span class="o">&lt;&lt;</span> <span class="p">{</span> <span class="ss">role</span><span class="p">:</span> <span class="ss">:assistant</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">response</span><span class="o">.</span><span class="n">content</span> <span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>Chat.new(model:, provider:)</code> is a valid public constructor. <code>.ask(content, &amp;)</code> forwards a streaming block. Zero manual <code>add_message</code> calls. Multi-turn delegated to RubyLLM itself. <strong>It&rsquo;s the cleanest pattern any model in this benchmark produced.</strong></p>
<p>But the holistic score = 67 because the other components have gaps:</p>
<ul>
<li><strong>Tests don&rsquo;t exercise the LLM path.</strong> The 21 tests only check constants and blank guards. <code>ChatSessionTest</code> has no happy-path test. If the LLM call worked or silently failed, no test would catch it.</li>
<li><strong>No error handling.</strong> No <code>rescue</code> around <code>@chat.ask</code>. Rate limit becomes 500 with stack trace on screen.</li>
<li><strong><code>ChatStore</code> Singleton is process-local.</strong> Dies on Puma restart, doesn&rsquo;t work with <code>WEB_CONCURRENCY &gt; 1</code>, no TTL.</li>
<li><strong>No <code>with_instructions</code></strong>, the model doesn&rsquo;t know its role.</li>
</ul>
<p>Around those four gaps are smaller ones: Docker without healthcheck, no <code>restart: unless-stopped</code>, no Thruster (Rails 8.1 default), short README.</p>
<p>Genuine advantage: idiomatic use of the library. For a greenfield single-worker app, it&rsquo;s less code with the same functionality. Opus&rsquo;s manual replay pattern is actually more defensive than the library itself recommends.</p>
<p>At $0.14/run in 11 minutes, it&rsquo;s <strong>8× cheaper and faster than Opus</strong>. For throwaway prototypes, it&rsquo;s genuinely useful.</p>
<p><strong>Verdict</strong>: MiMo is ~70% of Opus quality at 12.5% of the price. For throwaway prototype, worth it. For production, ~2 engineer-hours adding rescue + Rails.cache + FakeChat + WebMock + system prompt. At that point Opus at $1.10 comes out cheaper overall.</p>
<h2>Gemini 3.1 Pro: the quiet surprise (82/100 Tier A)<span class="hx:absolute hx:-mt-20" id="gemini-31-pro-the-quiet-surprise-82100-tier-a"></span>
    <a href="#gemini-31-pro-the-quiet-surprise-82100-tier-a" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I had classified it as Tier 3. Re-audit showed <code>Chat.new</code> and <code>add_message</code> kwargs form are real API.</p>
<p>Strong points: real Turbo Streams (not fetch + innerHTML), Rails.cache-backed persistence with 2h expiry, FakeChat mocks matching the real API, error path tested.</p>
<p>Critical weakness: it uses the model string <code>claude-3.7-sonnet</code> instead of the current Sonnet 4.x. One-character fix.</p>
<p>At $0.40/run, Gemini 3.1 Pro is the other cost-effective Tier A alongside Kimi K2.6.</p>
<h2>Qwen family: distillation doesn&rsquo;t save you<span class="hx:absolute hx:-mt-20" id="qwen-family-distillation-doesnt-save-you"></span>
    <a href="#qwen-family-distillation-doesnt-save-you" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Tested several Qwens:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Type</th>
          <th style="text-align: center">Tier</th>
          <th>Detail</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td>Cloud, OpenRouter</td>
          <td style="text-align: center">B (71)</td>
          <td>Correct RubyLLM API; tests make real calls (no WebMock); client-side JS history</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B</td>
          <td>Local NVIDIA</td>
          <td style="text-align: center">C (55)</td>
          <td>Correct entry point; no multi-turn; test wraps real call in <code>rescue =&gt; e; assert true</code></td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td>Local NVIDIA</td>
          <td style="text-align: center">D (37)</td>
          <td>Doesn&rsquo;t use ruby_llm; uses <code>Openrouter::Client</code> (wrong casing)</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td>Local NVIDIA</td>
          <td style="text-align: center">D (32)</td>
          <td>Invents <code>RubyLLM::Client.new</code>; commits placeholder <code>.env</code></td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude-distilled</td>
          <td>Local NVIDIA</td>
          <td style="text-align: center">still Tier 3 on re-review</td>
          <td>Code looks Claude-shaped, but invents the entire API</td>
      </tr>
  </tbody>
</table>
<h3>The discovery: Claude distillation doesn&rsquo;t transfer library knowledge<span class="hx:absolute hx:-mt-20" id="the-discovery-claude-distillation-doesnt-transfer-library-knowledge"></span>
    <a href="#the-discovery-claude-distillation-doesnt-transfer-library-knowledge" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I ran <a href="https://huggingface.co/Jackrong/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled"target="_blank" rel="noopener">Jackrong&rsquo;s Qwen 3.5 27B distilled from Claude 4.6 Opus</a>. The promise was &ldquo;Claude-at-home&rdquo; running locally. The result: the code comes out Claude-styled (frozen_string_literal, Response value objects, layered architecture, doc comments), but functionally it&rsquo;s full-blown hallucination:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">RubyLLM</span><span class="o">::</span><span class="no">Chat</span><span class="o">.</span><span class="n">new</span><span class="o">.</span><span class="n">with_model</span><span class="p">(</span><span class="vi">@model</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">chat</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">conversation_history</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">chat</span><span class="o">.</span><span class="n">add_message</span><span class="p">(</span><span class="ss">role</span><span class="p">:</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">msg</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">Response</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="n">response</span><span class="o">.</span><span class="n">text</span><span class="p">,</span> <span class="ss">usage</span><span class="p">:</span> <span class="n">build_usage</span><span class="p">(</span><span class="n">response</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The issue here is the invented block form <code>.with_model(@model) do |chat| ... end</code> (doesn&rsquo;t exist in the gem), and <code>response.text</code> instead of the real <code>response.content</code>.</p>
<p>Distillation transferred the <strong>style</strong> and stopped there. Factual memory about a specific library is binary recall: it&rsquo;s either in the weights or not. Claude&rsquo;s reasoning traces don&rsquo;t contain repetitions like &ldquo;use <code>ask</code>, not <code>complete</code>&rdquo; because that&rsquo;s not reasoning, it&rsquo;s raw memory.</p>
<p>If you need the model to actually use RubyLLM or any less popular library, Claude distillation won&rsquo;t save you. Use actual Claude or a large cloud model.</p>
<h3>Coder vs General: the inverse surprise<span class="hx:absolute hx:-mt-20" id="coder-vs-general-the-inverse-surprise"></span>
    <a href="#coder-vs-general-the-inverse-surprise" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Intuitively, models with &ldquo;Coder&rdquo; in the name should be better for programming. In this benchmark, the opposite happened. Of the three Qwen Coders tested:</p>
<ul>
<li>Qwen 3 Coder 30B returned a hardcoded mock string instead of calling RubyLLM</li>
<li>Qwen 2.5 Coder 32B timed out at 90 minutes with zero files</li>
<li>Qwen 3.5 27B Sushi Coder RL had infra failure</li>
</ul>
<p>Meanwhile the general Qwen 3.5 35B-A3B completed in 5 minutes with a recognizable Rails project. My reading: the &ldquo;Coders&rdquo; were fine-tuned on isolated problems (Codeforces, Leetcode, short snippets), far from the long-running agentic flows with tool calling. &ldquo;Coder = better for coding agent&rdquo; is false for this kind of task.</p>
<h3>Qwen 3.6 Plus: the cloud that almost arrived<span class="hx:absolute hx:-mt-20" id="qwen-36-plus-the-cloud-that-almost-arrived"></span>
    <a href="#qwen-36-plus-the-cloud-that-almost-arrived" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Qwen 3.6 Plus on OpenRouter completed the benchmark in 17 minutes and is the cleanest Qwen I&rsquo;ve measured (71/100 Tier B). Correct RubyLLM API. Well-built Stimulus controller. But tests make real calls to OpenRouter (no WebMock), and history is client-side JS only (lost on refresh). Uses <code>fetch</code> + <code>innerHTML</code> instead of Turbo Streams.</p>
<h2>GLM family: 5, 5.1, local 4.7 Flash<span class="hx:absolute hx:-mt-20" id="glm-family-5-51-local-47-flash"></span>
    <a href="#glm-family-5-51-local-47-flash" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>GLM 5 (64/100 Tier B)<span class="hx:absolute hx:-mt-20" id="glm-5-64100-tier-b"></span>
    <a href="#glm-5-64100-tier-b" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>RubyLLM.chat(model: &quot;anthropic/claude-sonnet-4&quot;)</code> + <code>chat.ask</code> + <code>response.content</code> correct. Mocha stubs match the real API. One happy-path test, no error-path coverage.</p>
<p><strong>Critical weakness</strong>: zero multi-turn state. Every POST creates a fresh <code>RubyLLM.chat</code> without history. The &ldquo;chat app&rdquo; is a stateless echo service. &ldquo;What did I just say?&rdquo; → &ldquo;I don&rsquo;t know.&rdquo;</p>
<h3>GLM 5.1 (46/100 Tier C, dropped)<span class="hx:absolute hx:-mt-20" id="glm-51-46100-tier-c-dropped"></span>
    <a href="#glm-51-46100-tier-c-dropped" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the most painful fall from the re-audit. <code>RubyLLM.chat(model:, provider:)</code> is correct, but history is replayed via <code>c.user(msg)</code> / <code>c.assistant(msg)</code>, fluent DSL that <strong>doesn&rsquo;t exist</strong> in RubyLLM (confirmed via grep of the gem source).</p>
<p>Worse: every HTTP request constructs a new <code>ChatSession.new</code> that discards history. The two bugs mask each other: the invented DSL rarely fires because there&rsquo;s never any history to replay.</p>
<p>Stimulus controller uses <code>fetch</code> + manual <code>innerHTML</code>. SSE-based but not Turbo Streams.</p>
<p>Per Z.ai itself, <a href="https://openrouter.ai/z-ai/glm-5.1/benchmarks"target="_blank" rel="noopener">GLM 5.1 beats GPT 5.4 and Opus 4.6 on SWE-Bench Pro</a>. In this specific benchmark (Rails + RubyLLM + complete deliverables), it drops to Tier C. More evidence that benchmarks are specific.</p>
<h3>GLM 4.7 Flash bf16 local (52/100 Tier C)<span class="hx:absolute hx:-mt-20" id="glm-47-flash-bf16-local-52100-tier-c"></span>
    <a href="#glm-47-flash-bf16-local-52100-tier-c" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The local model that most dominates the RubyLLM API. Uses the fluent chain <code>.with_model().with_temperature().with_params().with_instructions().complete(&amp;block)</code>, all real API per gem source.</p>
<p><strong>Fatal bug</strong>: <code>gem &quot;ruby_llm&quot;</code> is in <code>group :development, :test</code> with <code>require: false</code>. Doesn&rsquo;t load in production. <code>NameError</code> at boot. Uses class-var <code>Message.all</code> (process-local).</p>
<h2>Grok: from Tier D to Tier B (4.20 → 4.3)<span class="hx:absolute hx:-mt-20" id="grok-from-tier-d-to-tier-b-420--43"></span>
    <a href="#grok-from-tier-d-to-tier-b-420--43" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Grok 4.3 entered the benchmark in May 2026 and deserves a separate section, because it&rsquo;s the biggest intra-family jump in the whole benchmark. Grok 4.20 scored 25/100 Tier D (still listed in the losers section right below). Grok 4.3 scores <strong>72/100 Tier B</strong>, ranking between Qwen 3.6 Plus (71) and Kimi K2.5 (69). 47 points of jump. Cost $1.74 per run (15m wall time, 1.0M input + 18K output + 2.2M cache_read tokens, 102 step finishes).</p>
<p>What it does well, and does well:</p>
<ul>
<li><strong>Correct RubyLLM API on all five calls</strong>: <code>RubyLLM::Chat.new(model:)</code> + <code>add_message(role:, content:)</code> + <code>chat.ask</code> + <code>response.content</code> + <code>RubyLLM::Error</code> rescue. Verified against the gem 1.14.1 source, no hallucinations. Counter-grep zeroed: <code>grep -nE &quot;RubyLLM::Client|chat\.complete|chat\.send_message|chat\.user|chat\.assistant&quot;</code> returns 0 hits.</li>
<li><strong>Cleanest controller in the benchmark</strong>: 48 lines, no service-object over-engineering, no invented fluent DSL.</li>
<li><strong>Real Turbo Streams</strong>: the server-side reactive parts work.</li>
<li><strong>Material deliverables</strong>: real README, real <code>compose.yaml</code>, working multi-stage Dockerfile, cookie-based session persistence.</li>
</ul>
<p>The killer weakness is awkward to describe: <strong>Stimulus is dead at runtime.</strong> The <code>app/javascript/application.js</code> is a one-liner comment, no <code>import &quot;./controllers&quot;</code>, no <code>Application.start()</code>. The compiled <code>app/assets/builds/application.js</code> weighs 48 bytes (just a sourcemap pointer). The <code>controllers/index.js</code> imports <code>./application</code>, which doesn&rsquo;t exist. Result: every <code>data-controller=&quot;chat&quot;</code> is inert. Enter-to-send, autoresize, autoscroll, clear-input — all silently broken. Phase 2 of the benchmark (boot validation) reported &ldquo;local boot OK&rdquo; without exercising the JS layer, so the gap slipped through.</p>
<p>Other smaller issues:</p>
<ul>
<li>Tests stub <code>RubyLLM.stub :chat</code> but the controller calls <code>RubyLLM::Chat.new</code>. The stub is bypassed, so the test either hits the network for real or fails for missing key.</li>
<li>Stale model pin: <code>anthropic/claude-3.7-sonnet</code> in code, README says &ldquo;latest Claude Sonnet&rdquo; (current is 4.7).</li>
<li>No <code>with_instructions</code> for system prompt.</li>
</ul>
<p>The direct cost comparison is unfavorable: $1.74/run for Grok 4.3 vs $0.30/run for Kimi K2.6 (Tier A, 87/100). That&rsquo;s <strong>5× more expensive for a worse result</strong>. Grok 4.3 sits in an awkward price/quality slot: good enough for Tier B, but in Tier B there are options 5× cheaper (Kimi K2.5 at $0.10) and in Tier A there are options at almost the same price (Kimi K2.6 at $0.30). xAI still has to find the lane where Grok makes economic sense.</p>
<p>The general read: Grok 4.3 shows the family materially improved generation over generation (47 points is a huge jump). But it hasn&rsquo;t reached the level of the models that dominate the top yet. Worth keeping an eye on the next generation.</p>
<h2>The losers (Tier C/D): MiniMax, Grok 4.20, Step, DeepSeek V3.2, GPT OSS<span class="hx:absolute hx:-mt-20" id="the-losers-tier-cd-minimax-grok-420-step-deepseek-v32-gpt-oss"></span>
    <a href="#the-losers-tier-cd-minimax-grok-420-step-deepseek-v32-gpt-oss" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">Score</th>
          <th style="text-align: center">Tier</th>
          <th>Reason</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Step 3.5 Flash</td>
          <td style="text-align: right">56</td>
          <td style="text-align: center">C</td>
          <td>Bypasses <code>ruby_llm</code> entirely with <code>Net::HTTP</code>; not compliant with prompt</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td style="text-align: right">43</td>
          <td style="text-align: center">C</td>
          <td>Invents <code>RubyLLM::Client.new</code> and <code>client.chat(messages: [...])</code>; tests mock a class that doesn&rsquo;t exist</td>
      </tr>
      <tr>
          <td>MiniMax M2.7</td>
          <td style="text-align: right">41</td>
          <td style="text-align: center">C</td>
          <td>Invents <code>RubyLLM.chat(model:, messages: [...])</code> batch signature; crashes on first call</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td style="text-align: right">37</td>
          <td style="text-align: center">D</td>
          <td>Uses <code>Openrouter::Client</code> (wrong casing); calls <code>client.chat</code> on a method the gem doesn&rsquo;t expose</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td style="text-align: right">32</td>
          <td style="text-align: center">D</td>
          <td>Invents <code>RubyLLM::Client.new</code> + <code>client.chat(messages:)</code> + OpenAI-shaped response</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td style="text-align: right">25</td>
          <td style="text-align: center">D</td>
          <td><code>ruby-openai</code> in <code>:development, :test</code> with <code>require: false</code> → NameError in production; uncompilable Stimulus JS</td>
      </tr>
      <tr>
          <td>GPT OSS 20B</td>
          <td style="text-align: right">11</td>
          <td style="text-align: center">D</td>
          <td>No tests folder; nested <code>app/app/</code>; invents <code>RubyLLM::Client.new</code></td>
      </tr>
  </tbody>
</table>
<h2>Models that didn&rsquo;t complete the benchmark<span class="hx:absolute hx:-mt-20" id="models-that-didnt-complete-the-benchmark"></span>
    <a href="#models-that-didnt-complete-the-benchmark" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The ranking above lists the 24 models that <strong>completed</strong> the benchmark enough to be audited. But the benchmark covers more than that. In total, 34+ were configured, 28+ ran, and only 17-24 (depending on the criterion) completed enough to become auditable code. The ones that failed deserve to be logged:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Harness</th>
          <th>Problem</th>
          <th>Root cause</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Gemma 4 31B (local, llama.cpp)</td>
          <td>local</td>
          <td>Infinite tool-call loop after ~11 steps</td>
          <td><a href="https://github.com/ggml-org/llama.cpp/issues/21375"target="_blank" rel="noopener">llama.cpp bug #21375</a>, partially fixed in b8665</td>
      </tr>
      <tr>
          <td>Gemma 4 31B (Ollama Cloud)</td>
          <td>cloud</td>
          <td>504 timeout at ~20K tokens</td>
          <td>Cloudflare 100s edge timeout; benchmark runs pass 50K tokens, no way</td>
      </tr>
      <tr>
          <td>Llama 4 Scout (local)</td>
          <td>local</td>
          <td>Tool calls emitted as plain text</td>
          <td>llama.cpp has no parser for Llama 4&rsquo;s pythonic format (vLLM does)</td>
      </tr>
      <tr>
          <td>Qwen 3 32B (local)</td>
          <td>local</td>
          <td>Too slow (7.3 tok/s)</td>
          <td>Hardware bottleneck (fits in VRAM, bandwidth doesn&rsquo;t deliver)</td>
      </tr>
      <tr>
          <td>Qwen 2.5 Coder 32B (local)</td>
          <td>local</td>
          <td>90-minute timeout with zero files</td>
          <td>Infinite reasoning loop without calling the write tools</td>
      </tr>
      <tr>
          <td>GPT 5.4 Pro (OpenRouter)</td>
          <td>cloud</td>
          <td>Stalled after tool-calls</td>
          <td>OpenRouter tool-calling integration broken for GPT; use Codex CLI instead</td>
      </tr>
  </tbody>
</table>
<p>Also tested but didn&rsquo;t make the final comparison for various reasons: Qwen 3.5 27B Claude-distilled (covered in the Qwen section as an example of distillation not transferring library knowledge), Qwen 3 Coder 30B (returned a hardcoded mock string instead of calling RubyLLM), Qwen 3.5 27B Sushi Coder RL (llama-swap infra failure), Qwen 3.6 35B local (best Qwen local result, but missing <code>.content</code> on the return, 1-line fix to work).</p>
<p>Practical lesson from these failures: <strong>half the challenge of running open source locally in 2026 lives in the toolchain</strong>. llama.cpp bugs, Ollama lifecycle, missing tool-call parsers, Ollama Cloud Cloudflare timeouts. Even if the model is good, if the stack that runs it hangs every 11 tool calls, in practice you can&rsquo;t use it.</p>
<h2>The Chinese situation: the gap in practice<span class="hx:absolute hx:-mt-20" id="the-chinese-situation-the-gap-in-practice"></span>
    <a href="#the-chinese-situation-the-gap-in-practice" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The benchmark covers essentially every Chinese LLM family with recent releases: <strong>Moonshot</strong> (Kimi), <strong>DeepSeek</strong>, <strong>Xiaomi</strong> (MiMo), <strong>Alibaba</strong> (Qwen), <strong>Z.ai</strong> (GLM), <strong>MiniMax</strong> and <strong>StepFun</strong> (Step). Worth consolidating the whole picture, because the &ldquo;China has caught up&rdquo; narrative is more optimistic than this benchmark sustains.</p>
<h3>Distribution by tier<span class="hx:absolute hx:-mt-20" id="distribution-by-tier"></span>
    <a href="#distribution-by-tier" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Tier A (80+)</strong>: <strong>Kimi K2.6</strong> (87) and <strong>DeepSeek V4 Pro</strong> via DeepClaude (89, with the caveat that it requires a custom harness).</li>
<li><strong>Tier B (60-79)</strong>: Kimi K2.5 (69), DeepSeek V4 Flash (78), Xiaomi MiMo V2.5 Pro (67), Qwen 3.6 Plus (71), GLM 5 (64). DeepSeek V4 Pro in opencode scored 69 but with mixed authorship (reroute to Opus fallback), so unmeasurable in that harness.</li>
<li><strong>Tier C (40-59)</strong>: Step 3.5 Flash (56), GLM 4.7 Flash local (52), GLM 5.1 (46), DeepSeek V3.2 (43), MiniMax M2.7 (41).</li>
<li><strong>Tier D (&lt;40)</strong>: Qwen 3.5 122B local (37), Qwen 3 Coder Next local (32).</li>
</ul>
<p>Out of 13 Chinese models tested (counting both local and cloud), <strong>two</strong> reach Tier A: Kimi K2.6 with no caveats and DeepSeek V4 Pro only through DeepClaude. That&rsquo;s the current penetration in this specific benchmark.</p>
<h3>The gap in points<span class="hx:absolute hx:-mt-20" id="the-gap-in-points"></span>
    <a href="#the-gap-in-points" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>Kimi K2.6 vs Opus 4.7 (Tier A vs Tier A)</strong>: 87 vs 97. 10-point gap. In practice, both deliver correct RubyLLM, real-signature FakeChat, error rescue, multi-worker-safe session cookie, complete Gemfile. What Opus 4.7 has extra are secondary dimensions that accumulate: tests that cover error wrapping, model/provider override and explicit system prompt application; redundant rescue in the controller beyond the service; slightly better concerns separation. Perceptible differences side by side, but not tier-separated.</p>
<p>Cost: K2.6 $0.30/run vs Opus 4.7 $1.10/run. <strong>3.6× cheaper.</strong> In continuous production runs, that difference accumulates.</p>
<p><strong>Chinese Tier B vs Claude Opus 4.6 (83 Tier A)</strong>: 5 to 20-point gap. The Chinese Tier B models (MiMo, DeepSeek V4 Flash, Kimi K2.5, Qwen 3.6 Plus, GLM 5) have correct or near-correct RubyLLM code, but fail on specific components:</p>
<ul>
<li><strong>Test quality</strong> is the universal weakest spot. Chinese Tier B models often write many tests (K2.5 wrote 37, the most in the benchmark) but don&rsquo;t mock RubyLLM. Coverage theater.</li>
<li><strong>Persistence</strong> frequently uses process-local Singleton (MiMo) or class-var (K2.5) instead of session cookie or Rails.cache. Dies on restart, not multi-worker safe.</li>
<li><strong>Error handling</strong> is usually absent. Rate limit becomes 500 with a stack trace visible to the user.</li>
</ul>
<h3>Patterns by family<span class="hx:absolute hx:-mt-20" id="patterns-by-family"></span>
    <a href="#patterns-by-family" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>Moonshot (Kimi)</strong>: the most disciplined Chinese family. K2.5 at Tier B and K2.6 at Tier A. Real generational evolution in the dimensions that matter (tests, rescue, persistence).</p>
<p><strong>DeepSeek</strong>: pattern of tool support lagging. Each generation has better RubyLLM code than the previous (V3.2 invented everything, V4 Flash writes correct, V4 Pro writes perfect). In ai-sdk harnesses (opencode included), V4 Pro stays incompatible because of the <code>reasoning_content</code> echo protocol. But via DeepClaude (Claude Code with env vars swapping the endpoint), the same model delivers Tier A at 89/100. The marketing matches the product, as long as you use the right harness.</p>
<p><strong>Xiaomi (MiMo)</strong>: writes the most <strong>idiomatic</strong> RubyLLM code in the benchmark (persistent <code>@chat</code>, zero manual <code>add_message</code>). But forgets real tests, rescue, robust persistence. Lands at Tier B with prototype-demo quality, not production.</p>
<p><strong>Alibaba (Qwen)</strong>: high variance. Qwen 3.6 Plus cloud reaches Tier B. Qwen 3.5 35B local at Tier C with patches. Qwen 3.5 122B and the &ldquo;Coders&rdquo; (Qwen 3 Coder Next, Qwen 2.5 Coder 32B) fall to Tier D for inventing API or hanging. &ldquo;Coder&rdquo; in the name isn&rsquo;t a guarantee for coding agent.</p>
<p><strong>Z.ai (GLM)</strong>: GLM 5 at reasonable Tier B. GLM 5.1 <strong>regressed</strong> to Tier C, with invented fluent DSL plus history discard. Z.ai claims superiority in SWE-Bench Pro, but this specific benchmark caught two structural bugs. GLM 4.7 Flash local gets close but the gem in the <code>:test</code> group kills it in production.</p>
<p><strong>MiniMax</strong> and <strong>StepFun (Step)</strong> don&rsquo;t deliver. MiniMax invented a batch signature that crashes on first call. Step bypasses the <code>ruby_llm</code> gem entirely with <code>Net::HTTP</code>, violating the prompt.</p>
<h3>Conclusion on the Chinese gap<span class="hx:absolute hx:-mt-20" id="conclusion-on-the-chinese-gap"></span>
    <a href="#conclusion-on-the-chinese-gap" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>For this specific benchmark (Rails + RubyLLM + complete deliverables):</p>
<ul>
<li><strong>Two Chinese models</strong> deliver Tier A: <strong>Kimi K2.6</strong> (87/100, 10-point gap vs Opus, 3.6× cheaper) and <strong>DeepSeek V4 Pro</strong> (89/100 through DeepClaude — needs the custom harness, but fits within the budget of anyone who already has an <code>OPENROUTER_API_KEY</code>).</li>
<li><strong>Five Chinese models</strong> deliver Tier B usable with 1-2h of patching: K2.5, V4 Flash, MiMo, Qwen 3.6 Plus, GLM 5.</li>
<li><strong>The rest are not usable</strong> in production for this kind of task.</li>
</ul>
<p>The narrative of &ldquo;China has already caught up with the West in coding LLMs&rdquo; needs to be read with caveats. On synthetic reasoning benchmarks, maybe. On the benchmark of delivering a complete Rails app with every part functional? Two models caught up, with the caveat that one of them (V4 Pro) only works in a non-default harness. The others are still a generation behind.</p>
<h2>The reality of running open source locally<span class="hx:absolute hx:-mt-20" id="the-reality-of-running-open-source-locally"></span>
    <a href="#the-reality-of-running-open-source-locally" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every viable open source in the benchmark landed at Tier C or worse. Worth explaining why.</p>
<h3>VRAM + KV Cache: the math nobody does<span class="hx:absolute hx:-mt-20" id="vram--kv-cache-the-math-nobody-does"></span>
    <a href="#vram--kv-cache-the-math-nobody-does" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Take Qwen3 32B. FP16 takes ~64 GB. Quantized to Q4, it drops to ~19 GB. So it fits on a 32 GB RTX 5090, right? <strong>Wrong.</strong> That&rsquo;s only the model weights. You&rsquo;re missing the KV Cache.</p>
<p>KV Cache is the memory the model uses to &ldquo;remember&rdquo; what it already read. It scales <strong>linearly</strong> with context:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>KV Memory = 2 × Layers × KV_Heads × Head_Dim × Bytes_per_Element × Context_Tokens</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For a Llama 3.1 70B in BF16, that&rsquo;s ~0.31 MB per token. A 128K-token context = <strong>40 GB</strong> of KV Cache alone. In real benchmark runs, models consumed between 39K and 156K tokens. Less than 100K context isn&rsquo;t practical for a coding agent.</p>
<p>Google published <a href="https://research.google/blog/turboquant-redefining-ai-efficiency-with-extreme-compression/"target="_blank" rel="noopener">TurboQuant</a> (ICLR 2026), which compresses KV Cache to 3 bits without accuracy loss, 6× reduction and up to 8× speedup. Not yet implemented in llama.cpp or Ollama.</p>
<h3>Memory bandwidth rules, capacity doesn&rsquo;t<span class="hx:absolute hx:-mt-20" id="memory-bandwidth-rules-capacity-doesnt"></span>
    <a href="#memory-bandwidth-rules-capacity-doesnt" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>&ldquo;But I have 128 GB of RAM!&rdquo; Nice, but what matters is memory bandwidth. Brutal differences:</p>
<table>
  <thead>
      <tr>
          <th>Memory</th>
          <th style="text-align: right">Bandwidth</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DDR4</td>
          <td style="text-align: right">~50 GB/s</td>
      </tr>
      <tr>
          <td>LPDDR5x (AMD Strix)</td>
          <td style="text-align: right">~256 GB/s</td>
      </tr>
      <tr>
          <td>GDDR6 (RTX 3090)</td>
          <td style="text-align: right">~936 GB/s</td>
      </tr>
      <tr>
          <td>GDDR7 (RTX 5090)</td>
          <td style="text-align: right">~1,792 GB/s</td>
      </tr>
      <tr>
          <td>HBM3 (Mac Studio M4 Ultra)</td>
          <td style="text-align: right">~800 GB/s</td>
      </tr>
  </tbody>
</table>
<p>RTX 5090 has 7× more bandwidth than my Minisforum&rsquo;s LPDDR5x. On the AMD, Qwen3 32B runs at ~7 tok/s; on the 5090, it would be much faster if it fit.</p>
<p>For Mac Studio M4 Ultra with 512 GB of unified memory (~$10k), it&rsquo;s practical but pricey. For AMD Ryzen AI Max with LPDDR5x 256 GB/s, it&rsquo;s accessible but slow. For DDR4 desktop, it&rsquo;s unviable.</p>
<h3>Ollama vs llama.cpp: each with its own problems<span class="hx:absolute hx:-mt-20" id="ollama-vs-llamacpp-each-with-its-own-problems"></span>
    <a href="#ollama-vs-llamacpp-each-with-its-own-problems" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://ollama.com/"target="_blank" rel="noopener">Ollama</a> failed 6 of 8 local benchmark attempts: mid-session model unloading, context drift, flaky lifecycle, broken bf16. I migrated to <a href="https://github.com/mostlygeek/llama-swap"target="_blank" rel="noopener">llama-swap</a> (Go wrapper around llama-server). It fixed the lifecycle but brought other problems: each model needs specific flags (GLM/Qwen 3.5 need <code>--reasoning-format none</code> for <code>&lt;think&gt;</code> tags), tool-call parsers depend on the model (Llama 4&rsquo;s pythonic format doesn&rsquo;t parse), Gemma 4 requires build b8665+ and still enters repetition loops after ~11 tool calls.</p>
<p>Plug-and-play it isn&rsquo;t.</p>
<h2>The harness also matters<span class="hx:absolute hx:-mt-20" id="the-harness-also-matters"></span>
    <a href="#the-harness-also-matters" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><strong>The same Opus 4.7 model produced measurably worse code on Claude Code than on opencode.</strong> The difference is harness context pollution. Claude Code uses 6-11M cache-read tokens per run (vs ~210K on opencode) and that nudges the model toward generic patterns like the OpenAI SDK instead of RubyLLM-specific.</p>
<p>Practical translation: even with the same model, the tool you invoke it through changes the output. For stable model testing, I ran everything on opencode.</p>
<h2>Multi-model isn&rsquo;t worth it for greenfield coding<span class="hx:absolute hx:-mt-20" id="multi-model-isnt-worth-it-for-greenfield-coding"></span>
    <a href="#multi-model-isnt-worth-it-for-greenfield-coding" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Question that shows up every week in my feed: is it worth configuring two models in the same project? Opus for planning, GLM for executing, Haiku for boilerplate? I tested 7 combinations across 3 harnesses:</p>
<ul>
<li>Claude Code: Opus 4.7 alone (baseline), Opus 4.7 + Sonnet 4.6 subagent, Opus 4.7 + Haiku 4.5 subagent</li>
<li>opencode: Opus 4.7 + GLM 5.1 subagent, Opus 4.7 + Qwen 3.6 local subagent</li>
<li>Codex: GPT 5.4 xHigh + GPT 5.4 medium, GPT 5.4 xHigh + GPT 5.4 low</li>
</ul>
<p><strong>Zero of the 7 runs voluntarily delegated to the subagent.</strong> The main model did 100% of the work alone in every case, even with the subagent declared with aggressive language like &ldquo;Use PROACTIVELY&rdquo; and &ldquo;ALWAYS delegate to this agent for code implementation&rdquo;. Three reasons:</p>
<h3>Reason 1: Tier A models already plan-then-execute internally<span class="hx:absolute hx:-mt-20" id="reason-1-tier-a-models-already-plan-then-execute-internally"></span>
    <a href="#reason-1-tier-a-models-already-plan-then-execute-internally" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Opus 4.7 and GPT 5.4 xHigh recognize when a task calls for planning + implementation and already do that <strong>within the same thought session</strong>, without external context switch. The &ldquo;let&rsquo;s split this into design + code&rdquo; reasoning happens internally. Externalizing that with two separate models breaks the continuous context and gains nothing.</p>
<h3>Reason 2: coordination costs are high<span class="hx:absolute hx:-mt-20" id="reason-2-coordination-costs-are-high"></span>
    <a href="#reason-2-coordination-costs-are-high" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Subagents receive a reduced prompt, partial context, and have to return structured output the main model consumes. All that back-and-forth adds tokens, latency, and opportunities for misalignment. The main model prefers to absorb the cost of doing it alone rather than pay the cost of coordinating with another model. This is rational: economically, the subagent would only pay off if it were much cheaper AND produced equivalent output. Usually it&rsquo;s cheaper <strong>but worse</strong>.</p>
<h3>Reason 3: there&rsquo;s no clean plan-vs-execute line in greenfield<span class="hx:absolute hx:-mt-20" id="reason-3-theres-no-clean-plan-vs-execute-line-in-greenfield"></span>
    <a href="#reason-3-theres-no-clean-plan-vs-execute-line-in-greenfield" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>On a Rails app from scratch, you plan while implementing, revise decisions when you hit concrete problems, rebalance trade-offs along the way. Splitting that artificially between two agents forces a separation that doesn&rsquo;t reflect how the work actually happens on a cohesive task.</p>
<h3>The &ldquo;Opus plans, Qwen executes&rdquo; case<span class="hx:absolute hx:-mt-20" id="the-opus-plans-qwen-executes-case"></span>
    <a href="#the-opus-plans-qwen-executes-case" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The worst combination was Opus + Qwen 3.6 local. Opus is Tier A on RubyLLM, Qwen is Tier C. The theory: Opus (expensive) plans, Qwen (free) executes. In practice:</p>
<ol>
<li>Opus didn&rsquo;t delegate. It did everything alone.</li>
<li>If it had delegated, the Qwen code would come out Qwen-quality (invents API, no correct mocks).</li>
<li>Opus ↔ Qwen coordination isn&rsquo;t free.</li>
</ol>
<p>Three false assumptions in the theory. Practical conclusion: <strong>if you&rsquo;re going to pay Opus to plan, let it do everything</strong>. That&rsquo;s what it wants to do naturally. Forcing delegation to an inferior model adds coordination without reducing cost and makes the result worse.</p>
<h3>When multi-model does make sense<span class="hx:absolute hx:-mt-20" id="when-multi-model-does-make-sense"></span>
    <a href="#when-multi-model-does-make-sense" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Not on a cohesive greenfield task. It makes sense on separate pipelines where each model has a well-defined scope: fast classifier filtering input, large model processing only the subset that passed the filter, small model translating output. That&rsquo;s not &ldquo;Opus plans Qwen executes&rdquo; in the same agent session, it&rsquo;s multi-service architecture with different APIs.</p>
<p>For interactive coding agent on a real project, the rule of thumb is: pick one good Tier A model, use it alone, optimize the prompt instead of the orchestration.</p>
<h2>Best commercial, best open source<span class="hx:absolute hx:-mt-20" id="best-commercial-best-open-source"></span>
    <a href="#best-commercial-best-open-source" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Commercial<span class="hx:absolute hx:-mt-20" id="commercial"></span>
    <a href="#commercial" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>Tier A premium</strong>: Claude Opus 4.7, GPT 5.4 xHigh (Codex), GPT 5.5 xHigh (Codex), Claude Opus 4.6. Pick by behavior preference; code quality is there on all of them. Among the GPTs, 5.5 costs 40% less than 5.4 at the same output.</p>
<p><strong>Tier A cost-effective</strong>: Kimi K2.6 ($0.30/run), Gemini 3.1 Pro ($0.40/run). 3-4× cheaper than Opus/GPT with comparable quality within this benchmark.</p>
<p><strong>Tolerable Tier B</strong>: Claude Sonnet 4.6 ($0.63), DeepSeek V4 Flash ($0.01), Xiaomi MiMo V2.5 Pro ($0.14). Needs 1-2h of patching to ship to production.</p>
<p><strong>Cheapest useful</strong>: DeepSeek V4 Flash ($0.01/run) with the <code>anthropic/</code> prefix fix on the model slug.</p>
<h3>Open source (local)<span class="hx:absolute hx:-mt-20" id="open-source-local"></span>
    <a href="#open-source-local" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>No Tier A.</strong> The best local that ran was <strong>Qwen 3.5 35B-A3B</strong> at Tier C (55/100), and it needs 1-2 correction prompts to deliver working code. For those with an RTX 5090 who want to escape vendor lock-in, that&rsquo;s the model to run, but with hobby/lab expectations, not production.</p>
<p><strong>Qwen 3.6 35B local</strong> is the closest to working I&rsquo;ve seen (1-line fix to add <code>.content</code>), but still without multi-turn.</p>
<p><strong>GLM 4.7 Flash local</strong> is the most RubyLLM-literate local, but the gem in the wrong group kills the app in production. Trivial fix, structural impediment.</p>
<p><strong>In 2026, running open source locally for coding agent is viable with caution, on high-end hardware (RTX 5090 or Mac M4 Ultra), accepting 1-3 corrections per run.</strong> It&rsquo;s not a substitute for Claude yet.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Claude Opus 4.6 is still my daily pick for coding agent on real projects. Predictable behavior, defensive code, real mocks, sensible persistence, error handling.</p>
<p>Opus 4.7 leads the objective benchmark (97/100) but has a behavior downgrade (heavier tokenizer, aggressive resource optimization). On benchmark it delivers. In daily use I prefer 4.6.</p>
<p>GPT 5.4 xHigh via Codex ties at the top (97/100), and <strong>GPT 5.5 xHigh landed third at 96/100</strong>: same quality as 5.4, but 40% cheaper and 20% faster. For anyone already on Codex, 5.5 replaces 5.4 with no regression. At 10× Opus&rsquo;s price (used to be 15×) for essentially tied quality, it&rsquo;s still pricey for continuous use.</p>
<p>The cost-effective sweet spot now is <strong>Kimi K2.6 at $0.30/run</strong> or <strong>Gemini 3.1 Pro at $0.40/run</strong>. Both Tier A. 3-4× cheaper than Opus.</p>
<p>Local open source hasn&rsquo;t reached Tier A yet. The best is Qwen 3.5 35B-A3B at Tier C with corrections. For experimentation, worth it. For production, not yet.</p>
<p>The biggest lesson from the re-audit is about process: <strong>when you start classifying models based on &ldquo;hallucinations&rdquo; you think you found, go to the library source and check directly</strong>. I was discarding models for valid API calls. Once I checked against the gem, Kimi, Gemini, DeepSeek V4 Flash and GPT 5.4 all improved. And once the criterion included deliverables (docker-compose, substantive README, bundle-audit), models that looked clean but delivered incomplete projects (Step 3.5 Flash via bypass) fell to the tier that reflected that. And the DeepSeek V4 Pro case showed that harness choice can be as decisive as the model: under opencode the run is mixed authorship (Opus fallback underneath) and unmeasurable; through DeepClaude the same model delivers Tier A.</p>
<p>Benchmark is a tool, not truth. What I test here is a specific Rails app with a specific library. Models may perform differently on other tasks. The methodology is explicit in <a href="https://github.com/akitaonrails/llm-coding-benchmark/blob/master/docs/audit_prompt_template.md"target="_blank" rel="noopener"><code>audit_prompt_template.md</code></a> for anyone who wants to replicate, adapt, or challenge it.</p>
<p>The <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">repo</a> is public with all the success_reports and diffs.</p>
<h2>Sources<span class="hx:absolute hx:-mt-20" id="sources"></span>
    <a href="#sources" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><a href="https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7"target="_blank" rel="noopener">Claude Opus 4.7 — What&rsquo;s new</a>, Anthropic</li>
<li><a href="https://platform.claude.com/docs/en/about-claude/models/migration-guide"target="_blank" rel="noopener">Migration guide Claude 4.6 → 4.7</a>, new tokenizer</li>
<li><a href="https://github.com/anthropics/claude-code/issues/52368"target="_blank" rel="noopener">GitHub issue #52368 — Opus 4.7 instability</a></li>
<li><a href="https://dev.to/vibeagentmaking/why-we-switched-back-from-claude-opus-47-to-46-47f9"target="_blank" rel="noopener">DEV.to — Why We Switched Back from Opus 4.7 to 4.6</a></li>
<li><a href="https://openai.com/index/introducing-gpt-5-5/"target="_blank" rel="noopener">OpenAI — Introducing GPT-5.5</a></li>
<li><a href="https://community.openai.com/t/gpt-5-5-is-here-available-in-codex-and-chatgpt-today/1379630"target="_blank" rel="noopener">OpenAI Community — GPT-5.5 is here in Codex</a></li>
<li><a href="https://simonwillison.net/2026/Apr/23/gpt-5-5/"target="_blank" rel="noopener">Simon Willison — GPT-5.5 via Codex</a></li>
<li><a href="https://openrouter.ai/z-ai/glm-5.1/benchmarks"target="_blank" rel="noopener">GLM 5.1 benchmarks on OpenRouter</a></li>
<li><a href="https://www.buildfastwithai.com/blogs/glm-5-1-open-source-review-2026"target="_blank" rel="noopener">GLM 5.1 review — Build Fast With AI</a></li>
<li><a href="https://huggingface.co/Jackrong/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled"target="_blank" rel="noopener">Jackrong&rsquo;s Qwen 3.5 27B Claude Opus Distilled</a></li>
<li><a href="https://research.google/blog/turboquant-redefining-ai-efficiency-with-extreme-compression/"target="_blank" rel="noopener">TurboQuant (Google Research, ICLR 2026)</a></li>
<li><a href="https://x.com/TheAhmadOsman/status/2040103488714068245"target="_blank" rel="noopener">Ahmad Osman — GPU Memory Math for LLMs (2026)</a></li>
<li><a href="https://github.com/mostlygeek/llama-swap"target="_blank" rel="noopener">llama-swap</a>, llama.cpp wrapper</li>
<li><a href="https://github.com/ggml-org/llama.cpp/issues/21375"target="_blank" rel="noopener">llama.cpp issue #21375 — Gemma 4 tool call loops</a></li>
</ul>
]]></content:encoded><category>llm</category><category>benchmark</category><category>claude</category><category>ai</category><category>vibecoding</category><category>open-source</category><category>self-hosting</category></item><item><title>How DriveClub and shadPS4 Almost Defeated AI and Me: How to Learn</title><link>https://akitaonrails.github.io/en/2026/04/23/driveclub-shadps4-e-ia-como-aprender/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/23/driveclub-shadps4-e-ia-como-aprender/</guid><pubDate>Thu, 23 Apr 2026 12:00:00 GMT</pubDate><description>&lt;h2&gt;Where we were&lt;span class="hx:absolute hx:-mt-20" id="where-we-were"&gt;&lt;/span&gt;
&lt;a href="#where-we-were" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Last week I &lt;a href="https://akitaonrails.github.io/en/2026/04/19/retrogames-de-corrida-favoritos-no-distrobox/"&gt;posted about my distrobox-gaming setup&lt;/a&gt;, with more than ten retro racing games running on Linux, from Gran Turismo 1 on DuckStation to Forza Motorsport 4 on Xenia. At the end of that piece, I described Driveclub on shadPS4 as the &amp;ldquo;final boss&amp;rdquo;: the game boots, the menu loads, the race starts, the controller responds.&lt;/p&gt;
&lt;p&gt;Except I mentioned two problems in passing and treated them as &amp;ldquo;accepted limitations&amp;rdquo;:&lt;/p&gt;</description><content:encoded><![CDATA[<h2>Where we were<span class="hx:absolute hx:-mt-20" id="where-we-were"></span>
    <a href="#where-we-were" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Last week I <a href="/en/2026/04/19/retrogames-de-corrida-favoritos-no-distrobox/">posted about my distrobox-gaming setup</a>, with more than ten retro racing games running on Linux, from Gran Turismo 1 on DuckStation to Forza Motorsport 4 on Xenia. At the end of that piece, I described Driveclub on shadPS4 as the &ldquo;final boss&rdquo;: the game boots, the menu loads, the race starts, the controller responds.</p>
<p>Except I mentioned two problems in passing and treated them as &ldquo;accepted limitations&rdquo;:</p>
<ol>
<li><strong>During daytime, the image is a bit darker than on a reference PS4.</strong> Not alarming, but noticeable.</li>
<li><strong>At night, the race start in Canada or Munnar went pitch black for 10 to 30 seconds</strong> before the game &ldquo;recovered on its own&rdquo; around the 1:30 mark. During those seconds, only the HUD was visible. Track, car, headlights — all absolute black.</li>
</ol>
<p>In the previous article I pushed the explanation to &ldquo;it&rsquo;s just not fixed in the emulator yet, I&rsquo;ll live with it&rdquo;. But inside I couldn&rsquo;t make myself play the game in a visually unplayable state. I wanted to play DriveClub at its best, and I didn&rsquo;t want to sit around waiting for the project to fix it for me. I decided to take matters into my own hands. Thirty seconds of absolute black isn&rsquo;t &ldquo;a bit darker&rdquo;. It&rsquo;s a serious bug, the kind that makes a game unplayable in practice.</p>
<p>This article is what happened between Sunday night and Thursday morning.</p>
<h2>The blackout, live<span class="hx:absolute hx:-mt-20" id="the-blackout-live"></span>
    <a href="#the-blackout-live" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/original-blackout.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>This video is the behavior I wanted to fix. Race start in India (Munnar), night race. You see the track, headlights turn on, the first few seconds run. Then it darkens progressively. Within about 15 seconds everything is black. Stays that way for another 60-90 seconds. Then the game &ldquo;recalibrates&rdquo; on its own and the track reappears.</p>
<p>If you&rsquo;re a player, you don&rsquo;t care why. You just want to drive.</p>
<h2>Why shadPS4 is hard to debug<span class="hx:absolute hx:-mt-20" id="why-shadps4-is-hard-to-debug"></span>
    <a href="#why-shadps4-is-hard-to-debug" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://shadps4.net/"target="_blank" rel="noopener">shadPS4</a> <strong>still has no stable release</strong>. The project is under active development. The main branch changes several times a week, configs migrate from TOML to JSON without notice, settings hide behind &ldquo;Advanced&rdquo; so users with incompatible hardware don&rsquo;t file issues, open PRs compete over different approaches to the same problem.</p>
<p>Anyone trying to configure DriveClub today will find:</p>
<ul>
<li>September/2025 Reddit guides using <code>readbacks = true</code> (it was a boolean back then) saying &ldquo;for DriveClub always enable readbacks&rdquo;.</li>
<li>November/2025 guides saying &ldquo;disable readbacks, it kills performance&rdquo; (because in 2025-07 readbacks became an enum <code>Disabled | Relaxed | Precise</code> with different performance profiles, and Bloodborne started hanging with it on).</li>
<li>January/2026 YouTube videos using some custom fork nobody documented.</li>
<li>A thread on shadps4.net with three different, mutually exclusive explanations for the blackout.</li>
<li>My own notes from last week in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/driveclub-shadps4.md"target="_blank" rel="noopener"><code>distrobox-gaming/docs/driveclub-shadps4.md</code></a> saying <code>readbacks_mode: 0</code>. The exact opposite of what I found this week.</li>
</ul>
<p>So the information exists, but it&rsquo;s scattered, dated, contradictory, and most of it depends on version details that changed between the original post and today. Reproducing a setup from a YouTube video is like trying to solve a Rubik&rsquo;s cube blindfolded.</p>
<h2>Spoiler: the solution is one integer<span class="hx:absolute hx:-mt-20" id="spoiler-the-solution-is-one-integer"></span>
    <a href="#spoiler-the-solution-is-one-integer" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After 31 phases of investigation, 44 commits on my shadPS4 fork, 15,668 lines of instrumental code added, and three days of nearly uninterrupted debugging, the answer is <strong>one line in the per-game config</strong>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;GPU&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;readbacks_mode&#34;</span><span class="p">:</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>readbacks_mode: 2</code> is the <code>Precise</code> mode. Explaining what it does requires understanding how DriveClub implements auto-exposure, and that&rsquo;s where the journey starts. To know WHY this toggle fixes things, you have to understand the whole chain.</p>
<p>The short version: <strong>DriveClub implements auto-exposure as a GPU→CPU feedback loop</strong>.</p>
<ol>
<li>The scene renders to an HDR target.</li>
<li>A compute shader calculates a luminance histogram into an SSBO.</li>
<li>The CPU <strong>reads</strong> that SSBO on the next frame and derives a target exposure.</li>
<li>The CPU writes that exposure back into a 1936-byte lighting UBO (at slots <code>[38] [48] [50]</code>).</li>
<li>Fragment shaders read that UBO and multiply scene luminance by it.</li>
</ol>
<p>Without <code>readbacks_mode: Precise</code>, step 3 reads stale zeros. The memory page where the GPU wrote the histogram is never synchronized to the CPU side. The CPU exposure integrator concludes &ldquo;scene is dark, open the aperture all the way&rdquo; and ramps its output value monotonically: <code>2.59 → 7.84 → 24 → 90 → 179 → 255</code>. Within 60-90 seconds the scale saturates so high that everything clips to zero. That&rsquo;s the pitch black.</p>
<p>With <code>readbacks_mode: Precise</code> on, the emulator marks the pages the GPU just wrote as kernel-protected (via <code>mprotect</code> on Linux). When the CPU tries to read one of those pages for the first time, a page fault fires. The emulator intercepts it, issues a <code>vkCmdCopyBuffer</code> download from the GPU buffer to the host staging area, waits on the scheduler, and the CPU finally sees fresh data. The real histogram enters the integrator, exposure converges normally, and the race start opens bright as it should.</p>
<p>Why isn&rsquo;t it the default? Three documented reasons. First, per-page <code>mprotect</code> + per-fault GPU stall + compute dispatch + scheduler wait has a real cost: on NVIDIA&rsquo;s warm path it&rsquo;s tolerable, on AMD it tanks below 12 FPS, with an <a href="https://github.com/shadps4-emu/shadPS4/issues/3322"target="_blank" rel="noopener">open issue</a> about it. Second, <a href="https://github.com/shadps4-emu/shadPS4/issues/3826"target="_blank" rel="noopener">Bloodborne hangs</a> on loading screens with Precise enabled. Third, the maintainer tucked the option behind &ldquo;Advanced&rdquo; in the UI precisely to keep users with sensitive hardware from enabling it by mistake. The mode has existed since <code>v0.15.0</code> (September/2025), but <strong>nobody had publicly connected <code>Precise</code> + DriveClub + auto-exposure feedback loop</strong>. The <a href="https://github.com/shadps4-emu/shadPS4/issues/3346"target="_blank" rel="noopener">compat tracker already admits</a> that DriveClub &ldquo;requires readbacks enabled to function properly&rdquo;, but doesn&rsquo;t specify the mode.</p>
<p>In my setup the performance cost is irrelevant. I have an RTX 5090 on my desktop, plenty of hardware to swallow the mprotect, the page fault, the copy and the stall per frame. The call is trivial: I flip Precise on, pay the price, the game runs. On more modest hardware or on AMD the conversation is different, which is exactly why this setting isn&rsquo;t default and hides behind &ldquo;Advanced&rdquo;.</p>
<p>One thing I want to make clear: this whole explanation above (<code>Precise</code> vs <code>Relaxed</code> vs <code>Disabled</code>, GPU→CPU feedback loop, per-page mprotect syscall) is not knowledge I had on Sunday night. I didn&rsquo;t know what a buffer readback was, didn&rsquo;t know DriveClub implemented auto-exposure as a GPU→CPU feedback loop, didn&rsquo;t know <code>readbacks_mode</code> had become an enum in 2025 with three values. To arrive at the right answer, I had to learn every piece from scratch. I couldn&rsquo;t guess. Had I tried to &ldquo;enable readbacks&rdquo; last week without understanding the chain, I&rsquo;d have picked the wrong value (Relaxed isn&rsquo;t enough for this feedback loop, as I&rsquo;ll explain below) and given up thinking &ldquo;it doesn&rsquo;t work&rdquo;.</p>
<p>Two more things need to be in place for this to work, both covered in the <a href="/en/2026/04/19/retrogames-de-corrida-favoritos-no-distrobox/#driveclub-the-impossible-finally-possible">previous article</a>:</p>
<ul>
<li><strong>v1.28 patch applied</strong> on top of the v1.00 base install (without it, content stays locked with &ldquo;not yet released&rdquo;).</li>
<li><strong>60fps XML patch disabled</strong> (it raises the render rate but the logic tickrate is fixed; with it on, the game runs in slow motion).</li>
</ul>
<h2>The result<span class="hx:absolute hx:-mt-20" id="the-result"></span>
    <a href="#the-result" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/canada-fixed.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>Same race in Canada, night, with <code>readbacks_mode: 2</code> on. Race start opens bright, auto-exposure converges immediately, the night transition unfolds naturally as the game&rsquo;s TOD (time of day) advances. No blackout, no &ldquo;recalibration&rdquo; at 1:30, no 30-second window of absolute black. This is DriveClub running like it does on PS4.</p>
<p>Now the interesting question is not &ldquo;which setting&rdquo;. It&rsquo;s: <strong>how did we get here?</strong></p>
<h2>How does a junior learn today?<span class="hx:absolute hx:-mt-20" id="how-does-a-junior-learn-today"></span>
    <a href="#how-does-a-junior-learn-today" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a question I hear often: &ldquo;with AI doing everything, how does a junior learn?&rdquo;</p>
<p>The premise is wrong. <strong>AI doesn&rsquo;t do everything.</strong> AI does what you ask it to. It doesn&rsquo;t discover on its own. It has no domain initiative. It&rsquo;s not capable of telling you &ldquo;hey, this looks like a broken GPU→CPU readback feedback loop&rdquo; unless you already know there&rsquo;s a thing called a readback and that broken feedback loops are a class of bug.</p>
<p>Worse: if you go to an AI agent today and ask &ldquo;fix this black screen in DriveClub on shadPS4&rdquo;, it will give you a list of possible answers based on old forum posts, with a high probability of steering you wrong. It will suggest the 60fps XML patch (wrong — causes slow motion). It will suggest a tonemap override (wrong — the game already writes correct SDR). It will suggest a vblank_frequency tweak (useless for the real problem). It will suggest manual gamma (treats the symptom, not the cause).</p>
<p>The way to discover is to <strong>dive into the chain</strong>, phase by phase, ruling out wrong hypotheses until the terrain is familiar enough that you recognize the right answer when it appears.</p>
<p>That&rsquo;s where a lot of people misread me. &ldquo;Akita is already a senior, he has 25+ years of experience, of course he knows.&rdquo; <strong>False.</strong> A senior is someone who has been a junior in every topic they master. And they keep becoming a junior every time they pick up a new domain. I have 25 years of web programming, distributed systems, Ruby, Erlang, Go. But <strong>I&rsquo;ve never programmed for PS4</strong>. I&rsquo;d never looked at the shadPS4 codebase. I didn&rsquo;t know PS4&rsquo;s graphics architecture. On Sunday I was an absolute junior in this domain.</p>
<p>But junior doesn&rsquo;t mean totally green. A while back I had explored on my YouTube channel the low-level architecture of older consoles (from the NES&rsquo;s 6502 onward) and how emulators work internally in general. The two videos below helped build the basic vocabulary for how a game console works under the hood, and made sure I wouldn&rsquo;t trip on concepts like fetch-decode-execute, opcode, interrupt, or HLE vs LLE.</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/hYJ3dvHjeOE"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>



<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/vUqLLpUJ47s"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>I had also made an older video on the evolution of CPUs, GPUs, DirectX, and Vulkan, so at least the vocabulary of &ldquo;modern graphics pipeline&rdquo; and &ldquo;shading language&rdquo; wasn&rsquo;t alien.</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/JEp7ozWqIps"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>Knowing that <code>VkCommandBuffer</code> exists is very different from knowing where in shadPS4 a readback <code>vkCmdCopyBuffer</code> fires. But it gives you a base. That kind of old curiosity paid interest now.</p>
<h2>The rotation: Claude Code and Codex<span class="hx:absolute hx:-mt-20" id="the-rotation-claude-code-and-codex"></span>
    <a href="#the-rotation-claude-code-and-codex" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>AI (in my case <a href="https://claude.com/claude-code"target="_blank" rel="noopener">Claude Code</a>) didn&rsquo;t &ldquo;know&rdquo; more than I did. It has the same dated forum text you&rsquo;d find on Reddit. What it did well was <strong>aggregate</strong>: read shadPS4 source code in parallel with me, index comments, cross-reference between phase docs, compile, run probes, parse 61 MB logs, compare decompiled binary against UBO diffs, shine light on corners of the system faster than I could alone.</p>
<p>But the decision to &ldquo;keep investigating&rdquo; was mine. The perseverance of &ldquo;don&rsquo;t accept a mitigation, want the root cause&rdquo; was mine. And when the AI suggested &ldquo;honestly, we should just accept the current result and document it&rdquo; (and it suggested this <strong>several times</strong> over the four days), I was the one who had to say &ldquo;no, we keep going&rdquo;.</p>
<p>The central point about the rotation: <strong>no single LLM managed to close this problem alone.</strong></p>
<p>Over the four days, Claude Code repeatedly got stuck looping on the same hypotheses with no new direction. When that happened, I&rsquo;d switch to Codex for a few hours to bring in outside ideas, different probes, a re-read of the codebase from a fresh angle. Then back to Claude Code to integrate what Codex had surfaced. And so on, alternating.</p>
<p>The final resolution happened on Claude Code, but the journey switched models several times. Each LLM had its biases and blind spots. Left alone, each one would have given up sooner.</p>
<p>What broke the impasse was me continuing to bring in new ideas to try, forcing the model to reassess, switching models whenever one started to repeat itself.</p>
<p>At two points things went past &ldquo;just repeating hypotheses&rdquo;. Codex literally had an agentic-loop glitch: it started repeating the exact same &ldquo;solution&rdquo; it had just tried, over and over, in sequence, without noticing what it was doing. It was the first time I&rsquo;d ever seen that happen in any AI agent. I had to kill the session and start a fresh one from zero to break the cycle.</p>
<p>That says something about context size and session duration. The complexity and pace of this investigation were enough to bring both LLMs to their knees, each in their own way.</p>
<p>The whole journey below is documented in 33 phase docs on the <a href="https://github.com/akitaonrails/shadPS4/tree/gamma-debug/docs/driveclub-investigation"target="_blank" rel="noopener"><code>gamma-debug</code> branch of my shadPS4 fork</a>. Each section here links to the original doc for anyone who wants the complete detail. Here I summarize the essentials: the hypothesis we had, what we tried, what new concept we had to learn, and why the phase didn&rsquo;t close the case.</p>
<h2>Session zero: baseline and reproducibility<span class="hx:absolute hx:-mt-20" id="session-zero-baseline-and-reproducibility"></span>
    <a href="#session-zero-baseline-and-reproducibility" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every serious investigation starts before Phase 1. The first thing, for any project, is to make sure the <strong>baseline works as expected</strong> and that the situation you want to research is <strong>reproducible every time</strong>. If you can&rsquo;t reproduce the bug on demand, you&rsquo;re chasing ghosts. Every probe after that will be inconclusive because the bug &ldquo;appeared and vanished&rdquo; without control. And if the baseline is broken in some way you haven&rsquo;t noticed, every experiment measures the wrong problem. Without those two guarantees, you can&rsquo;t make progress.</p>
<p>In my case, on Sunday night I confirmed:</p>
<ul>
<li>The game boots all the way to the race screen without crashing, using the <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/driveclub-shadps4.md"target="_blank" rel="noopener"><code>distrobox-gaming</code></a> config.</li>
<li><a href="https://github.com/Nenkai/DriveClubFS"target="_blank" rel="noopener">DriveClubFS</a> extracts v1.28 cleanly, 8018 files, ~47 GB.</li>
<li>MSAA depth resolve applied on the fork; night tracks render shapes instead of uniform absolute black (covered in the <a href="/en/2026/04/19/retrogames-de-corrida-favoritos-no-distrobox/">previous article</a>).</li>
<li>8BitDo controller detected.</li>
<li>Night race in India 19:30 <strong>reproduces the blackout on 100% of attempts</strong>, with the same temporal pattern: progressive fade in the first ~15s, pitch black between ~20s and ~90s, &ldquo;recalibration&rdquo; at 1:30.</li>
</ul>
<p>That baseline is the only solid ground that lets me compare each experiment reliably. Without it, a probe that &ldquo;worked&rdquo; might just be a different cache state, not the real fix. With it, every change has a before and an after that are measurable.</p>
<p>Beyond that, zero specific knowledge of:</p>
<ul>
<li>PS4 PKG format (PFS, param.sfo, disc_info, keystone).</li>
<li>shadPS4 codebase (src/core, src/video_core, shader_recompiler, buffer_cache).</li>
<li>PS4 graphics pipeline (GCN ISA, forward+ lighting, MSAA depth resolve).</li>
<li>Vulkan beyond the surface (descriptor sets, UBO binding, push constants, pipeline cache).</li>
<li>SPIR-V disassembly and patching.</li>
<li>PS4 OELF loader, Itanium RTTI, SCE dynamic relocations.</li>
<li>mprotect-based page-fault tracking for CPU-GPU synchronization.</li>
</ul>
<h2>The journey, phase by phase<span class="hx:absolute hx:-mt-20" id="the-journey-phase-by-phase"></span>
    <a href="#the-journey-phase-by-phase" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ll group the 30+ phases into buckets that make sense as a narrative. Each phase links to the full doc for anyone who wants technical detail.</p>
<blockquote>
  <p><strong>Note for anyone who doesn&rsquo;t want to read all 30+ phases:</strong> after the phase sequence there are reflection and methodology sections you can read on their own. <a href="#the-shotgun-technique">The shotgun technique</a> explains the debugging methodology I used throughout the investigation. <a href="#what-i-knew-on-sunday-vs-thursday">What I knew on Sunday vs Thursday</a> sums up what I actually learned. <a href="#the-ai-suggested-accepting-the-perseverance-was-mine">The AI suggested accepting. The perseverance was mine</a> discusses AI&rsquo;s real role in this process. <a href="#the-real-cost-of-this-journey">The real cost of this journey</a> catalogs what got produced. If you want to jump straight to the argument, those are the shortcuts.</p>

</blockquote>
<h3>Phase 01: pipeline cache, the warm-up<span class="hx:absolute hx:-mt-20" id="phase-01-pipeline-cache-the-warm-up"></span>
    <a href="#phase-01-pipeline-cache-the-warm-up" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-01-shader-compile-stalls.md"target="_blank" rel="noopener"><code>phase-01-shader-compile-stalls.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> menus run at 2-3 fps on first launch. Must be Vulkan pipeline compilation happening in real time.</li>
<li><strong>Action:</strong> enable <code>pipeline_cache_enabled: true</code> in the global config.</li>
<li><strong>New concept:</strong> Vulkan pipeline caching. When the emulator translates a PS4 GCN shader into SPIR-V and then into driver-specific Vulkan binary, it can cache that binary. Without the cache, it recompiles every cold launch (~864 shaders + ~590 pipelines).</li>
<li><strong>Outcome:</strong> solved. First session pays the cost; subsequent sessions read from disk and launch immediately. Easy. Also my first lesson about the shadPS4 codebase.</li>
</ul>
<h3>Phase 02: gamma dim, starting in the wrong direction<span class="hx:absolute hx:-mt-20" id="phase-02-gamma-dim-starting-in-the-wrong-direction"></span>
    <a href="#phase-02-gamma-dim-starting-in-the-wrong-direction" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-02-gamma-dim-image.md"target="_blank" rel="noopener"><code>phase-02-gamma-dim-image.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> DriveClub&rsquo;s image is dim compared to reference. Must be wrong sRGB encoding or an HDR tonemap applied badly by the emulator.</li>
<li><strong>Action:</strong> instrumented the swapchain to log format, added <code>SHADPS4_PP_*</code> env vars to experiment, built a post-processing pipeline with three knobs (exposure, ACES tonemap, gamma curve).</li>
<li><strong>New concept:</strong> sRGB OETF, HDR tonemap (ACES vs Reinhard), Bayer dither as anti-banding.</li>
<li><strong>Outcome:</strong> via bypass path (<code>SHADPS4_PP_BYPASS=1</code>, writes raw game output with no post-processing) I discovered the <strong>game writes correct SDR already</strong> into the framebuffer. The emulator&rsquo;s post-processing pipeline was mangling the signal. Final shader became simply sRGB encode + Bayer dither. Dim persists, but it&rsquo;s not a gamma problem.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/wrong-gamma.png" alt="Gamma adjustments going wrong everywhere"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/wrong-gamma-blown.png" alt="Blown out: gamma too high"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/wrong-gamma-sky-dark.png" alt="Dark sky: gamma too low"  loading="lazy" /></p>
<p>Three hours on this phase. The most important lesson: <strong>when &ldquo;the image looks wrong&rdquo;, the first diagnostic should be a bypass path that shows raw game output</strong>. If bypass looks right, the emulator is the one mangling the signal. Stop touching the tonemap.</p>
<h3>Phase 03: v1.28 content access<span class="hx:absolute hx:-mt-20" id="phase-03-v128-content-access"></span>
    <a href="#phase-03-v128-content-access" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-03-v128-content-access.md"target="_blank" rel="noopener"><code>phase-03-v128-content-access.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> v1.28 extracts, but the game says &ldquo;content not released yet, download required&rdquo;.</li>
<li><strong>Action:</strong> merge v1.28 on top of v1.00, restore <code>param.sfo</code>, <code>disc_info.dat</code>, <code>keystone</code>.</li>
<li><strong>New concept:</strong> PS4 cumulative patches, package metadata.</li>
<li><strong>Outcome:</strong> solved. Detailed in the <a href="/en/2026/04/19/retrogames-de-corrida-favoritos-no-distrobox/">previous article</a>.</li>
</ul>
<h3>Phase 04: MSAA depth + slow motion<span class="hx:absolute hx:-mt-20" id="phase-04-msaa-depth--slow-motion"></span>
    <a href="#phase-04-msaa-depth--slow-motion" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-04-slowness.md"target="_blank" rel="noopener"><code>phase-04-slowness.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> night tracks are pitch black and the game &ldquo;feels slow&rdquo;.</li>
<li><strong>Action:</strong> two separate things. Disable the 60fps XML patch (which was decoupling render rate from logic tickrate). Implement <code>ReinterpretMsDepthAsColor</code> on my fork (to let the emulator resolve 4x MSAA depth to 1-sample color, which DriveClub needs for forward+ lighting on night tracks).</li>
<li><strong>New concept:</strong> forward+ lighting (a renderer that writes the scene to an MSAA depth target and then reads it as color for SSAO and volumetrics), MSAA depth resolve.</li>
<li><strong>Outcome:</strong> both fixed. Detailed in the <a href="/en/2026/04/19/retrogames-de-corrida-favoritos-no-distrobox/">previous article</a>.</li>
</ul>
<h3>Phase 05: second pass on brightness<span class="hx:absolute hx:-mt-20" id="phase-05-second-pass-on-brightness"></span>
    <a href="#phase-05-second-pass-on-brightness" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-05-brightness-followup.md"target="_blank" rel="noopener"><code>phase-05-brightness-followup.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> since the game writes correct SDR, maybe a static boost in post-processing helps without touching the UBO feedback loop.</li>
<li><strong>Action:</strong> tried linear boost (1.5x, 2.0x), gamma pre-encode (pow curve), scene-aware auto-exposure with peak+mean sampling.</li>
<li><strong>Outcome:</strong> all rolled back. Linear boost clips highlights. Gamma introduces banding. My auto-exposure layer cancels the user&rsquo;s manual brightness slider. There&rsquo;s no free lunch in post-processing. Dim stays dim without real HDR or shader patches.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/wrong-gamma-sky-dark.png" alt="Sky too dark"  loading="lazy" /></p>
<h3>Narrowing the blackout: the shared gate<span class="hx:absolute hx:-mt-20" id="narrowing-the-blackout-the-shared-gate"></span>
    <a href="#narrowing-the-blackout-the-shared-gate" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/race-start-blackout-040420.md"target="_blank" rel="noopener"><code>race-start-blackout-040420.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> the blackout has a curious signature. Main view goes dim/black, rearview mirror goes absolute black, HUD stays bright. Three layers with different behaviors suggest a shared &ldquo;gate&rdquo;, not a common fade.</li>
<li><strong>Action:</strong> instrumented texture-cache aliasing, probed with live fragment shader substitution on suspected inputs.</li>
<li><strong>New concept:</strong> texture-cache aliasing (multiple images sharing a memory address, which the emulator cache reuses), render-target lifetime.</li>
<li><strong>Outcome:</strong> points to a shared &ldquo;presentation permission&rdquo; between the main scene and the mirror, not a scripted fade.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/screenshot-2026-04-20_21-22-45.png" alt="Blackout baseline"  loading="lazy" /></p>
<h3>Phases 07-09: texture torture, UI surgery, binary patch plan<span class="hx:absolute hx:-mt-20" id="phases-07-09-texture-torture-ui-surgery-binary-patch-plan"></span>
    <a href="#phases-07-09-texture-torture-ui-surgery-binary-patch-plan" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-07-aggressive-torture-probe.md"target="_blank" rel="noopener"><code>phase-07-aggressive-torture-probe.md</code></a> · <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-08-ui-asset-surgery.md"target="_blank" rel="noopener"><code>phase-08-ui-asset-surgery.md</code></a> · <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-09-eboot-binary-patch-plan.md"target="_blank" rel="noopener"><code>phase-09-eboot-binary-patch-plan.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> the race-start dim overlay is an asset. Small texture, UI panel, or a method of the <code>FreeplayGetInCar</code> controller in the eboot.</li>
<li><strong>Action:</strong> null-substituted BC-compressed materials, small texture candidates, four-vector weight masks during critical draws. Disabled UI panels (<code>loading_freeplay.txt</code>, <code>get_in_car_animation.txt</code>, <code>vehicle_select_background</code>). Extracted the OELF, located <code>FreeplayGetInCar</code> ASCII strings, identified the constructor at VA 0xf89b00 and the vtable at 0x15dc3e0.</li>
<li><strong>New concept:</strong> PS4 OELF loader (Sony&rsquo;s variant of ELF), Itanium C++ RTTI (how C++ encodes type info in vtables), SCE dynamic relocations (placeholders that only the dynamic loader resolves at runtime).</li>
<li><strong>Outcome:</strong> nulls work but the fade persists. The overlay renders directly into HDR scene targets, not through the panel system. Binary patching has relocation placeholders that aren&rsquo;t statically resolvable without a Ghidra + PS4 plugin. <strong>Asset-side surface exhausted.</strong> Six hours of debugging, final conclusion: the bug lives in state, not in an asset.</li>
</ul>
<h3>Phase 10: rethink + animlib<span class="hx:absolute hx:-mt-20" id="phase-10-rethink--animlib"></span>
    <a href="#phase-10-rethink--animlib" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-10a-rethink-040422.md"target="_blank" rel="noopener"><code>phase-10a-rethink-040422.md</code></a> · <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-10b-video-diagnostic-animlib.md"target="_blank" rel="noopener"><code>phase-10b-video-diagnostic-animlib.md</code></a></p>

</blockquote>
<p>Tuesday morning. Sat down, rebooted mentally. New model: the blackout is a <strong>state-machine gate</strong>. It&rsquo;s not &ldquo;scene not ready&rdquo;, it&rsquo;s &ldquo;permission transition&rdquo;. The gate closes after the starting grid appears. Next probe: runtime dispatch trace, enough of the blind asset surgery.</p>
<p>In parallel: frame extraction from the recording at 10 Hz, cross-referenced against keyframes of the animation library (<code>animlib</code>) in <code>india_posteffects.lvl</code>. Found a smoking gun. The <code>MasterBrightness</code> track has keyframes 0.003 and 0.0007 (0.63% brightness) where the default would be 1.0. Animlib beats inline defaults every frame.</p>
<ul>
<li><strong>New concept:</strong> animation library, keyframe encoding, binary float-scanning.</li>
</ul>
<video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/debugging-blackout-fade.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>Video above: the process of probing the fade in real time.</p>
<h3>Phase 11: multi-scalar patch<span class="hx:absolute hx:-mt-20" id="phase-11-multi-scalar-patch"></span>
    <a href="#phase-11-multi-scalar-patch" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-11-multi-scalar-patch.md"target="_blank" rel="noopener"><code>phase-11-multi-scalar-patch.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> patching <code>MasterBrightness</code> + <code>AutoTargetLuminance</code> + <code>ManualAutoMix</code> simultaneously will lift the blackout.</li>
<li><strong>Action:</strong> extended <code>PatchAnimlibFloatValues</code> to rewrite three scalars. Instrumented with <code>SHADPS4_DC_DRAWLOG</code> + <code>SHADPS4_DC_UBOLOG</code>.</li>
<li><strong>Outcome:</strong> the patch produces a real <strong>numeric</strong> lift (1.2 → 6.5 in 255, a 5×), but still sub-perceptual black. And the crucial finding: the mirror works normally after the patch. It&rsquo;s not gated, just reflecting a dark scene. <strong>At least 285× of additional attenuation lives downstream of the scalars we can patch.</strong></li>
</ul>
<h3>Phase 12: bisect closes the asset side<span class="hx:absolute hx:-mt-20" id="phase-12-bisect-closes-the-asset-side"></span>
    <a href="#phase-12-bisect-closes-the-asset-side" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-12-bisect-closes-asset-side.md"target="_blank" rel="noopener"><code>phase-12-bisect-closes-asset-side.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> one of the 60 accumulated patches broke the mirror independently from the blackout. Bisect.</li>
<li><strong>Action:</strong> binary bisect across 60 overlay files over 5 rounds.</li>
<li><strong>Outcome:</strong> <code>x_live_lobby_pre_race.txt</code> (rerouting loading-spinner transition from <code>ZoomInAndFade</code> to <code>NoFade</code>) was the mirror killer. Rolled back. <strong>Asset side completely closed.</strong> The driver lives in compiled code or post-process state, not in a game asset.</li>
</ul>
<h3>Asset sweeps 01-09 (consolidated)<span class="hx:absolute hx:-mt-20" id="asset-sweeps-01-09-consolidated"></span>
    <a href="#asset-sweeps-01-09-consolidated" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/asset-sweep-09-conclusions.md"target="_blank" rel="noopener"><code>asset-sweep-09-conclusions.md</code></a></p>

</blockquote>
<p>In parallel with the phases above, I swept 12 RPK substitutions: <code>FreeplayGetInCar</code> page swaps, postfx edits in <code>india_landscape_gui.rpk</code>, <code>globaldata</code> RTT-only edits, prerace state-string rewrites. <strong>All clean misses.</strong> No asset family touches the blackout. Lesson: <strong>stop chasing by names; chase by state transitions.</strong></p>
<h3>Phase 13: UBO dump breakthrough<span class="hx:absolute hx:-mt-20" id="phase-13-ubo-dump-breakthrough"></span>
    <a href="#phase-13-ubo-dump-breakthrough" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-13-ubo-dump-breakthrough.md"target="_blank" rel="noopener"><code>phase-13-ubo-dump-breakthrough.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> eye-adaptation feedback loop with stale input is the culprit. A luminance scalar growing without converging.</li>
<li><strong>Action:</strong> dump 128 bytes of UBO in hex during the race window across six hand-picked pipelines. Look for monotonically changing values.</li>
<li><strong>New concept:</strong> <strong>UBO (Uniform Buffer Object)</strong>, a shared memory region the CPU writes and shaders read. <strong>Auto-exposure integrator</strong>, a classic eye-adaptation component that takes time to converge toward a target.</li>
<li><strong>Outcome:</strong> found it. <strong>UBO offset 3 climbs monotonically from 2.59 → 7.84 → 24 → 90 → 179 → 255</strong> over 2 seconds. Textbook feedback loop with broken input. First real hit in 12 phases.</li>
</ul>
<h3>Phase 14: scene-pipeline UBOs are the read side<span class="hx:absolute hx:-mt-20" id="phase-14-scene-pipeline-ubos-are-the-read-side"></span>
    <a href="#phase-14-scene-pipeline-ubos-are-the-read-side" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-14-scene-pipeline-ubos-wrong.md"target="_blank" rel="noopener"><code>phase-14-scene-pipeline-ubos-wrong.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> clamping the climbing scalar will stop the blackout.</li>
<li><strong>Action:</strong> <code>SHADPS4_DC_LUM_CLAMP=2.0</code> forces offset 3 to a fixed value during the race window.</li>
<li><strong>Outcome:</strong> the clamp fires 1093 times, blackout unchanged. Scene-pipeline UBOs <strong>consume</strong> the dim, they don&rsquo;t produce it. The dim is upstream, in a fullscreen tonemap or post-fx compute.</li>
</ul>
<h3>Phases 15-16: runtime texture dump + mutation<span class="hx:absolute hx:-mt-20" id="phases-15-16-runtime-texture-dump--mutation"></span>
    <a href="#phases-15-16-runtime-texture-dump--mutation" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-15-runtime-texture-dump.md"target="_blank" rel="noopener"><code>phase-15-runtime-texture-dump.md</code></a> · <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-16-runtime-texture-mutation.md"target="_blank" rel="noopener"><code>phase-16-runtime-texture-mutation.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> the blackout is a fullscreen texture composited over the scene.</li>
<li><strong>Action:</strong> <code>SHADPS4_DC_TEX_DUMP=1</code> dumps every bound texture during the race window (1079 textures captured). Convert tiled format to PNG for visual triage. Then, deterministically mutate each texture via hash-based tint + <code>InvalidateMemory</code> to force re-upload.</li>
<li><strong>New concept:</strong> <strong>GCN tile format</strong> (Sony interleaves bytes a specific way for cache locality), texture de-swizzle, buffer cache invalidation.</li>
<li><strong>Outcome:</strong> 14 rounds of mutation (BC textures, float data, render targets, UI atlases, post-fx buffers, LUTs). Blackout <strong>unchanged across all of them</strong>. Texture content 100% ruled out as the source.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/blue-ish-overlay.png" alt="Blue overlay during mutation"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/green-ish-overlay.png" alt="Green overlay during mutation"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/reflections-glitching-blown.png" alt="Reflections glitched"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/reflections-glitched-blown-white.png" alt="Reflections blown white"  loading="lazy" /></p>
<h3>Phases 17-19: UBO smashing, push-constant smashing, compute dispatch probe<span class="hx:absolute hx:-mt-20" id="phases-17-19-ubo-smashing-push-constant-smashing-compute-dispatch-probe"></span>
    <a href="#phases-17-19-ubo-smashing-push-constant-smashing-compute-dispatch-probe" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-17-ubo-batch-smashing.md"target="_blank" rel="noopener"><code>phase-17-ubo-batch-smashing.md</code></a> · <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-18-push-constant-smashing.md"target="_blank" rel="noopener"><code>phase-18-push-constant-smashing.md</code></a> · <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-19-compute-dispatch-probe.md"target="_blank" rel="noopener"><code>phase-19-compute-dispatch-probe.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> random-fill on UBOs, push constants, and compute input buffers can bracket and isolate the dim field without crashing.</li>
<li><strong>Action:</strong> <code>SHADPS4_DC_UBO_SMASH=1</code>, <code>SHADPS4_DC_PC_SMASH=1</code>, <code>SHADPS4_DC_DISPATCH_SMASH=1</code> with cb-index and size filters.</li>
<li><strong>New concept:</strong> <strong>push constants</strong> (128 bytes of fast params the driver pushes straight into the pipeline, no descriptor), <strong>compute dispatches</strong> (fires compute shaders with a grid of threads), <strong>ud_regs</strong> (GCN user data registers — how shadPS4 maps user data to push constants).</li>
<li><strong>Outcome:</strong> safe brackets produce overlay bokeh + blinks, but the dim stays put. Wide brackets that reach the dim also hit matrices/transforms and crash. <strong>Random-fill is the wrong instrument.</strong> Needs diff methodology.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/all-bege.png" alt="Everything beige after smash"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/all-green.png" alt="Everything green"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/all-cyan.png" alt="Everything cyan"  loading="lazy" /></p>
<h3>Phase 19b-c: dim-vs-lift dispatch diff<span class="hx:absolute hx:-mt-20" id="phase-19b-c-dim-vs-lift-dispatch-diff"></span>
    <a href="#phase-19b-c-dim-vs-lift-dispatch-diff" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-19b-dim-vs-lift-diff.md"target="_blank" rel="noopener"><code>phase-19b-dim-vs-lift-diff.md</code></a> · <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-19c-dispatch-draw-skip-null.md"target="_blank" rel="noopener"><code>phase-19c-dispatch-draw-skip-null.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> compute dispatches that only fire during dim (and stop once it lifts) are part of the chain.</li>
<li><strong>Action:</strong> split the clean log into pre / dim / bright windows (via user wall-clock), count dispatch frequency per pipeline.</li>
<li><strong>Outcome:</strong> identified <strong>8 compute pipelines</strong> (histogram 256→downsamples, tile-grid reductions, auto-exposure 1x1 scalars) that stop exactly when dim lifts. Shapes strongly suggest the eye-adaptation chain. <strong>First genuine diagnostic hit.</strong> Skip test on those 8 + 101 correlated draws: blackout <strong>unchanged</strong>. They&rsquo;re <strong>downstream effects</strong>, not drivers.</li>
</ul>
<h3>Phase 20-21: tonemap inline, fragment → flip buffer<span class="hx:absolute hx:-mt-20" id="phase-20-21-tonemap-inline-fragment--flip-buffer"></span>
    <a href="#phase-20-21-tonemap-inline-fragment--flip-buffer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-20-shader-dump-tonemap-inline.md"target="_blank" rel="noopener"><code>phase-20-shader-dump-tonemap-inline.md</code></a> · <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-21-fragment-to-flip-buffer.md"target="_blank" rel="noopener"><code>phase-21-fragment-to-flip-buffer.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> the auto-exposure compute shader is broken, or the tonemap compute has the wrong math.</li>
<li><strong>Action:</strong> recompile all shaders, dump SPIR-V, disassemble the histogram auto-exposure. Patch its output to constant 1.0 and test.</li>
<li><strong>New concept:</strong> <strong>SPIR-V disassembly</strong> (Khronos intermediate format with readable assembly). <strong>Compute shader verification.</strong> Later, <strong>pipeline-to-shader mapping</strong>.</li>
<li><strong>Outcome:</strong> patch <strong>has no effect</strong>. Discovery: <strong>DriveClub has no separate tonemap pass</strong>. Scene geometry writes directly to the flip buffer, with exposure inline in material shaders. Each material shader bakes the dim scalar from a shared UBO. Pipeline cache wipe + <code>SHADPS4_DC_PIPEMAP=1</code> dumps every pipeline→shader mapping. Filtering drawlog for pipelines writing to <code>0x5000900000</code> / <code>0x5000108000</code> (the actual swapchain buffers) yields <strong>14 unique pipelines</strong>. None with Exp2/Log2 tonemap ops. Exposure is applied upstream.</li>
</ul>
<h3>Phase 22: tonemap compute identified<span class="hx:absolute hx:-mt-20" id="phase-22-tonemap-compute-identified"></span>
    <a href="#phase-22-tonemap-compute-identified" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-22-tonemap-compute-identified.md"target="_blank" rel="noopener"><code>phase-22-tonemap-compute-identified.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> among the game&rsquo;s 510 compute shaders, one has a tonemap signature (many Exp2/Log2).</li>
<li><strong>Action:</strong> automated scan, cross-reference with dispatchlog.</li>
<li><strong>Outcome:</strong> <strong><code>cs_0x2c918c06</code></strong> is the strong candidate, with 8 Exp2 + 8 Log2, 5 SSBOs (scene params, TAA, bloom, camera, exposure), 11 images. Skip test on that specific pipeline: <strong>stops the blackout cycle</strong>, removes color grading, introduces temporal ghosting. <strong>That&rsquo;s the final scene→swapchain tonemap path.</strong></li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/temporal-glitching.png" alt="Temporal ghosting after skip"  loading="lazy" /></p>
<h3>Phase 22+: tonemap patches → playable<span class="hx:absolute hx:-mt-20" id="phase-22-tonemap-patches--playable"></span>
    <a href="#phase-22-tonemap-patches--playable" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-22plus-tonemap-compute-patches.md"target="_blank" rel="noopener"><code>phase-22plus-tonemap-compute-patches.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> two surgical SPIR-V edits (histogram no-op + tonemap exposure clamp/boost) make the game playable.</li>
<li><strong>Action:</strong> histogram patch to never update; tonemap patch to clamp exposure floor at 0.5 and boost blend by ×10. Both via SPIR-V patching + <code>shader/patch/</code> drop-in.</li>
<li><strong>Outcome:</strong> <strong>playable!</strong> Dim shrinks from pitch black to mild. A <strong>real mitigation, not a root-cause fix.</strong> The fade curve still drags exposure, and each track needs per-threshold tuning.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/blown-just-track-lights.png" alt="Track lights blowing out after ×10 boost"  loading="lazy" /></p>
<video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/india-gamma-correction-attempt.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>Here the AI suggested stopping for the first time. <strong>&ldquo;The game is playable, this is a decent mitigation, we should document and accept.&rdquo;</strong> I pushed back: &ldquo;it&rsquo;s playable but it&rsquo;s wrong. Don&rsquo;t accept. We keep going.&rdquo;</p>
<h3>Phase 23: CPU-side exposure intercept<span class="hx:absolute hx:-mt-20" id="phase-23-cpu-side-exposure-intercept"></span>
    <a href="#phase-23-cpu-side-exposure-intercept" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-23-cpu-exposure-intercept.md"target="_blank" rel="noopener"><code>phase-23-cpu-exposure-intercept.md</code></a></p>

</blockquote>
<ul>
<li><strong>Hypothesis:</strong> clamping the exposure scalar at the tonemap compute&rsquo;s <code>BindBuffers</code> point prevents over-dimming.</li>
<li><strong>Action:</strong> <code>SHADPS4_DC_EXPOSURE_PIN=1</code> intercepts <code>BindBuffers</code>, overwrites exposure offsets with a clamp-min threshold, invalidates buffer_cache.</li>
<li><strong>New concept:</strong> <strong>CPU-GPU buffer binding</strong> (the moment when the CPU hands a descriptor to the GPU). <strong>Buffer cache invalidation</strong> (how the emulator keeps consistency between a buffer&rsquo;s CPU-side and GPU-side state).</li>
<li><strong>Outcome:</strong> the scalar oscillates at race start, then climbs monotonically. Per-track tuning is needed. India wants 3.0, Canada blows out at the same threshold. <strong>A single-scalar solution doesn&rsquo;t converge cross-track.</strong></li>
</ul>
<video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/debugging-cli-gamma-correction.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<h3>Phase 24: dim upstream of tonemap<span class="hx:absolute hx:-mt-20" id="phase-24-dim-upstream-of-tonemap"></span>
    <a href="#phase-24-dim-upstream-of-tonemap" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-24-dim-upstream-of-tonemap.md"target="_blank" rel="noopener"><code>phase-24-dim-upstream-of-tonemap.md</code></a></p>

</blockquote>
<ul>
<li><strong>Conclusion:</strong> after the whole probe chain (identity tonemap, exposure pin, compute skip), the dim <strong>is in the HDR target before</strong> the tonemap reads it. Three upstream suspects: (1) atmospheric scattering / volumetric fog, (2) reflection-probe / envmap update, (3) pre-tonemap exposure apply.</li>
<li><strong>New concept:</strong> <strong>atmospheric scattering</strong> (simulating how light travels through atmosphere, Rayleigh/Mie-style), <strong>volumetric fog</strong> (fog rendered as a froxel volume).</li>
<li><strong>Outcome:</strong> plan set. Frame-order dump, walk backwards from tonemap through the whole chain.</li>
</ul>
<h3>Phase 25: frame-order trace<span class="hx:absolute hx:-mt-20" id="phase-25-frame-order-trace"></span>
    <a href="#phase-25-frame-order-trace" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-25-frameorder-trace.md"target="_blank" rel="noopener"><code>phase-25-frameorder-trace.md</code></a></p>

</blockquote>
<ul>
<li><strong>Action:</strong> <code>SHADPS4_DC_FRAMEORDER=&lt;N&gt;</code> logs strict per-submit pipeline/shader chain over N submits during race window.</li>
<li><strong>New concept:</strong> <strong>frame-order reconstruction</strong> (listing every GPU submission in exact order), <strong>froxel volumetrics</strong> (frustum volume voxels — a 3D grid aligned to the camera&rsquo;s frustum that stores light info).</li>
<li><strong>Outcome:</strong> three suspects: (A) half-res compute <code>(60,34,1)</code>, probably SSAO; (B) progressive dispatches <code>(256,1,1)→(2560,1,1)→(10240,1,1)</code>, froxel volumetric; (C) fullscreen apply-fog-to-HDR draw. <strong>Suspect B</strong> is the strongest fit (sunset best-fit, per-pixel non-uniform dim).</li>
</ul>
<h3>Phase 26: The UBO fade pin, the &ldquo;aha moment&rdquo;<span class="hx:absolute hx:-mt-20" id="phase-26-the-ubo-fade-pin-the-aha-moment"></span>
    <a href="#phase-26-the-ubo-fade-pin-the-aha-moment" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-26-ubo-fade-pin.md"target="_blank" rel="noopener"><code>phase-26-ubo-fade-pin.md</code></a></p>

</blockquote>
<p>This is where everything changed.</p>
<ul>
<li><strong>Hypothesis:</strong> a direct memory diff (bright vs dim vs recovered) identifies the exact bytes the game writes to control the blackout.</li>
<li><strong>Action:</strong> capture periodic UBO snapshots across visual state transitions. Diff three snapshots byte by byte. Look for fade signatures.</li>
<li><strong>New concept:</strong> <strong>state diff methodology</strong>. Instead of &ldquo;which pass looks suspicious?&rdquo;, ask &ldquo;which bytes actually change with the visual state?&rdquo;</li>
<li><strong>Outcome:</strong> <strong>breakthrough.</strong> Two UBOs control the fade: a 1936-byte one with slots <code>[38/48/50]</code> (light intensity) being multiplied by 0.094 (fade factor), and a 224-byte sun UBO with slot <code>[50]</code> dropping 97%. Pinning both: race start opens bright. TOD animation intact. Content-aware three-state lifecycle (idle → engaged → expired).</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/touching-sun-light.png" alt="First touch on the sun-light UBO"  loading="lazy" /></p>
<p>Wednesday night. Claude suggests stopping again. <strong>&ldquo;We have the pin working, three-state lifecycle is robust, Canada + Munnar are running. This is upstream quality, we should PR and close it.&rdquo;</strong> I insist: &ldquo;there&rsquo;s still residual dim on other tracks, it&rsquo;s not closed. We keep going.&rdquo;</p>
<h3>Phase 28: pivot to emulator code<span class="hx:absolute hx:-mt-20" id="phase-28-pivot-to-emulator-code"></span>
    <a href="#phase-28-pivot-to-emulator-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-28-pivot-to-emulator-code.md"target="_blank" rel="noopener"><code>phase-28-pivot-to-emulator-code.md</code></a></p>

</blockquote>
<ul>
<li><strong>Realization:</strong> the residual dim in Canada dusk/night isn&rsquo;t a wrong UBO value. It&rsquo;s an <strong>emulator translation gap</strong>. The game runs correctly on a real PS4, so shadPS4 has some path that translates or synchronizes incorrectly.</li>
<li><strong>Action:</strong> ranked 5 candidate emulator bugs: HLE shader misidentification, image storage classification, layout transitions, IMAGE_STORE_MIP fallback, OpImageFetch LOD restriction.</li>
<li><strong>New concept:</strong> <strong>HLE (High-Level Emulation)</strong>. Replacing complex PS4 OS functions with host-side implementations. Different from LLE (low-level) which emulates bit by bit. HLE is faster but can diverge from real behavior whenever the host implementation doesn&rsquo;t cover every case.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/canada-dim.png" alt="Canada dusk/night still dim"  loading="lazy" /></p>
<h3>Phase 29: calibrated state + recording harness<span class="hx:absolute hx:-mt-20" id="phase-29-calibrated-state--recording-harness"></span>
    <a href="#phase-29-calibrated-state--recording-harness" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-29-calibrated-state.md"target="_blank" rel="noopener"><code>phase-29-calibrated-state.md</code></a></p>

</blockquote>
<ul>
<li><strong>Action:</strong> <code>SHADPS4_DC_RECORD=1</code>. Recording harness that dumps lighting UBOs periodically + cross-correlates with wall-clock screenshots.</li>
<li><strong>Outcome:</strong> important discovery. <strong>Slots <code>[144..295]</code> of the 1936-byte UBO</strong> (30+ fields) stay <strong>denormal/uninitialized</strong> for the first ~90 seconds of a race. At the 1:30 &ldquo;auto-recalibration&rdquo;, the game writes the real values. <strong>It&rsquo;s not magic recovery, it&rsquo;s the CPU finally writing values that should have been there since frame 1.</strong> On PS4 they&rsquo;re ready before the first frame; on shadPS4 they&rsquo;re delayed by 90s. <strong>Root cause lives in an emulator init gap.</strong></li>
</ul>
<h3>Phase 30: UBO writer audit<span class="hx:absolute hx:-mt-20" id="phase-30-ubo-writer-audit"></span>
    <a href="#phase-30-ubo-writer-audit" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-30-ubo-writer-audit.md"target="_blank" rel="noopener"><code>phase-30-ubo-writer-audit.md</code></a></p>

</blockquote>
<ul>
<li><strong>Action:</strong> exhaustive audit of every path that writes into slot <code>[38]</code> of the 1936-byte UBO. 7 suspects ranked: (1) ObtainBuffer stream-copy race, (2) lazy RegionManager in memory_tracker, (3) histogram compute skip, (4) page-fault delayed invalidation, (5) readback gating, (6) buffer-coherency race, (7) push-constant.</li>
<li><strong>Status:</strong> audit incomplete. Before I could verify each suspect, Phase 31 short-circuited the whole thing.</li>
</ul>
<h3>Phase 31: readbacks_mode = Precise, the turning point<span class="hx:absolute hx:-mt-20" id="phase-31-readbacks_mode--precise-the-turning-point"></span>
    <a href="#phase-31-readbacks_mode--precise-the-turning-point" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><blockquote>
  <p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-31-readbacks-mode-fix.md"target="_blank" rel="noopener"><code>phase-31-readbacks-mode-fix.md</code></a></p>

</blockquote>
<p>Thursday morning. Tired, thinking about suspect <strong>#5, readback gating</strong>. That one itched at me because it was the only one of the 7 writers that touched the <em>timing</em> of the read rather than the <em>correctness</em> of the write.</p>
<ul>
<li><strong>Hypothesis:</strong> flipping <code>readbacks_mode: 2 (Precise)</code> in per-game config makes the feedback loop work by synchronizing the CPU&rsquo;s read to fresh GPU output.</li>
<li><strong>Action:</strong> one edit in <code>custom_configs/CUSA00003.json</code>. Boot. Canada night.</li>
<li><strong>Outcome:</strong> <strong>race start opens bright. Auto-exposure converges normally. Zero blackout. Zero 1:30 recalibration. Night TOD natural.</strong></li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/23/driveclub-learning/almost-fixed.png" alt="Almost there, the test before the final one"  loading="lazy" /></p>
<p>Tested on Munnar. Same result. One integer. <strong>31 phases. 44 commits. 15,668 lines. Answer: one integer in the config.</strong></p>
<p>Why did this slip past 30 phases? The Phase 31 doc has the honest analysis:</p>
<ol>
<li><strong>Symptom disguise.</strong> Progressive darkening looks exactly like a broken tonemap, bad shader, or miscompiled exposure scalar. Every dump-driven probe found <strong>wrong values in the UBO</strong>, which is true but describes the downstream read, not the upstream gap.</li>
<li><strong><code>readback_linear_images</code> red herring.</strong> I tested that flag in Phase 29, saw no effect, and concluded &ldquo;the readback surface is audited&rdquo;. I forgot that flag is for <em>image</em> readbacks (linear images via <code>TextureCache::DownloadImageMemory</code>). The histogram SSBO is a <strong>buffer</strong>, a separate path.</li>
<li><strong>Pinning making it worse, not better.</strong> Overwriting slots [38/48/50] looked promising but never converged. The game rewrote on top of the pin every frame. I interpreted that as &ldquo;the pin mechanism lands too late in the pipeline&rdquo;. I spent phases 27-29 moving the pin earlier. The real fix was to <strong>stop clobbering the GPU-side input the integrator reads</strong>.</li>
<li><strong>Never audited the buffer readback path.</strong> The Phase 30 audit listed 7 writer candidates, but all of them were about the correctness of the <em>write</em>. None asked &ldquo;what is the CPU reading, expecting the GPU to have filled?&rdquo;</li>
</ol>
<h2>The shotgun technique<span class="hx:absolute hx:-mt-20" id="the-shotgun-technique"></span>
    <a href="#the-shotgun-technique" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One thing I want to put on record: <strong>torture probes aren&rsquo;t waste, they&rsquo;re learning infrastructure.</strong> And the methodology behind them has a name: shotgun.</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/HkxVhFg81fs?start=40"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>If you have a suspicion (say, &ldquo;the dim comes from some texture&rdquo;), the worst thing you can do is start investigating one texture at a time, in depth. DriveClub has 1079 bound textures during the race window. If a detailed triage on one texture takes 20 minutes, you spend three days on a single branch of that tree and still don&rsquo;t know if the dim even comes from a texture. It might not be in the whole tree.</p>
<p>Shotgun fixes that. Instead of investigating one at a time, you fire at all of them simultaneously. Mutate every bound texture with a hash-based tint, force re-upload, run the game. In 30 seconds you know whether any of them affects the dim. If it does, the dim is texture-side and only then is it worth starting to narrow down. If nothing changes, congratulations: you just ruled out the entire &ldquo;texture&rdquo; category in 30 seconds. Switch categories, shotgun UBOs. No hit? Shotgun push constants. No hit? Shotgun compute dispatches. Keep going until something reacts.</p>
<p><strong>Don&rsquo;t start by narrowing down a tree that might be huge.</strong> You&rsquo;ll spend weeks in irrelevant leaves. Shotgun the trunk first. See which branch reacts. Then go deep inside that branch.</p>
<p>That&rsquo;s what I did, without knowing I was doing it, for the first 30 phases. 32 probe env vars (<code>SHADPS4_DC_UBO_SMASH</code>, <code>SHADPS4_DC_PC_SMASH</code>, <code>SHADPS4_DC_DISPATCH_SMASH</code>, <code>SHADPS4_DC_TEX_NUKE_*</code>, <code>SHADPS4_DC_UBO_NUKE</code>, and more). Random batches, hash-based tints, null substitutions. Most of them never pointed at the real fix. But every shotgun round ruled out an entire category of mental model. &ldquo;Not texture.&rdquo; &ldquo;Not push-constant.&rdquo; &ldquo;Not those 8 compute pipelines.&rdquo; &ldquo;Not the UBO write side anywhere in that region.&rdquo; Every ruling-out narrows the problem.</p>
<p><strong>And while I was doing it, I was learning the system.</strong> Every smash that crashed told me &ldquo;there&rsquo;s a critical descriptor pointer here&rdquo;. Every color tint that appeared told me &ldquo;this UBO feeds that composition pass&rdquo;. Every dispatch that fired differently between dim/bright showed me where the interesting boundary lives.</p>
<p><strong>When you don&rsquo;t know the system, shotgun probes are a map.</strong> You don&rsquo;t use them to find the answer. You use them to learn the terrain.</p>
<h2>What I knew on Sunday vs Thursday<span class="hx:absolute hx:-mt-20" id="what-i-knew-on-sunday-vs-thursday"></span>
    <a href="#what-i-knew-on-sunday-vs-thursday" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><strong>Sunday:</strong> zero everything. All the areas below were black boxes to me.</p>
<p><strong>Thursday:</strong></p>
<ul>
<li>PS4 package format end-to-end (PKG → PFS → param.sfo → disc_info → keystone → npbind). How v1.28 cumulative patches layer on top of a v1.00 base install.</li>
<li>shadPS4 architecture in outline: <code>src/common</code>, <code>src/core</code> (OS emulation, HLE libraries), <code>src/shader_recompiler</code> (GCN→SPIR-V), <code>src/video_core</code> (Vulkan renderer, buffer cache, texture cache, page manager).</li>
<li>Full graphics pipeline: G-buffer → forward+ lighting (writes MSAA depth) → read depth as color for SSAO / volumetric froxel → post-fx compute → tonemap → composite → swapchain.</li>
<li>UBO vs push constant vs descriptor set. When to use each.</li>
<li>SPIR-V disassembly and re-assembly for surgical patching. How to identify a tonemap compute by its Exp2/Log2 signature.</li>
<li>Forward+ lighting + MSAA depth resolve + how to implement ReinterpretMsDepthAsColor.</li>
<li>shadPS4 buffer cache: <code>MemoryTracker</code>, <code>RegionManager</code>, <code>FaultManager</code>. How page faults turn into GPU→CPU sync.</li>
<li><code>readbacks_mode</code>: Disabled / Relaxed / Precise. What each tradeoff looks like. Why Relaxed isn&rsquo;t enough for feedback loops (it only write-protects, not read-protects).</li>
<li>PS4 OELF → Itanium RTTI → SCE relocation → dynamic linker reasoning, even without actually doing the binary patch (the plan is documented).</li>
</ul>
<p>I didn&rsquo;t become an expert in any of it. But I went from <strong>total outsider</strong> to <strong>reasonably oriented operator</strong>. And that jump, as I&rsquo;ll argue below, was the single most valuable thing of the four days.</p>
<h2>The AI suggested accepting. The perseverance was mine<span class="hx:absolute hx:-mt-20" id="the-ai-suggested-accepting-the-perseverance-was-mine"></span>
    <a href="#the-ai-suggested-accepting-the-perseverance-was-mine" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve said a few times in this article that &ldquo;Claude suggested stopping&rdquo;. I want to be fair and specific: it wasn&rsquo;t an AI failure. It was exactly the correct behavior of an intelligent tool: <strong>when you have something that works reasonably well, stopping and documenting is frequently the right call.</strong> The Phase 22+ tonemap patch made the game playable. The Phase 26 UBO pin made the image pretty. The Phase 29 recording harness covered the critical window. All were legitimate stopping points.</p>
<p>If you read <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/codex-conclusion.md"target="_blank" rel="noopener"><code>codex-conclusion.md</code></a>, the document the AI produced after Phase 26 (still not knowing about Phase 31 yet), it describes the then-current solution as &ldquo;<strong>a mitigation, not a root-cause fix</strong>&rdquo;. It honestly admits what we knew: the UBO pin was a countermeasure, not an explanation. That&rsquo;s good behavior.</p>
<p>The difference is that <strong>I had a stubborn hunch that something was still wrong</strong>. It worked in the playable sense. But the cost (DriveClub-specific pin + tonemap SPIR-V patch + per-track threshold tuning + doesn&rsquo;t generalize) was too high for a supposedly engineering-quality fix. A senior professional recognizes when the asymmetry between cost and understanding points to something deeper. You don&rsquo;t always get it right. But that hunch deserves to be heard.</p>
<p>Four days without interruption, 8 a.m. to midnight, Sunday, Monday, Tuesday, Wednesday, resolved Thursday morning. How many times did the AI suggest &ldquo;honestly, we should just accept the current result and document&rdquo;? Four or five. Each technically defensible. <strong>Human perseverance is the difference between &ldquo;the game works well enough&rdquo; and &ldquo;the game works, and I know why&rdquo;.</strong></p>
<h2>The real cost of this journey<span class="hx:absolute hx:-mt-20" id="the-real-cost-of-this-journey"></span>
    <a href="#the-real-cost-of-this-journey" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/investigation-effort-accounting.md"target="_blank" rel="noopener"><code>investigation-effort-accounting.md</code></a> catalogs everything before cleanup. Some highlights:</p>
<ul>
<li><strong>3 days</strong> of active work (Apr 21-23, 2026).</li>
<li><strong>33 numbered phases</strong> + sweeps + side threads = 54 docs, 412 KB, <strong>6,867 lines of prose</strong>.</li>
<li><strong>44 commits</strong> on the gamma-debug fork. <strong>15,668 lines added</strong>, 1,204 removed.</li>
<li><code>src/video_core/renderer_vulkan/vk_rasterizer.cpp</code> grew from 1,364 to <strong>4,680 lines</strong> (3.4×) with instrumentation, pin lifecycles, recording harness.</li>
<li><strong>32 probe env vars</strong> defined. <strong>1 effectively used to find the bug</strong> (<code>SHADPS4_DC_LOG_STREAMCOPY</code>, in Phase 30).</li>
<li><strong>61 MB</strong> of shadPS4 log accumulated. <strong>57 MB</strong> of shader dumps (5,803 files). ~<strong>470 MB</strong> of total reclaimable artifacts once closed.</li>
</ul>
<p>Resolution: <strong>1 integer in the config.</strong></p>
<p>The ratio of <strong>6,867 lines of prose : 1 integer</strong> amuses me. But it isn&rsquo;t a joke about inefficiency. It&rsquo;s the real cost of learning a system you didn&rsquo;t know. Every line of prose ruled out a hypothesis. Every commit taught a layer of the stack. The integer was the end; the path was the product.</p>
<p>An honest note: despite the 15,668 lines added on the <code>gamma-debug</code> fork, <strong>almost none of it is useful to contribute upstream to shadPS4</strong>. What&rsquo;s there is instrumentation: torture probes, recording harness, pin lifecycles, calibrate-at-arm, UBO snapshots, dozens of debug env vars. Diagnostic code, not production code. Diagnostics that only make sense for DriveClub under those specific conditions.</p>
<p>The fork is going to stay public, but <strong>not as a contribution branch to the shadPS4 project</strong>. Unfortunately. It stays as a study document: 54 md files covering the full process, 44 commits showing the sequence of hypotheses, rule-outs and failures. It&rsquo;s material for anyone who wants to understand how the debugging process unfolded, or to reuse some of the techniques in similar investigations. No upstream-ready PR is coming out of that tree.</p>
<h2>Closing<span class="hx:absolute hx:-mt-20" id="closing"></span>
    <a href="#closing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Yes, the final solution was a config toggle that already existed. It&rsquo;s been in shadPS4&rsquo;s code for seven months. But on Sunday night, with zero PS4 knowledge, zero emulator knowledge, zero notion that auto-exposure was a GPU→CPU feedback loop, <strong>I had no technical authority to say &ldquo;flip readbacks Precise, done&rdquo;</strong>.</p>
<p>I had to see the whole chain to understand <em>why</em> it works. Why Relaxed isn&rsquo;t enough (it only write-protects, and DriveClub reads, it doesn&rsquo;t write into the histogram SSBO). Why Precise is expensive (per-page mprotect + per-fault GPU stall). Why the maintainer hid it behind &ldquo;Advanced&rdquo; (Bloodborne hangs, AMD tanks to 12 FPS). Why nobody in the community had connected Precise to DriveClub before: the compat tracker admits &ldquo;readbacks enabled&rdquo; but doesn&rsquo;t cite the mode, and nobody had seen the monotonic-drift signature through the GPU→CPU lens.</p>
<p>That&rsquo;s the difference between <strong>knowing the answer</strong> and <strong>knowing the terrain well enough to recognize the answer</strong>. To the junior asking me &ldquo;how do I learn today, in the AI era&rdquo;: that&rsquo;s my answer. Don&rsquo;t ask. Dive in. Use AI to speed up the search, to read code in parallel, to index old forums, to compile, to parse logs. But <strong>insist on understanding the why</strong>. When the AI suggests &ldquo;let&rsquo;s accept and document&rdquo; (and it will, because that&rsquo;s often the right behavior), ask yourself whether you actually understood the chain. If the itch &ldquo;this is wrong but I don&rsquo;t know why&rdquo; is still there, keep going.</p>
<p>You&rsquo;re probably going to spend four days on something that has a one-integer solution. You&rsquo;re probably going to discover five wrong paths before the right one. Probably half your probes will be torture shotguns that never pointed at the fix.</p>
<p>And you&rsquo;ll probably, when you finish, know a domain you didn&rsquo;t know before.</p>
<ul>
<li><strong>Fork gamma-debug:</strong> <a href="https://github.com/akitaonrails/shadPS4/tree/gamma-debug"target="_blank" rel="noopener">github.com/akitaonrails/shadPS4/tree/gamma-debug</a></li>
<li><strong>54 investigation docs:</strong> <a href="https://github.com/akitaonrails/shadPS4/tree/gamma-debug/docs/driveclub-investigation"target="_blank" rel="noopener">docs/driveclub-investigation/</a></li>
<li><strong>Operational runbook (recipe):</strong> <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/driveclub-shadps4.md"target="_blank" rel="noopener">distrobox-gaming/docs/driveclub-shadps4.md</a></li>
<li><strong>Phase 31 resolution:</strong> <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-investigation/phase-31-readbacks-mode-fix.md"target="_blank" rel="noopener"><code>readbacks_mode: 2</code></a></li>
</ul>
<p>Now I&rsquo;m going to go play DriveClub. Night race in Canada, natural brightness, no blackout. Good Thursday.</p>
]]></content:encoded><category>driveclub</category><category>shadps4</category><category>emulation</category><category>ai-agents</category><category>claude-code</category><category>learning</category><category>ps4</category></item><item><title>Clean Code for AI Agents</title><link>https://akitaonrails.github.io/en/2026/04/20/clean-code-for-ai-agents/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/20/clean-code-for-ai-agents/</guid><pubDate>Mon, 20 Apr 2026 12:00:00 GMT</pubDate><description>&lt;p&gt;In 2008, Robert C. Martin (Uncle Bob) published &lt;strong&gt;Clean Code: A Handbook of Agile Software Craftsmanship&lt;/strong&gt;. It&amp;rsquo;s one of the most influential software engineering books of the last couple of decades. For those who don&amp;rsquo;t know, Uncle Bob started programming professionally at 17, founded Object Mentor, signed the Agile Manifesto, served as the first chairman of the Agile Alliance, coined the SOLID acronym. He&amp;rsquo;s written a dozen books on design, architecture, and practice, and influenced entire generations of developers.&lt;/p&gt;</description><content:encoded><![CDATA[<p>In 2008, Robert C. Martin (Uncle Bob) published <strong>Clean Code: A Handbook of Agile Software Craftsmanship</strong>. It&rsquo;s one of the most influential software engineering books of the last couple of decades. For those who don&rsquo;t know, Uncle Bob started programming professionally at 17, founded Object Mentor, signed the Agile Manifesto, served as the first chairman of the Agile Alliance, coined the SOLID acronym. He&rsquo;s written a dozen books on design, architecture, and practice, and influenced entire generations of developers.</p>
<p>I&rsquo;ve been following Uncle Bob for many years. I&rsquo;ve exchanged messages with him a few times over the decades, I have formed opinions about his positions, and I did an hour-long live stream on my channel with him about Clean Code, Agile, Craftsmanship, and where the industry was heading. If you&rsquo;ve never watched it, I recommend it:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/ycvaECDc31w"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>Clean Code specifically set a standard: code is written once but read dozens of times. The programmer&rsquo;s job isn&rsquo;t just to make it work. It&rsquo;s to make it work <strong>in a way that another programmer can understand, modify, and not break</strong>. Meaningful names, small functions, one responsibility per class, no duplication, automated tests, clear structure. The target audience was always another human being sitting at an editor trying to figure out what the first one did.</p>
<p>In 2026, that audience changed.</p>
<h2>The audience isn&rsquo;t human anymore<span class="hx:absolute hx:-mt-20" id="the-audience-isnt-human-anymore"></span>
    <a href="#the-audience-isnt-human-anymore" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Last week I wrote <a href="/en/2026/04/11/vs-code-is-the-new-punch-card/">VS Code Is the New Punch Card</a>. The thesis is that typing code manually into a text editor is becoming a niche activity, the same way typing binary directly on an Altair front panel became a relic after compilers got good. The AI agent is the new compiler: I describe intent in natural language, the agent navigates code, edits, runs tests, adjusts, delivers.</p>
<p>If that thesis holds, the next question is obvious: <strong>who are we writing code for now?</strong></p>
<p>Not for the human programmer who&rsquo;ll sit down tomorrow to maintain it. It&rsquo;s for the agent that&rsquo;ll read, edit, and extend it. And an agent is not human. Agents have different technical constraints, different biases, different limitations. Part of Clean Code still applies (in some cases more critically). Another part shifts in weight. And new demands emerge that Uncle Bob couldn&rsquo;t have anticipated in 2008.</p>
<p>This article is about that. What&rsquo;s the version of Clean Code that makes sense when the primary reader is an LLM?</p>
<h2>The real agent constraints<span class="hx:absolute hx:-mt-20" id="the-real-agent-constraints"></span>
    <a href="#the-real-agent-constraints" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before re-ranking, worth reviewing what agents actually face.</p>
<p><strong>File truncation.</strong> Most agent CLIs limit file reads to small ranges. Claude Code reads 2000 lines per chunk by default. Cursor, Codex, Windsurf, all have similar caps. A giant file simply doesn&rsquo;t fit into the context window in one shot. The agent has to ask for piece by piece, or worse, use grep and reconstruct mentally.</p>
<p><strong>Attention degrades with context.</strong> Claude Opus has a 200k-token window, Sonnet 1M, Gemini 1M. Sounds like a lot. In practice, &ldquo;needle in haystack&rdquo; tests show retrieval quality drops well before the claimed limit. Flash Attention and variants speed up the computation, but they don&rsquo;t replace full native attention. The more you stuff into the window, the worse the detail precision. And the agent&rsquo;s context isn&rsquo;t just your code: it holds CLAUDE.md, the system prompt, chat history, tool output, error logs, test output. All competing for the same window.</p>
<p><strong>Grep is cheaper than read.</strong> The agent knows this. It prefers <code>rg &quot;funcName&quot;</code> over loading a whole file. It&rsquo;s faster, uses fewer tokens, hits the target. Unique, distinctive names make that much more effective. This isn&rsquo;t a shortcut, it&rsquo;s an architectural choice: I wrote about this in detail in <a href="/en/2026/04/06/rag-is-dead-long-context/">Is RAG Dead? Long Context, Grep, and the End of the Mandatory Vector DB</a>, showing that Claude Code itself navigates a repo with <code>Glob</code> and <code>Grep</code>, no vector DB, no embedding, and that&rsquo;s not a deficiency, it&rsquo;s mature design. Lexical search + smart reader consuming raw text beats dense retriever + top-k on practically every real-domain benchmark. You benefit from that when you organize your code: greppable names aren&rsquo;t just &ldquo;nice for humans&rdquo;, they&rsquo;re the agent&rsquo;s primary navigation API.</p>
<p><strong>Tool calls cost tokens.</strong> Every <code>Read</code> or <code>Edit</code> or <code>Bash</code> burns input and output tokens. Short files, small test output, concise logs — all of that keeps the agent productive and the bill low.</p>
<p><strong>Latency matters.</strong> Agent in a loop, each tool call adds seconds. A large file slow to process becomes perceptible session friction.</p>
<p><strong>Grepping by visual pattern is hard.</strong> If you used inconsistent indentation, mixed tabs and spaces, varied brace style between files, the agent spends tokens internalizing the mess. Consistency helps.</p>
<p>From those constraints, Clean Code principles can be re-ranked by relevance for agent-facing work.</p>
<h2>Re-ranking: Clean Code in the age of agents<span class="hx:absolute hx:-mt-20" id="re-ranking-clean-code-in-the-age-of-agents"></span>
    <a href="#re-ranking-clean-code-in-the-age-of-agents" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Most important to least. A caveat: it&rsquo;s not that the ones at the bottom stop mattering. It&rsquo;s that the ones at the top started mattering MUCH more.</p>
<h3>1. Small functions (and small files)<span class="hx:absolute hx:-mt-20" id="1-small-functions-and-small-files"></span>
    <a href="#1-small-functions-and-small-files" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Uncle Bob: &ldquo;functions should do ONE thing, they should do it WELL, and they should do it only&rdquo;. Ideal size 4 to 20 lines, per the book.</p>
<p>For an agent, that recommendation became a technical obligation. A small function fits in a single tool call without truncation. A short file (keep it under 500 lines, ideally 200-300) fits in a single read. If the agent can grab the whole unit of meaning in one call, it reasons about it with full attention. If it has to paginate, it builds a fragmented mental model, and each fragment costs attention.</p>
<p>Before, &ldquo;small function&rdquo; was good for humans because it aided reading. Today, &ldquo;small function&rdquo; is good because it matches the model&rsquo;s unit of processing. If there&rsquo;s one recommendation to take to heart, it&rsquo;s this one.</p>
<h3>2. Single Responsibility Principle (SRP)<span class="hx:absolute hx:-mt-20" id="2-single-responsibility-principle-srp"></span>
    <a href="#2-single-responsibility-principle-srp" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Each module does one thing and has one reason to change. It was already the heart of Clean Code. For an agent, it becomes even more critical because:</p>
<ul>
<li>The agent can isolate the unit to understand without loading the rest of the system</li>
<li>You can run focused tests on it</li>
<li>You can edit without fearing side effects</li>
<li>Grepping by responsibility becomes predictable</li>
</ul>
<p>Code with tangled responsibilities forces the agent to load way more context for any simple change. An 800-line class that does three things is worse for the agent than three 250-line classes, even if the total is the same.</p>
<h3>3. Meaningful and unique names<span class="hx:absolute hx:-mt-20" id="3-meaningful-and-unique-names"></span>
    <a href="#3-meaningful-and-unique-names" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Clean Code already preached: names reveal intention, no disinformation, distinctive, pronounceable, searchable. For the agent, &ldquo;searchable&rdquo; became the most important property of that list.</p>
<p>The agent searches code via grep/ripgrep all the time. A generic name (<code>data</code>, <code>process</code>, <code>handler</code>, <code>Manager</code>, <code>Service</code>) returns fifty matches and forces the agent to read each one. A distinctive name (<code>UserRegistrationValidator</code>, <code>InvoiceLineItemTotal</code>, <code>ClaudeCodeSessionTracker</code>) returns three matches and the agent goes straight to the right one.</p>
<p>Rule of thumb: if you grep the name and a lot of irrelevant stuff comes back, the name is bad for the agent. If only what matters comes back, the name is right.</p>
<h3>4. Comments with context and provenance<span class="hx:absolute hx:-mt-20" id="4-comments-with-context-and-provenance"></span>
    <a href="#4-comments-with-context-and-provenance" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is where the inversion is most jarring. For Uncle Bob in 2008, the axiom was: &ldquo;good code explains itself, excessive comments are a code smell, every comment is debt that gets stale&rdquo;. Every experienced programmer who read the book absorbed that rule. Well-named code doesn&rsquo;t need comments. Too many comments = flag of bad code trying to justify itself.</p>
<p>Now flip it. <strong>The agent reads comments. And likes them</strong>. Comments become first-class context. The agent has perfect syntax fluency, knows exactly what <code>x++</code> does, doesn&rsquo;t need obvious captions (that kind is still bad, see item 13). What it DOESN&rsquo;T know is why you chose this approach over the obvious one, what production bug motivated this weird logic, what business constraint forces this specific order, what workaround exists because the upstream lib has known bug #1234, which commit introduced this decision, which Jira issue is the reference. That kind of information is <strong>provenance</strong>: the why of the decision. It only exists in the head of the human who wrote it, in the commit message, or in a well-placed comment. For the agent, the comment is the most accessible source during a tool call.</p>
<p>Docstrings with intent and usage examples also became strong signals. When the agent picks up a function without understanding the context, a header docstring (JSDoc-style with examples, Python <code>&quot;&quot;&quot;</code>, Rust <code>///</code>) drastically shortens the path to a correct change. Uncle Bob was skeptical of JavaDoc in 2008 because they got stale. Today, with the agent able to rewrite the docstring alongside the code, that counter-argument lost weight.</p>
<p>A practical consequence: <strong>don&rsquo;t prune the comments the agent writes</strong>. If you have the reflex &ldquo;verbose comment is noise&rdquo; inherited from the original Clean Code era, that rule flipped. The agent wrote that comment because, in the act of generating the code, it decided that information was worth preserving for future edits. Removing the comment in code review strips context that the agent itself will want to read on the next interaction. Let the agent comment. It knows what it&rsquo;s doing. The only kind of agent-authored comment worth removing is the obvious redundant one (item 13), and modern models rarely produce those if the system prompt is well written.</p>
<h3>5. Explicit types<span class="hx:absolute hx:-mt-20" id="5-explicit-types"></span>
    <a href="#5-explicit-types" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This isn&rsquo;t in 2008&rsquo;s Clean Code because the industry hadn&rsquo;t converted yet. But in 2026 it&rsquo;s a fundamental criterion.</p>
<p>Python without type hints, JavaScript instead of TypeScript, Ruby without RBS. Dynamic code without annotations forces the agent to infer types from usage, which costs reasoning and gets it wrong frequently. Typed code gives an immediate answer key: the signature says what goes in, what comes out, which states are valid. The agent saves discovery work and makes fewer mistakes.</p>
<p>If you&rsquo;re still on Python 3 without type hints, the transition will boost agent productivity more than any logic refactor.</p>
<h3>6. DRY (Don&rsquo;t Repeat Yourself)<span class="hx:absolute hx:-mt-20" id="6-dry-dont-repeat-yourself"></span>
    <a href="#6-dry-dont-repeat-yourself" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Clean Code already said duplication is the root of all evil. For an agent, duplication is worse than for a human for one specific reason: when the agent has to change something that&rsquo;s replicated, it can update one copy and forget the others. The attention window doesn&rsquo;t have natural gravity pulling &ldquo;oh, there are two more copies of this in other files&rdquo;. The agent has to find each one via grep, and if the pattern has subtle variation between copies, the result ends up inconsistent.</p>
<p>Factoring into a reusable function or module isn&rsquo;t aesthetic. It&rsquo;s automated-refactor safety.</p>
<h3>7. Tests the agent can run<span class="hx:absolute hx:-mt-20" id="7-tests-the-agent-can-run"></span>
    <a href="#7-tests-the-agent-can-run" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Uncle Bob dedicates a full chapter to Unit Tests and F.I.R.S.T (Fast, Independent, Repeatable, Self-Validating, Timely). All of it still applies, with an important addendum: <strong>the test has to be executable by the agent without human setup</strong>.</p>
<p>Meaning: the command to run the test is in the README or CLAUDE.md, in the <code>Makefile</code>, in the <code>package.json</code>. Output has a predictable format the agent parses. It doesn&rsquo;t depend on manually seeding the database, on a config file that isn&rsquo;t in the repo, on a secret credential. The agent writes code, runs tests, reads output, adjusts, runs again. That cycle is the foundation. If the tests don&rsquo;t run headless, the agent goes blind.</p>
<p>Here I speak from field experience. I documented this in <a href="/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/">From Zero to Post-Production in 1 Week Using AI</a>, where I went hard on a real project: 274 commits in 8 days, 4 integrated applications, 1,323 automated tests by the end. What made it work wasn&rsquo;t &ldquo;AI programs on its own&rdquo;. It was <strong>Extreme Programming with the agent as pair instead of a human pair</strong>. Running tests on every commit, tight CI, coverage above 80% (95%+ on business logic), test-line-to-code-line ratio above 1:1 on some modules. Sounds like overkill. It isn&rsquo;t. In 274 commits, CI caught real bugs more than 50 times, bugs that would have gone straight to production if I had blindly trusted the agent. Without tests, the agent hands you plausible code that silently breaks something that worked yesterday. With strong tests, the agent becomes a multiplier: it generates a test, the test validates the code it wrote, the test is the safety net for the next change it makes. Virtuous loop.</p>
<p>XP practices (pair programming, CI, tests first, continuous refactoring, short feedback) didn&rsquo;t become obsolete. They became <strong>exactly the right way to work with an agent</strong>. Whoever programs in cowboy mode without tests today isn&rsquo;t rebellious. They&rsquo;re just slow, because the agent without tests keeps guessing, and guesses need manual review, which kills the speed the agent should bring. Good tests with good coverage became the difference between a productive agent and an agent that keeps flailing. Or, put another way: <strong>TDD became a technical obligation, not a philosophy</strong>.</p>
<p>I covered this theme from another angle in <a href="/en/2026/03/01/software-is-never-done-4-projects-life-after-deploy-one-shot-prompt-myth/">Software Is Never &ldquo;Done&rdquo;</a>, showing that post-deploy life is where tests matter most: in ten days of operation after launch, I ran 56 commits of fixes, hardening, and adjustment in response to real behavior, and each commit came with a regression test. Without the net, each of those 56 commits would be an opportunity to break something that worked yesterday. TDD isn&rsquo;t a phase, it&rsquo;s a habit.</p>
<h3>8. Predictable directory structure<span class="hx:absolute hx:-mt-20" id="8-predictable-directory-structure"></span>
    <a href="#8-predictable-directory-structure" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Clean Code barely discusses this (it was more focused on code inside a file). For an agent, tree organization matters. If <code>src/controllers/users.rb</code> implies <code>src/models/user.rb</code> and <code>src/views/users/</code>, the agent can anticipate paths without listing directories. If the project uses idiosyncratic naming (random files, unpatterned names, everything flat in one folder), the agent loses time with <code>find</code>.</p>
<p>Strong framework conventions (Rails, Django, Next.js, Laravel) help the agent a lot. Project without convention, the agent builds one over time, but until then it burns tokens exploring.</p>
<h3>9. Dependency Injection and Testability<span class="hx:absolute hx:-mt-20" id="9-dependency-injection-and-testability"></span>
    <a href="#9-dependency-injection-and-testability" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Code with injected dependencies (not hardcoded) is easier to test in isolation. The agent benefits from this. It can swap the real <code>EmailSender</code> for a <code>FakeEmailSender</code> in a test without touching the logic. Code that instantiates its dependencies internally forces the agent into monkey-patch-fake-server-hacks that are slow, fragile, and pollute the session with infra grime.</p>
<p>DI isn&rsquo;t ceremony. It&rsquo;s isolation scope. And in a real project, DI quickly becomes a load-bearing refactor: on one of my projects (the <a href="/en/2026/03/01/software-is-never-done-4-projects-life-after-deploy-one-shot-prompt-myth/">M.Akita Chronicles</a>), I discovered after launch that I needed to swap the default LLM model to another provider. The environment variable had existed from the start. But the model name was still hardcoded in references across 24 files. A whole commit (<code>Centralize LLM model config</code>) touched all 24 to isolate the config into a single constant. Swapping models after that became a one-line change. That&rsquo;s exactly the kind of refactor that only shows up after software meets reality, and it&rsquo;s where DI and config isolation pay dearly if you didn&rsquo;t do it earlier.</p>
<h3>10. Avoid deep nesting<span class="hx:absolute hx:-mt-20" id="10-avoid-deep-nesting"></span>
    <a href="#10-avoid-deep-nesting" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Clean Code talks about single level of abstraction per function. A corollary: avoid <code>if</code> inside <code>for</code> inside <code>if</code> inside <code>try</code>. Every indentation level is more attention the model has to spend tracking state. Four levels of indentation is MUCH more cognitively expensive for the agent than two levels with early return.</p>
<p>Pattern matching, guard clauses, early returns, flattening logic, all of this improves readability for the model the same way it improves it for the human, except measurably so, because the cost is measured in response quality.</p>
<h3>11. Errors with context<span class="hx:absolute hx:-mt-20" id="11-errors-with-context"></span>
    <a href="#11-errors-with-context" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>raise ValueError(&quot;invalid input&quot;)</code> doesn&rsquo;t help the agent when it reads the stack trace. <code>raise ValueError(f&quot;invalid input: received {repr(x)}, expected non-empty string of digits&quot;)</code> does. The agent uses exception messages as debug signal. Vague message = agent runs an extra round to figure out what went wrong.</p>
<p>Uncle Bob talked about this in Error Handling: &ldquo;Provide context with exceptions&rdquo;. It became critical now.</p>
<h3>12. Formatting and style<span class="hx:absolute hx:-mt-20" id="12-formatting-and-style"></span>
    <a href="#12-formatting-and-style" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Don&rsquo;t waste time on this. Use the default or most popular formatter for your language: <code>cargo fmt</code> for Rust, <code>gofmt</code> for Go, <code>prettier</code> for JS/TS, <code>black</code> or <code>ruff</code> for Python, <code>rubocop -A</code> for Ruby. Configure it in pre-commit, configure it in your editor to run on save, and move on. The agent handles any consistent style just fine, and the auto-formatter keeps the diff tidy between commits. Tab vs space, 80 vs 100 columns, brace style, all of that became noise. The formatter decides, you accept.</p>
<h3>13. Comments that describe the obvious<span class="hx:absolute hx:-mt-20" id="13-comments-that-describe-the-obvious"></span>
    <a href="#13-comments-that-describe-the-obvious" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Last on the list. Still bad, got even worse. Comments like <code>// increment i by 1</code> above <code>i++</code> waste the agent&rsquo;s tokens the same way they wasted the human&rsquo;s patience. The model knows how to read code, it doesn&rsquo;t need obvious captions.</p>
<p>If you have the habit of writing obvious comments because some school taught you that way, this is the moment to stop. In 2008 it was bad because it polluted visual space. In 2026 it&rsquo;s bad because it costs real money in tokens.</p>
<h2>What Uncle Bob couldn&rsquo;t foresee<span class="hx:absolute hx:-mt-20" id="what-uncle-bob-couldnt-foresee"></span>
    <a href="#what-uncle-bob-couldnt-foresee" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Beyond re-ranking what was in the book, some new things emerged that are specific to the agent world:</p>
<p><strong>Meta-documentation files for agents.</strong> <code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>.cursor/rules</code>, and so on. These are files the agent reads before any tool call, describing project conventions, important commands, caveats, things that don&rsquo;t go in a docstring. Writing these files is a new skill: short, direct, imperative, action-oriented. No philosophical prose. Bullet points of what the agent needs to know to not mess up.</p>
<p><strong>README with high-level architecture.</strong> Uncle Bob barely cared about README (the book is about code). For the agent, well-made READMEs drastically shorten the path to understanding the shape of the project. A simple ASCII or Mermaid diagram helps.</p>
<p><strong>Structured logging.</strong> JSON logs with named fields are much more useful for the agent than prose logs. The agent parses JSON trivially, uses fields to filter relevant errors, correlates across services. A loose <code>printf</code> in free text forces heuristic parsing.</p>
<p><strong>Accessible observability commands.</strong> <code>pnpm test</code>, <code>make lint</code>, <code>cargo check</code>, <code>python -m mypy</code> — the more the project exposes predictable commands the agent can invoke to validate changes, the better. If running tests requires 10 manual setup steps, the agent won&rsquo;t run tests, and the feedback loop breaks.</p>
<p><strong>Idempotent setup scripts.</strong> The agent has to be able to run <code>bin/setup</code> or <code>scripts/bootstrap.sh</code> on a clean machine and reach a state where it can work. If onboarding depends on instructions in someone&rsquo;s head, the agent is locked out.</p>
<h2>Instructing the agent to write clean code<span class="hx:absolute hx:-mt-20" id="instructing-the-agent-to-write-clean-code"></span>
    <a href="#instructing-the-agent-to-write-clean-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s an important detail that only becomes clear after about 500 hours of using agents: <strong>no LLM does any of this by default</strong>. You tell it &ldquo;implement feature X&rdquo; and it&rsquo;ll implement it the way the model considers average. No dependency injection. 80-line functions. No tests, or tests that mock the wrong thing. Duplicated logic because it&rsquo;s faster. 2000-line files because &ldquo;everything&rsquo;s in one place&rdquo;. You need to WRITE these rules. The agent reads, the agent follows.</p>
<p>Where to write: <code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>.cursor/rules</code>, <code>.github/copilot-instructions.md</code>, depending on the CLI. Format: short, imperative, action-oriented. The agent reads these files on every iteration (Claude Code re-reads CLAUDE.md on every query), so each line burns context tokens — density matters.</p>
<p>Below is a proposal for a template you can drop into a <code>CLAUDE.md</code> on a new project, consolidating what I discussed above in a format the agent consumes. <strong>This is not a definitive version</strong>, it&rsquo;s a starting point to test and tune to your language, your team, your flow. If a rule doesn&rsquo;t fit your context, remove it. If you need a new rule, add it. The point is to have a structured skeleton:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Code style
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> Functions: 4-20 lines. Split if longer.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Files: under 500 lines. Split by responsibility.
</span></span><span class="line"><span class="cl"><span class="k">-</span> One thing per function, one responsibility per module (SRP).
</span></span><span class="line"><span class="cl"><span class="k">-</span> Names: specific and unique. Avoid <span class="sb">`data`</span>, <span class="sb">`handler`</span>, <span class="sb">`Manager`</span>.
</span></span><span class="line"><span class="cl">  Prefer names that return &lt;5 grep hits in the codebase.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Types: explicit. No <span class="sb">`any`</span>, no <span class="sb">`Dict`</span>, no untyped functions.
</span></span><span class="line"><span class="cl"><span class="k">-</span> No code duplication. Extract shared logic into a function/module.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Early returns over nested ifs. Max 2 levels of indentation.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Exception messages must include the offending value and expected shape.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Comments
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> Keep your own comments. Don&#39;t strip them on refactor — they carry
</span></span><span class="line"><span class="cl">  intent and provenance.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Write WHY, not WHAT. Skip <span class="sb">`// increment counter`</span> above <span class="sb">`i++`</span>.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Docstrings on public functions: intent + one usage example.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Reference issue numbers / commit SHAs when a line exists because
</span></span><span class="line"><span class="cl">  of a specific bug or upstream constraint.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Tests
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> Tests run with a single command: <span class="sb">`&lt;project-specific&gt;`</span>.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Every new function gets a test. Bug fixes get a regression test.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Mock external I/O (API, DB, filesystem) with named fake classes,
</span></span><span class="line"><span class="cl">  not inline stubs.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Tests must be F.I.R.S.T: fast, independent, repeatable,
</span></span><span class="line"><span class="cl">  self-validating, timely.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Dependencies
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> Inject dependencies through constructor/parameter, not global/import.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Wrap third-party libs behind a thin interface owned by this project.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Structure
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> Follow the framework&#39;s convention (Rails, Django, Next.js, etc.).
</span></span><span class="line"><span class="cl"><span class="k">-</span> Prefer small focused modules over god files.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Predictable paths: controller/model/view, src/lib/test, etc.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Formatting
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> Use the language default formatter (<span class="sb">`cargo fmt`</span>, <span class="sb">`gofmt`</span>, <span class="sb">`prettier`</span>,
</span></span><span class="line"><span class="cl">  <span class="sb">`black`</span>, <span class="sb">`rubocop -A`</span>). Don&#39;t discuss style beyond that.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Logging
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">-</span> Structured JSON when logging for debugging / observability.
</span></span><span class="line"><span class="cl">- Plain text only for user-facing CLI output.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This block fits in under 100 lines and costs about 500 tokens per iteration. Sounds like a lot, but the savings in code quality and absence of rework easily make up for it, especially if you&rsquo;re on a pay-per-token API account. Use it as a base and evolve it with your own experience.</p>
<p>Some items (SRP, small functions, tests) the agent will try to do on its own. Others (DI, strict DRY, explicit types EVERYWHERE, aggressively unique names) it only does when you say so explicitly. And some (like &ldquo;don&rsquo;t strip the comments you wrote yourself on a refactor&rdquo;) are so counterintuitive to its default training that without the instruction it WILL prune them. Hence the importance of having a rules file that gets read every iteration.</p>
<p>An analogous pattern shows up around defensive programming: the agent implements circuit breaker, retry with backoff, aggressive timeout, graceful degradation, all of it nicely — <strong>when you ask</strong>. But on its own it won&rsquo;t propose them. The agent doesn&rsquo;t know what your system&rsquo;s operational failure points are, so it implements the happy path and waits for instruction. If your CLAUDE.md lists the categories of defensive code the project needs (rate limit, retry, breaker, fallback), the agent covers them. If it doesn&rsquo;t list them, the agent doesn&rsquo;t invent them. It&rsquo;s another case where explicit instruction to the agent is what separates robust code from naive code.</p>
<p>If you use the same agent across different projects, it&rsquo;s worth having a base template and adding project-specific rules on top. But start with something like the above and iterate from there.</p>
<h2>The short version<span class="hx:absolute hx:-mt-20" id="the-short-version"></span>
    <a href="#the-short-version" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Uncle Bob wrote Clean Code to be read by other humans. In 2026, the primary reader became the agent. The good news is that most of what the book preached still holds. The bad news is that some things that were opinions (&ldquo;a file should have N lines&rdquo;) became technical constraints (&ldquo;a file with X lines makes the agent perform worse&rdquo;). The difference is that now there&rsquo;s a metric: token cost, tool-call latency, output quality. Whoever writes clean code for the agent saves API-bill money, session time, and gets less hallucination in the output.</p>
<p>And there&rsquo;s a cultural bonus worth registering: all these practices (XP, TDD, SOLID, SRP, DI, small code, abundant tests) were falling out of fashion in the 2010s, replaced by &ldquo;move fast and break things&rdquo; and two-month bootcamps. Programmers who invested in fundamentals became a minority, and it became fashion to trash Uncle Bob on the internet. Turns out those very fundamentals became the technical differentiator of working with agents. Whoever kept the discipline is well served. Whoever dismissed it is now struggling to teach the agent not to commit mistakes the XP crowd had mapped out 25 years ago.</p>
<p>Clean code was never fashion. It became infrastructure.</p>
]]></content:encoded><category>clean-code</category><category>AI</category><category>claude-code</category><category>vibecoding</category><category>software-engineering</category></item><item><title>My Favorite Retro Racing Games Running on My Distrobox</title><link>https://akitaonrails.github.io/en/2026/04/19/my-favorite-retro-racing-games-on-distrobox/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/19/my-favorite-retro-racing-games-on-distrobox/</guid><pubDate>Sun, 19 Apr 2026 20:00:00 GMT</pubDate><description>&lt;p&gt;I spent the whole Sunday on this, and it was one of the most productive Sundays I&amp;rsquo;ve had in a while. The mission was specific: close the loop on the simcade racing games that are hardest to emulate, the ones I&amp;rsquo;ve wanted to run properly for years, and only now got to a reliable state.&lt;/p&gt;
&lt;p&gt;Two monsters sit at the top of that list. First, &lt;strong&gt;&lt;a href="#driveclub-the-impossible-finally-possible"&gt;Driveclub&lt;/a&gt;&lt;/strong&gt; on shadPS4. It&amp;rsquo;s a PS4 exclusive, never got a port, Evolution Studios was shut down, no remaster. To play it outside the original PS4, emulation is the only option, and PS4 emulation is still the most immature part of the ecosystem. This is &lt;strong&gt;by far the hardest on the list&lt;/strong&gt;.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I spent the whole Sunday on this, and it was one of the most productive Sundays I&rsquo;ve had in a while. The mission was specific: close the loop on the simcade racing games that are hardest to emulate, the ones I&rsquo;ve wanted to run properly for years, and only now got to a reliable state.</p>
<p>Two monsters sit at the top of that list. First, <strong><a href="#driveclub-the-impossible-finally-possible">Driveclub</a></strong> on shadPS4. It&rsquo;s a PS4 exclusive, never got a port, Evolution Studios was shut down, no remaster. To play it outside the original PS4, emulation is the only option, and PS4 emulation is still the most immature part of the ecosystem. This is <strong>by far the hardest on the list</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/19/distrobox-gaming/shadps4-launcher.png" alt="shadPS4 Qt Launcher showing DRIVECLUB CUSA00003 ready to run"  loading="lazy" /></p>
<p>Second, <strong><a href="#forza-motorsport-4-the-goat">Forza Motorsport 4 with Project Forza Plus</a></strong> on Xenia Canary. FM4 is the GOAT of the Xbox 360 era, Project Forza Plus is the community mod that consolidates patches and content, and getting the two to run together on Xenia with decent graphics, no audio crashes and no shadow bugs took serious hours of trial and error.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/19/distrobox-gaming/xenia-manager.png" alt="Xenia Manager with the Xbox 360 Forza collection installed"  loading="lazy" /></p>
<p>Around those two I organized the rest of what I already knew worked at some level, just needed consolidating into a reproducible setup: <strong><a href="#gran-turismo-4-spec-ii-mod">Gran Turismo 4 with Retexture 3.0 and HD HUD</a></strong>, <strong><a href="#gran-turismo-3-a-spec">Gran Turismo 3 with HD textures and widescreen</a></strong>, <strong><a href="#gran-turismo-2-spec-ii-mod">Gran Turismo 2 with the Spec II mod</a></strong>, the PS3 Gran Turismos (<a href="#gran-turismo-5">GT5</a> and <a href="#gran-turismo-6">GT6</a>), Forza Horizon <a href="#forza-horizon-1-with-xe-mod">1</a> and <a href="#forza-horizon-2">2</a>, the <a href="#project-gotham-racing-3-and-4">PGRs</a>, <a href="#ridge-racer-v">Ridge Racer</a>, <a href="#enthusia-professional-racing">Enthusia</a>, <a href="#colin-mcrae-rally-1-and-2">Colin McRae</a>. I had an idea how to make these work, I just needed a setup that doesn&rsquo;t break the next time I reinstall the system.</p>
<p>Before the details, two things to clarify.</p>
<p>First, on infra: I&rsquo;m not going to repeat here how the setup was built. I wrote that in detail in <a href="/en/2026/04/11/emulation-distrobox-with-claude-code/">An Emulation Distrobox with Claude Code</a>. Arch Linux in a distrobox with <code>--nvidia</code>, 17 Ansible roles, every emulator from the AUR, ES-DE as frontend, automated per-game configs, Python scripts for PS3 update checking and Xbox 360 title updates, Xenia via Wine. Go read that one. The repo is at <a href="https://github.com/akitaonrails/distrobox-gaming"target="_blank" rel="noopener">akitaonrails/distrobox-gaming</a> if you want to reproduce. Here I&rsquo;m going to talk about the games.</p>
<p>Second, on taste: I like simcade racing. Gran Turismo has been my declared addiction since PS1, and I wrote about the rest in the <a href="/en/2026/04/01/my-sim-racing-cockpit-formula-fx1/">Formula FX1 cockpit article</a>. Direct-drive wheel, load-cell pedal, triple monitor when I&rsquo;m feeling masochistic. That&rsquo;s the kind of racing I like.</p>
<h2>The warning for the &ldquo;well, actually&rdquo; crowd<span class="hx:absolute hx:-mt-20" id="the-warning-for-the-well-actually-crowd"></span>
    <a href="#the-warning-for-the-well-actually-crowd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Yes, I know iRacing and the rest of the current sims and simcades. I have 12 TB of ISOs and ROMs on the NAS, I probably have one you&rsquo;d remember to mention. It&rsquo;s not for lack of options that I&rsquo;m focusing on emulation today.</p>
<p>On the new side, the games I&rsquo;m genuinely anticipating: <strong><a href="https://forza.net/"target="_blank" rel="noopener">Forza Horizon 6</a> in Tokyo</strong>, <strong><a href="https://www.assettocorsa.it/"target="_blank" rel="noopener">Assetto Corsa EVO</a></strong>, and <strong><a href="https://www.assettocorsa.it/"target="_blank" rel="noopener">Assetto Corsa Rally</a></strong>. I&rsquo;ve been playing the demos of both Assetto Corsa titles and enjoying their single-player campaigns a lot. When those ship, they&rsquo;ll become my main sim rotation. This article covers a different side of the collection, pulling classics out of oblivion that still don&rsquo;t have a remaster.</p>
<p>Today, here, I&rsquo;m interested in these specific games I listed above. Period. You do what you want, I do what I want. If you want iRacing, fire up Steam and go in peace. This article is about messing with emulators, preserving old games, and pulling these gems out of oblivion on a Linux setup. If you&rsquo;re here for current-sim reviews, you won&rsquo;t find them.</p>
<p>Now to what matters.</p>
<p><strong>A note on the videos:</strong> most of them have no audio because I forgot to turn on sound capture in OBS before recording and I was too lazy to re-record. Audio works perfectly on every emulator here; this is my OBS mistake, not a setup issue.</p>
<h2>Global settings per emulator<span class="hx:absolute hx:-mt-20" id="global-settings-per-emulator"></span>
    <a href="#global-settings-per-emulator" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before the games, a quick consolidation of the global configs that apply across each emulator. This is automated in the repo, but if you&rsquo;re setting up by hand, here&rsquo;s the summary.</p>
<h3>DuckStation (PS1)<span class="hx:absolute hx:-mt-20" id="duckstation-ps1"></span>
    <a href="#duckstation-ps1" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Renderer:</strong> Vulkan</li>
<li><strong>Internal Resolution Scale:</strong> 8x (8K internal, downscaled to the monitor)</li>
<li><strong>Texture Filtering:</strong> JINC2 (preserves pixel art but smooths)</li>
<li><strong>PGXP:</strong> on (fixes the typical PS1 vertex wobble)</li>
<li><strong>Widescreen Hack:</strong> on as fallback, but I prefer per-game widescreen cheats</li>
<li><strong>Aspect Ratio:</strong> 16:9 with widescreen cheat, 4:3 without</li>
</ul>
<p>PS1 runs smoothly on DuckStation these days. It&rsquo;s the most mature emulator on the list. The attention goes to per-game choices (some games need a specific widescreen cheat).</p>
<h3>PCSX2 (PS2)<span class="hx:absolute hx:-mt-20" id="pcsx2-ps2"></span>
    <a href="#pcsx2-ps2" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Renderer:</strong> Vulkan</li>
<li><strong>Upscale:</strong> 4x SSAA (1440p native → 4K internal)</li>
<li><strong>Anisotropic Filtering:</strong> 16x</li>
<li><strong>Post Processing:</strong> FXAA on, PCRTC antiblur on</li>
<li><strong>Deinterlacing:</strong> Automatic (some games need override)</li>
<li><strong>Controller:</strong> Xbox-style bindings (I use an 8BitDo)</li>
</ul>
<p>PCSX2 2.7.x is much better than the 2.6.3 still in Arch&rsquo;s official repo. The AUR&rsquo;s <code>pcsx2-latest-bin</code> tracks 2.7.x and that&rsquo;s where texture replacement, modern FXAA and PCRTC antiblur live. If you&rsquo;re on a different distro stuck on 2.6.3, get the official AppImage.</p>
<h3>RPCS3 (PS3)<span class="hx:absolute hx:-mt-20" id="rpcs3-ps3"></span>
    <a href="#rpcs3-ps3" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Renderer:</strong> Vulkan</li>
<li><strong>Resolution Scale:</strong> 300% (generally) or native (for games with upscale issues)</li>
<li><strong>Shader Precision:</strong> Ultra</li>
<li><strong>Force High Precision Z:</strong> on</li>
<li><strong>SPU XFloat:</strong> Accurate</li>
<li><strong>Multithreaded RSX:</strong> on</li>
<li><strong>Write Color Buffers (WCB) / Read Color Buffers (RCB):</strong> off by default (GT-series safety)</li>
</ul>
<p>RPCS3 is where the most traps are. Each game has a recommended preset in the <a href="https://rpcs3.net/compatibility"target="_blank" rel="noopener">official compatibility DB</a>. And a heads-up: per-game configs live in <code>~/.config/rpcs3/custom_configs/config_&lt;SERIAL&gt;.yml</code>. The <code>config_</code> prefix is mandatory. It cost me time to figure out. RPCS3 silently accepts the YAML and then ignores it if the name is wrong.</p>
<h3>Xenia Canary (Xbox 360)<span class="hx:absolute hx:-mt-20" id="xenia-canary-xbox-360"></span>
    <a href="#xenia-canary-xbox-360" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Build:</strong> Canary (<code>master</code> has been stagnant for months, Canary is where development lives)</li>
<li><strong>Renderer:</strong> Vulkan</li>
<li><strong>Render Target Path:</strong> <code>rtv</code> by default, <code>fsi</code> for specific games (PGR4)</li>
<li><strong>User Profile:</strong> created once, persists</li>
<li><strong>Wine Prefix:</strong> dedicated, managed by Xenia Manager</li>
</ul>
<p>Xenia on Linux runs via Wine, managed by <a href="https://github.com/xenia-manager/xenia-manager"target="_blank" rel="noopener">Xenia Manager</a>. There&rsquo;s no decent native build. It works well, but each game needs specific tuning, and Title Updates have to be pulled manually from archive.org (I wrote a script for that, details in the previous article).</p>
<h3>shadPS4 (PS4)<span class="hx:absolute hx:-mt-20" id="shadps4-ps4"></span>
    <a href="#shadps4-ps4" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Gets its own section at the end. TL;DR: works for some simple games, Driveclub is still a moving target. Still a shot in the dark.</p>
<h2>PS1 on DuckStation<span class="hx:absolute hx:-mt-20" id="ps1-on-duckstation"></span>
    <a href="#ps1-on-duckstation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Gran Turismo (the first, from 1997)<span class="hx:absolute hx:-mt-20" id="gran-turismo-the-first-from-1997"></span>
    <a href="#gran-turismo-the-first-from-1997" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/gran%20turismo%201.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>This one is pure sentiment. GT1 was one of the first games I bought for the PS1 and I played it until the CD cracked. At the time, it was on another level. Real simulation on a machine with 128K of video RAM, decent physics, 140 cars when everybody else had 15, memorable soundtrack.</p>
<p>But let&rsquo;s be honest: you can&rsquo;t go back today. GT1&rsquo;s physics has exaggerated drift, the car slides more than it should on any corner above 80 km/h. It&rsquo;s part of the 1997 charm but gets annoying in 2026. The <strong>handling model was refined starting in GT2</strong>, and everything after only got better. Between GT1 and GT2, GT2 wins on content (650+ cars vs 140) and especially on drivability.</p>
<p>I keep GT1 in ES-DE to open in nostalgic moments, drive 10 minutes at Trial Mountain, and close it. For serious PS1-era gameplay, it&rsquo;s GT2.</p>
<h3>Gran Turismo 2 (Spec II Mod)<span class="hx:absolute hx:-mt-20" id="gran-turismo-2-spec-ii-mod"></span>
    <a href="#gran-turismo-2-spec-ii-mod" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/gran%20turismo%202.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>The original PS1 Gran Turismo is a historical milestone. I played a lot of it back then. To replay today? No. GT2 exists, and GT2 is superior in everything. More cars (650+), more tracks, more modes, and most importantly, <strong>a much better handling model</strong>. GT1 has that exaggerated drift where the car slides more than it should, GT2 tightened that up to something closer to what the series would become starting on PS2. Between playing GT1 or GT2, always GT2.</p>
<p>And the GT2 I run today is the <strong>GT2 Spec II Mod</strong> by <a href="https://x.com/projectaspec?lang=en"target="_blank" rel="noopener">Project A-Spec</a>, a community project that merges the two regional variants (Arcade + Simulation) into a single game, brings back events that were cut from the final release, fixes physics bugs, adds native widescreen support, and ships quality-of-life menu updates. It&rsquo;s the definitive way to play GT2 in 2026. The project doesn&rsquo;t maintain its own website anymore, updates come through the author&rsquo;s Twitter.</p>
<p>To run it:</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Disc</td>
          <td>GT2 Spec II Mod (patch applied over Simulation USA ISO)</td>
      </tr>
      <tr>
          <td>Serial</td>
          <td>SCUS-94455</td>
      </tr>
      <tr>
          <td>Widescreen cheat</td>
          <td>On</td>
      </tr>
      <tr>
          <td>8 MB RAM cheat</td>
          <td>On (fixes audio on crowded tracks)</td>
      </tr>
      <tr>
          <td>Texture Filter</td>
          <td>JINC2</td>
      </tr>
      <tr>
          <td>Resolution Scale</td>
          <td>8x</td>
      </tr>
  </tbody>
</table>
<p>The 8 MB RAM cheat matters for crowded tracks (Seattle in populated races for example) where audio starts clipping. It simulates the memory expansion of the Japanese version that never made it West.</p>
<h3>Colin McRae Rally 1 and 2<span class="hx:absolute hx:-mt-20" id="colin-mcrae-rally-1-and-2"></span>
    <a href="#colin-mcrae-rally-1-and-2" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Iconic PS1 games. CMR1 has that rough first-gen rally feel that has its charm. CMR2 is technically superior, better physics, more rallies, better graphics. But honestly: <strong>to play CMR2 today, it&rsquo;s worth looking for the original PC version</strong>. Higher resolution, native 60fps (the PS1 version is locked to 30), direct keyboard/wheel support. Codemasters repacks exist on the internet, a 40-second search will find them. I&rsquo;m not going to link them, you know where to look.</p>
<p>On PS1 via DuckStation they run smoothly, nostalgia guaranteed, but it&rsquo;s not where I spend my time.</p>
<h2>PS2 on PCSX2<span class="hx:absolute hx:-mt-20" id="ps2-on-pcsx2"></span>
    <a href="#ps2-on-pcsx2" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Gran Turismo 3 A-Spec<span class="hx:absolute hx:-mt-20" id="gran-turismo-3-a-spec"></span>
    <a href="#gran-turismo-3-a-spec" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/gran%20turismo%203.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>GT3 was a generational leap. Out of the blocky PS1 world into shaders, decent environment mapping, serious polygon car models, physics that finally started to feel like actual physics. The soundtrack with Feeder&rsquo;s &ldquo;Just a Day&rdquo; opening the game still gives me goosebumps.</p>
<p>The problem is that GT3 has LESS content than GT2. Polyphony was more cautious, cut modes to focus on polish. The A/B/S license tree is smaller, fewer cars, fewer tracks. But what&rsquo;s there is polished.</p>
<p>In 2026 on PCSX2 2.7.x, GT3 looks great. Community HD textures, widescreen pnach, anti-aliasing via SSAA, 16x AF. Feels like a re-release.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Disc</td>
          <td>GT3 A-spec (SCUS-97102) or Bundle (PBPX-95503)</td>
      </tr>
      <tr>
          <td>Widescreen pnach</td>
          <td>On</td>
      </tr>
      <tr>
          <td>Retexture pack</td>
          <td>Optional (off by default, enable if you want full HD)</td>
      </tr>
      <tr>
          <td>Upscale</td>
          <td>4x SSAA</td>
      </tr>
      <tr>
          <td>Deinterlacing</td>
          <td>Automatic</td>
      </tr>
  </tbody>
</table>
<p>Caveat on the retexture pack: when enabled, some car-showcase cutscenes in the garage have momentary flicker due to NFS streaming. Doesn&rsquo;t bother in-race. If it bothers you in menus, turn it off.</p>
<p><strong>Reminder:</strong> for the widescreen pnach to take effect, you still need to go into the <strong>in-game</strong> options and switch the aspect ratio to 16:9. The pnach unlocks the option in the menu, it doesn&rsquo;t apply automatically.</p>
<h3>Gran Turismo 4 (Spec II Mod)<span class="hx:absolute hx:-mt-20" id="gran-turismo-4-spec-ii-mod"></span>
    <a href="#gran-turismo-4-spec-ii-mod" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/gran%20turismo%204.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>GT4 is my GOAT in the series. 700+ cars, 50+ tracks, physics still respectable by 2026 standards, and that soundtrack mixing alternative rock, Japanese lounge, and Moby&rsquo;s infamous &ldquo;Sail Away&rdquo; on the main menu. It&rsquo;s the game I poured the most hours into back in 2004, and it&rsquo;s what I play the most today.</p>
<p>Like GT2, the GT4 I run today <strong>is not the original disc</strong>. It&rsquo;s the <a href="https://www.theadmiester.co.uk/specii/"target="_blank" rel="noopener"><strong>Gran Turismo 4: Spec II</strong></a> maintained by TheAdmiester — a massive community mod that&rsquo;s objectively the best way to play GT4 in 2026. It combines the final USA disc, the 2003 Beta prototype, and the original Japanese release into one game, restoring cut cars (including several that never left Japan), cancelled events, dozens of missing soundtrack tracks, adding 16:9 widescreen support, over 30 built-in cheats exposed as menu options (pro tuning, top speed, fuel economy), the option to switch between English and Japanese, and original bug fixes. The project is active, still gets patches, and ships its own installer that applies over a clean USA ISO.</p>
<p>In practice, Spec II is what GT4 should have been if Polyphony had six more months to polish. Playing it today feels like GT4 with all the content that got left out, plus modern quality-of-life. For anyone who enjoyed the original, it&rsquo;s mandatory.</p>
<p>On top of that goes the community&rsquo;s <strong>Retexture 3.0</strong> (HD texture pack) and the <strong>HD HUD</strong> by <a href="https://github.com/Silentwarior112/GT4-HD-HUD-Pack"target="_blank" rel="noopener">Silentwarior112</a>. With those three pieces together (Spec II Mod + Retexture + HD HUD), GT4 runs at 1440p SSAA on PCSX2 2.7.x with 16x AF, FXAA, and a look that&rsquo;s honestly indistinguishable from a new low-poly-style release. It&rsquo;s my current favorite to sit down and play long.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Disc</td>
          <td>GT4 USA (SCUS-97328) or Spec II (SCUS-97436, CRC <code>4CE521F2</code>)</td>
      </tr>
      <tr>
          <td>HD HUD pack</td>
          <td>Installed via symlink (Silentwarior112)</td>
      </tr>
      <tr>
          <td>Retexture 3.0</td>
          <td>Installed</td>
      </tr>
      <tr>
          <td>Widescreen pnach</td>
          <td>On (renamed to Spec II CRC if using Spec II)</td>
      </tr>
      <tr>
          <td>Silent&rsquo;s trigger/camera patches</td>
          <td>On</td>
      </tr>
      <tr>
          <td>Deinterlace (Spec II)</td>
          <td>Mode 8 Adaptive TFF</td>
      </tr>
      <tr>
          <td>ShadeBoost (Spec II)</td>
          <td>Saturation +10, Brightness +3, Contrast +2</td>
      </tr>
      <tr>
          <td>FXAA + PCRTC antiblur</td>
          <td>Global</td>
      </tr>
      <tr>
          <td>Upscale</td>
          <td>4x SSAA</td>
      </tr>
  </tbody>
</table>
<p>Spec II users need to pay attention to the CRC: the vanilla GT4 HD packs were built for <code>77E61C8A</code> (USA), so on Spec II with CRC <code>4CE521F2</code> I symlink the assets into the CRC-suffixed folder that PCSX2 looks for. The widescreen pnach was also renamed to match the Spec II CRC. Without that, the game runs but the mods don&rsquo;t load.</p>
<p><strong>In-game reminders:</strong> same as GT3, the widescreen pnach only unlocks the option, you still need to enter Options in-game and switch to 16:9. And important: <strong>change the video mode to progressive 480p</strong> (also in Options). The default is interlaced 480i, which looks terrible on a modern screen. Spec II Mod has native progressive support, just flip it on.</p>
<p>If I could recommend a single GT in the series for someone starting today, it would be this one. If you like racing and never played GT4 in full, you&rsquo;re missing out.</p>
<h3>Enthusia Professional Racing<span class="hx:absolute hx:-mt-20" id="enthusia-professional-racing"></span>
    <a href="#enthusia-professional-racing" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/enthusia.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>Enthusia is the forgotten Konami game from 2005. Launched in GT4&rsquo;s shadow and died on the shelves. It&rsquo;s a shame, because <strong>Enthusia&rsquo;s physics was more realistic than GT4&rsquo;s</strong>. Car weight, mass transfer, limit behavior, all closer to real-world behavior. The sim-racing community of the 2000s knew this and the game has a cult following today.</p>
<p>Enthusia&rsquo;s problem was the Enthusia Points system: you earned points for driving clean, lost them for crashing, and the game&rsquo;s progression depended on those points. A lot of people found it punitive. I found it perfect. It forced you to drive with your head.</p>
<p>On PCSX2 with retexture and widescreen, the game looks great. Strong recommendation for anyone who wants something different from Gran Turismo.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Disc</td>
          <td>Enthusia Professional Racing (SLUS-20967)</td>
      </tr>
      <tr>
          <td>Widescreen pnach</td>
          <td>On</td>
      </tr>
      <tr>
          <td>Retexture</td>
          <td>On</td>
      </tr>
      <tr>
          <td>Upscale</td>
          <td>4x SSAA</td>
      </tr>
  </tbody>
</table>
<h3>Ridge Racer V<span class="hx:absolute hx:-mt-20" id="ridge-racer-v"></span>
    <a href="#ridge-racer-v" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/ridge%20racer%20v.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>Ridge Racer is its own thing. Not simcade, pure arcade. Drift everything, cartoony grip, electronic soundtrack, saturated colors. Ridge Racer V was the Japanese PS2 launch title and it carries that &ldquo;show what the console can do&rdquo; vibe, with massive scenery and a sense of speed GT never attempted.</p>
<p>On PCSX2 it runs well, with a catch: car textures sometimes flicker on the hardware renderer (PCSX2 issues <a href="https://github.com/PCSX2/pcsx2/issues/3639"target="_blank" rel="noopener">#3639</a> and <a href="https://github.com/PCSX2/pcsx2/issues/13729"target="_blank" rel="noopener">#13729</a>). Software renderer fixes it but kills performance. I stay on hardware and accept the occasional flicker.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Disc</td>
          <td>Ridge Racer V (SLUS-20002)</td>
      </tr>
      <tr>
          <td>Widescreen pnach</td>
          <td>On</td>
      </tr>
      <tr>
          <td>No-interlace pnach</td>
          <td>On</td>
      </tr>
      <tr>
          <td>Upscale</td>
          <td>4x SSAA</td>
      </tr>
  </tbody>
</table>
<h3>Colin McRae Rally 3, 4, 5 (PS2)<span class="hx:absolute hx:-mt-20" id="colin-mcrae-rally-3-4-5-ps2"></span>
    <a href="#colin-mcrae-rally-3-4-5-ps2" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>PS2 Colin McRae versions run well on PCSX2 with default configs. CMR3 and CMR04 are good. CMR05 (the last before the rebrand to Dirt) is the best of the three technically. But again: <strong>if you&rsquo;re on PC, look for the native version</strong>. The PS2 ports were relatively inferior to PC of the era, ran at lower resolution, and a modern PC with a repack will hit 144Hz easily.</p>
<p>I keep them on PCSX2 just for collection completeness.</p>
<h2>PS3 on RPCS3<span class="hx:absolute hx:-mt-20" id="ps3-on-rpcs3"></span>
    <a href="#ps3-on-rpcs3" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Gran Turismo 5<span class="hx:absolute hx:-mt-20" id="gran-turismo-5"></span>
    <a href="#gran-turismo-5" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/gran%20turismo%205.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>GT5 is controversial. Polyphony promised the world and delivered a somewhat fragmented game: gorgeous Premium models next to Standards that were basically HD GT4 cars, a level system some loved and others hated, online that went to mud when PSN service was cut, a Top Gear mode that was weird. The contemporary critical reception was mixed, and I remember entire communities fighting over whether the game was a disappointment or a masterpiece.</p>
<p>For me, it&rsquo;s a masterpiece. GT5 has the Course Maker (design your own track from real sections), real 24h Nurburgring with dynamic weather, the Moon Rover (yes, a drive-on-the-moon mode), Red Bull X-Challenge with Vettel, and the single-player endurance experience is ridiculously big. Anyone who went deep into the game knows there&rsquo;s content for a whole year.</p>
<p>On RPCS3 in 2026, GT5 runs <strong>really well</strong>. Stable, without the serious bugs from years past. It&rsquo;s the most stable GT I have emulated today.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Serial</td>
          <td>BCUS98114 (US) or BCES00569 (EU)</td>
      </tr>
      <tr>
          <td>Resolution Scale</td>
          <td>300%</td>
      </tr>
      <tr>
          <td>Shader Precision</td>
          <td>Ultra</td>
      </tr>
      <tr>
          <td>Force High Precision Z</td>
          <td>On</td>
      </tr>
      <tr>
          <td>SPU XFloat</td>
          <td>Accurate</td>
      </tr>
      <tr>
          <td>Multithreaded RSX</td>
          <td>On</td>
      </tr>
      <tr>
          <td>WCB / RCB</td>
          <td>Off</td>
      </tr>
  </tbody>
</table>
<p>The Shader Precision Ultra + Force High Precision Z combo kills the typical RSX dithering that made GT5 look grainy on RPCS3 in years past. Without those two, asphalt looks permanently noisy. With them, the game looks clean.</p>
<h3>Gran Turismo 6<span class="hx:absolute hx:-mt-20" id="gran-turismo-6"></span>
    <a href="#gran-turismo-6" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/gran%20turismo%206.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>GT6 is one of my favorites in the series. It came out in 2013 for an end-of-life PS3 while the competition was already looking at PS4. Sold poorly. A lot of people didn&rsquo;t play it. That&rsquo;s a shame, because GT6 took the GT5 base, cut the fat (Standards were reduced), added more Premiums, better Moon circuit sessions, dynamic weather on more tracks, physics refinement. It&rsquo;s basically GT5 polished.</p>
<p>On content, GT6 is comparable to GT4. A lot of old-guard players put GT4 above it because of the PS2-era charm. I stay between the two and go GT6 on a normal day, GT4 on a nostalgic day.</p>
<p>On RPCS3, GT6 has a serious catch: <strong>patches 1.06 and beyond regress visually</strong>. Black surfaces on cars in the garage menu, flicker in cockpit view, full-screen flashing on certain tracks. The version that works well in 2026 is <strong>pinned at v1.05</strong>. The repo&rsquo;s <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/scripts/extract_ps3_dlc.py"target="_blank" rel="noopener"><code>extract_ps3_dlc.py</code></a> script has a per-title version ceiling specifically to pin GT6 at 1.05 even when PSN serves 1.22 as &ldquo;most recent&rdquo;.</p>
<p>Also, Force CPU Blit is mandatory. Without it, full-screen flicker in the menu. The trade-off is the rear-view mirror stays permanently black. I prefer losing the mirror to having the flicker. Anyone who wants the mirror can enable Write Color Buffers, but then resolution drops to 720p native.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Serial</td>
          <td>BCES01893 + regional variants</td>
      </tr>
      <tr>
          <td>Version pinned</td>
          <td>v1.05 (patches 1.06+ cause black surfaces)</td>
      </tr>
      <tr>
          <td>Resolution Scale</td>
          <td>300% (menus) or 200% (heavy gameplay)</td>
      </tr>
      <tr>
          <td>Force CPU Blit</td>
          <td>On (mandatory, kills flicker)</td>
      </tr>
      <tr>
          <td>WCB</td>
          <td>Off (trade-off: black rear mirror)</td>
      </tr>
      <tr>
          <td>Shader Precision</td>
          <td>Ultra</td>
      </tr>
  </tbody>
</table>
<p>In 2026, with these settings, GT6 is in my rotation. Not perfect, but playable enough that I can spend an afternoon doing endurance.</p>
<h3>Ridge Racer 7<span class="hx:absolute hx:-mt-20" id="ridge-racer-7"></span>
    <a href="#ridge-racer-7" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Ridge Racer 7 was a PS3 launch title in 2006 and the last mainline in the franchise. Arcade like Ridge Racer V, drift-first, electronic soundtrack, cinematic camera. For a PS3 of that era it was a pretty tech demo.</p>
<p>On RPCS3 it runs, but with caveats: requires Write Color Buffers on to avoid lighting issues, and the recommended preset in the compat DB is different from my GT preset. I use a dedicated per-game config. Not my favorite, but it&rsquo;s there for completeness.</p>
<p>No video for this one, I&rsquo;ll record later.</p>
<h2>Xbox 360 on Xenia Canary<span class="hx:absolute hx:-mt-20" id="xbox-360-on-xenia-canary"></span>
    <a href="#xbox-360-on-xenia-canary" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the part that caused me the most headaches this Sunday. Xbox 360 on Linux is hostile territory. Xenia runs via Wine, each game needs specific tweaks, title updates have to come from archive.org, and Forza Motorsport 4 specifically had serious problems until recently.</p>
<h3>Forza Motorsport 4 (the GOAT)<span class="hx:absolute hx:-mt-20" id="forza-motorsport-4-the-goat"></span>
    <a href="#forza-motorsport-4-the-goat" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/forza%20motorsport%204.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>FM4 is, for a lot of people, the best Forza Motorsport ever released. Polyphony made Gran Turismo, Turn 10 made Forza, and FM4 is the peak of the 360 era. Beautiful car models for their time, well-designed tracks, solid physics without being a hard sim, Autovista mode with Jeremy Clarkson narration, epic soundtrack, and that feeling of the Xbox 360 being pushed to its limit.</p>
<p>FM4 on Xenia <strong>was a nightmare until recently</strong>. Severe shadow artifacts, constant texture pop-in on the car, grainy pixelated track textures, sky brightness completely wrong (it came out white-blown-out instead of the deep blue of the original), and worst: frequent audio crashes where XMA (the Xbox 360&rsquo;s proprietary codec) would freeze and the audio would turn into a high-pitched noise until the game hung. I spent hours this Sunday messing with configs, testing builds, searching the xenia-canary issue tracker, testing different title updates.</p>
<p>What unlocked it:</p>
<ul>
<li><strong>Latest Xenia Canary build</strong> (<code>master</code> is stagnant, Canary tracks active development)</li>
<li><strong>Render Target Path:</strong> <code>rtv</code> (the default works fine for FM4)</li>
<li><strong>Title Update 1.0.17.0</strong> installed via Xenia Manager (version matters, older ones regress shadows)</li>
<li><strong>Vulkan GPU with NVIDIA ICD forced</strong> (on my hybrid setup with AMD iGPU, I had to hardforce the 5090)</li>
</ul>
<p>After that, FM4 ran. Not perfectly. Still gets the occasional audio crash (<a href="https://github.com/xenia-canary/xenia-canary/issues/161"target="_blank" rel="noopener">xenia-canary issue #161</a> open). But it&rsquo;s playable, and I spent two hours of the Sunday at Top Gear Test Track just admiring the Koenigsegg CCX. Worth every hour of debugging.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Build</td>
          <td>Latest Xenia Canary</td>
      </tr>
      <tr>
          <td>Render Target Path</td>
          <td><code>rtv</code> (default)</td>
      </tr>
      <tr>
          <td>Title Update</td>
          <td>1.0.17.0</td>
      </tr>
      <tr>
          <td>Wine Prefix</td>
          <td>dedicated via Xenia Manager</td>
      </tr>
      <tr>
          <td>GPU</td>
          <td>NVIDIA via <code>VK_ICD_FILENAMES</code> hardforce</td>
      </tr>
  </tbody>
</table>
<h3>Forza Motorsport 3<span class="hx:absolute hx:-mt-20" id="forza-motorsport-3"></span>
    <a href="#forza-motorsport-3" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/forza%20motorsport%203.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>FM3 is one of the solid entries in the series, sitting between FM2&rsquo;s rougher charm and FM4&rsquo;s technical peak. Getting it to emulate was surprisingly annoying, because I was using the wrong ISO.</p>
<p>There are two releases of FM3: the <strong>retail</strong> (original disc sold in stores) and the <strong>Ultimate Edition</strong> (all DLCs packaged on a second disc). I was trying to run the Ultimate, which combines both discs into a hybrid ISO that Xenia can&rsquo;t parse correctly. I swapped to the standard retail + DLCs installed separately via Title Update, and bang, the game ran first try.</p>
<p>Moral: if your FM3 isn&rsquo;t running on Xenia, check that it&rsquo;s retail. Ultimate breaks.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Version</td>
          <td>Retail (NOT Ultimate Edition)</td>
      </tr>
      <tr>
          <td>Build</td>
          <td>Latest Xenia Canary</td>
      </tr>
      <tr>
          <td>Render Target Path</td>
          <td><code>rtv</code></td>
      </tr>
      <tr>
          <td>DLCs</td>
          <td>Installed via Xenia Manager separately</td>
      </tr>
  </tbody>
</table>
<h3>Forza Motorsport 2<span class="hx:absolute hx:-mt-20" id="forza-motorsport-2"></span>
    <a href="#forza-motorsport-2" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/forza%20motorsport%202.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>FM2 is from 2007, first-gen 360. Less refined systems, more basic modeling, but that original Forza charm some prefer to FM4&rsquo;s sophistication. On Xenia it ran practically out of the box. No special tweak.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Build</td>
          <td>Xenia Canary</td>
      </tr>
      <tr>
          <td>Render Target Path</td>
          <td>default</td>
      </tr>
  </tbody>
</table>
<h3>Forza Horizon 2<span class="hx:absolute hx:-mt-20" id="forza-horizon-2"></span>
    <a href="#forza-horizon-2" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/forza%20horizon%202.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>FH2 was the most polished Forza Horizon of the 360 generation (there&rsquo;s also the Xbox One version, which is technically better, but the 360 has its charm because of the smaller map and more focused progression). Open world in southern Europe, iconic soundtrack, Horizon festival at its peak.</p>
<p>On Xenia it ran without asking for anything extra. Same as FM2.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Build</td>
          <td>Xenia Canary</td>
      </tr>
      <tr>
          <td>Render Target Path</td>
          <td>default</td>
      </tr>
  </tbody>
</table>
<h3>Forza Horizon 1 (with XE Mod)<span class="hx:absolute hx:-mt-20" id="forza-horizon-1-with-xe-mod"></span>
    <a href="#forza-horizon-1-with-xe-mod" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/forza%20horizon%201%20xe%20mod.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>Original FH1 runs OK on Xenia, but there&rsquo;s a community maintaining the <strong>Forza Horizon 1 XE Mod</strong>, which improves traffic AI, rebalances progression, adds cars that were cut, fixes known bugs, and brings quality-of-life tweaks to the UI. I tested the XE Mod this Sunday and it&rsquo;s worth it. The game feels more alive and progression feels fairer.</p>
<p>Install details for the XE Mod are on the project site. Basically it&rsquo;s a patch over the original ISO + a few custom title updates.</p>
<p>For comparison, here&rsquo;s FH1 <strong>without</strong> the mod, running vanilla:</p>
<video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/forza%20horizon%201.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>The most visible difference is traffic and the variety of situations that come up, but the XE Mod has a lot of stuff you only notice after a few hours.</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Build</td>
          <td>Xenia Canary</td>
      </tr>
      <tr>
          <td>XE Mod</td>
          <td>Applied over original ISO</td>
      </tr>
      <tr>
          <td>Title Updates</td>
          <td>Version pinned by XE Mod</td>
      </tr>
  </tbody>
</table>
<h3>Project Gotham Racing 3 and 4<span class="hx:absolute hx:-mt-20" id="project-gotham-racing-3-and-4"></span>
    <a href="#project-gotham-racing-3-and-4" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>PGR3 was a 360 launch title in 2005 and still looks gorgeous today. Urban tracks (New York, Tokyo, Nurburgring, Las Vegas), kudos system, cinematic camera. PGR4 added dynamic weather (rain and snow) and motorcycles (some people love them, I prefer to pretend they don&rsquo;t exist).</p>
<p>PGR3 on Xenia Canary runs reasonably well with default configs. PGR4 needs a specific tweak: <strong><code>render_target_path_vulkan = &quot;fsi&quot;</code></strong> in the config. Without it, some tracks break with severe visual artifacts (green bars across the screen in snow races).</p>
<p>And PGR4 on NVIDIA has a known audio bug: XMA decoding produces intermittent garbage (open xenia-canary issue, no resolution). Not game-breaking, but annoying.</p>
<table>
  <thead>
      <tr>
          <th>Game</th>
          <th>Relevant setting</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PGR3</td>
          <td>Default</td>
      </tr>
      <tr>
          <td>PGR4</td>
          <td><code>render_target_path_vulkan = &quot;fsi&quot;</code>, accept the XMA audio bug</td>
      </tr>
  </tbody>
</table>
<h3>Ridge Racer 6<span class="hx:absolute hx:-mt-20" id="ridge-racer-6"></span>
    <a href="#ridge-racer-6" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Ridge Racer 6 was the Ridge Racer of the 360, skipping the 7 that stayed on PS3. Pure arcade, like Ridge Racer V of the PS2. On Xenia Canary it runs with default config, no special tweak. Fun for short sessions. No video for this one in the article, but it&rsquo;s in the catalog running.</p>
<h2>PS4 on shadPS4: the state of the emulator<span class="hx:absolute hx:-mt-20" id="ps4-on-shadps4-the-state-of-the-emulator"></span>
    <a href="#ps4-on-shadps4-the-state-of-the-emulator" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://shadps4.net/"target="_blank" rel="noopener">shadPS4</a> <strong>still doesn&rsquo;t have a stable release</strong>. The project is under active development, the main branch changes several times a week, and &ldquo;what works today&rdquo; can regress on a rebase tomorrow. That said, <strong>many games already boot and run well enough</strong>. The community list is on the <a href="https://github.com/shadps4-compatibility/shadps4-game-compatibility/issues"target="_blank" rel="noopener">compatibility tracker</a> and grows every week.</p>
<p>The difference between shadPS4 and the other emulators in this article is the kind of effort. PCSX2, RPCS3, Xenia are mature projects where each game has a reasonably up-to-date wiki page. shadPS4 is the opposite: you read a Reddit guide, try to reproduce, discover the guide uses a specific fork (not the official main), or a two-month-old commit, or a firmware with <code>sys_modules</code> symlinked in a particular way, or XML patches applied in a specific order that the author didn&rsquo;t document. Reproducing a shadPS4 setup from a YouTube video is like trying to solve a Rubik&rsquo;s Cube blindfolded.</p>
<p>But that doesn&rsquo;t mean you shouldn&rsquo;t try. It means you need to treat shadPS4 as a hobby, not a workflow. And if you have the patience, you can pull off impressive results.</p>
<h2>Driveclub: the impossible, finally possible<span class="hx:absolute hx:-mt-20" id="driveclub-the-impossible-finally-possible"></span>
    <a href="#driveclub-the-impossible-finally-possible" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><video
  controls
  preload="metadata"
  playsinline
  class="html5-video"
  style="width:100%; max-width:960px; display:block; margin:1.25em auto; border-radius:8px; background:#000;"
  >
  <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/videos/driveclub.mp4" type="video/mp4">
  Your browser does not support HTML5 video.
</video>

<p>Driveclub is my Moby Dick of emulation. It&rsquo;s the only PS4 game I really want to run. Released in 2014 by Evolution Studios, had a catastrophic launch (online servers collapsed on day one), got patched over two years until it became one of the best racing games of the generation, and was discontinued by Sony when Evolution was closed in 2016. No PC port. No remaster. No sequel. Only exists on PS4.</p>
<p>And today, after many hours of debugging, <strong>it&rsquo;s running here</strong>. Main menu loads, intro plays, races start, controller responds, audio works, daytime tracks visible, night tracks with headlights lighting the road. The video above is real gameplay, stable 30 FPS, v1.28 with DLC content accessible.</p>
<p>Getting here required dismantling several traps. Worth documenting, because most online guides don&rsquo;t mention any of them.</p>
<h3>Trap 1: the FMOD loop error that wasn&rsquo;t FMOD<span class="hx:absolute hx:-mt-20" id="trap-1-the-fmod-loop-error-that-wasnt-fmod"></span>
    <a href="#trap-1-the-fmod-loop-error-that-wasnt-fmod" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When you try to boot Driveclub on shadPS4 straight from the retail PKG, the log hits an infinite loop of:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/app0/audio/fmodstudio/masterbank.bank failed, file does not exist</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The first reaction is to investigate FMOD. Some people online spent hours messing with sceFont, libtbb, sceNgs2, convinced it was an audio or HLE lib problem. <strong>None of that is the cause</strong>. The <code>masterbank.bank</code> file literally doesn&rsquo;t exist on disk, because it&rsquo;s packed inside the <code>gameNNN.dat</code> files in a custom Evolution Studios archive format that shadPS4&rsquo;s VFS can&rsquo;t read. Same story for fonts, models, shaders. All the game&rsquo;s content lives inside the <code>.dat</code> files.</p>
<p>The only public tool that parses that format is <strong><a href="https://github.com/Nenkai/DriveClubFS"target="_blank" rel="noopener">Nenkai&rsquo;s DriveClubFS</a></strong>. It&rsquo;s a .NET app that reads <code>game.ndx</code> (the index) and decompresses the <code>.dat</code> files into loose files. Without that unpack, shadPS4 can&rsquo;t open anything.</p>
<p>Build detail: the DriveClubFS project targets <code>net9.0</code>, and Arch has <code>net8</code> and <code>net10</code>. You need to retarget the csproj to <code>net10.0</code> before building. The exact command is documented in the repo&rsquo;s <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/driveclub-shadps4.md"target="_blank" rel="noopener"><code>docs/driveclub-shadps4.md</code></a>.</p>
<h3>Trap 2: v1.28 is a patch, not a base installer<span class="hx:absolute hx:-mt-20" id="trap-2-v128-is-a-patch-not-a-base-installer"></span>
    <a href="#trap-2-v128-is-a-patch-not-a-base-installer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Like every PS4 update, v1.28 is a patch that layers on top of a base install. The v1.28 PKG ships the updated eboot, new-content <code>.dat</code> files, and patch content (<code>sce_sys/about/right.sprx</code>), but <strong>no <code>param.sfo</code>, no <code>disc_info.dat</code>, no <code>keystone</code></strong>. Those metadata files live in the v1.00 base — on a real PS4 they&rsquo;d already be on disk from the original install.</p>
<p>And there&rsquo;s more: v1.28&rsquo;s <code>game.ndx</code> references both the base <code>.dat</code> files and the new ones. Running DriveClubFS against v1.28 alone makes the index point at files that aren&rsquo;t there.</p>
<p>The cruelest symptom hits later: the game boots, the Qt Launcher shows the tile, you click, the loading screen comes up. Then the game decides no content &ldquo;has been released yet&rdquo; and asks you to download. Download obviously isn&rsquo;t happening because PSN for PS4 isn&rsquo;t accessible. You get stuck thinking it&rsquo;s a network bug, when actually it&rsquo;s missing metadata.</p>
<p>The correct flow is to treat v1.28 as an overlay on top of the base. First, extract the v1.00 PKG into a dedicated dir (<code>CUSA00003.v100-working-backup/</code> works well, and doubles as a rollback snapshot if some experiment breaks the live install). Then extract v1.28 into staging and symlink the base&rsquo;s low-index <code>.dat</code> files into the staging dir so DriveClubFS sees the full set. Run the unpack. You get 8018 loose files, ~47 GB.</p>
<p>Move that into the live <code>CUSA00003/</code> dir, copy v1.28&rsquo;s updated eboot and new patch content on top, then pull <code>param.sfo</code>, <code>disc_info.dat</code>, and <code>keystone</code> from the v1.00 base. The base&rsquo;s <code>param.sfo</code> already has <code>APP_VER = 01.28</code> recorded (because it was the version that received the patch originally), so there&rsquo;s no version conflict. The v1.28 eboot reads those files and unlocks the content.</p>
<h3>Trap 3: the 60fps XML patch produces slow-motion<span class="hx:absolute hx:-mt-20" id="trap-3-the-60fps-xml-patch-produces-slow-motion"></span>
    <a href="#trap-3-the-60fps-xml-patch-produces-slow-motion" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>There&rsquo;s a community <code>Driveclub.xml</code> patch that makes the game render at 60fps. Intuitively, it looks like pure upside. In practice, the patch rewrites the render rate but doesn&rsquo;t touch the game&rsquo;s fixed logic tickrate, which is locked at 30fps. On a real PS4 Pro, the hardware reconciles the mismatch. On shadPS4, it doesn&rsquo;t.</p>
<p>Result: the game renders smoothly at 60fps, but the physics, AI, audio, race timing all run in slow motion. You press the throttle and the car takes a perceptible two seconds to launch. Looks like a random bug until you understand the mismatch.</p>
<p>Disabling the XML patch is the move (just rename the file to something like <code>.disabled</code>). Without the patch, the game runs at native 30 FPS, same speed as a stock PS4 base. Which is what we want. If someone in the future produces a patch that moves both rates (render + logic), we&rsquo;ll flip it back on.</p>
<h3>Trap 4: pipeline cache has to be on<span class="hx:absolute hx:-mt-20" id="trap-4-pipeline-cache-has-to-be-on"></span>
    <a href="#trap-4-pipeline-cache-has-to-be-on" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>shadPS4&rsquo;s default ships with <code>pipeline_cache_enabled = false</code>. On a large game like Driveclub, which has hundreds of Vulkan shaders and pipelines, that means every cold launch recompiles everything. My first launch counted ~864 shaders and ~590 pipelines being compiled in real time. The menu animated at 2-3 fps. A minute later, with everything compiled, it snapped to normal fluidity. Next launch, same from-scratch compilation cycle.</p>
<p>Just enable <code>&quot;pipeline_cache_enabled&quot;: true</code> in the global <code>config.json</code>. The first session pays the one-time compilation cost, subsequent sessions read from cache and start up instantly.</p>
<h3>Trap 5: TOML silently ignored<span class="hx:absolute hx:-mt-20" id="trap-5-toml-silently-ignored"></span>
    <a href="#trap-5-toml-silently-ignored" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>shadPS4 migrated from TOML to JSON for per-game configs in late 2025. If you have an old <code>CUSA00003.toml</code> in the <code>custom_configs/</code> folder, it&rsquo;s silently ignored, no warning in the log. You think your config is active, in reality you&rsquo;re running all defaults.</p>
<p>Delete any TOML and use <code>CUSA00003.json</code>. The per-game configs that work for Driveclub are specific and detailed, all documented in the <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/driveclub-shadps4.md"target="_blank" rel="noopener"><code>docs/driveclub-shadps4.md</code></a> of the repo.</p>
<h3>Trap 6: controller not detected without hidapi hints<span class="hx:absolute hx:-mt-20" id="trap-6-controller-not-detected-without-hidapi-hints"></span>
    <a href="#trap-6-controller-not-detected-without-hidapi-hints" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>shadPS4&rsquo;s Qt Launcher calls an internal <code>Shadps4-sdl.AppImage</code>. Without specific SDL hidapi env vars, the controller (8BitDo Ultimate 2 in my case) isn&rsquo;t detected, even though it works in every other emulator. The fix is to wrap the AppImage in a shell that exports <code>SDL_JOYSTICK_HIDAPI=1</code> and platform-specific variants (PS4, PS5, Xbox), before executing the original.</p>
<p>Plugging the controller in <strong>before</strong> opening the Qt Launcher also helps. Hot-plug works during the game, not before.</p>
<h3>Trap 7: night tracks are pitch black on upstream<span class="hx:absolute hx:-mt-20" id="trap-7-night-tracks-are-pitch-black-on-upstream"></span>
    <a href="#trap-7-night-tracks-are-pitch-black-on-upstream" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This one needed a code patch, not just config. On upstream shadPS4, Driveclub night tracks came out pitch black. The HUD rendered on top, but the track, the car, everything hidden in absolute black. Not even the car&rsquo;s own headlights lit anything up.</p>
<p>Investigation pointed at the log repeating 1325 times the message <code>ResolveDepthOverlap: Unimplemented depth overlap copy</code>. The cause: Driveclub uses a forward+ renderer, which writes scene depth into a 4x MSAA D32Sfloat buffer, then reads that same buffer as <code>R32G32B32A32Sfloat</code> 1x for effects like SSAO, volumetrics, and dynamic lighting (headlights). shadPS4 upstream has a path for MSAA → MSAA and for color → color as MSAA depth, but <strong>it didn&rsquo;t have a path for &ldquo;MSAA depth 4x becomes color 1x&rdquo;</strong> in that specific format. It falls into the final <code>else</code>, frees the buffer, and the shader reads uninitialized memory — i.e., black.</p>
<p>I implemented in my local shadPS4 fork a <code>ReinterpretMsDepthAsColor</code> symmetric to the existing <code>ReinterpretColorAsMsDepth</code>. It&rsquo;s a tiny fragment shader (<code>ms_depth_to_color.frag</code>) that does <code>texelFetch</code> of sample 0&rsquo;s depth and writes it as <code>vec4(depth, 0, 0, 1)</code> to the color attachment. Plus a <code>BlitHelper</code> helper and a new <code>else if</code> branch in <code>TextureCache::ResolveDepthOverlap</code>. With that, night tracks light up properly, opponent headlights appear, AO and volumetrics on day scenes get more precise too.</p>
<p>The fix lives on a <code>gamma-debug</code> branch of my fork, ready to become an upstream PR. Until it merges, anyone who wants to race at night on Driveclub on shadPS4 needs to build from that branch. Anyone sticking to daytime can use upstream nightly directly.</p>
<h3>What finally worked<span class="hx:absolute hx:-mt-20" id="what-finally-worked"></span>
    <a href="#what-finally-worked" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Component</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>shadPS4</td>
          <td>Upstream Pre-release (nightly) for daytime; <code>gamma-debug</code> fork for night (until MSAA depth fix lands upstream)</td>
      </tr>
      <tr>
          <td>Driveclub</td>
          <td><strong>v1.28 base</strong> (cumulative patch, ~47 GB, 8018 files)</td>
      </tr>
      <tr>
          <td>Extraction</td>
          <td><a href="https://github.com/shadps4-emu/ShadPKG"target="_blank" rel="noopener">ShadPKG</a> → <a href="https://github.com/Nenkai/DriveClubFS"target="_blank" rel="noopener">DriveClubFS</a> (retargeted to net10.0)</td>
      </tr>
      <tr>
          <td>Metadata</td>
          <td>Restore <code>param.sfo</code>, <code>disc_info.dat</code>, <code>keystone</code> from the v1.00 PKG</td>
      </tr>
      <tr>
          <td>Global config</td>
          <td><code>pipeline_cache_enabled: true</code>, <code>readbacks_mode: 0</code></td>
      </tr>
      <tr>
          <td>Per-game config</td>
          <td>JSON at <code>custom_configs/CUSA00003.json</code></td>
      </tr>
      <tr>
          <td><code>vblank_frequency</code></td>
          <td>60</td>
      </tr>
      <tr>
          <td><code>gpu_id</code></td>
          <td>0 (NVIDIA dGPU hardforced)</td>
      </tr>
      <tr>
          <td><code>Driveclub.xml</code> patch</td>
          <td><strong>Disabled</strong> (60fps produces slow-motion, root cause: fixed logic tickrate)</td>
      </tr>
      <tr>
          <td>Controller</td>
          <td>AppImage wrapper with <code>SDL_JOYSTICK_HIDAPI=1</code> + variants</td>
      </tr>
      <tr>
          <td>Qt Launcher</td>
          <td><code>checkForUpdates=false</code> to avoid GitHub rate-limit dialog</td>
      </tr>
  </tbody>
</table>
<p>All of this is encapsulated in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/driveclub-shadps4.md"target="_blank" rel="noopener"><code>docs/driveclub-shadps4.md</code></a> in the repo, with the exact step-by-step for extraction, config, and wrapper. The full technical investigation (5 phases, detailed engineering log) lives in the fork&rsquo;s <a href="https://github.com/akitaonrails/shadPS4/blob/gamma-debug/docs/driveclub-v128-investigation.md"target="_blank" rel="noopener"><code>driveclub-v128-investigation.md</code></a>.</p>
<h3>How playable it is today<span class="hx:absolute hx:-mt-20" id="how-playable-it-is-today"></span>
    <a href="#how-playable-it-is-today" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Good. The video above is real gameplay, stable 30 FPS, physics responding, audio in sync, HUD correct, v1.28 with DLC accessible. Day races and night races both work (with the MSAA depth fork for night). What&rsquo;s left as limitation:</p>
<ul>
<li><strong>Daytime brightness sits a bit below what the PS4 delivered.</strong> Not an emulator bug. I spent hours testing tonemap, auto-exposure, gamma curves, ACES, various post-processing shaders, and in the end discovered the game <strong>writes already-correct SDR</strong> to the framebuffer. What was missing was the display-calibration pipeline that PS4 firmware 4.0+ applied on the outside, and that Linux doesn&rsquo;t have. The in-game brightness slider (which on shadPS4 maps to <code>sceVideoOutAdjustColor</code>) works, but only up to the point where the game expected the firmware to pick up the rest. The 5-second dim at the start of a race is also the game&rsquo;s own cinematic fade-in VFX, not an emulator fault. Accepted.</li>
<li><strong>Native 30 FPS.</strong> No viable 60fps patch (due to the fixed logic tickrate explained above). Stock PS4 speed.</li>
<li><strong>MSAA depth fix still on the fork.</strong> PR candidate ready, awaiting upstream merge.</li>
</ul>
<p><strong>&ldquo;Achievement Unlocked&rdquo;.</strong> The game runs, with sound, controller, gameplay, day and night, and I complete a whole race without crashing. That&rsquo;s a personal milestone. I spent years trying to get this game running on Linux and would always hit a point where I just gave up. Now it&rsquo;s part of my ES-DE, shortcut on the desktop, sitting there waiting for me to play.</p>
<p>If you want to replicate, the repo is public. Good luck. You&rsquo;re going to need patience.</p>
<h2>&ldquo;Achievement Unlocked&rdquo;<span class="hx:absolute hx:-mt-20" id="achievement-unlocked"></span>
    <a href="#achievement-unlocked" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve been trying to make these games work, at some level, since each respective emulator came out of alpha. I&rsquo;ve been running GT2 since ePSXe. I&rsquo;ve been messing with GT4 since PCSX2 0.9.6. I tried to get FM4 running on Xenia twice in past years and gave up both times. And I looked at shadPS4&rsquo;s face three times before this Sunday and swallowed hard.</p>
<p>Today it closed. The two monsters that opened this article (Driveclub on shadPS4 and FM4 with Project Forza Plus on Xenia) are running, along with the rest of the Gran Turismo, Forza, Ridge Racer, Colin McRae, and Enthusia collection. All in a single setup, reproducible, automated. I&rsquo;m now part of the <strong>very small group of people who brute-forced their way into emulating Driveclub on Linux</strong>, and that alone made the Sunday unforgettable.</p>
<p>The frustration of years wasn&rsquo;t lack of information. It was the OPPOSITE. Too much information, poorly indexed, scattered across dead forums, YouTube videos that went stale, Reddit threads with solutions for two-year-old emulator versions that don&rsquo;t apply today, wikis that are out of date on half the pages, and mods with VERY specific requirements of CRC, version, patch, path, setting. Every time I sat down to get one of these games running right, I spent three hours reading conflicting material before writing the first line of config. Worse still when the good material was buried under wrong diagnoses, like the Driveclub FMOD loop everyone investigated as if it was an audio problem when in reality it was Evolution Studios&rsquo; proprietary archive format.</p>
<p>What changed in the last year is that Claude Code can be my aggregator. I tell Claude to hit the Xenia issue tracker, the PCSX2 wiki, the RPCS3 subreddit, shadPS4 PRs, cross-reference all that info with the emulator version I have installed, cross-reference with the logs I&rsquo;m looking at, and give me back the combination that works today. It reads source code when needed, understands why <code>Force CPU Blit</code> changes behavior on GT6, finds which FM4 title update was the one that fixed the shadow bug, discovers that DriveClubFS needs a retarget to .NET 10 before compiling. What used to take three weekends of research now takes an afternoon of assisted execution.</p>
<p>This Sunday is the consolidated result of all that work. <a href="https://github.com/akitaonrails/distrobox-gaming"target="_blank" rel="noopener">distrobox-gaming</a> is that knowledge turned into code, reproducible, automated. Starting on a fresh machine, one <code>ansible-playbook site.yml</code> puts me back in this state in under two hours. Fewer than 10 commands separate &ldquo;vanilla Arch Linux&rdquo; from &ldquo;Driveclub running&rdquo;.</p>
<p>If anyone wants to contribute, test on different hardware, report a bug, or send a config that&rsquo;s missing, the repo is open. The more people test on different setups, the more resilient the project gets. If you improve the Driveclub situation (get the MSAA depth fix merged upstream, a 60fps patch that also fixes the logic tickrate, font dumps that don&rsquo;t need jailbroken hardware), send a PR and I&rsquo;ll embrace it.</p>
<p>For now, I&rsquo;m going to do a race in Iceland on Driveclub. Good Sunday night.</p>
]]></content:encoded><category>gaming</category><category>emulation</category><category>linux</category><category>distrobox</category><category>racing</category><category>gran-turismo</category><category>forza</category></item><item><title>LLM Benchmarks Part 2: Is It Worth Combining Multiple Models in the Same Project? Claude + GLM??</title><link>https://akitaonrails.github.io/en/2026/04/18/llm-benchmarks-part-2-multi-model/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/18/llm-benchmarks-part-2-multi-model/</guid><pubDate>Sat, 18 Apr 2026 14:00:00 GMT</pubDate><description>&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Obsolete article (updated 2026-04-24).&lt;/strong&gt; The conclusions and rankings in this post were superseded after I re-audited the benchmark against the &lt;code&gt;ruby_llm&lt;/code&gt; gem source. The core finding (multi-model is not worth it for greenfield coding) still holds and has been folded into the canonical post. &lt;strong&gt;The canonical version lives at &lt;a href="https://akitaonrails.github.io/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/"&gt;LLM Coding Benchmark (April 2026)&lt;/a&gt;.&lt;/strong&gt; This post stays as a historical record.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Yes, the title is clickbait. The answer is no, it&amp;rsquo;s not worth it. Keep using Claude Code with Opus 4.6 or 4.7. Details below.&lt;/p&gt;</description><content:encoded><![CDATA[<blockquote>
  <p>⚠️ <strong>Obsolete article (updated 2026-04-24).</strong> The conclusions and rankings in this post were superseded after I re-audited the benchmark against the <code>ruby_llm</code> gem source. The core finding (multi-model is not worth it for greenfield coding) still holds and has been folded into the canonical post. <strong>The canonical version lives at <a href="/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/">LLM Coding Benchmark (April 2026)</a>.</strong> This post stays as a historical record.</p>

</blockquote>
<hr>
<p><strong>TL;DR:</strong> Yes, the title is clickbait. The answer is no, it&rsquo;s not worth it. Keep using Claude Code with Opus 4.6 or 4.7. Details below.</p>
<hr>
<p>A few weeks ago I wrote a <a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">detailed LLM coding benchmark</a> comparing 33 open source and commercial models on the same test: build a Rails app using RubyLLM. The conclusion was that only 4 models generated code that works on first try (both Claudes, GLM 5, and GLM 5.1), and that for anyone who doesn&rsquo;t want to waste time fixing API hallucinations, Claude Opus via Claude Code remains the most rational choice despite the price.</p>
<p>This article is a follow-up. I&rsquo;ll keep that benchmark updated as new models come out (Opus 4.7, Qwen 3.6, GPT 5.4 via Codex are already in). This one tackles a different question that shows up in my feed every week: <strong>what if I combine two models in the same project? Opus to plan, GLM to execute. Does that work?</strong></p>
<p>Short answer: no, it doesn&rsquo;t. Long answer is the rest of this article.</p>
<h2>First: a word about Opus 4.7<span class="hx:absolute hx:-mt-20" id="first-a-word-about-opus-47"></span>
    <a href="#first-a-word-about-opus-47" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There are people on Reddit claiming Opus 4.7 is an absurd downgrade from 4.6, that it regressed, that it &ldquo;got worse at coding&rdquo;. I get suspicious whenever I see &ldquo;everything&rsquo;s getting worse&rdquo; narratives. I have hundreds of hours with Opus 4.6, and I&rsquo;ve been testing 4.7 since it came out a few days ago. Quality is equal to or better than 4.6 on non-trivial tasks where I have a reference for how 4.6 used to behave.</p>
<p>When you see someone complaining &ldquo;4.7 is terrible&rdquo;, ask for the exact prompt, the repo, the context. Most of the time they can&rsquo;t reproduce it, or the repo has a badly written CLAUDE.md, or the task is too subjective to measure. &ldquo;Felt like it got worse&rdquo; isn&rsquo;t data. I got caught by that feeling myself in a 4.7 session where the context had been contaminated with a bunch of stale docs. The culprit was my config, not the model.</p>
<p>In the benchmarks I ran this week, Opus 4.7 on opencode delivered clean Tier 1, same level as the Opus 4.6 baseline. The tests it ran via Claude Code had a weirder story, and there the blame is likely the harness, not the model. More on that below.</p>
<h2>What&rsquo;s new in the benchmark<span class="hx:absolute hx:-mt-20" id="whats-new-in-the-benchmark"></span>
    <a href="#whats-new-in-the-benchmark" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">benchmark</a> now supports testing model combinations across the three main harnesses:</p>
<ol>
<li><strong>Claude Code</strong> (<code>claude -p --output-format stream-json</code>) — supports sub-agents declared in <code>.claude/agents/*.md</code> that Opus can delegate to via the <code>Task</code> tool</li>
<li><strong>opencode</strong> — has its own sub-agent system that can run different models</li>
<li><strong>Codex CLI</strong> — got sub-agent support via TOML with <code>-c agents.&lt;name&gt;.config_file=...</code></li>
</ol>
<p>On top of these three, I configured 7 combinations:</p>
<table>
  <thead>
      <tr>
          <th>Runner</th>
          <th>Primary model</th>
          <th>Sub-agent</th>
          <th>Idea</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Code</td>
          <td>Opus 4.7</td>
          <td>—</td>
          <td>Baseline, Opus alone</td>
      </tr>
      <tr>
          <td>Claude Code</td>
          <td>Opus 4.7</td>
          <td>Sonnet 4.6</td>
          <td>Opus plans, Sonnet executes</td>
      </tr>
      <tr>
          <td>Claude Code</td>
          <td>Opus 4.7</td>
          <td>Haiku 4.5</td>
          <td>Opus plans, Haiku (smaller) executes</td>
      </tr>
      <tr>
          <td>opencode</td>
          <td>Opus 4.7</td>
          <td>GLM 5.1</td>
          <td>Opus + GLM (cheap + good)</td>
      </tr>
      <tr>
          <td>opencode</td>
          <td>Opus 4.7</td>
          <td>Qwen 3.6 local</td>
          <td>Opus + free local model</td>
      </tr>
      <tr>
          <td>Codex</td>
          <td>GPT 5.4 xHigh</td>
          <td>GPT 5.4 medium</td>
          <td>High reasoning plans, lower executes</td>
      </tr>
      <tr>
          <td>Codex</td>
          <td>GPT 5.4 xHigh</td>
          <td>GPT 5.4 low</td>
          <td>High reasoning plans, minimum executes</td>
      </tr>
  </tbody>
</table>
<p>Each runs the same prompt: build a Rails app with RubyLLM, Tailwind, Stimulus, Turbo Streams, Minitest tests, Brakeman, RuboCop, Dockerfile, docker-compose. Same prompt as the original benchmark.</p>
<h3>How to enable multi-model in each harness<span class="hx:absolute hx:-mt-20" id="how-to-enable-multi-model-in-each-harness"></span>
    <a href="#how-to-enable-multi-model-in-each-harness" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Before showing what went wrong, it&rsquo;s worth understanding how each harness exposes the sub-agent and who decides to call it. The mechanics are similar across all three, but the details matter.</p>
<h4>Claude Code<span class="hx:absolute hx:-mt-20" id="claude-code"></span>
    <a href="#claude-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h4><p>Claude Code automatically reads files in <code>.claude/agents/*.md</code> from the project directory. Each file is an agent definition:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">---
</span></span><span class="line"><span class="cl">name: sonnet-coder
</span></span><span class="line"><span class="cl">description: Claude Sonnet 4.6 for concrete coding execution. Use PROACTIVELY for any code change where the plan is already clear. Opus should plan and delegate; Sonnet should execute. Only skip delegation for cross-file architectural decisions.
</span></span><span class="line"><span class="cl">model: claude-sonnet-4-6
</span></span><span class="line"><span class="cl">---
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">You are a focused coding agent. The parent (Opus) has already decided
</span></span><span class="line"><span class="cl">the approach — your job is to execute cleanly.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl"><span class="k">-</span> Follow the provided instructions precisely. Don&#39;t re-plan.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Prefer editing existing files over creating new ones.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Match the existing codebase style.
</span></span><span class="line"><span class="cl"><span class="k">-</span> Keep changes minimal.
</span></span><span class="line"><span class="cl">- Default to no comments.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The YAML frontmatter has three required fields: <code>name</code> (the handle the primary model uses), <code>model</code> (which model runs the agent), and <code>description</code> (what the primary reads to decide whether to delegate). The file body is the system prompt the sub-agent receives when invoked.</p>
<p>To invoke, Opus uses the native <code>Task(subagent_type=&quot;sonnet-coder&quot;, prompt=&quot;...&quot;)</code> tool. Claude Code bills tokens to the sub-agent&rsquo;s model, not the primary&rsquo;s.</p>
<h4>opencode<span class="hx:absolute hx:-mt-20" id="opencode"></span>
    <a href="#opencode" class="subheading-anchor" aria-label="Permalink for this section"></a></h4><p>opencode uses a JSON config file (can be the default <code>opencode.json</code> or a custom one via <code>--config</code>). Agents live under an <code>agents</code> key:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;model&#34;</span><span class="p">:</span> <span class="s2">&#34;openrouter/anthropic/claude-opus-4.7&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;agents&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;coder&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;model_id&#34;</span><span class="p">:</span> <span class="s2">&#34;zai/glm-5.1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;provider&#34;</span><span class="p">:</span> <span class="s2">&#34;zai&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;description&#34;</span><span class="p">:</span> <span class="s2">&#34;Use proactively for concrete coding execution...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;prompt&#34;</span><span class="p">:</span> <span class="s2">&#34;You are a focused coding agent. The parent...&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each entry has <code>model_id</code>, <code>provider</code>, <code>description</code>, and <code>prompt</code>. The primary model (set at the top) invokes the agent via a <code>task</code> tool, passing the name (<code>coder</code> in the example) and specific instructions.</p>
<h4>Codex CLI<span class="hx:absolute hx:-mt-20" id="codex-cli"></span>
    <a href="#codex-cli" class="subheading-anchor" aria-label="Permalink for this section"></a></h4><p>Codex uses TOML files per agent, passed via <code>-c</code> flags on the command line:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># .codex-coder.toml</span>
</span></span><span class="line"><span class="cl"><span class="nx">name</span> <span class="p">=</span> <span class="s2">&#34;coder&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">model</span> <span class="p">=</span> <span class="s2">&#34;gpt-5.4&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">reasoning_effort</span> <span class="p">=</span> <span class="s2">&#34;medium&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">description</span> <span class="p">=</span> <span class="s2">&#34;Use proactively for concrete coding execution...&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">prompt</span> <span class="p">=</span> <span class="s2">&#34;You are a focused coding agent. The parent (xhigh)...&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And invoked:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">codex <span class="nb">exec</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dangerously-bypass-approvals-and-sandbox <span class="se">\
</span></span></span><span class="line"><span class="cl">  -c <span class="nv">model_reasoning_effort</span><span class="o">=</span>xhigh <span class="se">\
</span></span></span><span class="line"><span class="cl">  -c agents.coder.config_file<span class="o">=</span>.codex-coder.toml <span class="se">\
</span></span></span><span class="line"><span class="cl">  -p <span class="s2">&#34;&lt;main prompt&gt;&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The primary model gets access to the <code>spawn_agent</code> tool to invoke the <code>coder</code>. Codex lets you configure a different <code>reasoning_effort</code> for the primary and the sub-agent, which is exactly what the <code>multi_balanced</code> and <code>multi_faster</code> variants test.</p>
<h3>Who decides which model runs each task<span class="hx:absolute hx:-mt-20" id="who-decides-which-model-runs-each-task"></span>
    <a href="#who-decides-which-model-runs-each-task" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In all three harnesses, the decision to delegate is made by the <strong>primary model at runtime</strong>, not by a programmatic rule. There&rsquo;s no deterministic heuristic like &ldquo;if the file is larger than X lines, call the sub-agent&rdquo;. What exists is the primary model reading the sub-agent&rsquo;s description and judging, step by step, whether the current task fits.</p>
<p>Three consequences:</p>
<ol>
<li>
<p><strong>The sub-agent description is the only control knob</strong>. If you write &ldquo;use PROACTIVELY for X&rdquo; without caveats, the model tends to delegate more. If you add &ldquo;skip for Y&rdquo;, it tends not to delegate on Y.</p>
</li>
<li>
<p><strong>The primary model is conservative by default</strong>. Across all three, current training favors not delegating when the task needs cross-file context or architectural decisions. A greenfield Rails app is exactly that kind of task.</p>
</li>
<li>
<p><strong>You can&rsquo;t force delegation via config</strong>. You can write an aggressive description, but if the model judges the task doesn&rsquo;t fit, it ignores the sub-agent. No <code>--force-subagent</code> flag exists. The call is the model&rsquo;s, not the operator&rsquo;s.</p>
</li>
</ol>
<p>Matters for what comes next.</p>
<h2>The finding that kills the argument<span class="hx:absolute hx:-mt-20" id="the-finding-that-kills-the-argument"></span>
    <a href="#the-finding-that-kills-the-argument" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I opened the logs of each run expecting to see delegation happen. Tools like <code>Task</code> (Claude Code) or <code>spawn_agent</code> (Codex) should show up in the ndjson every time the primary model calls the sub-agent.</p>
<p>Across 7 runs, <strong>the delegation tool was called zero times</strong>. No Opus called Sonnet. No Opus called Haiku. No Opus called GLM 5.1 or the local Qwen 3.6. No GPT xHigh called GPT medium or low.</p>
<p>All primary models did the entire job alone, ignoring the sub-agent that was registered and visible to them. The sub-agents were read, parsed, listed, and never invoked. It&rsquo;s like hiring an assistant and letting them sit at their desk all day while you do everything yourself.</p>
<p>Why did this happen? Two layers of explanation.</p>
<h3>The technical layer<span class="hx:absolute hx:-mt-20" id="the-technical-layer"></span>
    <a href="#the-technical-layer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The primary models read the sub-agent descriptions and decided the task didn&rsquo;t fit. Descriptions typically said &ldquo;use proactively for concrete code execution&rdquo; with a caveat &ldquo;skip for cross-file architectural decisions&rdquo;. Except an entire Rails app is cross-file architectural decision. Controller depends on service, service depends on initializer, view depends on partial, all depend on how tests mock the LLM. There&rsquo;s no isolated piece you can hand off to a dumber executor without losing context.</p>
<p>I could have written the sub-agent description more imperatively, forcing delegation. But that would be cheating to get a result. The point of the test is to see what the model does freely, not what it does under force. And freely, it didn&rsquo;t delegate.</p>
<h3>The management layer<span class="hx:absolute hx:-mt-20" id="the-management-layer"></span>
    <a href="#the-management-layer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Delegation has coordination cost. That&rsquo;s basic project management knowledge, not news. When you outsource a task, you have to:</p>
<ul>
<li>Write a clear spec for the other executor</li>
<li>Wait for the result</li>
<li>Review</li>
<li>Request adjustments if there&rsquo;s a gap between what you wanted and what came back</li>
<li>Reintegrate into the rest of the work</li>
</ul>
<p>For seniors outsourcing to juniors, that cost is real. Productivity doesn&rsquo;t scale linearly with the number of executors. Doubling the team doesn&rsquo;t double speed. In many cases, outsourcing costs more time than doing it yourself would have.</p>
<p>With LLMs the same thing happens, compounded by a specific trait: Opus&rsquo;s planning is rarely perfect on the first try. Never is. Opus reads the prompt, builds a plan, starts implementing, hits a problem (library that doesn&rsquo;t have that version, method that doesn&rsquo;t exist as it imagined, test that fails for a reason it didn&rsquo;t anticipate), adjusts the plan, tries again. That &ldquo;plan → try → adjust&rdquo; loop is inherent to the work. Not an Opus failure, it&rsquo;s the nature of software development.</p>
<p>Now imagine you insert a smaller model in the middle of that loop. Opus plans, hands off to Qwen to execute, Qwen writes code that likely hallucinates the API (as we saw in the previous benchmark, Qwen invents <code>RubyLLM::Client.new</code> which doesn&rsquo;t exist), Opus gets the code back, finds it&rsquo;s wrong, has to make a sub-plan to fix it, hands back to Qwen, which invents something else, and on it goes. The communication and correction overhead explodes.</p>
<p>That&rsquo;s why the models themselves, without being forced, decided not to delegate. They know the coordination cost outweighs the benefit, especially for a cohesive task like building a Rails app from scratch.</p>
<h2>Notes per run<span class="hx:absolute hx:-mt-20" id="notes-per-run"></span>
    <a href="#notes-per-run" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Even without delegation happening, the 7 runs produced different results. Let me comment on each.</p>
<h3>Claude Code: Opus 4.7 alone<span class="hx:absolute hx:-mt-20" id="claude-code-opus-47-alone"></span>
    <a href="#claude-code-opus-47-alone" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>11 minutes, $6.74, 24 tests, 1742 files. Result <strong>Tier 3</strong> (broken). Opus in this run hallucinated the <code>chat.complete</code> method on RubyLLM, which doesn&rsquo;t exist, and used <code>chat.add_message(role:, content:)</code> with keyword args instead of a positional hash. Same typical hallucination other models have, now from Opus itself. Weird, because the same Opus 4.7 on opencode delivered correct Tier 1 code on the same prompt.</p>
<h3>Claude Code: Opus 4.7 + Sonnet 4.6<span class="hx:absolute hx:-mt-20" id="claude-code-opus-47--sonnet-46"></span>
    <a href="#claude-code-opus-47--sonnet-46" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>10 minutes, $5.13, 18 tests, 1829 files. Result <strong>Tier 2</strong> (first message works, multi-turn breaks). Better than the Opus-alone baseline, but still has the keyword-args bug on <code>add_message</code>. Zero delegations to Sonnet.</p>
<h3>Claude Code: Opus 4.7 + Haiku 4.5<span class="hx:absolute hx:-mt-20" id="claude-code-opus-47--haiku-45"></span>
    <a href="#claude-code-opus-47--haiku-45" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>15 minutes, $7.83, 34 tests, 1984 files. Result <strong>Tier 3</strong>, same hallucination as Opus alone. The highest test count (34!) all pass because the test fakes mock the hallucinated API Opus itself invented. Tests that prove nothing.</p>
<p>The point worth underlining: Opus 4.7 on Claude Code wrote 34 passing tests, and none of them prove the code works. The hallucinated API is tested against a hallucinated implementation. In the real world, the app crashes on the first message. Test count is a vanity metric when the mock is wrong.</p>
<h3>opencode: Opus 4.7 + GLM 5.1<span class="hx:absolute hx:-mt-20" id="opencode-opus-47--glm-51"></span>
    <a href="#opencode-opus-47--glm-51" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>19 minutes, $1.10, Tier 1 (works on first try). Correct RubyLLM API. Both phases (build + Docker validation) completed clean. Zero calls to GLM 5.1, Opus did everything.</p>
<h3>opencode: Opus 4.7 + local Qwen 3.6<span class="hx:absolute hx:-mt-20" id="opencode-opus-47--local-qwen-36"></span>
    <a href="#opencode-opus-47--local-qwen-36" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>30 minutes, $1.10 (only Opus billed, local Qwen is free), Tier 1. Same quality as above. Zero calls to the Qwen 3.6 running on the 5090.</p>
<h3>Codex: xHigh planning, medium executing<span class="hx:absolute hx:-mt-20" id="codex-xhigh-planning-medium-executing"></span>
    <a href="#codex-xhigh-planning-medium-executing" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>21 minutes, ~$11, Tier 1. The most expensive multi-agent run on the Codex side, but curiously the one that generated the best code, and fixed the <code>add_message</code> bug the previous benchmark caught on GPT 5.4 alone. But zero delegations, all the work came from xHigh.</p>
<h3>Codex: xHigh planning, low executing<span class="hx:absolute hx:-mt-20" id="codex-xhigh-planning-low-executing"></span>
    <a href="#codex-xhigh-planning-low-executing" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>20 minutes, ~$10, Tier 2. Reproduced the keyword-args bug. Cheaper than the above but worse code. Zero delegations to low either.</p>
<h2>Same model, different runs, different results<span class="hx:absolute hx:-mt-20" id="same-model-different-runs-different-results"></span>
    <a href="#same-model-different-runs-different-results" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the real story of this benchmark, which becomes clearer when you group by model instead of by combination.</p>
<p>Since the sub-agents never ran, each &ldquo;multi-model&rdquo; combination effectively became another run of the primary model. That gave me something I didn&rsquo;t have in the previous benchmark: <strong>multiple runs of the same model on the same prompt</strong>. Let me compare.</p>
<h3>GPT 5.4 xHigh: three runs<span class="hx:absolute hx:-mt-20" id="gpt-54-xhigh-three-runs"></span>
    <a href="#gpt-54-xhigh-three-runs" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In <a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">last week&rsquo;s benchmark</a>, GPT 5.4 via Codex with xHigh ran once, scoring Tier 2 (first message works, multi-turn breaks due to <code>chat.add_message(role:, content:)</code> with keyword args instead of positional hash).</p>
<p>This week I ran two more, with different sub-agent configurations (which weren&rsquo;t used, but the presence changes the primary&rsquo;s behavior, as I showed above):</p>
<table>
  <thead>
      <tr>
          <th>Run</th>
          <th>Tier</th>
          <th style="text-align: right">Tokens</th>
          <th style="text-align: right">Cost</th>
          <th>Correct API?</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>xHigh alone (last week)</td>
          <td>2</td>
          <td style="text-align: right">7.6M</td>
          <td style="text-align: right">~$16</td>
          <td>Bug in <code>add_message</code></td>
      </tr>
      <tr>
          <td>xHigh + medium subagent</td>
          <td>1</td>
          <td style="text-align: right">5.44M</td>
          <td style="text-align: right">~$11</td>
          <td><strong>Fixed the bug</strong></td>
      </tr>
      <tr>
          <td>xHigh + low subagent</td>
          <td>2</td>
          <td style="text-align: right">4.28M</td>
          <td style="text-align: right">~$10</td>
          <td>Bug came back</td>
      </tr>
  </tbody>
</table>
<p>Same model, same prompt, three runs. One of them wrote <code>chat.add_message(message)</code> with positional hash (Tier 1, works in multi-turn). The other two wrote it with keyword args (Tier 2, breaks on the second message).</p>
<p>No sub-agent was called in any of the multi variants. The only thing that changed between the three runs was the text of the available sub-agent description (or its absence). Even so, that GPT 5.4 &ldquo;got&rdquo; the API right in one run and &ldquo;got it wrong&rdquo; in the other two.</p>
<h3>Claude Opus 4.7: six runs<span class="hx:absolute hx:-mt-20" id="claude-opus-47-six-runs"></span>
    <a href="#claude-opus-47-six-runs" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>With Opus it was even more instructive. Six different runs, same model, same prompt, results spread from Tier 1 to Tier 3:</p>
<table>
  <thead>
      <tr>
          <th>Run</th>
          <th>Harness</th>
          <th>Tier</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Cost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Opus 4.7 baseline</td>
          <td>opencode</td>
          <td>1</td>
          <td style="text-align: right">18.2m</td>
          <td style="text-align: right">$1.10</td>
      </tr>
      <tr>
          <td>Opus 4.7 + GLM 5.1</td>
          <td>opencode</td>
          <td>1</td>
          <td style="text-align: right">10.3m</td>
          <td style="text-align: right">$1.10</td>
      </tr>
      <tr>
          <td>Opus 4.7 + Qwen 3.6 local</td>
          <td>opencode</td>
          <td>1</td>
          <td style="text-align: right">19.4m</td>
          <td style="text-align: right">$1.10</td>
      </tr>
      <tr>
          <td>Opus 4.7 alone</td>
          <td>Claude Code</td>
          <td>3</td>
          <td style="text-align: right">11.0m</td>
          <td style="text-align: right">$6.74</td>
      </tr>
      <tr>
          <td>Opus 4.7 + Sonnet</td>
          <td>Claude Code</td>
          <td>2</td>
          <td style="text-align: right">10.1m</td>
          <td style="text-align: right">$5.13</td>
      </tr>
      <tr>
          <td>Opus 4.7 + Haiku</td>
          <td>Claude Code</td>
          <td>3</td>
          <td style="text-align: right">14.7m</td>
          <td style="text-align: right">$7.83</td>
      </tr>
  </tbody>
</table>
<p>The three Opus runs on opencode: consistent Tier 1. Correct RubyLLM API, works in multi-turn, clean code.</p>
<p>The three Opus runs on Claude Code: one Tier 2 and two Tier 3. Code that hallucinated <code>chat.complete</code> (method doesn&rsquo;t exist) or got the <code>add_message</code> signature wrong.</p>
<p>Same model. Same prompt. Different harness = different result.</p>
<h3>What that means<span class="hx:absolute hx:-mt-20" id="what-that-means"></span>
    <a href="#what-that-means" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Two possible readings:</p>
<p><strong>The lazy reading:</strong> &ldquo;Opus 4.7 on Claude Code regressed, switch to 4.6&rdquo; or &ldquo;opencode is better than Claude Code&rdquo;. Both would be wrong conclusions from such a small benchmark.</p>
<p><strong>The honest reading:</strong> a single-run benchmark, or even three runs, isn&rsquo;t enough to assert anything about the absolute quality of a model. Variance is high, the context loaded by the harness (CLAUDE.md, tool schemas, agent registries) shifts the &ldquo;mental model&rdquo; the model activates, and the result can swing between tiers.</p>
<p>In the previous benchmark, I got lucky (or unlucky) with runs consistent enough for hierarchies like &ldquo;Claude/GLM work, Kimi/DeepSeek/Qwen hallucinate the API&rdquo; to hold. But even there, run-to-run variance is real. If I run Kimi K2.5 ten times, maybe two or three of those runs would hit the API right. I didn&rsquo;t test this, but it&rsquo;s plausible.</p>
<p>This benchmark reinforces the point: the rankings in the previous article count as signal, not proof. &ldquo;Works on first try 80% of the time&rdquo; is different from &ldquo;always works&rdquo;. For production use, you want a model robust to variance, one that doesn&rsquo;t have a 20% chance of returning hallucinated code. Today, the only models that meet that bar for me are Claude Opus and Claude Sonnet, on any harness. GLM 5.1 is close but I don&rsquo;t have a large sample yet.</p>
<p>Does that mean Opus 4.7 &ldquo;got worse&rdquo;? No. Does it mean Claude Code &ldquo;is worse than opencode&rdquo;? No. It means a single-run benchmark on a greenfield Rails app doesn&rsquo;t capture model variance. Worth knowing before jumping to strong conclusions.</p>
<h2>Execution time: is multi-model slower?<span class="hx:absolute hx:-mt-20" id="execution-time-is-multi-model-slower"></span>
    <a href="#execution-time-is-multi-model-slower" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Adjacent question worth measuring: if the sub-agent never ran, were the multi-model runs slower than the alone baselines? My initial intuition was yes, since without cross-session parallelism the primary model does the work serially. The data tells another story.</p>
<table>
  <thead>
      <tr>
          <th>Run</th>
          <th style="text-align: right">Time</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Code Opus alone</td>
          <td style="text-align: right">11.0m</td>
      </tr>
      <tr>
          <td>Claude Code Opus + Sonnet</td>
          <td style="text-align: right">10.1m</td>
      </tr>
      <tr>
          <td>Claude Code Opus + Haiku</td>
          <td style="text-align: right">14.7m</td>
      </tr>
      <tr>
          <td>opencode Opus baseline</td>
          <td style="text-align: right">18.2m</td>
      </tr>
      <tr>
          <td>opencode Opus + GLM 5.1</td>
          <td style="text-align: right">10.3m</td>
      </tr>
      <tr>
          <td>opencode Opus + Qwen 3.6</td>
          <td style="text-align: right">19.4m</td>
      </tr>
      <tr>
          <td>Codex xHigh baseline</td>
          <td style="text-align: right">21.9m</td>
      </tr>
      <tr>
          <td>Codex xHigh + medium</td>
          <td style="text-align: right">21.2m</td>
      </tr>
      <tr>
          <td>Codex xHigh + low</td>
          <td style="text-align: right">20.2m</td>
      </tr>
  </tbody>
</table>
<p>Some multi-model runs were <strong>faster</strong> than the alone baseline. opencode + GLM 5.1 was almost half the time of opencode alone (10m vs 18m). Claude Code + Sonnet was 1 minute faster than pure Opus. Codex multi-agent variants ended up slightly faster than xHigh alone.</p>
<p>Others were slower: Claude Code + Haiku took 15m (4m more than baseline). opencode + Qwen 3.6 ran 19m (same as baseline, likely penalized by llama-swap overhead even without invoking the model).</p>
<p>No consistent pattern of &ldquo;multi-model is always slower&rdquo; or &ldquo;always faster&rdquo;. What happened, looking at tool calls and test counts, is more interesting: <strong>the primary model changes its own behavior when it sees a sub-agent is available, even without calling it</strong>.</p>
<ul>
<li>Claude Code Opus alone: 24 tests, 11m</li>
<li>Claude Code Opus + Sonnet: 18 tests, 10m (fewer tests, faster)</li>
<li>Claude Code Opus + Haiku: 34 tests, 15m (more tests, slower)</li>
</ul>
<p>The pattern: when the sub-agent exists in the description as &ldquo;executor&rdquo;, the primary model sometimes produces leaner output, as if &ldquo;leaving work for later&rdquo;. When the sub-agent describes more expensive execution (Haiku as &ldquo;high-volume execution&rdquo;), the model seems to assume it can afford to write more tests because &ldquo;the cheap executor will handle it&rdquo;. The executor is never called in either case. But the sub-agent&rsquo;s presence influences the primary&rsquo;s planning.</p>
<p>Subtle effect, like a delegation placebo. The model doesn&rsquo;t delegate, but behaves as if it would. It can be good (more focused output) or bad (lower test coverage than baseline). Not something you control, it&rsquo;s emergent behavior from the model reading the sub-agent description.</p>
<h3>So is it worth configuring Haiku just for the placebo?<span class="hx:absolute hx:-mt-20" id="so-is-it-worth-configuring-haiku-just-for-the-placebo"></span>
    <a href="#so-is-it-worth-configuring-haiku-just-for-the-placebo" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>You might be tempted: &ldquo;if Opus wrote more code and more tests with Haiku configured, then it&rsquo;s worth configuring Haiku as a sub-agent even if it never runs, just for the placebo&rdquo;. The numbers say no.</p>
<p>Comparing Opus alone vs Opus with Haiku configured, both on Claude Code:</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th style="text-align: right">Opus alone</th>
          <th style="text-align: right">Opus + Haiku</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Time</td>
          <td style="text-align: right">11.0m</td>
          <td style="text-align: right">14.7m</td>
      </tr>
      <tr>
          <td>Cost</td>
          <td style="text-align: right">$6.74</td>
          <td style="text-align: right">$7.83</td>
      </tr>
      <tr>
          <td>Tests</td>
          <td style="text-align: right">24</td>
          <td style="text-align: right">34</td>
      </tr>
      <tr>
          <td>Quality tier</td>
          <td style="text-align: right">3 (broken)</td>
          <td style="text-align: right">3 (broken, same hallucination)</td>
      </tr>
  </tbody>
</table>
<p>With Haiku configured, Opus spent 3.7 more minutes, $1.09 more, and wrote 10 more tests. The quality tier stayed the same. The same <code>chat.complete</code> hallucination appeared in both runs. The 10 extra tests mock the same hallucinated API, so they prove nothing the original 24 weren&rsquo;t already proving. More code, not better code.</p>
<p>Delegation placebo can shift quantity but doesn&rsquo;t fix factual errors. And with a sample of 1 run each, even the quantity increase isn&rsquo;t reliable, because Opus-alone run-to-run variance is also high (probably another Opus-alone run would hit 30+ tests by chance).</p>
<p><strong>Practical takeaway:</strong> don&rsquo;t configure a &ldquo;fake&rdquo; sub-agent just to try to manipulate the primary. The cost in tokens/time is certain, the benefit is speculative. Opus alone, no sub-agent, stays the recommended default configuration. Sub-agents are only worth it when you have a real delegation use case that works (and we saw here that greenfield isn&rsquo;t one).</p>
<h3>&ldquo;Multi-model is slower&rdquo; hypothesis doesn&rsquo;t hold<span class="hx:absolute hx:-mt-20" id="multi-model-is-slower-hypothesis-doesnt-hold"></span>
    <a href="#multi-model-is-slower-hypothesis-doesnt-hold" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Back to the original question: no, you can&rsquo;t say multi-model without delegation is consistently slower. Sometimes it is, sometimes it&rsquo;s faster, depends on the model and the sub-agent description. What you can say is that the sub-agent&rsquo;s presence shifts the primary&rsquo;s behavior in unpredictable ways, and that alone is an argument against multi-model configurations with no clear need.</p>
<h2>Two unexpected findings<span class="hx:absolute hx:-mt-20" id="two-unexpected-findings"></span>
    <a href="#two-unexpected-findings" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Outside the main topic (multi-model didn&rsquo;t work), two patterns showed up.</p>
<h3>First: the harness affects code quality, not just cost<span class="hx:absolute hx:-mt-20" id="first-the-harness-affects-code-quality-not-just-cost"></span>
    <a href="#first-the-harness-affects-code-quality-not-just-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The same Opus 4.7 produced Tier 1 on opencode and Tier 2/Tier 3 on Claude Code, same prompt. That&rsquo;s new. As far as I know, this is the first benchmark evidence that the harness (the CLI wrapping the model) can degrade factual correctness, not just cost.</p>
<p>The hypothesis is that Claude Code carries 6-11 million cache-read tokens per run (CLAUDE.md, tool schemas, agent registries, etc.), against opencode&rsquo;s ~210 thousand. That volume of context seems to pull Opus toward a generic OpenAI SDK &ldquo;mental model&rdquo;, where <code>chat.complete</code> makes sense, instead of the specific mental model for the RubyLLM gem. Speculation on my part, I can&rsquo;t prove it. But the Tier difference between the two harnesses running the same model is concrete.</p>
<p>This <strong>does not mean</strong> opencode is better than Claude Code for daily use. In my day-to-day, Claude Code with Opus beats opencode on almost everything: editor integration, long-session context management, native tool support, multi-step planning quality. The benchmark has a narrow scope (greenfield Rails app, specific prompt, no human iteration) that doesn&rsquo;t reflect real use.</p>
<p>What the data says: variance between harnesses is real and measurable. Worth keeping in mind when you&rsquo;re evaluating a model.</p>
<h3>Second: cost of Claude Code vs opencode<span class="hx:absolute hx:-mt-20" id="second-cost-of-claude-code-vs-opencode"></span>
    <a href="#second-cost-of-claude-code-vs-opencode" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Running the same Opus 4.7 on the same prompt:</p>
<ul>
<li>Claude Code: $5 to $8 per run</li>
<li>opencode: $1.10 per run</li>
</ul>
<p>Claude Code costs 5 to 7 times more per run on the same model. The difference is cache-read: Claude Code loads 6-11M context tokens per run, opencode loads ~210K. There&rsquo;s a legit technical reason (tool schemas, TodoWrite, agent registries, CLAUDE.md, editor integration), but the overhead is real and shows up directly on the bill of anyone paying per token.</p>
<p>Worth a more refined take here, because this changes the calculus depending on how you consume.</p>
<p><strong>If you&rsquo;re on Pro or Max:</strong> use Claude Code. Period. Subscription covers the tokens, you get the full feature set (native tool support, skills, agents, Plan mode, better long-session context). No reason to switch.</p>
<p><strong>If you pay per token directly through the API, the math changes with volume.</strong></p>
<p>For light use (a few hundred dollars a month): opencode with Opus is cheaper, and in this specific benchmark hit Tier 1 while Claude Code landed in Tier 2/3. Works well for automated pipelines, CI, benchmarks, server-side agents.</p>
<p>For heavy use (thousands of dollars a month on API): staying on per-token doesn&rsquo;t make sense. The Max 20x subscription at $200/month covers heavy volume and includes Claude Code. For a heavy vibe-coder, Max is cheaper than Opus on API by a wide margin. Then you&rsquo;re back in the first bucket, on Claude Code.</p>
<p><strong>Opencode is better, regardless of cost, for:</strong></p>
<ul>
<li>Headless or automated use (CI, benchmarks, server agents)</li>
<li>Multi-provider setups where you want the same harness hitting OpenRouter, Z.ai, and local llama-swap</li>
<li>When you need structured JSON output (<code>--format json</code>)</li>
<li>Neutral model comparisons</li>
</ul>
<p><strong>Claude Code is better, regardless of cost, for:</strong></p>
<ul>
<li>Interactive coding sessions with a human in the loop</li>
<li>Projects with CLAUDE.md, skills, custom MCP</li>
<li>Work where Opus&rsquo;s Plan mode matters</li>
<li>Long iterative sessions where accumulated context helps</li>
</ul>
<p>Honest reading: this benchmark measures a narrow scenario (greenfield, one-shot, no human iteration). For real daily work, Claude Code with Max remains the recommendation for 99% of people. Opencode&rsquo;s cost win shows up in a specific niche (automated pipelines or per-token API use below the Max break-even). Most people aren&rsquo;t in that niche.</p>
<h2>The &ldquo;Opus plans, Qwen executes&rdquo; myth<span class="hx:absolute hx:-mt-20" id="the-opus-plans-qwen-executes-myth"></span>
    <a href="#the-opus-plans-qwen-executes-myth" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every so often someone on Twitter talks about setting up a pipeline where Opus makes the detailed technical plan and a smaller model (Qwen, GLM 5, Haiku, Sonnet) executes. &ldquo;Save tokens, same quality, everybody wins&rdquo;.</p>
<p>Doesn&rsquo;t work. Or rather, works for a demo, doesn&rsquo;t work for a real project.</p>
<p>The most serious problem is that <strong>the plan is never perfect on the first pass</strong>. Code is never one-shot. You implement, find a problem, adjust. With one big model, that adjustment happens in real time by the model itself. With two models, every adjustment has to go back to the planner, be reprocessed, a new plan written, a new executor invoked. The loop is slower.</p>
<p>Then there&rsquo;s the question of <strong>factual API knowledge</strong>. If Opus&rsquo;s plan says &ldquo;use RubyLLM to call OpenRouter&rdquo;, Opus knows it&rsquo;s <code>RubyLLM.chat(model:).ask(msg).content</code>. The smaller Qwen reads the plan and implements with the API it thinks exists, which may be <code>RubyLLM::Client.new.complete</code>. The plan doesn&rsquo;t correct this because the plan doesn&rsquo;t carry the gem&rsquo;s factual knowledge. Only the model that knows that API can implement it correctly.</p>
<p>And then there&rsquo;s <strong>coordination cost</strong>, which explodes with iteration. Every round of &ldquo;plan → execute → fail → re-plan → execute again&rdquo; costs more tokens than just letting the big model do everything in one session. You pay in planning tokens AND in wrong-code tokens that need to be rewritten.</p>
<p>In theory, multi-model makes sense. In practice, it&rsquo;s work for a Twitter thread with pretty animations, not the workflow of someone who ships code.</p>
<h2>When multi-model can make sense<span class="hx:absolute hx:-mt-20" id="when-multi-model-can-make-sense"></span>
    <a href="#when-multi-model-can-make-sense" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I don&rsquo;t want to sound absolute. There are scenarios where multi-model is the right pick.</p>
<p>The main one is <strong>genuinely parallel and decoupled tasks</strong>. API migration across 30 identical files, for example: each file follows the same pattern, no dependencies between them. Opus could supervise 20 sub-agents doing the same transformation on 20 different files. In that case, the coordination cost is amortized by the parallelism.</p>
<p>Another is <strong>tasks with a heavy research phase followed by a direct implementation phase</strong>. Opus does the architectural spike exploring legacy code, then delegates the mechanical implementation to the smaller model.</p>
<p>A real example I went through last week: <a href="/en/2026/04/09/20-years-of-blogging-ai-finally-translated-everything/">I translated 700+ posts and all the video subtitles of the blog to English</a> using Claude Code. I burned through the Max 20x and blew another $1120 in extra usage on top. Translation is exactly the kind of task that would have benefited from multi-model: each post is independent of the others, no cross-file dependency, zero architectural planning, just batch translation. Opus orchestrating + Sonnet executing each file&rsquo;s translation would have cut the cost in half, easily. Didn&rsquo;t occur to me at the time, I ran everything on Opus. The lesson I take: for genuinely parallel tasks, multi-model with Sonnet as executor makes sense, and I missed a clear chance to save money.</p>
<p>But neither of the cases above is &ldquo;greenfield Rails app&rdquo;. A new app from scratch is the worst scenario for multi-model because every part depends on every other part. The models aren&rsquo;t dumb, they recognize this and refuse to delegate.</p>
<h2>The rule of thumb stays the same<span class="hx:absolute hx:-mt-20" id="the-rule-of-thumb-stays-the-same"></span>
    <a href="#the-rule-of-thumb-stays-the-same" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For 90% of day-to-day programming work, my recommendation remains:</p>
<ul>
<li>Claude Code + Opus (4.6 or 4.7)</li>
<li>If cost is critical and you&rsquo;re OK plugging into OpenRouter, GLM 5.1 is a comfortable second place</li>
<li>If you have good GPU (5090 or equivalent), local Qwen 3.6 35B is acceptable for simple tasks, with caveats</li>
</ul>
<p>Multi-model? Only for specific cases where the parallelism is genuine. For normal projects, it&rsquo;s unnecessary overhead.</p>
<h2>Benchmarks aren&rsquo;t absolute truth<span class="hx:absolute hx:-mt-20" id="benchmarks-arent-absolute-truth"></span>
    <a href="#benchmarks-arent-absolute-truth" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This benchmark measures a specific thing: greenfield Rails app, deterministic prompt, no human iteration, in automated runners. A narrow slice of real use.</p>
<p>If you want to know which combination works for YOUR workflow, YOUR types of projects, YOUR quality expectations, don&rsquo;t trust my benchmark. Run your own. The <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">code is all on GitHub</a>, the harness is extensible, you swap the prompt and you have your own comparison.</p>
<p>What I hope this work contributes is methodology, not a definitive answer. &ldquo;Claude is better than Qwen&rdquo; depends on what you&rsquo;re doing. &ldquo;Multi-model doesn&rsquo;t work&rdquo; depends on the type of task. Benchmarks narrow the space for speculation with concrete data, they don&rsquo;t close the discussion.</p>
<p>Meanwhile, if someone tells you they combined Claude + GLM and it was magical, ask for the code, the prompt, the repo. Most of the time they measured something quite different, or have a specific task where that combination fits. Don&rsquo;t generalize from a tweet.</p>
]]></content:encoded><category>llm</category><category>benchmark</category><category>claude</category><category>ai</category><category>vibecoding</category></item><item><title>Omarchy on the Thinkpad T14 Gen 6: Mini-Review and Full Setup</title><link>https://akitaonrails.github.io/en/2026/04/18/omarchy-on-thinkpad-t14-gen-6/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/18/omarchy-on-thinkpad-t14-gen-6/</guid><pubDate>Sat, 18 Apr 2026 08:30:00 GMT</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/closed-lid.jpg" alt="Thinkpad T14 Gen 6 closed, next to its USB-C charger" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;I bought a Lenovo Thinkpad T14 Gen 6 and installed Omarchy on it. It&amp;rsquo;s not my main machine, and it&amp;rsquo;s not supposed to be. It&amp;rsquo;s a companion: a notebook I can open on the 3D printing office desk, SSH into the desktop, fire up Claude Code, access files on the NAS, debug the network through ethernet without hunting for a USB dongle and a long cable. This article covers the hardware choice, the Omarchy setup on top, the customizations specific to a laptop and to this Thinkpad in particular, and the architecture decisions that might not be obvious.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/closed-lid.jpg" alt="Thinkpad T14 Gen 6 closed, next to its USB-C charger"  loading="lazy" /></p>
<p>I bought a Lenovo Thinkpad T14 Gen 6 and installed Omarchy on it. It&rsquo;s not my main machine, and it&rsquo;s not supposed to be. It&rsquo;s a companion: a notebook I can open on the 3D printing office desk, SSH into the desktop, fire up Claude Code, access files on the NAS, debug the network through ethernet without hunting for a USB dongle and a long cable. This article covers the hardware choice, the Omarchy setup on top, the customizations specific to a laptop and to this Thinkpad in particular, and the architecture decisions that might not be obvious.</p>
<h2>Mini-review of the Thinkpad T14 Gen 6<span class="hx:absolute hx:-mt-20" id="mini-review-of-the-thinkpad-t14-gen-6"></span>
    <a href="#mini-review-of-the-thinkpad-t14-gen-6" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/open-lid-turned-off.jpg" alt="Thinkpad T14 Gen 6 open with the screen off, showing the keyboard and the Intel Core Ultra 5 sticker"  loading="lazy" /></p>
<p>Let&rsquo;s get this out of the way: this isn&rsquo;t the notebook of my dreams. If I were picking by looks, I&rsquo;d grab an Asus Zenbook S 14 with an OLED screen. If I were picking by portability, the Thinkpad T14s with the aluminum shell, which is lighter and much better looking. The regular T14 has a 14&quot; 1920x1200 IPS panel, 400 nits, 60Hz, no HDR. It gets the job done, but it&rsquo;s a far cry from the Zenbook&rsquo;s screen. The shell is plastic with a rubberized finish, which makes it scratch-resistant but not premium. Reviews from <a href="https://www.notebookcheck.net/AMD-Ryzen-AI-meets-classic-ThinkPad-Lenovo-ThinkPad-T14-Gen-6-AMD-laptop-review.1222690.0.html"target="_blank" rel="noopener">NotebookCheck</a> and <a href="https://www.xda-developers.com/lenovo-thinkpad-t14-gen-6-review/"target="_blank" rel="noopener">XDA Developers</a> land on the same verdict: good price, good connectivity, fine performance for office work, but the screen is dated.</p>
<p>What makes up for it:</p>
<ul>
<li>Port selection. Full-size HDMI. Gigabit ethernet. USB-A, USB-C Thunderbolt 4, charges over USB-C (not a proprietary charger). For a debug companion, this is exactly what I wanted. The Zenbook S 14, being thinner, cuts ports.</li>
<li>Fingerprint sensor that actually works on Linux (Goodix MOC, works with libfprint on kernel 6.11+). I use this, details later.</li>
<li>Thinkpad keyboard. 1.5mm key travel, trackpoint, classic layout. It&rsquo;s not the best keyboard in the world in 2026, but it&rsquo;s reliable and durable.</li>
<li>Rugged shell. It will fall, it will scratch, it will travel in a backpack. If I put a Zenbook OLED or a Macbook Pro on the 3D printing office desk next to the printer with PLA dust in the air, I&rsquo;d be nervous. The Thinkpad can take a beating.</li>
</ul>
<p>If I wanted a gaming machine, I&rsquo;d get an Asus Zephyrus G14, my favorite gaming notebook. If I wanted a creative work machine, I&rsquo;d get the <a href="https://www.asus.com/laptops/for-home/zenbook/zenbook-duo-ux8406/"target="_blank" rel="noopener">Asus Zenbook Duo (UX8406)</a> with two vertical OLED screens, great for video editing and 3D modeling. I can afford any Macbook Pro or Mac Studio, and I chose not to go that way. I&rsquo;ll explain in the next section.</p>
<h2>My use case<span class="hx:absolute hx:-mt-20" id="my-use-case"></span>
    <a href="#my-use-case" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/open-lid-fastfetch.jpg" alt="Thinkpad open on a 3D printing office bench, with fastfetch showing Omarchy in the terminal"  loading="lazy" /></p>
<p>My main PC is a desktop with Ryzen 9 7950X3D, 96 GB of RAM, RTX 5090 with 32 GB. That&rsquo;s where I work, experiment with local models, run containers, edit the blog. For gaming, I have a separate mini-PC with an RTX 4090. Those two cover 100% of what I need to do at home.</p>
<p>The notebook exists to cover the remaining 1%: sitting on the couch, taking it to the 3D printing office, taking it to the kitchen, short trips. It&rsquo;s not meant to replace the desktop. It&rsquo;s meant to give me remote access to the desktop when I&rsquo;m away from it.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/ssh-to-remote-desktop-on-left.png" alt="Hyprland with two terminals side by side: left is an SSH session on the main desktop (96 GB, RTX 5090), right is the Thinkpad itself. Same UI, two machines."  loading="lazy" /></p>
<p>In practice it looks like this: open the notebook, split the Hyprland layout, left pane SSHs into the main desktop, right pane is the notebook itself. Same Omarchy on both, same keybindings, same bash. The notebook becomes an extension of the desktop, not a parallel environment I have to re-learn in my head every time I switch.</p>
<h3>SSH from outside the house: Tailscale<span class="hx:absolute hx:-mt-20" id="ssh-from-outside-the-house-tailscale"></span>
    <a href="#ssh-from-outside-the-house-tailscale" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Inside the local network, SSH is trivial, the notebook talks to the desktop through the internal IP. Outside the house is another story. My home IP is dynamic, opening port 22 to the internet is a terrible idea, and even with DDNS and port forwarding you&rsquo;re putting SSH on the public internet for any scanner to find.</p>
<p>The solution I use is <a href="https://tailscale.com/"target="_blank" rel="noopener">Tailscale</a>. For those who don&rsquo;t know: Tailscale is a mesh VPN built on WireGuard that creates a private network between your devices (the &ldquo;tailnet&rdquo;). Each machine runs the agent, authenticates once, and gets a fixed IP on the private network (something like 100.x.y.z). Traffic between your own devices goes peer-to-peer, encrypted by WireGuard. It doesn&rsquo;t route through Tailscale&rsquo;s central server, they only coordinate NAT traversal. Result: from my notebook in a café anywhere in the world, I run <code>ssh hal9000</code> and land on my home desktop as if I were on the same network.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/tailscale-machines.png" alt="Tailscale admin panel showing my two tailnet machines, hal9000 (desktop) and hal9666 (thinkpad), both with SSH enabled"  loading="lazy" /></p>
<p>More sophisticated options exist: <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/"target="_blank" rel="noopener">Cloudflare Tunnel</a> with Zero Trust to expose services publicly with SSO auth, self-hosted headscale, raw WireGuard with manual config, Nebula, OpenVPN. Each has its use case. If you need to expose services to third parties, control granular per-identity access, or run the whole infrastructure at home without depending on anyone, those options win. In my case, it&rsquo;s just notebook talking to desktop, for short periods (it&rsquo;s not a full week of work, it&rsquo;s a weekend debug session), so free Tailscale covers it. The free tier accepts up to 100 devices and 3 users, way more than I need.</p>
<p>Setup is as simple as it gets:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># On Arch/Omarchy</span>
</span></span><span class="line"><span class="cl">sudo pacman -S tailscale
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now tailscaled.service
</span></span><span class="line"><span class="cl">sudo tailscale up --ssh</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>--ssh</code> flag enables <a href="https://tailscale.com/kb/1193/tailscale-ssh/"target="_blank" rel="noopener">Tailscale SSH</a>, which authenticates via tailnet identity instead of a local SSH key. Once you log in through the browser to tailscale, each registered machine can SSH into the others based on ACL policy defined in the admin panel. Zero key management.</p>
<p>I repeat the same on the desktop, log in with the same account, done. Both machines show up in the panel (hal9000 and hal9666 in the screenshot above, both with SSH enabled). From the notebook: <code>ssh hal9000</code>. From the desktop to the notebook: <code>ssh hal9666</code>. No port forwarding, no public IP, no port 22 exposed to the internet. If the notebook is stolen, I remove it from the tailnet with one click.</p>
<p>A practical detail: since the tailnet gives stable names, I added entries to <code>~/.ssh/config</code> to use these short names:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Host hal9000
  HostName hal9000
  User akitaonrails</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Now <code>ssh hal9000</code> works from anywhere that has Tailscale connected. It&rsquo;s the closest thing to &ldquo;it just works&rdquo; I&rsquo;ve seen for remote SSH.</p>
<h3>Why not a Mac<span class="hx:absolute hx:-mt-20" id="why-not-a-mac"></span>
    <a href="#why-not-a-mac" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I have no use for macOS. As a dev, I live better in native Linux. Every tool I need has a first-class Linux version, and on macOS I&rsquo;d get a second-class version via Homebrew. I don&rsquo;t do iOS, so I don&rsquo;t need XCode. For mobile I use Flutter or Hotwire Native, which run on any OS. iTerm2 and Ghostty on Mac are fine, but Alacritty, Kitty and Ghostty itself on Linux work just as well. Every good piece of software lands on Linux first and gets ported afterwards. Arch with AUR covers everything in a single <code>yay</code>.</p>
<p>For creative work, I haven&rsquo;t done it professionally in years. DaVinci Resolve Studio on Linux is better than Final Cut Pro. Krita or Affinity Photo replace Photoshop for most cases. Clip Studio Paint on Android is better than Procreate. I simply don&rsquo;t have a workflow that depends on Apple, and the App Store annoys me.</p>
<p>For gaming, Mac is terrible. Apple Silicon has a decent GPU for some things, but the native macOS game library is pathetic compared to Windows or Linux. Game Porting Toolkit exists, CrossOver exists, but for anyone who games seriously, it doesn&rsquo;t cut it. I won&rsquo;t be gaming on the Thinkpad, I have the main desktop and the mini-PC with the RTX 4090 for that. But if I ever feel like running Hollow Knight or some indie on a train, I just install Steam and let Proton handle it. Linux became a real gaming platform in the last few years, with Proton/DXVK running almost the entire Steam catalog (check <a href="https://www.protondb.com/"target="_blank" rel="noopener">ProtonDB</a>). I recently wrote about <a href="/en/2026/04/11/emulation-distrobox-with-claude-code/">running my emulation library in distrobox</a> without polluting the host system. With Mac, those options don&rsquo;t exist.</p>
<p>Another argument that always shows up: &ldquo;but a Mac Mini M4 or a Mac Studio with 128GB of unified memory runs large models locally, it&rsquo;s a ChatGPT replacement.&rdquo; I already tested that thesis and wrote a <a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">detailed benchmark</a>. The conclusion: expensive local hardware to run LLMs is a weekend hobby, not a production tool. The open source models that fit don&rsquo;t deliver the quality Claude Opus delivers. I have a <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">home server with AMD Strix Halo and 96 GB of unified RAM</a>, I ran models for dozens of hours, and in my real coding flow they&rsquo;re fine for simple tasks. For complex tasks, Claude Opus. Before spending $4000 on a Mac Studio justifying it with local models, actually test it first. You&rsquo;ll probably end up paying for Opus again.</p>
<h2>Why Omarchy<span class="hx:absolute hx:-mt-20" id="why-omarchy"></span>
    <a href="#why-omarchy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve been using Omarchy on the desktop for months and documented the path in a <a href="/en/tags/omarchy/">series of articles</a>. <a href="/en/2025/08/29/new-omarchy-2-0-install/">Omarchy 2.0</a> has its own installer with LUKS, Btrfs, Limine, snapper and SDDM already configured. I wrote about <a href="/en/2025/09/12/omarchy-2-0-install-with-the-omarchy-iso/">using the official ISO</a>, about <a href="/en/2025/09/07/omarchy-2-0-zsh-configs/">ZSH customizations</a> with atuin, starship, secrets properly organized, about <a href="/en/2025/09/07/omarchy-2-0-mise-to-organize-dev-environments/">Mise for multiple languages</a>, about <a href="/en/2025/09/07/omarchy-2-0-lazyvim-lazyextras/">LazyVim and LazyExtras</a>, about <a href="/en/2025/09/09/omarchy-2-0-understanding-ssh-and-yubikeys/">SSH and Yubikeys</a>, about <a href="/en/2025/09/09/omarchy-2-0-tuis/">modern TUIs</a>. There&rsquo;s also <a href="/en/2026/01/21/omarchy-3-dual-gpu-setup-with-amd-and-nvidia/">Omarchy 3 with dual GPU AMD + NVIDIA</a> and <a href="/en/2026/01/09/omarchy-3-one-of-the-best-coding-agents-crush/">Crush</a> as a coding agent.</p>
<p>For those who don&rsquo;t know: Omarchy is plain Arch Linux with a cosmetic layer on top of Hyprland/Wayland. Pre-configured with sane defaults. I could build all of it from scratch, I&rsquo;ve done it several times in my life, but why redo work someone already did well? I install Omarchy, I stack the tweaks that are mine on top, and I have customized Arch in a fraction of the time.</p>
<p>A point I raise every time I recommend Omarchy: the documentation is excellent. There&rsquo;s an <a href="https://manuals.omamix.org/2/the-omarchy-manual"target="_blank" rel="noopener">official manual</a> covering everything from installation to theme customization, keybindings, Hyprland, Waybar, the works. If you&rsquo;re coming from Ubuntu or Fedora and are wary of Arch, this manual handles most of the doubts. If you&rsquo;ve never touched Hyprland, open it and read around. It&rsquo;s not a README dumped on GitHub, it&rsquo;s a real manual with chapters and an index.</p>
<p>The story of this article starts in a previous thread. I recently migrated my home server, swapping an old Ubuntu box for a <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">Minisforum MS-S1 running openSUSE MicroOS configured with Claude Code</a>. That was the first serious experiment of letting Claude Code drive an entire infra migration, with containers, NFS, services, networking. It worked. And it left an idea in the air: why not use the same approach to configure a new notebook?</p>
<p>That&rsquo;s what I did. I grabbed the Thinkpad, downloaded the latest Omarchy ISO, burned it to a flash drive, installed it over Windows. From zero to working desktop in under an hour. From there on, it&rsquo;s all tweaking, which I documented as I went.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/open-lid-claude-terminal.jpg" alt="Claude Code running on the Thinkpad terminal, ready to start configuring the notebook"  loading="lazy" /></p>
<p>I&rsquo;ll detail the two layers of customization: what any notebook needs, and what&rsquo;s specific to this Thinkpad.</p>
<h2>Laptop-specific configs<span class="hx:absolute hx:-mt-20" id="laptop-specific-configs"></span>
    <a href="#laptop-specific-configs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A notebook has problems a desktop doesn&rsquo;t: battery, suspend, lid, brightness, trackpad. Those are the parts Omarchy default doesn&rsquo;t cover the way I want.</p>
<h3>Power management with TLP<span class="hx:absolute hx:-mt-20" id="power-management-with-tlp"></span>
    <a href="#power-management-with-tlp" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Omarchy defaults to <code>power-profiles-daemon</code>. I swapped it for TLP, which gives granular control over CPU scaling, battery thresholds and dynamic profiles based on AC vs battery.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pacman -S tlp tlp-rdw
</span></span><span class="line"><span class="cl">sudo systemctl mask power-profiles-daemon.service
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now tlp.service</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>mask</code> is necessary because upower pulls <code>power-profiles-daemon</code> back in if you just <code>disable</code> it.</p>
<p>Charge thresholds: 60% to start, 85% to stop. The notebook spends most of its time plugged in on the office desk, and keeping the battery at 100% 24/7 wrecks capacity over time. With 60/85, the battery spends most of its time in the healthy lithium range and still has decent usable capacity.</p>
<p>Profiles: <code>balanced</code> with <code>balance_power</code> on battery, <code>performance</code> on AC. TLP&rsquo;s <code>low-power</code> was too aggressive on the Core Ultra 5 235U, window and terminal response became noticeable. Balanced gives the best consumption/responsiveness ratio for normal use.</p>
<p>There&rsquo;s a gotcha: <code>tlp auto</code> after <code>tlp ac</code> doesn&rsquo;t always re-apply the battery profile. I wrote a small script bound to <code>Super+Ctrl+P</code> that reads the current state and calls <code>tlp bat</code> or <code>tlp ac</code> explicitly:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">profile</span><span class="o">=</span><span class="k">$(</span>cat /sys/firmware/acpi/platform_profile 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> unknown<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">on_ac</span><span class="o">=</span><span class="k">$(</span>cat /sys/class/power_supply/AC*/online 2&gt;/dev/null <span class="p">|</span> head -1 <span class="o">||</span> <span class="nb">echo</span> 0<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$profile</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;performance&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$on_ac</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    sudo /usr/bin/tlp auto &gt;/dev/null
</span></span><span class="line"><span class="cl">    <span class="nv">label</span><span class="o">=</span><span class="s2">&#34;Plugged in — back to AC auto&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    sudo /usr/bin/tlp bat &gt;/dev/null
</span></span><span class="line"><span class="cl">    <span class="nv">label</span><span class="o">=</span><span class="s2">&#34;On battery — normal profile&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  notify-send -t <span class="m">2000</span> <span class="s2">&#34;Performance mode: off&#34;</span> <span class="s2">&#34;</span><span class="nv">$label</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  sudo /usr/bin/tlp ac &gt;/dev/null
</span></span><span class="line"><span class="cl">  notify-send -t <span class="m">2000</span> <span class="s2">&#34;Performance mode: on&#34;</span> <span class="s2">&#34;Forcing AC profile&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In waybar, a custom module <code>custom/perf</code> shows the current state (󰓅 PERF, 󰌪 ECO, or empty for balanced) and accepts a click to toggle. The output script is tiny:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nv">p</span><span class="o">=</span><span class="k">$(</span>cat /sys/firmware/acpi/platform_profile 2&gt;/dev/null<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$p</span><span class="s2">&#34;</span> in
</span></span><span class="line"><span class="cl">  performance<span class="o">)</span> <span class="nb">printf</span> <span class="s1">&#39;󰓅 PERF&#39;</span> <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  low-power<span class="o">)</span>   <span class="nb">printf</span> <span class="s1">&#39;󰌪 ECO&#39;</span>  <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  balanced<span class="p">|</span><span class="s2">&#34;&#34;</span><span class="o">)</span> <span class="nb">printf</span> <span class="s1">&#39;&#39;</span>       <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  *<span class="o">)</span>           <span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$p</span><span class="s2">&#34;</span> <span class="p">;;</span>
</span></span><span class="line"><span class="cl"><span class="k">esac</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Suspend, hibernate, and the lid<span class="hx:absolute hx:-mt-20" id="suspend-hibernate-and-the-lid"></span>
    <a href="#suspend-hibernate-and-the-lid" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is what changed compared to the notebooks I used in 2015. The T14 Gen 6 closes the lid, suspends, and wakes up when you open it. No bug, no weird delay, no needing to log in again mid graphical session. Hyprlock kicks in after the suspend, accepts fingerprint or password, and brings me back to the desktop in seconds. This is the behavior Apple has had for years and that on Linux used to be an adventure. In 2026, on modern hardware with kernel 6.11+, it just works.</p>
<p>Mem sleep mode is s2idle (no deep sleep). Hibernation is enabled via a ~30 GB Btrfs swapfile and <code>resume=/dev/mapper/root resume_offset=...</code> on the Limine kernel cmdline. I rarely use it, but it&rsquo;s there.</p>
<p>Hypridle has aggressive timeouts for a laptop:</p>
<ul>
<li>2.5 min → screensaver</li>
<li>5 min → lock</li>
<li>5.5 min → DPMS off + keyboard backlight off</li>
</ul>
<p>After lock, another 5 min and the screen turns off completely. On unlock, it restores screen and keyboard brightness to the previous level. These timings are much shorter than on the desktop (20-40 min there). The battery difference over a day is measurable.</p>
<h3>Brightness and keyboard backlight<span class="hx:absolute hx:-mt-20" id="brightness-and-keyboard-backlight"></span>
    <a href="#brightness-and-keyboard-backlight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>brightnessctl</code> controls both. Fn+Space toggles the keyboard backlight across three levels. Hypridle saves the current level before turning off and restores it on resume. Command I use in the script:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">brightnessctl -sd <span class="s1">&#39;*::kbd_backlight&#39;</span> <span class="nb">set</span> <span class="m">0</span>    <span class="c1"># save and turn off</span>
</span></span><span class="line"><span class="cl">brightnessctl -rd <span class="s1">&#39;*::kbd_backlight&#39;</span>          <span class="c1"># restore</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Touchpad<span class="hx:absolute hx:-mt-20" id="touchpad"></span>
    <a href="#touchpad" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In <code>hypr/input.conf</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>touchpad {
    natural_scroll = true
    clickfinger_behavior = true
    disable_while_typing = true
    scroll_factor = 0.4
}
gesture = 3, horizontal, workspace</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>clickfinger_behavior</code> swaps the 2-finger click as right-click (more comfortable than hitting the lower-right zone). <code>disable_while_typing</code> is basic palm rejection. Three-finger horizontal swipes switch workspaces, the most useful gesture in Hyprland.</p>
<h2>Thinkpad-specific configs<span class="hx:absolute hx:-mt-20" id="thinkpad-specific-configs"></span>
    <a href="#thinkpad-specific-configs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is what&rsquo;s specific to this model. Some things modern Linux handles on its own, others need explicit configuration.</p>
<h3>Fingerprint sensor<span class="hx:absolute hx:-mt-20" id="fingerprint-sensor"></span>
    <a href="#fingerprint-sensor" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Goodix MOC <code>27c6:6594</code>, works with libfprint 1.94.9+ on kernel 6.11+. Package is <code>fprintd</code>.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pacman -S fprintd
</span></span><span class="line"><span class="cl">fprintd-enroll                        <span class="c1"># right index finger by default</span>
</span></span><span class="line"><span class="cl">fprintd-enroll -f left-index-finger   <span class="c1"># other finger</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>To make <code>sudo</code> accept fingerprint, I added <code>auth sufficient pam_fprintd.so</code> above the <code>pam_unix.so</code> line in <code>/etc/pam.d/sudo</code>. With <code>sufficient</code>, if the fingerprint passes, it authenticates directly. If it fails or I hit ESC, it falls back to the password prompt. This is genuinely worth it: dozens of times a day, <code>sudo pacman -Syu</code> or <code>sudo systemctl restart something</code>, and I just touch the sensor.</p>
<p>On hyprlock, I use hyprlock&rsquo;s native configuration, not PAM:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>auth {
    fingerprint {
        enabled = true
        ready_message = Scan fingerprint or type password
        present_message = Scanning...
        retry_delay = 250
    }
}</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The PAM path gives a double prompt. Configured natively, hyprlock accepts fingerprint or password, whichever succeeds first unlocks.</p>
<p>On SDDM (initial login), I kept password only. The reason: the login password unlocks the GNOME keyring, and the fingerprint can&rsquo;t provide plaintext for that. Once the keyring is unlocked, hyprlock can use fingerprint without issues.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/sudo-asks-for-fingerprinting.png" alt="sudo prompt asking for fingerprint read"  loading="lazy" /></p>
<p>In practice, sudo looks like that. Touch the sensor, authenticate, move on.</p>
<h3>Brazilian Thinkpad keyboard<span class="hx:absolute hx:-mt-20" id="brazilian-thinkpad-keyboard"></span>
    <a href="#brazilian-thinkpad-keyboard" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The Brazilian Thinkpad keyboard has an annoying quirk. The <code>/?</code> key sits where the right Ctrl would be (keycode 97), not in the traditional ABNT2 AB11 position (keycode 89). If you use the standard <code>br(abnt2)</code> layout, that key is inaccessible. It literally prints nothing.</p>
<p>The solution is the <code>br(thinkpad)</code> variant that exists in <code>/usr/share/X11/xkb/symbols/br</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>xkb_symbols &#34;thinkpad&#34; {
    include &#34;br(abnt2)&#34;
    name[Group1]=&#34;Portuguese (Brazil, IBM/Lenovo ThinkPad)&#34;;
    key &lt;RCTL&gt; { [ slash, question, degree, questiondown ] };
};</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In <code>hypr/input.conf</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>kb_layout = br
kb_variant = thinkpad
kb_model = thinkpad60</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And system-wide for TTY/X11:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo localectl set-keymap br-abnt2
</span></span><span class="line"><span class="cl">sudo localectl set-x11-keymap br thinkpad thinkpad60</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I wrote a small Python that reads raw scancodes from <code>/dev/input/event*</code> to diagnose these quirks. Useful when a key decides not to work and you need to find out if it&rsquo;s hardware, kernel, or xkb.</p>
<h3>Input method: fcitx5<span class="hx:absolute hx:-mt-20" id="input-method-fcitx5"></span>
    <a href="#input-method-fcitx5" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Alongside the layout I install fcitx5. Input method, for those who don&rsquo;t know, is the layer that turns a key sequence into characters. It handles deadkeys (tilde for nasalization, acute accent, circumflex), composing characters that aren&rsquo;t on the keyboard (ç, Ç, uppercase accented letters), emoji support. In Qt or GTK apps, the input method also drives context menus for cedilla and accents.</p>
<p>Packages:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pacman -S --needed fcitx5 fcitx5-configtool fcitx5-gtk fcitx5-qt</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the environment variables for toolkits to find fcitx5. I created <code>~/.config/environment.d/fcitx.conf</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>INPUT_METHOD=fcitx
QT_IM_MODULE=fcitx
XMODIFIERS=@im=fcitx
SDL_IM_MODULE=fcitx</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>systemd&rsquo;s <code>environment.d</code> is loaded before graphical sessions, so Brave, Alacritty, VS Code and any GTK/Qt app pick it up automatically. I enable autostart in Hyprland:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>exec-once = fcitx5 -d</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Typing <code>~a</code> produces <code>ã</code>, <code>'e</code> produces <code>é</code>, <code>ç</code> works as it should in every app. On a Brazilian Thinkpad keyboard, this is the difference between typing Portuguese naturally or hunting for each character.</p>
<h3>SOF audio<span class="hx:absolute hx:-mt-20" id="sof-audio"></span>
    <a href="#sof-audio" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Realtek ALC3306/ALC287 codec via Sound Open Firmware. Without <code>sof-firmware</code>, the kernel module loads but the DSP never boots and PipeWire silently falls back to <code>auto_null</code>. Result: you think the speaker is on mute, but actually PipeWire has no device at all.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pacman -S --needed sof-firmware alsa-ucm-conf pipewire pipewire-pulse wireplumber</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If you need to force SOF instead of legacy HDA:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;options snd-intel-dspcfg dsp_driver=3&#34;</span> <span class="p">|</span> sudo tee /etc/modprobe.d/alsa.conf</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Reload without reboot:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo modprobe -r snd_sof_pci_intel_mtl
</span></span><span class="line"><span class="cl">sudo modprobe snd_sof_pci_intel_mtl
</span></span><span class="line"><span class="cl">systemctl --user restart wireplumber pipewire pipewire-pulse</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Firmware updates via fwupd<span class="hx:absolute hx:-mt-20" id="firmware-updates-via-fwupd"></span>
    <a href="#firmware-updates-via-fwupd" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/bios-update.jpg" alt="Lenovo firmware update (ME Corp) running on boot"  loading="lazy" /></p>
<p><code>fwupdmgr update</code> works, but with Limine there&rsquo;s a gotcha: fwupd tries to write to <code>/boot/EFI/systemd/</code> or <code>/boot/EFI/arch/</code>, which don&rsquo;t exist. The workaround:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /boot/EFI/arch
</span></span><span class="line"><span class="cl">sudo fwupdmgr update -y --no-reboot-check
</span></span><span class="line"><span class="cl">fwupdmgr get-history  <span class="c1"># should show &#34;Success&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>HiDPI / fractional scaling<span class="hx:absolute hx:-mt-20" id="hidpi--fractional-scaling"></span>
    <a href="#hidpi--fractional-scaling" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>14&quot; panel at 1920x1200, Hyprland&rsquo;s free resolution. Omarchy&rsquo;s auto 1.5x felt too chunky. I pinned it at 1.333x:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>env = GDK_SCALE,1
monitor=,preferred,auto,1.3333,vrr,2</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Effective 1440x900. GTK with <code>GDK_SCALE=1</code> renders 1:1 with Hyprland (no double magnification). VRR mode 2 only in fullscreen, because LCD panels tend to flicker on static content with VRR active.</p>
<p>Brave and Chromium flags to render well at this scale:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>--ozone-platform=wayland
--enable-features=WaylandFractionalScaleV1,UseOzonePlatform,VaapiVideoDecoder,VaapiVideoEncoder
--enable-gpu-rasterization</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>VAAPI makes a real difference on YouTube battery life.</p>
<h2>Tuning Omarchy to be mine<span class="hx:absolute hx:-mt-20" id="tuning-omarchy-to-be-mine"></span>
    <a href="#tuning-omarchy-to-be-mine" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Omarchy comes with good defaults. What I adjust stacks on top of them, without touching <code>~/.local/share/omarchy/</code> (which is clobbered by <code>omarchy-update</code>). All customization lives in <code>~/.config/</code>.</p>
<h3>Infra: Btrfs, snapshots, Snapper<span class="hx:absolute hx:-mt-20" id="infra-btrfs-snapshots-snapper"></span>
    <a href="#infra-btrfs-snapshots-snapper" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Omarchy already ships with Btrfs and separate subvolumes:</p>
<table>
  <thead>
      <tr>
          <th>Subvolume</th>
          <th>Mount</th>
          <th>In snapshots?</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>@</code></td>
          <td><code>/</code></td>
          <td>yes</td>
      </tr>
      <tr>
          <td><code>@home</code></td>
          <td><code>/home</code></td>
          <td>no</td>
      </tr>
      <tr>
          <td><code>@log</code></td>
          <td><code>/var/log</code></td>
          <td>no</td>
      </tr>
      <tr>
          <td><code>@pkg</code></td>
          <td><code>/var/cache/pacman/pkg</code></td>
          <td>no</td>
      </tr>
  </tbody>
</table>
<p><code>@home</code> separated means <code>~/.cache</code>, <code>~/.config/BraveSoftware</code>, etc. don&rsquo;t bloat root snapshots. Snapshot is for system, not profile.</p>
<p>Swap: 4 GB zram at priority 100 (hit first), plus a 30 GB swapfile at priority 0 (enables hibernation, sized to match RAM).</p>
<p>The snapshot stack: Snapper takes a snapshot before and after every <code>pacman -Syu</code> via snap-pac. <code>limine-snapper-sync</code> writes those snapshots into the Limine menu, so you can boot into a previous snapshot to roll back. If something breaks after an update, you hold a key on boot, pick the pre-update snapshot, boot read-only to verify, and if it&rsquo;s good, run <code>snapper rollback</code>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/btrfs-snapshots-config.png" alt="Btrfs subvolumes separated so root snapshots don’t balloon with user cache"  loading="lazy" /></p>
<p>Omarchy leaves <code>snapper-cleanup.timer</code> and <code>snapper-boot.timer</code> disabled by default. I enabled both and configured retention to fit on a 1 TB SSD without blowing up:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>TIMELINE_LIMIT_HOURLY=10
TIMELINE_LIMIT_DAILY=0
TIMELINE_LIMIT_WEEKLY=1
TIMELINE_LIMIT_MONTHLY=1
NUMBER_LIMIT=50</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A detail that costs debug time if you forget: Docker and Ollama write gigabytes into <code>/var/lib/docker</code> and <code>/var/lib/ollama</code>. If that falls inside <code>@</code>, snapshots go catastrophic. Each Docker image or Ollama model downloaded triples in size. I created nested subvolumes for both, with <code>chattr +C</code> to disable CoW:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo btrfs subvolume create /var/lib/docker
</span></span><span class="line"><span class="cl">sudo chattr +C /var/lib/docker
</span></span><span class="line"><span class="cl">sudo btrfs subvolume create /var/lib/ollama</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This has to be done BEFORE Docker or Ollama writes any data. If they already have data there, you need to migrate.</p>
<h3>NFS to the Synology NAS<span class="hx:absolute hx:-mt-20" id="nfs-to-the-synology-nas"></span>
    <a href="#nfs-to-the-synology-nas" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>My Synology exposes three volumes. On the desktop, I mount them the usual way. On the notebook, it has to be more defensive. The notebook moves around, forgets networks, connects to public WiFi. Notebook&rsquo;s fstab:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>nfs4  _netdev,noauto,nofail,x-systemd.automount,x-systemd.idle-timeout=10min,x-systemd.mount-timeout=15s,noatime,nodiratime,nconnect=4,actimeo=10,soft,timeo=30,retrans=2</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Critical difference vs the desktop: <code>soft</code> with short timeouts (doesn&rsquo;t hang forever), <code>x-systemd.idle-timeout=10min</code> (auto-unmount when idle), no <code>network-online.target</code> in the require (doesn&rsquo;t slow boot). Practical result: <code>cd /mnt/gigachad</code> at home mounts lazily, away from home it fails fast without locking the shell.</p>
<p>Another important detail: my user on the notebook has UID 1026, which matches the share permissions on the Synology. Linux defaults to creating users at 1000, the Synology enforces identity via UID on the wire. If the UIDs don&rsquo;t match, you can&rsquo;t read the files, or worse, you write as nobody. I ran the <code>usermod</code>/<code>groupmod</code> from a TTY (with the user logged out) to remap the user to 1026/1026 and did <code>chown -R</code> on <code>/home</code>.</p>
<h3>Public WiFi hardening<span class="hx:absolute hx:-mt-20" id="public-wifi-hardening"></span>
    <a href="#public-wifi-hardening" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The notebook will leave the house. In an airport or café, I don&rsquo;t want to announce hostname, don&rsquo;t want a trackable MAC, don&rsquo;t want a service listening on an open port.</p>
<p><code>/etc/NetworkManager/conf.d/00-macrandomize.conf</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>[device]
wifi.scan-rand-mac-address=yes

[connection]
wifi.cloned-mac-address=stable
ethernet.cloned-mac-address=stable
connection.stable-id=${CONNECTION}/${BOOT}

ipv6.ip6-privacy=2
ipv6.addr-gen-mode=stable-privacy</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Random MAC per scan (passive anti-fingerprinting). Stable cloned MAC per SSID (captive portals don&rsquo;t re-prompt you every time) but different MACs between networks. IPv6 with temporary addresses and interface ID derived from a stable secret, not from the MAC (no EUI-64 leak).</p>
<p><code>/etc/systemd/resolved.conf.d/hardening.conf</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>[Resolve]
LLMNR=no
MulticastDNS=no</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Kills hostname broadcast on LLMNR and mDNS. I also disable avahi:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl disable --now avahi-daemon.service avahi-daemon.socket</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>UFW firewall:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo ufw default deny incoming
</span></span><span class="line"><span class="cl">sudo ufw default allow outgoing
</span></span><span class="line"><span class="cl">sudo ufw --force enable</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>SSH server is already off by default on Omarchy. Nothing listening. Even if the firewall leaks, there&rsquo;s no surface.</p>
<p>All of it rolls into a single initial setup ritual.</p>
<h3>SSH agent persistence (keyring + keychain)<span class="hx:absolute hx:-mt-20" id="ssh-agent-persistence-keyring--keychain"></span>
    <a href="#ssh-agent-persistence-keyring--keychain" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If you use SSH seriously, you want to type the passphrase once per boot and have <code>ssh</code>, <code>git push</code>, <code>scp</code> stay silent for the rest of the day. On current Arch there&rsquo;s a catch: <code>gnome-keyring</code> 50 dropped its SSH component, and the replacement (<code>gcr-ssh-agent</code>) is a plain in-memory agent with no passphrase persistence. The &ldquo;remember this key&rdquo; checkbox you saw in old guides simply doesn&rsquo;t exist anymore.</p>
<p>The combination that works has three pieces:</p>
<ol>
<li><code>gcr-ssh-agent.socket</code> managed by systemd serves <code>SSH_AUTH_SOCK</code> at <code>$XDG_RUNTIME_DIR/gcr/ssh</code></li>
<li><code>pam_gnome_keyring</code> on SDDM login unlocks the keyring with the login password (used by GUI apps like Brave, not by SSH)</li>
<li><code>keychain</code> (wrapper) keeps an <code>ssh-agent</code> alive across logouts, overrides <code>SSH_AUTH_SOCK</code> to point at that persistent agent, and caches the PID in <code>~/.keychain/&lt;host&gt;-sh</code></li>
</ol>
<p>First, install the keyring:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pacman -S --needed gnome-keyring seahorse keychain</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then edit <code>/etc/pam.d/sddm</code> so the login password unlocks the keyring automatically. Add at the top:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>-auth      optional    pam_gnome_keyring.so</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And at the end:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>-session   optional    pam_gnome_keyring.so    auto_start</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The leading hyphen (<code>-auth</code>, <code>-session</code>) marks it optional, so if the module fails to load, login doesn&rsquo;t break.</p>
<p>Pin <code>SSH_AUTH_SOCK</code> for graphical and TTY sessions via <code>~/.config/environment.d/ssh-agent.conf</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>SSH_AUTH_SOCK=%t/gcr/ssh</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>%t</code> resolves to <code>$XDG_RUNTIME_DIR</code>, something like <code>/run/user/1026</code>. Enable the gcr socket and user linger (so user daemons survive logout):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">systemctl --user <span class="nb">enable</span> --now gcr-ssh-agent.socket
</span></span><span class="line"><span class="cl">sudo loginctl enable-linger <span class="nv">$USER</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Finally, in <code>~/.config/bash/init.sh</code>, keychain starts or reuses the agent:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">if</span> <span class="nb">command</span> -v keychain <span class="p">&amp;</span>&gt;/dev/null <span class="o">&amp;&amp;</span> <span class="o">[[</span> -r ~/.ssh/id_ed25519 <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">eval</span> <span class="s2">&#34;</span><span class="k">$(</span>keychain --eval --quiet ~/.ssh/id_ed25519<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>--eval</code> emits shell assignments (<code>SSH_AUTH_SOCK</code>, <code>SSH_AGENT_PID</code>) pointing to keychain&rsquo;s agent, overriding the gcr path set by environment.d. <code>--quiet</code> silences the banner once the key is loaded.</p>
<p>Flow in practice: boot → first terminal → keychain prompts for the passphrase once → key stays loaded. Logout → login again → new shells reattach to the same agent (via <code>~/.keychain/&lt;host&gt;-sh</code>) → zero prompts. <code>ssh-add -l</code> confirms the key is there, <code>echo $SSH_AUTH_SOCK</code> confirms it&rsquo;s at keychain&rsquo;s path.</p>
<p>When you&rsquo;ll re-enter the passphrase: reboot, <code>ssh-add -D</code>, or <code>keychain --clear</code>. In normal use, once a day.</p>
<h3>Bash instead of ZSH<span class="hx:absolute hx:-mt-20" id="bash-instead-of-zsh"></span>
    <a href="#bash-instead-of-zsh" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>On the desktop I use ZSH. On the notebook I went with Bash to align with Omarchy&rsquo;s default, without having to maintain a parallel stack of modular layers. <code>~/.bashrc</code> is a symlink to <code>~/.config/bash/bashrc</code>, which sources Omarchy&rsquo;s defaults first and then stacks my customizations:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">source</span> ~/.local/share/omarchy/default/bash/rc
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/.config/bash/envs.sh
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/.config/bash/aliases.sh
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/.config/bash/mounts.sh
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/.config/bash/init.sh
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/.config/bash/secrets    <span class="c1"># gitignored, chmod 600</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>envs.sh</code> has what&rsquo;s mine: OpenRouter base URL, Ollama pointing to the LAN GPU box (192.168.0.14), AWS region, Hugo analytics, zoxide and SSH agent configs. <code>aliases.sh</code> has TLP shortcuts, an alias for <code>shell-gpt</code> via Docker, and functions to harden PATH when running <code>makepkg</code> or <code>yay</code> (prevents binary injection via a malicious user config).</p>
<p><code>init.sh</code> does the integration work. Atuin with a manual bind for Ctrl-R (so the up arrow keeps bash&rsquo;s default history-search, which I use more). Keychain loading <code>~/.ssh/id_ed25519</code> once per boot and reusing it across shells (no need to re-authenticate SSH every time). Blesh if installed (ZSH-style autosuggestions for Bash). And a function that has PROMPT_COMMAND set the window title to the current pwd and the running command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">__title_idle<span class="o">()</span> <span class="o">{</span> <span class="nb">printf</span> <span class="s1">&#39;\033]2;%s\007&#39;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">PWD</span><span class="p">/#</span><span class="nv">$HOME</span><span class="p">/~</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">__title_busy<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">cmd</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">BASH_COMMAND</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$cmd</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;__title_&#34;</span>* <span class="o">||</span> <span class="s2">&#34;</span><span class="nv">$cmd</span><span class="s2">&#34;</span> <span class="o">==</span> *<span class="s2">&#34;PROMPT_COMMAND&#34;</span>* <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="nb">printf</span> <span class="s1">&#39;\033]2;%s — %s\007&#39;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">PWD</span><span class="p">/#</span><span class="nv">$HOME</span><span class="p">/~</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$cmd</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> -n <span class="s2">&#34;</span><span class="si">${</span><span class="nv">PROMPT_COMMAND</span><span class="p">-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nv">PROMPT_COMMAND</span><span class="o">=</span><span class="s2">&#34;__title_idle; </span><span class="si">${</span><span class="nv">PROMPT_COMMAND</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  <span class="nv">PROMPT_COMMAND</span><span class="o">=</span><span class="s2">&#34;__title_idle&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> <span class="s1">&#39;__title_busy&#39;</span> DEBUG</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Idle, the title shows just the pwd. With a command running, <code>trap DEBUG</code> catches the <code>BASH_COMMAND</code> and updates the title. Waybar&rsquo;s <code>hyprland/window</code> picks that up and displays it.</p>
<p>For the modern Rust toolbelt, the list:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pacman -S --needed <span class="se">\
</span></span></span><span class="line"><span class="cl">  eza bat fd ripgrep sd git-delta dust procs bottom duf tokei hyperfine <span class="se">\
</span></span></span><span class="line"><span class="cl">  zoxide atuin tldr starship</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>eza</code> replaces <code>ls</code>. <code>bat</code> replaces <code>cat</code> with syntax highlighting. <code>fd</code> replaces <code>find</code>. <code>ripgrep</code> replaces <code>grep</code>. <code>sd</code> replaces <code>sed</code>. <code>delta</code> plugs into git for colored side-by-side diff. <code>dust</code> for visual <code>du</code>. <code>procs</code> for <code>ps</code>. <code>btm</code> (bottom) for <code>top</code>. <code>duf</code> for <code>df</code>. <code>tokei</code> counts lines of code. <code>hyperfine</code> for command benchmarking. <code>zoxide</code> is <code>cd</code> with memory (very useful). <code>atuin</code> is shell history with encrypted sync (I point it to my home server via <code>sync_address = &quot;http://192.168.0.90:8888&quot;</code>). <code>starship</code> is the prompt. <code>tldr</code> is a man page in 10 lines.</p>
<p>The <code>atuin key</code> has to be backed up offline. If you lose it, you lose the encrypted history. I saved mine in my self-hosted Bitwarden (Vaultwarden), documented in the <a href="/en/2025/09/10/omarchy-2-0-bitwarden-self-hosted-vaultwarden/">Bitwarden self-hosted article</a>.</p>
<p>Git pipes diff through delta:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>[core]        pager = delta
[interactive] diffFilter = delta --color-only
[delta]       navigate side-by-side line-numbers hyperlinks, light=false
[merge]       conflictstyle = zdiff3</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Hyprland and Waybar<span class="hx:absolute hx:-mt-20" id="hyprland-and-waybar"></span>
    <a href="#hyprland-and-waybar" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Visual and UX customizations on top of the default.</p>
<p>In <code>hypr/looknfeel.conf</code>, smaller gaps (2/5 vs the default 5/10), slide animation between workspaces, VFR on (reduces consumption when the screen is static), <code>allow_session_lock_restore</code> (if hyprlock crashes, it goes back to the lock screen instead of dumping you on the desktop).</p>
<p>In <code>hypr/bindings.conf</code>:</p>
<table>
  <thead>
      <tr>
          <th>Binding</th>
          <th>Action</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Super+B</code></td>
          <td>Brave</td>
      </tr>
      <tr>
          <td><code>Super+L</code></td>
          <td>Lock screen (default was layout toggle)</td>
      </tr>
      <tr>
          <td><code>Super+Ctrl+L</code></td>
          <td>Layout toggle (moved here)</td>
      </tr>
      <tr>
          <td><code>Super+Ctrl+P</code></td>
          <td>Toggle TLP perf mode</td>
      </tr>
  </tbody>
</table>
<p>On Waybar, three custom modules I recommend:</p>
<p><strong>Window title</strong>: the <code>hyprland/window</code> module shows what&rsquo;s focused. In bash, PROMPT_COMMAND updates the title to something like &ldquo;~/Projects/blog — hugo server&rdquo;. So in waybar the directory and running command show up, without me needing to look at the terminal.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/hypr---window-title-on-waybar.png" alt="Waybar showing workspaces on the left and the focused window title with the pwd and the running command"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/hypr---another-window-title-on-waybar.png" alt="Another example of the window title on waybar, this time showing “Claude Code”"  loading="lazy" /></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jsonc" data-lang="jsonc"><span class="line"><span class="cl"><span class="s2">&#34;hyprland/window&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;format&#34;</span><span class="p">:</span> <span class="s2">&#34;{title}&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;max-length&#34;</span><span class="p">:</span> <span class="mi">50</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;rewrite&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;(.{47}).+&#34;</span><span class="p">:</span> <span class="s2">&#34;$1…&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Perf mode</strong>: <code>custom/perf</code> runs a script that reads TLP state and shows an icon:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jsonc" data-lang="jsonc"><span class="line"><span class="cl"><span class="s2">&#34;custom/perf&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;exec&#34;</span><span class="p">:</span> <span class="s2">&#34;~/.config/scripts/perf-waybar.sh&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;interval&#34;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;format&#34;</span><span class="p">:</span> <span class="s2">&#34;{}&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;on-click&#34;</span><span class="p">:</span> <span class="s2">&#34;~/.config/scripts/perf-toggle.sh&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Empty for balanced, 󰓅 PERF for performance, 󰌪 ECO for low-power. Click toggles. Useful to quickly see what mode the machine is in.</p>
<p><strong>Claudebar</strong>: I integrated <a href="https://github.com/alfredopiquet/claudebar"target="_blank" rel="noopener">claudebar</a> as a custom module that shows the Claude Code session usage % and extra spend inline in waybar. No need to open another window.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/hypr---power-profile-and-claudebar-next-to-traybar.png" alt="Claudebar showing 9%, 1h16m remaining and $1120.74 extra spend, followed by the PERF indicator and the tray"  loading="lazy" /></p>
<p>The clock:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jsonc" data-lang="jsonc"><span class="line"><span class="cl"><span class="s2">&#34;clock&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;format&#34;</span><span class="p">:</span> <span class="s2">&#34;{:L%A %d %B - %H:%M}&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;format-alt&#34;</span><span class="p">:</span> <span class="s2">&#34;{:L%A W%V %Y - %H:%M}&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Shows &ldquo;Thursday 17 April - 14:32&rdquo; by default, click toggles to &ldquo;Thursday W16 2026 - 14:32&rdquo; with the ISO week number, useful for planning articles and tasks.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/18/thinkpad/hypr---better-date-time-on-waybar-center.png" alt="Waybar clock in the center with date and time"  loading="lazy" /></p>
<h3>Self-hosted Atuin<span class="hx:absolute hx:-mt-20" id="self-hosted-atuin"></span>
    <a href="#self-hosted-atuin" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Atuin runs on my home server at 192.168.0.90:8888. I made a separate account for the notebook (<code>akitaonrails-thinkpad</code>), I don&rsquo;t mix history with the desktop on purpose. <code>atuin sync</code> runs every 5 minutes and all history is encrypted before going to the server. The server doesn&rsquo;t see the commands, only encrypted bytes.</p>
<h2>Conclusion: picking a notebook for Linux<span class="hx:absolute hx:-mt-20" id="conclusion-picking-a-notebook-for-linux"></span>
    <a href="#conclusion-picking-a-notebook-for-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One thing I learned on this journey: picking a notebook to run Linux comfortably is not trivial. You have to check the <a href="https://wiki.archlinux.org/title/Laptop"target="_blank" rel="noopener">ArchWiki</a> for your specific model before buying. The <a href="https://wiki.archlinux.org/title/Lenovo_ThinkPad_T14s_%28AMD%29_Gen_6"target="_blank" rel="noopener">T14s Gen 6 AMD page</a>, for example, catalogs every hardware quirk and how to work around it. Without that reference, you discover the problems during install.</p>
<p>Rule I follow: never buy a freshly-released model. Let 6 to 12 months go by after launch. That gives the community time to iron out bugs, drivers to land mainline, the ArchWiki to have a decent page, libfprint to include the fingerprint sensor, and the kernel to cover WiFi and the IR camera. Buying on day one means signing up as a beta tester.</p>
<p>Brands with a decent Linux track record: Lenovo (especially Thinkpad, they ship Ubuntu and Fedora pre-loaded), Dell (the XPS and Latitude lines), Asus (Zenbook and ROG have reasonable support), Framework (built for Linux from the factory). Any recent Mac is cut off from Linux mainline or runs through Asahi with caveats.</p>
<p>The Thinkpad T14 Gen 6 isn&rsquo;t the prettiest notebook I could have bought. But it&rsquo;s rugged, it has the right ports, the fingerprint sensor works, and the plastic shell lets it fall, get scratched, travel in a backpack without making me nervous. To serve as a remote debug companion, it&rsquo;s what I needed. If that&rsquo;s your use case too, I recommend it. If you want OLED and metal, go Zenbook or T14s. Every premium comes with a compromise, no Linux notebook in 2026 is perfect.</p>
<p>Everything I described here is versioned in a private repo of mine. If I need to reinstall tomorrow, I run half a dozen steps in order and I&rsquo;m back at the same place. That&rsquo;s the whole point of keeping config in Git: notebook is commodity, config is mine.</p>
]]></content:encoded><category>omarchy</category><category>thinkpad</category><category>archlinux</category><category>hyprland</category><category>linux</category><category>homeserver</category></item><item><title>Why LLMs Aren't Giving You the Result You Expect | Why I Prefer Claude Code Today</title><link>https://akitaonrails.github.io/en/2026/04/15/how-to-talk-to-claude-code-effectively/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/15/how-to-talk-to-claude-code-effectively/</guid><pubDate>Wed, 15 Apr 2026 13:00:00 GMT</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/talk-to-claude/hero-gpt-vs-claude.png" alt="Editorial illustration contrasting GPT-coding and Claude-coding: one side showing rapid generation and broad repertoire, the other a structured architecture with a concept tree and context-aware debugging" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;Every time I get pulled into an online thread about LLMs I hear the same chorus, in slightly different keys. &amp;ldquo;Claude didn&amp;rsquo;t perform as well as GPT for me.&amp;rdquo; &amp;ldquo;GPT did a way better job than Claude, I&amp;rsquo;m canceling my sub.&amp;rdquo; &amp;ldquo;Honestly, Kimi or MiniMax does the job for me just fine, I&amp;rsquo;m not paying for anything.&amp;rdquo; Anecdote piled on anecdote, each one a variation of &amp;ldquo;works on my machine&amp;rdquo; vs. &amp;ldquo;doesn&amp;rsquo;t work on my machine.&amp;rdquo; It sounds off to me.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/talk-to-claude/hero-gpt-vs-claude.png" alt="Editorial illustration contrasting GPT-coding and Claude-coding: one side showing rapid generation and broad repertoire, the other a structured architecture with a concept tree and context-aware debugging"  loading="lazy" /></p>
<p>Every time I get pulled into an online thread about LLMs I hear the same chorus, in slightly different keys. &ldquo;Claude didn&rsquo;t perform as well as GPT for me.&rdquo; &ldquo;GPT did a way better job than Claude, I&rsquo;m canceling my sub.&rdquo; &ldquo;Honestly, Kimi or MiniMax does the job for me just fine, I&rsquo;m not paying for anything.&rdquo; Anecdote piled on anecdote, each one a variation of &ldquo;works on my machine&rdquo; vs. &ldquo;doesn&rsquo;t work on my machine.&rdquo; It sounds off to me.</p>
<p>I&rsquo;ve already benchmarked most of the relevant open source and commercial models in my <a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">LLM testing post</a>, so I&rsquo;m not pulling this out of thin air. And beyond the benchmarks, I&rsquo;ve got 500+ hours in Claude Code and Codex on real projects. 16 hours a day, two and a half months straight, something in the neighborhood of 400,000 effective lines of code generated.</p>
<p>And look: on neither of them, Claude or Codex, did I ever see the model wander off, do something I didn&rsquo;t ask for, or flat-out fail to deliver what I wanted. Not once. When the model really couldn&rsquo;t do something, it told me so upfront instead of inventing. So when someone tells me &ldquo;Claude blew it,&rdquo; my first question is always the same: what did you ask it for, exactly?</p>
<h2>The fake problem<span class="hx:absolute hx:-mt-20" id="the-fake-problem"></span>
    <a href="#the-fake-problem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The ecosystem&rsquo;s answer to this supposed &ldquo;wandering off&rdquo; problem is to pile on more layers. Spec Driven Development showed up, 15-section prompt templates showed up, whole frameworks to force the LLM to ask more questions before it starts. I respect the effort, but I think they&rsquo;re treating the symptom, not the cause.</p>
<p>I practice what I call <a href="/en/2026/03/05/37-days-of-vibe-coding-immersion-conclusions-on-business-models/">Agile Vibe Coding</a>: applying XP techniques (pair programming, test-driven, short feedback loops, continuous refactor) on top of normal prompting. I don&rsquo;t need a framework. I don&rsquo;t need a three-page template. I need the same things that have always been needed to work on software in a team: know what I want, know what I don&rsquo;t want, know how to validate it when it lands.</p>
<h2>The real problem is one thing: nobody knows how to communicate<span class="hx:absolute hx:-mt-20" id="the-real-problem-is-one-thing-nobody-knows-how-to-communicate"></span>
    <a href="#the-real-problem-is-one-thing-nobody-knows-how-to-communicate" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve got an old post from 2013 called <a href="/en/2013/11/02/off-topic-programadores-sao-pessimos-comunicadores-udp-vs-tcp/">Programmers are terrible communicators (UDP vs TCP)</a>. Go read it if you haven&rsquo;t, because the problem I described there in 2013 is exactly the thing blowing up now that everyone&rsquo;s flying an LLM. Nothing changed. The tech got more powerful, but the people are the same people.</p>
<p>Here&rsquo;s how it works. You&rsquo;ve got a pile of information in your head. Project context, history, stack constraints, personal preference, things that went sideways in the past, decisions that got made in a meeting two months back. And then you walk into a conversation, whether it&rsquo;s with a human coworker or an LLM, and fire off your request assuming everything in your head is also in the head of whoever is on the other side. &ldquo;It&rsquo;s obvious, everybody knows that.&rdquo; So you write &ldquo;do what I&rsquo;m telling you,&rdquo; except what you&rsquo;re telling them is really &ldquo;do what I&rsquo;m thinking.&rdquo; And you don&rsquo;t even notice they&rsquo;re not the same thing.</p>
<p>Developers are terrible communicators. Managers are terrible communicators too, and that&rsquo;s exactly why most of the useful hours in a corporate week get burned in pointless meetings. Nobody gets to the point on time, nobody aligns expectations, the result comes in below the bar, and the default managerial answer to that is &ldquo;more of the same.&rdquo; More meetings, more spreadsheets, more reports. Except if your communication was bad at volume 1, it&rsquo;s going to stay bad at volume 5. The problem is quality. Volume doesn&rsquo;t fix bad quality.</p>
<h2>How I actually talk to Claude or Codex<span class="hx:absolute hx:-mt-20" id="how-i-actually-talk-to-claude-or-codex"></span>
    <a href="#how-i-actually-talk-to-claude-or-codex" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I treat any LLM exactly how I&rsquo;d treat a human during a pair programming session. No ceremony, no form to fill out, no 10-page spec. But with communication discipline. Let me walk you through a real example, from last week.</p>
<p>I&rsquo;ve got about 12 TB of ROMs piled up on my NAS, under <code>/mnt/terachad/Emulators</code>, split across two trees (<code>ROMS/</code> and <code>ROMS2/</code>) that accumulated across different collections over the last 10-plus years. More than 400,000 files total. Uncompressed romsets, <code>.7z</code>, <code>.rar</code>, giant CDI/GDI bundles, inconsistent file naming, duplicates everywhere. I wanted to consolidate all of it, by platform, into a new <code>ROMS_FINAL/</code> tree, using standardized naming (No-Intro / Redump / TOSEC) so that when I run Screenscraper later the match is automatic. That&rsquo;s the <strong>goal</strong>, stated up front.</p>
<p>But I don&rsquo;t stop there. I tell it what I <strong>DON&rsquo;T</strong> want, too. &ldquo;Never dedup by filename, only by sha1 + size, filenames lie way too much in this world.&rdquo; &ldquo;A Neo Geo romset depends on which emulator is going to consume it, so the MAME zip, the FBNeo bundle, and the Darksoft MVS cart are three mutually incompatible things, keep one canonical copy of each.&rdquo; &ldquo;Same idea for NAOMI: the MAME romset is not the same file as the GDI.&rdquo; &ldquo;Saturn has USA, Japan, and Europe versions, I want to keep a copy of each region, region is not duplicate.&rdquo; If I leave that out, the model has no way to know, because that knowledge is in my head and not in the code. If I don&rsquo;t give it, it&rsquo;ll assume its own most-reasonable default, which might be the opposite of what I need.</p>
<p>Then I get into <strong>method detail</strong>. &ldquo;Create a <code>docs/</code> directory to serve as living knowledge base, and <code>docs/scripts/</code> with the steps broken into numbered files (<code>01_walk_and_hash.py</code>, <code>02_classify.py</code>, etc.). Each step has to be idempotent so I can re-run it if it crashes or if I need to resume halfway through. Progress state lives in a SQLite catalog, not in-memory variables.&rdquo; This isn&rsquo;t micromanaging the model, this is aligning the way I work. I know an operation this size is going to hit problems, and I want to be able to come back without losing the previous hours of hashing and classification.</p>
<p>Then, even after it hands me its plan, I keep thinking about what could go wrong. &ldquo;Zero deletions under <code>ROMS/</code> and <code>ROMS2/</code>. Whatever doesn&rsquo;t get forwarded to <code>ROMS_FINAL/</code> just stays where it was. The only thing the whole pipeline is allowed to delete is temporary files from an extraction that bailed halfway through. Also, the phase that actually executes the moves can only run after I manually approve the planning phase, create a flag file <code>docs/.phase4-approved</code>, and make phase 5 refuse to start without it.&rdquo; This is the equivalent of making a commit before a big refactor, plus a human gate between planning and applying. I&rsquo;m protecting against my own mistakes, its mistakes, or both at once.</p>
<p>When the model gets going, <strong>I don&rsquo;t leave the room</strong>. I keep asking for status. I notice the hash ETA is way longer than the problem warrants, so I interrupt: &ldquo;I think we can parallelize, I&rsquo;ve got spare CPU and 10GbE talking to the NAS, and the Synology over NFS can handle it. Bump up the concurrency, test, check that it&rsquo;s still stable and that the SQLite transaction ordering doesn&rsquo;t break.&rdquo; That&rsquo;s real pair programming, not blind automation.</p>
<h2>The structure behind it<span class="hx:absolute hx:-mt-20" id="the-structure-behind-it"></span>
    <a href="#the-structure-behind-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Notice the pattern. I don&rsquo;t say &ldquo;solve this problem.&rdquo; I say &ldquo;solve this problem, <strong>this way and that way</strong>, and <strong>not this way or that way</strong>, and when you&rsquo;re done, <strong>validate X and Y</strong>.&rdquo; In other words, I&rsquo;m communicating four things, not one:</p>
<p>First, <strong>what I want</strong>. The end goal in plain language. Second, <strong>how I want it done</strong>, in broad strokes, leaving room for it to suggest a better solution if it has one, because it usually does. Third, <strong>what I don&rsquo;t want</strong>. This is the part most people skip, and it&rsquo;s the most critical, because it&rsquo;s where all the unspoken assumptions live, the ones that turn into bugs later. Fourth, <strong>how we validate it landed</strong>. What&rsquo;s the expected result, what&rsquo;s the test, what&rsquo;s the &ldquo;done&rdquo; signal.</p>
<p>And that fourth part is a killer. Most of the clients I&rsquo;ve worked with over the past 20 years couldn&rsquo;t tell me what the expected result was. Because it&rsquo;s easy to want something, and hard to say how you&rsquo;d measure whether it showed up. Without a success metric, expectations break by definition, because there was no concrete expectation to begin with. That&rsquo;s one of the main reasons consulting projects go sideways, and it&rsquo;s identical in the world of AI agents.</p>
<p>When I hand the model all four blocks, it almost never fails. And when it actually can&rsquo;t do the thing, because the task is impossible given my constraints or because there&rsquo;s information I forgot to pass along, it doesn&rsquo;t try to guess. It tells me: &ldquo;given your constraints, I can&rsquo;t proceed because of X or Y.&rdquo; Then I adjust, or I loosen a constraint, or I realize I didn&rsquo;t actually know what I wanted. It all works.</p>
<h2>Once the context is solid, ask instead of prescribe<span class="hx:absolute hx:-mt-20" id="once-the-context-is-solid-ask-instead-of-prescribe"></span>
    <a href="#once-the-context-is-solid-ask-instead-of-prescribe" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a shift I make after the first few well-loaded prompts. In the opening turns I pour in a ton: goal, constraints, method, validation, all the context the model needs to understand the terrain. It&rsquo;s a lot of detail, it&rsquo;s my job to front-load it, and I already explained why there&rsquo;s no way around that.</p>
<p>But once that ground is solid, I stop prescribing solutions and start asking for suggestions. Instead of saying &ldquo;implement it this way, with that library, following this pattern,&rdquo; I switch to &ldquo;given everything we&rsquo;ve already covered, what&rsquo;s the best approach here? Research online if you need to, compare the options, and come back with the solution you&rsquo;d pick for this goal.&rdquo;</p>
<p>That&rsquo;s the step most people skip, and it&rsquo;s exactly where the LLM earns useful autonomy. It&rsquo;s got way more vocabulary than I do across a pile of topics. It read the whole internet; I read whatever I had time for. If I prescribe line by line, I&rsquo;m throwing that edge away. If I set the context and then ask, it comes back with options I hadn&rsquo;t considered, with trade-offs laid out, sometimes better than my original idea, and I get to pick.</p>
<p>The trick is simple: <strong>asking well requires having set the context well first</strong>. A dry question with no ground gets back a generic answer, or the first idea that seemed to fit. A question resting on ground you already laid comes back with a real proposal, alternatives compared, references cited. The model is ready for that kind of contribution, it just won&rsquo;t give it to you until you&rsquo;ve spent the time setting the stage.</p>
<p>And look: this method wasn&rsquo;t invented for LLMs. It&rsquo;s the same way I&rsquo;d work with any human developer, senior or not. Give them the context, give them the goal, explain what I care about, then ask &ldquo;what&rsquo;s the best way to do this?&rdquo; instead of dictating the solution. The only difference is the LLM gives it back in seconds when a human would take days. The way you run the conversation is the same.</p>
<h2>It&rsquo;s not a 10-page spec<span class="hx:absolute hx:-mt-20" id="its-not-a-10-page-spec"></span>
    <a href="#its-not-a-10-page-spec" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And here&rsquo;s the important bit: what I&rsquo;m describing is not a formalism. It&rsquo;s not a long spec, it&rsquo;s not a Confluence doc, it&rsquo;s not a template. It&rsquo;s just how I talk to anyone who has to deliver something to me. I picked it up running projects, managing contractors, integrating teams that weren&rsquo;t mine, and getting knocked around every time my communication was bad. After a while it becomes second nature.</p>
<p>If I don&rsquo;t really care about the final result, say a quick experiment, a weekend toy, something disposable, I cut way back. I know my expectations might break, and that&rsquo;s fine, the cost of a bug here is cheap. But if the result actually matters, I invest the time needed to give the other side (human or AI) the best shot at delivering what I want. Output is proportional to the time you spend on input. If you&rsquo;re not willing to explain what you want properly, don&rsquo;t complain when what comes back is wrong.</p>
<p>To illustrate: this very post you&rsquo;re reading was written by Claude, off a prompt I gave it. Below is the screenshot of what I typed. Notice: no hidden assumptions, clear goal, references to earlier posts baked in, constraints stated explicitly, enough detail that it didn&rsquo;t have to guess.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/talk-to-claude/prompt-example.png" alt="Screenshot of the prompt I used to ask Claude Code to write this article: goal, context, references to previous posts, constraints, and expected outcome, all stated directly"  loading="lazy" /></p>
<p>But look, that&rsquo;s just the first prompt. It wasn&rsquo;t the only one. While Claude was writing, I kept tracking, sending corrections, adding points I forgot to put in the first prompt, flagging factual errors I caught in the generated text, and fine-tuning tone. &ldquo;Hey, I also forgot to tell you we need to cover X.&rdquo; &ldquo;No, that reference is out of date, OpenAI&rsquo;s Sora got shut down, fix that.&rdquo; &ldquo;Go read what we already documented over at <code>/mnt/terachad/Emulators/docs/</code> and see if you can sharpen up the ROMs example.&rdquo; This very observation you&rsquo;re reading right now started as a mid-flight prompt:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/talk-to-claude/prompt-followup.png" alt="Screenshot of the follow-up prompt asking Claude to read the real ROM-organization docs and also explain that the blog prompt isn’t a single shot but iterative"  loading="lazy" /></p>
<p>And the conversation kept going all the way to the commit. I had already told it to humanize, translate to English, and push, and I still interrupted at the last second because I spotted an unnecessary English loanword I wanted to fix before it went up. Here&rsquo;s that moment:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/talk-to-claude/prompt-anglicism.png" alt="Screenshot of the moment when I’d already authorized the translation and commit, but interrupted right before to ask for the anglicism “figurar as coisas do nada” to be rewritten in more natural Portuguese"  loading="lazy" /></p>
<p>That&rsquo;s real pair programming. Nobody sits down with a colleague, drops a 15-line task, stands up, and walks away expecting magic. You stay there, you watch the execution, you see the code come together, you suggest adjustments, you catch errors while they&rsquo;re still cheap to fix, you add context you just remembered. The initial prompt is the starting point, not the final contract. Agile Vibe Coding in action: short cycles, fast feedback, continuous correction.</p>
<p>This isn&rsquo;t a formal spec. It&rsquo;s a conversation that keeps going during the work, not before it. And that&rsquo;s how it&rsquo;s always going to be, with me and with any good professional.</p>
<h2>&ldquo;Akita, you write too much detail, shouldn&rsquo;t the AI figure this out on its own?&rdquo;<span class="hx:absolute hx:-mt-20" id="akita-you-write-too-much-detail-shouldnt-the-ai-figure-this-out-on-its-own"></span>
    <a href="#akita-you-write-too-much-detail-shouldnt-the-ai-figure-this-out-on-its-own" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>That&rsquo;s the question I know is coming, so let me answer it upfront. No, the AI is not going to figure it out on its own. There is no future version of Claude, GPT, Gemini, whatever, that&rsquo;s going to guess what&rsquo;s in your head. Context doesn&rsquo;t generate itself by osmosis. If the information isn&rsquo;t in the code, in the docs, or in my prompt, it simply doesn&rsquo;t exist for the model. Full stop.</p>
<p>Neo Geo romsets being different per emulator? That&rsquo;s domain knowledge. Saturn having separate regions? Domain knowledge. My NAS having 10GbE to handle aggressive parallelism? Environment context. Having <code>docs/</code> with a SQLite catalog to survive crashes? An engineering decision I made. The model has no way to &ldquo;discover&rdquo; any of this. All of it is my job to bring to the conversation. And if I&rsquo;m not willing to spend my own time understanding these details, why would anyone, or anything, do it for me for free?</p>
<p>The rule is simple: <strong>the quality of what you get back is directly proportional to the effort you put into asking</strong>. It has always been this way. Anybody who&rsquo;s hired a contractor knows it: vague requests, ill-defined scope, &ldquo;the client knows what they want, they just can&rsquo;t explain it,&rdquo; that&rsquo;s a guaranteed recipe for a project to go off the rails. Always was. The LLM is the same thing, just faster and more patient. Think of it as modern outsourcing, not magic. A magician solves the problem without you saying anything. A contractor solves exactly what you asked for, exactly the way you asked, with the information you provided. If you didn&rsquo;t ask properly, you don&rsquo;t get it properly.</p>
<p>The frustration of people showing up here burned out on Claude or Codex is almost always the same: they asked for little, expected a lot. And when the result came back short, they blamed the tool. Never the question.</p>
<h2>That&rsquo;s why AI won&rsquo;t replace the good ones<span class="hx:absolute hx:-mt-20" id="thats-why-ai-wont-replace-the-good-ones"></span>
    <a href="#thats-why-ai-wont-replace-the-good-ones" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>That&rsquo;s exactly why I say, with full conviction, that AI agents aren&rsquo;t going to replace good professionals. They&rsquo;re going to replace people who can&rsquo;t frame their own question properly, who don&rsquo;t know what they want, who don&rsquo;t know how to validate a result, who need somebody to think for them. And look, those people were always replaceable, it&rsquo;s just that now the replacement is cheaper. The market is doing the math.</p>
<p>The good professional, meanwhile, got more productive. Uses the LLM as a pair programming partner at 2 a.m., no complaints, no union, no ego clash. Ships in a week what used to take a month. And is still the same good professional they were, because the skill that mattered, knowing what to ask for, what not to accept, and how to measure, is still 100% theirs. The tool just executes.</p>
<h2>Stark + Jarvis = Iron Man<span class="hx:absolute hx:-mt-20" id="stark--jarvis--iron-man"></span>
    <a href="#stark--jarvis--iron-man" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me drop a metaphor that I think wraps the whole thing up. Think about Tony Stark, in the MCU. He&rsquo;s got Jarvis, probably the most advanced fictional AI pop culture has produced in the last two decades. Voice control, context awareness, planning, parallel execution, even a sharp personality. Technologically, Jarvis would easily be the best AI agent you could imagine today.</p>
<p>And even so, Jarvis on his own doesn&rsquo;t build an Iron Man suit. Doesn&rsquo;t build the Arc reactor. Doesn&rsquo;t build any of the gear that makes Stark be Stark. All that advanced tech, with no Stark to guide it, just sits there. It&rsquo;s Stark&rsquo;s genius, his vision, his stubbornness, his engineer&rsquo;s intuition about where to push next, that makes any of it real. Jarvis executes. Stark thinks.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/talk-to-claude/stark-iterating.jpg" alt="Tony Stark iterating on a 3D model of the Mark III at one of his workbenches, surrounded by monitors with design variants: this is literally how real engineering work happens, AI or no AI"  loading="lazy" /></p>
<p>There&rsquo;s another detail in that story almost nobody remembers: not even Stark, with all his genius and with Jarvis riding shotgun, nails the perfect suit on the first try. He spends movie after movie iterating. The Mark I was literally bolted together in a cave out of scrap. The Mark II could fly, but iced over at altitude. The Mark III fixed the ice but weighed too much. And on it goes, each iteration fixing a specific flaw in the last, all the way to the Mark LXXXV in Endgame, his 85th suit. <strong>Eighty-five</strong>. The whole MCU Iron Man arc is basically a ten-year campaign of iterative development on one extremely complicated product.</p>
<p>That&rsquo;s exactly the mental model I think we should be using when we work with AI today. You don&rsquo;t fire off a prompt and wait for the definitive solution to pop out. You, sitting in the Stark chair, use the AI (your own Jarvis) as an accelerator on each lap of the cycle: propose, test, see what broke, fix it, propose again. The AI speeds up every lap, it doesn&rsquo;t erase the laps. If someone swapped iteration for &ldquo;one magic final answer,&rdquo; they never understood that engineering was never like that, human or no human.</p>
<p>Put Stark and Jarvis together and now you&rsquo;ve got Iron Man. Take either one away and the half that actually matters is missing.</p>
<h2>Claude Code vs. Codex: my pick today (April 2026)<span class="hx:absolute hx:-mt-20" id="claude-code-vs-codex-my-pick-today-april-2026"></span>
    <a href="#claude-code-vs-codex-my-pick-today-april-2026" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A quick aside so this doesn&rsquo;t go unsaid: these days I&rsquo;m alternating between Claude Code and Codex, but I&rsquo;ve been leaning into Claude Code. Let me spell out why, because it&rsquo;s not about the LLM itself.</p>
<p>Claude Opus and GPT-5.4 xHigh, to me, are basically tied as models. On the hard tasks, when one of them can&rsquo;t pull it off, I swap over to the other and the other usually does. Head to head, both are strong. What separates them today is the harness, not the model.</p>
<p>And the Claude Code harness, right now, is flat-out better. Two concrete reasons: planning and parallel execution.</p>
<p><strong>Planning.</strong> Claude Code breaks a long task into subtasks, keeps a visible to-do list I can actually watch on screen, tries to run things in parallel when it can, and doesn&rsquo;t drop items. When it tells me &ldquo;done,&rdquo; I know the full list got executed, because it&rsquo;s right there for me to check. That detail changes the whole game: I trust the &ldquo;done&rdquo; without having to chase every single item with &ldquo;hey, did you actually do that other one?&rdquo;.</p>
<p><strong>Parallel execution.</strong> This one is clean-cut. If Claude Code is mid-task and I hit <code>ESC</code> to throw in something else, it typically <strong>keeps the first task running and kicks off the second in parallel</strong>, unless the new request actually requires cancelling the first. Codex, same situation, stops the first to deal with the second, and doesn&rsquo;t always know how to resume the first from where it left off without me prodding it manually. With Claude Code I get to actually flow, opening new fronts while the old ones keep running. With Codex I have to go serial, be patient, and be more deliberate about every request, because interrupting is expensive.</p>
<p>None of which means Codex is bad. It&rsquo;s good. Plenty of times when Claude Code gets jammed on a hairy task I pop over to Codex and it unjams it immediately. But then my way of working shifts: smaller requests, more focused, one at a time, wait for it, move on. Works fine, it&rsquo;s just not the flow I prefer.</p>
<p>Codex is probably going to catch up on the harness side over the next few months, and the conversation will shift. But today, April 15th, 2026, if I have to pick one as my daily driver, it&rsquo;s Claude Code, and the reason is the harness, not because OpenAI&rsquo;s LLM is somehow inferior. Writing this down so that six months from now I can reread it and laugh.</p>
<h2>Which is why my company is called Codeminer 42<span class="hx:absolute hx:-mt-20" id="which-is-why-my-company-is-called-codeminer-42"></span>
    <a href="#which-is-why-my-company-is-called-codeminer-42" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To close with a reference I&rsquo;ve been carrying around for years: my company is called <a href="https://www.codeminer42.com/"target="_blank" rel="noopener">Codeminer 42</a>. The 42 isn&rsquo;t a random number. It&rsquo;s a direct nod to Douglas Adams, from The Hitchhiker&rsquo;s Guide to the Galaxy.</p>
<p>If you don&rsquo;t know the bit, here&rsquo;s how it goes. An entire civilization builds a planet-sized supercomputer to calculate the Answer to Life, the Universe, and Everything. After millions of years of processing, the machine finally spits out the final answer: <strong>42</strong>. And then there&rsquo;s that awkward silence, because nobody there could remember what the original question had been. 42 is a technically correct answer to a question no one bothered to formulate properly. Which is to say, it means absolutely nothing. Plenty of people seem to think 42 has some deep meaning baked into it. It doesn&rsquo;t. It&rsquo;s the wrong answer to the wrong question.</p>
<p>That&rsquo;s the sharpest lesson about engineering I&rsquo;ve ever read in fiction. Every expectation that breaks, breaks because the question was wrong, not because the answer was badly executed. That&rsquo;s what Codeminer 42 exists for, and it&rsquo;s exactly what I practice day to day, whether the other side is a human client or an LLM. Before I deliver what you asked for, my job is to make you discover that what you think you want is probably wrong, to force you to rethink the assumptions you walked in with, and only once the question is sharpened do I get to hand you the best possible result. Skip that step and anything I deliver is going to be a 42.</p>
<p>So next time you read somebody saying &ldquo;I canceled Claude because it doesn&rsquo;t deliver,&rdquo; or &ldquo;GPT is way better,&rdquo; or the other way around, take a closer look at the question the person was asking. Nine times out of ten, the problem isn&rsquo;t the model. It&rsquo;s the question. And nine times out of ten, the answer that person got back was 42.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/talk-to-claude/deep-thought-42.webp" alt="Deep Thought from The Hitchhiker’s Guide to the Galaxy revealing the Answer to Life, the Universe, and Everything to a crowd that waited millions of years: 42"  loading="lazy" /></p>
]]></content:encoded><category>ai</category><category>claude-code</category><category>vibe-coding</category><category>agile</category><category>xp</category><category>communication</category></item><item><title>Seedance 2.0 Is Finally Out: First Impressions</title><link>https://akitaonrails.github.io/en/2026/04/15/seedance-2-0-public-launch-first-impressions/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/15/seedance-2-0-public-launch-first-impressions/</guid><pubDate>Wed, 15 Apr 2026 10:00:00 GMT</pubDate><description>&lt;p&gt;ByteDance opened up the public release of &lt;a href="https://seed.bytedance.com/en/blog/official-launch-of-seedance-2-0"target="_blank" rel="noopener"&gt;Seedance 2.0&lt;/a&gt; today, April 15. They had promised the model late last year, but access stayed locked to commercial partners for months, with delay after delay. As of today, anyone can try it.&lt;/p&gt;
&lt;p&gt;The pitch is strong. It&amp;rsquo;s a multimodal video generation model that takes text, up to 9 reference images, 3 reference video clips, and 3 audio tracks all in the same call. On top of that, it produces up to 15 seconds of output with native synced audio (dialogue, sound effects, ambient music), supports video extension, surgical editing of characters and objects, and prompt-driven camera planning. ByteDance is pitching it at commercial advertising, explainer videos, film production, e-commerce, and gaming. Translation: for content creators trying to climb out of the &amp;ldquo;random meme&amp;rdquo; tier and start producing things that look professional.&lt;/p&gt;</description><content:encoded><![CDATA[<p>ByteDance opened up the public release of <a href="https://seed.bytedance.com/en/blog/official-launch-of-seedance-2-0"target="_blank" rel="noopener">Seedance 2.0</a> today, April 15. They had promised the model late last year, but access stayed locked to commercial partners for months, with delay after delay. As of today, anyone can try it.</p>
<p>The pitch is strong. It&rsquo;s a multimodal video generation model that takes text, up to 9 reference images, 3 reference video clips, and 3 audio tracks all in the same call. On top of that, it produces up to 15 seconds of output with native synced audio (dialogue, sound effects, ambient music), supports video extension, surgical editing of characters and objects, and prompt-driven camera planning. ByteDance is pitching it at commercial advertising, explainer videos, film production, e-commerce, and gaming. Translation: for content creators trying to climb out of the &ldquo;random meme&rdquo; tier and start producing things that look professional.</p>
<p>If you want a quick visual tour before going further, here&rsquo;s a great walkthrough from Stefan, of the <a href="https://www.youtube.com/channel/UCRW08KcTVjXEmBzBsVl7XjA"target="_blank" rel="noopener">Stefan 3D AI Lab</a> channel, which I already plugged in the <a href="/en/2026/01/23/ai-3d-can-you-actually-model-3d-with-prompts-now/">post on AI 3D modeling</a>:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/fv9vA6RCNHU"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>The main interface is Dreamina&rsquo;s &ldquo;Photo Studio,&rdquo; where you pick the generation mode and stack your references. So far I&rsquo;ve only tested the standard multi-reference mode.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/studio.png" alt="The Seedance 2.0 Photo Studio interface, with a reference upload panel and prompt configuration"  loading="lazy" /></p>
<h2>Test 1: lip sync with podcast audio<span class="hx:absolute hx:-mt-20" id="test-1-lip-sync-with-podcast-audio"></span>
    <a href="#test-1-lip-sync-with-podcast-audio" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My first test was simple. I grabbed a few seconds of the opening bumper from my podcast <a href="/en/tags/themakitachronicles/">The M.Akita Chronicles</a>, which is generated by AI through an <a href="/en/2026/04/09/how-elevenlabs-was-not-killed-by-qwen3-tts/">ElevenLabs v3 pipeline</a>, passed in my new anime-style avatar as a reference image, and asked Seedance to make the character lip-sync and gesture along with that audio.</p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/test1.mp4" type="video/mp4">
  </video>
  <em>Anime avatar lip-syncing to the M.Akita Chronicles bumper</em>
</div>
<p>The result is decent. All of that came from a single still image. The lip sync follows the speech reasonably well, the expression has some life to it, and the hand gesture lands at the right beat. But this is a YouTube short or Instagram clip at best. Not the kind of animation you&rsquo;d open a serious video with.</p>
<h2>The feature that actually matters: video reference<span class="hx:absolute hx:-mt-20" id="the-feature-that-actually-matters-video-reference"></span>
    <a href="#the-feature-that-actually-matters-video-reference" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And here&rsquo;s what I think is the only real use of these models for actual work: feeding in <strong>video as a reference</strong>. Text alone is never going to be precise enough. You can describe a scene with whatever vocabulary you want, swap adjectives, specify lens, angle, framing, and every generation will still hand you back something subtly different. It&rsquo;s a slot machine. Great for memes, terrible for production.</p>
<p>Most amateurs see one random clip that comes out nice on the first try and go straight to flooding their X and Instagram feeds with it. That&rsquo;s not control. That&rsquo;s luck. For film, for ads, for show openers, for anything where the next shot has to match the previous one, you have to be able to tell the model: &ldquo;do exactly this motion, with exactly this camera.&rdquo; That&rsquo;s where reference video changes the game, because it replaces hours of 3D modeling, rigging, animation, and rendering that would cost a fortune in a studio.</p>
<p>For my test, I recycled the model I&rsquo;d already generated in the <a href="/en/2026/01/23/ai-3d-can-you-actually-model-3d-with-prompts-now/">post on AI 3D with Hunyuan and Nano Banana</a>, where I tested how far you can take 3D modeling from a prompt and even hit &ldquo;print&rdquo; on the result. But for something more cinematic I wanted a model with a proper animation cycle. So I grabbed the <a href="https://sketchfab.com/3d-models/darth-talon-4338105e09704f359c6afcab7e5fce10"target="_blank" rel="noopener">Darth Talon</a> model from Sketchfab, which has a short, well-made motion sequence, just because it looked cool.</p>
<p>I imported the FBX into Blender, set up a basic camera and lighting, and did a quick Cycles render. Nothing pretty about it, just a sketch to feed in as motion and framing reference.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/blender.png" alt="The Darth Talon model imported into Blender with camera and lighting set up for a rough Cycles render"  loading="lazy" /></p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/blender-render.mp4" type="video/mp4">
  </video>
  <em>The quick Cycles render that's about to become motion reference</em>
</div>
<p>I uploaded that render to Seedance as a video reference along with three t-pose images of the character (front, back, left side) for visual consistency:</p>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin: 1em 0; justify-content: center;">
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/front.jpg" alt="Front t-pose" style="flex: 1 1 30%; min-width: 140px; max-width: 32%; border-radius: 8px;" />
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/back.jpg" alt="Back t-pose" style="flex: 1 1 30%; min-width: 140px; max-width: 32%; border-radius: 8px;" />
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/left.jpg" alt="Left side t-pose" style="flex: 1 1 30%; min-width: 140px; max-width: 32%; border-radius: 8px;" />
</div>
<p>First attempt, with a generic prompt:</p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/test2.mp4" type="video/mp4">
  </video>
  <em>First generation: the character stayed consistent, but the motion and camera missed the reference</em>
</div>
<p>Not bad. The character keeps its identity, the lighting got a serious upgrade over my raw render, and the clip is coherent. But the motion and camera position drifted noticeably from the reference. The model basically read it as &ldquo;there&rsquo;s a character here that looks like that doing something like that,&rdquo; and improvised the rest.</p>
<p>Then I remembered Stefan&rsquo;s tutorial: the trick is to literally drop something like <code>Follow exact motion and camera from reference video</code> into the prompt. Tried again:</p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/test3.mp4" type="video/mp4">
  </video>
  <em>Second generation: explicit instruction to follow the reference's motion and camera</em>
</div>
<p>A lot better. It&rsquo;s clearly following the reference choreography, the camera angle matches in several beats, and the character looks more polished than anything I could hand-render in a quick Cycles sketch. But it&rsquo;s still not a frame-for-frame match. In some shots the model is still taking liberties with timing, position, and framing. There are other prompting tricks and parameters to play with, but I stopped here to write this up while the launch is still fresh.</p>
<h2>ComfyUI, Runway, and the rest of the ecosystem<span class="hx:absolute hx:-mt-20" id="comfyui-runway-and-the-rest-of-the-ecosystem"></span>
    <a href="#comfyui-runway-and-the-rest-of-the-ecosystem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Seedance 2.0 also already shipped <a href="https://blog.comfy.org/p/seedance-20-is-now-available-in-comfyui"target="_blank" rel="noopener">as a ComfyUI cloud node</a>, which is great for anyone who wants to drop the model into a bigger pipeline without bouncing between web UIs. If you&rsquo;ve already got an image-gen workflow built in ComfyUI, this is genuinely handy.</p>
<p>And for anyone who wants volume without doing the per-generation credit math, Runway is offering Seedance 2.0 as part of their <a href="https://www.mindstudio.ai/blog/seedance-2-0-runway-unlimited-plan"target="_blank" rel="noopener">Unlimited plan</a>, which runs around $76/month annual or $95/month monthly. &ldquo;Unlimited&rdquo; means no per-generation charge, but you do trade for lower queue priority at peak hours and an output storage cap. If you iterate a lot, the subscription model makes way more sense than buying credits one at a time.</p>
<h2>The price question<span class="hx:absolute hx:-mt-20" id="the-price-question"></span>
    <a href="#the-price-question" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Seedance&rsquo;s own pricing is credit-based. The Standard plan is $24.90/month annual or $49.90/month monthly, and gets you 2,500 credits a month. Every 10 seconds of generated video burns 6 credits.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/pricing.png" alt="Seedance 2.0 pricing table, with plans from Standard up to the top tier and per-second credit costs"  loading="lazy" /></p>
<p>The math is brutal. With 2,500 credits on Standard, you get about 416 ten-second generations a month. Sounds like a lot, until you remember my own test: it took at least two tries to get close to what I wanted, and even then it wasn&rsquo;t a one-to-one match. In practice, plan on 3 to 5 regenerations per usable scene. That drops your real number to something like 80 to 140 short clips per month. Maybe enough for a solo creator doing short-form content. Nowhere near enough for production that needs minutes of polished, continuous video.</p>
<p>The higher tiers scale credits roughly linearly, but the same problem holds: at today&rsquo;s price, Seedance 2.0 is still a tool for short clips and well-made memes, or for pre-vis sketches before you send the real thing to an actual studio. It doesn&rsquo;t replace continuous professional production. For that use case, Runway with Unlimited is honestly the cheaper option in practice. <a href="https://seedance2.ai/pricing"target="_blank" rel="noopener">Full pricing table here</a>.</p>
<h2>What nobody wants to talk about: restrictions and deepfakes<span class="hx:absolute hx:-mt-20" id="what-nobody-wants-to-talk-about-restrictions-and-deepfakes"></span>
    <a href="#what-nobody-wants-to-talk-about-restrictions-and-deepfakes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Two things I don&rsquo;t want to leave out. First: Seedance 2.0 took its sweet time getting to the public partly because ByteDance is being a lot more conservative than the competition when it comes to moderation. The model blocks generation that uses real people as reference (celebrities, politicians, public figures) and filters strong-IP characters from Disney, Marvel/DC, Nintendo, and registered trademarks generally. If you want the details of what passes, what doesn&rsquo;t, and why ByteDance went that way, <a href="https://www.mindstudio.ai/blog/seedance-2-0-content-restrictions-workarounds"target="_blank" rel="noopener">this MindStudio piece breaks it down</a>. It makes sense: ByteDance operates across dozens of jurisdictions, lives under heavy regulatory scrutiny worldwide, and has the elephant in the room of Hollywood pushing the entire industry with copyright suits over training data and generation. The release got delayed and arrived with heavier filters precisely because of that.</p>
<p>Second point, and this is the one that matters more: for you, the average reader, the message is that <strong>deepfakes are no longer hypothetical</strong>. Photos on the internet stopped counting as proof a long time ago, because image models like Nano Banana, Hunyuan, and the rest deliver realism that fools anyone. Now video sits in the same bucket. What we just saw above, where a couple of references and two prompts get you a coherent animated scene in minutes, is just the start. The average person, with no training, isn&rsquo;t going to be able to tell generated video from real video at a glance anymore. And Seedance&rsquo;s filters only block above-board, legitimate use. Anyone with bad intent will always find a path through open source models, workarounds, or less strict platforms. That&rsquo;s the world we&rsquo;re in now, and the sooner we collectively accept it, the better off we are.</p>
<h2>No, this doesn&rsquo;t replace real artists<span class="hx:absolute hx:-mt-20" id="no-this-doesnt-replace-real-artists"></span>
    <a href="#no-this-doesnt-replace-real-artists" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me cut off the usual speech before it shows up in the comments. No, Seedance doesn&rsquo;t replace VFX artists, directors, editors, none of that. This is a hammer. The tool is the same for everybody. What comes out the other end depends entirely on who&rsquo;s swinging it.</p>
<p>Anyone who&rsquo;s actually studied framing, pacing, continuity, art direction, editing, color, composition is going to use Seedance to 10x the work they were already capable of delivering. Anyone who hasn&rsquo;t studied any of that is going to crank out the same Instagram meme they were already cranking out, just 10x faster. <strong>The AI reflects who you are.</strong> If you&rsquo;re a real artist, the tool multiplies you. If you only think you are, the tool multiplies the mediocrity. There&rsquo;s no miracle here.</p>
<p>This is exactly why the whole &ldquo;now everyone can be a film director with AI&rdquo; wave is the same song we&rsquo;ve heard with the digital camera, with Photoshop, with After Effects, with Premiere, and with every powerful tool that&rsquo;s shown up in the last 30 years. The technical bar drops. The bar of taste, reference, and study sits exactly where it always has. Good people get more productive. Everyone else still needs to hire real ones to do real work.</p>
<h2>Where this goes next<span class="hx:absolute hx:-mt-20" id="where-this-goes-next"></span>
    <a href="#where-this-goes-next" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Seedance 2.0 is clearly a step in the right direction, but it&rsquo;s not the tool that replaces professional production yet. Text alone stays imprecise, video reference helps but still drifts, and the price per minute of polished output is high relative to what you actually get. The good news is that this story always rhymes: Google has Veo, Runway has its own model and is also reselling Seedance, Kling and Hailuo from China keep pushing, and the open source models are gaining ground. OpenAI had Sora, but they shut it down. As competition tightens, quality goes up and price comes down. A year or two from now we&rsquo;ll be looking back at this post and finding what I just showed pretty quaint. Same as it ever was.</p>
]]></content:encoded><category>ai</category><category>video</category><category>seedance</category><category>bytedance</category><category>vfx</category><category>blender</category><category>deepfake</category><category>themakitachronicles</category></item><item><title>An Emulation Distrobox with Claude Code</title><link>https://akitaonrails.github.io/en/2026/04/11/emulation-distrobox-with-claude-code/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/11/emulation-distrobox-with-claude-code/</guid><pubDate>Sat, 11 Apr 2026 18:00:00 GMT</pubDate><description>&lt;p&gt;I&amp;rsquo;ve loved old videogames since before many of you were born. My first contact was back in the Atari era, in the early 80s. Then came the 8-bit micros, the 90s arcades, the 16, 32 and 64-bit consoles, and I kept following all of it. For me, nostalgia is not a cute Instagram folder. It&amp;rsquo;s an archive. ROMs, BIOS files, dumps, patches, DLC, firmware, saves. Over the years I kept everything on my NAS. At this point I have terabytes of it under &lt;code&gt;/mnt/terachad/Emulators&lt;/code&gt;.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I&rsquo;ve loved old videogames since before many of you were born. My first contact was back in the Atari era, in the early 80s. Then came the 8-bit micros, the 90s arcades, the 16, 32 and 64-bit consoles, and I kept following all of it. For me, nostalgia is not a cute Instagram folder. It&rsquo;s an archive. ROMs, BIOS files, dumps, patches, DLC, firmware, saves. Over the years I kept everything on my NAS. At this point I have terabytes of it under <code>/mnt/terachad/Emulators</code>.</p>
<p>On my YouTube channel I even used old games to teach computer science fundamentals. In <a href="https://www.youtube.com/watch?v=hYJ3dvHjeOE&amp;pp=ygUUYWtpdGFuZG8gc3VwZXIgbWFyaW8%3D"target="_blank" rel="noopener">this Akitando episode about Super Mario and old computers</a>, I explain the 6502, memory maps, the PPU, hardware constraints, and why those games were programmed the way they were. If you never watched it, start there:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/hYJ3dvHjeOE"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>The problem is always the same: every few years I decide to set up a new emulation machine, download the main emulators again, and there I go repeating the same masochistic ritual. <code>PCSX2</code>, <code>RPCS3</code>, <code>Eden</code>, <code>Azahar</code>, <code>Dolphin</code>, <code>RetroArch</code>, <code>Flycast</code>, <code>shadPS4</code>, and so on. Each one with its quirks. Each one with its own particular way of wasting hours of your life.</p>
<p>In theory, setting all this up should be fun. In practice, it takes days. And that&rsquo;s not an exaggeration. <code>Dolphin</code> still manages to be one of the worst because GameCube and Wii controller setups have no standard. <code>RPCS3</code> requires per-game tuning. <code>Eden</code> needs DLC, updates and cheats in the right place. <code>shadPS4</code> is a festival of trial and error. When I&rsquo;d finally finish configuring everything, I didn&rsquo;t feel like playing anything anymore. I just wanted to close the menus and go do something else.</p>
<p>I&rsquo;ve been known to say that maybe the fun was the tuning itself. I don&rsquo;t think that anymore. After repeating this process too many times, I&rsquo;m done. I want to play the games, not fill out forms hidden inside emulator GUIs.</p>
<h2>The problem was never Linux<span class="hx:absolute hx:-mt-20" id="the-problem-was-never-linux"></span>
    <a href="#the-problem-was-never-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve solved this kind of thing in different ways in the past. Years ago I ran Linux on the host and a virtualized Windows with GPU passthrough to play inside the VM. At the time that made sense. I made a whole video about it:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/IDnabc3DjYY"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>But that was before Valve, Proton, modern Wine and the whole open source community pushed the Linux desktop to a much better place. Today you can avoid Windows in most cases. In the other video below I explain the evolution of CPU, GPU, OpenGL, Vulkan, and why we ended up here:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/JEp7ozWqIps"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>My gaming mini-PC with the RTX 4090 is still my main Steam machine, especially now that I&rsquo;ve built a <a href="/en/2026/04/01/my-sim-racing-cockpit-formula-fx1/">proper sim racing cockpit</a>. On it I use <a href="https://www.emudeck.com/"target="_blank" rel="noopener">EmuDeck</a>, which was born to automate installing and configuring emulators on the Steam Deck, then grew Windows support, and helps cut the complexity of setting up this kind of environment. But my main desktop is also way too powerful to leave underused. It has an RTX 5090, and today I use that GPU mostly to test LLMs and for benchmarking, as I wrote in <a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">Testing Open Source and Commercial LLMs</a>. Leaving it idle for gaming would be a waste.</p>
<p>Only on Linux I wanted something else. I didn&rsquo;t want a black box doing everything for me without me knowing exactly what was being changed, especially on my main PC, which is my work machine. I wanted my own setup, handmade in the right sense: not clicking GUI by GUI, but understanding what&rsquo;s being configured, keeping the files under my control, and being able to rebuild everything the way I like. I know the NixOS folks will jump in here to say &ldquo;just use Nix&rdquo; — I&rsquo;ll explain why I ruled that out below. I also didn&rsquo;t want to turn my work machine into a carnival of emulator packages, GTK themes, wrappers, launchers, weird runtimes and configs buried in <code>~/.config</code>. Nor did I want dual boot or a VM. In 2026, the best answer for this, in my opinion, is <a href="https://distrobox.it/"target="_blank" rel="noopener">Distrobox</a>.</p>
<p>From what the project itself explains, Distrobox is a wrapper on top of <code>podman</code>, <code>docker</code> or <code>lilipod</code> to create containers tightly integrated with the host, with access to <code>HOME</code>, Wayland/X11, audio, USB devices, external storage and GPU. In other words: exactly the kind of pragmatic isolation I wanted. It&rsquo;s not a security boundary, and the project site makes that clear. Don&rsquo;t use it thinking about high-security sandboxing. Use it thinking about separating environments without paying the price of a VM.</p>
<p>And before anyone repeats the usual confusion: a container is not a virtual machine. I explain this calmly in this other episode:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/85k8se4Zo70"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<h2>The setup<span class="hx:absolute hx:-mt-20" id="the-setup"></span>
    <a href="#the-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The idea is simple: a vanilla Arch Linux inside a Distrobox called <code>gaming</code>, with NAS paths mounted read-only, Steam library accessible where it makes sense, and all emulators installed inside. Since the container has access to the NVIDIA GPU, audio, USB and other peripherals, I can separate work and gaming on the same machine without practical performance penalty.</p>
<p>The initial bootstrap looks roughly like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">distrobox create --name gaming --image archlinux:latest --nvidia <span class="se">\
</span></span></span><span class="line"><span class="cl">  --home /mnt/data/distrobox/gaming <span class="se">\
</span></span></span><span class="line"><span class="cl">  --volume /mnt/data/steam:/mnt/data/steam <span class="se">\
</span></span></span><span class="line"><span class="cl">  --volume /mnt/terachad/Emulators:/mnt/terachad/Emulators:ro</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Even that first step has a catch. <code>archlinux:latest</code> inside Distrobox comes without <code>[multilib]</code> in <code>pacman.conf</code>, with a misconfigured <code>sudoers</code>, and with the old <code>--nvidia</code> problem: the host driver libs get bind-mounted read-only, so any install that depends on <code>nvidia-utils</code> breaks with file conflicts. Just that alone is the kind of headache that, a few years ago, would make me open half a dozen tabs, a bunch of terminals, and spend a whole night in trial and error.</p>
<p>This time I did it differently.</p>
<h2>Claude Code as an infrastructure assistant<span class="hx:absolute hx:-mt-20" id="claude-code-as-an-infrastructure-assistant"></span>
    <a href="#claude-code-as-an-infrastructure-assistant" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve been getting more and more comfortable using Claude Code as my infrastructure assistant on my personal machines. I wouldn&rsquo;t do this blindly on a client server. But on my own machine, in an environment I can destroy and rebuild however many times I want, it makes all the sense in the world. I wrote about this in <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">Migrating my Home Server with Claude Code</a>, when I used Claude to install and configure openSUSE MicroOS, Docker, NFS, firewall, hardening, and all the rest without me having to remember annoying shell commands.</p>
<p>So I thought: why not do the same thing here?</p>
<p>That&rsquo;s exactly what I did. I started with a sequence of objective prompts, always asking for two things at once: do the work, and document enough for me to rebuild everything later. The most important history ended up in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/distrobox-gaming-prompts.md"target="_blank" rel="noopener"><code>docs/distrobox-gaming-prompts.md</code></a>.</p>
<p>A clarification worth making: those prompts on GitHub aren&rsquo;t my raw messages, the way they came out in real time. After everything worked, I asked Claude itself to rewrite the prompts in a much more organized and detailed way, purely for documentation. The original prompts were much simpler and much less specific. I described the goal and let Claude figure out paths, config files, formats and commands on its own.</p>
<p>The first prompt created the box with <code>--nvidia</code>, a separate <code>--home</code>, and correct mounts. The second solved the three classic problems of Arch inside Distrobox: <code>sudo</code>, <code>multilib</code>, and the <code>nvidia-utils</code> dummy package. The third installed the gaming base, including a detail I would have easily forgotten if I was doing it by hand: <code>pipewire-pulse</code>, needed so several emulators don&rsquo;t end up mute or out of sync.</p>
<p>The point isn&rsquo;t the text itself. The point is the method. I don&rsquo;t need to remember the exact order, the precise options, or where the docs for each detail are. I describe the goal and let the agent carry the piano. I stay in the tech-lead role: watching, reviewing, course-correcting when needed, and telling it to keep going.</p>
<h2>The GitHub project<span class="hx:absolute hx:-mt-20" id="the-github-project"></span>
    <a href="#the-github-project" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The setup lives in <a href="https://github.com/akitaonrails/distrobox-gaming"target="_blank" rel="noopener">akitaonrails/distrobox-gaming</a>, structured as an Ansible project. 17 roles cover everything from box creation, package bootstrap, config seeding, DLC installation, controller setup, to post-setup verification. The main playbooks are:</p>
<ul>
<li><code>site.yml</code>: full setup from scratch</li>
<li><code>reset-configs.yml</code>: reset configs only (useful when you want to wipe tuning and redeploy)</li>
<li><code>backup.yml</code> / <code>restore.yml</code>: container snapshot and restore via <code>podman commit</code></li>
<li><code>refresh-shadps4.yml</code>: standalone shadPS4 update</li>
<li><code>install-xenia.yml</code>: optional Xenia Manager install (Wine prefix)</li>
</ul>
<p>Full walkthrough is in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/rebuild-runbook.md"target="_blank" rel="noopener"><code>docs/rebuild-runbook.md</code></a>. Package decisions (why AUR instead of Flatpak, for example) are in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/distrobox-gaming-packages.md"target="_blank" rel="noopener"><code>docs/distrobox-gaming-packages.md</code></a>.</p>
<h3>Why not Nix?<span class="hx:absolute hx:-mt-20" id="why-not-nix"></span>
    <a href="#why-not-nix" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Someone will ask. &ldquo;You&rsquo;re recreating a reproducible environment. Why not use NixOS, or at least <code>nix-shell</code>/flakes?&rdquo;</p>
<p>I considered and ruled it out. The emulator ecosystem I need lives on the AUR. AUR <code>-bin</code> packages are wrappers around AppImages that ship the official upstream binary without compiling anything. <code>rpcs3-bin</code>, <code>pcsx2-latest-bin</code>, <code>eden-bin</code>, <code>duckstation-qt-bin</code>, <code>shadps4-bin</code> — these binaries track upstream practically on release day. On Nixpkgs, many of these emulators are weeks or months behind release, and some don&rsquo;t exist. Creating a Nix overlay for every emulator I need, maintaining it, and dealing with compatibility patches when upstream changes is work I don&rsquo;t want to have.</p>
<p>There&rsquo;s also the mental model. I don&rsquo;t want to learn the Nix language, the derivation system and the evaluation model just to set up a personal gaming environment. I already know Ansible, roles are folders with YAML tasks, and debugging is <code>ansible-playbook -vvv</code>. When something goes wrong, I read the error, open the task that failed, and know exactly where to fix it. If I needed the same level of reproducibility at scale, across a cluster, with atomic rollback, then Nix would have an argument. For a distrobox with emulators on my personal machine, Ansible solves it with much less friction.</p>
<p>Flatpak was also tested and ruled out. Flatpak&rsquo;s <code>bwrap</code> (bubblewrap) doesn&rsquo;t nest properly inside Docker&rsquo;s mount namespaces, so Flatpak apps simply don&rsquo;t run inside distrobox. The alternative would be installing Flatpak on the host, but then you&rsquo;re back to polluting the work machine with gaming packages. Plus, Flatpak versions of emulators lag well behind AUR — the AUR&rsquo;s <code>pcsx2-latest-bin</code> tracks the 2.7.x AppImage, while Flathub still ships v2.6.3.</p>
<h2>Platforms covered<span class="hx:absolute hx:-mt-20" id="platforms-covered"></span>
    <a href="#platforms-covered" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The setup covers 12 platforms today:</p>
<table>
  <thead>
      <tr>
          <th>Platform</th>
          <th>Emulator</th>
          <th>Renderer</th>
          <th>Highlight</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PS1</td>
          <td>DuckStation</td>
          <td>Vulkan, 8x upscale</td>
          <td>JINC2 filter, PGXP, widescreen hack</td>
      </tr>
      <tr>
          <td>PS2</td>
          <td>PCSX2 2.7.x</td>
          <td>Vulkan, 4x SSAA</td>
          <td>FXAA + PCRTC antiblur, Xbox bindings</td>
      </tr>
      <tr>
          <td>PS3</td>
          <td>RPCS3</td>
          <td>Vulkan</td>
          <td>Curated per-game configs via API</td>
      </tr>
      <tr>
          <td>PS4</td>
          <td>shadPS4</td>
          <td>Vulkan</td>
          <td>Driveclub-focused (experimental)</td>
      </tr>
      <tr>
          <td>PS Vita</td>
          <td>Vita3K</td>
          <td>—</td>
          <td>Default seeding</td>
      </tr>
      <tr>
          <td>GameCube</td>
          <td>Dolphin</td>
          <td>Vulkan</td>
          <td>8BitDo Ultimate 2 profiles</td>
      </tr>
      <tr>
          <td>Wii</td>
          <td>Dolphin</td>
          <td>Vulkan</td>
          <td>Classic Controller + Nunchuk</td>
      </tr>
      <tr>
          <td>Wii U</td>
          <td>Cemu</td>
          <td>Vulkan</td>
          <td>Baseline seed only-if-missing</td>
      </tr>
      <tr>
          <td>Switch</td>
          <td>Eden</td>
          <td>Vulkan</td>
          <td>Atmosphere DLC + cheats</td>
      </tr>
      <tr>
          <td>Dreamcast</td>
          <td>Flycast</td>
          <td>Vulkan, 2880p</td>
          <td>Hi-res wrapper, widescreen</td>
      </tr>
      <tr>
          <td>OG Xbox</td>
          <td>xemu</td>
          <td>—</td>
          <td>BIOS/HDD via symlink</td>
      </tr>
      <tr>
          <td>Xbox 360</td>
          <td>Xenia (Wine)</td>
          <td>Vulkan</td>
          <td>Project Forza Plus, PGR3/4</td>
      </tr>
  </tbody>
</table>
<p>RetroArch comes on top to cover the rest (NES, SNES, Genesis, N64, arcade, etc.), with 21 buildbot cores and 8 asset packs (databases, shaders, cheats, overlays, autoconfig).</p>
<p>Frontend is <a href="https://es-de.org/"target="_blank" rel="noopener">ES-DE</a>. Custom system definitions live in <code>ansible/group_vars/all/esde.yml</code>, and each emulator comes with a wrapper that sets the right GPU environment variables (more on that in a sec).</p>
<h2>GPU hardforce for NVIDIA<span class="hx:absolute hx:-mt-20" id="gpu-hardforce-for-nvidia"></span>
    <a href="#gpu-hardforce-for-nvidia" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On a hybrid system with NVIDIA dGPU + AMD iGPU (my case on the desktop), Vulkan sometimes picks the iGPU on its own and you end up with an emulator running at 15 fps while the 5090 sits idle. The fix is to force NVIDIA&rsquo;s Vulkan ICD in every launcher.</p>
<p>Each desktop entry rendered by Ansible exports <code>VK_ICD_FILENAMES</code> pointing to NVIDIA&rsquo;s ICD, and the ES-DE wrapper does the same before launching any core. Combined with the <code>--nvidia</code> flag at box creation, which bind-mounts host drivers read-only, the result is predictable: Vulkan always on the NVIDIA dGPU, regardless of system state at runtime.</p>
<p>This mostly matters for hybrid setups. On a desktop with a single GPU, the ICD filtering is placebo, but it doesn&rsquo;t hurt.</p>
<h2>PS2: PCSX2 with per-game tuning<span class="hx:absolute hx:-mt-20" id="ps2-pcsx2-with-per-game-tuning"></span>
    <a href="#ps2-pcsx2-with-per-game-tuning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Gran Turismo line is my declared addiction. Each version needs its own specific tweak. The PCSX2 setup today has sane global configs (Vulkan, 4x SSAA, widescreen 16:9, Xbox-style bindings, FXAA + PCRTC antiblur, 16x anisotropic, Automatic deinterlace) and per-game INIs for the titles I actually play:</p>
<ul>
<li><strong>Gran Turismo 3 A-spec</strong> (SCUS-97102 + bundle PBPX-95503): widescreen pnach, optional retexture pack (disabled by default because NFS streaming causes flicker in car-showcase cutscenes)</li>
<li><strong>Gran Turismo 4 USA</strong> (SCUS-97328): Silentwarior112 HD HUD, full retexture pack, Silent&rsquo;s trigger/cam patches, widescreen pnach</li>
<li><strong>Gran Turismo 4 Spec II</strong> (SCUS-97436, CRC <code>4CE521F2</code>): special case — the vanilla GT4 HD packs are linked via symlink because Spec II has the same structure but a different CRC. Widescreen pnach renamed to match the CRC. Deinterlace mode 8 (Adaptive TFF), ShadeBoost tuning (+10 saturation, +3 brightness, +2 contrast) to fix pause-interlace ghosting and the anemic upscale saturation</li>
<li><strong>Enthusia Professional Racing</strong> (SLUS-20967): HD textures + widescreen</li>
<li><strong>Ridge Racer V</strong> (SLUS-20002): widescreen + no-interlace pnach</li>
</ul>
<p>Silent&rsquo;s and community pnach files are downloaded automatically by the <code>pcsx2_textures</code> role, which also deploys the cheats pack from the NAS and installs the Spec II HD packs via symlink. Widescreen is enabled per-CRC via <code>gamesettings</code> (PCSX2 identifies games by CRC before applying pnach).</p>
<p>On PCSX2 versioning: the AUR&rsquo;s <code>pcsx2-latest-bin</code> tracks the 2.7.x line, which is where all the modern features live (texture replacement, FXAA, PCRTC antiblur). The default <code>pcsx2</code> in Arch repos is still on 2.6.3. The gap is over 250 commits of fixes and new features.</p>
<h2>PS3: RPCS3 with curated per-game configs<span class="hx:absolute hx:-mt-20" id="ps3-rpcs3-with-curated-per-game-configs"></span>
    <a href="#ps3-rpcs3-with-curated-per-game-configs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>RPCS3 has an annoying detail: per-game configs live in <code>~/.config/rpcs3/custom_configs/config_&lt;SERIAL&gt;.yml</code>. The file format has a version, and if you hand-write YAML with the wrong version, RPCS3 silently rejects it and falls back to defaults. That cost me time to figure out.</p>
<p>The <code>rpcs3_per_game_configs</code> role handles it like this:</p>
<ol>
<li>Queries the <a href="https://rpcs3.net/compatibility"target="_blank" rel="noopener">RPCS3 community compatibility DB</a> for recommended presets per title</li>
<li>Applies curated overrides on top for titles I know need specific tuning (typically GT5 and GT6)</li>
<li>Saves the YAML with the exact filename convention (<code>config_&lt;SERIAL&gt;.yml</code>, <code>config_</code> prefix mandatory)</li>
</ol>
<p>For the Gran Turismo line:</p>
<ul>
<li><strong>GT5</strong> (BCUS98114, BCES00569): Resolution Scale 300%, Shader Precision Ultra, Force High Precision Z, SPU XFloat Accurate, Multithreaded RSX. The combo kills the typical RSX dithering without breaking the GT-series safety config (WCB/RCB off).</li>
<li><strong>GT6</strong> (BCES01893 + regional variants): special case, <strong>version pinned to v1.05</strong>. Patches 1.06+ regress with black surfaces on cars in the garage menu. The <code>extract_ps3_dlc.py</code> script has a per-title PATCH ceiling specifically to pin GT6 at 1.05 even when PSN serves newer patches. Also, Force CPU Blit is mandatory (without it, full-screen flicker). The trade-off is that the rear-view mirror stays permanently black — turning WCB on would restore the mirror but downgrade to native 720p. I&rsquo;ll take no mirror.</li>
</ul>
<p>More details on those two in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/gt5-rpcs3.md"target="_blank" rel="noopener"><code>docs/gt5-rpcs3.md</code></a> and <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/gt6-rpcs3.md"target="_blank" rel="noopener"><code>docs/gt6-rpcs3.md</code></a>.</p>
<h3>PS3 update checker<span class="hx:absolute hx:-mt-20" id="ps3-update-checker"></span>
    <a href="#ps3-update-checker" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>To avoid opening RPCS3&rsquo;s GUI and clicking <code>Download Game Updates</code> game by game, I wrote a Python script: <code>check_ps3_updates.py</code>. It walks the games installed in <code>dev_hdd0/game</code>, parses <code>PARAM.SFO</code> to get the current version per title, queries the PSN update server (<code>a0.ww.np.dl.playstation.net</code>), and compares against the local patch cache. <code>--list</code> shows the diff; <code>--download</code> fetches what&rsquo;s missing. <code>--max-version</code> respects per-title ceilings (e.g., 1.05 for GT6).</p>
<p>The first scan of my library found 51 of 72 games with updates available, 69 patches missing locally, ~24 GB in total. GT6 alone had 16 missing patches from 1.07 to 1.22, which I skipped for the reasons above.</p>
<p>Patches arrive as PSN packages type 0x0001 without encryption, so <code>extract_ps3_dlc.py</code> handles the install. Standard call:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">distrobox enter gaming -- python3 scripts/extract_ps3_dlc.py <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s2">&#34;/mnt/terachad/Emulators/EmuDeck/roms_heavy/ps3-DLC&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dest <span class="s2">&#34;/mnt/data/distrobox/gaming/.config/rpcs3/dev_hdd0/game&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Tracking PS3 DLC versions manually, game by game through RPCS3, is impractical. It becomes an infinite to-do list, and you only find out there was a 1.22 patch when the game crashes.</p>
<h2>PS1: DuckStation per-game<span class="hx:absolute hx:-mt-20" id="ps1-duckstation-per-game"></span>
    <a href="#ps1-duckstation-per-game" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Gran Turismo trilogy starts on PS1, and quality there only goes up if you enable the right things:</p>
<ul>
<li><strong>Gran Turismo</strong> (SCUS-94949): DuckStation&rsquo;s built-in WidescreenHack (no cheat exists for this game), PGXP enabled</li>
<li><strong>Gran Turismo 2 Simulation</strong> (SCUS-94455): widescreen cheat + 8 MB RAM cheat (fixes audio issues on crowded tracks), JINC2 filter</li>
<li><strong>Gran Turismo 2 Arcade</strong> (SCUS-94488): same treatment as Simulation</li>
</ul>
<p>Global defaults: Vulkan, 8x upscale, JINC2 as the default filter.</p>
<p>GT2 cheats come from a widescreen-fixes repo and are linked automatically into DuckStation&rsquo;s cheats folder.</p>
<h2>Switch: Eden with DLC, update and cheats<span class="hx:absolute hx:-mt-20" id="switch-eden-with-dlc-update-and-cheats"></span>
    <a href="#switch-eden-with-dlc-update-and-cheats" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On Switch, <code>eden-bin</code> is the emulator. The integration automates three things I used to do by hand:</p>
<ol>
<li>
<p><strong>DLC and update install</strong>: the <code>install_dlcs</code> role points to the dumps directory on the NAS, identifies <code>.nsp</code>/<code>.nsz</code>/<code>.xci</code>/<code>.xcz</code> files, and installs everything into <code>~/.local/share/eden/nand/user/Contents/registered/</code>. If the dump is messy (patches and DLC mixed in a flat folder), the <code>reorganize_switch_nsps.py</code> script sorts everything by Title ID first.</p>
</li>
<li>
<p><strong>Atmosphere cheats</strong>: Atmosphere-format cheats get symlinked into Eden&rsquo;s load path (<code>~/.local/share/eden/load/&lt;TITLE_ID&gt;/cheats/</code>). The <code>switch_cheats</code> role handles this for every title covered by the NAS pack.</p>
</li>
<li>
<p><strong>Update checker</strong>: <code>check_switch_updates.py</code> lists titles with updates available against what&rsquo;s installed locally. Useful so I don&rsquo;t miss important patches for games still getting updates.</p>
</li>
</ol>
<p>A detail that burned me: <code>QT_STYLE_OVERRIDE</code> needs to be unset before launching <code>eden-bin</code>, otherwise it conflicts with Kvantum and breaks the UI. The wrapper does the <code>unset</code> before <code>exec</code>.</p>
<h2>Xbox 360: Xenia Manager via Wine<span class="hx:absolute hx:-mt-20" id="xbox-360-xenia-manager-via-wine"></span>
    <a href="#xbox-360-xenia-manager-via-wine" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Xbox 360 is still rough territory on Linux. There&rsquo;s no decent native emulator. The best route today is <a href="https://xenia.jp/"target="_blank" rel="noopener">Xenia</a> running through Wine, managed by <a href="https://github.com/xenia-manager/xenia-manager"target="_blank" rel="noopener">Xenia Manager</a>.</p>
<p>This is opt-in in my setup. The <code>install-xenia.yml</code> playbook creates a dedicated Wine prefix, downloads Xenia Manager, and registers launchers. It&rsquo;s not part of the main <code>site.yml</code> because not everyone wants Wine in the mix.</p>
<h3>Project Forza Plus (Forza Motorsport 2/3/4)<span class="hx:absolute hx:-mt-20" id="project-forza-plus-forza-motorsport-234"></span>
    <a href="#project-forza-plus-forza-motorsport-234" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Old Forza is still the king of previous-gen simcade. Running FM2/3/4 on Xenia requires the Project Forza Plus community mods: compatibility patches, performance mods, specific title-update installs. I documented the process in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/project-forza.md"target="_blank" rel="noopener"><code>docs/project-forza.md</code></a>.</p>
<h3>Project Gotham Racing 3 and 4<span class="hx:absolute hx:-mt-20" id="project-gotham-racing-3-and-4"></span>
    <a href="#project-gotham-racing-3-and-4" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>PGR3 runs reasonably well on Xenia Canary. PGR4 needs a specific tweak: <code>render_target_path_vulkan = &quot;fsi&quot;</code> in the config, otherwise some races break with visual artifacts. Setup documented in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/xbox360-pgr.md"target="_blank" rel="noopener"><code>docs/xbox360-pgr.md</code></a>. PGR4 audio on NVIDIA still has an XMA decoding issue that produces intermittent garbage. There&rsquo;s an open xenia-canary issue, no resolution so far.</p>
<h3>Batch Title Updates<span class="hx:absolute hx:-mt-20" id="batch-title-updates"></span>
    <a href="#batch-title-updates" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Title Updates for Xbox 360 games were distributed via Xbox Live, which has been shut down. The alternative is archive.org, which has the complete catalog preserved. I wrote <code>scripts/download-xbox360-tus.py</code> to automate:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">distrobox enter gaming -- python3 scripts/download-xbox360-tus.py <span class="se">\
</span></span></span><span class="line"><span class="cl">  --src /mnt/terachad/Emulators/EmuDeck/roms_heavy/xbox360 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dest /mnt/terachad/Emulators/EmuDeck/roms_heavy/xbox360-updates <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dry-run</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The script needs the <code>internetarchive</code> CLI authenticated (<code>ia configure</code> once, stores the token). It scans the games folder, matches against the archive.org manifest (cached locally), prioritizes by region (USA &gt; World &gt; Europe &gt; Japan), and downloads via <code>ia download --checksum</code> with automatic retry. The resulting .zip files go to Xenia Manager via <code>Manage → Install Content</code>, which extracts into the correct directory (<code>000B0000/</code>).</p>
<p>Full setup in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/xbox360-title-updates.md"target="_blank" rel="noopener"><code>docs/xbox360-title-updates.md</code></a>.</p>
<h2>Steam in the distrobox<span class="hx:absolute hx:-mt-20" id="steam-in-the-distrobox"></span>
    <a href="#steam-in-the-distrobox" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since Proton became a first-class citizen on Linux, it makes sense to have Steam in the gaming box too. I added the <code>steam</code> package via Ansible (which pulls in multilib and 32-bit deps), mounted <code>/mnt/data/steam</code> read-write so the library persists outside the container, and created a host-side launcher.</p>
<p>In practice, this lets me run Steam games via Proton side-by-side with emulation, without switching environments. The mini-PC with the RTX 4090 is still the main Steam machine in the sim racing cockpit. The desktop is fallback and a test environment.</p>
<h2>Shell and controllers<span class="hx:absolute hx:-mt-20" id="shell-and-controllers"></span>
    <a href="#shell-and-controllers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Minimal in-box shell<span class="hx:absolute hx:-mt-20" id="minimal-in-box-shell"></span>
    <a href="#minimal-in-box-shell" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When I <code>distrobox enter gaming</code>, I want a decent prompt even for quick troubleshooting. I installed <code>zsh</code> + <code>starship</code> with a minimal config, zero exotic plugins. It&rsquo;s not my full dev setup, it&rsquo;s just &ldquo;a prompt that shows path and git branch without embarrassing me&rdquo;.</p>
<h3>Dolphin and the modern-controller hell<span class="hx:absolute hx:-mt-20" id="dolphin-and-the-modern-controller-hell"></span>
    <a href="#dolphin-and-the-modern-controller-hell" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>Dolphin</code> has always been the king of manual friction. If you use a modern controller, like my 8BitDo Ultimate 2, you have to remember how I like the GameCube mapping, how I adapt the Wii Remote, when to use a Nunchuk profile, when to switch to Classic Controller. I don&rsquo;t have the patience to rebuild that by hand every time. Profiles are pre-built in <code>config/emulator-overrides/dolphin/Profiles/</code> and copied by the <code>seed_configs</code> role. Details in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/controller-hotkeys.md"target="_blank" rel="noopener"><code>docs/controller-hotkeys.md</code></a>.</p>
<h2>A few stumbles that stuck around<span class="hx:absolute hx:-mt-20" id="a-few-stumbles-that-stuck-around"></span>
    <a href="#a-few-stumbles-that-stuck-around" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Not everything became magic. Emulation always has banana peels.</p>
<p><code>Flycast</code>, for example, has an irritating trap. The <code>emu.cfg</code> file uses <code>rend.*</code> keys inside the <code>[config]</code> section. If you create a separate <code>[rend]</code> section, it looks right, but the emulator just ignores it and later rewrites everything with mediocre defaults. The fix became a dedicated wrapper at <code>$DG_BOX_HOME/bin/flycast-hires</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">$DG_BOX_HOME</span>/bin/flycast-hires <span class="se">\
</span></span></span><span class="line"><span class="cl">  -config config:pvr.rend<span class="o">=</span><span class="m">4</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -config config:rend.Resolution<span class="o">=</span><span class="m">2880</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -config config:rend.EmulateFramebuffer<span class="o">=</span>no <span class="se">\
</span></span></span><span class="line"><span class="cl">  -config config:rend.WideScreen<span class="o">=</span>yes</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Details in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/flycast-resolution.md"target="_blank" rel="noopener"><code>docs/flycast-resolution.md</code></a>.</p>
<p><code>shadPS4</code> is still far from a closed case. The current setup is focused on <code>Driveclub</code>, with a mirrored config, XML patch and sys_modules links from firmware 11.00. And that wasn&rsquo;t random: I really like <code>Driveclub</code>. It&rsquo;s practically the only game still stuck on PS4 that I really wanted to get running properly. I&rsquo;ve seen several YouTube videos showing the game working, but so far I haven&rsquo;t been able to get it running satisfactorily on Linux myself. Documented in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/driveclub-shadps4.md"target="_blank" rel="noopener"><code>docs/driveclub-shadps4.md</code></a>. The good part is that I no longer depend on muscle memory to remember how to launch it, with which patch, in which folder, with which modules. The bad part is that PS4 emulation is still PS4 emulation. No script makes upstream mature faster. If anyone knows how to close this setup on Linux, check the project on GitHub and send a PR.</p>
<p>Ridge Racer V has car-texture flickering documented in PCSX2 issues (#3639, #13729). The Hardware renderer is acceptable for a racing game where you&rsquo;re not standing still admiring the model. Software renderer fixes it but kills performance. I chose to live with the flicker.</p>
<h2>The real gain isn&rsquo;t just automation<span class="hx:absolute hx:-mt-20" id="the-real-gain-isnt-just-automation"></span>
    <a href="#the-real-gain-isnt-just-automation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;d be easy to sum this up as &ldquo;look how cool, I used AI to automate shell scripts&rdquo;. That&rsquo;s not it.</p>
<p>The real gain is more mundane and more important: I stopped spending mental energy on manual work. I didn&rsquo;t have to remember rare commands. I didn&rsquo;t have to keep dozens of tabs of wiki, issue, forum, README. I didn&rsquo;t have to leave half a dozen terminals scattered tailing logs while I try to remember which hidden GUI option the emulator insists on overwriting on the first boot. I started working in conversation.</p>
<p>I tell the agent the goal. It looks up the files, reads the logs, finds the right config format, compares defaults, proposes fixes, writes scripts, validates paths, checks UID/GID, verifies broken symlinks, generates wrappers, exports launchers. I&rsquo;m still responsible for decisions, of course. But I stopped being a typist of rare commands.</p>
<p>That&rsquo;s the point that interests me most about using coding agents on Linux. They dramatically reduce the entry friction. A lot of people bounce off the Linux desktop not because the system is incapable, but because the tuning curve has historically been too irritating. Having an assistant capable of reading docs, cross-referencing configs, proposing automation and executing under supervision changes that game.</p>
<h2>If you want to reproduce<span class="hx:absolute hx:-mt-20" id="if-you-want-to-reproduce"></span>
    <a href="#if-you-want-to-reproduce" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The repo was published for this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/distrobox-gaming.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> distrobox-gaming/ansible
</span></span><span class="line"><span class="cl">ansible-galaxy collection install -r collections/requirements.yml
</span></span><span class="line"><span class="cl">cp host_vars/localhost.yml.example host_vars/localhost.yml
</span></span><span class="line"><span class="cl"><span class="nv">$EDITOR</span> host_vars/localhost.yml  <span class="c1"># adjust paths for your machine</span>
</span></span><span class="line"><span class="cl">ansible-playbook site.yml</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Xenia is opt-in:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ansible-playbook install-xenia.yml</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Read the <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/README.md"target="_blank" rel="noopener"><code>README.md</code></a> and <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/rebuild-runbook.md"target="_blank" rel="noopener"><code>docs/rebuild-runbook.md</code></a> first. I don&rsquo;t distribute ROMs, BIOS, firmware or keys. The repo only detects, links and configures what you already have on your own machine.</p>
<h2>If you prefer the Claude Code route<span class="hx:absolute hx:-mt-20" id="if-you-prefer-the-claude-code-route"></span>
    <a href="#if-you-prefer-the-claude-code-route" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My recommendation is simple:</p>
<ol>
<li>Start with the goal, not the command. &ldquo;I want an Arch distrobox with NVIDIA GPU, separate home, ROMs read-only and Steam rw&rdquo; beats dumping a half shell command without context.</li>
<li>Always ask it to document what it&rsquo;s doing. If it works, promote the result to a script or role. If you don&rsquo;t document, you just manufactured a throwaway hack.</li>
<li>Work in phases. Box creation. Bootstrap. Emulator config. Launcher export. Verification. That&rsquo;s exactly how I broke down the problem.</li>
<li>Ask for objective checks. <code>command -v</code>, file existence, broken symlinks, UID/GID, ROM paths, audio, GPU. The best automation is the one that fails early.</li>
<li>Don&rsquo;t use the agent as a command parrot. Use it as a technical assistant. You still review the decisions and tell it to adjust course.</li>
</ol>
<p>For me, that was the gain. I went from zero to a much more complete emulation machine without having to customize everything by hand, GUI by GUI, one window at a time. And this time, I finished with energy left to do what I wanted from the start.</p>
<p>Play.</p>
]]></content:encoded><category>gaming</category><category>emulation</category><category>linux</category><category>distrobox</category><category>ansible</category><category>claude-code</category><category>AI</category></item><item><title>VS Code Is the New Punch Card</title><link>https://akitaonrails.github.io/en/2026/04/11/vs-code-is-the-new-punch-card/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/11/vs-code-is-the-new-punch-card/</guid><pubDate>Sat, 11 Apr 2026 12:00:00 GMT</pubDate><description>&lt;p&gt;Every time someone asks whether juniors are going to stop learning how to program because LLMs can write code for them, I have the same reaction: you&amp;rsquo;re asking the wrong question.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;re confusing &lt;strong&gt;code input&lt;/strong&gt; with &lt;strong&gt;software engineering&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Those are not the same thing. They never were.&lt;/p&gt;
&lt;p&gt;There was a time when programming meant knowing how to convert numbers into binary in your head and jam instructions straight into memory addresses, bit by bit, by hand. There was a time when programming meant knowing how to organize a punched-card deck, knowing whether the deck order was right, knowing where one bad hole wrecked execution, and knowing how to debug visually without the modern fantasy of infinite backspace. There was a time when real programming meant knowing 6502, Z80, and Assembly because computers had so few resources that every byte actually mattered, not as a figure of speech.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Every time someone asks whether juniors are going to stop learning how to program because LLMs can write code for them, I have the same reaction: you&rsquo;re asking the wrong question.</p>
<p>You&rsquo;re confusing <strong>code input</strong> with <strong>software engineering</strong>.</p>
<p>Those are not the same thing. They never were.</p>
<p>There was a time when programming meant knowing how to convert numbers into binary in your head and jam instructions straight into memory addresses, bit by bit, by hand. There was a time when programming meant knowing how to organize a punched-card deck, knowing whether the deck order was right, knowing where one bad hole wrecked execution, and knowing how to debug visually without the modern fantasy of infinite backspace. There was a time when real programming meant knowing 6502, Z80, and Assembly because computers had so few resources that every byte actually mattered, not as a figure of speech.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/altair-8800-computer.jpg" alt="Altair 8800, symbol of the era when programming still meant physical panels and manual instruction entry"  loading="lazy" /></p>
<p><em>Front panel and switches: before editors, before IDEs, before comfortable terminals.</em></p>
<p>And there were phases of computing that were even worse than punch cards. The video below shows someone programming an LGP-21, one of the oldest personal computers ever made (the second oldest, after the 1950s Bendix G15). Starts at the 5-minute mark:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/TJjRCCetyo4?start=300"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>Imagine what that was: you typed the program in binary directly on a typewriter, looking at the accumulator, instruction by instruction, then physically flipped a switch to execute, and the result got typed back onto paper. The metric wasn&rsquo;t framerate. It was characters per minute. An operation you can do today in the blink of an eye inside any app used to take hours of human work, typing bit by bit, verifying, testing, verifying again.</p>
<p>It&rsquo;s the same thing happening with typing code in a text editor today versus an AI agent running in your place. The manual way still works, the same way the Altair front panel kept working for years after compilers became common. But the tool of choice shifted, and the gap in speed and effort is the same order of magnitude that separated the LGP-21 from a modern text editor. Our generation is watching this transition happen in slow motion, and a lot of people don&rsquo;t want to see it.</p>
<p>Then compilers got better. C showed up. Machines got fatter, consoles moved into the 32/64-bit era, PCs got more decent, and Assembly stopped being the center of everything. It became what it should be: a low-level tool, local optimization, critical routines, bootstrapping, drivers, that sort of thing. Nobody serious looked at that transition and said, &ldquo;well, that&rsquo;s it, programming is over because the compiler writes machine instructions for you now.&rdquo;</p>
<p>The 21st century brought the Web and shoved an entire generation into HTML, CSS, and a pile of markup bureaucracy that adds very little intellectual value while demanding a ton of manual labor just to get it mostly right. I still think the industry dragged that model out way too long. For way too many years, programmers became glorified form operators, CRUD assemblers, <code>div</code> aligners, priests of front-end frameworks that all do the same thing with slightly different syntax.</p>
<p>Then the 2010s bubble made it worse.</p>
<p>I&rsquo;ve already written about that in <a href="/en/2026/02/08/rant-ai-killed-programmers/">RANT: Did AI Kill Programmers?</a> and also in <a href="/en/2026/03/05/37-days-of-vibe-coding-immersion-conclusions-on-business-models/">37 Days of Vibe Coding Immersion</a>. The startup bubble, cheap money, and the hiring frenzy produced a legion of very bad programmers, coming out of two-month bootcamps and sketchy online courses promising Google salaries with no foundation, no education, and no depth. The market spent a decade pretending that was normal. It wasn&rsquo;t. Same story as always: lots of volume, little value, and a whole lot of people confusing inflated employability with actual competence.</p>
<p>And when the layoffs started in 2022, that did not come out of nowhere. I spent years warning that the bubble would pop. It&rsquo;s all there in the playlist <a href="https://www.youtube.com/watch?v=wpPv1dJWjDs&amp;list=PLdsnXVqbHDUehzKjiRruy--gncHz9Injy&amp;pp=sAgC"target="_blank" rel="noopener">EU AVISEI</a>. The message was always the same: once the cheap money dried up, the bar would go back up, and the only people with a real shot would be the ones who had made the effort to actually learn Computer Science. The next economic cycle was always going to be less about hiring volume and more about efficiency. That&rsquo;s exactly what happened.</p>
<h2>What really changed<span class="hx:absolute hx:-mt-20" id="what-really-changed"></span>
    <a href="#what-really-changed" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>LLMs went mainstream at the end of 2022. That&rsquo;s true. But mainstream is not the same thing as useful for serious work.</p>
<p>Throughout 2023 and 2024, I was already using AI to write code. Did it work? Sure, it worked. But it was still full of nonsense: too many hallucinations, too many agent loops, context getting lost too easily, tooling breaking too often, too much cost for too little reliability. It was useful for experienced programmers who knew how to keep the thing on a leash. It still wasn&rsquo;t a mature tool for day-to-day heavy lifting.</p>
<p>For me, the turn came at the end of 2025. That&rsquo;s when the combination of better models, prompt caching, decent tool calling, inference optimizations, context windows that were actually useful in practice, and above all real agent interfaces made the whole thing stop feeling like conference-demo theater and start feeling like an actual work tool.</p>
<p>That was the point where Claude Code, Codex, OpenCode, and the rest stopped being &ldquo;autocomplete on steroids&rdquo; and became a different category of interface.</p>
<p>To me, Claude Code is already the new terminal. The editor has moved into the background.</p>
<p>I talked about that in <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">Migrating my Home Server with Claude Code</a>, too. I simply have no patience left to burn attention on Linux shell busywork when the problem is mundane: installing a server, bringing up and organizing Docker services, hardening a firewall, reviewing security rules, tuning kernel parameters, auditing <code>dmesg</code>, digging through systemd logs, that kind of thing. I let Claude do the heavy lifting, and I review direction and risk. Ironically, my Linux machines have never felt more stable, faster, or more robust.</p>
<p>Today, if you spend your day building software, going back to the raw combo of text editor plus terminal and doing everything manually starts to feel like regression. Not because typing became impossible. Of course not. I&rsquo;ve been typing code for decades. The problem is something else: it became a waste of attention.</p>
<p>If I can describe an intent, ask an agent to inspect the codebase, edit twenty files, run tests, compile, fix the fallout, and bring me back a proposed change in minutes, why exactly am I supposed to feel nostalgic about hand-typing boilerplate inside VS Code?</p>
<p>I don&rsquo;t.</p>
<p>And this is where a distinction a lot of people still don&rsquo;t get matters. You should not use a coding agent like some dumb editor extension, the kind of thing where you say &ldquo;generate this file here&rdquo; and then spend the next half hour micromanaging every line in a tiny corner pane. That&rsquo;s using a Ferrari to go buy bread around the block. The big gain does not come from treating Claude Code, Codex, or similar tools like glorified autocomplete inside VS Code. The gain comes when you drop the editor-operator mindset and start treating the agent like an actual pair programmer.</p>
<p>Instead of acting like a professional typist, you move up a level. You act more like a tech lead, product owner, QA, manager of the flow. You define intent, explain context, demand criteria, ask for a plan, ask it to run tests, ask for refactors, ask for alternative approaches, ask it to review its own change. You leave the manual code labor to the agent and use your own brain to judge direction, priority, risk, and quality.</p>
<p>But there is a balance there, and I&rsquo;ve already covered it in other Agile Vibe Coding posts. You do not hand over the wheel and let the LLM <code>git push</code> everything blind. And you also don&rsquo;t swing to the opposite extreme and turn into a comma cop, nitpicking every tiny detail until the agent becomes one more layer of bureaucracy and kills the speed advantage. Both extremes are bad. In one, you outsource responsibility. In the other, you strangle productivity.</p>
<p>The right point on that pendulum is somewhere else: use XP and actual engineering discipline to sustain the speed. Continuous refactoring. Tests. CI. Review. Fast feedback. Small code. Incremental change. That&rsquo;s exactly what I documented in posts like <a href="/en/2026/02/20/zero-to-post-production-in-1-week-using-ai-on-real-projects-behind-the-m-akita-chronicles/">From Zero to Post-Production in 1 Week - How to Use AI on Real Projects</a> and <a href="/en/2026/03/01/software-is-never-done-4-projects-life-after-deploy-one-shot-prompt-myth/">Software Is Never &lsquo;Done&rsquo;</a>. The 10x multiplier does not come from model magic. It comes from the model plus a sane process.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/cursor-homepage-crop.png" alt="Modern coding-agent interface, representing the shift from the traditional editor to an intent-driven, execution-assisted workflow"  loading="lazy" /></p>
<p><em>The interface changed. The need for judgment did not.</em></p>
<h2>VS Code is the new punch card<span class="hx:absolute hx:-mt-20" id="vs-code-is-the-new-punch-card"></span>
    <a href="#vs-code-is-the-new-punch-card" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>That&rsquo;s what the title means.</p>
<p>VS Code did not &ldquo;become bad.&rdquo; That&rsquo;s not the point. Punch cards weren&rsquo;t &ldquo;bad&rdquo; in their own historical context either. They were a massive step up from toggling bits by hand or rewiring a board. The point is that they were the mechanism of their era for feeding instructions into the machine.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/punched-card-program-deck.jpg" alt="Punched-card deck, from an era when the profession demanded more discipline in preparing input than comfort in the interface"  loading="lazy" /></p>
<p><em>VS Code is not the enemy. It&rsquo;s just becoming the input mechanism of the previous era.</em></p>
<p>Today, text editors are becoming that again: an input mechanism that still works, will still be around for a long time, but is no longer the center of the activity.</p>
<p>If you&rsquo;ve never looked at those older eras of computing, I already went through it in <a href="/2020/10/23/akitando-86-o-computador-de-turing-e-von-neumann-por-que-calculadoras-nao-sao-computadores/">Akitando #86 - The Turing and Von Neumann Computer</a>:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/G4MvFT8TGII"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>And if you want a refresher on why 6502, Z80, and old machines forced a different kind of discipline, go back to the <a href="/2020/06/04/akitando-80-o-guia-hardcore-de-introducao-a-computacao/">Hardcore Guide to Computing Fundamentals</a> and <a href="/2020/06/18/akitando-81-aprendendo-sobre-computadores-com-super-mario-do-jeito-hardcore/">Learning About Computers with Super Mario (Hardcore++)</a>. That wasn&rsquo;t old-man nostalgia. It was there to show that in every era the interface changes, but the machine still demands precision.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/akitando-80-6502.jpg" alt="Thumbnail from Akitando #80, part of the series built to teach computing fundamentals through the 6502 and the microcomputer era"  loading="lazy" /></p>
<p><em>This is what a good part of Akitando existed for: teaching what still holds when the fashionable tool changes.</em></p>
<p>And it&rsquo;s worth remembering something a lot of people forget: the 146 videos on <a href="https://www.youtube.com/@Akitando"target="_blank" rel="noopener">Akitando</a>, more than 96 hours of material, were built precisely to teach this kind of foundation to Computer Science students, juniors, and people trying to stop being framework button-pushers. I recorded all of that because I could already see the industry pushing too many people toward manual busywork and not enough understanding. Ironically, now that agents are here, that archive is more relevant than ever.</p>
<p>The interface changed again.</p>
<p>First you needed to know how to type the instruction.
Then you needed to know how to organize the deck.
Then you needed to know how to convince the compiler.
Then you needed to know how to stitch together frameworks, HTML, CSS, YAML, CI, containers, cloud, ORMs, queues, observability, and fifty other layers of junk.</p>
<p>Now you need to know how to <strong>orchestrate an agent</strong>.</p>
<p>And again, that does not eliminate foundations. It only changes where the manual labor ends and the intellectual work begins.</p>
<h2>&ldquo;So we don&rsquo;t need Computer Science anymore?&rdquo;<span class="hx:absolute hx:-mt-20" id="so-we-dont-need-computer-science-anymore"></span>
    <a href="#so-we-dont-need-computer-science-anymore" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Quite the opposite.</p>
<p>Now you need it more.</p>
<p>Someone with no foundation looks at an agent issuing a <code>SELECT * FROM table</code>, sees the thing working locally against 300 fake rows, and assumes everything is fine. In production the query pulls a million rows, blows memory, degrades latency, backs up a queue, congests connections, and the person has no clue why &ldquo;it works on my machine&rdquo; but melts in production.</p>
<p>That&rsquo;s the real problem.</p>
<p>The agent does not know your system&rsquo;s context the way an experienced engineer does. It doesn&rsquo;t know which tables are going to grow tenfold next quarter. It doesn&rsquo;t know which endpoint needs to answer in 80 ms and which one can take 2 seconds. It doesn&rsquo;t know which flow needs a transaction, which one needs idempotency, which one needs a pessimistic lock, which one needs async compensation, which one needs auditing, which one can never leak sensitive data.</p>
<p>It may get the syntax right.</p>
<p>But syntax was never the hardest part.</p>
<p>I already said that in <a href="/en/2026/02/24/rant-akita-caved-to-ai/">RANT: Did Akita Bend Over for AI??</a>: what AI is really good at is removing the mundane work. And thank God for that. I did not get into computing to become an IDE operator. I have no romantic attachment to formatting HTML, fighting CSS, assembling the same old CRUD, gluing one more framework onto an old stack, or writing the same pile of infrastructure code for the hundredth time when any decent machine should be able to produce that by now.</p>
<p>So what is left after that mundane layer disappears?</p>
<p>Exactly the part that separates amateurs from real programmers:</p>
<ul>
<li>domain modeling</li>
<li>architecture</li>
<li>trade-offs</li>
<li>performance</li>
<li>scalability</li>
<li>security</li>
<li>observability</li>
<li>maintainability</li>
<li>readability</li>
<li>operational cost</li>
<li>product decisions</li>
</ul>
<p>All of that still exists. All of that is still hard. All of that still depends on judgment.</p>
<h2>The mistake people make when they think programming was typing<span class="hx:absolute hx:-mt-20" id="the-mistake-people-make-when-they-think-programming-was-typing"></span>
    <a href="#the-mistake-people-make-when-they-think-programming-was-typing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Some people genuinely think that if the machine writes the code, then the need to understand software is gone.</p>
<p>That is the same stupidity as thinking the compiler killed the need to understand computers.</p>
<p>It didn&rsquo;t.</p>
<p>It only killed the need to write everything in Assembly.</p>
<p>And thank God for that too.</p>
<p>Same thing here: coding agents do not kill the need to understand software. They kill the need for you to be a syntax typist.</p>
<p>And good riddance.</p>
<p>There is a nice irony here too: for years the industry sold the fantasy that programming meant &ldquo;learning a framework.&rdquo; Then it sold the fantasy that programming meant &ldquo;learning React.&rdquo; Then it sold the fantasy that programming meant &ldquo;learning the stack of the month.&rdquo; Now it&rsquo;s going to sell the fantasy that programming means &ldquo;learning prompt engineering.&rdquo;</p>
<p>It doesn&rsquo;t.</p>
<p>Prompts are an interface.
Frameworks are an interface.
IDEs are an interface.
Punch cards were an interface.</p>
<p>Programming is still the act of instructing a machine to compute something useful under real constraints.</p>
<p>People who understand that survive every tool shift.
People who don&rsquo;t become operators of whatever the trendy tool is, and when the trend changes, they go down with it.</p>
<h2>What I think is going to happen to juniors<span class="hx:absolute hx:-mt-20" id="what-i-think-is-going-to-happen-to-juniors"></span>
    <a href="#what-i-think-is-going-to-happen-to-juniors" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>So let&rsquo;s answer the original question properly.</p>
<p>Juniors are not going to stop learning.</p>
<p>But they are going to have to learn <strong>something else</strong>.</p>
<p>If the junior of 2015 could spend years hiding ignorance behind low-value manual tasks, tweaking views, adjusting CSS, assembling dumb endpoints, copying Stack Overflow snippets, and making it all look like &ldquo;productivity,&rdquo; that hiding place is going away.</p>
<p>The junior of the agent era is either going to level up faster or get exposed faster. There isn&rsquo;t much middle ground.</p>
<p>If they use agents and actually study fundamentals, they&rsquo;ll be able to test hypotheses faster, read more code, compare more solutions, iterate more, make mistakes earlier and fix them earlier. They&rsquo;ll learn more in less time.</p>
<p>But if they use agents without foundations, all they&rsquo;ll do is outsource their own ignorance. They&rsquo;ll become reviewers who cannot review. They&rsquo;ll accept patches they do not understand. They&rsquo;ll approve decisions they do not know how to measure. They&rsquo;ll confuse &ldquo;passed locally&rdquo; with &ldquo;ready for production.&rdquo;</p>
<p>That kind of professional is dangerous.</p>
<p>Far more dangerous than the old junior who was at least limited by their own speed.</p>
<h2>The post-bubble world<span class="hx:absolute hx:-mt-20" id="the-post-bubble-world"></span>
    <a href="#the-post-bubble-world" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The good news is that this comes right after the collapse of the dumbest phase of the hiring bubble.</p>
<p>It&rsquo;s past time for the market to stop rewarding labor-intensive stupidity as if it were competence. It&rsquo;s past time to stop treating stack bureaucracy as technical depth. It&rsquo;s past time to stop confusing commit volume with engineering value.</p>
<p>If the new era wipes out a good chunk of that theater, great.</p>
<p>In a post-bubble, post-miracle-bootcamp, post-CSS-as-a-career, post-CRUD-as-a-profession world, foundations go back to being what they should always have been: the main asset.</p>
<p>People who understand operating systems, databases, networking, data structures, compilers, computer architecture, profiling, debugging, concurrency, consistency, security, and cost will use agents as an exoskeleton.</p>
<p>People who understand none of that will use agents as a crutch.</p>
<p>An exoskeleton amplifies strength.
A crutch only tries to hide weakness.</p>
<h2>&ldquo;But this isn&rsquo;t sustainable&rdquo;<span class="hx:absolute hx:-mt-20" id="but-this-isnt-sustainable"></span>
    <a href="#but-this-isnt-sustainable" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There is always someone with the same excuse: &ldquo;well, I don&rsquo;t think this is sustainable, the data centers can&rsquo;t keep up, the prices are too heavily subsidized, there&rsquo;s no way this can keep going like this.&rdquo;</p>
<p>And to be fair, that person is not entirely wrong.</p>
<p>It just doesn&rsquo;t change a thing about what I have to do tomorrow morning.</p>
<p>This kind of concern might be great for bar talk or an X thread, but it doesn&rsquo;t help me decide anything useful. Me, you, none of us are going to sit down with Anthropic or OpenAI leadership and redesign data-center capex, renegotiate power contracts, decide subsidy margins, or plan the next GPU generation. There is no concrete action for us that comes out of this besides repeating that &ldquo;someday this will be a problem.&rdquo;</p>
<p>It&rsquo;s the same mindset as the person in the 1990s saying, &ldquo;let&rsquo;s not use the internet too much, it&rsquo;s too slow, the limits are too low, the price per kilobyte is absurd, better wait until someone fixes it.&rdquo; Or the person in the early 2000s looking at mobile data and saying, &ldquo;2G is too slow, too limited, better not depend on it.&rdquo; Why exactly would you want to be that person?</p>
<p>Thank God OpenAI, Anthropic, and the rest are fighting like hell and heavily subsidizing this race. I&rsquo;m taking full advantage of it. I&rsquo;ve already burned through my entire Claude Max 20x, already hit the extra-usage ceiling, already burned through my Codex plan, and upgraded to Pro so I can keep using it this weekend. People paying a monthly subscription and barely touching it are, in practice, subsidizing me to use as much as I can. I have no intention of slowing down. Why would you?</p>
<p>If prices change tomorrow, if infrastructure gets tighter, if the game changes, then I reassess tomorrow. That&rsquo;s how technology has always worked. While the window is open, the rational move is not to slow yourself down in advance. The rational move is to learn as much as possible, extract as much as possible, and build an advantage while everybody else is busy explaining why they still haven&rsquo;t started.</p>
<h2>The decision is still human<span class="hx:absolute hx:-mt-20" id="the-decision-is-still-human"></span>
    <a href="#the-decision-is-still-human" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>At the end of the day, nothing that matters actually changed.</p>
<p>Someone still has to look at the result and decide:</p>
<ul>
<li>can this go to production?</li>
<li>can this handle load?</li>
<li>is this readable?</li>
<li>is this secure?</li>
<li>is this maintainable?</li>
<li>does this fit the rest of the system?</li>
<li>does this solve the right problem?</li>
</ul>
<p>If the answer is no, someone still has to know <strong>why</strong> it&rsquo;s no.</p>
<p>More importantly, someone still has to know <strong>how to fix it</strong>.</p>
<p>That&rsquo;s why, in the age of agents, foundational knowledge did not become less important. It became more expensive to be wrong without it.</p>
<p>VS Code is the new punch card.</p>
<p>Not because it became useless.</p>
<p>But because we&rsquo;re finally entering an era where the act of typing code by hand stops being the center of the profession.</p>
<p>And honestly? About time.</p>
<blockquote>
  <p><strong>AI reflects who you are.</strong></p>
<p>If you&rsquo;re good, it accelerates good code.</p>
<p>If you&rsquo;re bad, it accelerates technical debt at industrial speed.</p>
<p>AI is not going to take a bad programmer and turn them into a good one. It never did, it doesn&rsquo;t, and it won&rsquo;t.</p>

</blockquote>
<p>That&rsquo;s why foundations matter more now than they did before.</p>
<p>The agent can write. You&rsquo;re still the one who has to know whether what it wrote is any good.</p>
]]></content:encoded><category>ai</category><category>llm</category><category>opinion</category><category>programming</category></item><item><title>How ElevenLabs Was Not Killed by Qwen3 TTS</title><link>https://akitaonrails.github.io/en/2026/04/09/how-elevenlabs-was-not-killed-by-qwen3-tts/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/09/how-elevenlabs-was-not-killed-by-qwen3-tts/</guid><pubDate>Thu, 09 Apr 2026 08:30:00 GMT</pubDate><description>&lt;p&gt;&lt;strong&gt;TL;DR — Listen to this and keep reading:&lt;/strong&gt;&lt;/p&gt;
&lt;audio controls preload="metadata" style="width: 100%; max-width: 640px;"&gt;
&lt;source src="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3" type="audio/mpeg"&gt;
Your browser doesn't support the audio element. &lt;a href="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3"&gt;Download the mp3 here.&lt;/a&gt;
&lt;/audio&gt;
&lt;p&gt;That&amp;rsquo;s the April 6th episode of the &lt;a href="https://akitaonrails.github.io/en/tags/themakitachronicles/"&gt;The M.Akita Chronicles&lt;/a&gt; podcast, already generated with the new ElevenLabs v3 pipeline. Subscribe to the show on &lt;a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener"&gt;Spotify&lt;/a&gt; so you don&amp;rsquo;t miss new episodes like this one.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;When Qwen3 TTS dropped, back around January this year, everyone on Twitter/X and on the AI newsletter circuit was shouting &amp;ldquo;ElevenLabs killer&amp;rdquo;. There&amp;rsquo;s a &lt;a href="https://medium.com/@warpie/qwen3-tts-is-the-first-real-open-source-threat-to-elevenlabs-56ba200ab5ee"target="_blank" rel="noopener"&gt;Medium piece&lt;/a&gt; claiming it&amp;rsquo;s the first real open source threat to ElevenLabs. There&amp;rsquo;s a &lt;a href="https://byteiota.com/qwen3-tts-3-second-voice-cloning-beats-elevenlabs/"target="_blank" rel="noopener"&gt;post on byteiota&lt;/a&gt; saying 3-second voice cloning beats ElevenLabs. There&amp;rsquo;s an &lt;a href="https://www.analyticsvidhya.com/blog/2025/12/qwen3-tts-flash-review/"target="_blank" rel="noopener"&gt;analysis on Analytics Vidhya&lt;/a&gt; calling it the most realistic open source TTS released so far. The enthusiast internet consensus was: we finally have open source that can go toe-to-toe with ElevenLabs, the tables have turned, it&amp;rsquo;s just a matter of time.&lt;/p&gt;</description><content:encoded><![CDATA[<p><strong>TL;DR — Listen to this and keep reading:</strong></p>
<audio controls preload="metadata" style="width: 100%; max-width: 640px;">
  <source src="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3" type="audio/mpeg">
  Your browser doesn't support the audio element. <a href="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3">Download the mp3 here.</a>
</audio>
<p>That&rsquo;s the April 6th episode of the <a href="/en/tags/themakitachronicles/">The M.Akita Chronicles</a> podcast, already generated with the new ElevenLabs v3 pipeline. Subscribe to the show on <a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener">Spotify</a> so you don&rsquo;t miss new episodes like this one.</p>
<hr>
<p>When Qwen3 TTS dropped, back around January this year, everyone on Twitter/X and on the AI newsletter circuit was shouting &ldquo;ElevenLabs killer&rdquo;. There&rsquo;s a <a href="https://medium.com/@warpie/qwen3-tts-is-the-first-real-open-source-threat-to-elevenlabs-56ba200ab5ee"target="_blank" rel="noopener">Medium piece</a> claiming it&rsquo;s the first real open source threat to ElevenLabs. There&rsquo;s a <a href="https://byteiota.com/qwen3-tts-3-second-voice-cloning-beats-elevenlabs/"target="_blank" rel="noopener">post on byteiota</a> saying 3-second voice cloning beats ElevenLabs. There&rsquo;s an <a href="https://www.analyticsvidhya.com/blog/2025/12/qwen3-tts-flash-review/"target="_blank" rel="noopener">analysis on Analytics Vidhya</a> calling it the most realistic open source TTS released so far. The enthusiast internet consensus was: we finally have open source that can go toe-to-toe with ElevenLabs, the tables have turned, it&rsquo;s just a matter of time.</p>
<p>I decided to test it on my own production pipeline, as usual. I built out a whole pipeline on top of Qwen3 TTS 1.7B to generate the weekly podcast for <a href="/en/tags/themakitachronicles/">The M.Akita Chronicles</a>, and I documented the behind-the-scenes on the <a href="/en/2026/02/18/serving-ai-in-the-cloud-my-personal-tts-behind-the-m-akita-chronicles/">serving-AI-in-the-cloud post</a>. Anyone who wants the detail on cold starts, voice cloning, and sampling parameters that change from one mode to another, that&rsquo;s the place. I&rsquo;m not repeating it all here.</p>
<p>This post asks a different question. After almost two months running that setup in production, shipping an episode every Monday, last night I finally pulled the plug on Qwen3 and switched the whole thing to ElevenLabs v3. Let me tell you why.</p>
<h2>What didn&rsquo;t work on Qwen3<span class="hx:absolute hx:-mt-20" id="what-didnt-work-on-qwen3"></span>
    <a href="#what-didnt-work-on-qwen3" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Between February 15 and March 30, I made dozens of commits fine-tuning the podcast flow: prompts, sampling parameters, generation order, leading silence on the voice clone sample, volume normalization, pronunciation of tech acronyms. Fixing Marvin&rsquo;s voice, which was clipping the first syllable because the reference audio started without any lead-in silence. Tuning Akita&rsquo;s voice to sound more confident and assertive. Adding crossfades between section jingles. Sorting out the &ldquo;podcast&rdquo; pronunciation so it didn&rsquo;t come out as &ldquo;pódcast&rdquo;. Making the script generator prefer Portuguese over gratuitous anglicisms so the TTS wouldn&rsquo;t choke. Each of those fixes was a multi-hour session of listening to audio, regenerating, tweaking a parameter.</p>
<p>The result came out acceptable. &ldquo;Acceptable&rdquo; in the sense that I managed to ship every episode without re-recording anything by hand. But if you listen closely, the Qwen3 voice has that unmistakable AI-generating-audio quality. Flat intonation, uniform rhythm. Over long stretches you can feel it&rsquo;s a machine talking. Good enough to ship, but miles away from what you&rsquo;d hear on a professional podcast made by humans.</p>
<p>The worst problem was English pronunciation. My podcast covers tech news, so terms like &ldquo;MCP&rdquo;, &ldquo;RAG&rdquo;, &ldquo;Claude Opus&rdquo;, &ldquo;GPT-5&rdquo;, &ldquo;open source&rdquo; show up in every conversation. Qwen3 took those terms and pronounced them with a Brazilian accent, something like &ldquo;oh-pen-ee-sohrss-eh&rdquo;, that kind of thing. Unlistenable for the audience. The workaround I had to implement was manually mapping in the script-generation LLM prompt which English words to swap for Portuguese equivalents. The prompt today has a whole section split between &ldquo;keep in English&rdquo; (proper nouns, brands, terms already adopted into Brazilian tech lingo) and &ldquo;translate to Portuguese&rdquo; (gratuitous anglicisms), looking roughly like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**REPLACE with Portuguese** (common English words that have natural
</span></span><span class="line"><span class="cl">Brazilian Portuguese equivalents):
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;update&#34; → &#34;atualização&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;release&#34; → &#34;lançamento&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;feature&#34; → &#34;recurso/funcionalidade&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;deploy&#34; → &#34;implantação&#34; or just &#34;colocar em produção&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;trade-off&#34; → &#34;dilema&#34; or &#34;escolha&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;performance&#34; → &#34;desempenho&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;default&#34; → &#34;padrão&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;insight&#34; → &#34;percepção/sacada&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;skills&#34; → &#34;habilidades&#34;
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;approach&#34; → &#34;abordagem&#34;
</span></span><span class="line"><span class="cl">- &#34;highlights&#34; → &#34;destaques&#34;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I call this &ldquo;scrubbing anglicisms so the TTS doesn&rsquo;t choke&rdquo;. The funny part is I didn&rsquo;t want that level of restriction on my script. I wanted the voice to just pronounce &ldquo;update&rdquo; when &ldquo;update&rdquo; was the natural word. Since the model couldn&rsquo;t, I had to mutilate the podcast&rsquo;s vocabulary so the final result was actually listenable. It&rsquo;s a band-aid, the kind you add hoping to rip it off later when the tech grows up.</p>
<h2>The experience with ElevenLabs v3<span class="hx:absolute hx:-mt-20" id="the-experience-with-elevenlabs-v3"></span>
    <a href="#the-experience-with-elevenlabs-v3" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Yesterday afternoon I opened an ElevenLabs account, bought the Pro plan ($99/month), and started experimenting with the <code>eleven_v3</code> model, which shipped in February of this year. Thirty minutes later I had a proof of concept running, and about two hours later the whole podcast system was migrated. The effort gap is an abyss.</p>
<p>The technical details of the migration ended up in an internal project doc, so here&rsquo;s the summary comparison table that actually matters:</p>
<table>
  <thead>
      <tr>
          <th>Dimension</th>
          <th>Qwen3 TTS 1.7B (old)</th>
          <th>ElevenLabs <code>eleven_v3</code> (current)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Quality (Akita)</td>
          <td>Good, cloned from real audio</td>
          <td>Better, same clone but more natural prosody</td>
      </tr>
      <tr>
          <td>Inline emotion in script</td>
          <td>Not supported</td>
          <td><code>[sighs]</code>, <code>[sarcastically]</code>, <code>[excited]</code>, <code>[laughs]</code>, works in pt-BR</td>
      </tr>
      <tr>
          <td>Cold start</td>
          <td>5 to 15 min spinning up a RunPod GPU before each run</td>
          <td>Zero, HTTPS call with immediate response</td>
      </tr>
      <tr>
          <td>Throughput</td>
          <td>~1× real time (serialized)</td>
          <td>~6× real time with concurrency 4</td>
      </tr>
      <tr>
          <td>Wall clock for a 28-min episode</td>
          <td>~25 to 30 min</td>
          <td><strong>~4 min</strong></td>
      </tr>
      <tr>
          <td>Operational surface</td>
          <td>RunPod, Docker, FastAPI, Qwen weights, GPU billing</td>
          <td>One env var (<code>ELEVENLABS_API_KEY</code>)</td>
      </tr>
      <tr>
          <td>Cost per episode</td>
          <td>~$0.08 of GPU</td>
          <td>~$2.70 in ElevenLabs credits</td>
      </tr>
  </tbody>
</table>
<p>Look at the last line. Qwen3 costs less than ten cents of a dollar per episode. ElevenLabs costs almost thirty times more. And it&rsquo;s still worth it. The other lines on the table solve problems that were eating hours of my week. I no longer need to write code to scale GPUs in the cloud, I don&rsquo;t need to wait for the machine to spin up every time, I don&rsquo;t need to babysit a model or rebuild a Docker image when the weights change. The operation became a single line of configuration.</p>
<p>And here&rsquo;s the interesting part: the inline emotion tags. The v3 model accepts markers like <code>[sighs]</code>, <code>[sarcastically]</code>, <code>[dryly]</code>, <code>[excited]</code>, and changes the delivery accordingly. This works in more than 70 languages, including Brazilian Portuguese. It transformed script generation, because now I can ask the LLM that writes the script to drop emotional tags at the right moments, which gives a liveliness Qwen3 couldn&rsquo;t deliver in a million years. A concrete example of what comes out of the script later:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>AKITA: Isso é simples. [dismissive] Quem ainda acredita que Bitcoin
vai morrer não tá prestando atenção.
MARVIN: [sighs] Mais uma semana, mais uma leva de devs confiando
cegamente em pacotes npm. Previsível.</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I even have two separate tag palettes, one for Akita (expressive but controlled, uses <code>[excited]</code>, <code>[dismissive]</code>, <code>[emphatic]</code>) and another for Marvin (stoic, just <code>[sighs]</code>, <code>[sarcastically]</code>, <code>[tired]</code>, <code>[dryly]</code>). That&rsquo;s all encoded in the script generation prompts so the LLM knows which character is allowed to use what.</p>
<h2>About Marvin&rsquo;s voice<span class="hx:absolute hx:-mt-20" id="about-marvins-voice"></span>
    <a href="#about-marvins-voice" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For listeners who are already used to Marvin&rsquo;s voice, don&rsquo;t worry: I cloned him on ElevenLabs using the same audio sample I had used to train on Qwen3. It&rsquo;s the same voice. It just sounds even better now, because the ElevenLabs model captures nuance and prosody that Qwen3 couldn&rsquo;t deliver.</p>
<h2>Listen to it and tell me<span class="hx:absolute hx:-mt-20" id="listen-to-it-and-tell-me"></span>
    <a href="#listen-to-it-and-tell-me" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To prove the talk is real, here&rsquo;s Monday&rsquo;s episode, April 6th, already generated with the new pipeline:</p>
<audio controls preload="metadata" style="width: 100%; max-width: 640px;">
  <source src="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3" type="audio/mpeg">
  Your browser doesn't support the audio element. <a href="https://makita-news.s3.amazonaws.com/podcasts/episodes/2026-04-06.mp3">Download the mp3 here.</a>
</audio>
<p>If you&rsquo;re already a podcast listener on <a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener">Spotify</a> and you&rsquo;ve heard previous episodes made with Qwen3, compare them and tell me in the comments whether you notice the difference or whether it&rsquo;s the same to you. I&rsquo;m genuinely curious how much of this is my trained ear after hours of listening to TTS audio and how much is an obvious difference for a casual listener.</p>
<p>Starting next week, every episode of the podcast will be generated by ElevenLabs v3. The newsletter is already plugged into the new pipeline, the scheduled jobs to pre-warm the RunPod GPU have been disabled, and the legacy Qwen3 code stays in the repo as plan B in case v3 starts acting up chronically. Two file edits and I can switch back. I probably never will.</p>
<h2>The part about dubbing the YouTube videos<span class="hx:absolute hx:-mt-20" id="the-part-about-dubbing-the-youtube-videos"></span>
    <a href="#the-part-about-dubbing-the-youtube-videos" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now the hook for the second half of this post. On the <a href="/en/2026/04/09/20-years-of-blogging-ai-finally-translated-everything/">anniversary piece I published earlier today</a>, I told the story of how Claude Code translated nearly half my blog to English over a weekend. In the same spirit, I went after dubbing the videos on the <a href="https://www.youtube.com/@Akitando"target="_blank" rel="noopener">Akitando</a> channel.</p>
<p>The channel has 146 episodes, around 96 hours of technical content in Portuguese, and more than 500k subscribers. I already had the subtitles translated (one curated <code>.srt</code> per episode), and YouTube even offers automatic English dubbing. But the result is like Google Translate circa 2015: gets the idea across, nobody wants to listen for very long.</p>
<p>I tested ElevenLabs&rsquo; three voice approaches and only one worked. Speech-to-Speech converts a voice but doesn&rsquo;t translate. The Dubbing API does translate but creates the voice on its own, with no way to force a specific clone. Only Text-to-Speech could solve this: take the English <code>.srt</code> I already had, send each block to the TTS endpoint with my cloned voice, and assemble the audio aligned to the original video.</p>
<p>But having a translated <code>.srt</code> is not enough. Raw subtitle translation doesn&rsquo;t survive TTS. Passages with source code, URLs, hex hashes, lists of shell commands — the model switches to spelling mode and the audio comes out at twice the expected duration. Translations that run too long blow past the time window and need to be condensed so the voice doesn&rsquo;t sound like an auctioneer. Truncated SRTs need to be completed. And after each fix, you re-run the pipeline, listen, find the next problem, fix it, re-run. The whole thing was a cycle of interruptions, manual corrections, and re-runs — a far cry from the &ldquo;press a button and get a dub&rdquo; fantasy.</p>
<p>The big challenge was the accent. My cloned voice was trained on Brazilian Portuguese audio, so when it tries to speak English, the thick accent comes through. ElevenLabs has an <code>[American accent]</code> tag that works on v3, but on top of a voice trained in another language it&rsquo;s weak — the Brazilian accent still bleeds through. The fix was to train a second voice of mine, English only. I recorded a few minutes in my best American accent, uploaded it as a separate Instant Voice Clone in my account, named it &ldquo;Akita English&rdquo;, and set the pipeline to use that voice by default. The result comes out more natural, no tag needed, and the voice identity is still mine.</p>
<h3>The honest ceiling on my English voice quality<span class="hx:absolute hx:-mt-20" id="the-honest-ceiling-on-my-english-voice-quality"></span>
    <a href="#the-honest-ceiling-on-my-english-voice-quality" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Let&rsquo;s be frank. I&rsquo;m a Brazilian who speaks English, not a native speaker, and I don&rsquo;t have the time or patience to drill pronunciation for hours. A clone can only ever be as good as the source sample — if the source is a non-native reading a script, the clone inherits every imperfection. No clone is going to make me sound like &ldquo;Fabio speaking perfect English&rdquo; because that sound doesn&rsquo;t exist for the model to learn from.</p>
<p>To get to the version that&rsquo;s running today, I recorded five different training samples throughout the day, each with a different rhythm and script, and ran an A/B battery against the original Portuguese. None sounded native, and that was never the goal. The realistic bar was more modest: sound recognizably like me, read technical content without stumbling, and carry a full one-hour episode without wearing the listener out. The winner ended up being the first iteration, the original &ldquo;Akita English&rdquo;. Far from perfect, but good enough to ship. Swapping it out later is literally one line in the config file.</p>
<p>What surprised me most along the way was realizing that the rhythm of the training sample matters more than the timbre. The clone learns the cadence of whoever recorded it: iteration 5 came out 25% slower than iteration 2 reading the exact same text, purely because I recorded that particular sample more slowly. For dubbing tech videos, the right target is the rhythm of a &ldquo;YouTube tech explainer&rdquo; — brisk, energetic, around 150 words per minute. No audiobook pacing.</p>
<h3>How I align audio to the original video<span class="hx:absolute hx:-mt-20" id="how-i-align-audio-to-the-original-video"></span>
    <a href="#how-i-align-audio-to-the-original-video" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A non-obvious point: spoken English tends to be longer than equivalent Portuguese. If you just generate the audio for each subtitle and paste it at the original timestamp, the dub drifts later and later. With 30 minutes of video you&rsquo;re already several seconds out of sync.</p>
<p>My approach was to attack the problem in layers. The first version split the SRT into smaller blocks, called &ldquo;chunks&rdquo;. Each chunk maxed out at 700 characters (so v3 wouldn&rsquo;t hallucinate) and could only cut at a sentence boundary, never mid-sentence. A simple algorithm accumulated subtitles into a buffer until it was about to overflow, then walked back looking for the last cue whose text ended with <code>.</code>, <code>!</code>, or <code>?</code>, and flushed only up to there. The cues still in the buffer got rolled into the next chunk. Each chunk also stored the start and end timestamps of the subtitles it covered, so the assembly step knew exactly where to drop that piece of audio later.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># For every incoming cue, check whether adding it</span>
</span></span><span class="line"><span class="cl"><span class="c1"># would push the buffer past the char cap.</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">cue</span> <span class="ow">in</span> <span class="n">cues</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">projected</span> <span class="o">=</span> <span class="n">buffer_char_len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="o">+</span> <span class="nb">len</span><span class="p">(</span><span class="n">cue</span><span class="o">.</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">projected</span> <span class="o">&gt;</span> <span class="n">max_chars</span> <span class="ow">and</span> <span class="n">buf</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Walk back for the last cue in the buffer that</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># ends in &#39;.&#39;, &#39;!&#39;, &#39;?&#39;, or &#39;…&#39;.</span>
</span></span><span class="line"><span class="cl">        <span class="n">sentence_end</span> <span class="o">=</span> <span class="n">last_sentence_end_index</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">sentence_end</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">buf</span> <span class="o">=</span> <span class="n">flush</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">sentence_end</span><span class="p">,</span> <span class="n">chunks</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="c1"># Run-on sentence longer than max_chars with no</span>
</span></span><span class="line"><span class="cl">            <span class="c1"># terminator. Rare, but happens. Warn and cut.</span>
</span></span><span class="line"><span class="cl">            <span class="n">log</span><span class="o">.</span><span class="n">warning</span><span class="p">(</span><span class="s2">&#34;run-on sentence &gt; </span><span class="si">%d</span><span class="s2"> chars — &#34;</span>
</span></span><span class="line"><span class="cl">                        <span class="s2">&#34;splitting mid-sentence as a last resort&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                        <span class="n">max_chars</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">buf</span> <span class="o">=</span> <span class="n">flush</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">),</span> <span class="n">chunks</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">buf</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">cue</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Soft-break: if the buffer is already at least 60% full</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># AND the current cue ended a sentence, flush now. Keeps</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># chunks balanced around 60-100% of max.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">cue</span><span class="o">.</span><span class="n">ends_sentence</span> <span class="ow">and</span> <span class="n">buffer_char_len</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">max_chars</span> <span class="o">*</span> <span class="mf">0.6</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">buf</span> <span class="o">=</span> <span class="n">flush</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">buf</span><span class="p">),</span> <span class="n">chunks</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Technically, this was the turning point: instead of letting the dub follow the visual structure of the subtitle file, the chunker started following the real paragraph structure of the script. I won&rsquo;t retell the whole story of how I got there and why it eventually forced a mass rerender, because I come back to that later in the section about the problems that only showed up after the first upload. The technical point here is simple: once the window represented a real spoken paragraph, the audio started sounding natural, and the stretch target could move from 92% to 95%.</p>
<p>Once the chunking is done, there&rsquo;s still the problem that English runs longer than the Portuguese equivalent when spoken. The trick here is predicting whether a chunk is going to blow past its target window <strong>before</strong> generating the audio. If the ratio <code>characters / 16 chars-per-second</code> already exceeds the target duration by a margin, the pipeline passes the <code>speed</code> parameter directly to the ElevenLabs API, asking the model to natively generate faster speech. This preserves prosody way better than compressing afterwards. The ceiling is 1.15× (beyond that the voice starts sounding rushed).</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># before calling the API, predict if it will overrun</span>
</span></span><span class="line"><span class="cl"><span class="n">expected_sec</span> <span class="o">=</span> <span class="n">char_count</span> <span class="o">/</span> <span class="n">EXPECTED_CHARS_PER_SEC</span>  <span class="c1"># 16 chars/s</span>
</span></span><span class="line"><span class="cl"><span class="n">predicted_ratio</span> <span class="o">=</span> <span class="n">expected_sec</span> <span class="o">/</span> <span class="n">target_sec</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">voice_speed</span> <span class="o">=</span> <span class="mf">1.0</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">predicted_ratio</span> <span class="o">&gt;</span> <span class="n">PREEMPTIVE_SPEED_THRESHOLD</span><span class="p">:</span>   <span class="c1"># 1.05</span>
</span></span><span class="line"><span class="cl">    <span class="n">voice_speed</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">predicted_ratio</span> <span class="o">*</span> <span class="mf">0.98</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">PREEMPTIVE_SPEED_MAX</span><span class="p">)</span>         <span class="c1"># cap at 1.15</span>
</span></span><span class="line"><span class="cl">    <span class="n">voice_settings</span><span class="p">[</span><span class="s2">&#34;speed&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">voice_speed</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Even with preemptive <code>speed</code> turned on, sometimes the generated audio still comes out a tiny bit longer than the target window. That&rsquo;s what the second safety net is for: measure the actual audio with <code>ffprobe</code> after it&rsquo;s generated, compare to the target window, and apply the <code>ffmpeg</code> <code>atempo</code> filter to compress in post if it exceeded the 2% tolerance, capped at 1.20×. Combining native <code>speed</code> (1.15×) with <code>atempo</code> (1.20×) gives an effective compression ceiling of 1.38×, enough to fit naturally even in the worst cases without breaking quality.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># after generating the chunk</span>
</span></span><span class="line"><span class="cl"><span class="n">actual</span> <span class="o">=</span> <span class="n">ffprobe_duration</span><span class="p">(</span><span class="n">chunk_path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">ratio</span> <span class="o">=</span> <span class="n">actual</span> <span class="o">/</span> <span class="n">target_sec</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">ratio</span> <span class="o">&gt;</span> <span class="n">FIT_TOLERANCE</span><span class="p">:</span>       <span class="c1"># 1.02</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># compress the audio without touching pitch</span>
</span></span><span class="line"><span class="cl">    <span class="n">ffmpeg_atempo</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">ratio</span><span class="p">,</span> <span class="n">FIT_MAX_ATEMPO</span><span class="p">)</span>   <span class="c1"># 1.20</span>
</span></span><span class="line"><span class="cl">    <span class="n">apply_atempo</span><span class="p">(</span><span class="n">chunk_path</span><span class="p">,</span> <span class="n">ffmpeg_atempo</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When a generated chunk comes out shorter than the target window, the pipeline slightly stretches the audio (without affecting pitch) to reduce the ugly silence after it ends. The goal isn&rsquo;t to fill the whole window — a natural pause between sentences is fine — just to smooth over the worst offenders that would feel like &ldquo;cut audio&rdquo;.</p>
<p>On a big episode, 95 minutes, 194 chunks, the total cumulative drift came out to -0.7%. About 43 seconds of drift across the entire episode, imperceptible while you&rsquo;re watching.</p>
<p>What saved the budget on this architecture is that each chunk lives on disk as its own <code>.mp3</code>. The pipeline keeps a manifest with the normalized text of every chunk, and before calling the ElevenLabs API, it compares the current text against the cache. If the text hasn&rsquo;t changed, it reuses the existing audio without spending a single credit. If I rewrite a problematic cue, only the chunks affected by that cue get regenerated — the rest of the episode stays untouched.</p>
<p>This is what made the iterations viable. I&rsquo;d run the batch, listen to sections, spot a problem (translation too long, a code snippet the TTS couldn&rsquo;t pronounce, a chunk boundary that landed at a bad spot), fix the SRT or tweak the chunker parameters, and re-run. Each re-run consumed a fraction of the credits and time of the original run, because only the changed chunks were regenerated. Without this cache, every iteration would have cost nearly as much as the first pass, and the total batch cost would have been two or three times higher.</p>
<p>That chunking change also had a heavy cache and cost impact, but I&rsquo;ll leave that part for the later section where I explain the actual retracing and rerender cycle. Here, the important point is just that it was the right technical move.</p>
<h3>When <code>atempo</code> can&rsquo;t save you: rewriting cues before TTS<span class="hx:absolute hx:-mt-20" id="when-atempo-cant-save-you-rewriting-cues-before-tts"></span>
    <a href="#when-atempo-cant-save-you-rewriting-cues-before-tts" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Not everything is about window alignment. There&rsquo;s a kind of cue that breaks the whole pipeline, where neither preemptive <code>speed</code> nor post-process <code>atempo</code> can save it, and I only ran into it once I started running the batch over the more technical episodes of the channel.</p>
<p>The problem is this. The v3 reads at about 16 characters per second when you hand it normal conversational text. But when you hand it a cue stuffed with a literal URL, a hex hash, a binary string, a long sorted number list, or a shell code block, the model shifts into &ldquo;spelling it out letter by letter&rdquo; mode and drops to about 9 characters per second. A 500-character cue that was supposed to turn into 30 seconds of audio comes out at 55. The sanity check rejects it (because it&rsquo;s past 1.8×), the automatic retry tries the same 500 characters again, the five retries all fail in a row, and the chunk gets stuck.</p>
<p>And this did not happen by accident. I used to write those passages that way because I knew the same scripts would later become blog posts, so it made sense to leave the raw URL, the full command, the complete hash, the whole technical detail there for the reader. In Portuguese that worked fine because it matched my original workflow exactly: record the video, then publish the matching text on the blog. What I had never prepared for was the next step, turning that same material into automated English dubbing. I only realized that conflict when the pipeline started failing for real.</p>
<p>I hit this first on ep052, the Ubuntu beginner&rsquo;s guide, where two cues carried <code>github.com</code> URLs, <code>hkp://keyserver.ubuntu.com</code> URLs, and a 40-character GPG hash. Trying to fix that in post-processing is a waste of time. The 1.20× <code>atempo</code> ceiling will never compress 55 seconds into 30, and even if it did, the result would be an unlistenable chipmunk. The fix has to land upstream, in the text itself.</p>
<p>The solution was to rewrite the cue so it describes what the command does instead of showing the literal command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gd">- Run: apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 \
</span></span></span><span class="line"><span class="cl"><span class="gd">-   --recv-keys 0A6A3E7F79F93EF8AAB9E92BAEBB74C8B5A1E44D
</span></span></span><span class="line"><span class="cl"><span class="gi">+ Run the full command in the video description to import the
</span></span></span><span class="line"><span class="cl"><span class="gi">+ signing key from the Ubuntu keyserver.
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The on-screen English caption still shows the full command, so anyone reading the caption sees exactly what they need to type. The dubbed audio describes the intent in spoken language, which is what the TTS can deliver without choking. The instruction to the viewer stays intact, the characters-per-second ratio goes back to the expected 16, and the generated audio fits the original window without needing any stretch or compression in post.</p>
<p>I went hunting for cues like this across the whole batch. I scanned from ep052 through ep146 looking for cues with a high density of &ldquo;hard&rdquo; characters (digits, brackets, operators, long URLs, binary, hex) and ended up rewriting around 30 cues across 13 episodes:</p>
<ul>
<li>ep052 Ubuntu for devs: GitHub URLs and a 40-character GPG hash</li>
<li>ep091 Hello World in C: binary examples</li>
<li>ep095 640kB memory: address wrap, segment-offset</li>
<li>ep106 CodeMiner: spelled-out email address</li>
<li>ep113 Compression: pure binary strings, 75% hard characters</li>
<li>ep115 SQL Server: long sorted number list</li>
<li>ep120 Internet: dotted IP addresses</li>
<li>ep121 Sockets: two JavaScript code blocks</li>
<li>ep122 Proxies: Chrome User-Agent headers</li>
<li>ep123 Secure networking: shell one-liners, Docker flags</li>
<li>ep126 Gentoo: C <code>chroot</code> demo</li>
<li>ep136 Containers: GitHub release URL</li>
<li>ep144 Cryptography: signing key address</li>
</ul>
<p>The pattern was always the same: keep the caption showing the code, URL, or hash verbatim, and rewrite the spoken text so the narrator describes what it does. I could have automated the rewrite by letting an LLM read each cue and propose a substitution on the fly, but it felt safer to review them all by hand. It&rsquo;s exactly the kind of thing where the model decides to &ldquo;improve&rdquo; a command to make it cleaner and ships with broken shell. The manual sweep took about two hours. The automated version would have cost the same time in review, with the bonus anxiety of having shipped a mangled command by accident.</p>
<p>There was also one weird structural case on ep146, about Docker Compose. Two cues had glued themselves together because of a missing blank line in the SRT, and <code>pysrt</code> was treating them as one giant cue. The TTS never even got to process it — the chunker choked first. I fixed it by hand, adding the missing blank line. One-character fix, half an hour to track down.</p>
<h3>Reconstructing truncated SRTs<span class="hx:absolute hx:-mt-20" id="reconstructing-truncated-srts"></span>
    <a href="#reconstructing-truncated-srts" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Another trap showed up when I went to run the full batch: three episodes had English captions that were too short relative to the actual video length. Ep056, the Rails episode, was the most absurd case. Nineteen minutes of caption for an 80-minute video. Sixty-two minutes of content with no caption at all. Ep057 (WSL 2) was missing 14 minutes, and ep068 (Git Direito) was missing 2.</p>
<p>This is a leftover from my old translation workflow. At some point I started hand-revising the caption, stopped partway through, and the <code>.en.srt</code> file got saved truncated at the spot where I stopped. You can&rsquo;t dub an 80-minute video with a 19-minute caption. The chunker produces audio up to where it can read and then simply has no idea what to do with the rest of the video.</p>
<p>The fix became a new script. It diffs the truncated <code>.en.srt</code> against the <code>.pt-orig.srt</code> (the raw YouTube auto-caption, which always covers the whole video), picks up the cues that only exist in Portuguese, sends them to Claude Sonnet 4.6 with a strict JSON schema asking for cue-by-cue translation, and pastes the result back at the tail of the English file. The schema is the detail that matters. When I tried asking for the response as plain text in <code>N|text</code> format, Claude was dropping about 20% of the cues along the way. With a strict JSON schema, the drop rate fell to zero across the three repairs.</p>
<p>The numbers:</p>
<ul>
<li>ep068 Git Direito: +50 cues / 2 minutes reconstructed</li>
<li>ep057 WSL 2: +368 cues / 14 minutes</li>
<li>ep056 Rails: +1445 cues / 62 minutes (plus dropping 5 fake cues the old translator had invented at the tail to plug the gap)</li>
</ul>
<p>After the repairs, the three episodes joined the batch normally and got dubbed like any other. The kind of tool you hope you never need, but when you need it, it&rsquo;s worth writing once and closing the problem for good.</p>
<h3>Automatic emotion tags<span class="hx:absolute hx:-mt-20" id="automatic-emotion-tags"></span>
    <a href="#automatic-emotion-tags" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>While I was testing, I decided to add one more step to the pipeline: an &ldquo;emotion tagger&rdquo; that reads the English SRT before it goes to TTS and inserts tags like <code>[sarcastic]</code>, <code>[thoughtful]</code>, <code>[emphatic]</code>, <code>[deadpan]</code> at spots where a human narrator would naturally shift tone. The idea is to mimic what a professional voice actor would do — hit the emphasis at the right moments — without turning the video into a theater of overblown emotions.</p>
<p>This part is tricky for two reasons. First: letting an LLM loose on your SRT runs a real risk of it &ldquo;improving&rdquo; the text (swapping a word here, rewriting a sentence there) and you ending up with a dub that doesn&rsquo;t match the caption. Second: LLMs love to overdo it. Ask them to tag emotion and they&rsquo;ll put a tag on every other line. To guard against both, I pinned a small allow-list of tags and run a round-trip validation after Claude&rsquo;s response comes back:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">ALLOWED_TAGS</span><span class="p">:</span> <span class="nb">frozenset</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;[sarcastic]&#34;</span><span class="p">,</span> <span class="s2">&#34;[thoughtful]&#34;</span><span class="p">,</span> <span class="s2">&#34;[emphatic]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;[deadpan]&#34;</span><span class="p">,</span> <span class="s2">&#34;[serious]&#34;</span><span class="p">,</span> <span class="s2">&#34;[amused]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;[sighs]&#34;</span><span class="p">,</span> <span class="s2">&#34;[exasperated]&#34;</span><span class="p">,</span> <span class="s2">&#34;[confident]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;[matter-of-fact]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">validate_tagged</span><span class="p">(</span><span class="n">original</span><span class="p">,</span> <span class="n">tagged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Ensure the tagged SRT preserves the original with no drift.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">original</span><span class="p">)</span> <span class="o">!=</span> <span class="nb">len</span><span class="p">(</span><span class="n">tagged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="n">TagValidationError</span><span class="p">(</span><span class="s2">&#34;cue count mismatch&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">o</span><span class="p">,</span> <span class="n">t</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">original</span><span class="p">,</span> <span class="n">tagged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Index and timestamp must match byte for byte.</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">o</span><span class="o">.</span><span class="n">index</span> <span class="o">!=</span> <span class="n">t</span><span class="o">.</span><span class="n">index</span> <span class="ow">or</span> <span class="n">o</span><span class="o">.</span><span class="n">timestamp</span> <span class="o">!=</span> <span class="n">t</span><span class="o">.</span><span class="n">timestamp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">TagValidationError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;cue </span><span class="si">{</span><span class="n">o</span><span class="o">.</span><span class="n">index</span><span class="si">}</span><span class="s2">: header drift&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="c1"># Any tag outside the allow-list invalidates the whole response.</span>
</span></span><span class="line"><span class="cl">        <span class="n">bad</span> <span class="o">=</span> <span class="n">find_disallowed_tags</span><span class="p">(</span><span class="n">t</span><span class="o">.</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">bad</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">TagValidationError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;cue </span><span class="si">{</span><span class="n">o</span><span class="o">.</span><span class="n">index</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">bad</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="c1"># The text with the tags stripped must be byte-identical to</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># the original. If the LLM swapped a word, rewrote anything,</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># validation fails and the response is rejected.</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">strip_tags</span><span class="p">(</span><span class="n">o</span><span class="o">.</span><span class="n">text</span><span class="p">)</span> <span class="o">!=</span> <span class="n">strip_tags</span><span class="p">(</span><span class="n">t</span><span class="o">.</span><span class="n">text</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">TagValidationError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;cue </span><span class="si">{</span><span class="n">o</span><span class="o">.</span><span class="n">index</span><span class="si">}</span><span class="s2">: text drift&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When validation fails, the pipeline throws the response away and re-requests (or, if it keeps failing, ships the SRT untagged). On tag density, the prompt sent to Claude explicitly says: aim for <code>N/10</code> tags in a SRT with N cues, hard floor of 1 tag per 12 cues, hard ceiling of 1 tag per 7 cues, distribution balanced across the four quarters of the episode, and no two tagged cues within 4 cues of each other. That rule set was the sixth iteration — the five before it either blanketed everything in tags or loaded them all into the first quarter.</p>
<h3>Don&rsquo;t let v3 hallucinate<span class="hx:absolute hx:-mt-20" id="dont-let-v3-hallucinate"></span>
    <a href="#dont-let-v3-hallucinate" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A critical detail that only shows up in production: ElevenLabs v3 tends to hallucinate when you send very long blocks of text. I&rsquo;ve seen blocks where the input text was around 1,500 characters and the model generated <strong>nine minutes</strong> of audio when the expected duration was a minute and a half. The model just decides to keep talking on its own, inventing content.</p>
<p>The official ElevenLabs docs recommend keeping each call under 800 characters to avoid this. I went more conservative and cut at 700, always at sentence end (never mid-sentence, because a mid-sentence split sounds horrible when concatenated). On top of that, I bumped <code>stability</code> to 0.9 (Robust mode, more stable but less responsive to emotion tags), turned on <code>apply_text_normalization</code> so the model pronounces numbers and acronyms correctly, and added a sanity check that rejects any generated audio longer than 1.8× the expected duration.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">DEFAULT_VOICE_SETTINGS</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;stability&#34;</span><span class="p">:</span> <span class="mf">0.9</span><span class="p">,</span>          <span class="c1"># Robust mode</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;similarity_boost&#34;</span><span class="p">:</span> <span class="mf">0.95</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;style&#34;</span><span class="p">:</span> <span class="mf">0.0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;use_speaker_boost&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">EXPECTED_CHARS_PER_SEC</span> <span class="o">=</span> <span class="mf">16.0</span>
</span></span><span class="line"><span class="cl"><span class="n">CHUNK_SANITY_MAX_FACTOR</span> <span class="o">=</span> <span class="mf">1.8</span>   <span class="c1"># reject if actual &gt; 1.8× expected</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With those mitigations in place, a 95-minute episode (194 blocks) ran start to finish with zero hallucinations. Exactly one block failed mid-run on a transient 502 error from the API, which the automatic retry caught on the next attempt.</p>
<h3>Mastering for YouTube<span class="hx:absolute hx:-mt-20" id="mastering-for-youtube"></span>
    <a href="#mastering-for-youtube" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>YouTube doesn&rsquo;t publish an official number, but industry consensus is that its playback loudness normalization targets -14 LUFS. Deliver audio louder than that and YouTube turns it down; deliver it quieter and it leaves it alone. To land on the target exactly, the pipeline runs <code>ffmpeg</code>&rsquo;s <code>loudnorm</code> filter in two passes. The first pass measures the entire audio and prints the stats (<code>input_i</code>, <code>input_tp</code>, <code>input_lra</code>, <code>input_thresh</code>) as a JSON blob on stderr. The second pass reads those stats back in and applies a linear static gain to land precisely at -14 LUFS, -1.5 dBTP:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Pass 1: measure</span>
</span></span><span class="line"><span class="cl">ffmpeg -i final_en.mp3 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -af <span class="s2">&#34;highpass=f=80,loudnorm=I=-14:TP=-1.5:LRA=9:print_format=json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -f null -
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Pass 2: apply linear gain with the measured values</span>
</span></span><span class="line"><span class="cl">ffmpeg -i final_en.mp3 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -af <span class="s2">&#34;highpass=f=80,loudnorm=I=-14:TP=-1.5:LRA=9\
</span></span></span><span class="line"><span class="cl"><span class="s2">:measured_I=-18.3:measured_TP=-3.1:measured_LRA=5.4\
</span></span></span><span class="line"><span class="cl"><span class="s2">:measured_thresh=-28.7:offset=1.2:linear=true&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -ar <span class="m">48000</span> -ac <span class="m">2</span> -c:a pcm_s16le final_en_mastered.wav</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Two passes instead of one because single-pass mode runs in dynamic compression and ends up &ldquo;pumping&rdquo; the gain during silent stretches. With <code>linear=true</code> on pass 2, the gain stays static on top of the pass-1 measurements, so no pumping. The result lands within ±0.1 LU of the target, which is effectively inaudible. The <code>highpass=f=80</code> filter in front kills HVAC rumble and mains hum below 80 Hz, which the human ear doesn&rsquo;t catch but will move peak measurements around.</p>
<h3>The point where I thought it was done. And it wasn&rsquo;t.<span class="hx:absolute hx:-mt-20" id="the-point-where-i-thought-it-was-done-and-it-wasnt"></span>
    <a href="#the-point-where-i-thought-it-was-done-and-it-wasnt" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is where the most annoying, and most educational, part of the project starts.</p>
<p>I really thought I had nailed it on the first big pass. The pipeline closed all 146 episodes, I uploaded everything, and it looked done. The durations matched, the files were all there, and the whole process had finally crossed the complete batch of nearly 96 hours of video. But it was exactly after that upload that I started noticing problems I simply had not anticipated.</p>
<p>The first scare was the jingle situation. I already knew many Akitando episodes begin with that short instrumental intro before I start talking, but I underestimated how much trouble it would cause. YouTube&rsquo;s <code>.srt</code> is almost useless here, because a jingle is not speech. Sometimes it marks <code>[Music]</code>, sometimes it gives you nothing useful, sometimes the window is just wrong. In the first assembly pass, some episodes lost the jingle entirely, some got it doubled, and in others the source-fill from the original audio already contained the right jingle and my splice came in on top of it, pushing everything else forward. This was the kind of bug you only catch when you sit down and actually listen to the final mastered file, not when you just look at total duration.</p>
<p>The fix was to stop hoping the subtitle would tell me where the jingle was and go straight to the source. I built a detector using the original video audio itself plus reference jingles, with an audit and repair pass on top of the mastered outputs already generated. That eventually became a small subsystem of its own: detector, audit, cached reassembly, exact-window repair. At the end of that detour, the set of changed episodes collapsed into a well-defined reupload bucket and the jingle problem finally came under control. It was one of those classic production moments: the first solution looked good enough until it hit the whole archive.</p>
<p>Then came the second punch, and this one was more expensive: I had forgotten a basic fact about YouTube subtitles. SRT is built to be read on screen, not to become spoken script. So an idea that was a full paragraph in my original text showed up split into several small cues, each with its own micro time window. In the first version of the pipeline I was using those cues as the generation unit for ElevenLabs. The result was TTS audio that was coherent locally, but the final assembly inserted artificial silences in the middle of sentences. Technically aligned, humanly weird.</p>
<p>Fixing that hurt because it meant walking several steps back. Instead of respecting the cue structure of the SRT, I had to go back to my original scripts and rebuild chunking around the paragraphs I had actually read when recording the videos. Almost every Akitando episode has a matching blog post with a <code>## Script</code> section, and that is what saved the project. Because I had been meticulous back when I made those videos — writing the scripts first, reading exactly that on camera, and archiving both the videos and the scripts — I had a source of truth. That made it possible to map the script paragraphs onto the timings in the <code>.pt-BR.srt</code>, regroup the <code>.en.srt</code> on top of that, and regenerate the chunks in the right structure.</p>
<p>The problem is that this destroys the previous cache. Even when the text barely changes, if the chunk boundary changes then the TTS call changes too: different context, different prosody, different beginning, different ending. So this was not a touch-up. It was a full regeneration of everything again. That was the point where the cost started escaping my original estimate.</p>
<p>And just when I thought now it really was over, the third hit landed: external clips. Many videos on the channel include inserted footage from other sources. The Ruby on Rails episode, for example, has 37signals clips, Apple material, commercials, external inserts in the middle of the narrative. YouTube&rsquo;s <code>.srt</code> did not know what to do with that. In some places it simply hallucinated text, in others the timings were completely wrong, and in others the original speech had nothing to do with the subtitle I was using as ground truth. Again: if you only look at the automation, it looks fine; if you actually listen to the final master, it isn&rsquo;t.</p>
<p>The fix, again, was to leave the subtitle behind and go back to the original material. I built an external-clip window detector based on alignment gaps between transcript, <code>.pt-BR.srt</code>, speechless cues and chunks that crossed those windows. Then, instead of trying to dub what made no sense to dub, the process started recovering the original audio from the source video exactly in those stretches and filling the master with the correct source audio there. That&rsquo;s what fixed the 37signals, Apple Education, Mac vs PC commercial and other inserted segments I had forgotten were even there.</p>
<p>That was the biggest lesson from the second half of this project: automation takes you very far, but the full archive always has more dark corners than you imagine on the first pass.</p>
<h2>The dubbing batch numbers (and why it hurt more than I expected)<span class="hx:absolute hx:-mt-20" id="the-dubbing-batch-numbers-and-why-it-hurt-more-than-i-expected"></span>
    <a href="#the-dubbing-batch-numbers-and-why-it-hurt-more-than-i-expected" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I need to correct myself again. I didn&rsquo;t just miscalculate the first bill. I also overestimated how confident I should have been after the first full run.</p>
<p>When I upgraded to ElevenLabs Business, I thought I would finally have plenty of headroom. In practice, I didn&rsquo;t. The latest dashboard screenshot shows <strong>10,046,279 credits consumed out of a limit of 11,000,000</strong>, leaving <strong>953,721</strong>:</p>
<p><img src="/2026/04/09/como-a-elevenlabs-nao-foi-morta-pelo-qwen3-tts/elevenlabs-credits-dashboard.png" alt="ElevenLabs dashboard showing 10,046,279 credits consumed out of 11,000,000"  loading="lazy" /></p>
<p>In plain English: I burned through more than 91% of the Business workspace limit. And that helps explain what &ldquo;10 million credits&rdquo; really means in this context. It does not mean &ldquo;I rendered 146 episodes once&rdquo;. It means rendering 146 episodes, discovering a structural problem, going back, fixing it, regenerating chunks, discovering another structural problem, going back again, rebuilding masters, auditing jingles, repairing external clip windows, uploading again. Every extra round costs money. And when the change invalidates the cache, it costs a lot.</p>
<p>The cost also doesn&rsquo;t stop at ElevenLabs. All the subtitle curation, problematic cue rewrites, truncated SRT reconstruction, jingle audits, external clip detector work and the rest ran through Claude Code on the Claude Max 20× plan. Combined with the mass blog translation to English that I described in the <a href="/en/2026/04/09/20-years-of-blogging-ai-finally-translated-everything/">anniversary post</a>, the Claude Max 20× weekly limit hit 100%, with more than BRL 300 in extra usage on top. If you think generative AI becomes free once you&rsquo;ve signed up for the plan, the invoices disagree.</p>
<p>Even so, I still think it&rsquo;s worth it. We&rsquo;re talking about 146 episodes, almost 96 hours of technical content, more than 5 years of channel archive. Doing that manually in a studio, with an actor, direction, pickups and human review on everything, would cost a completely different order of magnitude.</p>
<p>What changed was how I think about the project. At the start I was looking at this as &ldquo;one very large batch&rdquo;. In the end it was something else. It was digging through a whole archive, finding hidden exceptions, fixing old rot, rebuilding masters, rerendering, relistening.</p>
<p>The first full batch took a little under two days and made me think I had solved everything. I hadn&rsquo;t. The second pass, with paragraph-aware chunking, was already expensive. The third, with jingle repairs and mastered-output fixes, cost more again. And the fourth hit came from the external clips I had not modeled correctly in the first automation pass.</p>
<p>But now, finally, I think it&rsquo;s done. Or rather: I hope it&rsquo;s done. Everything has been uploaded again, for the last time. It isn&rsquo;t perfect in the absolute sense of the word. It&rsquo;s as good as I can get it with scripts, audits and automation, without descending into manual-intervention hell episode by episode.</p>
<p>And if all of this was possible, it&rsquo;s because years ago I had the discipline to produce the videos the right way: write first, read the script when recording, and archive the materials afterward. Without the original videos and without the original scripts, I would have had no way to discover where the <code>.srt</code> was lying, where the timing was wrong, where the external clip started, where the original paragraph began and ended. That old organization is what made it possible to repair the project now.</p>
<p>So yes: the dub for all 146 episodes is done. Not perfect. But closed. At least, I sincerely hope this time it really is.</p>
<h2>The first test, watch it<span class="hx:absolute hx:-mt-20" id="the-first-test-watch-it"></span>
    <a href="#the-first-test-watch-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I made a test video to prove the thing works. Two caveats up front: first, the embed below already loads with English captions on by default, so that side is solvable via URL. Second, the audio is annoying: YouTube removed the audio track selector from embedded players back around March of this year. So inside the embed you won&rsquo;t find that option in the gear menu at all, it just plays in Portuguese. To actually hear the English dub, click <strong><a href="https://www.youtube.com/watch?v=QNLd8TZ_JQc"target="_blank" rel="noopener">watch directly on YouTube</a></strong> — the main YouTube watch page still has the audio track switcher in the gear menu.</p>
<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/QNLd8TZ_JQc?cc_load_policy=1&amp;cc_lang_pref=en&amp;hl=en"
    title="Akitando dubbed in English (test)"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>
<p>If you&rsquo;re used to YouTube&rsquo;s automatic dubbing or to the AI dubbing on TikTok, compare them. The difference is glaring. The voice is mine, with a worked-over American accent, and the sync with the original video stays within 1 to 2% cumulative drift, practically imperceptible over 95 minutes of video.</p>
<h2>And the missing piece: translating the thumbnails<span class="hx:absolute hx:-mt-20" id="and-the-missing-piece-translating-the-thumbnails"></span>
    <a href="#and-the-missing-piece-translating-the-thumbnails" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>While I was closing this post, the penny dropped: I&rsquo;d left a loose end. All 146 thumbnails on my channel are in Portuguese, many of them with big all-caps titles like &ldquo;9 DICAS PARA PALESTRANTES&rdquo; or &ldquo;7 RECOMENDAÇÕES DE SHOWS PARA PESSOAS DE TECH&rdquo;. Perfect English audio doesn&rsquo;t help if the image in the search results is an illegible block of text to anyone who doesn&rsquo;t read Portuguese. YouTube lets you upload an alternative thumbnail per language, so I needed a way to generate English versions, keeping the rest of the art identical and swapping only the text.</p>
<p>The tool uses two pieces. <strong><code>yt-dlp</code></strong> is the modern fork of the old <code>youtube-dl</code>, a Python CLI that downloads anything from YouTube (videos, audio, subtitles, thumbnails) without needing an API key. <strong>Nano Banana Pro</strong> (<code>nano-banana-pro-preview</code> on the API) is Google Gemini&rsquo;s latest image-editing model — you feed it an input image with a prompt and it returns an edited image, preserving the rest of the composition when you ask it to touch only a specific part.</p>
<p>The pipeline has two steps. Step 1: <code>yt-dlp</code> grabs the thumbnail as a <code>.jpg</code>, no video download:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">yt-dlp --skip-download <span class="se">\
</span></span></span><span class="line"><span class="cl">       --write-thumbnail <span class="se">\
</span></span></span><span class="line"><span class="cl">       --convert-thumbnails jpg <span class="se">\
</span></span></span><span class="line"><span class="cl">       -o <span class="s2">&#34;thumbnails/originals/&lt;slug&gt;/&lt;video_id&gt;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">       <span class="s2">&#34;https://www.youtube.com/watch?v=&lt;video_id&gt;&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Step 2: send each image to Gemini Nano Banana Pro with a prompt that&rsquo;s a rigid contract of hard requirements, not just a &ldquo;translate this&rdquo;:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">TASK:
</span></span><span class="line"><span class="cl">Detect every piece of Portuguese text visible on this image and
</span></span><span class="line"><span class="cl">translate it to clear, natural American English. Replace the
</span></span><span class="line"><span class="cl">Portuguese text with the English translation in the same visual
</span></span><span class="line"><span class="cl">position.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">STRICT REQUIREMENTS — follow every one:
</span></span><span class="line"><span class="cl">- Preserve EVERY non-text element identically: face, pose,
</span></span><span class="line"><span class="cl">  expression, background, color palette, lighting, icons, logos,
</span></span><span class="line"><span class="cl">  decorative shapes, borders, layout. Only the text changes.
</span></span><span class="line"><span class="cl">- The English translation must be IDIOMATIC and CONFIDENT — not
</span></span><span class="line"><span class="cl">  a literal word-for-word rewrite. It&#39;s a YouTube thumbnail for a
</span></span><span class="line"><span class="cl">  tech audience, so use punchy phrasing a native English-speaking
</span></span><span class="line"><span class="cl">  tech YouTuber would write.
</span></span><span class="line"><span class="cl">- Match the original text&#39;s font family, weight, size, color,
</span></span><span class="line"><span class="cl">  stroke outline, drop shadow, and any decorative treatment.
</span></span><span class="line"><span class="cl">- If there is no Portuguese text at all on the image, return the
</span></span><span class="line"><span class="cl">  original image unchanged.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The &ldquo;return the original image unchanged&rdquo; clause is what saves the earliest episodes, which don&rsquo;t have overlay text, just my face. Without it, the model would invariably try to &ldquo;improve&rdquo; the composition, swap the lighting, and so on.</p>
<p>Here&rsquo;s the result on two examples (episodes 10 and 11):</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0;">
<div><strong>Original (PT)</strong><br><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_thumb_ep010_pt.jpg" alt="Original Portuguese thumbnail: 7 Recomendações de Shows para pessoas de Tech" style="width: 100%;"></div>
<div><strong>Translated (EN)</strong><br><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_thumb_ep010_en.jpg" alt="Translated English thumbnail: 7 TV Shows You Must Watch If You're In Tech" style="width: 100%;"></div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0;">
<div><strong>Original (PT)</strong><br><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_thumb_ep011_pt.jpg" alt="Original Portuguese thumbnail: 9 Dicas para Palestrantes: venda sua caneta" style="width: 100%;"></div>
<div><strong>Translated (EN)</strong><br><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_thumb_ep011_en.jpg" alt="Translated English thumbnail: 9 Tips for Speakers: sell your pen" style="width: 100%;"></div>
</div>
<p>Notice the detail on episode 10. The model didn&rsquo;t translate &ldquo;7 Recomendações de Shows para Pessoas de Tech&rdquo; literally into &ldquo;7 Show Recommendations for Tech People&rdquo; the way Google Translate would. It rewrote it as &ldquo;7 TV Shows You Must Watch If You&rsquo;re In Tech&rdquo;, the way an actual American tech YouTuber would headline the video. The rest of the image stays pixel-for-pixel identical. On episode 11, &ldquo;9 DICAS PARA PALESTRANTES: venda sua caneta&rdquo; becomes &ldquo;9 TIPS FOR SPEAKERS: sell your pen&rdquo;, with the font, all-caps treatment and position preserved. If you didn&rsquo;t know the original was in Portuguese, you couldn&rsquo;t guess the second image is an automatic AI edit.</p>
<p>Cost on the Gemini side: a few cents per image, a handful of dollars total for the whole batch. Trivial next to the $1,500+ on the audio dub. With the thumbnail sorted, the channel&rsquo;s English conversion is finally buttoned up — ElevenLabs v3 cloning the voice over hand-curated English <code>.srt</code> files, and Nano Banana Pro editing the image so the title matches the audio.</p>
<h2>The conclusion, which is the title of this post<span class="hx:absolute hx:-mt-20" id="the-conclusion-which-is-the-title-of-this-post"></span>
    <a href="#the-conclusion-which-is-the-title-of-this-post" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When Qwen3 TTS dropped, the hype was calling it an &ldquo;ElevenLabs killer&rdquo;. I spent weeks trying to live with that premise in practice, with real content shipping every week. And what I found is that open source TTS is still miles behind ElevenLabs. The gap is large, and it&rsquo;s a gap that shows up precisely when you step off the 5-second demo tweet and put the model to work on a real 30-minute weekly podcast.</p>
<p>In practice, Qwen3 doesn&rsquo;t even beat ElevenLabs&rsquo; v2 model. v3, which is the current one, is still a step above v2. The prosody comes out better, the inline emotion tags work in Portuguese and English without effort, and the API stays up without you maintaining a server. The cost per character is a bit higher, but for my current volume it sits comfortably within budget and gives me back the hours I was spending on GPU babysitting.</p>
<p>The lesson here is the same one the LLM crowd is learning slowly. A 30-second demo tweet is one thing, production is a completely different thing. Open source has its niche, mainly when you have sensitive data that can&rsquo;t leave the house, or when you have tons of idle GPU and little recurring budget. But for serious TTS use in a commercial product, here in April 2026, ElevenLabs is still untouchable. Qwen3 didn&rsquo;t kill anyone.</p>
<p>And don&rsquo;t forget: subscribe to <a href="https://open.spotify.com/show/7MzG2UB7IAkC3GAwEXEIVD"target="_blank" rel="noopener">The M.Akita Chronicles on Spotify</a> so you don&rsquo;t miss new episodes like this one, made with the new pipeline.</p>
]]></content:encoded><category>ai</category><category>llm</category><category>tts</category><category>elevenlabs</category><category>qwen</category><category>themakitachronicles</category></item><item><title>20 Years of Blogging: Translating Everything to English</title><link>https://akitaonrails.github.io/en/2026/04/09/20-years-of-blogging-ai-finally-translated-everything/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/09/20-years-of-blogging-ai-finally-translated-everything/</guid><pubDate>Thu, 09 Apr 2026 08:00:00 GMT</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_20years_blog_celebration.png" alt="Big bold glowing “20 ANOS” in neon-tech style, with champagne flutes, sparklers, confetti, and silhouettes of a CRT monitor and code window in the background, purple palette with cyan and magenta accents" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;Four days ago, April 5th 2026, my blog turned 20. Yeah, I&amp;rsquo;m late on the anniversary post. There&amp;rsquo;s a reason, and that reason is what this piece is about.&lt;/p&gt;
&lt;p&gt;I started in 2006 on Google&amp;rsquo;s Blogspot, like most people did back then. Later I migrated to a Rails 2.0 CMS that already existed, went through Typo3, and in 2012 I &lt;a href="https://akitaonrails.github.io/2025/09/10/meu-novo-blog-como-eu-fiz/"&gt;built my own engine from scratch on ActiveAdmin&lt;/a&gt;, which I dragged along from Rails 3 all the way to Rails 7. Only recently, in September 2025, I finally abandoned that custom engine and moved to &lt;a href="https://akitaonrails.github.io/2025/09/10/meu-novo-blog-como-eu-fiz/"&gt;Hugo with the Hextra theme&lt;/a&gt;, which is what this post is running on. Twenty years carrying the same posts across Textile, migrating from Less to Sass, trading Liquid for Markdown, and trimming obsolete junk along the way.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_20years_blog_celebration.png" alt="Big bold glowing “20 ANOS” in neon-tech style, with champagne flutes, sparklers, confetti, and silhouettes of a CRT monitor and code window in the background, purple palette with cyan and magenta accents"  loading="lazy" /></p>
<p>Four days ago, April 5th 2026, my blog turned 20. Yeah, I&rsquo;m late on the anniversary post. There&rsquo;s a reason, and that reason is what this piece is about.</p>
<p>I started in 2006 on Google&rsquo;s Blogspot, like most people did back then. Later I migrated to a Rails 2.0 CMS that already existed, went through Typo3, and in 2012 I <a href="/2025/09/10/meu-novo-blog-como-eu-fiz/">built my own engine from scratch on ActiveAdmin</a>, which I dragged along from Rails 3 all the way to Rails 7. Only recently, in September 2025, I finally abandoned that custom engine and moved to <a href="/2025/09/10/meu-novo-blog-como-eu-fiz/">Hugo with the Hextra theme</a>, which is what this post is running on. Twenty years carrying the same posts across Textile, migrating from Less to Sass, trading Liquid for Markdown, and trimming obsolete junk along the way.</p>
<h2>Two decades, five eras<span class="hx:absolute hx:-mt-20" id="two-decades-five-eras"></span>
    <a href="#two-decades-five-eras" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Something few programmers stop to think about is how much the tech world changes in 20 years. I talked about this at length on <a href="/2019/01/30/akitando-37-a-dimensao-do-tempo-para-iniciantes-em-programacao-serie-comecando-aos-40/">Akitando #37 — The Dimension of Time</a>. When I opened this blog in April 2006, I already had 10 years of experience behind me. I&rsquo;d watched the dot-com bubble rise and blow up in 2000. And from that point on I saw a few more big ones: the rise of social networks (Orkut, Facebook, Twitter), the smartphone and mobile app revolution in 2008, the 2008 financial crisis, Bitcoin being born in 2009, the cloud and SaaS era, and now the generative AI era.</p>
<p>I watched those waves crash for real. Each one completely changed how we work and who survives professionally. This is the kind of thing you only see from a distance, after you&rsquo;ve stacked up a few turns of the wheel.</p>
<p>My own career went through some violent pivots. I told the whole story on <a href="/2019/09/12/akitando-61-meus-primeiros-5-anos-1990-1995/">My First 5 Years (1990-1995)</a>, but the short version: I started in multimedia agencies, moved into <a href="/2018/12/26/akitando-34-voce-nao-sabe-nada-de-enterprise-conhecendo-a-sap">enterprise consulting working on SAP</a>, dropped all of it in 2006 to jump into Ruby on Rails and open source, spent a decade running events — mainly Rubyconf Brasil from 2007 to 2018 — then shut the conference down and started the <a href="https://www.youtube.com/@Akitando"target="_blank" rel="noopener">Akitando YouTube channel</a>, which grew past 500k subscribers. In the middle of all that there was the pandemic that rearranged everyone&rsquo;s life. And since the beginning of 2025 I&rsquo;ve turned into a full-time AI researcher and user, running models locally and breaking things with Claude Code until they work.</p>
<p>Checking the <a href="/en/tags/ai/">/en/tags/ai/</a> tag, I wrote 51 AI-related posts between 2025 and 2026 alone — 19 in 2025 and 32 more in 2026, which has barely started. Between 2018 and 2024, the blog was mostly the transcript archive for my Akitando videos. I&rsquo;d record the video, transcribe it, dump it on the blog, and people would read it there. But once I started writing actively again, I noticed there was a loyal audience that never went anywhere, even through the quiet years. That feedback is what made me start <a href="/en/tags/themakitachronicles/">The M.Akita Chronicles</a>, a more personal series about the behind-the-scenes of recent projects.</p>
<h2>The problem I never solved<span class="hx:absolute hx:-mt-20" id="the-problem-i-never-solved"></span>
    <a href="#the-problem-i-never-solved" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For those 20 years, one thing kept nagging at me. Since everything was in Portuguese, it was inaccessible to anyone who didn&rsquo;t read the language. I&rsquo;d been getting messages for years from Brazilian readers living abroad (Portugal, US, Japan, Germany, Canada) asking for some English version they could share with their gringo coworkers. Stuff like &ldquo;look, I&rsquo;d show this piece to my team, but it&rsquo;s only in Portuguese.&rdquo;</p>
<p>I always said &ldquo;one day I&rsquo;ll translate it.&rdquo; And I never did. Why? Because we&rsquo;re talking hundreds of posts. I spent the last couple of days looking at the numbers: 727 Portuguese <code>index.md</code> files in the repo. Translating by hand, one by one, would take weeks if not months of dedicated work. I was never going to find the stamina for that. And every year that passed, more posts piled onto the queue.</p>
<p>The ironic part is that some blog posts were originally born in English. During 2017 and 2018 I had decided to write English-first, trying to reach an international audience. A whole interview series — the &ldquo;<a href="/en/2008/01/09/chatting-with-hal-fulton/">chatting-with</a>&rdquo; ones with folks like Hal Fulton, Scott Hanselman, Chris Wanstrath (GitHub), Blaine Cook (former Twitter), Adam Jacob (Chef) — were born in English and stayed there. The missing piece was the reverse: take the Portuguese posts and translate them the other way.</p>
<h2>The weekend that paid off 20 years of technical debt<span class="hx:absolute hx:-mt-20" id="the-weekend-that-paid-off-20-years-of-technical-debt"></span>
    <a href="#the-weekend-that-paid-off-20-years-of-technical-debt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This past Monday I opened Claude Code inside the blog directory. And asked it to translate everything to English. That was literally it.</p>
<p>It kicked off Monday, April 6th, around 6:30pm. I let it run. Went to sleep. Woke up Tuesday, it kept running. Slept again. Wednesday morning it was done. Counting from the first translation commit to the last, it was something like 39 hours of elapsed time. Not continuous, of course — there were nights, lunches, a coffee break or two. In practice, one long working weekend.</p>
<p>The result is sitting in the git repo, in commits anyone can inspect on the <a href="https://github.com/akitaonrails/akitaonrails.github.io"target="_blank" rel="noopener">public blog repository</a>. Scan the log and you can see the cadence: batch of 2008 QCon posts. Batch of 2009 RailsConf. Batch of 2011 Objective-C. All of 2012. The 2015 Elixir series. All of 2016, 2017, 2018. Batch after batch, organized by year and by series. More than 80 commits tagged <code>i18n:</code> or <code>EN translation:</code> between the night of the 6th and the morning of the 8th. By the time I&rsquo;m writing this post, 354 <code>index.en.md</code> files are live against the 727 original <code>index.md</code>. Almost half the blog translated in one shot.</p>
<p>And look, ninety percent of it was smooth sailing. The translation came out good, natural, faithful to my voice in the original — I honestly didn&rsquo;t expect it to work this well. Claude Code respects the tone of the source if you give it solid voice and style guidance, and it respects technical ideas without &ldquo;correcting&rdquo; anything you wrote. Worst case, you review a few paragraphs. Best case, you read the English version and it sounds like you wrote it directly in the other language.</p>
<p>A quick aside on the Claude side of the bill. I&rsquo;m a Max 20x subscriber on Anthropic, which is the heavy-use tier built for people who hammer Claude Code all day. I&rsquo;d never hit the ceiling of that plan before, not even in my most intense vibe coding sessions. This translation weekend was the first time I genuinely blew past the Max 20x limit and kept going into the &ldquo;extra usage&rdquo; mode (Anthropic charges on top when you go over the monthly plan ceiling).</p>
<p>For a sense of the damage, here&rsquo;s my account dashboard this morning:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260409_claude_usage_max20x.png" alt="Max 20x plan with 91% of the weekly ‘all models’ quota used up and R$280.88 already spent on extra usage"  loading="lazy" /></p>
<p><strong>91% of the weekly &ldquo;all models&rdquo; quota burned through</strong>, already into the extra-usage mode, R$280.88 (about $50 USD) spent on top of the flat monthly subscription, and there&rsquo;s still a good chunk of the cycle left. It tracks: read the full post, generate the translation, run the humanizer on it, repeat hundreds of times in a row. The token volume is massive. And today, while I&rsquo;m writing this piece, Claude Code keeps hitting me with <code>API Error: Request rejected (429) · Rate limited</code> more and more often, which also tracks: probably some combo of my own plan quota topping out and Anthropic applying general backpressure, because I can&rsquo;t be the only one going nuclear with the tool these days. Fine, whatever. That&rsquo;s the price of using the tool hard when it actually mattered.</p>
<p>The other ten percent was a different story.</p>
<h2>When commercial LLMs say &ldquo;no&rdquo;<span class="hx:absolute hx:-mt-20" id="when-commercial-llms-say-no"></span>
    <a href="#when-commercial-llms-say-no" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Half a dozen older posts flat out refused to translate. Both Claude and GPT, through their APIs, would bounce with a 400 error and a content policy message. I tried multiple times, fresh sessions, clean context. Nothing.</p>
<p>The hypothesis is simple: those posts touched topics that automated content moderation flagged. A 2009 post about Steve Jobs&rsquo;s 2005 Stanford commencement speech (which mentions cancer — Jobs was talking about the terminal diagnosis he&rsquo;d received). A couple of old Ayn Rand chapters I translated years ago about rights of man and argument by intimidation. An anti-Nazi post that ironically got blocked, probably just because the word &ldquo;nazi&rdquo; showed up, even though the context was critical. A post about the money speech from Atlas Shrugged. And a democracy-and-ethics essay that systematically broke every attempt.</p>
<p>The ironic part here is that the only way to get those posts translated was using an open source model. I loaded up <a href="https://qwen.ai"target="_blank" rel="noopener">Qwen 3.5 35B</a> in llama-swap, running locally, no corporate policy filters. The model read them, understood the context, and translated everything without drama. It&rsquo;s the same model I <a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">tested extensively in my last LLM benchmark</a>, and which I rate as one of the best open source models available right now.</p>
<p>So yeah — a Chinese model translated posts that Western models refused. I can&rsquo;t help finding this slightly hilarious. Oh, and of course, I&rsquo;m not allowed to speak ill of China (sarcasm). Commercial LLMs will always have the corporate-policy-and-preemptive-censorship problem applied with a fairly thick ruler. It&rsquo;s a real trade-off for anyone using them in production: you get fluency and reasoning power, you lose control the moment the topic brushes up against whatever the company decided is sensitive.</p>
<h2>The reverse case: 2017-2018 translated back into Portuguese<span class="hx:absolute hx:-mt-20" id="the-reverse-case-2017-2018-translated-back-into-portuguese"></span>
    <a href="#the-reverse-case-2017-2018-translated-back-into-portuguese" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>While I was dialing in Claude Code to generate the English content, I figured I might as well solve the symmetric problem. The posts I had originally written in English during 2017 and 2018, plus the whole &ldquo;chatting-with&rdquo; interview series from 2008, were sitting there without a Portuguese version. Claude Code ran the reverse: read the English, wrote the Portuguese. So if you never read the English originals (since most of my audience is Brazilian anyway), now you have access too. Take a look at the <a href="/en/off-topic/">Off-Topic section</a> to see what showed up that&rsquo;s new.</p>
<p>On top of that, Claude Code also updated my <code>generate_index.rb</code> script so it understands the blog&rsquo;s bilingual structure and generates two separate indexes, one in Portuguese and one in English. The PT/EN toggle in each post&rsquo;s footer shows up automatically whenever an <code>index.en.md</code> sibling exists. All nicely plugged into Hugo, using its native multilingual pattern.</p>
<h2>The bigger point<span class="hx:absolute hx:-mt-20" id="the-bigger-point"></span>
    <a href="#the-bigger-point" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the takeaway I wanted to leave in this anniversary post. Translation at scale, hundreds of articles, your content, in your voice, respecting the tone of the original, used to be an expensive and miserable problem. Now it&rsquo;s a weekend problem. This is already live — you&rsquo;re reading the result. The barrier that existed to reaching audiences outside Brazil became cheap enough that I don&rsquo;t have an excuse anymore.</p>
<p>Alright, so let&rsquo;s wrap the anniversary recap. 20 years. 727 Portuguese posts, built across five different eras of technology. 354 new English posts generated over a weekend. Half the blog is now bilingual. And the rest will follow. What&rsquo;s missing is mostly old ActiveAdmin posts, Rails 2 stuff, and obsolete tips that honestly deserve to be deleted or left untranslated on purpose.</p>
<p>If you&rsquo;ve got a gringo friend who&rsquo;s tired of seeing you post Portuguese links and needs an excuse to send them something of mine — send it. The main AI posts, the <a href="/en/tags/themakitachronicles/">Chronicles</a>, benchmarks, rants, and a good chunk of the historical archive are already available in English at the same domain, just flip the toggle in the footer. Thank you to everyone who&rsquo;s followed this blog for these twenty years. There are readers here who&rsquo;ve known me since the Blogspot days. You know who you are.</p>
<p>Here&rsquo;s to another year.</p>
]]></content:encoded><category>blog</category><category>ai</category><category>llm</category><category>themakitachronicles</category><category>off-topic</category></item><item><title>Is RAG Dead? Long Context, Grep, and the End of the Mandatory Vector DB</title><link>https://akitaonrails.github.io/en/2026/04/06/rag-is-dead-long-context/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/06/rag-is-dead-long-context/</guid><pubDate>Mon, 06 Apr 2026 11:00:00 GMT</pubDate><description>&lt;p&gt;This is one of those itches I can&amp;rsquo;t scratch. Back in the early LLM days, around 2022/2023, we had 4k of context on GPT 3.5, 8k if you were lucky, 32k was a luxury. To do anything with a real document you had no choice: chop the text into pieces, generate embeddings, throw them in a vector database, do similarity search, grab the top-5 chunks, and pray the right ones came back.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This is one of those itches I can&rsquo;t scratch. Back in the early LLM days, around 2022/2023, we had 4k of context on GPT 3.5, 8k if you were lucky, 32k was a luxury. To do anything with a real document you had no choice: chop the text into pieces, generate embeddings, throw them in a vector database, do similarity search, grab the top-5 chunks, and pray the right ones came back.</p>
<p>Then it became an industry. Pinecone, Weaviate, Qdrant, Chroma, Milvus, pgvector, LangChain, LlamaIndex, Haystack. Tutorials everywhere, &ldquo;build your chatbot with your PDFs,&rdquo; entire consultancies feeding off this. It became the &ldquo;hello world&rdquo; of applied LLMs: document → chunk → embed → vector DB → query.</p>
<p>Today, in April 2026, Claude Opus 4.6 has 1 million tokens of context. Sonnet 4.6 too. Gemini 3.1 Pro too. GPT 5.4 has a smaller window but still in the comfortable range, in the hundreds of thousands. And some models already have experimental 2M token modes. The question that keeps nagging at me: what on earth do I need a vector stack for, to solve a problem that fits inside the model&rsquo;s window?</p>
<p>And there&rsquo;s more: vector databases have real problems nobody wants to talk about. False neighbors. Arbitrary chunking that splits a definition from its usage. Embeddings that age badly. Not to mention that when the result is wrong, you have absolutely no idea why.</p>
<p>The thesis I&rsquo;ve been chewing on is simple: in most cases, a well-aimed <code>grep</code> plus a generous context window beats a full RAG stack. It&rsquo;s cheaper, it&rsquo;s easier to maintain, and when it breaks you can actually debug it. Let&rsquo;s break this down.</p>
<h2>What the Claude Code leak showed<span class="hx:absolute hx:-mt-20" id="what-the-claude-code-leak-showed"></span>
    <a href="#what-the-claude-code-leak-showed" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before we get into the theory, let me bring up something that happened a few days ago that backs this whole argument up. On March 31, 2026, Anthropic, by accident, published version 2.1.88 of the <code>@anthropic-ai/claude-code</code> package on npm with a nearly 60 MB source map attached, and roughly 512,000 lines of TypeScript from their internal tool leaked into the wild. I already <a href="/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/">wrote about the incident last week</a>, with more detail on what showed up in the code.</p>
<p>The part that matters for this discussion is Claude Code&rsquo;s memory system. Instead of dumping everything into a vector DB, the architecture has three layers. There&rsquo;s a <code>MEMORY.md</code> that stays permanently loaded in context, but it doesn&rsquo;t hold any actual data: it&rsquo;s just an index of pointers, around 150 characters per line, kept under 200 lines and about 25 KB. The real facts live in &ldquo;topic files&rdquo; that get pulled on demand when the agent needs them. And the raw transcripts from previous sessions are never reloaded whole, only searched with grep, hunting for specific identifiers. No embedding. No Pinecone. Just write discipline (topic file first, index after) and lexical search. That&rsquo;s it.</p>
<p>Claude Code&rsquo;s main loop also has a tiered system for handling a context that&rsquo;s filling up. As <a href="/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/">I detailed in the previous post</a>, there are five different context compaction strategies, with names like <code>microcompact</code> (clears old tool results based on age), <code>context collapse</code> (summarizes long stretches of conversation), and <code>autocompact</code> (which fires when the context gets close to the limit). The CLAUDE.md file, which a lot of people thought was just a convention, is first-class in the architecture: the system re-reads it at every iteration of the query.</p>
<p>What this tells me: the best coding agent on the market right now, built by the company selling the most expensive model out there, <strong>does not use a vector DB</strong>. It uses files on disk, a markdown index, lexical search, and smart compaction strategies for when the context overflows. They could&rsquo;ve slapped embeddings on top, they have the money to run whatever they wanted, and they chose not to. The reason, in my reading, is exactly what this post is arguing: to retrieve text from files you control, with generous context available, a vector DB is dead weight. Better to invest in compacting the window you already have than indexing everything into an external store.</p>
<p>There&rsquo;s a curious security detail that came along with the leak: people noticed the compaction pipeline has a vulnerability they&rsquo;re calling &ldquo;context poisoning.&rdquo; Content that looks like an instruction, coming from a file the model reads (say, a CLAUDE.md from a cloned repo), can end up being preserved by the compaction model as if it were &ldquo;user feedback,&rdquo; and the next model takes that as a real user instruction. It&rsquo;s a new attack vector. But that&rsquo;s a topic for another post.</p>
<h3>The &ldquo;Dream&rdquo; system and memory consolidation<span class="hx:absolute hx:-mt-20" id="the-dream-system-and-memory-consolidation"></span>
    <a href="#the-dream-system-and-memory-consolidation" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>But what really caught my eye for the RAG debate, which I <a href="/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/">unpacked in detail last week</a>, is the system called <code>autoDream</code>. It&rsquo;s a forked subagent, with read-only bash access to the project, that runs in the background while you&rsquo;re not using the tool. Its job is literally to dream: to consolidate memory. The name isn&rsquo;t accidental, and the obvious analogy (which I couldn&rsquo;t resist) is the human brain consolidating memory during sleep, turning short-term experience into something more stable.</p>
<p>For a dream to actually run, three gates have to open at once: 24 hours since the last dream, at least 5 sessions since the last dream, and a consolidation lock that prevents concurrent dreams. When it fires, it goes through four phases. Orient (does an <code>ls</code> on the memory directory, reads the index). Gather (looks for new signals in logs, stale memories, transcripts). Consolidate (writes or updates the topic files, converts relative dates into absolute ones, deletes facts that have been contradicted). And Prune, the final cleanup that keeps the index under 200 lines.</p>
<p>The decision to make <code>autoDream</code> a forked subagent is the detail that matters here. It does not run in the same loop as the main agent. Why? Because memory consolidation is a noisy process. The model has to re-read old transcripts, compare them against what&rsquo;s in <code>MEMORY.md</code>, decide what stays and what goes, form hypotheses about things it saw in earlier sessions. If that ran in the main context, it would pollute the &ldquo;train of thought&rdquo; of the agent that&rsquo;s trying to help you with your current task. By forking, you keep the two separate. The main agent stays focused on what you asked for, and <code>autoDream</code> does the housekeeping in parallel, with no write permission on the project.</p>
<p>And the way it figures out what needs to be consolidated is plain old lexical search. The transcripts live as JSONL files on disk, and <code>autoDream</code> uses grep to look for new signals. Just grep, on text logs. Stop and think about that for a second. The memory consolidation of the most advanced agent in the world, built by one of the richest AI companies out there, is a forked subagent running grep on text logs. If a vector DB were the right answer for this kind of problem, Anthropic would&rsquo;ve put a vector DB in there. They didn&rsquo;t.</p>
<p>And there&rsquo;s a detail that, to me, is the buried gold of the entire leak, and it fits this argument like a glove. In <code>autoDream</code>, memory is treated as a hint. The system assumes that what&rsquo;s stored may be stale, wrong, contradicted by something that happened later, and the model has to verify before it trusts it. The vector DB pitch is the opposite of that: index everything, search by similarity, return the top-k, trust the result. Claude Code went the conservative route. Index little, search by word, return a hint, and stay skeptical until you&rsquo;ve laid eyes on the actual fact.</p>
<p>The whole strategy works in two layers. Inside a single session: generous context plus grep plus smart compaction (<code>microcompact</code>, <code>context collapse</code>, <code>autocompact</code>). Between sessions: a subagent that consolidates memory asynchronously, using grep on the transcripts and treating the result as a tip, not as truth. Embeddings and vector DBs don&rsquo;t show up in either layer. The deliberate choice was a smart reader chewing on raw text, not a dumb reader being spoonfed the top-k of an embedding.</p>
<p>The practical lesson for our debate is simple. The most advanced agents on the market are heading toward generous context, lexical search, and smart compaction, not toward classic RAG pipelines. If Anthropic, with all the infrastructure and talent they&rsquo;ve got, picked this path for Claude Code, those of us building internal applications on a fraction of that budget should at least think about going the same way.</p>
<h2>Where the story started turning<span class="hx:absolute hx:-mt-20" id="where-the-story-started-turning"></span>
    <a href="#where-the-story-started-turning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When the ceiling was 32k of context, retrieval was the bottleneck of the entire problem. You had to pre-filter aggressively, because anything that made it into the window was sacred space. A vector DB was the only halfway-decent way to do that semantic pre-filtering. The logic was: &ldquo;the reader (LLM) is expensive and dumb, so the retriever has to be smart and selective.&rdquo;</p>
<p>Today the equation has flipped. The reader is now the smartest one at the table, and the window grew big enough to hold an entire document. So the retriever can (and maybe should) go back to being dumb. The dumber, the better. You want high recall and low precision, and you let the model do the fine work. Grep does exactly that. So does BM25. And ripgrep flies through millions of lines without breaking a sweat.</p>
<p>And this isn&rsquo;t just my hunch. The BEIR benchmarks have shown for a while now that BM25 matches or beats a lot of dense retrievers when the domain drifts away from where the embeddings were trained. Anthropic itself published a post on <a href="https://www.anthropic.com/news/contextual-retrieval"target="_blank" rel="noopener">Contextual Retrieval</a> that basically says the same thing: a lexical signal plus an LLM&rsquo;s judgment beats pure embeddings on most knowledge tasks. And take a look at Claude Code, the tool I&rsquo;ve been using every day for 500 hours: it navigates the repo with <code>Glob</code> and <code>Grep</code>. No vector DB, no embedding, no LangChain. It works ridiculously well.</p>
<h2>The real problems with vector databases nobody advertises<span class="hx:absolute hx:-mt-20" id="the-real-problems-with-vector-databases-nobody-advertises"></span>
    <a href="#the-real-problems-with-vector-databases-nobody-advertises" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The vector DB marketing sells the dream of perfect semantic search. Reality is messier.</p>
<p>False neighbors come first. Cosine similarity rewards topical similarity, not relevance. You ask &ldquo;how do we handle authentication errors&rdquo; and the DB returns every chunk that mentions authentication. The chunk that actually answers the question may be in tenth place, or may not have been retrieved at all because the doc author wrote &ldquo;login&rdquo; instead of &ldquo;auth.&rdquo;</p>
<p>Chunking is the second one, and it&rsquo;s a disguised disaster. A 512-token window with a 64-token overlap sounds reasonable, until you realize your important table got cut in half, the function definition ended up separated from its usage, and the piece of documentation with the exact command got orphaned without the context of its section. The chunk boundary tends to land exactly where the answer was living.</p>
<p>When it fails, it fails without leaving a trace. When BM25 misses, you know why: the word isn&rsquo;t there. When a vector DB returns garbage, you get a plausible-looking wrong chunk, with no diagnostic signal at all. Good luck debugging that in production at two in the morning.</p>
<p>The index gets stale. Every document update calls for re-embedding. If you have 10,000 docs and 200 of them change per day, that turns into a batch process, monitoring, a queue, retries, embedding API costs, and an unavoidable inconsistency window between what&rsquo;s on disk and what&rsquo;s in the index. Grep has none of that. File changed? The next query already sees it.</p>
<p>And there&rsquo;s the operating cost nobody adds up. Pinecone charges per vector. Weaviate wants a cluster to maintain. pgvector saves you a new server but you still own a schema, an index, and a re-embedding pipeline. Each of those things wants engineer time, monitoring, tests, deploys. All of that to do a search that <code>rg</code> would often crack in 200ms.</p>
<h2>Comparing the complexity<span class="hx:absolute hx:-mt-20" id="comparing-the-complexity"></span>
    <a href="#comparing-the-complexity" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Look at the diagram:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/06/rag/rag-vs-grep-complexity.png" alt="Complexity: classic RAG vs grep &#43; long context"  loading="lazy" /></p>
<p>On one side, eight steps, four or five services, an external index that needs to be maintained and kept up to date. On the other, four steps, zero new infrastructure. This isn&rsquo;t a caricature: it is literally what you have to set up for each case.</p>
<p>The honest question: does the left column pay off? In 2023, yes, because the right column didn&rsquo;t exist (no LLM had a 200k window). In 2026, in most cases, it doesn&rsquo;t.</p>
<h2>Pros and cons of each side<span class="hx:absolute hx:-mt-20" id="pros-and-cons-of-each-side"></span>
    <a href="#pros-and-cons-of-each-side" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Classic RAG (vector DB)<span class="hx:absolute hx:-mt-20" id="classic-rag-vector-db"></span>
    <a href="#classic-rag-vector-db" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>For:</strong></p>
<ul>
<li>Works for huge document bases, on the order of hundreds of GB, where even <code>rg</code> won&rsquo;t cut it without prior indexing</li>
<li>Handles heavy paraphrase and cross-lingual queries (&ldquo;how do I cancel&rdquo; vs. &ldquo;subscription termination process&rdquo;) where the user&rsquo;s vocabulary doesn&rsquo;t match the document&rsquo;s</li>
<li>Works for non-textual modalities (image, audio) where grep has nothing to look at</li>
<li>Saves input tokens if you&rsquo;re tight on budget or absolute latency</li>
</ul>
<p><strong>Against:</strong></p>
<ul>
<li>Complex stack: embedding, vector DB, chunking, reranker, re-indexing pipeline</li>
<li>Opaque failures, hard to debug</li>
<li>Chunking destroys the context of tables, code, long definitions</li>
<li>Operational overhead (index, queue, monitoring, re-embedding cost)</li>
<li>The semantic search the marketing is selling rarely works the way the marketing promises</li>
</ul>
<h3>Grep + long context<span class="hx:absolute hx:-mt-20" id="grep--long-context"></span>
    <a href="#grep--long-context" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>For:</strong></p>
<ul>
<li>Practically zero new infrastructure: ripgrep, sqlite, or a plain <code>LIKE</code> in Postgres</li>
<li>Always fresh: file changes, the next query sees them</li>
<li>Transparent failures: the word is either there or it isn&rsquo;t</li>
<li>Loads the document in generous chunks, the model does the fine filtering with actual semantics</li>
<li>Cheaper in dev and ops, cheaper to pivot domains</li>
</ul>
<p><strong>Against:</strong></p>
<ul>
<li>Doesn&rsquo;t scale to terabytes of raw text without some kind of indexing</li>
<li>Suffers when the user&rsquo;s vocabulary is very different from the document&rsquo;s</li>
<li>Doesn&rsquo;t work for non-textual modalities</li>
<li>Per-query latency is higher in absolute terms (loading 100k tokens always costs more than loading 5k)</li>
<li>Per-query input cost is higher if you don&rsquo;t have prompt caching</li>
</ul>
<h2>But what about cost?<span class="hx:absolute hx:-mt-20" id="but-what-about-cost"></span>
    <a href="#but-what-about-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the argument I get hit with the most when I defend the &ldquo;load everything into context&rdquo; thesis. &ldquo;It&rsquo;ll get crazy expensive, 200k tokens of input per query is absurd.&rdquo; Let&rsquo;s actually run the numbers.</p>
<p>In <a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">yesterday&rsquo;s LLM benchmark post</a> I mapped out the per-token price of every model. Take Claude Sonnet 4.6: $3 per million input tokens, $15 per million output. Take GLM 5 (which I proved actually works): $0.60 input, $2.20 output. Take GPT 5.4 Pro at the top of the heap: $15 input, $180 output (yeah, that one stings, I know).</p>
<p>Before we turn &ldquo;200k tokens&rdquo; into dollars, let&rsquo;s land that number on something tangible, because &ldquo;100k tokens&rdquo; doesn&rsquo;t mean anything to anyone. A token, on average, is roughly 0.75 of a word in English (Portuguese is similar, maybe a touch heavier because of longer words). So, translating:</p>
<ul>
<li><strong>100k tokens</strong> ≈ 75,000 words ≈ a whole short novel like Hemingway&rsquo;s <em>The Old Man and the Sea</em> with room to spare, or about three long Wikipedia articles glued together.</li>
<li><strong>200k tokens</strong> ≈ 150,000 words ≈ a big novel, like <em>Crime and Punishment</em> in full, or half of the first <em>Game of Thrones</em> book (which clocks in around 298k words, so roughly 400k tokens).</li>
<li><strong>400k tokens</strong> ≈ 300,000 words ≈ <em>A Game of Thrones</em> in full, the entire first book of the series in your window.</li>
<li><strong>1M tokens</strong> ≈ 750,000 words ≈ the entire <em>Lord of the Rings</em> trilogy plus <em>The Hobbit</em>, or the whole Bible (King James is around 783k words, roughly 1M tokens), or about two and a half <em>Game of Thrones</em> books stacked on top of each other.</li>
</ul>
<p>So when I say &ldquo;throw 200k tokens of input at the model,&rdquo; what that actually means in the real world is &ldquo;throw the entire <em>Crime and Punishment</em> in as the context for your question.&rdquo; That&rsquo;s a lot. And that&rsquo;s exactly what makes the argument of this post viable: today&rsquo;s models can read an entire novel in one go and still answer a specific question about it. In 2023, this was science fiction. In 2026, it&rsquo;s the base case.</p>
<p>So picture a query that throws 200k tokens of input at the model (there goes <em>Crime and Punishment</em> again) and produces 2k tokens of output (about three pages of response):</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">Input ($)</th>
          <th style="text-align: right">Output ($)</th>
          <th style="text-align: right">Total per query</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td style="text-align: right">$0.60</td>
          <td style="text-align: right">$0.03</td>
          <td style="text-align: right"><strong>$0.63</strong></td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td style="text-align: right">$3.00</td>
          <td style="text-align: right">$0.15</td>
          <td style="text-align: right"><strong>$3.15</strong></td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td style="text-align: right">$0.12</td>
          <td style="text-align: right">$0.0044</td>
          <td style="text-align: right"><strong>$0.12</strong></td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: right">$0.40</td>
          <td style="text-align: right">$0.024</td>
          <td style="text-align: right"><strong>$0.42</strong></td>
      </tr>
      <tr>
          <td>GPT 5.4 Pro</td>
          <td style="text-align: right">$3.00</td>
          <td style="text-align: right">$0.36</td>
          <td style="text-align: right"><strong>$3.36</strong></td>
      </tr>
  </tbody>
</table>
<p>Now throw prompt caching into the mix. Claude has a cache that drops cached input to a fraction of the full price (in the ballpark of 10%, depending on the model). Gemini has a similar mechanism. When you fire a sequence of queries against the same 200k-token dump, the cost of subsequent queries plummets to pennies. With Sonnet cached, you can fairly call it about $0.10 per follow-up query without making things up.</p>
<p>Now compare that to the cost of running a Pinecone, or a Weaviate, or a pgvector. Setting aside the price of the subscription itself (which varies a lot), you need an engineer to wire up the pipeline, maintain it, monitor it, deal with embedding failures, redo the chunking when the domain shifts. Conservatively, you&rsquo;re looking at somewhere between 40 and 80 hours of engineering to make the thing stable. At R$ 200/hour, that&rsquo;s between R$ 8,000 and R$ 16,000. In USD, somewhere between $1,600 and $3,200 just to stand it up.</p>
<p>With $3,200, on Sonnet 4.6 with prompt caching, you can run something on the order of 30,000 queries of 200k tokens each. Thirty thousand queries, depending on the scale of the project, gives you several months or even an entire year of an average internal tool. And you didn&rsquo;t pay an engineer to wire up a pipeline. There&rsquo;s no vector DB server to maintain. And if the document changes, the system already sees it on the next query.</p>
<p>The &ldquo;RAG is cheaper in tokens&rdquo; argument ignores that tokens are the cheapest thing in the entire equation. Engineers cost a lot, servers cost a lot, bugs in production cost a whole lot more. Tokens have become a commodity, and they&rsquo;re getting cheaper with every new model release.</p>
<p>The classic RAG argument was &ldquo;the model is expensive, retrieval is cheap.&rdquo; Today it&rsquo;s the opposite: the model is the cheap part of the stack, smart retrieval is what costs a fortune to build and maintain.</p>
<h2>Where the thesis doesn&rsquo;t hold<span class="hx:absolute hx:-mt-20" id="where-the-thesis-doesnt-hold"></span>
    <a href="#where-the-thesis-doesnt-hold" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I don&rsquo;t want to come off as a fanboy. There are cases where classic RAG still wins:</p>
<ol>
<li><strong>Massive corpora.</strong> If you have 500 GB of raw text, even <code>rg</code> won&rsquo;t solve it in acceptable time. You need some kind of indexing. It can be indexed BM25 (Tantivy, Elasticsearch), it can be a vector DB. But notice: the first option is still lexical, not vector.</li>
<li><strong>Wildly scattered vocabulary.</strong> Customer support, where the user types &ldquo;my wifi&rsquo;s down&rdquo; and the documentation says &ldquo;loss of connectivity at the physical layer.&rdquo; BM25 won&rsquo;t catch that. Embedding will. Vector DB scores a point here.</li>
<li><strong>Non-textual modalities.</strong> Image-by-image search, audio-by-audio. Embedding is mandatory.</li>
<li><strong>Critical absolute latency.</strong> If you have to answer in 100ms with a 5k input budget, a generous dump won&rsquo;t fit. Pre-filtering is necessary.</li>
<li><strong>Compliance and audit.</strong> If you have to prove that a specific document was consulted to answer a specific query, having indexed and trackable chunks helps. A 200k-token context dump is more opaque from an audit standpoint.</li>
</ol>
<p>For those cases, classic RAG still makes sense. But notice the size of the list. These are specific cases. The general case, things like &ldquo;chat with our internal docs&rdquo; or &ldquo;ask the product manual,&rdquo; almost all of it falls into the &ldquo;grep + long context handles it better&rdquo; bucket.</p>
<h2>Lazy retrieval: the recipe I&rsquo;d defend<span class="hx:absolute hx:-mt-20" id="lazy-retrieval-the-recipe-id-defend"></span>
    <a href="#lazy-retrieval-the-recipe-id-defend" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If I were building a &ldquo;chat with docs&rdquo; tool today, from scratch, it would look more or less like this:</p>
<ol>
<li><strong>Keep the documents raw.</strong> Markdown, converted PDF, code, whatever. On disk, organized in folders that make sense for the domain.</li>
<li><strong>Fast lexical filter.</strong> <code>ripgrep</code> with regex, or BM25 with Tantivy/SQLite FTS5, or a <code>LIKE</code> in Postgres if you already have one. Returns 100-300 hits.</li>
<li><strong>Load generously.</strong> Grab not just the matching snippet, but the entire file, or a wide window around it. Throw all of it into the context.</li>
<li><strong>Let the LLM do the fine work.</strong> Pass the original question, tell the model to find what matters, drop the rest, and answer with citations.</li>
<li><strong>(Optional) Add embeddings only for the query classes where lexical fails</strong>, after you have real data showing that it fails.</li>
</ol>
<p>This is the opposite of the old advice (&ldquo;start with vectors, fall back to keyword&rdquo;). It&rsquo;s: <strong>start with keyword, and add vector only if you feel the gap</strong>. In most projects, you never will.</p>
<h2>A toy implementation in Ruby<span class="hx:absolute hx:-mt-20" id="a-toy-implementation-in-ruby"></span>
    <a href="#a-toy-implementation-in-ruby" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To make it concrete. Here&rsquo;s a Ruby script using the <a href="https://github.com/crmne/ruby_llm"target="_blank" rel="noopener"><code>ruby_llm</code></a> gem (the same one from yesterday&rsquo;s benchmark) that does exactly this flow: grep through the files, load the snippets with context, send to Claude, get the answer back. No vector DB, no chunking, no embedding, no LangChain.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env ruby</span>
</span></span><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;ruby_llm&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;open3&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">DOCS_DIR</span> <span class="o">=</span> <span class="no">ARGV</span><span class="o">[</span><span class="mi">0</span><span class="o">]</span> <span class="o">||</span> <span class="s2">&#34;./docs&#34;</span>
</span></span><span class="line"><span class="cl"><span class="no">QUERY</span>    <span class="o">=</span> <span class="no">ARGV</span><span class="o">[</span><span class="mi">1</span><span class="o">]</span> <span class="ow">or</span> <span class="nb">abort</span> <span class="s2">&#34;uso: ./ask.rb &lt;pasta&gt; &lt;pergunta&gt;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1. Fast lexical filter with ripgrep.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#    -i case insensitive, -l file names only, --type-add covers md/txt/extracted-pdf.</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">lexical_search</span><span class="p">(</span><span class="n">dir</span><span class="p">,</span> <span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">terms</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="n">downcase</span><span class="o">.</span><span class="n">scan</span><span class="p">(</span><span class="sr">/\w{4,}/</span><span class="p">)</span><span class="o">.</span><span class="n">uniq</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span>  <span class="c1"># words with 4+ letters</span>
</span></span><span class="line"><span class="cl">  <span class="n">pattern</span> <span class="o">=</span> <span class="n">terms</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;|&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span> <span class="o">=</span> <span class="o">[</span><span class="s2">&#34;rg&#34;</span><span class="p">,</span> <span class="s2">&#34;-l&#34;</span><span class="p">,</span> <span class="s2">&#34;-i&#34;</span><span class="p">,</span> <span class="s2">&#34;-e&#34;</span><span class="p">,</span> <span class="n">pattern</span><span class="p">,</span> <span class="n">dir</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">files</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="no">Open3</span><span class="o">.</span><span class="n">capture2</span><span class="p">(</span><span class="o">*</span><span class="n">cmd</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">files</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:empty?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Load entire files (up to a reasonable cap).</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">load_context</span><span class="p">(</span><span class="n">files</span><span class="p">,</span> <span class="ss">max_chars</span><span class="p">:</span> <span class="mi">600_000</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">total</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="n">files</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">path</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">body</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">total</span> <span class="o">+</span> <span class="n">body</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;</span> <span class="n">max_chars</span>
</span></span><span class="line"><span class="cl">    <span class="n">total</span> <span class="o">+=</span> <span class="n">body</span><span class="o">.</span><span class="n">size</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;## </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="se">\n\n</span><span class="si">#{</span><span class="n">body</span><span class="si">}</span><span class="se">\n</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span><span class="o">.</span><span class="n">compact</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">---</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Send to Claude with the question and the documents.</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">ask</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">context</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;anthropic/claude-sonnet-4-6&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">prompt</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">    <span class="no">Você</span> <span class="n">tem</span> <span class="n">acesso</span> <span class="n">aos</span> <span class="n">documentos</span> <span class="n">abaixo</span><span class="o">.</span> <span class="no">Responda</span> <span class="n">a</span> <span class="n">pergunta</span> <span class="k">do</span> <span class="n">usuário</span>
</span></span><span class="line"><span class="cl">    <span class="n">usando</span> <span class="n">apenas</span> <span class="n">o</span> <span class="n">que</span> <span class="n">está</span> <span class="n">nos</span> <span class="n">documentos</span><span class="o">.</span> <span class="no">Cite</span> <span class="n">o</span> <span class="n">nome</span> <span class="k">do</span> <span class="n">arquivo</span> <span class="n">nas</span>
</span></span><span class="line"><span class="cl">    <span class="n">referências</span><span class="o">.</span> <span class="no">Se</span> <span class="n">a</span> <span class="n">resposta</span> <span class="n">não</span> <span class="n">estiver</span> <span class="n">nos</span> <span class="n">documentos</span><span class="p">,</span> <span class="n">diga</span> <span class="n">isso</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="o">---</span> <span class="no">DOCUMENTOS</span> <span class="o">---</span>
</span></span><span class="line"><span class="cl">    <span class="c1">#{context}</span>
</span></span><span class="line"><span class="cl">    <span class="o">---</span> <span class="no">FIM</span> <span class="no">DOS</span> <span class="no">DOCUMENTOS</span> <span class="o">---</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="ss">Pergunta</span><span class="p">:</span> <span class="c1">#{query}</span>
</span></span><span class="line"><span class="cl">  <span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span><span class="o">.</span><span class="n">content</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">files</span> <span class="o">=</span> <span class="n">lexical_search</span><span class="p">(</span><span class="no">DOCS_DIR</span><span class="p">,</span> <span class="no">QUERY</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">abort</span> <span class="s2">&#34;nenhum arquivo bateu&#34;</span> <span class="k">if</span> <span class="n">files</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl"><span class="nb">puts</span> <span class="s2">&#34;Encontrei </span><span class="si">#{</span><span class="n">files</span><span class="o">.</span><span class="n">size</span><span class="si">}</span><span class="s2"> arquivos. Carregando contexto...&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">context</span> <span class="o">=</span> <span class="n">load_context</span><span class="p">(</span><span class="n">files</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">puts</span> <span class="n">ask</span><span class="p">(</span><span class="no">QUERY</span><span class="p">,</span> <span class="n">context</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>About 40 lines. No Pinecone dependency, no vector schema, no re-indexing pipeline. You run it as <code>./ask.rb ./docs &quot;how do I configure the payment webhook&quot;</code> and that&rsquo;s it.</p>
<p>That example is one-shot. You run it, it answers, done. For a real chat, with multiple questions in a row over the same documents, the design changes. Instead of running <code>lexical_search</code> upfront and shoving everything into the context at once, you expose the search as a tool to the model. Then it&rsquo;s the agent that decides when it needs to pull more docs, what term to look for, which file is worth opening in full. That&rsquo;s how Claude Code actually works: <code>Glob</code>, <code>Grep</code> and <code>Read</code> are tools, and the model picks the sequence. <code>ruby_llm</code> supports tool calling, so you can do the same thing in Ruby. It looks something like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;ruby_llm&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">require</span> <span class="s2">&#34;open3&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">DOCS_DIR</span> <span class="o">=</span> <span class="s2">&#34;./docs&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">SearchFiles</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Procura arquivos cujo conteúdo casa com o padrão dado (regex). Retorna lista de paths.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:pattern</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Padrão regex pra busca lexical (case-insensitive)&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">pattern</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="no">Open3</span><span class="o">.</span><span class="n">capture2</span><span class="p">(</span><span class="s2">&#34;rg&#34;</span><span class="p">,</span> <span class="s2">&#34;-l&#34;</span><span class="p">,</span> <span class="s2">&#34;-i&#34;</span><span class="p">,</span> <span class="s2">&#34;-e&#34;</span><span class="p">,</span> <span class="n">pattern</span><span class="p">,</span> <span class="no">DOCS_DIR</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:empty?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ReadFile</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Lê o conteúdo completo de um arquivo do projeto.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:path</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Caminho relativo do arquivo&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">path</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="no">File</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;erro: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;anthropic/claude-sonnet-4-6&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="o">.</span><span class="n">with_tools</span><span class="p">(</span><span class="no">SearchFiles</span><span class="p">,</span> <span class="no">ReadFile</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="o">.</span><span class="n">with_instructions</span><span class="p">(</span><span class="o">&lt;&lt;~</span><span class="no">SYS</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">              <span class="no">Você</span> <span class="n">responde</span> <span class="n">perguntas</span> <span class="n">sobre</span> <span class="n">os</span> <span class="n">documentos</span> <span class="n">em</span> <span class="c1">#{DOCS_DIR}.</span>
</span></span><span class="line"><span class="cl">              <span class="no">Use</span> <span class="n">search_files</span> <span class="n">pra</span> <span class="n">encontrar</span> <span class="n">arquivos</span> <span class="n">relevantes</span> <span class="n">e</span> <span class="n">read_file</span>
</span></span><span class="line"><span class="cl">              <span class="n">pra</span> <span class="n">ler</span> <span class="n">o</span> <span class="n">conteúdo</span><span class="o">.</span> <span class="no">Sempre</span> <span class="n">cite</span> <span class="n">o</span> <span class="n">arquivo</span> <span class="n">na</span> <span class="n">resposta</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">            <span class="no">SYS</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kp">loop</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nb">print</span> <span class="s2">&#34;&gt; &#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">msg</span> <span class="o">=</span> <span class="nb">gets</span><span class="o">&amp;.</span><span class="n">chomp</span>
</span></span><span class="line"><span class="cl">  <span class="k">break</span> <span class="k">if</span> <span class="n">msg</span><span class="o">.</span><span class="n">nil?</span> <span class="o">||</span> <span class="n">msg</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl">  <span class="nb">puts</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span><span class="o">.</span><span class="n">content</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The model gets the question, decides whether it needs to search, calls <code>search_files</code>, sees what came back, decides whether it needs to open any file, calls <code>read_file</code>, and only then answers. On the next question it already has the previous context in the session and can ask for more if it needs to. The context only receives what the model asked for, not the whole grep dump from the earlier example.</p>
<p>The same idea works for databases: swap <code>rg</code> for a SQL query with <code>LIKE</code> or <code>tsvector</code> (Postgres full-text), load the relevant rows, throw them in the context. If you have 10k records in an internal database, this handles it. If you have 10 million, you start needing smarter pagination or a more serious pre-filtering layer. But the mental model is the same: <strong>dumb filter + smart reader</strong>.</p>
<h2>The point that matters<span class="hx:absolute hx:-mt-20" id="the-point-that-matters"></span>
    <a href="#the-point-that-matters" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The most interesting thing in all of this isn&rsquo;t even the Pinecone savings. It&rsquo;s that the nature of the bottleneck has changed. In 2023, the bottleneck was retrieval: the reader was small, slow, expensive, and you needed a clever retriever to fill the window with the bare minimum. In 2026, the bottleneck is reasoning over messy context: the reader is big, relatively fast, and cheap. So it makes more sense to have a dumb retriever with high recall and let the model do the heavy lifting.</p>
<p>Anyone still designing systems with the 2023 mindset is paying a premium to solve a problem whose shape has changed. RAG didn&rsquo;t die, the &ldquo;R&rdquo; got dumber and cheaper, and that&rsquo;s an upgrade. The vector DB vendors aren&rsquo;t going to tell you this, but it&rsquo;s the path the more experienced folks have been quietly walking.</p>
<p>The next wave of LLM applications, in my bet, is going to be dominated by the people who got this inversion. Smaller stacks, simpler infrastructure, generous context, and a whole lot less LangChain.</p>
<h2>What the recent literature says<span class="hx:absolute hx:-mt-20" id="what-the-recent-literature-says"></span>
    <a href="#what-the-recent-literature-says" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before I close out, I went and checked what the research crowd published on this. Blog hot takes age in three months in this field, so it&rsquo;s better to look at the papers.</p>
<p><a href="https://arxiv.org/abs/2407.16833"target="_blank" rel="noopener"><strong>Retrieval Augmented Generation or Long-Context LLMs?</strong></a>, out of Google DeepMind, published at EMNLP 2024, is probably the most cited piece in the debate. Their conclusion: when the model has enough resources, long context beats RAG on average quality, but RAG is still much cheaper in tokens. They propose Self-Route, an approach where the model itself decides whether it needs retrieval or whether it can just go straight through context. The token savings are big and the quality loss is small.</p>
<p>Then <a href="https://openreview.net/forum?id=CLF25dahgA"target="_blank" rel="noopener"><strong>LaRA</strong></a>, presented at ICML 2025, is more measured. The authors built 2326 test cases across four QA task types and three long-context types, ran them across 11 different LLMs, and the conclusion was: there is no silver bullet. The choice between RAG and long context depends on the model, the context size, the task type, and the retrieval characteristics. RAG wins on dialogue and generic queries, long context wins on Wikipedia-style QA.</p>
<p><a href="https://arxiv.org/abs/2501.01880"target="_blank" rel="noopener"><strong>Long Context vs. RAG for LLMs: An Evaluation and Revisits</strong></a>, from January 2025, is the one that most reinforces this post&rsquo;s thesis. Long context tends to beat RAG on QA benchmarks, especially when the base document is stable. Summarization-based retrieval comes close, and chunk-based retrieval lags behind. In other words: the old way, chunk plus embed plus top-k, is the one that comes out worst.</p>
<p>Worth keeping on the radar too is the original <a href="https://arxiv.org/abs/2307.03172"target="_blank" rel="noopener"><strong>Lost in the Middle</strong></a> (Liu et al., 2023, published in TACL in 2024). That&rsquo;s the paper that showed even models with big windows have performance that depends on the position of the relevant information. Stuff at the beginning or end of the context is found easily; stuff in the middle degrades. For a long time this got used as the argument against long context, but the paper is from 2023, with 2023 models. Today&rsquo;s models, the Claude 4.x and Gemini 3.x line, handle the middle a lot better. It&rsquo;s not a solved problem, but it&rsquo;s much smaller than it was.</p>
<p>On the lexical retrieval side, <a href="https://arxiv.org/abs/2104.08663"target="_blank" rel="noopener"><strong>BEIR</strong></a> is still the canonical reference. The classic result is that BM25, all the way from the 90s, is still ridiculously competitive in out-of-domain scenarios. Dense models only win consistently when you have in-domain data to fine-tune the embeddings. In zero-shot scenarios, which is where most projects live, BM25 is hard to beat without serious work.</p>
<p>To wrap up, the <a href="https://www.anthropic.com/news/contextual-retrieval"target="_blank" rel="noopener"><strong>Anthropic post on Contextual Retrieval</strong></a>, from September 2024, is the most practical piece on the list. They show that combining contextual embedding with contextual BM25 drops the top-20 failure rate from 5.7% to 2.9%. Add a reranker and it drops to 1.9%. Important detail: BM25 is the centerpiece of their result, not a sidekick. The right reading is &ldquo;lexical plus vector plus reranker is the combination that works.&rdquo; Anyone who can only pick one picks BM25 and still gets pretty far.</p>
<p>To sum up what we can actually nail down: the literature isn&rsquo;t claiming &ldquo;RAG is dead.&rdquo; It&rsquo;s saying that long context, when you can use it, tends to win on quality. It&rsquo;s saying RAG&rsquo;s cost is still its main argument. It&rsquo;s saying lexical BM25 is much stronger than the vector DB marketing makes it sound. And it&rsquo;s saying that when you really do need heavy retrieval, the robust combination is hybrid (lexical plus vector plus reranker), not pure vector. All of that lines up with what I&rsquo;ve been defending in practice.</p>
<h2>Sources<span class="hx:absolute hx:-mt-20" id="sources"></span>
    <a href="#sources" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li>Li, Z. et al. (2024). <a href="https://arxiv.org/abs/2407.16833"target="_blank" rel="noopener">Retrieval Augmented Generation or Long-Context LLMs? A Comprehensive Study and Hybrid Approach</a>. EMNLP 2024 Industry Track.</li>
<li>Yuan, K. et al. (2025). <a href="https://openreview.net/forum?id=CLF25dahgA"target="_blank" rel="noopener">LaRA: Benchmarking Retrieval-Augmented Generation and Long-Context LLMs – No Silver Bullet for LC or RAG Routing</a>. ICML 2025.</li>
<li>Yu, T. et al. (2025). <a href="https://arxiv.org/abs/2501.01880"target="_blank" rel="noopener">Long Context vs. RAG for LLMs: An Evaluation and Revisits</a>. arXiv:2501.01880.</li>
<li>Liu, N. F. et al. (2023). <a href="https://arxiv.org/abs/2307.03172"target="_blank" rel="noopener">Lost in the Middle: How Language Models Use Long Contexts</a>. TACL 2024.</li>
<li>Thakur, N. et al. (2021). <a href="https://arxiv.org/abs/2104.08663"target="_blank" rel="noopener">BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models</a>. NeurIPS Datasets and Benchmarks 2021.</li>
<li>Anthropic (2024). <a href="https://www.anthropic.com/news/contextual-retrieval"target="_blank" rel="noopener">Introducing Contextual Retrieval</a>. Blog post.</li>
<li>Akita, F. (2026). <a href="/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/">Claude Code&rsquo;s Source Code Leaked. Here&rsquo;s What We Found Inside.</a> — my coverage of the leak, with more detail on the memory architecture, KAIROS and <code>autoDream</code>.</li>
</ul>
]]></content:encoded><category>llm</category><category>rag</category><category>vibecoding</category><category>ai</category></item><item><title>Testing Open Source and Commercial LLMs - Can Anyone Beat Claude Opus?</title><link>https://akitaonrails.github.io/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/</guid><pubDate>Sun, 05 Apr 2026 18:00:00 GMT</pubDate><description>&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Obsolete article (updated 2026-04-24).&lt;/strong&gt; The conclusions and rankings in this post were superseded after I re-audited the benchmark against the &lt;code&gt;ruby_llm&lt;/code&gt; gem source and restructured the evaluation criteria. Several &amp;ldquo;hallucinations&amp;rdquo; I had cataloged were actually valid API. Kimi K2.6 and Gemini 3.1 Pro moved up to Tier A. GLM 5.1 dropped to Tier C. MiMo V2.5 Pro fell from &amp;ldquo;first non-Anthropic Tier 1&amp;rdquo; to Tier B. &lt;strong&gt;The canonical version lives at &lt;a href="https://akitaonrails.github.io/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/"&gt;LLM Coding Benchmark (April 2026)&lt;/a&gt;.&lt;/strong&gt; This post stays as a historical record of what I concluded before the re-audit.&lt;/p&gt;</description><content:encoded><![CDATA[<blockquote>
  <p>⚠️ <strong>Obsolete article (updated 2026-04-24).</strong> The conclusions and rankings in this post were superseded after I re-audited the benchmark against the <code>ruby_llm</code> gem source and restructured the evaluation criteria. Several &ldquo;hallucinations&rdquo; I had cataloged were actually valid API. Kimi K2.6 and Gemini 3.1 Pro moved up to Tier A. GLM 5.1 dropped to Tier C. MiMo V2.5 Pro fell from &ldquo;first non-Anthropic Tier 1&rdquo; to Tier B. <strong>The canonical version lives at <a href="/en/2026/04/24/llm-benchmarks-parte-3-deepseek-kimi-mimo/">LLM Coding Benchmark (April 2026)</a>.</strong> This post stays as a historical record of what I concluded before the re-audit.</p>

</blockquote>
<hr>
<p><strong>Update April 16, 2026:</strong> Added Claude Opus 4.7, Qwen 3.6 and GPT 5.4 via Codex CLI (xHigh reasoning). Opus 4.7 is an incremental improvement over 4.6 (28 tests vs 16, same correct API) and becomes the new baseline. GPT 5.4, previously Tier 1 based on my personal vouch, now has objective data and dropped to Tier 2 — burned 7.6M tokens (~$16/run, 15x more expensive than Opus) and got the <code>add_message</code> calling convention wrong on multi-turn. Qwen 3.6 Plus remains Tier 3 with the same API hallucination as 3.5. The conclusion stands: if you want safety, pick Opus.</p>
<hr>
<p><strong>TL;DR:</strong> If you don&rsquo;t want to read the whole analysis: the only models that produced code that actually works in our benchmark were Claude Opus 4.7, Claude Opus 4.6, GLM 5 and GLM 5.1 (from Z.AI, ~89% cheaper than Opus). Sonnet works in this benchmark too, but in practice it falls short on projects requiring deeper reasoning — details in the conclusion. GPT 5.4, previously Tier 1 based on my personal vouch, now has objective data via Codex CLI: it burned 7.6M tokens ($16/run) and got the <code>add_message</code> calling convention wrong — works on the first message, breaks on multi-turn. Dropped to Tier 2. Everything else — Kimi, DeepSeek, MiniMax, Qwen, Gemini, Grok 4.20 — invented APIs that don&rsquo;t exist or ignored the gem we asked for.</p>
<p>There&rsquo;s a new wrinkle in this update: I redid the local part of the benchmark on an RTX 5090 (instead of the AMD Strix Halo) and added a fresh batch of Qwen models, including a Qwen 3.5 27B distilled directly from Claude 4.6 Opus. That reopened the conversation on running open source models locally. The 5090&rsquo;s memory bandwidth flips the game from &ldquo;unworkable&rdquo; to &ldquo;workable with 1-2 follow-up prompts.&rdquo; The bottleneck for open source models has moved to a lack of factual knowledge about specific libraries, which I unpack in detail in the new section on the Qwen family. The Claude distillation gamble, by the way, gave a pretty frustrating result that I haven&rsquo;t seen documented in these terms before.</p>
<hr>
<p>If you&rsquo;ve been following <a href="/en/en/tags/vibecoding/">my previous vibe coding pieces</a>, you know I spent the last two months in a 500-hour marathon using Claude Opus as my main coding agent. The results were good, as I reported in the <a href="/en/2026/03/05/37-days-of-vibe-coding-immersion-conclusions-on-business-models/">conclusion about business models</a>. But there was an itch I couldn&rsquo;t scratch: am I locked into one model? Is there a real alternative to Claude Opus for daily use on real projects?</p>
<p>I&rsquo;ve got an RTX 5090 with 32 GB of GDDR7. I know I can run the latest open source models. I bought a <a href="/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">Minisforum MS-S1</a> with an AMD Ryzen AI Max 395 and 128 GB of unified memory, and built a <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">home server with Docker</a> to serve local models. The infrastructure was ready. What was missing was actually testing it.</p>
<p>I built an automated benchmark to compare open source and commercial models under identical conditions. 33 models configured in total (25 from the original run plus 8 added in the NVIDIA rerun), 27 executed, 16 completed in some form. The code is on <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">GitHub</a>.</p>
<h2>The bottleneck nobody explains: VRAM and KV Cache<span class="hx:absolute hx:-mt-20" id="the-bottleneck-nobody-explains-vram-and-kv-cache"></span>
    <a href="#the-bottleneck-nobody-explains-vram-and-kv-cache" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before getting to the results, I have to explain why running large models locally is much harder than it looks.</p>
<p>Take Qwen3 32B. The model in FP16 (full precision) takes ~64 GB. Quantized to Q4 (4 bits), it drops to ~19 GB. So it fits in my RTX 5090&rsquo;s 32 GB, right? Wrong. That&rsquo;s just the model weights. There&rsquo;s a part nobody tells you about: the <strong>KV Cache</strong>.</p>
<p>KV Cache is the memory the model uses to &ldquo;remember&rdquo; what it has already read. Every time it processes a token (a word or piece of a word), it computes two vectors — K (key) and V (value) — for every attention layer. Those vectors stick around so the model doesn&rsquo;t have to recompute everything when it generates the next token. Without that, generation would be quadratically slow.</p>
<p>The KV Cache scales linearly with the size of the context. The formula:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>KV Memory = 2 × Layers × KV_Heads × Head_Dimension × Bytes_per_Element × Context_Tokens</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For a model like Llama 3.1 70B in BF16, that comes out to ~0.31 MB per token. Sounds tiny, until you realize that a 128K context eats <strong>40 GB</strong> of KV Cache alone. The model itself plus KV Cache adds up to way more VRAM than most GPUs have.</p>
<p>And for actual coding agent use, 128K tokens isn&rsquo;t a luxury, it&rsquo;s the bare minimum. The agent has to read files, keep conversation history, receive command output. In long benchmark sessions, our models consumed between 39K and 156K tokens. Less than 100K of context isn&rsquo;t practical for day-to-day project work.</p>
<p>Google published <a href="https://research.google/blog/turboquant-redefining-ai-efficiency-with-extreme-compression/"target="_blank" rel="noopener">TurboQuant</a> (ICLR 2026), which compresses the KV Cache to 3 bits without accuracy loss — a 6x memory reduction and up to 8x speedup. It uses random vector rotation (PolarQuant) followed by a 1-bit algorithm on the residuals. Works online during inference, compressing on write and decompressing on read. Not yet implemented in the runtimes we use (llama.cpp, Ollama), but when it lands it&rsquo;ll change the equation a lot.</p>
<p>For anyone wanting to dig deeper into the VRAM math, I recommend <a href="https://x.com/TheAhmadOsman/status/2040103488714068245"target="_blank" rel="noopener">this link from Ahmad Osman</a> for the article &ldquo;GPU Memory Math for LLMs (2026 Edition)&rdquo;.</p>
<h2>The hardware problem: not all memory is created equal<span class="hx:absolute hx:-mt-20" id="the-hardware-problem-not-all-memory-is-created-equal"></span>
    <a href="#the-hardware-problem-not-all-memory-is-created-equal" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>&ldquo;But I have 128 GB of RAM!&rdquo; Cool, but that&rsquo;s not what matters. What matters is memory bandwidth, and the difference between types is wild:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/memory-bandwidth.png" alt="Memory bandwidth by type"  loading="lazy" /></p>
<p>The RTX 5090 has 7x the bandwidth of the LPDDR5x memory in my Minisforum. That means even if a model fits in the AMD&rsquo;s unified RAM, inference will be proportionally slower. On my Minisforum with LPDDR5x at 256 GB/s, Qwen3 32B runs at ~7 tok/s. On the RTX 5090 at 1,792 GB/s, it&rsquo;d be much faster — if it fit entirely in VRAM alongside the KV Cache.</p>
<p>Most folks running local models are still on DDR4. At 50 GB/s, 32B models are basically unusable. And there&rsquo;s another factor people forget: storage. When the RAM can&rsquo;t keep up and the system swaps, the storage speed becomes the bottleneck:</p>
<table>
  <thead>
      <tr>
          <th>Storage</th>
          <th style="text-align: right">Sequential Speed</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SATA SSD</td>
          <td style="text-align: right">~550 MB/s</td>
      </tr>
      <tr>
          <td>NVMe Gen3</td>
          <td style="text-align: right">~3,500 MB/s</td>
      </tr>
      <tr>
          <td>NVMe Gen4</td>
          <td style="text-align: right">~7,000 MB/s</td>
      </tr>
      <tr>
          <td>NVMe Gen5</td>
          <td style="text-align: right">~12,000 MB/s</td>
      </tr>
  </tbody>
</table>
<p>From SATA to NVMe Gen5 you&rsquo;re looking at a 22x difference. If you&rsquo;re doing partial offloading to disk (which is common when the model doesn&rsquo;t fit entirely on the GPU), NVMe Gen4 or Gen5 makes a real difference. SATA is a non-starter.</p>
<p>To sum up: running local models isn&rsquo;t just &ldquo;having enough RAM.&rdquo; You need the right kind of memory, with the right bandwidth, and fast storage as a fallback. For a lot of people, a Mac Studio with high-bandwidth unified memory (up to 800 GB/s on the M4 Ultra with 512 GB) would be the more practical option, but it costs more than US$ 10,000. The AMD Ryzen AI Max is the cheaper alternative with unified memory, but its LPDDR5 caps out at 256 GB/s.</p>
<h2>Ollama vs llama.cpp: why Ollama falls apart on benchmarks<span class="hx:absolute hx:-mt-20" id="ollama-vs-llamacpp-why-ollama-falls-apart-on-benchmarks"></span>
    <a href="#ollama-vs-llamacpp-why-ollama-falls-apart-on-benchmarks" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://ollama.com/"target="_blank" rel="noopener">Ollama</a> is the most popular way to run local models. Install, pull the model, run. For casual use it works. But when I tried to use it for automated benchmarks with long unattended sessions, it broke in 6 different ways across 8 models:</p>
<ol>
<li>Unloads the model mid-session. On long runs, Ollama decides the model isn&rsquo;t being used and unloads it from the GPU. The agent sits there waiting for a response from a model that no longer exists.</li>
<li>Ignores the requested context. You ask for <code>num_ctx=131072</code>, Ollama accepts, then halfway through the run it reverts to the default without warning.</li>
<li>Unstable lifecycle. Asking for <code>keep_alive: 0</code> to unload doesn&rsquo;t always work. The model stays resident and blocks the next one.</li>
<li>Incompatible formats. Native bf16 variants on Ollama failed, while the same model as a Q8 GGUF from HuggingFace worked fine.</li>
</ol>
<p>The fix: migrate to <a href="https://github.com/mostlygeek/llama-swap"target="_blank" rel="noopener">llama-swap</a>, a Go wrapper that manages llama.cpp processes with hot-swap. A request comes in for a different model than the one currently loaded, it kills the current process and starts the new one. No context negotiation, no flaky lifecycle.</p>
<p>llama-swap fixed the loading of 6 of the 8 models that had failed under Ollama:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Ollama</th>
          <th>llama-swap</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Gemma 4 27B</td>
          <td>HTTP 500</td>
          <td>47.6 tok/s</td>
      </tr>
      <tr>
          <td>GLM 4.7 Flash</td>
          <td>No output</td>
          <td>47.4 tok/s</td>
      </tr>
      <tr>
          <td>Llama 4 Scout</td>
          <td>Unloaded</td>
          <td>17.5 tok/s</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B</td>
          <td>Output off-spec</td>
          <td>49.7 tok/s</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td>Context drift</td>
          <td>23.1 tok/s</td>
      </tr>
      <tr>
          <td>GPT OSS 20B</td>
          <td>Model not found</td>
          <td>78.3 tok/s</td>
      </tr>
  </tbody>
</table>
<p>But llama-swap isn&rsquo;t magic.</p>
<h2>Why &ldquo;just use llama.cpp&rdquo; doesn&rsquo;t fix everything<span class="hx:absolute hx:-mt-20" id="why-just-use-llamacpp-doesnt-fix-everything"></span>
    <a href="#why-just-use-llamacpp-doesnt-fix-everything" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>llama.cpp solves Ollama&rsquo;s lifecycle problems but brings its own:</p>
<p>Each model needs specific flags. GLM and Qwen 3.5 emit <code>&lt;think&gt;</code> tags that break clients if you don&rsquo;t pass <code>--reasoning-format none</code>. Gemma 4 needs build b8665+ for the tool call parser to work.</p>
<p>Not every model supports tool calling. llama.cpp needs a dedicated parser for each model&rsquo;s tool call format. Llama 4 Scout uses a &ldquo;pythonic&rdquo; format (<code>[func(param=&quot;value&quot;)]</code>) that llama.cpp simply doesn&rsquo;t parse and emits as plain text. vLLM has a parser for it, llama.cpp doesn&rsquo;t.</p>
<p>And then there are the repetition loops. Gemma 4, even with the right parser, gets into an infinite loop after ~11 tool calls in long sessions. It&rsquo;s a <a href="https://github.com/ggml-org/llama.cpp/issues/21375"target="_blank" rel="noopener">known bug</a> that PR #21418 didn&rsquo;t fully fix.</p>
<p>Tool calling compatibility per model:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Tool Calling</th>
          <th>Required Flags</th>
          <th>Benchmark Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Gemma 4 27B</td>
          <td>Partial (b8665+)</td>
          <td><code>--jinja --reasoning-format none</code></td>
          <td>Infinite loop after ~11 steps</td>
      </tr>
      <tr>
          <td>GLM 4.7 Flash</td>
          <td>Yes</td>
          <td><code>--jinja --reasoning-format none</code></td>
          <td>2029 files, ended mid-tool-call</td>
      </tr>
      <tr>
          <td>Qwen 3.5 (35B, 122B)</td>
          <td>Yes</td>
          <td><code>--jinja --reasoning-format none</code></td>
          <td>Completed successfully</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td>Yes</td>
          <td><code>--jinja</code></td>
          <td>Completed (best local result)</td>
      </tr>
      <tr>
          <td>GPT OSS 20B</td>
          <td>Yes</td>
          <td><code>--jinja</code></td>
          <td>Tool calls ok, but app in wrong directory</td>
      </tr>
      <tr>
          <td>Llama 4 Scout</td>
          <td>No</td>
          <td>—</td>
          <td>No parser in llama.cpp</td>
      </tr>
  </tbody>
</table>
<p>At the end of the day, llama.cpp is better than Ollama for automated runs, but &ldquo;plug and play&rdquo; it ain&rsquo;t. Each model requires specific configuration, and some just don&rsquo;t work for agentic coding yet.</p>
<h2>Reasoning: models that think vs models that wing it<span class="hx:absolute hx:-mt-20" id="reasoning-models-that-think-vs-models-that-wing-it"></span>
    <a href="#reasoning-models-that-think-vs-models-that-wing-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s one difference between models worth explaining: reasoning. The idea is that the model &ldquo;thinks before answering&rdquo; instead of generating tokens straight from left to right. Models with reasoning go through an internal chain-of-thought step where they evaluate the problem, consider alternatives, plan, and only then emit the response.</p>
<p>In practice this shows up as <code>&lt;think&gt;...&lt;/think&gt;</code> tags in the output, blocks of text the model writes to itself that shouldn&rsquo;t go to the end user. Claude Opus 4.6, GPT 5.4, DeepSeek V3.2 and the Qwen 3.5 line support reasoning natively. The smaller ones (Gemma 4, GPT OSS 20B, older models) don&rsquo;t have that capability.</p>
<p>Why does it matter for coding? When a coding agent gets &ldquo;build a Rails app with 9 components,&rdquo; it has to decompose the task into steps, decide the order, anticipate dependencies, adapt when something fails. Without reasoning, the model generates code sequentially with no planning. It works for simple tasks, falls apart on projects with interdependent parts.</p>
<p>In the benchmark, the difference was clear:</p>
<ul>
<li>GPT OSS 20B (no reasoning, 20B parameters) created the app in the wrong directory. Couldn&rsquo;t keep workspace instructions in mind while generating code.</li>
<li>Qwen 3 32B has reasoning, but at 7 tok/s it was too slow. The &ldquo;thinking&rdquo; tokens drag out the generation time.</li>
<li>Gemma 4 31B, with no reasoning trained for agentic use, fell into repetitive tool calling loops.</li>
<li>GLM 5 (cloud, 745B MoE) with reasoning and 44B active parameters, finished cleanly and used the correct API.</li>
</ul>
<p>There&rsquo;s a trade-off: reasoning consumes extra tokens (the <code>&lt;think&gt;</code> blocks), which take up VRAM in the KV Cache and slow generation down. That&rsquo;s why flags like <code>--reasoning-format none</code> are needed in llama.cpp. Some clients don&rsquo;t know what to do with reasoning tokens and break. Models that emit reasoning when the runtime isn&rsquo;t expecting it can produce garbage in the output.</p>
<p>And reasoning isn&rsquo;t something you &ldquo;turn on&rdquo; in any model. It&rsquo;s a capability trained with reinforcement learning on top of the base model, using data from problems that require multi-step thinking. The smaller open source models (20B-35B) typically didn&rsquo;t go through that training, or went through it on a smaller scale. They know how to generate code, but they don&rsquo;t know how to <em>plan</em> code. On tasks that require 50+ coordinated tool calls, that difference is fatal.</p>
<h2>The benchmark: methodology<span class="hx:absolute hx:-mt-20" id="the-benchmark-methodology"></span>
    <a href="#the-benchmark-methodology" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To compare models fairly, I built an automated harness in Python. Each model gets the exact same prompt: build a complete Ruby on Rails application, a ChatGPT-style chat SPA using the RubyLLM gem, with Hotwire/Stimulus/Turbo Streams, Tailwind CSS, Minitest tests, CI tools (Brakeman, RuboCop, SimpleCov, bundle-audit), Dockerfile, docker-compose and README.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/crush-screenshot.png" alt="Crush — coding CLI from Charm"  loading="lazy" /></p>
<p>The runner is <a href="https://github.com/sst/opencode"target="_blank" rel="noopener">opencode</a>, one of the most popular open source coding CLIs, competing with Claude Code and Codex. One clarification worth making: the original author left the project to work on <a href="https://github.com/charmbracelet/crush"target="_blank" rel="noopener">Crush</a> together with the <a href="https://charm.sh/"target="_blank" rel="noopener">Charm</a> team (the folks behind Bubble Tea, Lip Gloss and several other Go terminal tools), but the rest of the original team kept evolving opencode normally — the project has not been discontinued. Today the two coexist. If you read <a href="/en/2026/01/09/omarchy-3-one-of-the-best-coding-agents-crush/">my piece on Crush</a>, you already know that branch. Both run everywhere: macOS, Linux, Windows, Android, FreeBSD.</p>
<p>I actually tried to use Crush for the benchmark first. The problem: it advertised a <code>--yolo</code> flag in its help to auto-approve every action (essential for unattended automated runs), but at runtime it rejected the flag. Without auto-approve there&rsquo;s no way to do an unattended benchmark. opencode, on the other hand, had the <code>opencode run --agent build --format json</code> mode that emits JSON events with session IDs and token counts, perfect for automation. So we went with opencode.</p>
<p>I picked opencode (and not Claude Code or Codex) for two reasons:</p>
<ol>
<li>Neutrality. Claude Code is optimized for Anthropic models. Codex is optimized for OpenAI models. opencode is agnostic, same interface for all.</li>
<li>Automation. opencode exposes a machine-readable JSON format. Claude Code and Codex don&rsquo;t have an equivalent interface for external benchmarking.</li>
</ol>
<p>Cloud models ran in two phases: phase 1 (build the app) and phase 2 (validate local boot, docker build, docker compose). Local models only ran phase 1.</p>
<p>Worth mentioning: the entire benchmark cost less than $10 in tokens on OpenRouter. Apart from GPT 5.4 Pro which torched $7.20 to fail, the other 11 cloud models added up to about $2.50 total. Local models cost only electricity. The point is: running your own benchmark is cheap. If you want to know whether a model works for your use case, drop the $2 and test it. The harness code is on GitHub, just swap the prompt for your own project.</p>
<h2>Why GPT 5.4 failed the benchmark (but not in real life)<span class="hx:absolute hx:-mt-20" id="why-gpt-54-failed-the-benchmark-but-not-in-real-life"></span>
    <a href="#why-gpt-54-failed-the-benchmark-but-not-in-real-life" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>GPT 5.4 Pro is the only cloud model that consistently failed our benchmark. Two separate runs, same result: the model generated files but never reached <code>finish_reason: stop</code>. It always ended on <code>finish_reason: tool-calls</code> — wanted to keep calling tools but the loop kept breaking.</p>
<p>For folks who don&rsquo;t know: tool calling is when an LLM needs to perform an action (read a file, run a command, edit code) and emits a &ldquo;tool call&rdquo; in a structured format. The client (opencode, Claude Code, Codex) interprets it, executes it, and returns the result back to the model. Each provider has its own format: Anthropic uses <code>tool_use</code> blocks, OpenAI uses <code>function_calling</code> with proprietary JSON schemas, Google uses <code>FunctionCall</code>.</p>
<p>GPT 5.4 is heavily trained for OpenAI&rsquo;s native function calling format — <code>tool_choice</code>, <code>tools</code> with proprietary JSON schemas. When the benchmark routes through opencode → OpenRouter → GPT 5.4, the tool schemas get translated at every hop. If GPT emits tool calls in a format that OpenRouter or opencode doesn&rsquo;t parse correctly, the agent loop breaks.</p>
<p>The evidence: every other cloud model (Claude Opus, Claude Sonnet, Kimi K2.5, DeepSeek V3.2, MiniMax M2.7, GLM 5, Qwen 3.6 Plus, Step 3.5 Flash) ended on <code>finish_reason: stop</code>. Only GPT ends on <code>finish_reason: tool-calls</code>.</p>
<p>A fair comparison for GPT 5.4 would require running it in its native environment. And now we have that comparison: we built support to automate the Codex CLI (<code>codex exec</code> with <code>--dangerously-bypass-approvals-and-sandbox</code> and reasoning effort <code>xhigh</code>) and ran the same benchmark. GPT 5.4 completed in 22 minutes, generated all 9 artifacts, wrote 22 tests with the most sophisticated architecture in the entire benchmark: dependency injection of the RubyLLM client, PORO models for <code>ChatMessage</code> and <code>PromptSubmission</code>, session-backed <code>ChatSession</code> with TTL and message trimming, bin/ci script.</p>
<p>But the code breaks on the second message. GPT 5.4 uses <code>chat.add_message(role:, content:)</code> with keyword arguments instead of a positional hash <code>chat.add_message({role:, content:})</code> — this causes <code>ArgumentError: wrong number of arguments (given 0, expected 1)</code> on the first multi-turn exchange. The first message works (uses <code>chat.ask</code> directly), multi-turn doesn&rsquo;t.</p>
<p>And the cost: <strong>7.6 million tokens</strong> on xHigh reasoning effort. That&rsquo;s 65x more tokens than Opus 4.7 (118K) for the same benchmark. Estimated cost of ~$16 per run, versus ~$1.10 for Opus. Spent 15x more and still got the calling convention wrong. A massive token budget and maximum reasoning effort still can&rsquo;t guarantee factual correctness on a gem API. API knowledge is binary recall in the weights, not a function of how hard the model &ldquo;thinks.&rdquo;</p>
<p>With objective data in hand, GPT 5.4 moves from Tier 1 to Tier 2. The architecture it generates is better than Opus in terms of design patterns. But the code needs a fix for multi-turn to work, and the per-token cost is prohibitive.</p>
<p>Sonnet and Opus through opencode/OpenRouter were probably also not pushed to their limits. Claude Code offers native tool support that opencode doesn&rsquo;t replicate — meaning the benchmark results represent a floor, not a ceiling, for those models.</p>
<h2>Open source models: reality vs the narrative<span class="hx:absolute hx:-mt-20" id="open-source-models-reality-vs-the-narrative"></span>
    <a href="#open-source-models-reality-vs-the-narrative" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A lot of people are saying open source models have already caught up with the commercial ones and you can run your own &ldquo;Claude&rdquo; at home. In practice, not really.</p>
<p>The scale isn&rsquo;t comparable. Frontier models like Claude Opus 4.6 and GPT 5.4 are closed-source, but estimates put them in the hundreds of billions to trillions of parameters range, trained with compute and data no open source company can replicate. The best models that fit on reasonable hardware are:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">Total Parameters</th>
          <th style="text-align: right">Active Parameters</th>
          <th>Architecture</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td style="text-align: right">35B</td>
          <td style="text-align: right">3B</td>
          <td>MoE (A3B)</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B</td>
          <td style="text-align: right">27B</td>
          <td style="text-align: right">27B</td>
          <td>Dense</td>
      </tr>
      <tr>
          <td>Qwen 3 32B</td>
          <td style="text-align: right">32B</td>
          <td style="text-align: right">32B</td>
          <td>Dense</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td style="text-align: right">122B</td>
          <td style="text-align: right">122B</td>
          <td>Dense</td>
      </tr>
      <tr>
          <td>GPT OSS 20B</td>
          <td style="text-align: right">20B</td>
          <td style="text-align: right">20B</td>
          <td>Dense</td>
      </tr>
      <tr>
          <td>Gemma 4 31B</td>
          <td style="text-align: right">31B</td>
          <td style="text-align: right">31B</td>
          <td>Dense</td>
      </tr>
  </tbody>
</table>
<p>Post-publication correction: Qwen 3.5 35B is actually the <strong>35B-A3B</strong>, an MoE with only 3B active parameters per token (not dense, as I&rsquo;d originally written). That&rsquo;s why it runs relatively fast for its size. And for folks with 24 GB of VRAM, the model recommended by <a href="https://unsloth.ai/docs/models/qwen3.5#qwen3.5-27b"target="_blank" rel="noopener">Unsloth</a> themselves is the <strong>Qwen 3.5 27B</strong> dense — that one I didn&rsquo;t get around to testing in the benchmark, but it&rsquo;s worth a look. For anyone wanting to dig deeper into local models, <a href="https://x.com/sudoingX"target="_blank" rel="noopener">@sudoingX</a> has been doing some serious experimentation in this space. Thanks to <a href="https://x.com/thpmacedo/status/2041105305111502927"target="_blank" rel="noopener">@thpmacedo</a> for the heads-up.</p>
<p>Even the largest open source MoE (Mixture of Experts) models that companies make publicly available activate only a small fraction of parameters per token:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">Total Parameters</th>
          <th style="text-align: right">Active Parameters</th>
          <th>Notes</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Kimi K2.5</td>
          <td style="text-align: right">1T</td>
          <td style="text-align: right">32B</td>
          <td>384 experts, top-8 + shared</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td style="text-align: right">745B</td>
          <td style="text-align: right">44B</td>
          <td>256 experts, 8 activated</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td style="text-align: right">671B</td>
          <td style="text-align: right">37B</td>
          <td>Sparse Attention</td>
      </tr>
      <tr>
          <td>Qwen 3.5 397B</td>
          <td style="text-align: right">397B</td>
          <td style="text-align: right">17B</td>
          <td>MoE, cloud-only</td>
      </tr>
  </tbody>
</table>
<p>These large models aren&rsquo;t self-hostable. Kimi K2.5 with 1T parameters needs GPU clusters with hundreds of GBs of VRAM. GLM 5 with 745B is the same. Even if Alibaba or Z.AI release the weights (and some do), nobody has home hardware to run them.</p>
<p>What fits on your home GPU are the 20B-35B models — and those have real limitations.</p>
<h3>What each local model did in the benchmark<span class="hx:absolute hx:-mt-20" id="what-each-local-model-did-in-the-benchmark"></span>
    <a href="#what-each-local-model-did-in-the-benchmark" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Results from the original run on the AMD Strix Halo:</p>
<p><strong>Qwen 3 Coder Next (30B)</strong> — Completed in 17 minutes on the Strix, generated 1675 files, Rails app with all the artifacts. But only 3 tests. And more importantly: it invented <code>RubyLLM::Client.new</code>, a class that doesn&rsquo;t exist in the gem. The app doesn&rsquo;t run.</p>
<p><strong>Qwen 3.5 35B</strong> — Completed in 28 minutes on the Strix, 1478 files, 11 tests. Used <code>RubyLLM.chat</code> without a model parameter — works only if the default is configured. No LLM mocking in the tests.</p>
<p><strong>Qwen 3.5 122B</strong> — Completed in 43 minutes on the Strix, 1503 files, 16 tests. But it ignored the RubyLLM gem completely and built a custom HTTP client for OpenRouter. The prompt explicitly asked for ruby_llm.</p>
<p><strong>GLM 4.7 Flash (local, Strix)</strong> — Produced 2029 files with all the artifacts, but the session ended mid-tool-call. The cloud version (GLM 5) works perfectly.</p>
<p><strong>Gemma 4 31B (Strix)</strong> — Infinite tool call loop after ~11 productive steps. Known llama.cpp bug.</p>
<p><strong>GPT OSS 20B (Strix)</strong> — Created the Rails app in the wrong directory (<code>project/app/</code> instead of <code>project/</code>). A 20B model doesn&rsquo;t follow workspace instructions reliably.</p>
<p><strong>Qwen 3 32B (Strix)</strong> — Way too slow (7.3 tok/s). The hardware can&rsquo;t keep up.</p>
<p>And the results from the rerun on the NVIDIA RTX 5090 (all with Q3_K_M or Q4_K_M and contexts between 64k and 128k to fit the 32 GB of VRAM):</p>
<p><strong>Qwen 3.5 35B-A3B (5090)</strong> — 5 minutes at 273 tok/s. Recognizable Rails project, entry point <code>RubyLLM.chat(model:)</code> is right, but it hallucinates <code>chat.add_message(role:, content:)</code> and <code>chat.complete</code> instead of <code>.ask</code>. Fixable in 1-2 follow-ups. The best candidate for &ldquo;OSS local that&rsquo;s actually worth trying.&rdquo;</p>
<p><strong>Qwen 3.5 27B Claude-distilled (5090)</strong> — 12 minutes at 129 tok/s. Impeccable Claude style, total API hallucination (<code>RubyLLM::Chat.new.with_model{}</code>, <code>add_message</code>, <code>response.text</code>). More details in the distillation section below.</p>
<p><strong>Qwen 3 Coder 30B (5090)</strong> — 6 minutes at 145 tok/s. Returned a hardcoded mock string instead of calling the API. Tier 3 unusable.</p>
<p><strong>Qwen 2.5 Coder 32B (5090)</strong> — 90 minutes of timeout, zero files. The model spun without ever calling a write tool.</p>
<p><strong>Qwen 3 32B (5090)</strong> — 4 minutes at 69 tok/s, partial scaffold, errors. The general version is better than the Coder one but still breaks.</p>
<p><strong>Gemma 4 31B (5090)</strong> — 8 minutes at 213 tok/s. Same repetition loop it had on the Strix. The llama.cpp bug isn&rsquo;t a hardware issue.</p>
<p><strong>Qwen 3.5 27B Sushi Coder RL (5090)</strong> — Infrastructure failure (<code>ProviderModelNotFoundError</code>), couldn&rsquo;t be evaluated. Redo on a future run.</p>
<p><strong>GPT OSS 20B (5090)</strong> — Pulled from this run because of a recent llama.cpp main regression in the harmony family tool call parser. The logs show <code>Failed to parse input at pos 755: &lt;|channel|&gt;...</code> in multi-turn sessions. It worked on the Strix with llama.cpp <code>b8643</code>, broken on today&rsquo;s main. Waiting on upstream to fix it.</p>
<h2>Cloud models: what actually works<span class="hx:absolute hx:-mt-20" id="cloud-models-what-actually-works"></span>
    <a href="#cloud-models-what-actually-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Of the 12 models that completed the benchmark, all of them generated a recognizable Rails project with all the requested artifacts (Gemfile, routes, views, JS, tests, README, Dockerfile, docker-compose). 9 out of 9 on the completeness checklist.</p>
<p>But here comes the question that matters: does the code run?</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/cost-vs-quality.png" alt="Cost vs time — and does the code work?"  loading="lazy" /></p>
<p>The correct RubyLLM API is simple:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;anthropic/claude-sonnet-4&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="s2">&#34;Hello&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span><span class="o">.</span><span class="n">content</span>  <span class="c1"># =&gt; &#34;Hi there!&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>8 of the 12 models invented APIs that don&rsquo;t exist. The most common pattern: hallucinating an interface that doesn&rsquo;t match the actual gem:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>What It Invented</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DeepSeek V3.2</td>
          <td><code>RubyLLM::Client.new</code> — nonexistent class</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td><code>RubyLLM::Client.new</code> — same error</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td><code>Openrouter::Client</code> — nonexistent gem</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td><code>add_message()</code> and <code>complete()</code> — nonexistent methods</td>
      </tr>
      <tr>
          <td>MiniMax M2.7</td>
          <td><code>RubyLLM.chat(messages: [...])</code> — nonexistent signature</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td><code>chat.add_message()</code> — nonexistent method</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td><code>RubyLLM::Chat.new()</code> and <code>add_message()</code> — internal API, not public</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td>Ignores the gem completely — uses <code>OpenAI::Client</code> (ruby-openai) hitting the OpenRouter URL directly</td>
      </tr>
  </tbody>
</table>
<p>The models that got it right — both Claudes, GLM 5 and GLM 5.1 — used the simple two-step pattern (<code>chat = RubyLLM.chat(model:)</code> then <code>chat.ask(message)</code>). The ones that got it wrong tried to make RubyLLM look like the OpenAI Python SDK, which is a different thing. Grok 4.20 was the most brazen case: it didn&rsquo;t even try to use the gem, it went straight for <code>OpenAI::Client</code> pointing at the OpenRouter URL, ignoring the explicit prompt.</p>
<p>And the tests? Only Opus, Sonnet, GLM 5 and GLM 5.1 did proper mocking of the LLM calls. All the others either hit the real API (which fails without a key) or mocked the invented API (tests pass but prove nothing). Test count is a misleading metric: Kimi K2.5 wrote 37 tests, more than anyone else, but none of them test real functionality because the API it uses doesn&rsquo;t exist.</p>
<h3>Real viability table<span class="hx:absolute hx:-mt-20" id="real-viability-table"></span>
    <a href="#real-viability-table" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: center">Correct API?</th>
          <th style="text-align: center">Runs?</th>
          <th style="text-align: center">Test Mocking?</th>
          <th>Problem</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Claude Opus 4.7</strong></td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center"><strong>Yes</strong></td>
          <td style="text-align: center">Yes (FakeChat)</td>
          <td>Clean implementation, 28 tests</td>
      </tr>
      <tr>
          <td><strong>Claude Sonnet 4.6</strong></td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center"><strong>Yes</strong></td>
          <td style="text-align: center">Yes (mocha)</td>
          <td>Clean implementation</td>
      </tr>
      <tr>
          <td><strong>Claude Opus 4.6</strong></td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center"><strong>Yes</strong></td>
          <td style="text-align: center">Yes (mocha)</td>
          <td>Clean implementation</td>
      </tr>
      <tr>
          <td><strong>GLM 5</strong></td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center"><strong>Yes</strong></td>
          <td style="text-align: center">Yes (mocha)</td>
          <td>Correct API, works</td>
      </tr>
      <tr>
          <td><strong>GLM 5.1</strong></td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center"><strong>Yes</strong></td>
          <td style="text-align: center">Yes</td>
          <td>Correct API, works</td>
      </tr>
      <tr>
          <td>GPT 5.4 (Codex)</td>
          <td style="text-align: center">Partial</td>
          <td style="text-align: center">1st msg only</td>
          <td style="text-align: center">Yes (FakeChat)</td>
          <td><code>add_message(role:, content:)</code> with keyword args instead of positional hash — breaks multi-turn</td>
      </tr>
      <tr>
          <td>Step 3.5 Flash</td>
          <td style="text-align: center">N/A</td>
          <td style="text-align: center"><strong>Yes</strong>*</td>
          <td style="text-align: center">No</td>
          <td>Bypasses RubyLLM, uses HTTP directly</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td style="text-align: center">N/A</td>
          <td style="text-align: center"><strong>Yes</strong>*</td>
          <td style="text-align: center">No</td>
          <td>Bypasses RubyLLM, uses <code>OpenAI::Client</code> directly</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td style="text-align: center">Partial</td>
          <td style="text-align: center">Only 1st msg</td>
          <td style="text-align: center">No</td>
          <td><code>add_message()</code> doesn&rsquo;t exist</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B</td>
          <td style="text-align: center">Partial</td>
          <td style="text-align: center">Maybe</td>
          <td style="text-align: center">No</td>
          <td>No model parameter</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>add_message()</code>/<code>complete()</code> invented</td>
      </tr>
      <tr>
          <td>MiniMax M2.7</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>RubyLLM.chat</code> signature wrong</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>RubyLLM::Client</code> nonexistent</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>RubyLLM::Client</code> nonexistent</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">Wrong mock</td>
          <td><code>RubyLLM::Chat.new()</code> and <code>add_message()</code> don&rsquo;t exist</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center"><strong>No</strong></td>
          <td style="text-align: center">No</td>
          <td><code>Openrouter::Client</code> gem doesn&rsquo;t exist</td>
      </tr>
  </tbody>
</table>
<p>*Step 3.5 Flash works by calling the OpenRouter REST API directly with <code>Net::HTTP</code>, completely bypassing the gem the prompt asked for.</p>
<p>Now, this doesn&rsquo;t mean those models are useless. If you take Kimi K2.5 or DeepSeek V3.2 and tell it &ldquo;the RubyLLM::Client class doesn&rsquo;t exist, fix it to use the gem&rsquo;s real API&rdquo;, it&rsquo;ll probably fix it. One or two follow-ups and the project becomes functional. Most of the models that failed here could deliver a working project with a few more rounds of conversation.</p>
<p>But that&rsquo;s where the trade-off lives. With Opus or GPT 5.4, the first output already works. You ask, they deliver, you test, it runs. With the cheaper models, you&rsquo;ll spend time fixing API hallucinations, debugging code that &ldquo;looks right&rdquo; but crashes, steering the model in the right direction. Each of those rounds is 10-30 minutes. Three extra rounds and you&rsquo;ve spent an hour of your time to save $0.90 in tokens.</p>
<p>You save dollars, you spend time. And time is money. For someone learning or exploring without urgency, that trade can make sense. For someone who needs to ship, the frontier models pay for themselves fast.</p>
<h3>Comparing the models that work<span class="hx:absolute hx:-mt-20" id="comparing-the-models-that-work"></span>
    <a href="#comparing-the-models-that-work" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Provider</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Tests</th>
          <th style="text-align: right">Cost/Run</th>
          <th>vs Opus</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Opus 4.7</td>
          <td>OpenRouter</td>
          <td style="text-align: right">18m</td>
          <td style="text-align: right">28</td>
          <td style="text-align: right">~$1.10</td>
          <td>New baseline</td>
      </tr>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td>OpenRouter</td>
          <td style="text-align: right">16m</td>
          <td style="text-align: right">30</td>
          <td style="text-align: right">~$0.63</td>
          <td>40% cheaper, more tests</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td>OpenRouter</td>
          <td style="text-align: right">16m</td>
          <td style="text-align: right">16</td>
          <td style="text-align: right">~$1.05</td>
          <td>Previous baseline</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td>OpenRouter</td>
          <td style="text-align: right">17m</td>
          <td style="text-align: right">7</td>
          <td style="text-align: right">~$0.11</td>
          <td>89% cheaper</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td>Z.AI direct</td>
          <td style="text-align: right">22m</td>
          <td style="text-align: right">24</td>
          <td style="text-align: right">~$0.13</td>
          <td>~88% cheaper, more tests than GLM 5</td>
      </tr>
  </tbody>
</table>
<h3>Full ranking by time and tokens<span class="hx:absolute hx:-mt-20" id="full-ranking-by-time-and-tokens"></span>
    <a href="#full-ranking-by-time-and-tokens" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/time-to-complete.png" alt="Time to complete by model"  loading="lazy" /></p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Provider</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Total Tokens</th>
          <th style="text-align: right">Tok/s</th>
          <th style="text-align: right">Cost/Run</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Grok 4.20</td>
          <td>OpenRouter</td>
          <td style="text-align: right">8m</td>
          <td style="text-align: right">63,457</td>
          <td style="text-align: right">412.54</td>
          <td style="text-align: right">~$0.04</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td>OpenRouter</td>
          <td style="text-align: right">14m</td>
          <td style="text-align: right">104,034</td>
          <td style="text-align: right">128.28</td>
          <td style="text-align: right">~$0.50</td>
      </tr>
      <tr>
          <td>MiniMax M2.7</td>
          <td>OpenRouter</td>
          <td style="text-align: right">14m</td>
          <td style="text-align: right">79,743</td>
          <td style="text-align: right">574.52</td>
          <td style="text-align: right">~$0.05</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td>OpenRouter</td>
          <td style="text-align: right">16m</td>
          <td style="text-align: right">136,806</td>
          <td style="text-align: right">347.18</td>
          <td style="text-align: right">~$1.05</td>
      </tr>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td>OpenRouter</td>
          <td style="text-align: right">16m</td>
          <td style="text-align: right">127,067</td>
          <td style="text-align: right">532.26</td>
          <td style="text-align: right">~$0.63</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td>OpenRouter</td>
          <td style="text-align: right">17m</td>
          <td style="text-align: right">59,378</td>
          <td style="text-align: right">400.01</td>
          <td style="text-align: right">~$0.11</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td>OpenRouter</td>
          <td style="text-align: right">17m</td>
          <td style="text-align: right">88,940</td>
          <td style="text-align: right">182.91</td>
          <td style="text-align: right">Free</td>
      </tr>
      <tr>
          <td>Claude Opus 4.7</td>
          <td>OpenRouter</td>
          <td style="text-align: right">18m</td>
          <td style="text-align: right">118,216</td>
          <td style="text-align: right">328.24</td>
          <td style="text-align: right">~$1.10</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td>Z.AI direct</td>
          <td style="text-align: right">22m</td>
          <td style="text-align: right">81,666</td>
          <td style="text-align: right">166.62</td>
          <td style="text-align: right">~$0.13</td>
      </tr>
      <tr>
          <td>GPT 5.4 (Codex)</td>
          <td>Codex CLI</td>
          <td style="text-align: right">22m</td>
          <td style="text-align: right">7,643,800</td>
          <td style="text-align: right">5,824.56</td>
          <td style="text-align: right">~$16.00</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td>Local</td>
          <td style="text-align: right">17m</td>
          <td style="text-align: right">39,054</td>
          <td style="text-align: right">37.49</td>
          <td style="text-align: right">Electricity</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B</td>
          <td>Local</td>
          <td style="text-align: right">28m</td>
          <td style="text-align: right">76,919</td>
          <td style="text-align: right">46.03</td>
          <td style="text-align: right">Electricity</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td>OpenRouter</td>
          <td style="text-align: right">29m</td>
          <td style="text-align: right">63,638</td>
          <td style="text-align: right">160.14</td>
          <td style="text-align: right">~$0.07</td>
      </tr>
      <tr>
          <td>Step 3.5 Flash</td>
          <td>OpenRouter</td>
          <td style="text-align: right">38m</td>
          <td style="text-align: right">156,267</td>
          <td style="text-align: right">242.11</td>
          <td style="text-align: right">~$0.02</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td>Local</td>
          <td style="text-align: right">43m</td>
          <td style="text-align: right">57,472</td>
          <td style="text-align: right">22.41</td>
          <td style="text-align: right">Electricity</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td>OpenRouter</td>
          <td style="text-align: right">60m</td>
          <td style="text-align: right">115,278</td>
          <td style="text-align: right">53.37</td>
          <td style="text-align: right">~$0.04</td>
      </tr>
  </tbody>
</table>
<p>DeepSeek V3.2 is the slowest despite being cloud — it has no prompt caching, so it resends the full context on every turn.</p>
<h3>Token efficiency and cache<span class="hx:absolute hx:-mt-20" id="token-efficiency-and-cache"></span>
    <a href="#token-efficiency-and-cache" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Models with prompt caching pay much less in effective tokens:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/token-efficiency.png" alt="Token efficiency: cache vs new"  loading="lazy" /></p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">Total Tokens</th>
          <th style="text-align: right">Cache Read</th>
          <th style="text-align: right">Effective New Tokens</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td style="text-align: right">127,067</td>
          <td style="text-align: right">126,429</td>
          <td style="text-align: right">638</td>
      </tr>
      <tr>
          <td>Claude Opus 4.7</td>
          <td style="text-align: right">118,216</td>
          <td style="text-align: right">116,824</td>
          <td style="text-align: right">1,392</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td style="text-align: right">136,806</td>
          <td style="text-align: right">135,976</td>
          <td style="text-align: right">830</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td style="text-align: right">59,378</td>
          <td style="text-align: right">58,240</td>
          <td style="text-align: right">1,138</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td style="text-align: right">81,666</td>
          <td style="text-align: right">81,216</td>
          <td style="text-align: right">450</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td style="text-align: right">63,457</td>
          <td style="text-align: right">62,400</td>
          <td style="text-align: right">1,057</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: right">104,034</td>
          <td style="text-align: right">98,129</td>
          <td style="text-align: right">5,905</td>
      </tr>
      <tr>
          <td>GPT 5.4 (Codex)</td>
          <td style="text-align: right">7,643,800</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">7,643,800</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td style="text-align: right">115,278</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">115,278</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td style="text-align: right">63,638</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">63,638</td>
      </tr>
  </tbody>
</table>
<h2>Speed: the chasm between cloud and local<span class="hx:absolute hx:-mt-20" id="speed-the-chasm-between-cloud-and-local"></span>
    <a href="#speed-the-chasm-between-cloud-and-local" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s an aspect that the cost tables hide: inference speed. And the difference is brutal.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/speed-comparison.png" alt="Inference speed by model"  loading="lazy" /></p>
<p>Claude Sonnet generates 532 tok/s. Qwen 3.5 122B running locally on my Minisforum (AMD Strix Halo) generates 22 tok/s. That&rsquo;s a 24x difference. In practice, what Sonnet does in 16 minutes, Qwen 3.5 122B takes 43 minutes. Qwen 3 Coder Next at 37 tok/s is the fastest of the local models on the Strix and even so it&rsquo;s 14x slower than Sonnet.</p>
<p>And it&rsquo;s not just clock time. When you&rsquo;re in an interactive coding loop — ask for a change, wait for output, test, ask for another — the model&rsquo;s speed sets your rhythm. At 37 tok/s, every long response makes you wait 30-60 seconds. At 530 tok/s, it appears almost instantly. Over a day, you feel it.</p>
<p>DeepSeek V3.2 is a curious case: it&rsquo;s cloud but it runs at 53 tok/s, slower than the locally-running Qwen 3.5 35B on the Strix (46 tok/s). The reason is that DeepSeek has no prompt caching — it resends the full context on every turn, strangling throughput. Paying for a cloud model that&rsquo;s slower than running it locally doesn&rsquo;t make any sense.</p>
<p>Local models are free in tokens, but they pay in time. On the AMD Strix, that math was a non-starter for every Qwen I tested: two minutes waiting for a long response, multiplied by 50 turns, eats your whole afternoon. But that changes when the hardware changes, and that&rsquo;s why I redid the local part of the benchmark on a different machine.</p>
<h2>AMD Strix Halo vs NVIDIA RTX 5090: what changes when the memory bandwidth doubles<span class="hx:absolute hx:-mt-20" id="amd-strix-halo-vs-nvidia-rtx-5090-what-changes-when-the-memory-bandwidth-doubles"></span>
    <a href="#amd-strix-halo-vs-nvidia-rtx-5090-what-changes-when-the-memory-bandwidth-doubles" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To check whether the bottleneck was hardware or model, I took the same Qwen models and reran the benchmark on a workstation with an NVIDIA RTX 5090 (Blackwell, 32 GB GDDR7, 1,792 GB/s bandwidth). The numbers shift in a way that&rsquo;s worth looking at carefully.</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: right">AMD Strix (LPDDR5x)</th>
          <th style="text-align: right">NVIDIA 5090 (GDDR7)</th>
          <th style="text-align: right">Speedup</th>
          <th style="text-align: right">Total time on 5090</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3 32B (dense)</td>
          <td style="text-align: right">7 tok/s</td>
          <td style="text-align: right">69 tok/s</td>
          <td style="text-align: right">~10x</td>
          <td style="text-align: right">4 min</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder 30B (Coder)</td>
          <td style="text-align: right">37 tok/s</td>
          <td style="text-align: right">145 tok/s</td>
          <td style="text-align: right">~4x</td>
          <td style="text-align: right">6 min</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B-A3B (MoE)</td>
          <td style="text-align: right">46 tok/s</td>
          <td style="text-align: right"><strong>273 tok/s</strong></td>
          <td style="text-align: right">~6x</td>
          <td style="text-align: right">5 min</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude (distilled)</td>
          <td style="text-align: right">timeout 90m</td>
          <td style="text-align: right">129 tok/s</td>
          <td style="text-align: right">n/a</td>
          <td style="text-align: right">12 min</td>
      </tr>
      <tr>
          <td>Gemma 4 31B</td>
          <td style="text-align: right">(didn&rsquo;t test on Strix)</td>
          <td style="text-align: right">213 tok/s</td>
          <td style="text-align: right">n/a</td>
          <td style="text-align: right">8 min</td>
      </tr>
      <tr>
          <td>Qwen 2.5 Coder 32B</td>
          <td style="text-align: right">(didn&rsquo;t test on Strix)</td>
          <td style="text-align: right">2.86 tok/s</td>
          <td style="text-align: right">n/a</td>
          <td style="text-align: right">timeout 90m</td>
      </tr>
  </tbody>
</table>
<p>To put those speeds in context, remember that in the cloud Sonnet runs at 532 tok/s, Opus at 347 tok/s, Step 3.5 Flash at 242 tok/s, Gemini 3.1 Pro at 128 tok/s and Kimi K2.5 at 160 tok/s. Qwen 3.5 35B-A3B on the 5090, at 273 tok/s, is in the same neighborhood as Step 3.5 Flash, faster than Gemini, Kimi and GLM 5.1. Qwen 3 Coder 30B at 145 tok/s is in Gemini territory. The classic line &ldquo;local models are ten times slower than cloud&rdquo; stopped being true the moment the 5090 entered the conversation.</p>
<p>The practical consequence is that the &ldquo;time is money&rdquo; argument shifts. On the Strix, &ldquo;waiting an hour for a Qwen 3.5 122B to do what Sonnet does in 16 minutes&rdquo; is straight-up loss. On the 5090, waiting 5 minutes for Qwen 3.5 35B-A3B to do the work, plus 10-15 minutes for you to do 1-2 correction prompts, gives you a total in the 20-25 minute range. Sonnet does it in 16 minutes with zero corrections. The difference shrank enough that, if cost matters a lot, it&rsquo;s worth it.</p>
<p>The catch: for this to be worth it, the model has to be close enough to the right answer that 1-2 correction prompts can fix it. When the error is &ldquo;the model decided not to use the gem I asked for and returned a hardcoded mock string,&rdquo; like Qwen 3 Coder 30B did, no easy correction prompt fixes that. That&rsquo;s a redo.</p>
<h3>Before you spend money on hardware thinking it&rsquo;s the answer<span class="hx:absolute hx:-mt-20" id="before-you-spend-money-on-hardware-thinking-its-the-answer"></span>
    <a href="#before-you-spend-money-on-hardware-thinking-its-the-answer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I&rsquo;ve got to give a warning here, because it&rsquo;s the most common buying mistake I see right now. Every other week somebody tells me they&rsquo;re going to grab a Ryzen AI Max because it has 128 GB of unified memory and that &ldquo;lets you run huge models.&rdquo; Technically, sure — the model fits. In practice, it&rsquo;s almost unusable. The memory is LPDDR5x at 256 GB/s, seven times slower than the 5090&rsquo;s GDDR7. What fits doesn&rsquo;t run at human speed. My own Strix with Qwen 3.5 122B hit 22 tok/s and the run took 43 minutes. To do anything serious day to day, that&rsquo;s not workable.</p>
<p>The 5090 is clearly superior, and it starts to make sense even for smaller models precisely because of the memory bandwidth. A Mac Studio with high-speed unified memory (up to 800 GB/s on the M4 Ultra) is the other viable option, and costs proportionally the same. But neither of those comes anywhere close to beating the commercial models on quality — and the per-token price of Claude, GPT or GLM, combined with their brutal inference speed, makes the math hard to justify for anyone who isn&rsquo;t an enthusiast or a researcher. Expensive local AI hardware is a weekend hobby, a tool for people who need to run offline for compliance reasons, or a research playground. For day-after-day production work, right now, cloud is still the rational choice. A 128 GB Ryzen AI Max may look tempting on the spec sheet, but if the goal is serious coding agent work, it&rsquo;s money badly spent.</p>
<h2>The Qwen family: Coder vs General, distillation, and why nothing is a silver bullet<span class="hx:absolute hx:-mt-20" id="the-qwen-family-coder-vs-general-distillation-and-why-nothing-is-a-silver-bullet"></span>
    <a href="#the-qwen-family-coder-vs-general-distillation-and-why-nothing-is-a-silver-bullet" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>With so many different Qwens running in this rerun, it&rsquo;s worth doing a more focused analysis. What I learned might surprise people who follow model benchmarks on Twitter.</p>
<h3>Before getting to the results: what quantization is and what distillation is<span class="hx:absolute hx:-mt-20" id="before-getting-to-the-results-what-quantization-is-and-what-distillation-is"></span>
    <a href="#before-getting-to-the-results-what-quantization-is-and-what-distillation-is" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>These two concepts come up constantly in this discussion and they deserve a quick explanation.</p>
<p><strong>Quantization</strong> is the technique of compressing the model&rsquo;s weights so they take up less memory. A model trained in FP16 (16 bits per weight) can be quantized to Q8 (8 bits), Q4 (4 bits), Q3_K_M (3 bits, but with medium-sized groupings), and so on. Each step halves the size of the model on disk and in VRAM, at the cost of some loss of precision. Q8 is practically lossless. Q4 already loses something measurable. Q3 loses more. Q2 is the line where the model starts saying real nonsense. The rule of thumb is that for coding and multi-step reasoning, you want to stay at Q4 or higher. Q3_K_M is the minimum that still works for many models, and it&rsquo;s what fits a 27B on the 5090 with 128k context.</p>
<p>The surprise from my test, and look, this goes against the consensus, is that quantization wasn&rsquo;t the bottleneck here. I ran the Qwen 3.5 27B Claude-distilled in two versions: Q8 on the AMD Strix (~27 GB of weights) and Q3_K_M on the 5090 (~12 GB of weights). Both hallucinated exactly the same fake RubyLLM APIs. Q3_K_M even produced a cleaner Gemfile. The model&rsquo;s limitation was in what those weights know, not in the precision they were compressed to.</p>
<p><strong>Distillation</strong> is the technique of training a smaller model (the &ldquo;student&rdquo;) to imitate the output or behavior of a larger model (the &ldquo;teacher&rdquo;). The classic version is logit distillation — the student learns to approximate the teacher&rsquo;s probability distributions. The modern, more popular version for coding agents is distillation of <strong>reasoning traces</strong>: you take chain-of-thought from the big model on real problems and train the smaller one to reproduce the same reasoning style.</p>
<p>The hype of the moment is distilling Claude and GPT into open source models. The promise is that you can have &ldquo;Claude-at-home&rdquo; running locally. I wanted to test this, and that&rsquo;s why I added <a href="https://huggingface.co/Jackrong/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled"target="_blank" rel="noopener">Jackrong&rsquo;s Qwen 3.5 27B distilled from Claude 4.6 Opus</a> to the benchmark. If any open source model was going to use RubyLLM correctly, this was the bet — after all, in the entire benchmark, Claude and GLM 5 are the only ones that get the API right.</p>
<h3>What the Claude-distilled learned (and what it didn&rsquo;t)<span class="hx:absolute hx:-mt-20" id="what-the-claude-distilled-learned-and-what-it-didnt"></span>
    <a href="#what-the-claude-distilled-learned-and-what-it-didnt" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I ran the same distillation twice: once at Q8 on the AMD Strix (which blew through the 90-minute timeout), and once at Q3_K_M on the 5090 (completed in 12 minutes). Both produced the same elegant frustration.</p>
<p>The code that comes out looks like Claude. It has <code># frozen_string_literal: true</code> at the top of every file. It has a separate <code>Response</code> class as a value object with explicit attribute readers. It has a clear separation between service, controller and model. It has doc comments at the top of every file. It correctly comments out things like <code>active_record</code>, <code>active_job</code> and <code>action_mailer</code> in <code>application.rb</code>. It has defensive <code>case</code> statements trying multiple return formats. Stylistically, it&rsquo;s Claude.</p>
<p>Functionally, it&rsquo;s a complete RubyLLM hallucination. Look at the service generated by the 5090 run:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">RubyLLM</span><span class="o">::</span><span class="no">Chat</span><span class="o">.</span><span class="n">new</span><span class="o">.</span><span class="n">with_model</span><span class="p">(</span><span class="vi">@model</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">chat</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">conversation_history</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">chat</span><span class="o">.</span><span class="n">add_message</span><span class="p">(</span><span class="ss">role</span><span class="p">:</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">msg</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">Response</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="n">response</span><span class="o">.</span><span class="n">text</span><span class="p">,</span> <span class="ss">usage</span><span class="p">:</span> <span class="n">build_usage</span><span class="p">(</span><span class="n">response</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Every primitive in this code is invented:</p>
<ul>
<li><code>RubyLLM::Chat.new</code> — the constructor isn&rsquo;t public, the correct entry is <code>RubyLLM.chat(model:)</code></li>
<li><code>.with_model(@model) do |chat| ... end</code> — there&rsquo;s no block API like that</li>
<li><code>chat.add_message(role:, content:)</code> — doesn&rsquo;t exist</li>
<li><code>response.text</code> — the real API exposes <code>response.content</code></li>
<li><code>response.usage.prompt_tokens</code> — the object doesn&rsquo;t have that shape</li>
</ul>
<p>This will blow up with a <code>NoMethodError</code> on the first request. The initializer also tries <code>config.openrouter_api_base=</code> which doesn&rsquo;t exist on <code>RubyLLM.configure</code>, so the app probably won&rsquo;t even boot.</p>
<p>The Q8 version on the AMD Strix does the exact same thing, with one difference: the entry call is <code>RubyLLM.chat(model:, provider: :openrouter)</code> — the entry point is right, but <code>provider:</code> is invented and it&rsquo;s immediately followed by the same fake <code>chat.add_message(role:, content:)</code>. Worse, the Gemfile from the 90-minute run lists <code>gem &quot;ruby-openai&quot;</code> (wrong gem!), <code>gem &quot;minitest&quot;, &quot;~&gt; 6.0&quot;</code> (minitest 6.0 doesn&rsquo;t exist) and <code>gem &quot;tailwindcss&quot;</code> (wrong gem name, it&rsquo;s <code>tailwindcss-rails</code>). The Gemfile doesn&rsquo;t include the gem the service code itself is trying to use.</p>
<p>For comparison, look at the actual Claude Opus 4.6 baseline, in the same benchmark, getting it all right:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="vi">@chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="n">model_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="vi">@chat</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span><span class="o">.</span><span class="n">content</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Twelve lines in the entire service. Zero hallucination. Includes streaming via block. The distilled model produced three times the code volume and got the API wrong.</p>
<p>The honest reading is that distillation transferred one layer and stopped. The layer that came along was the style: code organization, comments, class structure, the order of things. The layer that got left behind was factual memory about specific libraries. That makes sense when you think about it: Claude&rsquo;s reasoning traces, even when written carefully, rarely contain repeated references to <code>chat.ask(msg).content</code> in some obscure Ruby gem. The student only learns what the teacher repeats, and Claude never had any reason to keep whispering &ldquo;use ask, not complete&rdquo; throughout its chains of thought. Library API knowledge is binary recall memory, the kind that&rsquo;s either in the weights or it isn&rsquo;t. Decomposing that into reasoning steps is impossible because it isn&rsquo;t reasoning, it&rsquo;s just raw memorization.</p>
<p>To wrap up the practical recommendation: if you need the model to actually use RubyLLM, or any less-popular library for that matter, Claude distillation won&rsquo;t save you. Use real Claude or GLM 5. The &ldquo;Claude-stand-ins&rdquo; in open source will fail the same way the Qwen base would, just with prettier handwriting.</p>
<h3>Coder vs General: the surprise of the &ldquo;for coding&rdquo; models<span class="hx:absolute hx:-mt-20" id="coder-vs-general-the-surprise-of-the-for-coding-models"></span>
    <a href="#coder-vs-general-the-surprise-of-the-for-coding-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Almost everyone&rsquo;s instinct is that models with &ldquo;Coder&rdquo; in the name are the best for programming. Makes sense, they were specifically fine-tuned on code. But in the benchmark, it was exactly the opposite.</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Type</th>
          <th>Hardware</th>
          <th style="text-align: right">Time</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td>General (MoE)</td>
          <td>5090</td>
          <td style="text-align: right">5 min</td>
          <td>Runs Rails, hallucinates <code>add_message</code>/<code>complete</code> (1-2 follow-ups fix it)</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder 30B</td>
          <td>Coder</td>
          <td>5090</td>
          <td style="text-align: right">6 min</td>
          <td>Returned a hardcoded mock string instead of calling RubyLLM</td>
      </tr>
      <tr>
          <td>Qwen 2.5 Coder 32B</td>
          <td>Coder</td>
          <td>5090</td>
          <td style="text-align: right">timeout 90m</td>
          <td>Zero files, model froze</td>
      </tr>
      <tr>
          <td>Qwen 3 32B</td>
          <td>General</td>
          <td>5090</td>
          <td style="text-align: right">4 min</td>
          <td>Partial scaffold, errors</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude-distilled</td>
          <td>General + distilled</td>
          <td>5090</td>
          <td style="text-align: right">12 min</td>
          <td>Runs Rails, hallucinates the entire API</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Sushi Coder RL</td>
          <td>Coder (RL)</td>
          <td>5090</td>
          <td style="text-align: right">6 min</td>
          <td>Infrastructure failure, couldn&rsquo;t be tested</td>
      </tr>
  </tbody>
</table>
<p>Of the three dedicated Coders, two failed catastrophically (full timeout and hardcoded mock string) and one didn&rsquo;t even run properly because of an infra bug. Meanwhile, the Qwen 3.5 35B-A3B, which is the general model in the line (not the Coder), came closest to something usable: 5 minutes of execution, recognizable Rails project, and the problem is fixable in 1-2 prompts.</p>
<p>Qwen 3 Coder 30B is particularly disappointing. It went so far past trying to use the API that it didn&rsquo;t really try at all: the controller it generated has this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Api</span><span class="o">::</span><span class="no">V1</span><span class="o">::</span><span class="no">MessagesController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">create</span>
</span></span><span class="line"><span class="cl">    <span class="n">render</span> <span class="ss">json</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;This is a mock response. In a real implementation, this would connect to RubyLLM with Claude Sonnet via OpenRouter.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The Gemfile lists <code>gem &quot;ruby_llm&quot;</code> but nothing imports it. The service layer is nonexistent. The model decided it was easier to return a fake string and call it a day. That&rsquo;s Tier 3 garbage in a way no correction prompt fixes — you have to tell it to start over.</p>
<p>Qwen 2.5 Coder 32B is even worse: 90 minutes running, zero files. The 1.8 MB <code>opencode-output.ndjson</code> shows the model spinning without managing to write anything. It probably got stuck in a planning loop without ever calling the write tools. Total slot waste.</p>
<p>Why did the &ldquo;Coder&rdquo; Qwens do so badly? My read is that the coding-specific fine-tuning they got was trained on more isolated problems (Codeforces, Leetcode, short snippets), far from agentic flows with long-running tool calling. The general Qwen 3.5 35B-A3B has broader training and handles the orchestration part better. The popular intuition &ldquo;Coder = best for coding agent&rdquo; is wrong for this kind of task. The use case where Coders shine is &ldquo;complete an isolated function,&rdquo; which is exactly what they were trained for, and that&rsquo;s a tiny fraction of what a coding agent does day to day.</p>
<h3>The question I wanted to answer<span class="hx:absolute hx:-mt-20" id="the-question-i-wanted-to-answer"></span>
    <a href="#the-question-i-wanted-to-answer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>It was this: running locally on the 5090, which Qwen model is worth the 1-2 correction prompts to deliver code that works?</p>
<p>The honest answer is: only Qwen 3.5 35B-A3B, and maybe the Claude-distilled if you don&rsquo;t mind spending 12 minutes more.</p>
<ul>
<li>Qwen 3.5 35B-A3B on the 5090: 5 minutes, correct entry point (<code>RubyLLM.chat(model:)</code>), errors on the subsequent calls. Realistic total until it works: in the 15-20 minute range with 1-2 follow-ups. Beats cloud OSS on cost.</li>
<li>Qwen 3.5 27B Claude-distilled on the 5090: 12 minutes, deeper hallucination (entry point is invented too). Realistic total: 25-30 minutes with 2-3 follow-ups. Still competes on cost, and loses on absolute time to the real Claude.</li>
<li>The others (Coder 30B, Coder 2.5 32B, 3 32B): don&rsquo;t pay back the correction time. Each one has a structural problem that calls for a full rewrite from scratch.</li>
</ul>
<p>For folks with hardware in this category who want to escape Anthropic vendor lock-in, it now works. It didn&rsquo;t work on the 5090 from last year, and forget about it on the Strix Halo. In 2026, on NVIDIA Blackwell, with the right model, it works. For folks with low-bandwidth hardware (LPDDR5x, DDR4, DDR5), it&rsquo;s still a waste of time: the clock alone takes down any plan to make this practical.</p>
<h3>Qwen 3.6: what changed from 3.5<span class="hx:absolute hx:-mt-20" id="qwen-36-what-changed-from-35"></span>
    <a href="#qwen-36-what-changed-from-35" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>We tested two flavors of Qwen 3.6: the <strong>3.6 Plus</strong> (cloud, OpenRouter, free) and the <strong>3.6 35B</strong> (local, NVIDIA 5090, Q3_K_M).</p>
<p>Qwen 3.6 Plus (cloud) completed in 17 minutes with 88,940 tokens and 9/9 on the artifact checklist. Completed fast and for free. But the generated service uses <code>chat.add_message()</code>, a method that doesn&rsquo;t exist in RubyLLM. First message works, second one crashes. Same problem as 3.5.</p>
<p>Qwen 3.6 35B (local, 5090) is more interesting. Completed in 4.7 minutes at 240 tok/s, 169 files, correct entry point <code>RubyLLM.chat(model:, provider:)</code> and correct <code>chat.ask(message)</code>. The bug is subtler: it returns <code>response</code> instead of <code>response.content</code> and doesn&rsquo;t do history replay. One-line fix. That&rsquo;s a real improvement over Qwen 3.5 35B-A3B (which hallucinated <code>add_message</code> and <code>complete</code>). It&rsquo;s the cleanest Qwen result we&rsquo;ve seen.</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Version</th>
          <th>Hardware</th>
          <th style="text-align: center">Correct API?</th>
          <th style="text-align: center">Multi-turn works?</th>
          <th style="text-align: right">Time</th>
          <th style="text-align: right">Tok/s</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td>3.5</td>
          <td>NVIDIA 5090</td>
          <td style="text-align: center">Entry point yes, <code>add_message</code>/<code>complete</code> no</td>
          <td style="text-align: center">No</td>
          <td style="text-align: right">5m</td>
          <td style="text-align: right">273</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude-distilled</td>
          <td>3.5</td>
          <td>NVIDIA 5090</td>
          <td style="text-align: center">No (entry point invented)</td>
          <td style="text-align: center">No</td>
          <td style="text-align: right">12m</td>
          <td style="text-align: right">129</td>
      </tr>
      <tr>
          <td>Qwen 3.6 35B</td>
          <td>3.6</td>
          <td>NVIDIA 5090</td>
          <td style="text-align: center">Entry point yes, <code>chat.ask</code> yes, missing <code>.content</code></td>
          <td style="text-align: center">No (no replay)</td>
          <td style="text-align: right">5m</td>
          <td style="text-align: right">240</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td>3.6</td>
          <td>Cloud</td>
          <td style="text-align: center">Entry point yes, <code>add_message</code> no</td>
          <td style="text-align: center">No</td>
          <td style="text-align: right">17m</td>
          <td style="text-align: right">183</td>
      </tr>
  </tbody>
</table>
<p>The gap shrank but didn&rsquo;t close. The 3.6 35B local is closer to working than any previous Qwen — the bug is a forgotten <code>.content</code>, not an entirely invented API. But it still doesn&rsquo;t do multi-turn out of the box. In practice, Qwen 3.6 35B local moves up from Tier 3 to Tier 2: it&rsquo;s the local open source model that comes closest to delivering correct code on the first try, one fix away from working.</p>
<h2>The Deep Code Review: Sonnet vs GLM 5 vs Gemini vs Kimi vs MiniMax<span class="hx:absolute hx:-mt-20" id="the-deep-code-review-sonnet-vs-glm-5-vs-gemini-vs-kimi-vs-minimax"></span>
    <a href="#the-deep-code-review-sonnet-vs-glm-5-vs-gemini-vs-kimi-vs-minimax" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The tables above measure structural completeness. But does the project work? I did detailed code review of the models that completed the benchmark.</p>
<p><strong>Claude Sonnet 4.6 — works and is the most complete.</strong> Synchronous responses via Turbo Stream. Chat history persisted in a session cookie with full replay of previous messages on every request. Correct LLM mocking in the tests with mocha (30 tests in 328 lines). LLM logic extracted into a separate <code>LlmChatService</code>. Views decomposed into 9 partials. Minor problems: duplicated model constant, leak in the auto-resize event listener. None are blockers. Of the generated projects, it&rsquo;s the closest to something you&rsquo;d actually put into production.</p>
<p><strong>GLM 5 — works, but it&rsquo;s the bare minimum.</strong> Uses the correct API (<code>RubyLLM.chat(model:)</code> then <code>.ask()</code>), does mocking with mocha in the tests. But the project is way leaner than Sonnet&rsquo;s: 21-line controller (vs Sonnet&rsquo;s 52), no service layer (LLM logic inline in the controller), no chat history persistence, every message handled in isolation. The first message works, but the app doesn&rsquo;t keep conversation context, so you can&rsquo;t have a multi-turn dialog. The tests exist (7 methods) but they&rsquo;re skeletal: <code>ruby_llm_test.rb</code> only checks that the module is loaded, <code>chat_flow_test.rb</code> is a copy of the controller test. The Dockerfile, on the other hand, is the best of the four: multi-stage, non-root, jemalloc. But as a chat app? It&rsquo;s more of a proof of concept than something functional. Funny detail: the README says &ldquo;Powered by Claude Sonnet 4&rdquo; instead of the model that actually generated the project.</p>
<p><strong>Gemini 3.1 Pro — the fastest, but trips on the API.</strong> Completed in 14 minutes, the fastest along with MiniMax. The Rails code itself is well written: uses <code>Rails.cache</code> with session ID and a 2-hour expiration to keep state (instead of a database), Turbo Streams nicely integrated, Stimulus controller for auto-scroll, and the Dockerfile is the best of the group (multi-stage, non-root, jemalloc). The problem is the usual one: it uses <code>RubyLLM::Chat.new()</code> instead of <code>RubyLLM.chat()</code>, and calls <code>add_message()</code> which doesn&rsquo;t exist. The app boots, Docker runs, the health check passes, but the first chat message returns 500. The tests (5 methods) mock with a <code>FakeChat</code> that replicates the wrong signature, so they pass. It&rsquo;s frustrating because the rest of the code is the most &ldquo;Rails way&rdquo; of the non-Anthropic models. Fixing it would be 3 lines, but the benchmark measures what comes out the first time.</p>
<p><strong>Kimi K2.5 — ambitious but broken.</strong> Tried the most sophisticated architecture: ActionCable streaming, configurable models, dual Dockerfiles, 37 tests in 374 lines. Problem: the streaming depends on ActionCable, which is commented out in <code>config/application.rb</code>. The <code>return unless defined?(ActionCable)</code> guard makes the method do nothing. The assistant never responds. The Stimulus controller has a scope bug: <code>submitTarget</code> references a button outside the controller&rsquo;s subtree. Thread-unsafe storage with a hash in a class variable. Kimi wrote more tests than any other model (37), but none of them mock the LLM calls — so the tests pass without proving any of the functionality works.</p>
<p><strong>Grok 4.20 — fast and wrong.</strong> It was the fastest in the entire benchmark: 8 minutes, 412 tok/s. Except it was fast because it cut corners. The prompt explicitly asked for the <code>ruby_llm</code> gem, and Grok ignored it. It went straight for <code>OpenAI::Client</code> from the <code>ruby-openai</code> gem pointing at the OpenRouter URL. Technically the first message comes back, so yeah, it &ldquo;works.&rdquo; But it&rsquo;s the same trick as Step 3.5 Flash and Qwen 3.5 122B: skip the part that was actually being tested. No history, 33-line controller calling the HTTP client by hand, two tests, no real mocks. It was fast because it did less than what was asked.</p>
<p><strong>MiniMax M2.7 — looks right, crashes.</strong> Calls <code>RubyLLM.chat(model: '...', messages: [...])</code> — that signature doesn&rsquo;t exist. No message persistence. Duplicated HTML (DOCTYPE inside the layout). Committed master.key. And the tests? They mock the wrong API, so they pass but they don&rsquo;t prove anything.</p>
<p>Code review summary:</p>
<table>
  <thead>
      <tr>
          <th>Aspect</th>
          <th style="text-align: center">Sonnet 4.6</th>
          <th style="text-align: center">GLM 5</th>
          <th style="text-align: center">Gemini 3.1 Pro</th>
          <th style="text-align: center">Kimi K2.5</th>
          <th style="text-align: center">MiniMax M2.7</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Correct API</td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center">No</td>
      </tr>
      <tr>
          <td>Chat history</td>
          <td style="text-align: center">Session cookie</td>
          <td style="text-align: center">None</td>
          <td style="text-align: center">Rails.cache (2h)</td>
          <td style="text-align: center">Broken (ActionCable off)</td>
          <td style="text-align: center">None</td>
      </tr>
      <tr>
          <td>Service layer</td>
          <td style="text-align: center">LlmChatService</td>
          <td style="text-align: center">Inline in controller</td>
          <td style="text-align: center">LlmService</td>
          <td style="text-align: center">LlmService</td>
          <td style="text-align: center">ChatService (wrong API)</td>
      </tr>
      <tr>
          <td>Tests (methods)</td>
          <td style="text-align: center">30</td>
          <td style="text-align: center">7</td>
          <td style="text-align: center">5</td>
          <td style="text-align: center">37</td>
          <td style="text-align: center">12</td>
      </tr>
      <tr>
          <td>LLM mocking</td>
          <td style="text-align: center">Yes (mocha)</td>
          <td style="text-align: center">Yes (mocha)</td>
          <td style="text-align: center">FakeChat (wrong API)</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center">Mocks wrong API</td>
      </tr>
      <tr>
          <td>Dockerfile</td>
          <td style="text-align: center">Multi-stage</td>
          <td style="text-align: center">Multi-stage + jemalloc</td>
          <td style="text-align: center">Multi-stage + jemalloc</td>
          <td style="text-align: center">Dual (dev/prod)</td>
          <td style="text-align: center">Single-stage</td>
      </tr>
      <tr>
          <td>Actually runs?</td>
          <td style="text-align: center">Yes</td>
          <td style="text-align: center">Yes (no history)</td>
          <td style="text-align: center">No (500 in chat)</td>
          <td style="text-align: center">No</td>
          <td style="text-align: center">No</td>
      </tr>
  </tbody>
</table>
<h3>GLM 5 vs GLM 5.1: what changed<span class="hx:absolute hx:-mt-20" id="glm-5-vs-glm-51-what-changed"></span>
    <a href="#glm-5-vs-glm-51-what-changed" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>GLM 5 was one of the few models that spat out functional code on the first try, so it was obvious to test the new version. One important detail before the numbers: GLM 5 ran via OpenRouter, GLM 5.1 wasn&rsquo;t there yet when I ran this test, so I used the Z.AI direct API. Different provider, different infra, different cache. The numbers below are reference, not exact measurement.</p>
<table>
  <thead>
      <tr>
          <th>Aspect</th>
          <th>GLM 5</th>
          <th>GLM 5.1</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Provider</td>
          <td>OpenRouter</td>
          <td>Z.AI direct</td>
      </tr>
      <tr>
          <td>Total time</td>
          <td>17m</td>
          <td>22m</td>
      </tr>
      <tr>
          <td>Tok/s (final phase)</td>
          <td>400</td>
          <td>167</td>
      </tr>
      <tr>
          <td>Effective new tokens</td>
          <td>1,138</td>
          <td>450</td>
      </tr>
      <tr>
          <td>Cache read</td>
          <td>58,240</td>
          <td>81,216</td>
      </tr>
      <tr>
          <td>Correct RubyLLM API</td>
          <td>Yes</td>
          <td>Yes</td>
      </tr>
      <tr>
          <td>Test mocking</td>
          <td>Yes (mocha)</td>
          <td>Yes</td>
      </tr>
      <tr>
          <td>Tests</td>
          <td>7</td>
          <td>24</td>
      </tr>
      <tr>
          <td>Chat history</td>
          <td>No</td>
          <td>Yes (in-memory)</td>
      </tr>
      <tr>
          <td>Service layer</td>
          <td>Inline in controller</td>
          <td><code>ChatSession</code> model with <code>add_user_message</code>/<code>add_assistant_message</code></td>
      </tr>
  </tbody>
</table>
<p>The GLM 5.1 project came out way more complete. 24 tests vs 7. Real separation between <code>ChatSession</code>, <code>ChatMessage</code> and the controller, instead of GLM 5 cramming everything inline. Chat history persisted in memory during the session, so you can actually have a real multi-turn conversation (GLM 5 treated every message like it was the first). And the RubyLLM API is still correct, the same <code>RubyLLM.chat(model:, provider:)</code> pattern followed by <code>c.user</code>/<code>c.assistant</code> to build the context. There&rsquo;s even a test covering the <code>MODEL</code> constant, which usually nobody does.</p>
<p>The price was speed. 22 minutes vs 17, and throughput dropped from 400 to 167 tok/s. Could be the provider (Z.AI direct isn&rsquo;t the same infra as OpenRouter), could be a more loaded server during the run, could be that 5.1 reasons more. I didn&rsquo;t run it multiple times to take an average, so I won&rsquo;t say 5.1 is &ldquo;slower.&rdquo; A single run doesn&rsquo;t prove a regression. What I can say is that, in my test, 5.1 delivered a better-structured project and took a bit longer to do it.</p>
<p>For folks who want to get out from under Anthropic without losing quality, GLM 5 and GLM 5.1 are the two options that work. If you need centralized billing on OpenRouter, GLM 5. If you can use Z.AI direct and want a more rounded project on the first try, GLM 5.1.</p>
<h2>Costs: API vs Subscription<span class="hx:absolute hx:-mt-20" id="costs-api-vs-subscription"></span>
    <a href="#costs-api-vs-subscription" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>First, the per-token price of each model on OpenRouter:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/token-pricing.png" alt="Per-token price on OpenRouter"  loading="lazy" /></p>
<p>GPT 5.4 Pro charges $180 per million output tokens. Claude Opus charges $25. GLM 5 charges $2.30. And Qwen 3.6 Plus is free (with a rate limit). The log scale on the chart hides some of the brutality of the gap: from free Qwen to GPT 5.4 Pro is orders of magnitude.</p>
<p>But per-token price isn&rsquo;t the whole story. If you use Claude or GPT daily for coding, the monthly subscription can come out way cheaper than paying per token via the API:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/llm-benchmark/en/monthly-pricing.png" alt="Subscription vs API: how much it costs to use Claude and GPT per month"  loading="lazy" /></p>
<table>
  <thead>
      <tr>
          <th>Approach</th>
          <th style="text-align: right">Est. $/month*</th>
          <th>Notes</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Qwen 3.6 Plus (OpenRouter)</td>
          <td style="text-align: right">$0</td>
          <td>Free but rate-limited</td>
      </tr>
      <tr>
          <td>Local models</td>
          <td style="text-align: right">Electricity</td>
          <td>Needs hardware</td>
      </tr>
      <tr>
          <td>Claude Pro</td>
          <td style="text-align: right">$20</td>
          <td>~44K tokens/5hr</td>
      </tr>
      <tr>
          <td>ChatGPT Plus</td>
          <td style="text-align: right">$20</td>
          <td>Includes Codex</td>
      </tr>
      <tr>
          <td>Claude Max 5x</td>
          <td style="text-align: right">$100</td>
          <td>~88K tokens/5hr</td>
      </tr>
      <tr>
          <td>Claude Sonnet (OpenRouter API)</td>
          <td style="text-align: right">~$150</td>
          <td>No cap, pay-as-you-go</td>
      </tr>
      <tr>
          <td>Claude Max 20x</td>
          <td style="text-align: right">$200</td>
          <td>~220K tokens/5hr</td>
      </tr>
      <tr>
          <td>ChatGPT Pro</td>
          <td style="text-align: right">$200</td>
          <td>GPT 5.4 Pro unlimited</td>
      </tr>
      <tr>
          <td>Claude Opus (OpenRouter API)</td>
          <td style="text-align: right">~$450</td>
          <td>No cap, pay-as-you-go</td>
      </tr>
      <tr>
          <td>GPT 5.4 Pro (OpenRouter API)</td>
          <td style="text-align: right">~$990</td>
          <td>Absurdly expensive</td>
      </tr>
  </tbody>
</table>
<p>*Estimate for moderate coding use (~15M input + ~3M output tokens/month).</p>
<p>The main point: if you use GPT 5.4 Pro, the ChatGPT Pro subscription at $200/month with unlimited use is 5x cheaper than paying per token on the API. For Claude, Pro at $20/month covers light use, but for heavy users (a coding marathon like mine), the Max 20x at $200/month comes out cheaper than paying for Opus per token on OpenRouter (~$450/month). The open source models on OpenRouter all sit below $2.50/M output tokens, but as we saw, most of them generate code that doesn&rsquo;t run.</p>
<h2>What works for real use<span class="hx:absolute hx:-mt-20" id="what-works-for-real-use"></span>
    <a href="#what-works-for-real-use" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After testing 33 models across both runs and looking at the generated code in detail:</p>
<p>Tier 1 (works plug and play):</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Quality</th>
          <th style="text-align: right">Cost/Run</th>
          <th>Trade-off</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Opus 4.7</td>
          <td>New baseline (28 tests, FakeChat, 96.7% coverage)</td>
          <td style="text-align: right">~$1.10</td>
          <td>Incremental over 4.6, new gold standard</td>
      </tr>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td>Better than Opus 4.6 on opencode (30 vs 16 tests)</td>
          <td style="text-align: right">~$0.63</td>
          <td>Cheaper, but fails on deeper reasoning in real projects</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td>Previous gold standard</td>
          <td style="text-align: right">~$1.05</td>
          <td>Previous baseline</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td>Good (7 tests, correct API)</td>
          <td style="text-align: right">~$0.11</td>
          <td>89% cheaper, non-Anthropic/OpenAI alternative that works</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td>Good (24 tests, history, correct API)</td>
          <td style="text-align: right">~$0.13</td>
          <td>~88% cheaper, more complete project than GLM 5</td>
      </tr>
  </tbody>
</table>
<p>Tier 2 (works with caveats):</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: center">Hardware</th>
          <th style="text-align: right">Cost/Run</th>
          <th>Caveat</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GPT 5.4 (Codex)</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: right">~$16.00</td>
          <td>Impressive architecture (22 tests, dependency injection, PORO models), but <code>add_message</code> with keyword args instead of positional hash breaks multi-turn. 7.6M tokens, 15x more expensive than Opus</td>
      </tr>
      <tr>
          <td>Step 3.5 Flash</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: right">~$0.02</td>
          <td>Bypasses the requested gem, slow (38m)</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: right">~$0.04</td>
          <td>Bypasses the requested gem (goes straight to <code>OpenAI::Client</code>), but it&rsquo;s the fastest in the benchmark</td>
      </tr>
      <tr>
          <td>Qwen 3.6 35B</td>
          <td style="text-align: center">NVIDIA 5090</td>
          <td style="text-align: right">Free</td>
          <td>Entry point and <code>chat.ask</code> correct, missing <code>.content</code>. One-line fix. ~10-15 min total</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td style="text-align: center">NVIDIA 5090</td>
          <td style="text-align: right">Free</td>
          <td>Correct entry point, hallucinates <code>add_message</code>/<code>complete</code>. Fixable in 1-2 follow-ups. ~15-20 min total</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude-distilled</td>
          <td style="text-align: center">NVIDIA 5090</td>
          <td style="text-align: right">Free</td>
          <td>Claude style, complete API hallucination. 2-3 follow-ups to fix. ~25-30 min total</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B (local)</td>
          <td style="text-align: center">AMD Strix</td>
          <td style="text-align: right">Free</td>
          <td>Works if default is configured, no mocking, and slow</td>
      </tr>
  </tbody>
</table>
<p>Tier 3 (broken code, easier to redo than to fix):</p>
<p>Kimi K2.5, MiniMax M2.7, DeepSeek V3.2, Gemini 3.1 Pro, Qwen 3 Coder Next (Strix), Qwen 3 Coder 30B (5090, returned a hardcoded mock string), Qwen 3.5 122B, Qwen 3.6 Plus — all of them either invent APIs that don&rsquo;t exist or don&rsquo;t even try to use the gem.</p>
<p>Tier 4 (didn&rsquo;t complete):</p>
<p>Gemma 4 (infinite loop on both hardware), Llama 4 Scout (no parser), GPT OSS 20B (wrong directory on Strix, parser regression on 5090), Qwen 3 32B (too slow on Strix, partial scaffold on 5090), Qwen 2.5 Coder 32B (90m timeout with zero files).</p>
<h3>Simplified ranking (quality, time, price)<span class="hx:absolute hx:-mt-20" id="simplified-ranking-quality-time-price"></span>
    <a href="#simplified-ranking-quality-time-price" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>For folks who only want the report-card summary. Quality is whether the code runs and how complete it is. Time is the total runtime. Price is the estimated cost per execution on opencode. <strong>Hardware</strong> indicates where the model ran — Cloud, Strix (AMD Strix Halo, LPDDR5x 256 GB/s) or 5090 (NVIDIA RTX 5090, GDDR7 1792 GB/s). Cloud models ran via OpenRouter or the provider&rsquo;s direct API.</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th style="text-align: center">Type</th>
          <th style="text-align: center">Hardware</th>
          <th style="text-align: center">Quality</th>
          <th style="text-align: center">Time</th>
          <th style="text-align: center">Price</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Claude Opus 4.7</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">D</td>
      </tr>
      <tr>
          <td>Claude Sonnet 4.6</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">C</td>
      </tr>
      <tr>
          <td>Claude Opus 4.6</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">D</td>
      </tr>
      <tr>
          <td>GPT 5.4 (Codex)</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">B+</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">F</td>
      </tr>
      <tr>
          <td>GLM 5.1</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">A</td>
      </tr>
      <tr>
          <td>GLM 5</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">A−</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">A</td>
      </tr>
      <tr>
          <td>Qwen 3.6 35B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">B+</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3.5 27B Claude-distilled</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">C+</td>
          <td style="text-align: center">B</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Gemini 3.1 Pro</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">B</td>
      </tr>
      <tr>
          <td>Grok 4.20</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">C−</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+</td>
      </tr>
      <tr>
          <td>Step 3.5 Flash</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">C−</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">A+</td>
      </tr>
      <tr>
          <td>Qwen 3.5 35B-A3B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder Next</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">D+</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3 32B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3 Coder 30B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">D−</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3.6 Plus</td>
          <td style="text-align: center">Commercial</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Kimi K2.5</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">C</td>
          <td style="text-align: center">A</td>
      </tr>
      <tr>
          <td>MiniMax M2.7</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">A</td>
          <td style="text-align: center">A+</td>
      </tr>
      <tr>
          <td>Qwen 3.5 122B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">D</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>DeepSeek V3.2</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Cloud</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">A+</td>
      </tr>
      <tr>
          <td>Qwen 2.5 Coder 32B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Gemma 4 31B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">5090</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">A+</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Gemma 4 31B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>GLM 4.7 Flash</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Llama 4 Scout</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>GPT OSS 20B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
      <tr>
          <td>Qwen 3 32B</td>
          <td style="text-align: center">OSS</td>
          <td style="text-align: center">Strix</td>
          <td style="text-align: center">F</td>
          <td style="text-align: center">—</td>
          <td style="text-align: center">A+ (free)</td>
      </tr>
  </tbody>
</table>
<p>Quality criteria: A+ works and the code is well structured. A/B works with small to medium caveats. C runs but skips a prompt requirement or has a serious structural issue. D breaks on the first message because of an invented API. F didn&rsquo;t complete the benchmark or produced garbage. GPT 5.4 via Codex dropped from A+ to B+: the architecture is the most sophisticated in the benchmark (dependency injection, PORO models, 22 tests), but the first message works and multi-turn breaks due to a wrong calling convention on <code>add_message</code>. Burned 7.6M tokens (~$16/run) without reaching Opus-level correctness. &ldquo;Type&rdquo; separates commercial models (closed weights) from OSS (open weights, even when used through a hosted API). Some Qwens appear twice when they ran on both hardware profiles, because the results are different enough to justify it — Qwen 3.5 35B-A3B on the 5090 jumps to Tier B, on the Strix it stays at Tier C because of the wait time. Of the 33 models configured across both runs, some don&rsquo;t appear in this table because they never even executed (no quota, broken runner, infra failure, or timeout before the first message).</p>
<h3>The verdict<span class="hx:absolute hx:-mt-20" id="the-verdict"></span>
    <a href="#the-verdict" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If you want the best result and don&rsquo;t want to think about it: <strong>Claude Opus</strong>. Opus 4.7 is the new baseline — 28 tests, correct API, 96.7% coverage, FakeChat pattern in the tests. It&rsquo;s an incremental improvement over 4.6 (which had 16 tests), not a revolution. But it doesn&rsquo;t need to be a revolution. 4.6 already worked, 4.7 works the same and delivers a slightly more polished project. If you were on 4.6, upgrading to 4.7 means switching to a model that does the same thing with more care in the tests and structure.</p>
<p>A word about Sonnet: it beat Opus in this benchmark (30 tests vs 16 for Opus 4.6, vs 28 for Opus 4.7). But this benchmark is a small, well-defined web app. In my real-world experience with bigger projects, Sonnet fails when the reasoning needs to go deeper. I&rsquo;m not talking about massive projects — just going a bit further than this benchmark (more controllers, more integrations, architectural decisions that depend on each other) is enough for Sonnet to lose the thread. Opus has a 128K max output token ceiling vs Sonnet&rsquo;s 64K, and its training was specifically aimed at long-horizon tasks, multi-step planning and deep reasoning over complex code. On a small project like the benchmark, those muscles stay idle, and in that scenario Sonnet wins by being faster and cheaper. But if you extrapolate that to &ldquo;Sonnet is better than Opus,&rdquo; you&rsquo;ll get a surprise on the first task that requires sustained reasoning. You can try Sonnet — it&rsquo;s cheaper and for small projects it works. But for real projects, you&rsquo;ll probably end up on Opus anyway.</p>
<p>On GPT 5.4: we now have objective data. Ran it via Codex CLI with xHigh reasoning effort. The architecture it generates is the most sophisticated in the benchmark — dependency injection, PORO models, session management with TTL. But it burned 7.6M tokens (~$16/run, 15x more expensive than Opus) and got the <code>add_message</code> calling convention wrong (keyword args instead of positional hash), breaking multi-turn. Spent more, got it wrong. Dropped from Tier 1 to Tier 2. Same pattern: API correctness is binary recall in the weights. It doesn&rsquo;t scale with token budget or reasoning effort.</p>
<p>If cost matters and you want to leave Anthropic: <strong>GLM 5 or GLM 5.1</strong> are the plug-and-play alternatives that work. Correct API, mocking in the tests, ~$0.11-$0.13 per run, ~88-89% cheaper than Opus. GLM 5.1 delivered a more complete project (24 tests, chat history) at the cost of about 5 more minutes.</p>
<p>If you want to avoid total vendor lock-in and you have decent hardware: <strong>Qwen 3.6 35B</strong> running locally on an NVIDIA RTX 5090. Under 5 minutes of execution at 240 tok/s, correct entry point and <code>chat.ask</code>, just missing <code>.content</code> extraction — a 1-line fix. That&rsquo;s better than the Qwen 3.5 35B-A3B which hallucinated entire API methods. The 3.6 generation is the first Qwen that&rsquo;s genuinely one fix away from working, not a rewrite away. Realistic total: ~10-15 minutes with one follow-up.</p>
<p>If you want to avoid vendor lock-in but you&rsquo;re on weak hardware: <strong>GLM 5 or GLM 5.1</strong> remain the choice. They&rsquo;re cloud, true, but at $0.11-$0.13 per run it&rsquo;s basically the price of electricity.</p>
<p>If you want to test the &ldquo;Claude at home&rdquo; gamble via distillation: the <strong>Qwen 3.5 27B Claude-distilled</strong> is sitting there to play with, but I already warned you it hallucinates exactly the same fake APIs as the base Qwen. Distillation transferred Claude&rsquo;s style, not its factual knowledge about libraries. It&rsquo;s worth it as an experiment, not as production.</p>
<p>Yes, maybe with days of tweaking llama.cpp, calibrating flags, adjusting prompts, testing different builds, you could make Gemma 4 or other models work better. For most people, that isn&rsquo;t realistic. The distance between frontier models (Claude, GPT) and self-hosted open source models is real. It isn&rsquo;t marketing. The gap is shrinking, but it still exists, and the nature of it has changed: today what&rsquo;s missing in open source is factual knowledge about specific libraries, not raw reasoning capacity. Hardware stopped being the bottleneck, at least for anyone with a recent GPU.</p>
<p>In the end, what matters is whether the code runs. A model can generate 3,405 files, write 37 tests, produce a 181-line README, and the app still won&rsquo;t work because the API it uses doesn&rsquo;t exist. Completeness metrics and test counts are necessary but not sufficient. The only reliable signal is whether the model uses real APIs correctly.</p>
<p>The full benchmark, with code, configuration, prompts and per-model results, is on <a href="https://github.com/akitaonrails/llm-coding-benchmark"target="_blank" rel="noopener">GitHub</a>.</p>
]]></content:encoded><category>llm</category><category>benchmark</category><category>open-source</category><category>claude</category><category>ai</category><category>self-hosting</category></item><item><title>Turning YouTube into a Karaoke App | Frank Karaoke</title><link>https://akitaonrails.github.io/en/2026/04/05/turning-youtube-into-a-karaoke-app-frank-karaoke/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/05/turning-youtube-into-a-karaoke-app-frank-karaoke/</guid><pubDate>Sun, 05 Apr 2026 12:00:00 GMT</pubDate><description>&lt;p&gt;Project on GitHub: &lt;a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener"&gt;github.com/akitaonrails/frank_karaoke&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-in-action.jpg" alt="Real-time scoring overlaid on a YouTube karaoke video" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve always loved karaoke. I go out to sing with family or friends every now and then. In São Paulo there are good places in Liberdade and Bom Retiro, for instance, with private Japanese-style booths. If you&amp;rsquo;ve never been to a karaoke like that: you rent a private room by the hour, there&amp;rsquo;s a huge song catalog, two microphones, and a scoring system that grades your singing in real time. The best systems are Japanese, like &lt;a href="https://www.joysound.com/"target="_blank" rel="noopener"&gt;Joysound&lt;/a&gt; and &lt;a href="https://www.clubdam.com/"target="_blank" rel="noopener"&gt;DAM&lt;/a&gt;. A score above 90 (out of 100) is considered advanced. DAM, in the LIVE DAM Ai series, even uses AI to give scores that feel more &amp;ldquo;human.&amp;rdquo;&lt;/p&gt;</description><content:encoded><![CDATA[<p>Project on GitHub: <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">github.com/akitaonrails/frank_karaoke</a></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-in-action.jpg" alt="Real-time scoring overlaid on a YouTube karaoke video"  loading="lazy" /></p>
<p>I&rsquo;ve always loved karaoke. I go out to sing with family or friends every now and then. In São Paulo there are good places in Liberdade and Bom Retiro, for instance, with private Japanese-style booths. If you&rsquo;ve never been to a karaoke like that: you rent a private room by the hour, there&rsquo;s a huge song catalog, two microphones, and a scoring system that grades your singing in real time. The best systems are Japanese, like <a href="https://www.joysound.com/"target="_blank" rel="noopener">Joysound</a> and <a href="https://www.clubdam.com/"target="_blank" rel="noopener">DAM</a>. A score above 90 (out of 100) is considered advanced. DAM, in the LIVE DAM Ai series, even uses AI to give scores that feel more &ldquo;human.&rdquo;</p>
<p>But not every place has that level.</p>
<h2>The problem with karaoke in Brazil<span class="hx:absolute hx:-mt-20" id="the-problem-with-karaoke-in-brazil"></span>
    <a href="#the-problem-with-karaoke-in-brazil" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In Brazil we grew up with <a href="https://www.videoland.com.br/wwwroot/historia.asp"target="_blank" rel="noopener">Videokê</a>, the brand the Korean Seok Ha Hwang brought to the country in 1996, importing equipment from Korea. It became a craze in the 90s and 2000s, showed up in every bar, barbecue and birthday party. The problem is that those machines stopped in time. The current models, like the VSK 5.0, ship with around 12-13 thousand songs in the catalog, which you expand by buying cartridges or song packs. In practice, the repertoire is old, the interface is straight out of the 2000s, and if the song you want to sing came out after 2015, good luck.</p>
<p>The workaround a lot of bars adopted was to allow Chromecast or screen mirroring so that customers can search for songs directly on YouTube. Makes sense: on YouTube you can find karaoke for any song. With-lyrics version, instrumental version, vocal guide version.</p>
<p>But there&rsquo;s a downgrade: you lose the scoring. One of the most fun parts of karaoke is the competition. Watching your score climb, comparing with friends, trying to beat the night&rsquo;s record. If you&rsquo;re just singing on top of a YouTube video, you get no feedback. It&rsquo;s like bowling without a scoreboard.</p>
<p>And buying a professional system for home? Importing a Joysound F1 runs north of US$ 2,000 just for the hardware, not counting the monthly catalog subscription. For casual use it makes no sense.</p>
<h2>The idea: YouTube with real-time scoring<span class="hx:absolute hx:-mt-20" id="the-idea-youtube-with-real-time-scoring"></span>
    <a href="#the-idea-youtube-with-real-time-scoring" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">Frank Karaoke</a> came out of that frustration. If YouTube already has every song, why not build an app that works as a YouTube wrapper with a real-time scoring overlay? You search for any karaoke video, sing along, and the app analyzes your voice through the mic and shows a live score.</p>
<p>It&rsquo;s a Flutter app for Android. Internally it loads YouTube into a webview and injects an overlay in HTML/CSS/JavaScript right into the page. The score display, the pitch trail, the settings panel, the mode selector — all of it rendered inside the webview through JS injection.</p>
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/karaoke-full-dark.png">
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/karaoke-full-light.png" alt="Frank Karaoke">
</picture>
<h2>Scoring without a reference<span class="hx:absolute hx:-mt-20" id="scoring-without-a-reference"></span>
    <a href="#scoring-without-a-reference" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now, the real problem. Every professional karaoke system depends on prebuilt reference files for each song. Every single one.</p>
<p>Sony&rsquo;s <a href="https://en.wikipedia.org/wiki/SingStar"target="_blank" rel="noopener">SingStar</a>, which sold over 12 million copies between 2004 and the end of the PS3 era, had a hand-crafted note track for every song. Every note, every syllable, all mapped manually. The mechanism compared the singer&rsquo;s pitch via FFT against that reference in real time. A detail I thought was clever: octave was ignored. If the right note was a C, it didn&rsquo;t matter if you sang C3 or C4. Men sing women&rsquo;s songs no problem.</p>
<p>Joysound and DAM in Japan go further and evaluate three separate dimensions: pitch accuracy (音感), rhythm/timing (リズム感) and expressiveness/dynamic volume (表現力). All based on MIDI data from the operator&rsquo;s server. The open source equivalent format is UltraStar, where each song has a <code>.txt</code> file like:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>: 12 4 5 Hel-    (NoteType StartBeat Duration Pitch Syllable)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>Pitch 5</code> = MIDI 65 (F4). Scoring compares the singer&rsquo;s pitch against the note&rsquo;s pitch, modulo octave, with a tolerance of 1 semitone.</p>
<p>Frank Karaoke works with any YouTube video. There&rsquo;s no reference file. There&rsquo;s no MIDI. There&rsquo;s no melody annotation. Zero metadata about what note you&rsquo;re supposed to be singing.</p>
<p>I don&rsquo;t know anything about karaoke scoring. I don&rsquo;t know anything about audio processing, pitch detection, music theory applied to software. Nothing. So I asked Claude Code to do extensive research on the subject. What it brought back is documented in <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> in the repository, and it&rsquo;s a lot: academic papers on singing evaluation (Nakano et al. 2006, Tsai &amp; Lee 2012, Molina et al. 2013), patents (Yamaha has one from 1999, US5889224A, that details MIDI-based scoring with 3 tolerance bands), and the source code of open source projects like UltraStar Deluxe, AllKaraoke, Vocaluxe and Nightingale.</p>
<p>The conclusion of the research: without a per-song reference, you have to evaluate vocal quality generically. Measure <em>how</em> the person is singing, not <em>what</em> they should be singing. And since no single metric works for every case, we decided to implement four different scoring modes, each measuring a different dimension of vocal quality.</p>
<h2>The phone microphone problem<span class="hx:absolute hx:-mt-20" id="the-phone-microphone-problem"></span>
    <a href="#the-phone-microphone-problem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before the scoring modes, I have to explain a more fundamental problem the research uncovered: the phone microphone.</p>
<p>When you sing karaoke with the phone, the mic picks up three things at once: your voice, the music coming out of the speaker, and ambient noise from the room. Your voice is physically closer to the mic, so it dominates the signal. But not enough for clean separation.</p>
<p>I tried several approaches to isolate the voice:</p>
<p>Spectral subtraction using YouTube&rsquo;s reference audio. Dropped it. The YouTube CDN blocks direct audio extraction by non-browser user-agents, and even with the reference audio in hand, the speaker&rsquo;s EQ, the room reverberation and the Bluetooth delay make the signal too different from what the mic captures. Naive subtraction produces artifacts worse than no subtraction at all.</p>
<p>Pre-emphasis + center clipping. Dropped that too. Center clipping destroys the waveform that the YIN algorithm needs for autocorrelation, and pre-emphasis amplifies noise as much as it amplifies voice.</p>
<p>What works is a 200-3500 Hz bandpass filter: a second-order IIR (Butterworth, Q=0.707) in cascade. The high-pass at 200 Hz kills bass, kick drum, bass guitar bleed from the speaker. The low-pass at 3500 Hz kills cymbals, hi-hats, high-frequency noise. Human voice fundamentals (85-300 Hz) and formants (300-3000 Hz) pass through the filter. It&rsquo;s not perfect isolation, but it improves the voice/music ratio enough for pitch detection.</p>
<p>But the bandpass alone doesn&rsquo;t solve everything. Guitars, synths and piano produce periodic signals in the same frequency range as voice, and YIN detects pitch in them too. To deal with that, the app does adaptive calibration: in the first 5 seconds of warmup (when nobody&rsquo;s singing yet), it collects RMS samples from the signal to establish a baseline of the speaker&rsquo;s level. During the song, it keeps that baseline updated (25th percentile of the last ~4 seconds of frames). For a frame to be scored, the RMS has to be at least 1.3x above the baseline. Your voice is closer to the mic, so it pushes the RMS above the speaker&rsquo;s level. The instrumental melody stays near the baseline and gets filtered out. In testing, the original singer coming out of the speaker scored around 37 with sparse dots in the trail, while someone actually singing scored ~59 with dense dots.</p>
<p>Another annoying detail: on Android, specifically on Samsungs, the DSP&rsquo;s <code>AutomaticGainControl</code> (AGC) attenuates the signal instead of amplifying it. On Galaxies, enabling AGC drops the mic peak from ~0.06 to ~0.003. Silence as far as pitch detection is concerned. So the app disables AGC, echo cancellation and noise suppression. When the peak falls below 0.01, it applies software gain (up to 30x) to bring the signal up to usable levels.</p>
<h2>The YIN algorithm<span class="hx:absolute hx:-mt-20" id="the-yin-algorithm"></span>
    <a href="#the-yin-algorithm" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To detect the voice&rsquo;s pitch I use <a href="http://audition.ens.fr/adc/pdf/2002_JASA_YIN.pdf"target="_blank" rel="noopener">YIN</a>, by Alain de Cheveigné (IRCAM-CNRS) and Hideki Kawahara (Wakayama University). It&rsquo;s a fundamental frequency estimator in the time domain. The central idea is the Cumulative Mean Normalized Difference Function (CMNDF), which basically measures how periodic the signal is at each lag, normalizes it to reduce false positives, and uses parabolic interpolation to refine the result. It&rsquo;s lightweight enough to run in real time on a phone, which is what matters here.</p>
<p>In the app, the YIN threshold is 0.70 (tuned for mixed voice + music signals), and frames with confidence below 0.3 get discarded. Below that, it&rsquo;s probably noise or an instrument.</p>
<h2>The 4 scoring modes<span class="hx:absolute hx:-mt-20" id="the-4-scoring-modes"></span>
    <a href="#the-4-scoring-modes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Each mode evaluates a different aspect of vocal quality. They all share the same audio pipeline (bandpass → YIN → confidence gate). The difference is how they interpret the detected pitch.</p>
<h3>Pitch Match<span class="hx:absolute hx:-mt-20" id="pitch-match"></span>
    <a href="#pitch-match" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Measures how cleanly you sustain notes. Uses Gaussian decay based on the standard deviation of MIDI values in a rolling ~15-frame window. Steady notes (deviation &lt; 0.3 semitones) score 85-100%. A trembling voice (deviation &gt; 2 semitones) scores near zero. Good for songs you already know well.</p>
<h3>Contour<span class="hx:absolute hx:-mt-20" id="contour"></span>
    <a href="#contour" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Measures the melodic shape of your singing. It doesn&rsquo;t matter which exact note you hit, only the direction and the flow. Evaluates the pitch range and melodic movements (jumps &gt; 0.5 semitone) in a rolling window. Monotone singing scores ~10%. Smooth melodic movement with a 2-6 semitone range scores 70-100%. Good for when you&rsquo;re learning a new song.</p>
<h3>Intervals<span class="hx:absolute hx:-mt-20" id="intervals"></span>
    <a href="#intervals" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Measures the musical quality of jumps between consecutive notes. A whole tone (2 semitones) scores highest. Thirds and fourths score well. Wild jumps of an octave or more score low. Uses a Gaussian curve centered on the whole tone. Works when you&rsquo;re singing in a different key from the original.</p>
<h3>Streak<span class="hx:absolute hx:-mt-20" id="streak"></span>
    <a href="#streak" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>It&rsquo;s Pitch Match with a combo multiplier. Each consecutive frame with a score above 0.4 increments the streak counter. The streak adds bonus points (up to +0.4 on a streak of 30+). Breaking a streak &gt; 5 frames pushes a 0.05 penalty into the EMA. Silence freezes the streak, so instrumental breaks don&rsquo;t hurt you. The most fun mode for parties.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/score-display-detail.jpg" alt="Score display detail: live score, overall score, pitch trail with note grid (C3-G5)"  loading="lazy" /></p>
<p>The logic behind these four modes came from the research Claude did across academic papers. Each one measures a different dimension: pitch accuracy, melodic contour, phrasing and consistency. None of them is sufficient on its own, but together they cover, reasonably well, what you can evaluate without having the song&rsquo;s reference melody.</p>
<h2>The Pitch Oracle<span class="hx:absolute hx:-mt-20" id="the-pitch-oracle"></span>
    <a href="#the-pitch-oracle" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Beyond the four purely vocal modes, the app has what I call the Pitch Oracle. The idea: instead of evaluating your voice in isolation, the app downloads the video&rsquo;s reference audio via <code>youtube_explode_dart</code>, decodes it to PCM, runs YIN on it, and builds a timestamped pitch timeline of the entire song. During scoring, if the mic&rsquo;s pitch matches the reference&rsquo;s pitch at that moment in the video, it&rsquo;s probably speaker bleed, and gets ignored. If it differs, it&rsquo;s your voice, and gets scored.</p>
<p>The synchronization works through the <code>currentTime</code> of the HTML5 video element, sent to Dart through a JS <code>timeupdate</code> listener every ~250ms. The oracle queries the reference pitch at the exact playback position, accounting for pause, seek and speed change.</p>
<p>The first time you play a song, the oracle takes 5-15 seconds to download and analyze the audio. But the timeline is saved as JSON in the app&rsquo;s local cache (<code>pitch_oracle/&lt;videoId&gt;.json</code>). If you play the same song again, it loads instantly from cache, no network request. That also fixes YouTube&rsquo;s rate limiting problem for the songs you sing the most.</p>
<p>With the oracle active, the modes change behavior. Pitch Match compares the singer&rsquo;s pitch class against the reference&rsquo;s, agnostic to octave (like SingStar). Contour uses cross-correlation between the singer&rsquo;s pitch movement and the reference&rsquo;s. Intervals compares semitone jumps against the reference&rsquo;s.</p>
<p>When YouTube blocks the download with rate limiting (happens after many consecutive requests from the same IP, clears in 15-30 minutes), the oracle silently fails and the modes fall back to purely vocal analysis.</p>
<h2>The road to here<span class="hx:absolute hx:-mt-20" id="the-road-to-here"></span>
    <a href="#the-road-to-here" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The app you see now went through a lot of iteration before reaching this state.</p>
<p>First, I tried to make a Linux desktop version to make debugging easier. Makes sense, right? Test on the desktop, iterate fast, then port to mobile. The problem is that Flutter has no webview backend for Linux desktop. <code>webview_flutter</code> simply doesn&rsquo;t work. I tried <code>webview_cef</code>, which is based on the Chromium Embedded Framework. CEF spawns its own GPU process, and on Hyprland (a Wayland compositor based on wlroots) that conflicts with the compositor&rsquo;s render pipeline. On my NVIDIA setup, the entire Hyprland session froze. Locked screen, no keyboard response, I had to kill it from a TTY. On top of that, CEF requires downloading a ~200MB binary on the first build. I gave up on CEF and wrote a native bridge in C++ with Claude using WebKitGTK and Flutter method channels. It worked, but every YouTube quirk required separate code for Linux and Android. <code>just_audio</code> also has no Linux desktop implementation. The Linux version turned into dead weight. I deleted ~1,500 lines of Linux-specific code and focused only on Android.</p>
<p>Then came the Samsung mic saga. On my Galaxy Z Fold, the mic was capturing an absurdly low signal. Peaks of ~0.005, basically silence as far as pitch detection was concerned. I spent two hours trying to figure it out. I lowered thresholds, raised software gain to 50x, disabled audio preprocessors. Nothing was working right. Until I figured out the real problem: Android&rsquo;s <code>AutomaticGainControl</code>. The name says &ldquo;automatic gain control,&rdquo; which suggests it <em>amplifies</em> weak signals. In the Samsung DSP implementation, it does the opposite. It <em>attenuates</em> the signal to a low reference level, optimized for voice calls. With AGC on, the peak dropped from ~0.06 to ~0.003. Disabling AGC fixed it. But then the <code>audio_session</code> package was re-enabling AGC under the hood. I removed that one too. It was three rounds of fixes, each finding one more layer of the problem.</p>
<p>And the scoring. The scoring took longer than everything else combined. The first implementation used a cumulative average, which kept the score stuck at one value and never responded to live singing. I switched to a rolling window. Then the score was stuck at ~50% because of a bug in the primary score weight. I fixed it, and it started showing 70% even with nobody singing. Fixed it again. Streak mode wasn&rsquo;t resetting properly during silence. The chromatic snap was giving high scores for anything. The pitch history wasn&rsquo;t being cleared on silence gaps and the modes were going stagnant. Every fix revealed another bug. It took more than 25 commits just on the scoring, from the first prototype to the current state.</p>
<p>The result isn&rsquo;t perfect. I know. But it works well enough to be fun, which was the goal from the start.</p>
<h2>Settings<span class="hx:absolute hx:-mt-20" id="settings"></span>
    <a href="#settings" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/settings-panel.jpg" alt="Settings panel: mic presets, pitch shift, calibration"  loading="lazy" /></p>
<p>The settings panel lives behind the gear icon on the overlay. There are three mic presets for different environments (clean external mic, normal room, loud party), each adjusting confidence and amplitude thresholds. There&rsquo;s a pitch shift for when the song is too high for your vocal range. The shift moves both the video audio and the scoring at the same time: it uses the HTML5 element&rsquo;s <code>playbackRate</code> with <code>preservesPitch=false</code>, so +2 semitones speeds the audio up to 1.12x (pitch goes up) and -2 semitones slows it down to 0.89x (pitch goes down). The scoring compensates for the offset, so you sing in your comfortable range and the system grades you correctly. There&rsquo;s mic calibration, a 3-second process that measures the room noise and adapts the thresholds. And there&rsquo;s a restart to reset the score without reloading the video.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-modes-selector.jpg" alt="Scoring mode selector"  loading="lazy" /></p>
<p>To switch scoring modes, tap the score box during playback.</p>
<h2>Usage flow<span class="hx:absolute hx:-mt-20" id="usage-flow"></span>
    <a href="#usage-flow" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ol>
<li>Open the app. YouTube loads inside the app with the Frank Karaoke logo.</li>
<li>Search for a karaoke video. Any video works, but instrumental tracks with on-screen lyrics give better results.</li>
<li>The video pauses briefly to initialize the mic, download the song&rsquo;s data for the pitch oracle, and prepare the overlay. The first time with a new song this takes 5-15 seconds. If you&rsquo;ve played it before, it loads from cache instantly.</li>
<li>Sing. The &ldquo;live&rdquo; score reflects your current performance (exponential moving average with alpha 0.15, ~1 second response). The &ldquo;overall&rdquo; score is the cumulative average of the entire song.</li>
<li>When the video pauses, scoring pauses with it (so it doesn&rsquo;t score ambient noise). If you seek, the score resets and gets a 5-second warmup.</li>
</ol>
<h2>How to install<span class="hx:absolute hx:-mt-20" id="how-to-install"></span>
    <a href="#how-to-install" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The app isn&rsquo;t on the Play Store yet, I&rsquo;m waiting for Google to verify my developer identity. It should show up there in the next few days. In the meantime, it&rsquo;s an open project and you can install it directly.</p>
<p>The easiest way is to download the signed APK directly from the <a href="https://github.com/akitaonrails/frank_karaoke/releases"target="_blank" rel="noopener">GitHub releases page</a>. On your Android phone or tablet, download <code>FrankKaraoke-0.2.0-android.apk</code>, open it and tap Install. If Android complains about &ldquo;unknown sources,&rdquo; enable it under Settings &gt; Security for your browser. On the first run the app will ask for mic permission. Then go into settings (the gear icon) and calibrate the mic before singing — three seconds.</p>
<p>If you want to compile from source or contribute, the repository is on <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">GitHub</a>. You&rsquo;ll need Flutter SDK 3.10+, Android SDK API 24+, and a physical device for mic testing (an emulator doesn&rsquo;t give representative results).</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frank_karaoke.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frank_karaoke
</span></span><span class="line"><span class="cl">flutter pub get
</span></span><span class="line"><span class="cl">flutter run -d &lt;device_id&gt;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The README has the rest.</p>
<p>Stack: Flutter + Riverpod for state management, <code>webview_flutter</code> for YouTube, <code>youtube_explode_dart</code> for audio extraction, <code>record</code> for PCM mic capture, <code>audio_decoder</code> for reference decoding via Android MediaCodec, and the YIN algorithm implemented in pure Dart.</p>
<p>The technical documentation for the scoring system is in <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> in the repository. It covers how SingStar, Joysound and DAM work, the academic papers, the pitch oracle architecture, the voice isolation problems on Android, and the roadmap.</p>
<h2>The scoring is experimental<span class="hx:absolute hx:-mt-20" id="the-scoring-is-experimental"></span>
    <a href="#the-scoring-is-experimental" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I have to be straight: the scoring system is experimental. Without per-song reference files, the evaluation is approximate. The app measures whether you&rsquo;re in tune, whether you follow a melodic contour, whether your intervals are musical, whether you&rsquo;re consistent. But it doesn&rsquo;t tell you whether you&rsquo;re singing the correct melody for this specific song (unless the pitch oracle manages to download the audio, and that doesn&rsquo;t always work).</p>
<p>If you have experience with audio processing, pitch detection, or music evaluation, the repository is open and the research documentation in <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> details what was tried, what works and what doesn&rsquo;t. In particular: tuning the modes&rsquo; thresholds, improving voice isolation, and integrating with <a href="https://github.com/rakuri255/UltraSinger"target="_blank" rel="noopener">UltraSinger</a> (which generates reference files from songs using Demucs + basic-pitch + WhisperX) are areas where contribution from people who know the subject would make a real difference. I&rsquo;d appreciate any help from specialists on calibrating these systems.</p>
<p>Oh, and the name. Frank Karaoke. It&rsquo;s a tribute to Sinatra. Who else?</p>
<p>Project on GitHub: <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">github.com/akitaonrails/frank_karaoke</a></p>
]]></content:encoded><category>flutter</category><category>android</category><category>karaoke</category><category>audio</category><category>pitch-detection</category><category>AI</category><category>open-source</category></item><item><title>Bitcoin on the Home Server: Sovereignty and Privacy with Coldcard, Sparrow and Fulcrum</title><link>https://akitaonrails.github.io/en/2026/04/01/bitcoin-on-the-home-server-sovereignty-with-coldcard-sparrow-fulcrum/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/01/bitcoin-on-the-home-server-sovereignty-with-coldcard-sparrow-fulcrum/</guid><pubDate>Wed, 01 Apr 2026 19:00:00 GMT</pubDate><description>&lt;p&gt;This post is a direct follow-up to my recent articles about the &lt;a href="https://akitaonrails.github.io/en/2026/03/31/migrating-my-home-server-with-claude-code/"&gt;new home server with openSUSE MicroOS&lt;/a&gt; and the &lt;a href="https://akitaonrails.github.io/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/"&gt;Minisforum MS-S1 Max&lt;/a&gt;. Those covered the foundation. Here I want to show one concrete use for it: putting together a decent Bitcoin stack at home, focused on privacy, operational sovereignty and safe transactions on my side.&lt;/p&gt;
&lt;p&gt;First things first: this isn&amp;rsquo;t an evangelism piece or a day-trading pitch. Quite the opposite. As I write this, on April 1, 2026, Bitcoin is around US$ 68k and close to R$ 391k, below the 2025 peaks. Plenty of people look at that and either panic or start fantasizing about leveraged trades. I think both reactions are wrong. There&amp;rsquo;s a &amp;ldquo;super cycle&amp;rdquo; thesis floating around based on institutional demand, spot ETFs and the lagged halving effect. Maybe. Maybe not. What I do know is that short-term candles don&amp;rsquo;t change the part I actually care about: infrastructure. If you need leverage to &amp;ldquo;speed up your gains,&amp;rdquo; you&amp;rsquo;re probably just speeding up your chances of getting liquidated.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is a direct follow-up to my recent articles about the <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">new home server with openSUSE MicroOS</a> and the <a href="/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">Minisforum MS-S1 Max</a>. Those covered the foundation. Here I want to show one concrete use for it: putting together a decent Bitcoin stack at home, focused on privacy, operational sovereignty and safe transactions on my side.</p>
<p>First things first: this isn&rsquo;t an evangelism piece or a day-trading pitch. Quite the opposite. As I write this, on April 1, 2026, Bitcoin is around US$ 68k and close to R$ 391k, below the 2025 peaks. Plenty of people look at that and either panic or start fantasizing about leveraged trades. I think both reactions are wrong. There&rsquo;s a &ldquo;super cycle&rdquo; thesis floating around based on institutional demand, spot ETFs and the lagged halving effect. Maybe. Maybe not. What I do know is that short-term candles don&rsquo;t change the part I actually care about: infrastructure. If you need leverage to &ldquo;speed up your gains,&rdquo; you&rsquo;re probably just speeding up your chances of getting liquidated.</p>
<p>For me, the useful question isn&rsquo;t &ldquo;is it going up tomorrow?&rdquo; The useful question is: &ldquo;if I want to store and move Bitcoin without outsourcing everything to an exchange, a web wallet and a public API, how do I set that up properly at home?&rdquo;</p>
<h2>The real problem: too much convenience costs too much privacy<span class="hx:absolute hx:-mt-20" id="the-real-problem-too-much-convenience-costs-too-much-privacy"></span>
    <a href="#the-real-problem-too-much-convenience-costs-too-much-privacy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Most people&rsquo;s default flow is simple: buy on an exchange, leave the balance sitting there, or install some random wallet on the phone and call it done. It works. It also concentrates risk and leaks metadata everywhere.</p>
<p>If you leave a balance on an exchange, you have custody risk. If you use a desktop wallet pointed at a public server, you have privacy risk. If you use a hardware wallet casually, bought second-hand on Mercado Livre, you have supply chain risk. Mix all that with hurry, and it gets worse.</p>
<p>That&rsquo;s why I ended up at a combination that, for someone technical who wants to run their own infra, feels pretty solid:</p>
<ul>
<li>Coldcard for cold storage</li>
<li>Sparrow Wallet on Linux as the desktop wallet and transaction coordinator</li>
<li>Fulcrum on the home server as a private Electrum server</li>
<li>bitcoind on the same server as a real full node, validating the chain and broadcasting without depending on third parties</li>
</ul>
<p>It&rsquo;s not the easiest path. But that&rsquo;s exactly the point. Real security rarely comes from the easiest path.</p>
<h2>The concepts that confuse beginners<span class="hx:absolute hx:-mt-20" id="the-concepts-that-confuse-beginners"></span>
    <a href="#the-concepts-that-confuse-beginners" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before getting into the stack, it&rsquo;s worth aligning on four terms that usually get tossed around like everyone already knows them:</p>
<table>
  <thead>
      <tr>
          <th>Concept</th>
          <th>What it is</th>
          <th>Why it matters</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Airgap</td>
          <td>A device that never touches the internet, not even over data USB</td>
          <td>Reduces the signer&rsquo;s attack surface</td>
      </tr>
      <tr>
          <td>PSBT</td>
          <td>Partially Signed Bitcoin Transaction</td>
          <td>Standard format for preparing, signing and finalizing transactions in stages</td>
      </tr>
      <tr>
          <td>Watch-only wallet</td>
          <td>A wallet that sees balances/addresses but doesn&rsquo;t hold a private key</td>
          <td>Great for the desktop: it observes and assembles the transaction, but doesn&rsquo;t sign</td>
      </tr>
      <tr>
          <td>Full node</td>
          <td>A node that validates blocks and protocol rules locally</td>
          <td>You don&rsquo;t have to &ldquo;trust&rdquo; anyone&rsquo;s API</td>
      </tr>
      <tr>
          <td>Electrum server</td>
          <td>An indexing layer that quickly answers wallet queries</td>
          <td>Without one, desktop wallets end up dependent on public servers</td>
      </tr>
  </tbody>
</table>
<p>In plain language, the flow looks like this:</p>
<ol>
<li>Sparrow, on the desktop, builds the transaction.</li>
<li>That transaction becomes a PSBT.</li>
<li>The PSBT goes to the Coldcard via microSD.</li>
<li>The Coldcard signs it offline.</li>
<li>The signed file goes back to Sparrow.</li>
<li>Sparrow broadcasts through your own server, not through someone else&rsquo;s public infrastructure.</li>
</ol>
<p>That&rsquo;s what people mean by &ldquo;airgapped workflow.&rdquo; It&rsquo;s not magic. It&rsquo;s just disciplined separation of roles.</p>
<h2>Coldcard: cold signer, offline, the right kind of annoying<span class="hx:absolute hx:-mt-20" id="coldcard-cold-signer-offline-the-right-kind-of-annoying"></span>
    <a href="#coldcard-cold-signer-offline-the-right-kind-of-annoying" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I use <a href="https://coldcard.com/"target="_blank" rel="noopener">Coldcard</a> as cold storage. The reason is simple: it was designed from day one as a Bitcoin-only device, with a heavy focus on airgapped operation through microSD. That alone eliminates an entire category of &ldquo;conveniences&rdquo; that many people find practical, but that I&rsquo;d rather not have anywhere near my keys.</p>
<p><a href="https://coldcard.com/mk4"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/bitcoin-sovereignty/coldcard-mk4-official.png" alt="Coldcard Mk4 on the official Coinkite site"  loading="lazy" /></a></p>
<p>In practice, the Coldcard holds the most important part of the system: the private key. It doesn&rsquo;t need to know about a server, Electrum, public API, exchange, or any of that. Its job is one thing: sign transactions offline.</p>
<p>That decoupling is great for two reasons:</p>
<ul>
<li>The desktop can be convenient without becoming a single point of failure.</li>
<li>The signer stays isolated even if your main machine has problems.</li>
</ul>
<p>And here&rsquo;s a warning I really want to put in mental all-caps:</p>
<p><strong>Never buy a hardware wallet second-hand. Ever.</strong></p>
<p>This isn&rsquo;t an exaggeration. You have no way to actually know what happened to that device before it reached your hands. It could have a pre-generated seed, tampered firmware, swapped components, repackaged box, compromised supply chain, or simply some dumb trick waiting for you to let your guard down. Hardware wallet is one of those categories where saving R$ 300 buying used is insanity. Always buy from the manufacturer&rsquo;s official site or from a reseller officially authorized by the manufacturer. And even then, check seals, provenance and firmware.</p>
<h2>Can you do something similar with an old phone?<span class="hx:absolute hx:-mt-20" id="can-you-do-something-similar-with-an-old-phone"></span>
    <a href="#can-you-do-something-similar-with-an-old-phone" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You can. But I&rsquo;d treat it as a study or budget alternative, not as an obvious substitute for a Coldcard.</p>
<p>The most serious path for that today is <a href="https://airgap.it/"target="_blank" rel="noopener">AirGap Vault</a>, which was specifically designed to use an old smartphone as an offline signer over QR codes, keeping the device off the network. The idea is good, and for many people it might be the right entry point.</p>
<p>But there are trade-offs:</p>
<ul>
<li>An old smartphone wasn&rsquo;t designed as a dedicated hardware wallet</li>
<li>The device&rsquo;s prior history matters</li>
<li>An aged battery, bad screen and abandoned Android are real problems</li>
<li>The threat model is less clear than on a dedicated device</li>
</ul>
<p>So my view is simple: can you use it? Yes. Would I recommend it as the main solution for storing meaningful wealth? No. For that I still prefer dedicated hardware bought from the right source.</p>
<h2>Sparrow Wallet: the best desktop piece in this puzzle<span class="hx:absolute hx:-mt-20" id="sparrow-wallet-the-best-desktop-piece-in-this-puzzle"></span>
    <a href="#sparrow-wallet-the-best-desktop-piece-in-this-puzzle" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On Linux, I use <a href="https://sparrowwallet.com/"target="_blank" rel="noopener">Sparrow Wallet</a>. For me, today, it&rsquo;s one of the best pieces of software in this ecosystem.</p>
<p><a href="https://www.sparrowwallet.com/features/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/bitcoin-sovereignty/sparrow-transactions.png" alt="Sparrow Wallet running, showing a detailed history of the wallet and transactions"  loading="lazy" /></a></p>
<p>What I like about it:</p>
<ul>
<li>works very well on Linux desktop</li>
<li>supports hardware wallets properly</li>
<li>understands PSBT without drama</li>
<li>makes it crystal clear what&rsquo;s happening in a transaction</li>
<li>it&rsquo;s great as a watch-only wallet</li>
</ul>
<p>In my flow, Sparrow does three things:</p>
<ol>
<li>Holds the watch-only wallet.</li>
<li>Builds the transaction with outputs and fees.</li>
<li>Receives the signature back from the Coldcard and broadcasts it.</li>
</ol>
<p>That separation is elegant. The desktop becomes the coordinator. The signer stays cold.</p>
<h2>Why Coldcard + Sparrow works so well<span class="hx:absolute hx:-mt-20" id="why-coldcard--sparrow-works-so-well"></span>
    <a href="#why-coldcard--sparrow-works-so-well" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This combo is good because each piece does what it does best:</p>
<ul>
<li>the Coldcard protects the key</li>
<li>Sparrow organizes the human use of the wallet</li>
<li>the server handles the infrastructure</li>
</ul>
<p>A lot of wallets try to do everything. I prefer this modular design. It&rsquo;s less &ldquo;magic,&rdquo; more explicit, and easier to reason about without lying to yourself.</p>
<p>If I&rsquo;m at the desktop, I want visibility. If I&rsquo;m at the signer, I want isolation. If I&rsquo;m at the server, I want validation and a local index. That division is clean.</p>
<h2>The Sparrow problem when you don&rsquo;t run your own infra<span class="hx:absolute hx:-mt-20" id="the-sparrow-problem-when-you-dont-run-your-own-infra"></span>
    <a href="#the-sparrow-problem-when-you-dont-run-your-own-infra" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now comes the important detail. Sparrow alone doesn&rsquo;t solve privacy.</p>
<p>If you install it, open it and just use public servers, the people on the other end learn quite a lot about your wallet: your address set, xpubs or derivations, balance, history, query behavior, broadcast. It&rsquo;s not custody, but it&rsquo;s still exposure.</p>
<p>That&rsquo;s the hole Fulcrum fills.</p>
<h2>Fulcrum: the home&rsquo;s private Electrum server<span class="hx:absolute hx:-mt-20" id="fulcrum-the-homes-private-electrum-server"></span>
    <a href="#fulcrum-the-homes-private-electrum-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/cculianu/Fulcrum"target="_blank" rel="noopener">Fulcrum</a> is an Electrum server. Instead of letting Sparrow ask things of a third-party public server, it asks my own server.</p>
<p>In practice, that means:</p>
<ul>
<li>local balance lookups</li>
<li>local history</li>
<li>local address discovery</li>
<li>local broadcast</li>
</ul>
<p>In other words: the desktop wallet stops &ldquo;phoning home&rdquo; to the world every time you open the program.</p>
<p>In my current setup, Sparrow points at a Fulcrum running on the home server on the LAN, with port <code>50001</code> on the internal network and <code>50002</code> with TLS.</p>
<h2>And why Fulcrum isn&rsquo;t enough on its own<span class="hx:absolute hx:-mt-20" id="and-why-fulcrum-isnt-enough-on-its-own"></span>
    <a href="#and-why-fulcrum-isnt-enough-on-its-own" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Because Fulcrum doesn&rsquo;t replace a full node. It indexes on top of a full node.</p>
<p>The thing actually validating blocks, consensus rules, scripts, transactions and the chain is <code>bitcoind</code>. Fulcrum sits in front of it as an indexing layer, because plain Bitcoin Core wasn&rsquo;t built to serve a desktop wallet with that kind of fast querying.</p>
<p>So the correct architecture is:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Coldcard (offline signer)
</span></span><span class="line"><span class="cl">        ^
</span></span><span class="line"><span class="cl">        | microSD / PSBT
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">Sparrow Wallet (desktop watch-only + coordinator)
</span></span><span class="line"><span class="cl">        |
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">Fulcrum (private Electrum server)
</span></span><span class="line"><span class="cl">        |
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">bitcoind (full node)</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>What I actually brought up on the home server<span class="hx:absolute hx:-mt-20" id="what-i-actually-brought-up-on-the-home-server"></span>
    <a href="#what-i-actually-brought-up-on-the-home-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On my home server, the stack lives in a dedicated Docker Compose folder and is made of two containers:</p>
<ul>
<li><code>bitcoin-bitcoind</code></li>
<li><code>bitcoin-fulcrum</code></li>
</ul>
<p>The compose is simple. And that&rsquo;s good. Sensitive infra doesn&rsquo;t gain anything by getting clever in YAML.</p>
<p>The main design is this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">bitcoin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">lncm/bitcoind:v28.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">bitcoin-bitcoind</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">user</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${BITCOIN_UID}:${BITCOIN_GID}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/data:/data/.bitcoin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8333:8333&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">stop_grace_period</span><span class="p">:</span><span class="w"> </span><span class="l">5m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;bitcoin-cli&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-datadir=/data/.bitcoin&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;ping&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">60s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">fulcrum</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cculianu/fulcrum:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">bitcoin-fulcrum</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/fulcrum:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/data:/bitcoin:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;Fulcrum&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;/data/fulcrum.conf&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;50001:50001&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;50002:50002&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">bitcoin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In my case, the restriction of <code>50001</code> to LAN happens at the host&rsquo;s network layer. The YAML above is the skeleton of the stack, not the entire firewall policy.</p>
<p>The most important parts of this:</p>
<ul>
<li><code>restart: always</code> because this is a long-running service</li>
<li>explicit volume so state isn&rsquo;t lost</li>
<li><code>user: &quot;${BITCOIN_UID}:${BITCOIN_GID}&quot;</code> because the persistent directory needs to match the storage&rsquo;s real ownership, so I&rsquo;d rather pin UID/GID explicitly than trust the image&rsquo;s default</li>
<li>the RPC isn&rsquo;t published on the host; it stays on the Compose internal network, which is all Fulcrum needs</li>
<li>the healthcheck uses Bitcoin Core&rsquo;s own local <code>.cookie</code>, so there&rsquo;s no need to spread a fixed password through commands</li>
<li>Fulcrum mounts the node&rsquo;s datadir as read-only just to authenticate via the <code>.cookie</code> without inventing parallel credentials</li>
<li>in <code>fulcrum.conf</code>, that becomes a simple configuration: talk to <code>bitcoin:8332</code> and read the mounted <code>.cookie</code>, instead of repeating credentials in plaintext</li>
<li><code>security_opt: label:disable</code> because on this host with MicroOS, SELinux and sensitive bind mounts, I preferred the pragmatic route of disarming this specific friction rather than wasting time fighting labels on a volume that&rsquo;s already being handled in a controlled way</li>
<li><code>depends_on</code> with <code>service_healthy</code> so Fulcrum only comes up after bitcoind&rsquo;s RPC is responding</li>
<li><code>stop_grace_period: 5m</code> because bitcoind needs real time to flush state on a graceful shutdown</li>
</ul>
<h2>The final version<span class="hx:absolute hx:-mt-20" id="the-final-version"></span>
    <a href="#the-final-version" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Today, the design I want to keep is this: <code>bitcoind</code> with <code>txindex</code>, <code>dbcache=1024</code>, persistent volume, 5-minute graceful stop, <code>.cookie</code> authentication, and Fulcrum in front serving Sparrow over LAN or TLS.</p>
<p>The current stack looks like this:</p>
<table>
  <thead>
      <tr>
          <th>Component</th>
          <th>State</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bitcoin Core</td>
          <td><code>28.0</code></td>
      </tr>
      <tr>
          <td>Fulcrum</td>
          <td><code>2.1.0</code></td>
      </tr>
      <tr>
          <td>Container stop timeout</td>
          <td><code>300</code> seconds</td>
      </tr>
      <tr>
          <td>Node data dir</td>
          <td>dedicated persistent volume mounted at <code>/data/.bitcoin</code></td>
      </tr>
      <tr>
          <td>Network</td>
          <td><code>8333</code> for P2P, RPC only on the Compose internal network, <code>50001/50002</code> for the private Electrum</td>
      </tr>
  </tbody>
</table>
<p>I&rsquo;m not interested in turning this into a spectacle. The point is simpler: the final infrastructure has to be boring, predictable and stable.</p>
<h2>The tunings that actually matter<span class="hx:absolute hx:-mt-20" id="the-tunings-that-actually-matter"></span>
    <a href="#the-tunings-that-actually-matter" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s no magic here. There are a few parameters that make a real difference and a bunch of stuff that just decorates compose files.</p>
<p><code>stop_grace_period: 5m</code> exists because bitcoind isn&rsquo;t a disposable stateless API container. It maintains chainstate, indexes and an in-memory cache. If you don&rsquo;t give the process time to finish properly, you create unnecessary work for the next start.</p>
<p><code>user: &quot;${BITCOIN_UID}:${BITCOIN_GID}&quot;</code> is there for a much less glamorous and much more important reason: persistent storage with the wrong permissions is an excellent way to break a working service. So I&rsquo;d rather align the container with the volume&rsquo;s actual ownership instead of leaving that implicit.</p>
<p><code>dbcache=1024</code> is the spot I find most reasonable for a domestic node that&rsquo;s always on. Big enough not to suffer constant I/O, small enough that every restart isn&rsquo;t a labor.</p>
<p><code>txindex=1</code> I keep because I want the complete node, not a minimalist install just to claim &ldquo;it runs Bitcoin.&rdquo; If the goal here is operational autonomy, I&rsquo;d rather have the full index.</p>
<p><code>rpcworkqueue=512</code> and <code>rpcthreads=16</code> are the kind of tweak that makes sense when you know you&rsquo;ll have Fulcrum querying the node all day and you want some headroom.</p>
<p>On the Fulcrum side, the main parameters are:</p>
<ul>
<li><code>db_mem = 8192</code></li>
<li><code>db_max_open_files = -1</code></li>
<li><code>bitcoind_clients = 8</code></li>
<li><code>worker_threads = 0</code></li>
<li><code>peering = false</code></li>
</ul>
<p>Again: nothing esoteric. Just enough cache, reasonable parallelism and absolutely no announcing this server as a public service.</p>
<p>In my current <code>bitcoin.conf</code>, the important core ended up like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">server</span><span class="o">=</span><span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">txindex</span><span class="o">=</span><span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">prune</span><span class="o">=</span><span class="s">0</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcbind</span><span class="o">=</span><span class="s">0.0.0.0</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcallowip</span><span class="o">=</span><span class="s">172.0.0.0/8</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcthreads</span><span class="o">=</span><span class="s">16</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcworkqueue</span><span class="o">=</span><span class="s">512</span>
</span></span><span class="line"><span class="cl"><span class="na">dbcache</span><span class="o">=</span><span class="s">1024</span>
</span></span><span class="line"><span class="cl"><span class="na">maxmempool</span><span class="o">=</span><span class="s">512</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>All of this makes sense on a server with decent RAM and fast NVMe. But the detail that matters most is still the clean shutdown. Wallet infrastructure has no room for &ldquo;we&rsquo;ll deal with it later&rdquo; thinking.</p>
<h2>The actual size of all this<span class="hx:absolute hx:-mt-20" id="the-actual-size-of-all-this"></span>
    <a href="#the-actual-size-of-all-this" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is another point a lot of people underestimate.</p>
<p>If you look at older Bitcoin Core documentation, you&rsquo;ll find numbers like 350 GB of disk for a node with default config. That&rsquo;s outdated. More current data on the size of the blockchain points to something around <strong>725.82 GB on March 11, 2026</strong>, and that&rsquo;s just the raw chain, without the extra indexes that many technical folks will want to keep.</p>
<p>And here comes the catch: the stack I&rsquo;m describing isn&rsquo;t &ldquo;a bare Bitcoin Core just to claim you run a node.&rdquo; It&rsquo;s <code>bitcoind</code> with <code>txindex</code>, plus Fulcrum, plus headroom for rebuild, logs, snapshots and normal network growth.</p>
<p>So to put together something similar today, I&rsquo;d think like this:</p>
<ul>
<li>below 1 TB: I wouldn&rsquo;t even start</li>
<li>1 TB: pragmatic minimum</li>
<li>2 TB: comfortable range</li>
<li>above that: if you want long-term headroom, snapshots and less operational anxiety</li>
</ul>
<p>And here&rsquo;s the most important observation of all in self-hosting: don&rsquo;t assume persistence, mount and backup are right just because the YAML looks clean. Verify.</p>
<p>Another thing I wouldn&rsquo;t forget on a btrfs host: put Fulcrum&rsquo;s database (<code>fulc2_db</code>) on a separate subvolume. The reason is mundane. That directory grows, changes constantly and has nothing to do with generic automated snapshots of <code>/var</code>. If you mix everything, you end up dragging a large rebuildable index along with system snapshots, burning space and making maintenance more annoying than it needed to be. The Fulcrum index isn&rsquo;t sensitive configuration. It&rsquo;s heavy, volatile, rebuildable data. I treat it exactly like that.</p>
<h2>Hardening: what I&rsquo;ve already applied<span class="hx:absolute hx:-mt-20" id="hardening-what-ive-already-applied"></span>
    <a href="#hardening-what-ive-already-applied" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is where the difference between &ldquo;ran on my laptop&rdquo; and &ldquo;I&rsquo;d trust this to operate my wallet&rdquo; shows up.</p>
<p>In the current state of the stack, the points I consider important ended up like this:</p>
<ul>
<li>Bitcoin Core&rsquo;s RPC no longer relies on unnecessary host exposure; Fulcrum talks to <code>bitcoind</code> over the Docker internal network, which is what actually matters</li>
<li><code>50001</code> is restricted to internal LAN use</li>
<li><code>50002</code> is available with TLS, which is the right move when you need to leave plaintext behind</li>
<li>shutdown is graceful, with <code>stop_grace_period: 5m</code>, so <code>bitcoind</code> has time to flush state instead of dying any old way</li>
<li>the storage mount isn&rsquo;t on a &ldquo;we&rsquo;ll see later&rdquo; basis; there&rsquo;s a mount check before Docker comes up, precisely to avoid silent drift</li>
</ul>
<p>Each of those items exists for a very concrete reason.</p>
<p>Pulling the RPC off the host&rsquo;s surface reduces attack at zero cost. Fulcrum is already in the same Compose and can already talk to the service by its internal name. There&rsquo;s no real gain in leaving that port exposed where it doesn&rsquo;t need to be exposed.</p>
<p>Separating <code>50001</code> and <code>50002</code> also helps keep the house in order. Within a controlled LAN, plaintext is acceptable. Outside of that, the minimum reasonable thing is TLS. Mixing the two scenarios usually turns into a mess.</p>
<p><code>stop_grace_period: 5m</code> looks like a container detail, but it isn&rsquo;t. Anyone who&rsquo;s ever had a database, an index or a blockchain node killed without grace knows how that turns into hours of work later. A stateful service needs a decent stop.</p>
<p>And the mount check is one of those annoying things that saves you from yourself. The YAML can look beautiful. If the storage didn&rsquo;t mount and the service came up writing where it shouldn&rsquo;t, you&rsquo;ve just manufactured a really irritating problem.</p>
<p>There&rsquo;s also one detail I really like in this final version of the stack: Fulcrum authenticates to <code>bitcoind</code> through the <code>.cookie</code> file, not through a fixed plaintext password. That&rsquo;s interesting for two reasons:</p>
<ul>
<li>you don&rsquo;t need to leave a static credential showing up in compose, inspect, healthcheck or documentation</li>
<li>the authentication is more aligned with the way Bitcoin Core already knows how to operate locally</li>
</ul>
<p>In practical terms, that reduces accidental leakage of operational secrets. It&rsquo;s not a magic solution to everything, but it&rsquo;s much better than spreading <code>rpcuser</code> and <code>rpcpassword</code> across files, logs and commands.</p>
<p>The only kind of hardening I try to avoid here is the one that&rsquo;s overly performative in YAML and loose in operation. I&rsquo;d rather have less &ldquo;stage engineering&rdquo; and more basic discipline:</p>
<ul>
<li>minimum network</li>
<li>minimum secrets</li>
<li>minimum privilege</li>
<li>clean shutdown</li>
<li>verified storage</li>
<li>separate subvolume for large rebuildable data, like the Fulcrum index</li>
</ul>
<p>And, again, document everything. Good infrastructure isn&rsquo;t the kind that just works today. It&rsquo;s the kind that keeps working when you come back to it six months later.</p>
<h2>Why this improves transactions on your side<span class="hx:absolute hx:-mt-20" id="why-this-improves-transactions-on-your-side"></span>
    <a href="#why-this-improves-transactions-on-your-side" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When I build a transaction in Sparrow and sign it on the Coldcard, the chain of trust is much better defined:</p>
<ul>
<li>the private key never touches the internet</li>
<li>the desktop wallet doesn&rsquo;t have to trust a public server</li>
<li>the broadcast can come out of my own node</li>
<li>the address history doesn&rsquo;t need to land on a third-party Electrum server</li>
</ul>
<p>This doesn&rsquo;t make anything invulnerable. There&rsquo;s still a risk of malware on the desktop, badly stored seeds, human error, social engineering and physical disaster. But the design becomes much more coherent.</p>
<h2>What about Lightning? Especially in Brazil?<span class="hx:absolute hx:-mt-20" id="what-about-lightning-especially-in-brazil"></span>
    <a href="#what-about-lightning-especially-in-brazil" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There I separate things.</p>
<p>Reserves and larger-value transactions I treat one way. Day-to-day spending I treat another.</p>
<p>For day-to-day spending, especially in Brazil, I think it&rsquo;s operationally dumb to carry a lot of balance in a hot wallet. Lightning wallets and spending apps have to be almost like a &ldquo;pocket wallet&rdquo;: just enough for daily life.</p>
<p>That goes double if you use a hybrid or custodial solution like <a href="https://www.redotpay.com/"target="_blank" rel="noopener">RedotPay</a>. I get why it&rsquo;s interesting for Brazilians: a Hong Kong company, international focus, a reasonably practical bridge between crypto and card spending. For travel, online shopping and life outside the Brazilian banking axis, it makes sense. But I&rsquo;d never treat it as a place to store wealth. That&rsquo;s a spending tool, not a vault.</p>
<p>Same logic for <a href="https://www.bitrefill.com/br/pt/"target="_blank" rel="noopener">Bitrefill Brasil</a>. I think the service is interesting precisely because it solves a real pain in Brazil: turning sats into concrete utility without selling your full position or depending on banking integration all the time. Gift cards, top-ups, small expenses. As a use tool, it makes a lot of sense.</p>
<p>For a Lightning wallet on the phone, I&rsquo;d look at first:</p>
<ul>
<li><a href="https://phoenix.acinq.co/"target="_blank" rel="noopener">Phoenix</a> for people who want something very good and simple</li>
<li><a href="https://breez.technology/"target="_blank" rel="noopener">Breez</a> for people who want a great payments experience</li>
<li><a href="https://zeusln.com/"target="_blank" rel="noopener">ZEUS</a> if you&rsquo;re more technical and you eventually plan to operate your own Lightning node</li>
</ul>
<p>All of them, in my head, fit into the &ldquo;pocket wallet&rdquo; category. Small balance. Daily use. Don&rsquo;t turn a phone app into a retirement vault.</p>
<h2>Recent news that reinforces this reasoning<span class="hx:absolute hx:-mt-20" id="recent-news-that-reinforces-this-reasoning"></span>
    <a href="#recent-news-that-reinforces-this-reasoning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m not building this kind of stack because I think it&rsquo;s pretty. I&rsquo;m building it because outsourcing too much goes wrong too often.</p>
<p>Two recent examples:</p>
<ul>
<li>the <a href="https://www.cnbc.com/2025/02/21/hackers-steal-1point5-billion-from-exchange-bybit-biggest-crypto-heist.html"target="_blank" rel="noopener">Bybit hack in 2025</a> showed, again, the basic risk of leaving meaningful custody at an exchange</li>
<li>the <a href="https://techcrunch.com/2025/05/15/coinbase-says-customers-personal-information-stolen-in-data-breach/"target="_blank" rel="noopener">Coinbase customer data leak in 2025</a> showed the other side of the problem: even when custody isn&rsquo;t the immediate concern, your identity, balance and history become an attack surface</li>
</ul>
<p>A stack like Coldcard + Sparrow + Fulcrum + full node doesn&rsquo;t eliminate every risk in the world. But it avoids two very real classes of problem:</p>
<ul>
<li>losing custody sovereignty</li>
<li>handing over wallet and transaction privacy on a silver platter to third parties</li>
</ul>
<h2>So is it worth it?<span class="hx:absolute hx:-mt-20" id="so-is-it-worth-it"></span>
    <a href="#so-is-it-worth-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For most people, honestly, probably not in the first month. It&rsquo;s labor-intensive, has a learning curve, and demands discipline.</p>
<p>But for programmers, engineers and any technical person who wants to learn not to depend on someone else&rsquo;s service all the time, I think it&rsquo;s an excellent exercise.</p>
<p>You learn about:</p>
<ul>
<li>separation of concerns</li>
<li>persistence and state</li>
<li>graceful shutdown</li>
<li>observability</li>
<li>secret isolation</li>
<li>the trade-off between convenience and security</li>
</ul>
<p>And all of that is valuable beyond Bitcoin.</p>
<p>In the end, that&rsquo;s what interests me most about this stack. It isn&rsquo;t about preaching &ldquo;hyperbitcoinization&rdquo; or posing as a price prophet. It&rsquo;s about building a system at home that I can trust more because I&rsquo;m the one who installed it, measured it, broke it, fixed it and documented it.</p>
<p>Is it work? Yes.</p>
<p>But that kind of work teaches exactly what modern software tries to make you forget: depending less on others is more work upfront, but it usually buys a lot more control in the long run.</p>
]]></content:encoded><category>bitcoin</category><category>homeserver</category><category>self-hosting</category><category>privacy</category><category>security</category><category>lightning</category></item><item><title>My Sim Racing Cockpit - Formula FX1</title><link>https://akitaonrails.github.io/en/2026/04/01/my-sim-racing-cockpit-formula-fx1/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/01/my-sim-racing-cockpit-formula-fx1/</guid><pubDate>Wed, 01 Apr 2026 17:00:00 GMT</pubDate><description>&lt;p&gt;I&amp;rsquo;ve loved cars for as long as I can remember. My first real contact with racing games was at the arcades of the 80s and 90s. And when I say &amp;ldquo;real,&amp;rdquo; I mean sitting at a cabinet with a wheel, pedals, a hard plastic seat and the screen wrapping in front of you. &lt;a href="https://en.wikipedia.org/wiki/Out_Run"target="_blank" rel="noopener"&gt;OutRun&lt;/a&gt; (1986), &lt;a href="https://en.wikipedia.org/wiki/Rad_Mobile"target="_blank" rel="noopener"&gt;Rad Mobile&lt;/a&gt; (1991), &lt;a href="https://en.wikipedia.org/wiki/Virtua_Racing"target="_blank" rel="noopener"&gt;Virtua Racing&lt;/a&gt; (1992), &lt;a href="https://en.wikipedia.org/wiki/Ridge_Racer"target="_blank" rel="noopener"&gt;Ridge Racer&lt;/a&gt; (1993), &lt;a href="https://en.wikipedia.org/wiki/Daytona_USA_%28video_game%29"target="_blank" rel="noopener"&gt;Daytona USA&lt;/a&gt; (1994), &lt;a href="https://en.wikipedia.org/wiki/Scud_Race"target="_blank" rel="noopener"&gt;Scud Race&lt;/a&gt; (1996). Every one of those games left a mark on me. But Daytona USA stuck in a different way. That twin cabinet, two machines side by side, the &amp;ldquo;DAYTONAAA, let&amp;rsquo;s go away&amp;rdquo; track blasting through the arcade, the wheel rumbling in your hand. I still remember it.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I&rsquo;ve loved cars for as long as I can remember. My first real contact with racing games was at the arcades of the 80s and 90s. And when I say &ldquo;real,&rdquo; I mean sitting at a cabinet with a wheel, pedals, a hard plastic seat and the screen wrapping in front of you. <a href="https://en.wikipedia.org/wiki/Out_Run"target="_blank" rel="noopener">OutRun</a> (1986), <a href="https://en.wikipedia.org/wiki/Rad_Mobile"target="_blank" rel="noopener">Rad Mobile</a> (1991), <a href="https://en.wikipedia.org/wiki/Virtua_Racing"target="_blank" rel="noopener">Virtua Racing</a> (1992), <a href="https://en.wikipedia.org/wiki/Ridge_Racer"target="_blank" rel="noopener">Ridge Racer</a> (1993), <a href="https://en.wikipedia.org/wiki/Daytona_USA_%28video_game%29"target="_blank" rel="noopener">Daytona USA</a> (1994), <a href="https://en.wikipedia.org/wiki/Scud_Race"target="_blank" rel="noopener">Scud Race</a> (1996). Every one of those games left a mark on me. But Daytona USA stuck in a different way. That twin cabinet, two machines side by side, the &ldquo;DAYTONAAA, let&rsquo;s go away&rdquo; track blasting through the arcade, the wheel rumbling in your hand. I still remember it.</p>
<p><img src="https://upload.wikimedia.org/wikipedia/commons/2/24/DaytonaUSA_arcade_SaoPaulo.jpg" alt="Daytona USA machines at a São Paulo mall — exactly the kind of twin cabinet that’s burned into my memory"  loading="lazy" /></p>
<p>But the game that really got me hooked on the simcade genre was the original <a href="https://en.wikipedia.org/wiki/Gran_Turismo_%28video_game%29"target="_blank" rel="noopener">Gran Turismo</a>, in 1997, on the PlayStation 1. &ldquo;The Real Driving Simulator&rdquo; on the cover. I played that game obsessively. Around the same time I was watching the <a href="https://en.wikipedia.org/wiki/Initial_D"target="_blank" rel="noopener">Initial D</a> anime, which premiered in 1998 in Japan. I bought every manga volume and read all of it from start to finish. The story of Takumi Fujiwara going down Mount Akina at dawn delivering tofu in his father&rsquo;s AE86 is, to me, one of the best motorsport stories ever told in any medium.</p>
<p>I still follow Shuichi Shigeno&rsquo;s work today. After Initial D came <a href="https://kodansha.us/series/mf-ghost/"target="_blank" rel="noopener">MF Ghost</a> (2017-2025), set in the same universe but in a near future where combustion cars have become museum pieces. And now, since July 2025, I&rsquo;m reading <a href="https://kmanga.kodansha.com/title/10664/episode/360584"target="_blank" rel="noopener">Subaru and Subaru</a>, the direct sequel that ties the Initial D and MF Ghost universes together with two protagonists named Subaru — one from Gunma, one from Kanagawa — competing in a new racing series. It&rsquo;s Shigeno at his best.</p>
<p><a href="https://kmanga.kodansha.com/title/10664/episode/360584"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/subaru-x-subaru-cover.png" alt="Subaru and Subaru — Shigeno’s new manga, the direct sequel to Initial D and MF Ghost"  loading="lazy" /></a></p>
<p>Two years ago I traveled to Japan with my girlfriend and made a point of going to <a href="https://en.wikipedia.org/wiki/Daikoku_Parking_Area"target="_blank" rel="noopener">Daikoku PA</a>, the famous parking area on the Shuto Expressway in Yokohama where the JDM culture concentrates. As an old fan of <a href="https://store.steampowered.com/app/2634950/Tokyo_Xtreme_Racer/"target="_blank" rel="noopener">Tokyo Xtreme Racer</a>, by Genki, I needed to see Daikoku with my own eyes at least once. And it didn&rsquo;t disappoint. Instead of renting a car, we booked a tour with a local guide in his prepped Nissan GT-R. Better that way. On the drive there he explained the history of the Wangan, how the scene works, what&rsquo;s YouTube exaggeration and what&rsquo;s real. When we got there on a Friday night and I saw the whole thing in person — Skyline R34, RX-7, Supra, GT-R, tuned kei trucks, insane bosozoku — the feeling was strange in the best possible way. It looked like Tokyo Xtreme Racer, except with the smell of fuel in the air and the sound of real exhaust pipes.</p>
<p>And there&rsquo;s another detail: I&rsquo;m playing the new <a href="https://store.steampowered.com/app/2634950/Tokyo_Xtreme_Racer/"target="_blank" rel="noopener">Tokyo Xtreme Racer</a> reboot on PC, and it&rsquo;s exactly the kind of game that understands its own audience. Strong single-player campaign, addictive progression, the right vibe, and none of the loot box nonsense. I&rsquo;d recommend it without hesitation. For the same reason, I&rsquo;m also really looking forward to <a href="https://forza.net/forzahorizon6"target="_blank" rel="noopener">Forza Horizon 6</a>, which this time is going to be set in Japan. I&rsquo;ve already pre-ordered it and I can&rsquo;t wait to play it on the new cockpit.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/daikoku---nissan.jpg" alt="At Daikoku PA with a modified GT-R"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/daikoku---trueno.jpg" alt="With an AE86 Trueno at Daikoku PA — Takumi’s car"  loading="lazy" /></p>
<h2>Driving for real<span class="hx:absolute hx:-mt-20" id="driving-for-real"></span>
    <a href="#driving-for-real" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now that I&rsquo;m semi-retired, I&rsquo;ve had the chance to take my Mercedes to track days. I&rsquo;ve driven at <a href="https://en.wikipedia.org/wiki/Aut%C3%B3dromo_Jos%C3%A9_Carlos_Pace"target="_blank" rel="noopener">Autódromo de Interlagos</a> (the Autódromo José Carlos Pace), the 4.309 km circuit in São Paulo that&rsquo;s been hosting the Brazilian F1 GP since 1973, famous for the S do Senna corner complex and the circuit&rsquo;s wild elevation changes. I&rsquo;ve also driven at <a href="https://www.velocittapark.com.br/"target="_blank" rel="noopener">Autódromo Velocitta</a>, a modern 3.443 km circuit opened in 2014 in Mogi Guaçu in the interior of São Paulo, which hosts Stock Car Brasil and Porsche Cup.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/velocitta.jpg" alt="At Velocitta with my Mercedes during an AMG track day"  loading="lazy" /></p>
<p>In Las Vegas I&rsquo;ve driven supercars on those track-day experiences. And when I traveled with my girlfriend to Gramado, in Rio Grande do Sul, we went to <a href="https://supercarros.cc/"target="_blank" rel="noopener">Super Carros</a>, which is on Av. das Hortênsias 4635. They have a 2,400 m² hangar with more than 50 cars — Ferraris, Lamborghinis, Porsches, GT-Rs, Corvettes, American muscle cars. You pick a car, head out with an instructor, and drive a roughly 17 km route between Gramado and Canela. I took out a Nissan GT-R and a Ferrari California.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/gramado---gtr.jpg" alt="With a GT-R in Gramado"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/gramado---ferrari.jpg" alt="With a Ferrari California in Gramado"  loading="lazy" /></p>
<p>Three years ago I also went to Abu Dhabi with my girlfriend and we went to Ferrari World, which has some of the best racing simulators I&rsquo;ve ever tried. Hydraulic platform with 6 degrees of freedom, F1 cockpit, the works. I&rsquo;ve always loved testing simulators wherever I go.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/ferrari-park.jpg" alt="F1 simulator with a hydraulic platform at Ferrari World in Abu Dhabi"  loading="lazy" /></p>
<p>But driving real cars on real tracks is a very expensive hobby. Tires, fuel, insurance, maintenance, registration. And more important: I&rsquo;m an introvert. I prefer being alone. My simulator cockpit is perfect for when I want to drive without having to deal with anyone. That&rsquo;s why I love rally so much — it&rsquo;s me, the virtual co-driver, and the road. Nothing else.</p>
<h2>The games I play<span class="hx:absolute hx:-mt-20" id="the-games-i-play"></span>
    <a href="#the-games-i-play" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I know most people who build a cockpit like this do it to play serious sims — iRacing, Assetto Corsa Competizione, Automobilista 2. I respect that, but it isn&rsquo;t my thing. I don&rsquo;t like playing online with other people. I have zero intention of starting a live streaming career. This is purely for my own enjoyment.</p>
<p>These days I play Gran Turismo 7 on the PS5, <a href="https://store.steampowered.com/app/2440510/Forza_Motorsport/"target="_blank" rel="noopener">Forza Motorsport</a> (the 8, from 2023) on PC, but where I have the most fun is in rally games: <a href="https://store.steampowered.com/app/1849250/EA_SPORTS_WRC/"target="_blank" rel="noopener">EA SPORTS WRC</a>, <a href="https://store.steampowered.com/app/1462810/WRC_10_FIA_World_Rally_Championship/"target="_blank" rel="noopener">WRC 10</a> and <a href="https://store.steampowered.com/app/690790/DiRT_Rally_20/"target="_blank" rel="noopener">DiRT Rally 2.0</a>. My first experience with Forza was on the Xbox One with Forza Motorsport 5 and then Forza Horizon 4, which kept me hooked for hundreds of hours.</p>
<p>And I have a huge soft spot for retro games. The original Colin McRae Rally from 1998 on the PS1 was my first rally game. But my favorite of all time is Colin McRae Rally 2.0 (2000), also on the PS1. I recently played through the entire campaign again on the PC version — you can find repacks that run in high resolution and widescreen, much better than the original PlayStation versions. I&rsquo;d recommend that for any of the titles in the series.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/colin-mcrae-rally-2-gameplay.jpg" alt="Colin McRae Rally 2.0 running in widescreen on PC — snow in Sweden with the Ford Focus"  loading="lazy" /></p>
<p>After that came Colin McRae 3 (2002), Colin McRae Rally 04 (2003) and Colin McRae 2005 (2004). Other arcades I revisit often: OutRun 2 SP (2004) and OutRun 2006: Coast 2 Coast (2006) — the best OutRun ever made, in my opinion.</p>
<p>But my game of the year, by a long shot, is <a href="https://store.steampowered.com/app/3218630/Super_Woden_Rally_Edge/"target="_blank" rel="noopener">Super Woden: Rally Edge</a>. An indie made by a solo developer (ViJuDa, from Spain) that launched in January 2026 for less than R$ 60. Eight countries, more than 80 cars, a career mode, local split-screen multiplayer for up to 4 players, online leaderboards. The behind-the-car camera instead of the top-down view of the previous Super Woden GP made all the difference. 96% positive reviews on Steam with more than 1,300 ratings. It&rsquo;s the kind of game that proves you don&rsquo;t need a million-dollar budget to make something amazing.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/super-woden-rally-edge.jpg" alt="Super Woden: Rally Edge — indie made by a solo dev that competes with the big ones"  loading="lazy" /></p>
<h2>The evolution of my wheel setup<span class="hx:absolute hx:-mt-20" id="the-evolution-of-my-wheel-setup"></span>
    <a href="#the-evolution-of-my-wheel-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Logitech G29 era (~2015-2021)<span class="hx:absolute hx:-mt-20" id="logitech-g29-era-2015-2021"></span>
    <a href="#logitech-g29-era-2015-2021" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I always wanted a racing wheel. I started over 15 years ago with something equivalent to Logitech&rsquo;s entry-level wheel, a G29. The G29 is a fine wheel to start with — gear-driven force feedback, pedals with a clutch, 900 degrees of rotation. But its force feedback is noisy and a bit crude. You can feel the gears turning.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/logitech-g29-and-myself.jpeg" alt="Me playing with the Logitech G29 on the couch — the beginning of it all"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/logitech-g29-with-support.jpg" alt="The G29 with a simple stand in front of the couch"  loading="lazy" /></p>
<h3>Thrustmaster T300RS + SXT V2 stand era (~2021-2024)<span class="hx:absolute hx:-mt-20" id="thrustmaster-t300rs--sxt-v2-stand-era-2021-2024"></span>
    <a href="#thrustmaster-t300rs--sxt-v2-stand-era-2021-2024" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Around 2021 I upgraded to the <a href="https://www.thrustmaster.com/products/t300rs-gt-edition/"target="_blank" rel="noopener">Thrustmaster T300RS</a>, a belt-driven wheel that&rsquo;s a huge jump up from the Logitech. The force feedback is much smoother and more precise. And I bought the <a href="https://loja.cockpitextremeracing.com.br/products/suporte-para-volantes-sxt-v2?variant=51111119454489"target="_blank" rel="noopener">Extreme Sim Racing SXT V2</a> stand, which is much sturdier than those generic desk clamps.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/thrustmaster-with-support.jpg" alt="The Thrustmaster T300RS mounted on the SXT V2 stand"  loading="lazy" /></p>
<p>First I set it up in front of my desktop PC, which at the time had an RTX 3090. It worked, but it was a hassle to keep mounting and unmounting the stand and the cables every time I wanted to play.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/thrustmaster-with-pc.jpg" alt="The Thrustmaster in front of the desktop PC — functional but annoying"  loading="lazy" /></p>
<p>Then I built a setup with a long fiber-optic HDMI cable to connect my 60&quot; TV to the PC at the back of the room. I moved the stand in front of the couch. Less of a hassle, but I still had to take it down whenever I wanted to watch a movie with my girlfriend.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/thrustmaster-with-big-tv.jpg" alt="Setup with the big TV — better, but still a hassle"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/thrustmaster-in-the-living-room.jpg" alt="The stand in the living room — always in the way"  loading="lazy" /></p>
<p>Around 2024 or 2025 I swapped my couch for one of those VIP cinema couches from <a href="https://www.starseat.com.br/sofa-cinema-sofa-cinema"target="_blank" rel="noopener">Star Seat</a>, which reclines and the whole nine yards. The problem: it was way taller than the previous couch. I had to do all kinds of workarounds to make the stand work at that height. I even 3D-printed mounts and sent them to PCBWay to machine steel plates so I could attach big wheels under the stand and gain a few centimeters of height. But that left the setup way too wobbly to drive comfortably.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec-support-sofa-too-high.jpg" alt="The problem: VIP couch too tall for the stand — total kludge"  loading="lazy" /></p>
<h3>Fanatec CSL DD + Direct Drive era (~2024-2025)<span class="hx:absolute hx:-mt-20" id="fanatec-csl-dd--direct-drive-era-2024-2025"></span>
    <a href="#fanatec-csl-dd--direct-drive-era-2024-2025" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In the meantime I gave the T300 to my brother and upgraded to a <a href="https://fanatec.com/eu-en/racing-wheels-wheel-bases/racing-wheels/gran-turismo-dd-pro-5-nm-wheel-base"target="_blank" rel="noopener">Fanatec CSL DD Gran Turismo Edition</a>. The CSL DD&rsquo;s direct drive motor delivers 5 Nm of torque at the base, but the Gran Turismo DD Pro kit comes with the Boost Kit 180 that brings it up to a sustained 8 Nm with no active cooling. Direct drive means there&rsquo;s no gear or belt between the motor and the wheel — the motor&rsquo;s shaft IS the wheel&rsquo;s shaft. The difference is absurd. The T300 was already great, much better than the Logitech. But the Fanatec is on another level. You feel every asphalt texture, every bump, every incipient slide. There&rsquo;s no going back once you try it.</p>
<p>I bought it together with the <a href="https://fanatec.com/eu-en/pedals/csl-pedals"target="_blank" rel="noopener">CSL Pedals with Load Cell</a> kit, which measures pressure on the brake instead of displacement. Makes all the difference in braking — you learn to modulate by foot pressure, not by how far the pedal moves. Way more natural.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec-direct-drive-close-up.jpg" alt="Close-up of the Fanatec CSL DD direct drive motor"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec-csl-pedals.jpg" alt="Fanatec CSL Pedals with Load Cell Kit"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec---shifter.jpg" alt="The Fanatec shifter — manual H-pattern with chrome knob"  loading="lazy" /></p>
<p>I also wanted to try the H-pattern manual shifter with the <a href="https://fanatec.com/us/en/p/add-ons/crd-9040002-ww/clubsport-shifter-sq"target="_blank" rel="noopener">ClubSport Shifter SQ V1.5</a> from Fanatec and a separate handbrake. It&rsquo;s fun to try out old cars with a clutch and an H-pattern, but in practice I never adapted. The SXT V2 stand was already shaking a lot with the direct drive, and using the shifter on that unstable setup was frustrating. And I know there are people who want to do heel-toe, but in the simulator I prefer to keep my left foot on the brake and my right on the gas and modulate both at the same time. Works better for me. Now that I have the McLaren wheel with the analog handbrake and clutch paddles right on the wheel, the H-pattern shifter and the external handbrake have been retired. For rally, an analog handbrake on the wheel is much more natural.</p>
<p>I also bought the PS5 with Gran Turismo 7 around this time. I put on the <a href="https://dbrand.com/shop/ps5"target="_blank" rel="noopener">dbrand Darkplates</a> matte black faceplates to replace the original white plates — it looks much better and more discreet.</p>
<p>But the setup was still the SXT V2 stand in front of the VIP couch. The same kludge. The same wobble. I obviously wasn&rsquo;t going to give up the cinema couch. The situation became unsustainable.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fanatec-with-support.jpg" alt="The Fanatec CSL DD on the SXT V2 stand — too powerful for the stand to handle"  loading="lazy" /></p>
<h2>The computer and the hardware setup<span class="hx:absolute hx:-mt-20" id="the-computer-and-the-hardware-setup"></span>
    <a href="#the-computer-and-the-hardware-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A note on my gaming hardware. I bought a <a href="https://www.minisforum.com/products/UX790-Pro.html"target="_blank" rel="noopener">Minisforum UX790 Pro</a> to be my dedicated Steam machine. It&rsquo;s a mini-PC with an Intel Core Ultra 9 285H processor, fits in the palm of your hand. Together I bought the <a href="https://www.minisforum.com/products/minisforum-deg1-egpu-dock"target="_blank" rel="noopener">Minisforum DEG1</a>, an external GPU dock that connects via OCuLink (PCIe 4.0 x4, 64 GT/s). It&rsquo;s an open design — basically a board with a PCIe x16 slot and room for an ATX or SFX power supply. There&rsquo;s no card size limit, so an RTX 4090 fits comfortably. The performance loss compared to a native PCIe slot is minimal. I put the RTX 4090 in it. The 4090 came from my desktop — at the start of 2025 I went to Miami and took the chance to buy an RTX 5090 because I was using more and more local AI and LLMs. I gave my old 3090 to my girlfriend to use for video editing. The 4090 went into the mini-PC.</p>
<p>So my gaming setup today is: Minisforum UX790 Pro + eGPU with RTX 4090 for Steam and PC games, and a PlayStation 5 with matte black Darkplates for Gran Turismo 7 and exclusives.</p>
<h2>The cockpit: Formula FX1<span class="hx:absolute hx:-mt-20" id="the-cockpit-formula-fx1"></span>
    <a href="#the-cockpit-formula-fx1" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To round out the setup I also needed a decent monitor. I was already used to my 80&quot; Samsung OLED TV in the living room and didn&rsquo;t want to downgrade picture quality. So I invested in the <a href="https://www.samsung.com/br/monitors/gaming/odyssey-oled-g8-g81sf-32-inch-240hz-oled-uhd-ls32fg810snxzd/"target="_blank" rel="noopener">Samsung Odyssey OLED G8 32&quot;</a>. It&rsquo;s a 4K (3840x2160) OLED monitor with a 240 Hz refresh rate, 0.03 ms response time (GTG), HDR True Black 400, HDR10+, 99% DCI-P3 coverage, 1,000,000:1 contrast, and FreeSync Premium Pro. It has 2 HDMI 2.1 inputs and 1 DisplayPort 1.4.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/closed-delivery-box.jpg" alt="The box of the Samsung Odyssey OLED G8 32&#34; when it arrived"  loading="lazy" /></p>
<p>In practice: the colors pop, black is real black (it&rsquo;s OLED, no backlight), and with the RTX 4090 I run most games at 4K and 120 fps with no problem. On lighter titles like Super Woden: Rally Edge, it easily hits 240 Hz. The smoothness is absurd. For a cockpit where you&rsquo;re 60-70 cm from the screen, 32&quot; OLED in 4K is the sweet spot. Bigger and you start to see pixels. Smaller and you lose the immersion.</p>
<p>In January 2026, after years of kludges, I finally ordered a dedicated cockpit. I researched a lot. I considered the <a href="https://loja.cockpitextremeracing.com.br/products/cockpit-ax160-horizontal?variant=52008751431961"target="_blank" rel="noopener">Cockpit AX160</a>, made of aluminum profile and very modular, and the <a href="https://loja.cockpitextremeracing.com.br/products/cockpit-4-0-horizontal?variant=52004294852889"target="_blank" rel="noopener">Cockpit 4.0</a>, which is the more traditional tubular steel kind. But neither was available at the time of purchase. And then I found the <a href="https://loja.cockpitextremeracing.com.br/products/cockpit-formula-fx1-preto-e-verde?variant=51700876509465"target="_blank" rel="noopener">Formula FX1 in black and green</a> from Extreme Racing — Petronas colors, F1-styled.</p>
<p>The FX1 is very different from traditional cockpits. The whole structure is welded thick steel tubing. When I say it doesn&rsquo;t shake, I mean it doesn&rsquo;t shake at all. Zero wobble. It&rsquo;s a brutal difference compared to a stand in front of the couch. The driving position is reclined, F1-style — your feet are at the same height or higher than your hips. You&rsquo;d think it would be uncomfortable, but it isn&rsquo;t. You can sit there for hours without complaining. It comes with a padded adjustable seat, an articulating monitor mount, a tilt-adjustable pedal mount, and a height-adjustable wheel mount.</p>
<p>I had to wait about a month for delivery. In the meantime, as anyone who follows my blog knows, I dove into a 16-hour-a-day marathon testing the new AI agents from Anthropic and OpenAI — check the <a href="/en/en/tags/vibe-coding/">#vibecoding</a> and <a href="/en/en/tags/ai/">#agents</a> tags to see everything I built. After about 30 days of that insane marathon, my lower back gave out and I started developing what looks like a herniated disc. I had to see a doctor and take heavy anti-inflammatories.</p>
<p>And right that week, the cockpit decided to arrive.</p>
<h3>The build<span class="hx:absolute hx:-mt-20" id="the-build"></span>
    <a href="#the-build" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I was in absurd pain, but I built the cockpit anyway. It took an entire day to unbox and assemble the heavy steel pieces with my back screaming, but I did it.</p>
<p>The official assembly video I followed:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/WnocqqhmZas"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/first-pieces-mounted.jpg" alt="First pieces mounted — the base and the seat structure"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/mostly-mounted.jpg" alt="Almost complete structure — seat, wheel mount, pedals"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/mostly-mounted-from-front.jpg" alt="Front view of the almost-finished structure"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/almost-mounted,-with-monitor.jpg" alt="Almost complete assembly with the monitor mount"  loading="lazy" /></p>
<h3>The McLaren GT3 V2 wheel<span class="hx:absolute hx:-mt-20" id="the-mclaren-gt3-v2-wheel"></span>
    <a href="#the-mclaren-gt3-v2-wheel" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>After assembling the cockpit, I decided that the standard wheel that comes with the CSL GT kit wasn&rsquo;t enough. I upgraded to the <a href="https://www.racingwheelbrasil.com.br/produtos/volante-fanatec-csl-elite-mclaren-gt3-v2-pc-xbox-ps4-ps5-ready/"target="_blank" rel="noopener">Fanatec CSL Elite McLaren GT3 V2</a> (~R$ 4,990). It&rsquo;s a 1:1 scale replica of the McLaren GT3 wheel, with carbon fiber, an OLED display, and compatibility with PC, Xbox, PS4 and PS5.</p>
<p>What I like most about it: it has the normal shift paddles behind it (shift up/down), but it also has two additional analog paddles that can be configured in four different modes. In mode B, which is what I use, the left paddle works as an analog handbrake and the right one as an analog clutch. That&rsquo;s perfect for rally — I can pull the handbrake mid-corner without taking my hand off the wheel. It also has two 2-position toggles, two 12-position rotaries, 7 standard buttons with interchangeable caps, and Fanatec&rsquo;s 7-direction FunkySwitch. It&rsquo;s a complete racing controller.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/mclaren-wheel.jpg" alt="The McLaren GT3 V2 wheel mounted on the cockpit — carbon fiber and OLED display"  loading="lazy" /></p>
<h2>The final setup<span class="hx:absolute hx:-mt-20" id="the-final-setup"></span>
    <a href="#the-final-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The cockpit ended up in a corner of my bedroom, between the manga shelves (you can spot Akira, Initial D, and 500-something other volumes in the background). I mounted the mini-PC and the PS5 on the cockpit&rsquo;s side structure, together with the eGPU and the RTX 4090. Everything stays permanently connected. That&rsquo;s what makes the difference: I no longer have to set anything up or take it down. I sit, turn it on, and I&rsquo;m driving in 30 seconds.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/fully-mounted-from-side.jpg" alt="Side view of the complete cockpit — between the manga shelves"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/all-consoles-from-front.jpg" alt="The consoles mounted on the structure: PS5 with Darkplates, Minisforum UX790 Pro, eGPU with RTX 4090"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/close-up-of-the-consoles-from-front.jpg" alt="Close-up of the consoles and cabling"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/sitting-angle.jpg" alt="The driver’s perspective — this is what I see when I sit down"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/monitor-on-view-from-front.jpg" alt="PS5 showing the game list — Gran Turismo 7 at the top"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/another-fully-mounted-shot.jpg" alt="The complete cockpit seen from behind"  loading="lazy" /></p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/from-front-gran-turismo-h264.mp4" type="video/mp4">
  </video>
  <em>Gran Turismo 7 running on the final cockpit</em>
</div>
<h2>The audio system<span class="hx:absolute hx:-mt-20" id="the-audio-system"></span>
    <a href="#the-audio-system" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To round out the setup I needed dedicated audio. I didn&rsquo;t want to use the monitor&rsquo;s audio (terrible) and I didn&rsquo;t want to be on a headset all the time. The fix was to build a separate audio system with HDMI audio extraction.</p>
<p>The centerpiece is an <a href="https://www.amazon.com.br/dp/B00MNGIP2Y"target="_blank" rel="noopener">HDMI 2.1 switcher from OREI</a> with audio extraction. It has 2 inputs and 1 HDMI output, supports 4K at 120Hz (48 Gbps of bandwidth), and extracts the audio through optical TOSLINK and 3.5mm. I connect the HDMI output of the RTX 4090 to one input and the PS5 to the other. Video goes to the monitor. Audio goes out through the optical port.</p>
<p>The optical audio goes to an <a href="https://www.mercadolivre.com.br/amplificador-de-potncia-aiyima-d03-bluetooth-50-150-watts-cor-preto/p/MLB46172770"target="_blank" rel="noopener">Aiyima D03 amplifier</a>, a compact 2.1 channel amp with 150W per channel, integrated DAC (PCM1808 chip), and Bluetooth 5.0 with aptX HD. It has optical, coaxial, USB, RCA and Bluetooth inputs. It even has a dedicated subwoofer output for when I get around to adding one. It uses Texas Instruments&rsquo; TAS5624 amplifier chip and has bass and treble control through the remote. For a cockpit setup where you&rsquo;re 1 meter from the speakers, 150W is more than enough.</p>
<p>In practice, I keep the amp at 50% and Windows volume at 50%, and that&rsquo;s already loud as hell. Which is to say: this isn&rsquo;t a &ldquo;good enough&rdquo; little system. It&rsquo;s set up to actually go loud if I want.</p>
<p>The speakers are <a href="https://edifier.com.br/caixa-de-som-passiva-p12-madeira-edifier.html"target="_blank" rel="noopener">Edifier P12</a>, passive, with a 4-inch woofer and a 19mm tweeter. Frequency response from 55Hz to 20kHz, 6 ohm impedance, 20W RMS each. The MDF cabinet with wood finish has a rear bass-reflex port that helps the lows. For passive speakers this size, they deliver well. The mid-range is clean and the highs don&rsquo;t distort even at high volume.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/audio-equipment.jpg" alt="The audio gear — Aiyima D03 box and Edifier P12"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/audio-speakers-next-to-other-stuff.jpg" alt="The Edifier P12s positioned on the shelves next to the cockpit, alongside the Akira collection and car miniatures"  loading="lazy" /></p>
<p>The setup logic is: HDMI switcher handles the switching between PS5 and PC, extracts audio to optical, the amp converts and amplifies, and the passive speakers deliver the sound. All without having to touch the monitor or swap cables. I press a button on the switcher and switch consoles.</p>
<p>When I want to play without bothering anyone, I plug my <a href="https://www.mercadolivre.com.br/fones-de-ouvido-meze-audio-109-pro-com-fio-de-madeira-com-en/p/MLB42456685"target="_blank" rel="noopener">Meze 109 Pro</a> directly into the 3.5mm output of the HDMI switcher. The Meze 109 Pro is an open-back headphone with 50mm dynamic drivers, 40 ohm impedance, 112 dB SPL/1mW sensitivity, and 5Hz to 30kHz response. The ear cups are walnut wood with handcrafted finish. It&rsquo;s an audiophile headphone that works perfectly without a dedicated amplifier thanks to the low impedance. The sound is warm, with full bass and rich mids. You can hear every detail of the engines.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/meze-headset.jpg" alt="Meze 109 Pro — walnut wood, audiophile sound"  loading="lazy" /></p>
<p>I haven&rsquo;t decided about a subwoofer yet, but it&rsquo;ll be my next upgrade. A dedicated sub is going to add that low-end weight that makes you feel the engine in your chest.</p>
<h2>The verdict<span class="hx:absolute hx:-mt-20" id="the-verdict"></span>
    <a href="#the-verdict" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The couch with a stand works. The PC desk with a stand works. But neither comes anywhere close to a dedicated cockpit. The FX1&rsquo;s steel structure doesn&rsquo;t move a millimeter, even with the Fanatec direct drive at max torque. The reclined F1 position is comfortable for sessions of hours. The load cell pedals stay firm on the base. The monitor is exactly at the right height and distance. And best of all: it&rsquo;s always ready. I don&rsquo;t need to assemble anything, take anything down, run cables, none of it. I sit and I drive.</p>
<p>For anyone who&rsquo;s wondering whether it&rsquo;s worth investing in a dedicated cockpit instead of staying with a desk or couch stand: it is. If you already have a direct drive wheel, the cockpit is the missing piece. I spent years thinking &ldquo;this is fine&rdquo; with the stand on the couch. It wasn&rsquo;t fine. The difference in driveability is something else entirely. And for my case — introvert, single-player only, simcade — I couldn&rsquo;t have built it any sooner. To be honest, I think I&rsquo;ve finally landed on the simulator setup that&rsquo;s perfect for my taste.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/monitor-on-view.jpg" alt="The monitor on with the driver’s view"  loading="lazy" /></p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/cockpit/video-testing-h264.mp4" type="video/mp4">
  </video>
  <em>Me driving on the final cockpit</em>
</div>
<h2>Shopping list: how much it all cost<span class="hx:absolute hx:-mt-20" id="shopping-list-how-much-it-all-cost"></span>
    <a href="#shopping-list-how-much-it-all-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the consolidated list of everything in my current setup, with approximate prices (some were bought in dollars and converted to reais at the time&rsquo;s exchange rate):</p>
<table>
  <thead>
      <tr>
          <th>Item</th>
          <th style="text-align: right">Estimated Price (R$)</th>
          <th>Link</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="https://loja.cockpitextremeracing.com.br/products/cockpit-formula-fx1-preto-e-verde?variant=51700876509465"target="_blank" rel="noopener">Cockpit Formula FX1 Black and Green</a></td>
          <td style="text-align: right">~6,290</td>
          <td>Extreme Racing</td>
      </tr>
      <tr>
          <td><a href="https://fanatec.com/us/en/p/sim-racing-bundles/crd-9020007-8nm-us/gran-turismo-dd-pro-8nm-qr2l-us"target="_blank" rel="noopener">Fanatec Gran Turismo DD Pro 8Nm (motor + wheel + pedals + Boost Kit)</a></td>
          <td style="text-align: right">~9,590</td>
          <td>Fanatec / Racing Wheel Brasil</td>
      </tr>
      <tr>
          <td><a href="https://fanatec.com/us/en/p/pedals/csl_p_lc/csl_pedals_lc"target="_blank" rel="noopener">Fanatec CSL Pedals LC (with Load Cell)</a></td>
          <td style="text-align: right">~1,500</td>
          <td>Fanatec</td>
      </tr>
      <tr>
          <td><a href="https://www.racingwheelbrasil.com.br/produtos/volante-fanatec-csl-elite-mclaren-gt3-v2-pc-xbox-ps4-ps5-ready/"target="_blank" rel="noopener">Fanatec CSL Elite McLaren GT3 V2</a></td>
          <td style="text-align: right">~4,990</td>
          <td>Racing Wheel Brasil</td>
      </tr>
      <tr>
          <td><a href="https://fanatec.com/us/en/p/add-ons/crd-9040002-ww/clubsport-shifter-sq"target="_blank" rel="noopener">Fanatec ClubSport Shifter SQ V1.5</a></td>
          <td style="text-align: right">~2,500</td>
          <td>Fanatec</td>
      </tr>
      <tr>
          <td><a href="https://www.minisforum.com/products/UX790-Pro.html"target="_blank" rel="noopener">Minisforum UX790 Pro</a></td>
          <td style="text-align: right">~5,000</td>
          <td>Minisforum</td>
      </tr>
      <tr>
          <td><a href="https://www.minisforum.com/products/minisforum-deg1-egpu-dock"target="_blank" rel="noopener">Minisforum DEG1 eGPU Dock</a> + RTX 4090</td>
          <td style="text-align: right">~12,000</td>
          <td>Minisforum / bought separately</td>
      </tr>
      <tr>
          <td>PlayStation 5 + <a href="https://dbrand.com/shop/limited-edition/ps5"target="_blank" rel="noopener">dbrand Darkplates</a></td>
          <td style="text-align: right">~4,500</td>
          <td>Sony / dbrand</td>
      </tr>
      <tr>
          <td><a href="https://www.samsung.com/br/monitors/gaming/odyssey-oled-g8-g81sf-32-inch-240hz-oled-uhd-ls32fg810snxzd/"target="_blank" rel="noopener">Samsung Odyssey OLED G8 32&quot;</a></td>
          <td style="text-align: right">~2,500</td>
          <td>Samsung</td>
      </tr>
      <tr>
          <td><a href="https://www.amazon.com.br/dp/B00MNGIP2Y"target="_blank" rel="noopener">OREI BK-21A HDMI 2.1 Switcher 2x1 with audio extraction</a></td>
          <td style="text-align: right">~450</td>
          <td>Amazon</td>
      </tr>
      <tr>
          <td><a href="https://www.mercadolivre.com.br/amplificador-de-potncia-aiyima-d03-bluetooth-50-150-watts-cor-preto/p/MLB46172770"target="_blank" rel="noopener">Aiyima D03 Amplifier</a></td>
          <td style="text-align: right">~900</td>
          <td>Mercado Livre</td>
      </tr>
      <tr>
          <td><a href="https://edifier.com.br/caixa-de-som-passiva-p12-madeira-edifier.html"target="_blank" rel="noopener">Edifier P12 (pair)</a></td>
          <td style="text-align: right">~799</td>
          <td>Edifier</td>
      </tr>
      <tr>
          <td><a href="https://www.mercadolivre.com.br/fones-de-ouvido-meze-audio-109-pro-com-fio-de-madeira-com-en/p/MLB42456685"target="_blank" rel="noopener">Meze 109 Pro</a></td>
          <td style="text-align: right">~5,390</td>
          <td>Mercado Livre / Heinrich Audio</td>
      </tr>
      <tr>
          <td>Cables (HDMI 2.1, optical, 3.5mm, power)</td>
          <td style="text-align: right">~300</td>
          <td>Various</td>
      </tr>
      <tr>
          <td><strong>TOTAL ESTIMATED</strong></td>
          <td style="text-align: right"><strong>~56,709</strong></td>
          <td></td>
      </tr>
  </tbody>
</table>
<p>Yes, almost R$ 57k is a lot of money. I worked like a dog for decades. Now that I&rsquo;ve managed to retire honestly, my family is well taken care of, I have no debts, and I can finally give myself something I always wanted as a kid but couldn&rsquo;t afford. When I sat at those OutRun and Daytona USA cabinets at the arcade, I dreamed of having something like this at home. It took 30-something years, but I got there.</p>
<p>And if you add up the years of kludges, stands that didn&rsquo;t work, 15-meter HDMI cables, 3D prints, machined steel plates, and the frustration of mounting and unmounting everything — a dedicated cockpit saves your sanity. Unlike a PC that depreciates fast, a steel cockpit lasts decades.</p>
]]></content:encoded><category>off-topic</category><category>gaming</category><category>sim-racing</category><category>cockpit</category><category>fanatec</category><category>gran-turismo</category><category>initial-d</category></item></channel></rss>