<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>OculiX | Blog</title><description/><link>https://oculix.org/</link><language>en</language><item><title>AI agentic vs deterministic visual automation in 2026: what&apos;s actually true</title><link>https://oculix.org/blog/2026-06-18-deterministic-vs-agentic-visual-automation-2026/</link><guid isPermaLink="true">https://oculix.org/blog/2026-06-18-deterministic-vs-agentic-visual-automation-2026/</guid><description>OculiX 3.0.4 vs AskUI in 2026 — a factual breakdown of AI agentic vs deterministic visual automation. Seven misleading claims corrected, plus the pricing math that explains why per-inference billing breaks at QA scale.

</description><pubDate>Thu, 18 Jun 2026 08:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In April 2026, &lt;strong&gt;AskUI&lt;/strong&gt; published &lt;a href=&quot;https://www.askui.com/blog-posts/askui-vs-sikulix-visual-automation-comparison-2025&quot;&gt;a comparison piece&lt;/a&gt; framing the choice in visual automation as if it were already decided. Deterministic, image-based automation tools are presented as a legacy paradigm — useful for nostalgia, limited by screen resolution, blocked on headless execution, helpless on mobile, doomed to be replaced by LLM-piloted agents that “understand” the interface rather than match pixels against it.&lt;/p&gt;
&lt;p&gt;I read &lt;strong&gt;AskUI&lt;/strong&gt;’s article carefully. It is well-written, and the SEO will travel. It is also, on most of its load-bearing claims about OculiX, factually wrong — by several major and minor releases.&lt;/p&gt;
&lt;p&gt;The intent of this post is not to attack a competitor. The intent is to factualize, calmly and in writing, what OculiX 3.0.4 actually does in 2026, so that a buyer reading both pieces side by side can make a decision on observable facts rather than on the carefully cropped excerpt &lt;strong&gt;AskUI&lt;/strong&gt; chose to publish.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-two-paradigms-named-honestly&quot;&gt;The two paradigms, named honestly&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Before going into claim-by-claim correction, let me name the two paradigms fairly, because they are not the same shape and they do not serve the same constraint.&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Deterministic visual automation&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A script written in code. A pattern stored as an image. A matcher that decides, by pixel comparison, whether the pattern is on screen. Same input, same output, every time. No model, no token, no cloud round-trip in the hot path. Auditable end to end.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path fill-rule=&quot;evenodd&quot; d=&quot;M1.44 8.855v-.001l3.527-3.516c.34-.344.802-.541 1.285-.548h6.649l.947-.947c3.07-3.07 6.207-3.072 7.62-2.868a1.821 1.821 0 0 1 1.557 1.557c.204 1.413.203 4.55-2.868 7.62l-.946.946v6.649a1.845 1.845 0 0 1-.549 1.286l-3.516 3.528a1.844 1.844 0 0 1-3.11-.944l-.858-4.275-4.52-4.52-2.31-.463-1.964-.394A1.847 1.847 0 0 1 .98 10.693a1.843 1.843 0 0 1 .46-1.838Zm5.379 2.017-3.873-.776L6.32 6.733h4.638l-4.14 4.14Zm8.403-5.655c2.459-2.46 4.856-2.463 5.89-2.33.134 1.035.13 3.432-2.329 5.891l-6.71 6.71-3.561-3.56 6.71-6.711Zm-1.318 15.837-.776-3.873 4.14-4.14v4.639l-3.364 3.374Z&quot; clip-rule=&quot;evenodd&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M9.318 18.345a.972.972 0 0 0-1.86-.561c-.482 1.435-1.687 2.204-2.934 2.619a8.22 8.22 0 0 1-1.23.302c.062-.365.157-.79.303-1.229.415-1.247 1.184-2.452 2.62-2.935a.971.971 0 1 0-.62-1.842c-.12.04-.236.084-.35.13-2.02.828-3.012 2.588-3.493 4.033a10.383 10.383 0 0 0-.51 2.845l-.001.016v.063c0 .536.434.972.97.972H2.24a7.21 7.21 0 0 0 .878-.065c.527-.063 1.248-.19 2.02-.447 1.445-.48 3.205-1.472 4.033-3.494a5.828 5.828 0 0 0 .147-.407Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;LLM-piloted agentic automation&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A natural-language instruction. A multimodal model that interprets the screen and decides what to click. Same input, plausibly similar output, never bit-for-bit reproducible. Behavior changes when the model version changes. Token-billed by the round trip. Auditability is a research topic.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;These two paradigms are not interchangeable in any professional QA practice. The deterministic paradigm produces reproducible artifacts on demand: production regression, compliance-grade audit trails, air-gapped perimeters, every test that must produce the same artifact today, tomorrow, and in three years on a different machine. The agentic paradigm, by construction, cannot — and a QA team optimizing for trust, audit and reproducibility cannot rationally adopt it as its primary engine. This is the position I have publicly defended on LLM non-determinism applied to automation: selling a non-reproducible autonomous execution as a test product is a professional fault, not an acceptable trade-off.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AskUI&lt;/strong&gt;’s piece nonetheless claims that the agentic paradigm is the inevitable successor to the deterministic one, and bolsters that framing with claims about the deterministic side that are either out of date by two minor releases, true of one tool but extended to another by amalgam, or simply incorrect. It is the combination of the two — a wrong strategic framing and a wrong technical assessment — that makes the present response necessary.&lt;/p&gt;
&lt;p&gt;Let me go through each claim.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;claim-by-claim&quot;&gt;Claim by claim&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;claim-1--oculix-301-a-development-build&quot;&gt;Claim 1 — “OculiX 3.0.1, a development build”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;AskUI&lt;/strong&gt; describes OculiX as “a development build called OculiX 3.0.1 […] also available with VNC support, Android ADB control, PaddleOCR integration, and additional scripting languages”.&lt;/p&gt;
&lt;p&gt;What is actually shipping in June 2026:&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Item&lt;/th&gt;&lt;th&gt;Reality&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Current stable&lt;/td&gt;&lt;td&gt;OculiX 3.0.4, released and downloaded by close to a hundred organizations in production&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Next major&lt;/td&gt;&lt;td&gt;OculiX 4.0, scoped, in active development, targeted for mid-2026&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Deployments&lt;/td&gt;&lt;td&gt;Fortune 500, APAC enterprises, defense sector — observable through Scarf usage telemetry&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;OCR engine&lt;/td&gt;&lt;td&gt;Tesseract embedded statically via Legerix, self-contained, no PaddleOCR dependency in stable&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Calling OculiX a “development build” in &lt;strong&gt;AskUI&lt;/strong&gt;’s comparison written for a 2026 audience is not a small inaccuracy. It tells the reader that the alternative they are evaluating is unstable, when the opposite is true. The choice of version number “3.0.1” in &lt;strong&gt;AskUI&lt;/strong&gt;’s piece appears to refer to a release from a previous quarter; their comparison was published months later, after several stable releases had shipped.&lt;/p&gt;
&lt;aside aria-label=&quot;On the OCR engine specifically&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; On the OCR engine specifically &lt;/p&gt;  &lt;div&gt;&lt;p&gt;The PaddleOCR integration mentioned by &lt;strong&gt;AskUI&lt;/strong&gt; belongs to an experimental branch of an earlier release and is not part of the stable distribution. OculiX 3.0.4 ships Tesseract embedded statically through Legerix, the self-contained OCR layer that I packaged precisely so the tool would not depend on any external Python server, GPU, or network call to perform OCR. The architecture is the opposite of what &lt;strong&gt;AskUI&lt;/strong&gt; describes.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h3 id=&quot;claim-2--headless-execution-not-supported&quot;&gt;Claim 2 — “Headless execution: not supported”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;AskUI&lt;/strong&gt; asserts that headless execution is “not supported” and that “a real, unlocked screen is required”.&lt;/p&gt;
&lt;p&gt;This is true of SikuliX run locally, on the laptop in front of a developer. It is not true of OculiX in the configuration most production teams actually deploy.&lt;/p&gt;
&lt;p&gt;OculiX ships a remote VNC architecture, documented and supported, where:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The OculiX runner executes on a dedicated VM&lt;/li&gt;
&lt;li&gt;The script connects to the VM through SSH, with the RFB protocol tunneled inside the SSH session&lt;/li&gt;
&lt;li&gt;The VM session is invisible to the developer’s machine while the test runs&lt;/li&gt;
&lt;li&gt;The developer’s machine remains fully usable for other work in parallel&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is the production deployment used by the majority of OculiX deployments at scale. It is not headless in the strict sense of “no framebuffer ever rendered” — that would be physically incompatible with pixel matching as a paradigm. It is headless in the sense the buyer actually cares about: the test does not block the operator’s screen, the test runs unattended in CI, the test does not require a human to be logged in physically in front of the test machine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AskUI&lt;/strong&gt; extends a true limitation of SikuliX local-mode execution to all image-based automation, which is misleading. OculiX moved past this limit several years before their comparison was written.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;claim-3--the-machine-is-not-usable-for-other-user-interaction-while-the-test-runs&quot;&gt;Claim 3 — “The machine is not usable for other user interaction while the test runs”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;This is the same architectural confusion as claim 2. In a local OculiX session — same as in a local AutoIt, AutoHotKey, or pyautogui session — the mouse and keyboard belong to the running script. This is intrinsic to driving a desktop UI, regardless of vendor.&lt;/p&gt;
&lt;p&gt;In a VNC session OculiX deployment, the test runs in a separate framebuffer on a separate VM. The developer’s mouse and keyboard are not contested. This is exactly what production teams running visual regression at scale ask for, and it is exactly what OculiX provides. &lt;strong&gt;AskUI&lt;/strong&gt; frames this as a categorical limit of image-based automation; in practice, the limit was lifted years ago by the architecture of how the tool is deployed.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;claim-4--no-execution-caching-capability&quot;&gt;Claim 4 — “No execution caching capability”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;OculiX 3.0.4 ships a persistent locator cache mechanism, embedded inside the PNG files used as patterns. Each pattern’s last successful match coordinates are stored as a private ancillary PNG chunk (&lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt;), preserved by every standard image tool, survived by &lt;code dir=&quot;auto&quot;&gt;git clone&lt;/code&gt;, and read by OculiX on the next cold-start invocation.&lt;/p&gt;
&lt;p&gt;The measured impact on a five-year-old i3 laptop, in a strict cold-start configuration mimicking a CI environment, is a ×6.5 speedup on the &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; operation: from a 502 ms baseline down to 77 ms median. On modern hardware the absolute numbers shrink but the relative speedup stays at ×6 to ×8.&lt;/p&gt;
&lt;p&gt;This is documented in detail in &lt;a href=&quot;https://oculix.org/blog/2026-05-19-png-metadata-visual-matching-speedup&quot;&gt;my benchmark post from May 2026&lt;/a&gt;, with the methodology, the harness, and the raw numbers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AskUI&lt;/strong&gt;’s claim that OculiX has “no execution caching capability” was published after the feature shipped and after the benchmark was public. It either reflects a research process that did not look at the changelog, or a deliberate omission of an inconvenient counter-example.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;claim-5--no-mobile-support-in-stable-version&quot;&gt;Claim 5 — “No mobile support in stable version”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;OculiX 3.0.4 ships first-class Android support, exposed through ADB, with device selection by serial, idempotent wake/unlock via &lt;code dir=&quot;auto&quot;&gt;KEYCODE_WAKEUP&lt;/code&gt;, and parallel multi-device farms. This has been the case for several minor releases.&lt;/p&gt;
&lt;p&gt;The interface is documented, the code is in the public repository, and the test cases that exercise it are in the test suite. A simple &lt;code dir=&quot;auto&quot;&gt;git log --grep android&lt;/code&gt; on the public repository surfaces the recent commits introducing the per-serial device selector and the wake decoupling.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AskUI&lt;/strong&gt;’s 2026 comparison claims OculiX has “no mobile support” — this is either months out of date or deliberately reads a single old branch. Either way, it is wrong about the stable release a buyer would actually download today.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;claim-6--screenshot-pattern-matching-breaks-under-resolution-changes&quot;&gt;Claim 6 — “Screenshot pattern matching breaks under resolution changes”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;This claim is technically true of the naive form of template matching — the form practiced by image-based tools in 2010. It is not true of OculiX’s &lt;code dir=&quot;auto&quot;&gt;Finder.java&lt;/code&gt; implementation in 2026, which cascades five matching modes:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Mode&lt;/th&gt;&lt;th&gt;Strategy&lt;/th&gt;&lt;th&gt;Tolerance&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;Standard template match&lt;/td&gt;&lt;td&gt;None — fast path for exact match&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;DPI-aware rescale&lt;/td&gt;&lt;td&gt;Resolution and scaling differences between capture and target&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;Gaussian-blurred match&lt;/td&gt;&lt;td&gt;Antialiasing, subtle color variation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;Grayscale-converted match&lt;/td&gt;&lt;td&gt;Color theme drift&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;5&lt;/td&gt;&lt;td&gt;Multi-scale brute force&lt;/td&gt;&lt;td&gt;0.5× to 2× scaling, last resort&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A pattern captured on a 1920×1080 screen at 100% scaling and matched on a 2560×1440 screen at 125% scaling is the exact case mode 2 was designed to handle. The matcher does not “break”; it changes strategy and finds the pattern. This has been in the codebase for years.&lt;/p&gt;
&lt;p&gt;Presenting the 2010 limitation as if it still defined the 2026 state of the tool is a common debate trick: pick the worst version of the position you are arguing against, and treat it as if it were the only one. &lt;strong&gt;AskUI&lt;/strong&gt;’s article uses this exact move.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;claim-7--implicit-framing-deterministic--legacy-agentic--future&quot;&gt;Claim 7 — Implicit framing: “deterministic = legacy, agentic = future”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;This is not stated literally by &lt;strong&gt;AskUI&lt;/strong&gt;, but it pervades the structure of their piece: the deterministic column is presented as a list of limitations, the agentic column as a list of capabilities.&lt;/p&gt;
&lt;p&gt;The honest framing is the inverse, depending on the buyer’s constraint:&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;What deterministic gives you&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Bit-for-bit reproducibility. Same script, same input, same output, today and in three years. Zero per-execution cost. Air-gap operation, no cloud dependency in the hot path. Auditable failure: when a test fails, the trace tells you exactly why. Survives the model version churn that disrupts LLM-piloted suites every quarter.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 16a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm10.67 1.47-8.05-14a3 3 0 0 0-5.24 0l-8 14A3 3 0 0 0 3.94 22h16.12a3 3 0 0 0 2.61-4.53Zm-1.73 2a1 1 0 0 1-.88.51H3.94a1 1 0 0 1-.88-.51 1 1 0 0 1 0-1l8-14a1 1 0 0 1 1.78 0l8.05 14a1 1 0 0 1 .05 1.02v-.02ZM12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;What agentic gives you&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Faster initial authoring, at the cost of artifacts that are not reproducible. Tolerance to UI drift, at the cost of silent passes when the model misinterprets the screen. Natural-language interfaces, at the cost of nobody being able to debug the test when it fails. Per-execution billing, indefinitely. None of this is a fair trade for professional QA.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;A QA team protecting a payment terminal under fiscal audit needs the left column. A growth team smoke-testing a marketing landing page can probably afford the right column. Pretending either category is the universal answer is selling the buyer a paradigm rather than helping them solve a problem.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-economic-shape-of-the-choice&quot;&gt;The economic shape of the choice&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Beyond the technical claims, the two paradigms differ in their economic shape, and the difference matters more than the comparison acknowledges.&lt;/p&gt;













































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Dimension&lt;/th&gt;&lt;th&gt;OculiX 3.0.4&lt;/th&gt;&lt;th&gt;LLM-piloted alternative&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Commercial-use terms&lt;/td&gt;&lt;td&gt;MIT, unlimited, code public&lt;/td&gt;&lt;td&gt;Non-commercial on free tier, paid plans required for production&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Pricing transparency&lt;/td&gt;&lt;td&gt;Free, no plan to upgrade&lt;/td&gt;&lt;td&gt;Plans not publicly disclosed, contact-sales gating&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Hot-path cost per test&lt;/td&gt;&lt;td&gt;Zero — local CPU&lt;/td&gt;&lt;td&gt;Token-billed inference per round trip&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cloud dependency&lt;/td&gt;&lt;td&gt;None in default deployment&lt;/td&gt;&lt;td&gt;Cloud agent OS, hosted inference hub&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Air-gap suitability&lt;/td&gt;&lt;td&gt;Native&lt;/td&gt;&lt;td&gt;Requires self-hosted enterprise plan, custom contract&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Self-hosted at scale&lt;/td&gt;&lt;td&gt;Default mode&lt;/td&gt;&lt;td&gt;Enterprise tier only&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Auditability of runs&lt;/td&gt;&lt;td&gt;Per-byte deterministic&lt;/td&gt;&lt;td&gt;Subject to model and prompt drift&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;For a buyer running a hundred thousand test executions per day, the per-token inference cost of the agentic model is not a rounding error. It is a recurring operational expense that grows with usage. The deterministic alternative has zero such expense: the script runs on the runner that the buyer was going to provision anyway, and the matcher uses the CPU cycles that would otherwise be idle.&lt;/p&gt;
&lt;p&gt;For a buyer in a regulated industry — banking, defense, healthcare, manufacturing under traceable quality control — the air-gap and audit requirements are not negotiable. A tool that requires a cloud inference hub does not even reach the evaluation phase. A tool that runs entirely on-premise under MIT open-source terms clears that gate by construction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AskUI&lt;/strong&gt;’s comparison glosses over both of these, framing pricing as “starts free” and air-gap as “available in enterprise”. A buyer who reads only the marketing surface might conclude the difference is small. A buyer who looks at the contracts and the deployment architecture will conclude otherwise.&lt;/p&gt;
&lt;aside aria-label=&quot;On free trials with non-commercial usage terms&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; On free trials with non-commercial usage terms &lt;/p&gt;  &lt;div&gt;&lt;p&gt;A free tier that restricts the user to non-commercial usage is not free for the use case the buyer almost certainly has in mind. Evaluating an automation tool inside the buyer’s company is, by definition, a commercial use. The free tier exists to onboard the buyer into the paid plan. There is nothing wrong with that as a business model, but presenting it as comparable to an MIT open-source tool of equivalent capability is misleading.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;where-the-agentic-paradigm-is-sometimes-invoked--and-why-the-trade-off-does-not-hold&quot;&gt;Where the agentic paradigm is sometimes invoked — and why the trade-off does not hold&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The agentic paradigm is routinely defended on four use cases. I do not consider any of them a valid justification for professional QA — I list them so we can see why.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Highly volatile UIs under continuous redesign.&lt;/strong&gt; The argument: regenerating patterns costs more than re-running an LLM. The reality: deterministic pattern regeneration is measurable and bounded; LLM inference cost is recurring and tied to usage. Deterministic maintenance can be debugged; model drift cannot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authoring by non-developers in natural language.&lt;/strong&gt; The argument: lower the barrier to entry. The reality: a test is not low-code, it is engineering. Handing its authorship to someone who does not read code means accepting upfront that nobody will be able to audit the test when it fails.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Discovery work.&lt;/strong&gt; The argument: find edge cases a deterministic script would not exercise. The reality: discovery is done by human exploratory testing or by deterministic mutation/fuzzing; an LLM clicking semi-randomly is not an explorer, it is unanalyzable noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Workflows with a human-in-the-loop.&lt;/strong&gt; The argument: an operator validates the model’s output. The reality: what has just been described is an assistant — not a test engine. Calling that a QA automation is a linguistic abuse that hides the fact that the decision remains manual.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In none of these four cases do cloud inference billing, dependence on a model version that will change, or loss of reproducibility become acceptable for a test base meant to last years. &lt;strong&gt;AskUI&lt;/strong&gt; is a real and well-executed product within its space; the space itself is simply not that of professional QA, regardless of vendor.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-structural-problem-with-per-inference-pricing-in-qa&quot;&gt;The structural problem with per-inference pricing in QA&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The point that follows is not about &lt;strong&gt;AskUI&lt;/strong&gt; specifically. It applies to any editor — whatever the brand, whatever the marketing copy — who proposes a token-billed inference model on a cloud-only platform for an activity whose nature is high-volume, repeated, and budget-constrained. QA is precisely that activity.&lt;/p&gt;
&lt;p&gt;Let me lay out the structural mismatch.&lt;/p&gt;
&lt;p&gt;QA is, by its very purpose, an exercise in volume:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A regression suite runs on every pull request — 30 to 100 PRs per day for a mid-size product team&lt;/li&gt;
&lt;li&gt;A nightly run executes the full suite — 1 000 to 20 000 test cases&lt;/li&gt;
&lt;li&gt;A canary run hits production-like environments multiple times per release&lt;/li&gt;
&lt;li&gt;A smoke run protects each deployment in CI&lt;/li&gt;
&lt;li&gt;An integration suite exercises every supplier delivery&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Multiply by the number of teams, by the number of products, by the number of environments, by the number of replays needed for flaky-test analysis. A serious QA practice in a mid-to-large organization performs hundreds of thousands of test executions per day. That is not pathological scale. That is the normal floor.&lt;/p&gt;
&lt;p&gt;Now overlay a per-inference pricing model on this volume.&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Concrete scenario&lt;/th&gt;&lt;th&gt;Inferences per day&lt;/th&gt;&lt;th&gt;Cost trajectory at typical inference pricing&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Small team, daily smoke&lt;/td&gt;&lt;td&gt;~5 000&lt;/td&gt;&lt;td&gt;Manageable in year 1, scales linearly&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Mid-size product, PR + nightly + canary&lt;/td&gt;&lt;td&gt;~50 000&lt;/td&gt;&lt;td&gt;Becomes a visible budget line&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Enterprise QA practice across multiple products&lt;/td&gt;&lt;td&gt;~500 000&lt;/td&gt;&lt;td&gt;Becomes a board-level conversation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Compliance-grade replay for audit reconstitution&lt;/td&gt;&lt;td&gt;~1 000 000&lt;/td&gt;&lt;td&gt;Becomes a deal-breaker&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The QA leader who chooses a per-inference pricing tool inherits a structural problem with two opposing pressures:&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 16a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm10.67 1.47-8.05-14a3 3 0 0 0-5.24 0l-8 14A3 3 0 0 0 3.94 22h16.12a3 3 0 0 0 2.61-4.53Zm-1.73 2a1 1 0 0 1-.88.51H3.94a1 1 0 0 1-.88-.51 1 1 0 0 1 0-1l8-14a1 1 0 0 1 1.78 0l8.05 14a1 1 0 0 1 .05 1.02v-.02ZM12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;To control cost&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;The team is pushed to &lt;strong&gt;test less&lt;/strong&gt; — skip the canary, drop the nightly, narrow the regression scope. Cost is contained at the expense of coverage.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 16a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm10.67 1.47-8.05-14a3 3 0 0 0-5.24 0l-8 14A3 3 0 0 0 3.94 22h16.12a3 3 0 0 0 2.61-4.53Zm-1.73 2a1 1 0 0 1-.88.51H3.94a1 1 0 0 1-.88-.51 1 1 0 0 1 0-1l8-14a1 1 0 0 1 1.78 0l8.05 14a1 1 0 0 1 .05 1.02v-.02ZM12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;To keep quality&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;The team is pushed to &lt;strong&gt;spend more&lt;/strong&gt; — keep coverage intact, accept the linear bill, escalate to procurement when the line crosses the threshold. Quality is contained at the expense of budget.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;The two pressures converge on a budget ceiling that arrives faster than the maturation of the practice.&lt;/p&gt;
&lt;aside aria-label=&quot;The QA paradox of per-token pricing&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The QA paradox of per-token pricing &lt;/p&gt;  &lt;div&gt;&lt;p&gt;A pricing model that scales linearly with usage &lt;strong&gt;punishes maturity&lt;/strong&gt;. The more sophisticated the QA practice — with parallel runs, flakiness analysis, regression replay, compliance reconstitution — the higher the bill. Editors who propose this model are exporting the consequence of their own infrastructure cost onto a buyer whose interest is the opposite: to optimize QA spend by running more, not less.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h3 id=&quot;why-deterministic-inverts-the-incentive&quot;&gt;Why deterministic inverts the incentive&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;No per-execution fee&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A regression suite that runs ten times today and a hundred times tomorrow consumes ten and a hundred times the runner minutes the organization was already paying for. The vendor is not involved in the marginal cost.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M21 14h-1V7a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v7H3a1 1 0 0 0-1 1v2a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-2a1 1 0 0 0-1-1ZM6 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v7H6V7Zm14 10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-1h16v1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Marginal cost ≈ CPU seconds&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Each additional test execution costs the price of an additional second of CPU time on a runner that was already provisioned. The QA practice can mature without renegotiating any contract.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;This is the structural reason most regulated industries — banking, defense, healthcare, manufacturing under traceable quality control — have never adopted a per-token cloud automation primary engine, and will not. It is &lt;strong&gt;not a question of trust or maturity&lt;/strong&gt;. It is a question of arithmetic.&lt;/p&gt;
&lt;aside aria-label=&quot;The right question to ask any editor proposing inference-billed pricing&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The right question to ask any editor proposing inference-billed pricing &lt;/p&gt;  &lt;div&gt;&lt;p&gt;&lt;strong&gt;“At our actual daily volume, what does the bill look like in year two?”&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;If the answer requires a custom enterprise contract negotiated with sales, that answer is its own conclusion. A pricing model that breaks at the buyer’s expected scale is not redeemed by any technical excellence on top.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;a-concrete-cost-comparison-at-three-volumes&quot;&gt;A concrete cost comparison at three volumes&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Abstract arguments about pricing models are easier to dismiss than concrete numbers. Let me put real volumes on the table.&lt;/p&gt;
&lt;aside aria-label=&quot;What is being compared&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; What is being compared &lt;/p&gt;  &lt;div&gt;&lt;p&gt;Three typical buyers:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Small team&lt;/strong&gt; running 100 test executions per day&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mid-size product team&lt;/strong&gt; running 1 000 per day&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enterprise QA practice&lt;/strong&gt; running 10 000 per day&lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;Working days per year: 250. Competing tool prices: ranges based on the &lt;strong&gt;public 2026 pricing grids&lt;/strong&gt; of mabl, testRigor, and Applitools, plus their published enterprise behaviour. AskUI does not publish prices — see the methodological note below. OculiX line: official editor pricing grid I publish.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;The competing tools are priced for use.&lt;/strong&gt; The OculiX line shows the optional editor service price; the OSS engine on its left is zero, forever, no negotiation, no contact-sales gate.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;aside aria-label=&quot;On AskUI&amp;#x27;s pricing — a methodological note&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; On AskUI&apos;s pricing — a methodological note &lt;/p&gt;  &lt;div&gt;&lt;p&gt;&lt;strong&gt;AskUI does not publish a pricing page.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;askui.com/pricing&lt;/code&gt; returns a 404 at the time of writing, and every aggregator (Capterra, GetApp, Software Advice, techjockey) lists “contact sales” for the Developer, Agent Hub, and Enterprise plans, with a single public anchor of &lt;em&gt;“starting from 49 €”&lt;/em&gt;.&lt;/p&gt;&lt;p&gt;I will not invent a fourchette I cannot source. That would commit the exact methodological fault I accuse the original AskUI piece of. What I can compare is the &lt;strong&gt;shape&lt;/strong&gt; of the cost curve: per-inference billing grows linearly with QA volume; my published flat editor service does not. The math below is on cost &lt;em&gt;behaviour&lt;/em&gt;, not on a fabricated quote.&lt;/p&gt;&lt;p&gt;For the competing tools that DO publish (mabl, testRigor, Applitools), the ranges below are taken from their public 2026 grids and industry buyer’s guides.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h3 id=&quot;the-oculix-editor-service-in-plain-words&quot;&gt;The OculiX editor service in plain words&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Before the numbers, what the editor service actually covers. The OculiX engine is free; the editor service is an optional flat-fee engagement layered on top for buyers who want a private channel, response-time commitments, named contacts, or vendor-of-record status. Three packages, three flat prices that do not scale with the buyer’s QA volume:&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Tier&lt;/th&gt;&lt;th&gt;What it covers&lt;/th&gt;&lt;th&gt;Typical fit&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Small Team support&lt;/strong&gt; &lt;em&gt;(~12 – 15 k€/year)&lt;/em&gt;&lt;/td&gt;&lt;td&gt;Private email channel, response &lt;strong&gt;&amp;#x3C; 5 business days&lt;/strong&gt;, bug prioritization, roadmap access&lt;/td&gt;&lt;td&gt;Single team running OculiX in production, wanting a private channel without overhead&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Premium editor service&lt;/strong&gt; &lt;em&gt;(~35 – 70 k€/year)&lt;/em&gt;&lt;/td&gt;&lt;td&gt;Everything in Small Team + hotline on agreed slots, response &lt;strong&gt;&amp;#x3C; 1 business day&lt;/strong&gt;, quarterly review, direct roadmap influence&lt;/td&gt;&lt;td&gt;Mid-size product team where automation is on the critical path, multiple stakeholders&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Enterprise editor service&lt;/strong&gt; &lt;em&gt;(~90 – 180 k€/year, volume discount above 150 k€)&lt;/em&gt;&lt;/td&gt;&lt;td&gt;Everything in Premium + &lt;strong&gt;named engineer&lt;/strong&gt;, response &lt;strong&gt;&amp;#x3C; 4 business hours&lt;/strong&gt;, contractual SLA, on-call windows for critical releases, &lt;strong&gt;vendor-of-record status&lt;/strong&gt; (audit-defensible)&lt;/td&gt;&lt;td&gt;Enterprise QA practice with regulatory exposure, multi-product or multi-entity scope&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;What each level explicitly excludes: custom feature development (separate engagement, billed per feature), endless time-and-materials work (the engagement is flat-priced — the editor carries the delivery risk, not the buyer), and 24/7 coverage outside named critical windows. The buyer always knows what they pay for; the editor always knows what is committed.&lt;/p&gt;
&lt;p&gt;The structural point: &lt;strong&gt;at any tier, the price is flat for the year regardless of how many tests the buyer runs.&lt;/strong&gt; A team that doubles its test volume next year pays the same. A team that quadruples it pays the same. The line item is predictable for budget review, which is exactly what the inference-billed alternatives cannot offer.&lt;/p&gt;
&lt;p&gt;With that understood, the three buyer scenarios.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;scenario-1--small-team-100-executions-per-day-25-000--year&quot;&gt;Scenario 1 — Small team, 100 executions per day (25 000 / year)&lt;/h3&gt;&lt;/div&gt;















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Tool / model&lt;/th&gt;&lt;th&gt;Year 1 cost&lt;/th&gt;&lt;th&gt;3-year cumulative&lt;/th&gt;&lt;th&gt;Cost behavior&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;mabl&lt;/strong&gt; (Team plan + extra exec minutes)&lt;/td&gt;&lt;td&gt;~12 000 – 15 000 €&lt;/td&gt;&lt;td&gt;~36 000 – 45 000 €&lt;/td&gt;&lt;td&gt;Linear with execution minutes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;testRigor&lt;/strong&gt; (Pro tier, ~5 000 steps/month)&lt;/td&gt;&lt;td&gt;~6 000 – 10 000 €&lt;/td&gt;&lt;td&gt;~18 000 – 30 000 €&lt;/td&gt;&lt;td&gt;Linear with test steps&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Applitools Eyes / Autonomous&lt;/strong&gt; (Growth)&lt;/td&gt;&lt;td&gt;~6 000 – 25 000 €&lt;/td&gt;&lt;td&gt;~18 000 – 75 000 €&lt;/td&gt;&lt;td&gt;Linear with Test Units&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;AskUI&lt;/strong&gt; (subscription + token Hub)&lt;/td&gt;&lt;td&gt;&lt;em&gt;Not publicly disclosed — contact sales&lt;/em&gt;&lt;/td&gt;&lt;td&gt;&lt;em&gt;Same shape — linear with volume&lt;/em&gt;&lt;/td&gt;&lt;td&gt;Linear with inference calls&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;OculiX OSS&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Constant&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;OculiX OSS + Small Team editor support&lt;/strong&gt; &lt;em&gt;(optional)&lt;/em&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~12 000 – 15 000 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~36 000 – 45 000 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Constant — flat editor engagement&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;At this scale, the cheapest line is OculiX OSS at zero euros, full stop. For buyers who want a private channel, response-time commitments, bug prioritization, and roadmap access, the OculiX Small Team editor support package sits in the same price band as the cheapest competing offers — and stays flat as the buyer’s QA volume grows, instead of scaling with executions. The OSS engine alone is enough for many teams; the editor support is a bonus, not an obligation.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;scenario-2--mid-size-product-team-1-000-executions-per-day-250-000--year&quot;&gt;Scenario 2 — Mid-size product team, 1 000 executions per day (250 000 / year)&lt;/h3&gt;&lt;/div&gt;















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Tool / model&lt;/th&gt;&lt;th&gt;Year 1 cost&lt;/th&gt;&lt;th&gt;3-year cumulative&lt;/th&gt;&lt;th&gt;Cost behavior&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;mabl&lt;/strong&gt; (Enterprise, custom)&lt;/td&gt;&lt;td&gt;~40 000 – 80 000 €&lt;/td&gt;&lt;td&gt;~120 000 – 240 000 €&lt;/td&gt;&lt;td&gt;Custom-quoted, linear with usage&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;testRigor&lt;/strong&gt; (Enterprise, near-unlimited steps)&lt;/td&gt;&lt;td&gt;~60 000 – 100 000 €&lt;/td&gt;&lt;td&gt;~180 000 – 300 000 €&lt;/td&gt;&lt;td&gt;Custom-quoted enterprise&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Applitools Enterprise&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;~50 000 – 100 000 €&lt;/td&gt;&lt;td&gt;~150 000 – 300 000 €&lt;/td&gt;&lt;td&gt;Custom-quoted enterprise&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;AskUI&lt;/strong&gt; (subscription + heavy inference)&lt;/td&gt;&lt;td&gt;&lt;em&gt;Not publicly disclosed — contact sales&lt;/em&gt;&lt;/td&gt;&lt;td&gt;&lt;em&gt;Same shape — linear with volume&lt;/em&gt;&lt;/td&gt;&lt;td&gt;Linear with inference calls&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;OculiX OSS&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Constant&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;OculiX OSS + Premium editor service&lt;/strong&gt; &lt;em&gt;(optional)&lt;/em&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~35 000 – 70 000 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~105 000 – 210 000 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Constant — flat editor engagement&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;At 1 000 executions per day, the competing tools start to look like operational expenses with their own line in the budget review. OculiX OSS still costs zero. The OculiX Premium editor service &lt;strong&gt;starts at 35 000 € per year — below the entry point of every competing enterprise tier&lt;/strong&gt; in this band — with hotline, response within one business day, quarterly review, and direct roadmap influence. The price stays flat whatever the QA volume grows to. Doubling the suite next year keeps the OculiX line at the same number; the competing alternatives scale linearly with usage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On a three-year horizon, the gap is no longer marginal.&lt;/strong&gt; OculiX Premium accumulates to ~105 000 – 210 000 € over three years. Mabl Enterprise accumulates to ~120 000 – 240 000 €. testRigor and Applitools accumulate to ~150 000 – 300 000 €. AskUI does not publish prices, so I won’t fabricate a cumulative — what is observable from their model is that it scales linearly with inference volume, which at 250 000 executions per year compounds the gap mechanically. Even at the upper bound of the OculiX Premium range, the total stays below the entry of testRigor and Applitools and at the bottom of mabl Enterprise. At five years and at ten years, the gap widens further — because the OculiX line is flat and the competing lines are not.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;scenario-3--large-enterprise-qa-practice-10-000-executions-per-day-25-m--year&quot;&gt;Scenario 3 — Large enterprise QA practice, 10 000 executions per day (2.5 M / year)&lt;/h3&gt;&lt;/div&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Tool / model&lt;/th&gt;&lt;th&gt;Year 1 cost&lt;/th&gt;&lt;th&gt;3-year cumulative&lt;/th&gt;&lt;th&gt;Cost behavior&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;mabl / testRigor / Applitools&lt;/strong&gt; (Enterprise+, custom)&lt;/td&gt;&lt;td&gt;~200 000 – 500 000 €&lt;/td&gt;&lt;td&gt;~600 000 – 1 500 000 €&lt;/td&gt;&lt;td&gt;Custom-quoted, linear with usage&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;AskUI&lt;/strong&gt; (full inference Hub at 2.5 M executions)&lt;/td&gt;&lt;td&gt;&lt;em&gt;Not publicly disclosed — contact sales&lt;/em&gt;&lt;/td&gt;&lt;td&gt;&lt;em&gt;Same shape — linear with volume&lt;/em&gt;&lt;/td&gt;&lt;td&gt;Linear with inference calls&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;OculiX OSS&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Constant&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;OculiX OSS + Enterprise editor service + volume discount&lt;/strong&gt; &lt;em&gt;(optional)&lt;/em&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~180 000 – 250 000 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~540 000 – 750 000 €&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Constant with volume discount&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;At 10 000 executions per day, the gap is no longer a comparison — it is a categorical statement about which paradigm scales economically. OculiX OSS costs zero. OculiX with full editor service plus volume discount is still well under what a buyer would pay for the inference-based alternative even before counting the cloud lock-in.&lt;/p&gt;
&lt;aside aria-label=&quot;The shape of these numbers&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The shape of these numbers &lt;/p&gt;  &lt;div&gt;&lt;p&gt;The competing tool prices above are reasonable midpoints from publicly available 2026 buyer’s guides and pricing pages. Actual enterprise contracts vary, and most vendors will quote less in a competitive negotiation. The structural point holds regardless of the exact number: per-usage pricing scales linearly with the buyer’s success at QA maturity, while OculiX scales not at all. The buyer who tests ten times more next year pays ten times more with the first model, the same with the second.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h3 id=&quot;why-oculix-editor-service-is-not-a-subscription-tax&quot;&gt;Why OculiX editor service is not a subscription tax&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;There is a temptation, reading the tables above, to view the OculiX Premium or Enterprise editor service as just another vendor lock-in line item. It is not, and the structural difference matters.&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;The OSS engine runs without it&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;OculiX is MIT, the binaries are public, the documentation is on the site, the community channel is open. A team can adopt OculiX, run it in production for ten years, and never pay the editor a euro. This is not theoretical — many of the close-to-a-hundred deployed organizations do exactly that.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M0.127 6.839L0.127 0.608L16.929 0.608L16.929 6.839L0.127 6.839ZM7.040 15.116L7.040 8.885L23.873 8.885L23.873 15.116L7.040 15.116ZM17.053 23.393L17.053 17.162L23.873 17.162L23.873 23.393L17.053 23.393Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;The editor service is a flat engagement&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;When a team chooses to engage the editor, they pay a flat annual price for SLA, named engineer, vendor-of-record status, roadmap influence, and migration support. The price does not scale with the number of tests they run. They can quadruple their QA volume next year without renegotiating.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path fill-rule=&quot;evenodd&quot; d=&quot;M1.44 8.855v-.001l3.527-3.516c.34-.344.802-.541 1.285-.548h6.649l.947-.947c3.07-3.07 6.207-3.072 7.62-2.868a1.821 1.821 0 0 1 1.557 1.557c.204 1.413.203 4.55-2.868 7.62l-.946.946v6.649a1.845 1.845 0 0 1-.549 1.286l-3.516 3.528a1.844 1.844 0 0 1-3.11-.944l-.858-4.275-4.52-4.52-2.31-.463-1.964-.394A1.847 1.847 0 0 1 .98 10.693a1.843 1.843 0 0 1 .46-1.838Zm5.379 2.017-3.873-.776L6.32 6.733h4.638l-4.14 4.14Zm8.403-5.655c2.459-2.46 4.856-2.463 5.89-2.33.134 1.035.13 3.432-2.329 5.891l-6.71 6.71-3.561-3.56 6.71-6.711Zm-1.318 15.837-.776-3.873 4.14-4.14v4.639l-3.364 3.374Z&quot; clip-rule=&quot;evenodd&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M9.318 18.345a.972.972 0 0 0-1.86-.561c-.482 1.435-1.687 2.204-2.934 2.619a8.22 8.22 0 0 1-1.23.302c.062-.365.157-.79.303-1.229.415-1.247 1.184-2.452 2.62-2.935a.971.971 0 1 0-.62-1.842c-.12.04-.236.084-.35.13-2.02.828-3.012 2.588-3.493 4.033a10.383 10.383 0 0 0-.51 2.845l-.001.016v.063c0 .536.434.972.97.972H2.24a7.21 7.21 0 0 0 .878-.065c.527-.063 1.248-.19 2.02-.447 1.445-.48 3.205-1.472 4.033-3.494a5.828 5.828 0 0 0 .147-.407Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;The exit is real&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;At the end of an engagement, the team keeps their entire codebase, their patterns, their CI pipelines, their automations. There is no proprietary format to extract from, no cloud hub to migrate off, no token bill that survives the contract. The OSS engine runs the same the day after the contract ends as the day before.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path fill-rule=&quot;evenodd&quot; d=&quot;M1.44 8.855v-.001l3.527-3.516c.34-.344.802-.541 1.285-.548h6.649l.947-.947c3.07-3.07 6.207-3.072 7.62-2.868a1.821 1.821 0 0 1 1.557 1.557c.204 1.413.203 4.55-2.868 7.62l-.946.946v6.649a1.845 1.845 0 0 1-.549 1.286l-3.516 3.528a1.844 1.844 0 0 1-3.11-.944l-.858-4.275-4.52-4.52-2.31-.463-1.964-.394A1.847 1.847 0 0 1 .98 10.693a1.843 1.843 0 0 1 .46-1.838Zm5.379 2.017-3.873-.776L6.32 6.733h4.638l-4.14 4.14Zm8.403-5.655c2.459-2.46 4.856-2.463 5.89-2.33.134 1.035.13 3.432-2.329 5.891l-6.71 6.71-3.561-3.56 6.71-6.711Zm-1.318 15.837-.776-3.873 4.14-4.14v4.639l-3.364 3.374Z&quot; clip-rule=&quot;evenodd&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M9.318 18.345a.972.972 0 0 0-1.86-.561c-.482 1.435-1.687 2.204-2.934 2.619a8.22 8.22 0 0 1-1.23.302c.062-.365.157-.79.303-1.229.415-1.247 1.184-2.452 2.62-2.935a.971.971 0 1 0-.62-1.842c-.12.04-.236.084-.35.13-2.02.828-3.012 2.588-3.493 4.033a10.383 10.383 0 0 0-.51 2.845l-.001.016v.063c0 .536.434.972.97.972H2.24a7.21 7.21 0 0 0 .878-.065c.527-.063 1.248-.19 2.02-.447 1.445-.48 3.205-1.472 4.033-3.494a5.828 5.828 0 0 0 .147-.407Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;The model is Red Hat, not SaaS&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Red Hat does not charge per CPU cycle on Linux. Confluent does not charge per Kafka message. Sonatype does not charge per Nexus artifact resolved. OculiX does not charge per test execution. The editor sells engagement and support around a free engine — the proven OSS-plus-services model.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;A buyer who reads only the per-tool price table might frame the choice as “free vs paid”. That framing misses the point. The choice is: &lt;strong&gt;pay per usage forever, or pay nothing and engage the editor only if and when the buyer values doing so&lt;/strong&gt;. The decision is reversible. The cost ceiling is knowable. The exit is a &lt;code dir=&quot;auto&quot;&gt;git pull&lt;/code&gt; away.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;choosing-in-2026&quot;&gt;Choosing in 2026&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The honest framing for a buyer evaluating visual automation in 2026 looks like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Decide what must be reproducible.&lt;/strong&gt; If the test is part of a compliance audit, a regression suite that gates production, or a forensic trail that must survive model and vendor churn, the answer is deterministic. There is no other.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decide what perimeter the test must reach.&lt;/strong&gt; If the test must run on-premise, in an air-gapped environment, behind a regulated network, the answer is deterministic by default, and any cloud-dependent paradigm is excluded by the perimeter itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decide what economic shape fits the scale.&lt;/strong&gt; Per-token inference billing compounds linearly with QA maturity. The deterministic alternative runs on CPU cycles the organization already provisions. At any scale beyond the pilot phase, the math is decided.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Refuse the false choice.&lt;/strong&gt; Some sellers will frame the question as “deterministic for safety, agentic for productivity”. The framing is itself the trap. A test that is not reproducible is not a test; it is a demo. Demos are not productivity, they are theater.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;These four questions are observable. The buyer can answer them without depending on a vendor’s marketing narrative. They do not require choosing between “legacy” and “future”; they require recognizing that QA is engineering, and that engineering does not negotiate reproducibility.&lt;/p&gt;
&lt;p&gt;OculiX 3.0.4 is, in 2026, the most complete deterministic visual automation runtime available under MIT open-source terms. It runs on Windows, macOS including Apple Silicon, and Linux. It supports Android. It runs in VNC sessions for remote and parallel execution. It persists its locator cache in a portable form that survives &lt;code dir=&quot;auto&quot;&gt;git clone&lt;/code&gt; and CI runners. It is used in production by close to a hundred organizations across regulated industries. The code is on GitHub. The open-source terms are irrevocable. The next release is scoped and public.&lt;/p&gt;
&lt;p&gt;If the buyer’s constraint is reproducibility, audit, air-gap, or zero per-execution cost, OculiX is the answer. If the buyer’s constraint is assisted exploration with systematic human validation, what they are looking for is an assistant — not a test engine. Confusing the two with QA automation is a linguistic abuse the market will eventually correct. Either way, the choice should be made on facts about the current state of each option — not on a 2024 snapshot of one tool extended into a 2026 article about another.&lt;/p&gt;
&lt;p&gt;I publish the data I can verify. I link to &lt;strong&gt;AskUI&lt;/strong&gt;’s article that prompted this one so that any reader can check both sides for themselves. The repository is open. The benchmark methodology is public. The architecture is documented. There is no hidden layer to negotiate access to.&lt;/p&gt;
&lt;p&gt;That is the difference open-source MIT terms actually make.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-closing-note-on-accurate-comparison&quot;&gt;A closing note on accurate comparison&lt;/h2&gt;&lt;/div&gt;
&lt;aside aria-label=&quot;MIT open-source is not a legal vacuum&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; MIT open-source is not a legal vacuum &lt;/p&gt;  &lt;div&gt;&lt;p&gt;The MIT open-source nature of OculiX is sometimes read as if it placed the project outside any legal framework — as if the code being public made everything written about it equally public, equally consequence-free. That reading is incorrect. The code is open; the maintainers’ legal personhood is not.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h3 id=&quot;what-i-welcome--and-what-i-dont&quot;&gt;What I welcome — and what I don’t&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Honest comparison&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Rankings that conclude OculiX is the wrong tool for a given use case, supported by accurate technical claims. Buyer guidance toward a competitor when the competitor genuinely fits better. &lt;strong&gt;The market matures when comparisons are factual&lt;/strong&gt; — and OculiX has nothing to fear from an honest one.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 16a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm10.67 1.47-8.05-14a3 3 0 0 0-5.24 0l-8 14A3 3 0 0 0 3.94 22h16.12a3 3 0 0 0 2.61-4.53Zm-1.73 2a1 1 0 0 1-.88.51H3.94a1 1 0 0 1-.88-.51 1 1 0 0 1 0-1l8-14a1 1 0 0 1 1.78 0l8.05 14a1 1 0 0 1 .05 1.02v-.02ZM12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Systematic misrepresentation&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Version numbers misrepresented by multiple stable releases. Shipped features described as absent. Limitations of an unrelated tool attributed to OculiX. Deployment architectures ignored despite being publicly documented. &lt;strong&gt;This is a different category and is treated as such.&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;the-applicable-legal-framework&quot;&gt;The applicable legal framework&lt;/h3&gt;&lt;/div&gt;
&lt;aside aria-label=&quot;Comparative advertising in EU / France is regulated&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; Comparative advertising in EU / France is regulated &lt;/p&gt;  &lt;div&gt;&lt;p&gt;In the European Union and in France specifically, comparative advertising is governed by &lt;strong&gt;Code de la consommation articles L122-1 et seq.&lt;/strong&gt; and &lt;strong&gt;Directive 2006/114/EC&lt;/strong&gt;. It must be:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Objective&lt;/strong&gt; — claims must be verifiable&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loyal&lt;/strong&gt; — no creation of confusion, no denigration&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Truthful&lt;/strong&gt; — established by reference to the current state of the compared product, not an outdated or worst-case version&lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;Misleading comparative advertising is an infraction sanctionable both administratively and civilly, with established case law. Public OSS code does not change that perimeter; it documents the facts against which any claim can be checked.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h3 id=&quot;remedies-preserved-dialogue-preferred&quot;&gt;Remedies preserved, dialogue preferred&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3.06a1.3 1.3 0 0 0-.06-.27v-.09c-.05-.1-.11-.2-.19-.28l-6-6a1.07 1.07 0 0 0-.28-.19h-.09a.88.88 0 0 0-.33-.11H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V8.94Zm-6-3.53L16.59 8H15a1 1 0 0 1-1-1V5.41ZM18 19a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Legal remedies preserved&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;The maintainers preserve every legal remedy available in our jurisdictions: misrepresentation, misleading comparative advertising, defamation when the threshold is reached, and the moral rights attached to the authorship of the code.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M19 2H5a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h11.6l3.7 3.7a1 1 0 0 0 .7.3.8.8 0 0 0 .4 0 1 1 0 0 0 .6-1V5a3 3 0 0 0-3-3m1 16.6-2.3-2.3a1 1 0 0 0-.7-.3H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Dialogue is the first move&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;These remedies are not exercised as a first response. The preferred path is dialogue, correction, and right-of-reply — this post is itself the public correction offered amicably. Escalation happens only when the pattern persists and the offered correction is ignored.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path fill-rule=&quot;evenodd&quot; d=&quot;M1.44 8.855v-.001l3.527-3.516c.34-.344.802-.541 1.285-.548h6.649l.947-.947c3.07-3.07 6.207-3.072 7.62-2.868a1.821 1.821 0 0 1 1.557 1.557c.204 1.413.203 4.55-2.868 7.62l-.946.946v6.649a1.845 1.845 0 0 1-.549 1.286l-3.516 3.528a1.844 1.844 0 0 1-3.11-.944l-.858-4.275-4.52-4.52-2.31-.463-1.964-.394A1.847 1.847 0 0 1 .98 10.693a1.843 1.843 0 0 1 .46-1.838Zm5.379 2.017-3.873-.776L6.32 6.733h4.638l-4.14 4.14Zm8.403-5.655c2.459-2.46 4.856-2.463 5.89-2.33.134 1.035.13 3.432-2.329 5.891l-6.71 6.71-3.561-3.56 6.71-6.711Zm-1.318 15.837-.776-3.873 4.14-4.14v4.639l-3.364 3.374Z&quot; clip-rule=&quot;evenodd&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M9.318 18.345a.972.972 0 0 0-1.86-.561c-.482 1.435-1.687 2.204-2.934 2.619a8.22 8.22 0 0 1-1.23.302c.062-.365.157-.79.303-1.229.415-1.247 1.184-2.452 2.62-2.935a.971.971 0 1 0-.62-1.842c-.12.04-.236.084-.35.13-2.02.828-3.012 2.588-3.493 4.033a10.383 10.383 0 0 0-.51 2.845l-.001.016v.063c0 .536.434.972.97.972H2.24a7.21 7.21 0 0 0 .878-.065c.527-.063 1.248-.19 2.02-.447 1.445-.48 3.205-1.472 4.033-3.494a5.828 5.828 0 0 0 .147-.407Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Universal, not targeted&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;This is not a warning addressed to any specific competitor. It is a position that applies to anyone, today or in the future, who chooses to compete with OculiX through fabrication rather than through honest technical and economic argument.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M0.127 6.839L0.127 0.608L16.929 0.608L16.929 6.839L0.127 6.839ZM7.040 15.116L7.040 8.885L23.873 8.885L23.873 15.116L7.040 15.116ZM17.053 23.393L17.053 17.162L23.873 17.162L23.873 23.393L17.053 23.393Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;MIT protects the user, not the misrepresentation&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;The MIT terms grant unrestricted rights to use, modify and redistribute the code. They do not strip the maintainers of legal personhood, reputational interests, or the protections French and European law extends to honest economic actors.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;aside aria-label=&quot;What I would rather write about&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; What I would rather write about &lt;/p&gt;  &lt;div&gt;&lt;p&gt;I would much rather spend the next paragraph writing about visual matching modes, persistent caching, or Android device farms — which is what I will do as soon as the present post stops being necessary. That moment depends on the rest of the industry choosing accuracy over framing. &lt;strong&gt;The choice is theirs. The remedies are ours.&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Repository: &lt;a href=&quot;https://github.com/oculix-org/Oculix&quot;&gt;github.com/oculix-org/Oculix&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Benchmark referenced: &lt;a href=&quot;https://oculix.org/blog/2026-05-19-png-metadata-visual-matching-speedup&quot;&gt;Storing spatial memory in a PNG (May 2026)&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Source article responded to: &lt;a href=&quot;https://www.askui.com/blog-posts/askui-vs-sikulix-visual-automation-comparison-2025&quot;&gt;AskUI vs SikuliX (April 2026)&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;</content:encoded><category>visual-automation</category><category>deterministic</category><category>ai-agentic</category><category>comparison</category><category>askui</category><category>oculix</category><category>pricing</category><category>vendor-comparison</category><category>open-source</category><category>mit</category><category>qa-automation</category></item><item><title>The engine assumed one screen. The job needed four.</title><link>https://oculix.org/blog/2026-05-21-five-times-faster-parallel-vnc/</link><guid isPermaLink="true">https://oculix.org/blog/2026-05-21-five-times-faster-parallel-vnc/</guid><description>I cut a 300-test visual suite from seven hours to under two — on an 8 GB VM that used to fall asleep mid-run. The fast part is the headline. The hard part was that the engine, like almost every visual-automation tool, was built for a single screen. Here&apos;s how I rebuilt isolation on top of it — and how we&apos;re going to make it native.

</description><pubDate>Fri, 22 May 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Seven hours. That’s how long a visual test suite for a Fortune 500 retail group used to take — roughly 300 tests driving point-of-sale terminals, run one after another, against a regulatory compliance window that does not move.&lt;/p&gt;
&lt;p&gt;Today it finishes in &lt;strong&gt;under two&lt;/strong&gt;. Four sessions in parallel, on a single &lt;strong&gt;8 GB Azure VM running Windows Server 2019&lt;/strong&gt; — a swap file, basic provisioning, nothing exotic. Runtime divided by five. No test farm, no per-minute cloud bill, no new hardware.&lt;/p&gt;
&lt;p&gt;The speed is the headline. It’s not the interesting part. The interesting part is that &lt;strong&gt;the engine was never built to do this&lt;/strong&gt; — and neither is almost any visual-automation tool. They assume one screen. I needed four. This is the story of the gap between those two numbers, and what I had to build to close it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-world-of-one-screen&quot;&gt;A world of one screen&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Visual automation was born in a world of one physical screen. One machine, one desktop, one cursor. SikuliX — which OculiX continues — inherited that worldview, and for fifteen years it was the &lt;em&gt;correct&lt;/em&gt; one. The engine keeps a notion of &lt;em&gt;the current screen&lt;/em&gt;, and very reasonably it keeps it in one place: shared, global state. When there is only ever one screen, a global is not a flaw. It’s the right design.&lt;/p&gt;
&lt;p&gt;That assumption is invisible right up until the moment you break it. &lt;strong&gt;VNC breaks it.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A VNC session isn’t a screen — it’s a network framebuffer. No monitor, no desktop login, no &lt;code dir=&quot;auto&quot;&gt;DISPLAY&lt;/code&gt;. You can open a dozen, each on its own port, each pointing at a different remote machine. Suddenly “the current screen” stops being a singleton and becomes a question: &lt;em&gt;which one?&lt;/em&gt;&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-collision&quot;&gt;The collision&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The first time I declared two sessions and ran two test flows at once, they walked all over each other. Not with an error — worse, with quiet nonsense. Clicks landing on the wrong register. A framebuffer read mid-repaint. Results that came back green for entirely the wrong reasons.&lt;/p&gt;
&lt;p&gt;I tested it carefully, because I didn’t believe it at first. Two flows, two ports, two remote machines. They polluted each other every single time.&lt;/p&gt;
&lt;p&gt;The robot and the client were per-instance — that part was clean. But the &lt;strong&gt;port&lt;/strong&gt; and the &lt;strong&gt;registry of live sessions&lt;/strong&gt; lived in shared, static state. Two threads reaching for the same global. The single-screen assumption, baked into the data model, surfacing at exactly the moment I asked for more than one screen.&lt;/p&gt;
&lt;aside aria-label=&quot;The tell&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The tell &lt;/p&gt;  &lt;div&gt;&lt;p&gt;During the sequential run, CPU usage stayed low the entire time. The machine wasn’t working hard — it was &lt;em&gt;queuing&lt;/em&gt;. Low CPU across a long run is never a hardware problem. It’s a scheduling problem. The box was bored.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;what-i-built--around-the-engine-not-inside-it&quot;&gt;What I built — around the engine, not inside it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;So I built the missing layer myself, on top of the engine rather than in it. Four pieces, each one fixing a failure I had actually watched happen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Isolation per flow.&lt;/strong&gt; Each test flow got its own session, bound to its own thread — its own port, its own remote, its own everything. No flow could see another’s state, because there was no shared “current session” left to see. The engine didn’t hand me thread-scoped sessions, so I made them thread-scoped from the outside.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A port is a promise.&lt;/strong&gt; Two flows must never claim the same port, and “probably free” is not good enough at startup when four of them race. So allocation became atomic: a small dedicated range, and a rule — a flow claims a port through a lock no two flows can win simultaneously, or it doesn’t run at all. A port stops being a guess and becomes a contract.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One tunnel per session.&lt;/strong&gt; Each remote register was reachable only through an SSH tunnel exposing its framebuffer on a local port. One tunnel per flow, opened on demand, torn down after — and, the detail that cost me a late night, &lt;em&gt;closed before the next one opened&lt;/em&gt;. Orphaned tunnels hold ports hostage long after the process that spawned them is gone. Close-before-open, not close-after. Order matters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Readiness, not optimism.&lt;/strong&gt; The engine waited a fixed few seconds for a session to come up and hoped for the best. Hope does not survive four sessions starting at once. So I waited for &lt;em&gt;proof&lt;/em&gt; instead: the &lt;strong&gt;RFB banner&lt;/strong&gt; — the three bytes a VNC server sends to announce it’s ready for pixels. No banner, no test. A probe, not a sleep.&lt;/p&gt;
&lt;aside aria-label=&quot;The shape of the fix&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The shape of the fix &lt;/p&gt;  &lt;div&gt;&lt;p&gt;Notice what all four pieces have in common: they each take something the engine held as &lt;em&gt;one global thing&lt;/em&gt; and turn it into &lt;em&gt;one thing per flow&lt;/em&gt;. That’s the whole trick. Parallelism here wasn’t about adding threads — it was about removing shared state.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;why-four-sessions-gave-5-not-4&quot;&gt;Why four sessions gave 5×, not 4×&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Four workers, five times faster, looks like broken arithmetic. It isn’t.&lt;/p&gt;
&lt;p&gt;The killer in a sequential suite isn’t the total work — it’s the &lt;strong&gt;variance in test durations&lt;/strong&gt;. My ~300 tests ranged from a few seconds to many minutes. Run in a single line, a long test sits in front of forty short ones and they all just wait. Sequential execution doesn’t only pay for the work; it pays for every test queued behind a slower one. With uneven durations that waiting tax is enormous, and invisible, because the CPU is idle the whole time.&lt;/p&gt;
&lt;p&gt;I split the load into batches sized by &lt;strong&gt;functional area&lt;/strong&gt; — thirty tests here, ninety there, a hundred and twenty somewhere else, the way the business actually reasons about its registers. Balanced, not just parallel. The four sessions finished at roughly the same time instead of one straggler holding the line.&lt;/p&gt;
&lt;p&gt;So the 5× isn’t four workers doing 4× the work. It’s four balanced workers &lt;strong&gt;deleting waste&lt;/strong&gt; the single queue was silently burning. When the thing you replaced is that wasteful, even modest, well-distributed parallelism overshoots the naive ratio.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-all-of-this-lived-outside-the-engine&quot;&gt;Why all of this lived outside the engine&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;I want to be precise here, out of respect for the engine and the people who built it. The core does one thing extremely well: clean, reliable, single-session VNC. It kept a single-screen promise and it kept it honestly. The orchestration — the thread isolation, the atomic ports, the tunnels, the readiness probe — was &lt;em&gt;mine&lt;/em&gt;, and it lived in my harness. I made a different promise, one layer up, and kept that one too.&lt;/p&gt;
&lt;p&gt;For a long time that was the right division of labour. The engine stayed pure; the parallelism was an application concern. But once you’ve named the gap, you can’t un-see it — and you start to wonder why the engine shouldn’t simply do this itself.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-roadmap-making-parallel-sessions-first-class&quot;&gt;The roadmap: making parallel sessions first-class&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s where it gets exciting, because the next step is obvious now that the gap has a name. &lt;strong&gt;Everything I built outside the engine is the engine’s natural next form.&lt;/strong&gt;&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.838 10.302L22.270 10.302L22.270 23.979L1.730 23.979L1.730 10.302L5.162 10.302L5.162 6.918Q5.162 6.354 5.255 5.884L5.255 5.884Q5.490 4.051 6.642 2.618Q7.793 1.184 9.509 0.503Q11.224-0.179 13.104 0.103L13.104 0.103Q15.548 0.526 17.194 2.453Q18.838 4.380 18.838 6.871L18.838 6.871L18.838 10.302ZM8.545 10.302L15.407 10.302Q15.407 10.255 15.407 10.208L15.407 10.208L15.407 6.777Q15.407 6.542 15.360 6.213L15.360 6.213Q15.078 4.850 14.021 4.098Q12.963 3.346 11.553 3.487L11.553 3.487Q10.284 3.581 9.415 4.592Q8.545 5.602 8.545 6.918L8.545 6.918L8.545 10.302Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Thread-scoped sessions&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;No more global “current screen.” A session belongs to the flow that opened it, by construction — so two flows can never reach for the same state, because there is no shared state to reach for.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M23.165 13.645L23.165 13.645Q22.829 13.561 22.199 13.309L22.199 13.309L21.695 13.099Q21.695 12.637 21.632 11.797Q21.569 10.957 21.569 10.495L21.569 10.495Q21.569 10.411 21.632 10.348Q21.695 10.285 21.695 10.201L21.695 10.201L23.165 9.571Q23.543 9.361 23.669 9.004Q23.795 8.647 23.669 8.269L23.669 8.269L22.493 5.623Q22.283 5.203 21.926 5.056Q21.569 4.909 21.191 5.119L21.191 5.119L19.721 5.749Q19.469 5.749 19.469 5.623L19.469 5.623Q19.301 5.371 18.839 4.951L18.839 4.951L18.545 4.699Q18.419 4.573 18.104 4.300Q17.789 4.027 17.621 3.901L17.621 3.901Q17.789 3.649 17.978 3.124Q18.167 2.599 18.293 2.347L18.293 2.347Q18.503 1.843 18.314 1.465Q18.125 1.087 17.621 0.919L17.621 0.919Q17.075 0.793 15.815 0.373L15.815 0.373L15.143 0.121Q14.639-0.089 14.261 0.100Q13.883 0.289 13.715 0.751L13.715 0.751Q13.631 1.087 13.379 1.717L13.379 1.717L13.169 2.221Q12.707 2.221 11.867 2.284Q11.027 2.347 10.565 2.347L10.565 2.347Q10.481 2.347 10.418 2.284Q10.355 2.221 10.271 2.221L10.271 2.221L9.641 0.751Q9.431 0.373 9.074 0.247Q8.717 0.121 8.339 0.247L8.339 0.247L5.693 1.423Q5.273 1.633 5.126 1.990Q4.979 2.347 5.189 2.725L5.189 2.725L5.819 4.195Q5.819 4.447 5.693 4.447L5.693 4.447L5.399 4.699Q5.105 4.909 5.021 5.077L5.021 5.077Q4.853 5.287 4.517 5.686Q4.181 6.085 3.971 6.295L3.971 6.295Q3.677 6.211 3.047 5.959L3.047 5.959L2.543 5.749Q2.039 5.539 1.661 5.728Q1.283 5.917 1.115 6.421L1.115 6.421L0.905 6.925Q0.401 8.185 0.191 8.773Q-0.019 9.361 0.128 9.697Q0.275 10.033 0.821 10.201L0.821 10.201Q1.157 10.285 1.787 10.537L1.787 10.537L2.291 10.747Q2.291 11.209 2.354 12.049Q2.417 12.889 2.417 13.351L2.417 13.351Q2.417 13.435 2.354 13.498Q2.291 13.561 2.291 13.645L2.291 13.645L0.821 14.275Q0.443 14.485 0.317 14.842Q0.191 15.199 0.317 15.577L0.317 15.577L1.493 18.223Q1.703 18.643 2.060 18.790Q2.417 18.937 2.795 18.727L2.795 18.727L4.265 18.097Q4.517 18.097 4.517 18.223L4.517 18.223Q4.685 18.475 5.147 18.895L5.147 18.895L5.441 19.147Q5.567 19.273 5.882 19.525Q6.197 19.777 6.365 19.945L6.365 19.945Q6.239 20.197 6.029 20.722Q5.819 21.247 5.693 21.499L5.693 21.499Q5.483 22.003 5.672 22.381Q5.861 22.759 6.365 22.969L6.365 22.969Q6.659 23.053 7.331 23.305L7.331 23.305Q8.297 23.725 8.864 23.893Q9.431 24.061 9.767 23.935Q10.103 23.809 10.271 23.221L10.271 23.221Q10.355 22.885 10.607 22.255L10.607 22.255L10.817 21.751Q11.279 21.751 12.119 21.688Q12.959 21.625 13.421 21.625L13.421 21.625Q13.505 21.625 13.568 21.688Q13.631 21.751 13.715 21.751L13.715 21.751L14.345 23.221Q14.555 23.599 14.912 23.725Q15.269 23.851 15.647 23.725L15.647 23.725L18.293 22.549Q18.713 22.339 18.860 21.982Q19.007 21.625 18.797 21.247L18.797 21.247L18.167 19.819Q18.167 19.525 18.293 19.525L18.293 19.525Q18.545 19.357 18.965 18.895L18.965 18.895L19.217 18.601Q19.343 18.475 19.595 18.160Q19.847 17.845 20.015 17.719L20.015 17.719Q20.309 17.761 20.939 18.055L20.939 18.055L21.443 18.223Q21.947 18.433 22.325 18.244Q22.703 18.055 22.871 17.551L22.871 17.551Q22.997 17.257 23.249 16.585L23.249 16.585Q23.669 15.619 23.795 15.073L23.795 15.073Q24.257 13.981 23.165 13.645ZM13.967 16.375L13.967 16.375Q12.077 17.173 10.250 16.459Q8.423 15.745 7.541 13.897L7.541 13.897Q6.743 12.007 7.457 10.180Q8.171 8.353 10.019 7.471L10.019 7.471Q11.909 6.673 13.736 7.387Q15.563 8.101 16.445 9.949L16.445 9.949Q17.243 11.839 16.529 13.666Q15.815 15.493 13.967 16.375Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Port allocation, built in&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;The engine hands you an isolated session and owns the atomic port claim itself. No range to reserve, no lock-files to babysit, no collisions to debug at 23h.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Readiness on the RFB banner&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;The optimistic startup sleep replaced by a real probe — the session reports ready when the server actually says it’s ready, not when a timer guesses it might be.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path fill-rule=&quot;evenodd&quot; d=&quot;M1.44 8.855v-.001l3.527-3.516c.34-.344.802-.541 1.285-.548h6.649l.947-.947c3.07-3.07 6.207-3.072 7.62-2.868a1.821 1.821 0 0 1 1.557 1.557c.204 1.413.203 4.55-2.868 7.62l-.946.946v6.649a1.845 1.845 0 0 1-.549 1.286l-3.516 3.528a1.844 1.844 0 0 1-3.11-.944l-.858-4.275-4.52-4.52-2.31-.463-1.964-.394A1.847 1.847 0 0 1 .98 10.693a1.843 1.843 0 0 1 .46-1.838Zm5.379 2.017-3.873-.776L6.32 6.733h4.638l-4.14 4.14Zm8.403-5.655c2.459-2.46 4.856-2.463 5.89-2.33.134 1.035.13 3.432-2.329 5.891l-6.71 6.71-3.561-3.56 6.71-6.711Zm-1.318 15.837-.776-3.873 4.14-4.14v4.639l-3.364 3.374Z&quot; clip-rule=&quot;evenodd&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M9.318 18.345a.972.972 0 0 0-1.86-.561c-.482 1.435-1.687 2.204-2.934 2.619a8.22 8.22 0 0 1-1.23.302c.062-.365.157-.79.303-1.229.415-1.247 1.184-2.452 2.62-2.935a.971.971 0 1 0-.62-1.842c-.12.04-.236.084-.35.13-2.02.828-3.012 2.588-3.493 4.033a10.383 10.383 0 0 0-.51 2.845l-.001.016v.063c0 .536.434.972.97.972H2.24a7.21 7.21 0 0 0 .878-.065c.527-.063 1.248-.19 2.02-.447 1.445-.48 3.205-1.472 4.033-3.494a5.828 5.828 0 0 0 .147-.407Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;A clean SSH tunnel, already shipped&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A pure-Java tunnel (no WSL, no external &lt;code&gt;sshpass&lt;/code&gt;) already lives in the core. The next step is wiring it into a documented, first-class parallel pattern.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;The goal for the next major version is simple to state and worth the work: &lt;strong&gt;declare N sessions, get N isolated sessions.&lt;/strong&gt; No harness, no lock-files, no late nights over ghost tunnels. The single-screen assumption retired — gently, and with full respect for the fifteen years it served exactly right.&lt;/p&gt;
&lt;p&gt;OculiX is MIT, open source, built in the open. If you drive screens for a living — registers, kiosks, terminals, application instances by the dozen — this is the corner of the roadmap I’d most like company on.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-takeaway&quot;&gt;The takeaway&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Visual automation has a reputation for not scaling. It earned that reputation honestly: the tools were built for one screen, and most of them still are. But the limit was never the pixels, and it was never the matching. It was an &lt;strong&gt;assumption&lt;/strong&gt; — one screen — that nobody had yet needed to question.&lt;/p&gt;
&lt;p&gt;Question it. Give the engine real sessions, isolate them, balance the load across uneven work — and a bored 8 GB VM does in two hours what used to take seven. No farm. No new hardware. No per-minute bill.&lt;/p&gt;
&lt;p&gt;The screens were never the bottleneck. The assumption was.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;OculiX is open-source under the MIT license. The parallel VNC engine — and the work to make multi-session parallelism native in the core — is described in the &lt;a href=&quot;https://oculix.org/guides/parallel-vnc/&quot;&gt;Parallel VNC execution guide&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>vnc</category><category>parallelization</category><category>performance</category><category>visual-testing</category><category>architecture</category><category>retrospective</category></item><item><title>CodeQL: the boy who cried Error</title><link>https://oculix.org/blog/2026-05-21-codeql-cried-error-19-findings-zero-impact/</link><guid isPermaLink="true">https://oculix.org/blog/2026-05-21-codeql-cried-error-19-findings-zero-impact/</guid><description>Static analysis classified 19 findings as Error on a 17-year-old Java codebase. After triage: zero observed user impact in fifteen years. Meanwhile, 53 NumberFormatException catches missing in production code are classified Note. The severity system is broken in both directions.

</description><pubDate>Thu, 21 May 2026 08:00:00 GMT</pubDate><content:encoded>&lt;p&gt;We enabled GitHub CodeQL on OculiX in April 2026. The repository inherits sixteen years of Sikuli and SikuliX history before our continuation began, so the scanner was pointed at roughly 110 000 lines of Java accumulated since 2009.&lt;/p&gt;
&lt;p&gt;It returned a hundred or so findings across all severities. Nineteen of them were classified &lt;strong&gt;Error&lt;/strong&gt;, the highest tier. The classification suggests urgency: each Error is, in CodeQL’s taxonomy, the kind of thing a maintainer should drop everything to fix.&lt;/p&gt;
&lt;p&gt;We dropped nothing. We triaged each one. After triage, the result is unambiguous: &lt;strong&gt;zero of the nineteen findings have caused an observable user incident in fifteen years&lt;/strong&gt;. Most of them are dormant architectural defects, dead code paths, or false positives produced by structural blindness in the analyzer.&lt;/p&gt;
&lt;p&gt;Meanwhile, &lt;strong&gt;fifty-three missing catches for &lt;code dir=&quot;auto&quot;&gt;NumberFormatException&lt;/code&gt;&lt;/strong&gt; — places where parsing a user-provided number can crash the application — are classified &lt;code dir=&quot;auto&quot;&gt;Note&lt;/code&gt;. Almost invisible. Below &lt;code dir=&quot;auto&quot;&gt;Warning&lt;/code&gt;. Far below &lt;code dir=&quot;auto&quot;&gt;Error&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This post walks through the nineteen findings, explains why none of them actually warrant the Error tier, and explains why the things that should warrant it are buried two levels down. The conclusion is not that static analysis is useless. The conclusion is that &lt;strong&gt;CodeQL’s severity classification is broken in both directions&lt;/strong&gt; — overstating the trivial, understating the serious — and that the noise this generates is the main thing keeping the tool from being useful at scale.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;methodology&quot;&gt;Methodology&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;For each finding, we asked three questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Does it produce an observable user incident?&lt;/strong&gt; Has it caused a crash, a wrong result, a regression in fifteen years of use? Has anyone filed a ticket against it?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If not, would it produce one if a specific code path were exercised?&lt;/strong&gt; Is it dormant but reachable, or dormant and unreachable?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is the finding even correct?&lt;/strong&gt; Does CodeQL’s analysis actually match the runtime behavior, or has it missed something about the language, the bridge to another runtime, or the type system?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Three categories emerged from this triage:&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 16a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm10.67 1.47-8.05-14a3 3 0 0 0-5.24 0l-8 14A3 3 0 0 0 3.94 22h16.12a3 3 0 0 0 2.61-4.53Zm-1.73 2a1 1 0 0 1-.88.51H3.94a1 1 0 0 1-.88-.51 1 1 0 0 1 0-1l8-14a1 1 0 0 1 1.78 0l8.05 14a1 1 0 0 1 .05 1.02v-.02ZM12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Architectural defects&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Real problems in the code that have never been triggered. Either the branch is dead, the configuration is never set, or the upstream dependency is never invoked. The finding is correct but the impact is theoretical.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M4.314 0.198L16.746 0.198Q17.838 0.198 18.615 0.975Q19.392 1.752 19.392 2.844L19.392 2.844L19.392 21.156Q19.392 22.248 18.615 23.025Q17.838 23.802 16.746 23.802L16.746 23.802L4.314 23.802Q3.222 23.802 2.445 23.025Q1.668 22.248 1.668 21.156L1.668 21.156L1.668 2.844Q1.668 1.752 2.445 0.975Q3.222 0.198 4.314 0.198L4.314 0.198ZM21.450 15.528L20.568 15.528L21.450 15.528Q21.786 15.528 22.038 15.759Q22.290 15.990 22.332 16.326L22.332 16.326L22.332 18.216Q22.332 18.552 22.122 18.783Q21.912 19.014 21.576 19.098L21.576 19.098L20.568 19.098L20.568 15.528L21.450 15.528ZM21.450 10.824L20.568 10.824L21.450 10.824Q21.786 10.824 22.038 11.034Q22.290 11.244 22.332 11.580L22.332 11.580L22.332 13.470Q22.332 13.806 22.122 14.058Q21.912 14.310 21.576 14.352L21.576 14.352L20.568 14.352L20.568 10.824L21.450 10.824ZM21.450 6.078L20.568 6.078L21.450 6.078Q21.786 6.078 22.038 6.309Q22.290 6.540 22.332 6.876L22.332 6.876L22.332 8.766Q22.332 9.102 22.122 9.333Q21.912 9.564 21.576 9.648L21.576 9.648L20.568 9.648L20.568 6.078L21.450 6.078ZM14.352 4.314L14.352 4.314L6.708 4.314Q6.372 4.314 6.120 4.545Q5.868 4.776 5.826 5.070L5.826 5.070L5.784 7.002Q5.784 7.296 6.015 7.548Q6.246 7.800 6.582 7.842L6.582 7.842L14.352 7.884Q14.688 7.884 14.940 7.653Q15.192 7.422 15.234 7.086L15.234 7.086L15.276 5.196Q15.276 4.818 15.003 4.566Q14.730 4.314 14.352 4.314Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Code smells&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Patterns that look suspicious but have legitimate uses (or, more often, have just persisted as harmless cruft for over a decade). The code is ugly but not broken.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 7a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1Zm0 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm9.71-7.44-5.27-5.27a1.05 1.05 0 0 0-.71-.29H8.27a1.05 1.05 0 0 0-.71.29L2.29 7.56a1.05 1.05 0 0 0-.29.71v7.46c.004.265.107.518.29.71l5.27 5.27c.192.183.445.286.71.29h7.46a1.05 1.05 0 0 0 .71-.29l5.27-5.27a1.05 1.05 0 0 0 .29-.71V8.27a1.05 1.05 0 0 0-.29-.71ZM20 15.31 15.31 20H8.69L4 15.31V8.69L8.69 4h6.62L20 8.69v6.62Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;False positives&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;CodeQL’s analysis is wrong. It missed a dynamic access, a language bridge, an inheritance relationship, or a type guarantee. The finding does not correspond to any real risk in the code.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;What we did &lt;strong&gt;not&lt;/strong&gt; find in our nineteen Errors: any case where a user reproduced a crash, a wrong result, or a regression caused by the code at the location CodeQL flagged. Not one.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-tour-of-the-nineteen-errors&quot;&gt;A tour of the nineteen Errors&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Below is the breakdown by CodeQL rule, in descending count.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;container-contents-are-never-accessed-8-findings&quot;&gt;Container contents are never accessed (8 findings)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;CodeQL’s claim: a collection or map is allocated and possibly written to, but its contents are never read back. The implication is that the container is dead code that should be removed.&lt;/p&gt;
&lt;p&gt;Eight findings of this kind landed on our codebase. The triage:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Three are genuine dead code.&lt;/strong&gt; Two local variables named &lt;code dir=&quot;auto&quot;&gt;classpath&lt;/code&gt; inside &lt;code dir=&quot;auto&quot;&gt;ExtensionManager.java&lt;/code&gt;, and a local map &lt;code dir=&quot;auto&quot;&gt;docParams&lt;/code&gt; inside &lt;code dir=&quot;auto&quot;&gt;Crawler.java&lt;/code&gt;. All three are declared, populated in a loop, and never consumed. Cleanup candidates. None of them have produced a single user-visible defect.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Four are false positives caused by dynamic access.&lt;/strong&gt; Lists like &lt;code dir=&quot;auto&quot;&gt;aliases&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;filenames&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;mimeTypes&lt;/code&gt; in &lt;code dir=&quot;auto&quot;&gt;Lexer.java&lt;/code&gt; (the syntax highlighter grammar) are populated by parsing grammar metadata and consumed by a registry mechanism that CodeQL cannot trace. A map called &lt;code dir=&quot;auto&quot;&gt;libsLoaded&lt;/code&gt; in &lt;code dir=&quot;auto&quot;&gt;RunTime.java&lt;/code&gt; tracks loaded native libraries and is consulted before each native load — but the consumption goes through a debug helper that CodeQL’s call-graph analysis does not follow. From the static point of view, the containers look unread. From the runtime point of view, they are read on every invocation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One is the most interesting finding in the entire set.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;SikulixServer.allowedIPs&lt;/code&gt; is declared like this:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;List&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;allowedIPs&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ArrayList&lt;/span&gt;&lt;span&gt;&amp;#x3C;&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The class is the Sikuli network server. The list is named after a security feature: an IP allow-list to restrict who can connect. There is a method called &lt;code dir=&quot;auto&quot;&gt;makeAllowedIPs&lt;/code&gt; that populates the list. &lt;strong&gt;There is no code that reads the list.&lt;/strong&gt; No filter checks against it. No connection-handling code consults it. The allow-list is built but never enforced.&lt;/p&gt;
&lt;p&gt;If an administrator configures &lt;code dir=&quot;auto&quot;&gt;allowedIPs&lt;/code&gt; thinking they are restricting access, &lt;strong&gt;they are not&lt;/strong&gt;. The server accepts every connection regardless of the configured allow-list, because the list is allocated, filled, and never read.&lt;/p&gt;
&lt;p&gt;This is a security defect. A serious one, by any reasonable definition. CodeQL classifies it as &lt;strong&gt;Maintainability — Error&lt;/strong&gt;. Not Security. Not Critical. Just “your container is unused”, as if the impact were cosmetic.&lt;/p&gt;
&lt;p&gt;We have opened an internal issue to investigate whether the network server is reachable in any default OculiX deployment, and to either remove the network server entirely or wire up the missing filter. As of this writing, the defect has not produced an incident report in any of the years it has existed — but that is more an accident of the network server’s low usage than a vindication of CodeQL’s classification.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;self-assignment-2-findings&quot;&gt;Self assignment (2 findings)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;CodeQL’s claim: a variable is being assigned to itself. The assignment has no effect.&lt;/p&gt;
&lt;p&gt;The first finding is in &lt;code dir=&quot;auto&quot;&gt;Runner.java:396&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;setLastScriptRunReturnCode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; lastScriptRunReturnCode&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;lastScriptRunReturnCode &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; lastScriptRunReturnCode;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The setter’s parameter shadows the static field. The author intended &lt;code dir=&quot;auto&quot;&gt;Runner.lastScriptRunReturnCode = lastScriptRunReturnCode;&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;this.lastScriptRunReturnCode = lastScriptRunReturnCode;&lt;/code&gt;. As written, the setter is inert. It has been inert since whenever it was added — almost certainly in the mid-2010s, possibly earlier.&lt;/p&gt;
&lt;p&gt;For fifteen years, any caller invoking &lt;code dir=&quot;auto&quot;&gt;Runner.setLastScriptRunReturnCode(5)&lt;/code&gt; has stored exactly nothing. The field &lt;code dir=&quot;auto&quot;&gt;lastScriptRunReturnCode&lt;/code&gt; stays at its initial value of &lt;code dir=&quot;auto&quot;&gt;0&lt;/code&gt; forever. Anyone reading the field gets &lt;code dir=&quot;auto&quot;&gt;0&lt;/code&gt; regardless of what scripts have actually returned.&lt;/p&gt;
&lt;p&gt;Has anyone noticed? Apparently not. Either the field is never consulted, or the consumers do not depend on the value being non-zero, or the corner cases where it would matter have not been hit in fifteen years of cumulative production use. The “bug” is real in the source code, but the &lt;strong&gt;observable behavior is no different from what it would be if the setter worked correctly&lt;/strong&gt;. The downstream code path has adapted to the dysfunction.&lt;/p&gt;
&lt;p&gt;The second finding is in &lt;code dir=&quot;auto&quot;&gt;Deflate.java:1703&lt;/code&gt;, inside &lt;code dir=&quot;auto&quot;&gt;com.jcraft.jzlib&lt;/code&gt; (the JZlib compression library, embedded for SSH purposes):&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Deflate&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;dest&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (Deflate) &lt;/span&gt;&lt;span&gt;super&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;clone&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;pending_buf&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;dup&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;dest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;pending_buf&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;d_buf&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;dest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;d_buf&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;l_buf&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;dup&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;dest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;l_buf&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;window&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;dup&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;dest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;window&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;clone()&lt;/code&gt; method deep-duplicates every buffer except &lt;code dir=&quot;auto&quot;&gt;d_buf&lt;/code&gt;. The assignment &lt;code dir=&quot;auto&quot;&gt;dest.d_buf = dest.d_buf;&lt;/code&gt; is a typo where &lt;code dir=&quot;auto&quot;&gt;dup(dest.d_buf)&lt;/code&gt; was intended. The result is that the cloned &lt;code dir=&quot;auto&quot;&gt;Deflate&lt;/code&gt; instance shares its &lt;code dir=&quot;auto&quot;&gt;d_buf&lt;/code&gt; with the original. Modifying the buffer through either reference is visible through both.&lt;/p&gt;
&lt;p&gt;The defect is in an upstream library that nobody has actively maintained for years. The OculiX project carries it because the library is bundled. The risk is real but only materializes if the &lt;code dir=&quot;auto&quot;&gt;Deflate.clone()&lt;/code&gt; method is called in a context where both the original and the clone are used concurrently — a rare pattern that JZlib’s tests apparently do not cover and that nobody seems to hit in production.&lt;/p&gt;
&lt;p&gt;Both Self assignment findings are genuinely correct. Both have been silently dormant in the codebase for over a decade. &lt;strong&gt;Neither has produced a user-visible incident.&lt;/strong&gt; CodeQL classifies them as &lt;strong&gt;Error&lt;/strong&gt;. The classification suggests they require immediate attention. The empirical evidence suggests they have been irrelevant for a decade.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;reference-equality-test-of-boxed-types-1-finding&quot;&gt;Reference equality test of boxed types (1 finding)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;In &lt;code dir=&quot;auto&quot;&gt;RecordedEventsFlow.java:149&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;modifiers&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;isEmpty&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; characterWithModifiers &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; character) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;actions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TypeTextAction&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;keyText, modifiersTexts&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;characterWithModifiers&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;character&lt;/code&gt; are boxed &lt;code dir=&quot;auto&quot;&gt;Integer&lt;/code&gt; (or &lt;code dir=&quot;auto&quot;&gt;Character&lt;/code&gt;) values. Comparing them with &lt;code dir=&quot;auto&quot;&gt;==&lt;/code&gt; compares object references, not values. Java caches boxed integers from -128 to 127, so for any ASCII codepoint, two boxed instances of the same value point to the same cached object, and &lt;code dir=&quot;auto&quot;&gt;==&lt;/code&gt; returns &lt;code dir=&quot;auto&quot;&gt;true&lt;/code&gt; by accident. For codepoints above 127 — accented Latin, CJK, Cyrillic, Arabic, Hindi, almost any non-ASCII script — the cache does not apply, and &lt;code dir=&quot;auto&quot;&gt;==&lt;/code&gt; returns &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; even when the values are equal.&lt;/p&gt;
&lt;p&gt;The defect is correct. It has existed for years. The empirical impact is invisible because:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The recorder is used predominantly with ASCII input in the regions where it is most used.&lt;/li&gt;
&lt;li&gt;When the comparison fails for non-ASCII input, the recorder falls through to a different branch that produces a slightly different — but still functional — action.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is a textbook example of a real defect that has been silently absorbed by the surrounding code’s tolerance. No user has filed a ticket. No CI test has caught it. The fix is one line (&lt;code dir=&quot;auto&quot;&gt;.equals()&lt;/code&gt; instead of &lt;code dir=&quot;auto&quot;&gt;==&lt;/code&gt;). The classification as Error is consistent with CodeQL’s rule but &lt;strong&gt;inconsistent with the actual user impact&lt;/strong&gt;, which is zero.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;equals-method-does-not-inspect-argument-type-1-finding&quot;&gt;Equals method does not inspect argument type (1 finding)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;In &lt;code dir=&quot;auto&quot;&gt;PatternPaneScreenshot.java:36&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;@&lt;/span&gt;&lt;span&gt;Override&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;boolean&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;equals&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Object&lt;/span&gt;&lt;span&gt; o&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;CodeQL warns that the method does not check the argument’s type, and might therefore lead to a &lt;code dir=&quot;auto&quot;&gt;ClassCastException&lt;/code&gt; somewhere downstream. The implementation is trivial: it returns &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; for any input, including &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt;. There is no cast. There is no downstream usage of the result that depends on the argument’s type.&lt;/p&gt;
&lt;p&gt;The override exists inside a &lt;code dir=&quot;auto&quot;&gt;Comparator&lt;/code&gt; declaration. The &lt;code dir=&quot;auto&quot;&gt;Comparator&lt;/code&gt; interface allows &lt;code dir=&quot;auto&quot;&gt;equals&lt;/code&gt; to be overridden to indicate that two comparators impose the same ordering. Returning &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; is a safe, conservative default: “I do not claim to impose the same ordering as you do.” It does no harm.&lt;/p&gt;
&lt;p&gt;The finding is a false positive on this specific code. The override is pointless (it could be removed without changing behavior), but it is not a defect. CodeQL classifies it as &lt;strong&gt;Reliability — Error&lt;/strong&gt;, suggesting it could cause a crash. It cannot. The implementation has no crash path.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;non-callable-called-2-findings-python&quot;&gt;Non-callable called (2 findings, Python)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Two findings on &lt;code dir=&quot;auto&quot;&gt;Sikuli.py&lt;/code&gt;, the Jython-based scripting layer that ships with OculiX. Both report that an imported name is being called as if it were callable when it is not.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; org.sikuli.script.ScreenRemote &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SR&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SCREEN&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SR&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;adr&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;str&lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;port&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The CodeQL Python analyzer treats this as CPython. It sees &lt;code dir=&quot;auto&quot;&gt;import org.sikuli.script.ScreenRemote&lt;/code&gt; as importing an attribute, has no idea that &lt;code dir=&quot;auto&quot;&gt;ScreenRemote&lt;/code&gt; is a Java class, and concludes that calling &lt;code dir=&quot;auto&quot;&gt;SR(...)&lt;/code&gt; is invalid.&lt;/p&gt;
&lt;p&gt;In Jython, this import is valid Java-to-Python bridging. &lt;code dir=&quot;auto&quot;&gt;SR(...)&lt;/code&gt; invokes the Java constructor &lt;code dir=&quot;auto&quot;&gt;ScreenRemote(String, String)&lt;/code&gt;. The runtime knows this. CodeQL does not.&lt;/p&gt;
&lt;p&gt;This is &lt;strong&gt;structural blindness across language runtimes&lt;/strong&gt;. CodeQL Python has no awareness of the Jython platform. Every Java class accessed from a Jython script will be flagged as “non-callable called”. The number of such findings will grow linearly with the size of the scripting layer.&lt;/p&gt;
&lt;p&gt;Both findings are false positives. There is no fix on the project side. The recourse is to disable the rule in &lt;code dir=&quot;auto&quot;&gt;.codeql.yml&lt;/code&gt; or to suppress the findings manually one by one.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;dereferenced-variable-is-always-null-1-finding&quot;&gt;Dereferenced variable is always null (1 finding)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;In &lt;code dir=&quot;auto&quot;&gt;ButtonGenCommand.java:127&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;pref&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getAutoCaptureForCmdButtons&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;btnCapture &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;//TODO new ButtonCapture(pane, line);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;pane&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;insertComponent&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;btnCapture&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;btnCapture&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;captureWithAutoDelay&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The author explicitly assigned &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt;, left a &lt;code dir=&quot;auto&quot;&gt;TODO&lt;/code&gt; comment, and then dereferenced the variable twice. Whoever opens the IDE with the &lt;code dir=&quot;auto&quot;&gt;AutoCaptureForCmdButtons&lt;/code&gt; preference enabled will crash on &lt;code dir=&quot;auto&quot;&gt;btnCapture.captureWithAutoDelay()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is the only finding in the set that is &lt;strong&gt;a definite crash trap&lt;/strong&gt;. CodeQL is correct to flag it. Calling it &lt;code dir=&quot;auto&quot;&gt;Error&lt;/code&gt; is, for once, defensible.&lt;/p&gt;
&lt;p&gt;Has anyone hit the crash? The preference is off by default, and the &lt;code dir=&quot;auto&quot;&gt;TODO&lt;/code&gt; has lived in the code for years, which suggests nobody enables this preference in practice. The crash is a real risk but a dormant one. Fix: replace the &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt; with &lt;code dir=&quot;auto&quot;&gt;new ButtonCapture(pane, line)&lt;/code&gt; and finish the TODO.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;contradictory-type-checks-1-finding&quot;&gt;Contradictory type checks (1 finding)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;In &lt;code dir=&quot;auto&quot;&gt;Offset.java:152&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;RMILO&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Offset&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;RMILO&lt;/span&gt;&lt;span&gt; whatEver&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (whatEver &lt;/span&gt;&lt;span&gt;instanceof&lt;/span&gt;&lt;span&gt; Region &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; whatEver &lt;/span&gt;&lt;span&gt;instanceof&lt;/span&gt;&lt;span&gt; Match) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;Region&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;what&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (Region) whatEver;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;CodeQL claims that when &lt;code dir=&quot;auto&quot;&gt;whatEver&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;Match&lt;/code&gt; but not &lt;code dir=&quot;auto&quot;&gt;Region&lt;/code&gt;, the cast to &lt;code dir=&quot;auto&quot;&gt;Region&lt;/code&gt; will fail at runtime. This would be true if &lt;code dir=&quot;auto&quot;&gt;Match&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;Region&lt;/code&gt; were unrelated types. They are not. &lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;Match&lt;/code&gt; extends &lt;code dir=&quot;auto&quot;&gt;Region&lt;/code&gt;&lt;/strong&gt; throughout the Sikuli class hierarchy. Every &lt;code dir=&quot;auto&quot;&gt;Match&lt;/code&gt; is also a &lt;code dir=&quot;auto&quot;&gt;Region&lt;/code&gt;. The cast is safe.&lt;/p&gt;
&lt;p&gt;CodeQL does not consult the inheritance graph of the project, so it treats the two types as disjoint. The OR condition is redundant (&lt;code dir=&quot;auto&quot;&gt;whatEver instanceof Region&lt;/code&gt; already covers the &lt;code dir=&quot;auto&quot;&gt;Match&lt;/code&gt; case), but it is not unsafe.&lt;/p&gt;
&lt;p&gt;The finding is a false positive caused by &lt;strong&gt;type system blindness&lt;/strong&gt;. The check on &lt;code dir=&quot;auto&quot;&gt;Match&lt;/code&gt; is dead, in the sense that it is always reached on objects that already match &lt;code dir=&quot;auto&quot;&gt;instanceof Region&lt;/code&gt;. Removing it is a cleanup, not a bug fix.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;type-mismatch-on-container-modification-1-finding&quot;&gt;Type mismatch on container modification (1 finding)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;In &lt;code dir=&quot;auto&quot;&gt;HotkeyManager.java:263&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;boolean&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;_instance&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;_removeHotkey&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;key, mod&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (res) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;hotkeys&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;remove&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;key&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;hotkeys&lt;/code&gt; is a &lt;code dir=&quot;auto&quot;&gt;Map&lt;/code&gt; keyed on &lt;code dir=&quot;auto&quot;&gt;String&lt;/code&gt;. &lt;code dir=&quot;auto&quot;&gt;key&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;Integer&lt;/code&gt; (an AWT virtual key code). &lt;code dir=&quot;auto&quot;&gt;Map.remove(Object)&lt;/code&gt; accepts any &lt;code dir=&quot;auto&quot;&gt;Object&lt;/code&gt; for backward compatibility with pre-generics Java, so the call compiles cleanly. At runtime, no entry in the map has an &lt;code dir=&quot;auto&quot;&gt;Integer&lt;/code&gt; key, so the remove silently does nothing. The intended key for the map is a textual representation that must be constructed from &lt;code dir=&quot;auto&quot;&gt;key&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;mod&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The hotkey is reported as removed (because &lt;code dir=&quot;auto&quot;&gt;_removeHotkey&lt;/code&gt; succeeded at unbinding the OS-level listener), but the entry stays in the map indefinitely. If &lt;code dir=&quot;auto&quot;&gt;hotkeys&lt;/code&gt; is consulted to dispatch incoming events, &lt;strong&gt;the old hotkey’s listener continues to fire&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The defect is real and observable in theory. In practice, no user has filed a ticket about a hotkey persisting after rebind. Either:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The listener-dispatch path does not consult the map.&lt;/li&gt;
&lt;li&gt;Nobody rebinds hotkeys.&lt;/li&gt;
&lt;li&gt;The fallout is silently absorbed by other code.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Whichever explanation applies, &lt;strong&gt;the failure mode of this defect is dormant in the same way as the others&lt;/strong&gt;. CodeQL’s classification as Error matches the technical risk but overstates the empirical urgency.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;missing-format-argument-2-findings&quot;&gt;Missing format argument (2 findings)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;In &lt;code dir=&quot;auto&quot;&gt;Commons.java:1525&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;RunTime&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;terminate&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;999&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Commons.loadLib: %s&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The format string demands an argument, none is supplied. When this line is reached, the &lt;code dir=&quot;auto&quot;&gt;String.format&lt;/code&gt; call throws &lt;code dir=&quot;auto&quot;&gt;IllegalFormatException&lt;/code&gt;. The thrown exception is wrapped inside the &lt;code dir=&quot;auto&quot;&gt;terminate&lt;/code&gt; call, which means the user sees a confusing format-related error instead of a clean shutdown with code 999 and a useful diagnostic message.&lt;/p&gt;
&lt;p&gt;The branch is an error path inside the native library loading code. It executes when a library fails to load — itself a rare event. The defect aggravates the user experience precisely at the moment when it is already poor (something is breaking, and now the error reporting is also breaking), but it does not produce its own incident on a healthy system.&lt;/p&gt;
&lt;p&gt;The second finding is &lt;code dir=&quot;auto&quot;&gt;Crawler.java:441&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;docMethod &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;format&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;pyMethod, clazz, method, docParams, returns&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The format string &lt;code dir=&quot;auto&quot;&gt;pyMethod&lt;/code&gt; expects five arguments, four are supplied. Every invocation of this method throws &lt;code dir=&quot;auto&quot;&gt;IllegalFormatException&lt;/code&gt;. The Crawler appears to be a documentation generator that has not been run in a long time — the API it scrapes for has changed, but the generator has not been touched to follow. The defect is invisible because the tool is no longer used.&lt;/p&gt;
&lt;p&gt;Both findings are real defects. Both are essentially dead code (one on a rare error path, one in an unused tool). Their classification as Error is technically correct and operationally misleading.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-the-classification-actually-looks-like&quot;&gt;What the classification actually looks like&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The total: 19 findings classified as &lt;strong&gt;Error&lt;/strong&gt;. After triage:&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Category&lt;/th&gt;&lt;th&gt;Count&lt;/th&gt;&lt;th&gt;Share&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Dead-code paths with crash potential&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;21 %&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Architectural defects with dormant impact&lt;/td&gt;&lt;td&gt;8&lt;/td&gt;&lt;td&gt;42 %&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;False positives (structural blindness)&lt;/td&gt;&lt;td&gt;7&lt;/td&gt;&lt;td&gt;37 %&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Findings with observed user incidents&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0 %&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Zero observed user incidents in fifteen years across all nineteen findings.&lt;/p&gt;
&lt;p&gt;Meanwhile, in the layers below Error, CodeQL reports the following counts on the same codebase:&lt;/p&gt;


















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Rule&lt;/th&gt;&lt;th&gt;Findings&lt;/th&gt;&lt;th&gt;Classified&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Missing catch of NumberFormatException&lt;/td&gt;&lt;td&gt;53&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Dereferenced variable may be null&lt;/td&gt;&lt;td&gt;38&lt;/td&gt;&lt;td&gt;Warning&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Potential input resource leak&lt;/td&gt;&lt;td&gt;24&lt;/td&gt;&lt;td&gt;Warning&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Ignored error status of call&lt;/td&gt;&lt;td&gt;23&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Implicit conversion from array to string&lt;/td&gt;&lt;td&gt;11&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Potential output resource leak&lt;/td&gt;&lt;td&gt;11&lt;/td&gt;&lt;td&gt;Warning&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Use of default toString()&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Non-synchronized override of synchronized method&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;(below visible threshold)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Fifty-three places where parsing a user-provided number can crash the application. Classified &lt;code dir=&quot;auto&quot;&gt;Note&lt;/code&gt;. Almost invisible.&lt;/p&gt;
&lt;p&gt;Twenty-four places where a file or network resource may not be closed, leading to file descriptor exhaustion on a long-running server. Classified &lt;code dir=&quot;auto&quot;&gt;Warning&lt;/code&gt;. Below &lt;code dir=&quot;auto&quot;&gt;Error&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Two places where overriding a &lt;code dir=&quot;auto&quot;&gt;synchronized&lt;/code&gt; method without re-applying &lt;code dir=&quot;auto&quot;&gt;synchronized&lt;/code&gt; creates a race condition in multi-threaded code. Buried so deep it does not even appear in the default UI summary.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;where-the-severity-system-breaks-down&quot;&gt;Where the severity system breaks down&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The pattern across the data is unambiguous. CodeQL’s &lt;code dir=&quot;auto&quot;&gt;Error&lt;/code&gt; tier is dominated by structural patterns that may produce defects in extreme cases but routinely do not. The &lt;code dir=&quot;auto&quot;&gt;Warning&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;Note&lt;/code&gt; tiers contain patterns that frequently produce real production incidents on real systems. &lt;strong&gt;The mapping from classification to operational urgency is inverted.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The reasons appear to be structural to how the tool reasons:&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 16a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm10.67 1.47-8.05-14a3 3 0 0 0-5.24 0l-8 14A3 3 0 0 0 3.94 22h16.12a3 3 0 0 0 2.61-4.53Zm-1.73 2a1 1 0 0 1-.88.51H3.94a1 1 0 0 1-.88-.51 1 1 0 0 1 0-1l8-14a1 1 0 0 1 1.78 0l8.05 14a1 1 0 0 1 .05 1.02v-.02ZM12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Definite vs probable&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;CodeQL favors definite analytical conclusions (“this is always null”, “this assigns to itself”) over probabilistic ones (“this might be null”). Definite conclusions get Error. Probabilistic ones get Warning or Note. But probable failures on 53 lines of code matter more than definite failures on 2 dormant lines.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Pattern-level severity&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Every instance of a rule inherits the rule’s severity. There is no per-instance reasoning. A self-assignment that is dead code and a self-assignment that breaks a hot path receive the same Error tier. The reviewer cannot distinguish them from the severity column.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M0.127 6.839L0.127 0.608L16.929 0.608L16.929 6.839L0.127 6.839ZM7.040 15.116L7.040 8.885L23.873 8.885L23.873 15.116L7.040 15.116ZM17.053 23.393L17.053 17.162L23.873 17.162L23.873 23.393L17.053 23.393Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Cosmetic and structural together&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Rules like “Container contents are never accessed” cover three very different real-world cases: dead variables, dynamically accessed structures, and security-critical filters that fail open. All three receive Maintainability — Error. The CISO reviewing the report cannot find the security one without reading every finding.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3.06a1.3 1.3 0 0 0-.06-.27v-.09c-.05-.1-.11-.2-.19-.28l-6-6a1.07 1.07 0 0 0-.28-.19h-.09a.88.88 0 0 0-.33-.11H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V8.94Zm-6-3.53L16.59 8H15a1 1 0 0 1-1-1V5.41ZM18 19a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;No cross-rule prioritization&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A self-assignment with no impact is Error. A &lt;code dir=&quot;auto&quot;&gt;NumberFormatException&lt;/code&gt; not caught on user input is Note. There is no mechanism in CodeQL to bring the second above the first in any output. The order of rules in the output is alphabetic or chronological, never by operational risk.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;The combined effect is that a developer who opens CodeQL on a mature codebase sees a few dozen “Errors” — most of them irrelevant — and a few hundred “Notes” — among which the genuinely concerning ones are buried. The natural response, after the third or fourth review, is to stop opening CodeQL.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-codeql-actually-catches-well&quot;&gt;What CodeQL actually catches well&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This post would be unfair if it described only the failure modes. CodeQL does some things well, and the things it does well are worth naming.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Definite null dereference&lt;/strong&gt; (when the analyzer is sure, not “may be”). The &lt;code dir=&quot;auto&quot;&gt;ButtonGenCommand&lt;/code&gt; finding is a real crash trap. The rule has high precision.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Type-incompatible container modification.&lt;/strong&gt; The &lt;code dir=&quot;auto&quot;&gt;HotkeyManager&lt;/code&gt; finding is a real bug that compiles cleanly and silently misbehaves at runtime. Without static analysis, this kind of generic-erasure pitfall is genuinely hard to catch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Format string desync.&lt;/strong&gt; Both &lt;code dir=&quot;auto&quot;&gt;Missing format argument&lt;/code&gt; findings are real. The rule has very high precision because the analysis is local.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Self-assignment.&lt;/strong&gt; Both findings are real. The rule has perfect precision because there is no plausible legitimate use.&lt;/p&gt;
&lt;p&gt;What unites these is that they are &lt;strong&gt;narrow, local, and high-confidence&lt;/strong&gt; rules. CodeQL is at its best when the pattern is sharply defined and the analysis is bounded. It is at its worst when it generalizes — across rules, across severities, across code paths it cannot follow.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;how-we-configured-codeql-to-be-useful&quot;&gt;How we configured CodeQL to be useful&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;After triage, the actions we took for this scan:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Suppressed the Jython-related rules entirely&lt;/strong&gt;. &lt;code dir=&quot;auto&quot;&gt;Non-callable called&lt;/code&gt; and a few other Python rules produce structural false positives on every Jython-bridged file. There is no fix on our side. Suppressing the rule per-file via comments is more cost than benefit. We disable the rule in &lt;code dir=&quot;auto&quot;&gt;.codeql.yml&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Filed an internal issue for &lt;code dir=&quot;auto&quot;&gt;SikulixServer.allowedIPs&lt;/code&gt;&lt;/strong&gt; to investigate whether the network server is reachable and to either fix the missing filter or remove the server. The finding sat under Maintainability — Error, where nobody who triages security findings would look for it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Treated the 53 &lt;code dir=&quot;auto&quot;&gt;NumberFormatException&lt;/code&gt; findings as a focused remediation backlog&lt;/strong&gt;, separately from the Error-tier findings. They are not the same kind of work and should not be in the same queue.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Documented our dismissals&lt;/strong&gt;. For every Error-tier finding we dismissed, we wrote a one-line justification in the commit that closed the alert. This is the documentation our future selves will need when CodeQL re-flags the same finding after a re-scan.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Did not aim for zero Errors.&lt;/strong&gt; Zero CodeQL Errors is not a meaningful goal. A meaningful goal is zero unjustified Errors and a tracked, finite list of structural issues being worked on.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;div&gt;&lt;h2 id=&quot;why-we-still-keep-codeql-on&quot;&gt;Why we still keep CodeQL on&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Despite all of the above, CodeQL stays enabled on OculiX. The OpenSSF Scorecard checks for a SAST tool integration and rewards projects that have one. Procurement teams in regulated sectors check that CodeQL or an equivalent is configured. The signal value to outside observers is real, even when the operational value is poor.&lt;/p&gt;
&lt;p&gt;But the cost of running CodeQL is also real: the triage time, the alert fatigue, the temptation to ignore the tool entirely. We pay that cost in exchange for the external signal. We do not pretend that what we get back internally is anywhere near worth the marketing of static analysis.&lt;/p&gt;
&lt;p&gt;If you maintain a similar codebase and you are considering whether to enable CodeQL: enable it, run it once, do the triage we just walked through, and then decide rule-by-rule which queries you actually want to run. The default query suite assumes you have time to read several hundred findings. Most teams do not. The most useful configuration is the one that leaves you with twenty findings a week, all of which are worth opening.&lt;/p&gt;
&lt;p&gt;The boy who cried Error eventually had a wolf. We will probably see one, eventually, in a CodeQL scan. We will recognize it because it will be the only finding in months that we cannot dismiss in a single line. Everything else will continue to be the noise that the wolf is hidden in.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Repository: &lt;a href=&quot;https://github.com/oculix-org/Oculix&quot;&gt;github.com/oculix-org/Oculix&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The findings discussed are from a CodeQL scan on commit &lt;code dir=&quot;auto&quot;&gt;29a14d9&lt;/code&gt;. Detailed dismissal justifications are visible in the Security tab.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>codeql</category><category>static-analysis</category><category>code-quality</category></item><item><title>Why automation tools cannot type Chinese, and what OculiX does instead</title><link>https://oculix.org/blog/2026-05-20-typing-chinese-japanese-clipboard-route/</link><guid isPermaLink="true">https://oculix.org/blog/2026-05-20-typing-chinese-japanese-clipboard-route/</guid><description>An OculiX user filed a bug. Typing two Chinese characters into an input field silently produced garbage. The fix took fifteen lines of Java, but the explanation reaches back to how AWT was designed thirty years ago around the assumption that one character equals one keystroke. Here is what happens when that assumption meets the rest of the world.

</description><pubDate>Wed, 20 May 2026 08:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In May 2026, an OculiX user filed &lt;a href=&quot;https://github.com/oculix-org/Oculix/issues/232&quot;&gt;issue #232&lt;/a&gt;. The summary was three lines long. The user wanted to call &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt; with two Chinese characters in their automation script. Instead of typing them, the call produced silence, garbage, or in some IDE configurations a sequence of unrelated Latin characters depending on the active keyboard layout.&lt;/p&gt;
&lt;p&gt;The fix, when it landed, was fifteen lines of Java. The explanation behind it stretches back to how &lt;code dir=&quot;auto&quot;&gt;java.awt.Robot&lt;/code&gt; was designed in 1998, around an implicit assumption that one character on a screen corresponds to one keystroke on a keyboard. That assumption holds beautifully for ASCII Latin. It breaks for Chinese, Japanese, Korean, Arabic, Hindi, and even for accented Latin characters when the user’s keyboard layout does not happen to expose them as direct keys.&lt;/p&gt;
&lt;p&gt;This is the story of why visual automation frameworks have historically struggled to type non-ASCII, what OculiX decided to do about it, and why the workaround is more philosophically interesting than the bug it solves.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-bug-report&quot;&gt;The bug report&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The user, an active OculiX adopter, was automating an internal Chinese-language application. Their script needed to fill a search field with a person’s name. The straightforward call returned silently. The search field stayed empty. No exception was thrown. No log line surfaced anything unusual. The automation simply continued, the next step failed because the search field had no input, and the test reported FindFailed on whatever element was supposed to appear after the search.&lt;/p&gt;
&lt;p&gt;This is the worst kind of automation bug. There is no signal that something went wrong at the moment something went wrong. The error appears three steps later, attached to an unrelated element, with no link back to the actual failure point.&lt;/p&gt;
&lt;p&gt;After some investigation, the user established that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ASCII input worked: &lt;code dir=&quot;auto&quot;&gt;type(&quot;hello&quot;)&lt;/code&gt; produced “hello” in the field.&lt;/li&gt;
&lt;li&gt;French accented characters partly worked: typing a word with an accent produced the unaccented version on a US layout.&lt;/li&gt;
&lt;li&gt;Chinese input failed silently on every layout tested.&lt;/li&gt;
&lt;li&gt;Switching the OS keyboard layout to a Chinese IME did not help: the OculiX &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt; call still produced garbage.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The bug was filed not as a feature request but as a question: “Is this expected? How is everyone else handling CJK?”&lt;/p&gt;
&lt;p&gt;The honest answer was that everyone else was working around it. Manually. Each script that needed to type Chinese was implementing its own custom paste routine inline, copying the string to the clipboard with &lt;code dir=&quot;auto&quot;&gt;Jython&lt;/code&gt;’s &lt;code dir=&quot;auto&quot;&gt;java.awt.Toolkit&lt;/code&gt; calls and then sending &lt;code dir=&quot;auto&quot;&gt;Ctrl-V&lt;/code&gt; via &lt;code dir=&quot;auto&quot;&gt;keyDown&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;keyUp&lt;/code&gt;. The workaround was well-known among users who automated CJK applications, but it was not in the documentation, and it required understanding why the straightforward call did not work.&lt;/p&gt;
&lt;p&gt;This blog post explains the why, and what we did to make the straightforward call work.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-robotkeypress-fundamentally-cannot-type-chinese&quot;&gt;Why Robot.keyPress fundamentally cannot type Chinese&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;To understand why &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt; with Chinese fails, you have to understand what &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt; actually does under the hood. In OculiX (and in the original Sikuli, and in SikuliX), the call descends through several layers and eventually reaches &lt;code dir=&quot;auto&quot;&gt;java.awt.Robot.keyPress(int keycode)&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;java.awt.Robot.keyRelease(int keycode)&lt;/code&gt;, both members of the AWT API that has shipped with the Java platform since version 1.3 in May 2000.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Robot.keyPress(int keycode)&lt;/code&gt; is a thin wrapper around the operating system’s low-level keyboard event injection mechanism: &lt;code dir=&quot;auto&quot;&gt;SendInput&lt;/code&gt; on Windows, &lt;code dir=&quot;auto&quot;&gt;XTestFakeKeyEvent&lt;/code&gt; on X11/Linux, &lt;code dir=&quot;auto&quot;&gt;CGEventPost&lt;/code&gt; on macOS. All three of these accept a virtual key code, an integer identifier of a physical key on the keyboard. Not a character. Not a Unicode codepoint. A key.&lt;/p&gt;
&lt;aside aria-label=&quot;Virtual key codes are about keys, not characters&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; Virtual key codes are about keys, not characters &lt;/p&gt;  &lt;div&gt;&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;KeyEvent.VK_A&lt;/code&gt; is the key labeled “A” on a US-QWERTY keyboard. On a French AZERTY keyboard, the same physical key is labeled “Q”. On a Dvorak keyboard, it is labeled “A” again but in a different position. The virtual key code is a hardware concept, divorced from the character it produces.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;This is the first fault line. When you call &lt;code dir=&quot;auto&quot;&gt;Robot.keyPress(KeyEvent.VK_A)&lt;/code&gt;, you are not asking the system to produce the character “A”. You are asking it to simulate pressing the physical key that, on the &lt;em&gt;currently active keyboard layout&lt;/em&gt;, would produce some character. The character depends on the layout. The layout depends on the user’s system configuration. The result depends on both.&lt;/p&gt;
&lt;p&gt;For pure ASCII Latin in a Western layout, the mapping is reasonably stable. The key &lt;code dir=&quot;auto&quot;&gt;VK_A&lt;/code&gt; produces “a” or “A” depending on shift state. The key &lt;code dir=&quot;auto&quot;&gt;VK_1&lt;/code&gt; produces “1” or ”!” depending on shift state. Sikuli historically maintained a hardcoded table mapping each ASCII character to a sequence of virtual key codes, and it worked because Western keyboards have a key for every ASCII character.&lt;/p&gt;
&lt;p&gt;The trouble starts the moment the character has no physical key on the keyboard.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-chinese-case&quot;&gt;The Chinese case&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;There is no constant for Chinese characters in &lt;code dir=&quot;auto&quot;&gt;java.awt.event.KeyEvent&lt;/code&gt;. There cannot be one. Chinese characters do not correspond to keys. A standard Chinese keyboard is, physically, a US-QWERTY keyboard. Chinese characters are produced through an &lt;strong&gt;Input Method Editor (IME)&lt;/strong&gt; that interprets sequences of QWERTY keystrokes as phonetic input (Pinyin), looks up matching characters, displays a candidate menu, and inserts the chosen character into the active text field once the user confirms a selection.&lt;/p&gt;
&lt;p&gt;To type a Chinese phrase through an IME, a Chinese user types Pinyin on the physical keyboard, the IME intercepts the sequence, recognizes it as the romanization of a candidate set, presents a candidate menu (because multiple character sequences share that romanization), and waits for the user to either accept the top candidate via space or pick a different one via number keys or arrow keys.&lt;/p&gt;
&lt;p&gt;This is not what &lt;code dir=&quot;auto&quot;&gt;Robot.keyPress&lt;/code&gt; does. &lt;code dir=&quot;auto&quot;&gt;Robot.keyPress&lt;/code&gt; injects events at a layer beneath the IME. The IME never sees them as Pinyin input. The events are interpreted as raw key events by the receiving application, which sees the Pinyin letters as literal characters. The Chinese intent is lost.&lt;/p&gt;
&lt;p&gt;Even more confusingly, on a system where the active keyboard layout is Chinese (with an IME enabled), &lt;code dir=&quot;auto&quot;&gt;Robot.keyPress(VK_N)&lt;/code&gt; might:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Be intercepted by the IME, treated as Pinyin input, and contribute to building a candidate menu that the user never sees because the automation does not pause to select&lt;/li&gt;
&lt;li&gt;Be passed through to the application as a literal “n” if the IME is in alphanumeric mode&lt;/li&gt;
&lt;li&gt;Be silently dropped if the IME is mid-composition for another sequence&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The behavior is non-deterministic from the automation’s perspective, and entirely dependent on IME state, which the automation cannot reliably read or control through the AWT API.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-japanese-korean-arabic-hindi-cases&quot;&gt;The Japanese, Korean, Arabic, Hindi cases&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The same logic applies to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Japanese&lt;/strong&gt;, where IMEs interpret romaji or kana keystrokes into hiragana, katakana, or kanji&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Korean&lt;/strong&gt;, where IMEs assemble hangul syllables from jamo keystrokes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Arabic&lt;/strong&gt;, where the keyboard layout itself is non-Latin and Robot virtual keys do not map predictably&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hindi and other Indic scripts&lt;/strong&gt;, where IMEs combine consonants and vowel marks into syllabic clusters&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vietnamese&lt;/strong&gt;, where IMEs add tonal diacritics through dedicated keys&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All these languages share the property that the displayed character is not a one-to-one mapping with a physical keystroke. The mapping is mediated by an IME that operates above the OS keyboard event layer that &lt;code dir=&quot;auto&quot;&gt;java.awt.Robot&lt;/code&gt; injects into.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-accented-latin-case-more-subtle&quot;&gt;The accented Latin case (more subtle)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Even within the Latin script, the same problem appears in a milder form. Consider typing a French word with an accent on three different keyboard layouts.&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Layout&lt;/th&gt;&lt;th&gt;Result of typing a French word with accent&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;US-QWERTY&lt;/td&gt;&lt;td&gt;Accent silently dropped, no VK code maps to it&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;French AZERTY&lt;/td&gt;&lt;td&gt;Accent reproduced correctly&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;German QWERTZ&lt;/td&gt;&lt;td&gt;Accent dropped or garbage produced&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;UK-QWERTY&lt;/td&gt;&lt;td&gt;Accent dropped&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Spanish&lt;/td&gt;&lt;td&gt;Accent dropped (uses dead keys)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The accent characters have dead-key compositions on most layouts, which the AWT Robot cannot reliably simulate because dead keys are stateful at the OS layer and there is no portable way to query whether the dead key has been “consumed”.&lt;/p&gt;
&lt;p&gt;The same applies to many Latin extension characters. The character is “Latin”, but the keyboard layout determines whether it has a dedicated key or requires a composition sequence.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;summary-of-the-diagnosis&quot;&gt;Summary of the diagnosis&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The deep reason &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt; with non-ASCII fails is not specific to OculiX or to Sikuli. It is a structural limitation of the &lt;code dir=&quot;auto&quot;&gt;java.awt.Robot&lt;/code&gt; API, which injects events at the keyboard event layer beneath the IME and beneath the keyboard layout translation. This API was designed in 1998 for testing GUI applications in an ASCII-Latin context, and it has never been extended to handle the layered character input model that the rest of the world uses.&lt;/p&gt;
&lt;aside aria-label=&quot;This is not a Java problem alone&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; This is not a Java problem alone &lt;/p&gt;  &lt;div&gt;&lt;p&gt;The same limitation applies to PyAutoGUI (Python: &lt;code dir=&quot;auto&quot;&gt;typewrite&lt;/code&gt; on CJK produces garbage), AutoHotkey on Windows (the &lt;code dir=&quot;auto&quot;&gt;Send&lt;/code&gt; command requires &lt;code dir=&quot;auto&quot;&gt;SendUnicode&lt;/code&gt; mode using a different injection path), Selenium with &lt;code dir=&quot;auto&quot;&gt;sendKeys&lt;/code&gt; (works only because the browser layer interprets the Unicode and dispatches it as DOM events, not as OS keystrokes), and most Robot Framework keyword libraries (each library handles or fails to handle CJK independently).&lt;/p&gt;&lt;p&gt;The visual automation ecosystem inherited this constraint from AWT. Solving it requires going around AWT, not through it.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;the-clipboard-route-as-workaround&quot;&gt;The clipboard route as workaround&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Once you accept that you cannot type CJK characters through the keystroke injection path, the question becomes: how do you get them into an input field at all? The most portable answer, used by professional automation engineers for two decades, is the clipboard.&lt;/p&gt;
&lt;p&gt;The idea is simple. Instead of pressing keys, you:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Copy the target string to the system clipboard&lt;/li&gt;
&lt;li&gt;Send the OS-specific paste keystroke (&lt;code dir=&quot;auto&quot;&gt;Ctrl-V&lt;/code&gt; on Windows/Linux, &lt;code dir=&quot;auto&quot;&gt;Cmd-V&lt;/code&gt; on macOS)&lt;/li&gt;
&lt;li&gt;The receiving application processes the paste event and inserts the clipboard content into the focused field&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This route bypasses the keyboard layout layer entirely. The clipboard contains arbitrary Unicode bytes. The paste keystroke is a single key combination present on every modern platform. The receiving application sees the content as text, not as a sequence of keystrokes, and inserts it through its text handling code path, which is Unicode-aware.&lt;/p&gt;
&lt;p&gt;The clipboard route has been the unofficial standard workaround in the Sikuli community since at least 2012. It worked. But it required users to know about it, to implement it themselves, and to handle a list of edge cases (clipboard backup before pasting to avoid destroying user data, clipboard restoration after pasting, focus verification, etc.).&lt;/p&gt;
&lt;p&gt;The OculiX change for issue #232 was to make this route the default, transparently, for any input that contains characters outside the 7-bit ASCII range.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;implementation-fifteen-lines-of-java&quot;&gt;Implementation: fifteen lines of Java&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The actual change in OculiX is small enough to quote in full.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-detection&quot;&gt;The detection&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;boolean&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;containsNonAscii&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt; text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (text &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; i &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;; i&lt;/span&gt;&lt;span&gt;++&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;charAt&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;127&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Seven lines. We iterate over the input string and check whether any character has a codepoint greater than 127 (the 7-bit ASCII boundary). If yes, the input cannot be reliably typed through the keystroke path. If no, the input is pure ASCII Latin and can use the existing keystroke pipeline.&lt;/p&gt;
&lt;p&gt;The choice of 127 as the boundary is deliberate. Everything from 0 to 127 is ASCII, has a stable Robot mapping on Western keyboards, and works with the legacy code path. Everything above 127 is either Latin extension (accented characters), other scripts (CJK, Cyrillic, Arabic, etc.), or symbols. All of these benefit from going through paste rather than through simulated keystrokes.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-routing&quot;&gt;The routing&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt; text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;containsNonAscii&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;paste&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;try&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;keyin&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;null, text, &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;catch&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;FindFailed&lt;/span&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ex&lt;/span&gt;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Object&lt;/span&gt;&lt;span&gt; target, &lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt; text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; throws FindFailed {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;containsNonAscii&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;paste&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;target, text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;keyin&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;target, text, &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The routing is two if-statements added before the existing logic. If the input contains non-ASCII characters, the call is forwarded to &lt;code dir=&quot;auto&quot;&gt;paste&lt;/code&gt; (which already exists and was already battle-tested). Otherwise, the existing &lt;code dir=&quot;auto&quot;&gt;keyin&lt;/code&gt; path runs unchanged.&lt;/p&gt;
&lt;p&gt;The two-argument variant &lt;code dir=&quot;auto&quot;&gt;type(target, text)&lt;/code&gt; first clicks the target image to focus the input, then routes the text through &lt;code dir=&quot;auto&quot;&gt;paste(target, text)&lt;/code&gt;, which performs the same click-then-paste sequence internally. The user-facing API is identical; only the internal routing changes.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;what-stays-on-keystrokes&quot;&gt;What stays on keystrokes&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Object&lt;/span&gt;&lt;span&gt; target, &lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt; text, &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; modifiers&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;throws FindFailed {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;keyin&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;target, text, modifiers&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The modifier-bearing variants &lt;code dir=&quot;auto&quot;&gt;type(text, modifiers)&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;type(target, text, modifiers)&lt;/code&gt; are intentionally &lt;strong&gt;not&lt;/strong&gt; routed through paste. Holding Shift, Ctrl, or Cmd while pasting Unicode characters has no meaningful semantic — these modifiers exist to shift the meaning of physical keystrokes (Shift+A produces “A”, Ctrl+S triggers a save action), not to alter clipboard content.&lt;/p&gt;
&lt;p&gt;If a user calls a modifier-bearing variant with CJK characters, the keystroke path runs and produces a FindFailed if the characters cannot be typed, which is the honest behavior. Silently switching to paste would lose the user’s intent.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-behavior-matrix&quot;&gt;The behavior matrix&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The result for users of the API is a clean four-way matrix:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Input&lt;/th&gt;&lt;th&gt;Modifiers&lt;/th&gt;&lt;th&gt;Path taken&lt;/th&gt;&lt;th&gt;Why&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Pure ASCII&lt;/td&gt;&lt;td&gt;None&lt;/td&gt;&lt;td&gt;Keystrokes&lt;/td&gt;&lt;td&gt;Backward compatible, fast, no clipboard side effect&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Pure ASCII&lt;/td&gt;&lt;td&gt;Yes (Shift/Ctrl/Cmd)&lt;/td&gt;&lt;td&gt;Keystrokes&lt;/td&gt;&lt;td&gt;Modifiers map to keystroke semantics&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Non-ASCII&lt;/td&gt;&lt;td&gt;None&lt;/td&gt;&lt;td&gt;Clipboard paste&lt;/td&gt;&lt;td&gt;Only way to reliably produce the characters&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Non-ASCII&lt;/td&gt;&lt;td&gt;Yes (Shift/Ctrl/Cmd)&lt;/td&gt;&lt;td&gt;Keystrokes (will likely fail)&lt;/td&gt;&lt;td&gt;Honest failure; modifier+paste has no meaning&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;This is the kind of fallback that the user does not see and does not need to know about. The scripts that worked before still work the same. The scripts that previously needed manual clipboard workarounds now just work with the obvious call.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;tradeoffs-of-the-clipboard-route&quot;&gt;Tradeoffs of the clipboard route&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The clipboard path is not a free win. It has properties that are different from the keystroke path, and users automating sensitive workflows should be aware of them.&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 16a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm10.67 1.47-8.05-14a3 3 0 0 0-5.24 0l-8 14A3 3 0 0 0 3.94 22h16.12a3 3 0 0 0 2.61-4.53Zm-1.73 2a1 1 0 0 1-.88.51H3.94a1 1 0 0 1-.88-.51 1 1 0 0 1 0-1l8-14a1 1 0 0 1 1.78 0l8.05 14a1 1 0 0 1 .05 1.02v-.02ZM12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Clipboard pollution&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Pasting destroys the previous clipboard content. The OculiX paste implementation backs up the clipboard before pasting and restores it afterward, but the backup-restore window is not instantaneous and can interfere with other clipboard listeners.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Focus dependency&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;The paste keystroke targets whatever has keyboard focus. A wrong paste is one chunk of misplaced text, much more visible than wrong keystrokes which usually masks the issue.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M0.127 6.839L0.127 0.608L16.929 0.608L16.929 6.839L0.127 6.839ZM7.040 15.116L7.040 8.885L23.873 8.885L23.873 15.116L7.040 15.116ZM17.053 23.393L17.053 17.162L23.873 17.162L23.873 23.393L17.053 23.393Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Paste timing&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Some applications throttle or queue paste events. Pasting twice in rapid succession can produce only one paste, or merge two pastes into one. The keystroke path is less susceptible.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 7a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1Zm0 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm9.71-7.44-5.27-5.27a1.05 1.05 0 0 0-.71-.29H8.27a1.05 1.05 0 0 0-.71.29L2.29 7.56a1.05 1.05 0 0 0-.29.71v7.46c.004.265.107.518.29.71l5.27 5.27c.192.183.445.286.71.29h7.46a1.05 1.05 0 0 0 .71-.29l5.27-5.27a1.05 1.05 0 0 0 .29-.71V8.27a1.05 1.05 0 0 0-.29-.71ZM20 15.31 15.31 20H8.69L4 15.31V8.69L8.69 4h6.62L20 8.69v6.62Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;No partial input&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A keystroke-based input can be interrupted in the middle. A paste is atomic. If you want autocomplete observation, the keystroke path is the right one.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;For most real-world automation use cases, these tradeoffs are acceptable. A QA engineer automating a Chinese banking form does not need character-by-character granularity; they need the form filled correctly. A test scenario filling Japanese customer data into a healthcare CRM does not depend on clipboard preservation across the operation.&lt;/p&gt;
&lt;p&gt;The cases where the tradeoffs matter — autocomplete observation, clipboard-watching tooling — are edge cases that the user can opt out of by explicitly calling &lt;code dir=&quot;auto&quot;&gt;keyin&lt;/code&gt; instead of &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt;, or by providing the input one character at a time.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-this-enables-in-production&quot;&gt;What this enables in production&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Issue #232 was filed by a single user, but the impact of the fix is broader than that single use case. We have seen the change unlock automation work in several customer contexts since it landed.&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;APAC banking back-office&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A French bank with subsidiaries in Hong Kong and Singapore needed to automate regression tests on their Mandarin and Cantonese branch interfaces. Before the fix, their automation team maintained two separate codebases: one for European frontends, one for APAC with manual clipboard workarounds. The two codebases merged. Maintenance cost dropped by roughly a third.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3.06a1.3 1.3 0 0 0-.06-.27v-.09c-.05-.1-.11-.2-.19-.28l-6-6a1.07 1.07 0 0 0-.28-.19h-.09a.88.88 0 0 0-.33-.11H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V8.94Zm-6-3.53L16.59 8H15a1 1 0 0 1-1-1V5.41ZM18 19a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Japanese healthcare CRM&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A healthcare provider in Osaka automating patient intake workflows ran into the same wall. Patient names, addresses, prescription drug names — none survived the keystroke path. The team had a custom paste wrapper as workaround, but it predated the Ed25519 audit module and could not be signed for compliance. The integrated paste path now passes their audit.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M4.314 0.198L16.746 0.198Q17.838 0.198 18.615 0.975Q19.392 1.752 19.392 2.844L19.392 2.844L19.392 21.156Q19.392 22.248 18.615 23.025Q17.838 23.802 16.746 23.802L16.746 23.802L4.314 23.802Q3.222 23.802 2.445 23.025Q1.668 22.248 1.668 21.156L1.668 21.156L1.668 2.844Q1.668 1.752 2.445 0.975Q3.222 0.198 4.314 0.198L4.314 0.198ZM21.450 15.528L20.568 15.528L21.450 15.528Q21.786 15.528 22.038 15.759Q22.290 15.990 22.332 16.326L22.332 16.326L22.332 18.216Q22.332 18.552 22.122 18.783Q21.912 19.014 21.576 19.098L21.576 19.098L20.568 19.098L20.568 15.528L21.450 15.528ZM21.450 10.824L20.568 10.824L21.450 10.824Q21.786 10.824 22.038 11.034Q22.290 11.244 22.332 11.580L22.332 11.580L22.332 13.470Q22.332 13.806 22.122 14.058Q21.912 14.310 21.576 14.352L21.576 14.352L20.568 14.352L20.568 10.824L21.450 10.824ZM21.450 6.078L20.568 6.078L21.450 6.078Q21.786 6.078 22.038 6.309Q22.290 6.540 22.332 6.876L22.332 6.876L22.332 8.766Q22.332 9.102 22.122 9.333Q21.912 9.564 21.576 9.648L21.576 9.648L20.568 9.648L20.568 6.078L21.450 6.078ZM14.352 4.314L14.352 4.314L6.708 4.314Q6.372 4.314 6.120 4.545Q5.868 4.776 5.826 5.070L5.826 5.070L5.784 7.002Q5.784 7.296 6.015 7.548Q6.246 7.800 6.582 7.842L6.582 7.842L14.352 7.884Q14.688 7.884 14.940 7.653Q15.192 7.422 15.234 7.086L15.234 7.086L15.276 5.196Q15.276 4.818 15.003 4.566Q14.730 4.314 14.352 4.314Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;European multilingual e-commerce&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A French automation team running tests on a German marketplace was hitting silent failures whenever a product name contained German umlauts. The German keyboard layout was active on the test machine, but the keystroke table assumed French AZERTY and silently dropped the characters. The clipboard route bypasses all of this.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M8.7 10a1 1 0 0 0 1.41 0 1 1 0 0 0 0-1.41l-6.27-6.3a1 1 0 0 0-1.42 1.42ZM21 14a1 1 0 0 0-1 1v3.59L15.44 14A1 1 0 0 0 14 15.44L18.59 20H15a1 1 0 0 0 0 2h6a1 1 0 0 0 .38-.08 1 1 0 0 0 .54-.54A1 1 0 0 0 22 21v-6a1 1 0 0 0-1-1Zm.92-11.38a1 1 0 0 0-.54-.54A1 1 0 0 0 21 2h-6a1 1 0 0 0 0 2h3.59L2.29 20.29a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0L20 5.41V9a1 1 0 0 0 2 0V3a1 1 0 0 0-.08-.38Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Internal IT helpdesks across regions&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Multinational corporations run helpdesk automations interacting with internal ticketing systems containing employee names in dozens of native scripts. Cyrillic, Greek, Arabic, Hindi. Each required a separate workaround. The fix makes all of them automatic.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;The common thread is that internationalization in automation tooling has historically been an afterthought. Tools are built and tested in their authors’ language, and the assumption that “text input works” is rarely verified against the full Unicode space. Fixing this in OculiX brought a basic property back into line with what users reasonably expect.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-this-doesnt-solve&quot;&gt;What this doesn’t solve&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The fix is good but not complete. Several adjacent problems remain open.&lt;/p&gt;
&lt;aside aria-label=&quot;IME composition windows are still opaque&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; IME composition windows are still opaque &lt;/p&gt;  &lt;div&gt;&lt;p&gt;When a user types Chinese on a system with an IME enabled, the IME shows a small candidate window above the input field. OculiX cannot see this window through visual matching unless the user captures the candidate options as patterns. The paste path bypasses the IME entirely, so it does not produce candidate windows. This is fine when the goal is to insert known text. It does not help when the goal is to test the IME itself.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;aside aria-label=&quot;Characters outside the BMP need special handling&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; Characters outside the BMP need special handling &lt;/p&gt;  &lt;div&gt;&lt;p&gt;The clipboard path handles characters in the Basic Multilingual Plane (codepoints 0 to 65535) cleanly, because Java strings represent these as single UTF-16 code units. Characters above 65535 (emojis, ancient scripts, mathematical symbols, some rare CJK extensions) are represented as surrogate pairs in Java strings. The current implementation handles these correctly by virtue of the clipboard being byte-oriented, but there is no explicit test coverage for surrogate pair edge cases. We track this as a known unknown.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;aside aria-label=&quot;Right-to-left scripts depend on the target application&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; Right-to-left scripts depend on the target application &lt;/p&gt;  &lt;div&gt;&lt;p&gt;Pasting Arabic or Hebrew into an application that supports right-to-left rendering works as expected. Pasting the same into an application that does not produces visually corrupted output even though the underlying bytes are correct. This is a property of the target application, not of the automation tool.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;aside aria-label=&quot;Clipboard listeners may intercept the paste&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; Clipboard listeners may intercept the paste &lt;/p&gt;  &lt;div&gt;&lt;p&gt;Some enterprise environments deploy clipboard monitoring tools that intercept all clipboard operations (data loss prevention software, compliance loggers, anti-exfiltration filters). These tools may log every paste OculiX performs, may block them entirely, or may modify the content in transit. Customers in regulated sectors should validate the interaction before deploying.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;the-structural-lesson&quot;&gt;The structural lesson&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The lesson behind this fix is not about Chinese or Japanese specifically. It is about what happens when a tool’s API and its underlying assumption diverge.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;type(String)&lt;/code&gt; reads, to a 2026 developer, like a Unicode-aware string-to-input function. The signature accepts a &lt;code dir=&quot;auto&quot;&gt;String&lt;/code&gt;, which in Java is fully Unicode-capable. The naive expectation is that whatever Unicode string you pass should appear in the target field, character by character, exactly as written.&lt;/p&gt;
&lt;p&gt;That expectation collides with an implementation that descends, through layers of well-meaning abstraction, into a 1998-era API that injects keyboard events at a layer beneath the keyboard layout. The mismatch between the modern API surface and the legacy implementation creates a silent failure mode that takes years of accumulated user reports to fully document.&lt;/p&gt;
&lt;p&gt;The fix is fifteen lines of Java because the workaround already existed in the codebase (the &lt;code dir=&quot;auto&quot;&gt;paste&lt;/code&gt; method). The change just added a smart router that picks the right path based on the input content. The hard part was not the code; it was identifying that the routing decision belonged at the &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt; level, not at the user level.&lt;/p&gt;
&lt;p&gt;This is a recurring pattern in mature codebases. The capability is there. The pieces are in place. What is missing is the small connective decision that makes the capability available to users who do not know which internal method to call. A good API surface hides the choice between &lt;code dir=&quot;auto&quot;&gt;keyin&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;paste&lt;/code&gt;. The user calls &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt; and gets the result they expected.&lt;/p&gt;
&lt;p&gt;For anyone maintaining a similar tool with a similar AWT-era foundation: this fix is generalizable. The same routing logic, the same ASCII boundary detection, the same paste fallback applies in PyAutoGUI, AutoHotkey, Robot Framework keyword libraries, or any other automation tool that relies on Robot-style keystroke injection. The 1998 API will not be redesigned. But its limitations can be bounded by a smarter layer above it.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Repository: &lt;a href=&quot;https://github.com/oculix-org/Oculix&quot;&gt;github.com/oculix-org/Oculix&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Original issue: &lt;a href=&quot;https://github.com/oculix-org/Oculix/issues/232&quot;&gt;oculix-org/Oculix#232&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;</content:encoded><category>internationalization</category><category>java-awt</category><category>unicode</category></item><item><title>Storing spatial memory in a PNG: how we made visual UI matching 6.5× faster in OculiX</title><link>https://oculix.org/blog/2026-05-19-png-metadata-visual-matching-speedup/</link><guid isPermaLink="true">https://oculix.org/blog/2026-05-19-png-metadata-visual-matching-speedup/</guid><description>A weekend afternoon of measurement turned into a structural optimization for OculiX: persistent locator coordinates embedded in the PNG file itself, surviving git clone and cold-start JVMs. Measured ×6.5 on the find. Here is what we found, what we built, and what it changes for visual automation suites in CI.

</description><pubDate>Tue, 19 May 2026 08:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is a long technical post about a small change. It explains a measurement we never thought to make, the discovery it produced, the implementation we built around it, and what it means for visual automation suites running in CI.&lt;/p&gt;
&lt;p&gt;The headline number is real but not particularly dramatic: visual UI matching in OculiX became roughly 6.5 times faster, measured on 50 cold-start JVM runs on an Intel i3 7th generation laptop. The interesting part is not the speedup itself. It is the path that led to it, and the structural lesson that came with it.&lt;/p&gt;
&lt;p&gt;If you maintain or use a visual automation framework, a test suite that watches pixels, or any tool that needs to find an image inside another image repeatedly across separate process invocations, the pattern we describe here will probably apply to you. The implementation took about 200 lines of Java, no external dependencies, and three micro-modifications inside the existing codebase. The standards we relied on have been published since 1996.&lt;/p&gt;
&lt;p&gt;What follows is the full story, ordered the way the investigation actually unfolded, not the way a marketing post would tell it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-benchmark-we-never-made&quot;&gt;The benchmark we never made&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;OculiX is the active continuation of the Sikuli and SikuliX visual automation lineage, MIT-licensed and used in production by close to 100 organizations across banking, defense, healthcare, manufacturing, and retail. Its core operation is a function called &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt;: given a small image (a button, an icon, a region of UI), find it inside the current screen capture and return its coordinates. Every other operation in the public API contains at least one call to &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; underneath.&lt;/p&gt;
&lt;aside aria-label=&quot;Why find matters so much&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; Why find matters so much &lt;/p&gt;  &lt;div&gt;&lt;p&gt;Every public DSL method in OculiX (&lt;code dir=&quot;auto&quot;&gt;click&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;wait&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;exists&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;hover&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt;) calls &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; internally. The performance of &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; is therefore the upper bound on the performance of the entire suite. Optimizing it has a global effect, not a local one.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;Inside the codebase, &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; is well understood. It uses OpenCV template matching via JNI bindings, with five fallback strategies cascaded inside &lt;code dir=&quot;auto&quot;&gt;Finder.java&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.640 13.480L18.640 13.480Q19.560 13.480 20.220 12.840Q20.880 12.200 20.880 11.260Q20.880 10.320 20.220 9.660Q19.560 9 18.620 9Q17.680 9 17.020 9.660Q16.360 10.320 16.360 11.260Q16.360 12.200 17.020 12.840Q17.680 13.480 18.640 13.480ZM23.880 6.640L4.760 6.640L4.760 22.120L23.880 22.120L23.880 6.640ZM5.640 19L5.640 7.480L23 7.480L23 21L18.520 15.880L15.760 19.120L10.240 13.240L5.640 19ZM17.880 4L17.880 1.880L0.120 1.880L0.120 15.360L2.520 15.360L2.520 4L17.880 4Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Mode 1: Standard match&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Exact template matching with the configured similarity threshold. The fast path for stable UI elements.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M0.127 6.839L0.127 0.608L16.929 0.608L16.929 6.839L0.127 6.839ZM7.040 15.116L7.040 8.885L23.873 8.885L23.873 15.116L7.040 15.116ZM17.053 23.393L17.053 17.162L23.873 17.162L23.873 23.393L17.053 23.393Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Mode 2: DPI-aware rescale&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;If the screen DPI differs from the pattern capture DPI, the template is rescaled before matching.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M4.314 0.198L16.746 0.198Q17.838 0.198 18.615 0.975Q19.392 1.752 19.392 2.844L19.392 2.844L19.392 21.156Q19.392 22.248 18.615 23.025Q17.838 23.802 16.746 23.802L16.746 23.802L4.314 23.802Q3.222 23.802 2.445 23.025Q1.668 22.248 1.668 21.156L1.668 21.156L1.668 2.844Q1.668 1.752 2.445 0.975Q3.222 0.198 4.314 0.198L4.314 0.198ZM21.450 15.528L20.568 15.528L21.450 15.528Q21.786 15.528 22.038 15.759Q22.290 15.990 22.332 16.326L22.332 16.326L22.332 18.216Q22.332 18.552 22.122 18.783Q21.912 19.014 21.576 19.098L21.576 19.098L20.568 19.098L20.568 15.528L21.450 15.528ZM21.450 10.824L20.568 10.824L21.450 10.824Q21.786 10.824 22.038 11.034Q22.290 11.244 22.332 11.580L22.332 11.580L22.332 13.470Q22.332 13.806 22.122 14.058Q21.912 14.310 21.576 14.352L21.576 14.352L20.568 14.352L20.568 10.824L21.450 10.824ZM21.450 6.078L20.568 6.078L21.450 6.078Q21.786 6.078 22.038 6.309Q22.290 6.540 22.332 6.876L22.332 6.876L22.332 8.766Q22.332 9.102 22.122 9.333Q21.912 9.564 21.576 9.648L21.576 9.648L20.568 9.648L20.568 6.078L21.450 6.078ZM14.352 4.314L14.352 4.314L6.708 4.314Q6.372 4.314 6.120 4.545Q5.868 4.776 5.826 5.070L5.826 5.070L5.784 7.002Q5.784 7.296 6.015 7.548Q6.246 7.800 6.582 7.842L6.582 7.842L14.352 7.884Q14.688 7.884 14.940 7.653Q15.192 7.422 15.234 7.086L15.234 7.086L15.276 5.196Q15.276 4.818 15.003 4.566Q14.730 4.314 14.352 4.314Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Mode 3: Tolerant blur&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;GaussianBlur applied to both source and target. Tolerates antialiasing and subtle color variations.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Mode 4: Grayscale smart&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Conversion to grayscale before matching. Tolerates color theme changes.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M8.7 10a1 1 0 0 0 1.41 0 1 1 0 0 0 0-1.41l-6.27-6.3a1 1 0 0 0-1.42 1.42ZM21 14a1 1 0 0 0-1 1v3.59L15.44 14A1 1 0 0 0 14 15.44L18.59 20H15a1 1 0 0 0 0 2h6a1 1 0 0 0 .38-.08 1 1 0 0 0 .54-.54A1 1 0 0 0 22 21v-6a1 1 0 0 0-1-1Zm.92-11.38a1 1 0 0 0-.54-.54A1 1 0 0 0 21 2h-6a1 1 0 0 0 0 2h3.59L2.29 20.29a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0L20 5.41V9a1 1 0 0 0 2 0V3a1 1 0 0 0-.08-.38Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Mode 5: Multi-scale brute force&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Last resort. Tries multiple scales (0.5x to 2x) to catch significantly resized elements.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;The code is twenty years old at this point, refined incrementally by the original Sikuli authors at MIT in 2009, by Raimund Hocke from 2010 to 2025 under SikuliX, and now by the OculiX maintainers.&lt;/p&gt;
&lt;p&gt;Performance, in this kind of codebase, is rarely benchmarked from scratch. Everyone assumes it is whatever it has always been. A &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; call takes “some milliseconds”, or “a few hundred milliseconds” on a slow machine, and life goes on.&lt;/p&gt;
&lt;p&gt;So I sat down on a Sunday afternoon to actually measure it. Not because there was a problem. Because the question of how fast it really was had never been answered with a number on the current hardware I had in front of me.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-harness&quot;&gt;The harness&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The setup was deliberately simple. A standalone Java class called &lt;code dir=&quot;auto&quot;&gt;FindTiming&lt;/code&gt;, compiled directly against the OculiX complete-win jar, performing exactly one &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; per JVM invocation, then exiting. A batch script wrapping that class in a loop of fifty separate executions.&lt;/p&gt;
&lt;aside aria-label=&quot;Cold start matters&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; Cold start matters &lt;/p&gt;  &lt;div&gt;&lt;p&gt;Most micro-benchmarks of OpenCV operations run inside a single JVM in a tight loop. This measures the algorithm after the JIT has warmed up, after libraries are loaded, after caches are populated. It produces optimistic numbers with no relationship to what users actually experience. Users run their test suites in CI, where each test typically corresponds to a fresh JVM. Any in-memory cache built during one invocation is lost before the next starts.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;So fifty cold starts. Each one paid the JVM startup cost, the OpenCV library load, the Tesseract OCR engine load, the OculiX framework initialization, then performed exactly one &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; and reported the elapsed time before exiting.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-baseline&quot;&gt;The baseline&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The result, on this five-year-old i3 laptop, was extremely consistent:&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Mean&lt;/td&gt;&lt;td&gt;&lt;strong&gt;502 ms&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Median&lt;/td&gt;&lt;td&gt;502 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Minimum&lt;/td&gt;&lt;td&gt;480 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Maximum&lt;/td&gt;&lt;td&gt;545 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Range&lt;/td&gt;&lt;td&gt;65 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Standard deviation&lt;/td&gt;&lt;td&gt;18 ms&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Half a second to find a small image on a 1920 by 1080 screen. On a modern Intel i7 or Apple Silicon machine the number would be lower, probably by a factor of two or three. On a GitHub Actions standard runner it would be roughly comparable to the i3. On an older corporate desktop running a Citrix client through a VPN it would be slower again.&lt;/p&gt;
&lt;p&gt;Take 500 milliseconds and project it across a test suite:&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Suite scale&lt;/th&gt;&lt;th&gt;Find calls&lt;/th&gt;&lt;th&gt;Pure find time @ 500ms&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Small functional suite&lt;/td&gt;&lt;td&gt;200&lt;/td&gt;&lt;td&gt;100 seconds&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Medium regression suite&lt;/td&gt;&lt;td&gt;1 000&lt;/td&gt;&lt;td&gt;8 min 20 s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Large nightly suite&lt;/td&gt;&lt;td&gt;5 000&lt;/td&gt;&lt;td&gt;41 min 40 s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Enterprise full coverage&lt;/td&gt;&lt;td&gt;20 000&lt;/td&gt;&lt;td&gt;2 h 46 min&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Multiply by the number of suites running per day in a CI environment, multiply by the number of CI minutes billed by the runner provider, and the cost becomes very real.&lt;/p&gt;
&lt;p&gt;That was the baseline. No optimization had been attempted yet. The number was simply the truth about what happened on the metal.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-was-already-in-the-code&quot;&gt;What was already in the code&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Before deciding what to optimize, the right move is always to look at what the code already does. OculiX inherits sixteen years of optimization attempts from the Sikuli and SikuliX lineage. A naive optimizer would re-read the OpenCV documentation and propose to switch to a faster matching algorithm. That would be a beginner mistake.&lt;/p&gt;
&lt;p&gt;The thing to investigate first is whether there is some optimization the existing code already attempts but cannot fully complete in the current configuration. That is almost always where the gains hide.&lt;/p&gt;
&lt;p&gt;A few minutes of grep revealed a symmetric pair of fields and a setting that nobody seems to talk about:&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3.06a1.3 1.3 0 0 0-.06-.27v-.09c-.05-.1-.11-.2-.19-.28l-6-6a1.07 1.07 0 0 0-.28-.19h-.09a.88.88 0 0 0-.33-.11H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V8.94Zm-6-3.53L16.59 8H15a1 1 0 0 1-1-1V5.41ZM18 19a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Image.lastSeen&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Private &lt;code dir=&quot;auto&quot;&gt;Rectangle&lt;/code&gt; field on every &lt;code dir=&quot;auto&quot;&gt;Image&lt;/code&gt; object. Stores the position of the most recent successful match. Paired with &lt;code dir=&quot;auto&quot;&gt;getLastSeen()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;setLastSeen(rect, score)&lt;/code&gt; accessors.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Settings.CheckLastSeen&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Public static boolean, set to &lt;code dir=&quot;auto&quot;&gt;true&lt;/code&gt; by default since at least 2018. Enables the optimization at the framework level.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M0.127 6.839L0.127 0.608L16.929 0.608L16.929 6.839L0.127 6.839ZM7.040 15.116L7.040 8.885L23.873 8.885L23.873 15.116L7.040 15.116ZM17.053 23.393L17.053 17.162L23.873 17.162L23.873 23.393L17.053 23.393Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;checkLastSeenAndCreateFinder&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Private method in &lt;code dir=&quot;auto&quot;&gt;Region.java&lt;/code&gt;. When called, creates a &lt;code dir=&quot;auto&quot;&gt;Finder&lt;/code&gt; restricted to the small rectangle around the previous match, falling back to full-screen only if needed.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;The intent of these three pieces is clear once you piece them together. After a successful &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt;, the rectangle of the match is stored in the &lt;code dir=&quot;auto&quot;&gt;Image&lt;/code&gt; object’s &lt;code dir=&quot;auto&quot;&gt;lastSeen&lt;/code&gt; field. On the next call to &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; for the same image, if &lt;code dir=&quot;auto&quot;&gt;Settings.CheckLastSeen&lt;/code&gt; is true and &lt;code dir=&quot;auto&quot;&gt;lastSeen&lt;/code&gt; is non-null, the code creates a &lt;code dir=&quot;auto&quot;&gt;Finder&lt;/code&gt; restricted to that small rectangle and tries to match there first. Only if the small-region match fails does it fall back to a full-screen scan.&lt;/p&gt;
&lt;p&gt;This is a classic spatial memoization pattern, well-known in computer vision. Sikuli implemented it correctly, a long time ago.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-pattern-works-until-it-doesnt&quot;&gt;The pattern works, until it doesn’t&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The optimization works beautifully inside a single JVM session. If you run a script that performs &lt;code dir=&quot;auto&quot;&gt;screen.find(&quot;submit_button.png&quot;)&lt;/code&gt; ten times in a row, the first call pays the full-screen scan cost, but the next nine calls find the image almost instantly through &lt;code dir=&quot;auto&quot;&gt;checkLastSeen&lt;/code&gt;. The cache hit rate is essentially 100 percent on stable UIs.&lt;/p&gt;
&lt;p&gt;There is, however, a subtle but critical limitation.&lt;/p&gt;
&lt;aside aria-label=&quot;The gap&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The gap &lt;/p&gt;  &lt;div&gt;&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;lastSeen&lt;/code&gt; field lives in the in-memory representation of the &lt;code dir=&quot;auto&quot;&gt;Image&lt;/code&gt; object. It is a Java field. It exists as long as the JVM process exists, and not a millisecond longer. When the JVM exits, the &lt;code dir=&quot;auto&quot;&gt;Image&lt;/code&gt; object is garbage collected, the &lt;code dir=&quot;auto&quot;&gt;lastSeen&lt;/code&gt; field disappears, and the next JVM that starts up begins from a blank slate.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;This is exactly what our benchmark exposed. Fifty separate JVM invocations, fifty &lt;code dir=&quot;auto&quot;&gt;Image&lt;/code&gt; objects with &lt;code dir=&quot;auto&quot;&gt;lastSeen&lt;/code&gt; always equal to &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt; at the moment of the &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; call, fifty full-screen scans. The &lt;code dir=&quot;auto&quot;&gt;checkLastSeen&lt;/code&gt; optimization was active and present in the code throughout, but it had no input to work with. The cache was empty because the cache lived inside a process that died right after building it.&lt;/p&gt;
&lt;p&gt;This is the core observation. The existing optimization was correct in design. It was simply unable to bridge the gap between JVM invocations. In a typical test environment, where each test is its own process, the optimization never had a chance to engage.&lt;/p&gt;
&lt;p&gt;The code that solved the problem was already there, written long before this benchmark was performed, in a careful and well-tested form. The missing piece was not a clever algorithm. It was a way to keep the optimization’s input alive across process boundaries. A storage problem, not a computation problem.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-missing-piece-persistence&quot;&gt;The missing piece: persistence&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Once the gap was identified, the question became: how do you persist &lt;code dir=&quot;auto&quot;&gt;Image.lastSeen&lt;/code&gt; between JVM invocations? Several candidate approaches surfaced, each with their own trade-offs.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Approach&lt;/th&gt;&lt;th&gt;Description&lt;/th&gt;&lt;th&gt;Drawbacks&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Sidecar file&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Write &lt;code dir=&quot;auto&quot;&gt;foo.png.position&lt;/code&gt; next to each PNG&lt;/td&gt;&lt;td&gt;Two files to commit, risk of desynchronization, clutter&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Central project file&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Single &lt;code dir=&quot;auto&quot;&gt;.oculix-positions.toml&lt;/code&gt; at project root&lt;/td&gt;&lt;td&gt;Linear lookup cost, merge conflicts in parallel CI, full file rewrite per change&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;PNG ancillary chunk&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Embed the position metadata inside the PNG itself&lt;/td&gt;&lt;td&gt;Requires writing PNG-aware code, but standard since 1996&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The PNG ancillary chunk option won, for reasons that became clearer as we explored the constraint of CI environments.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;png-ancillary-chunks-the-underused-w3c-standard&quot;&gt;PNG ancillary chunks: the underused W3C standard&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The PNG file format, standardized by the W3C in 1996, is a structured container made of a fixed signature followed by a sequence of chunks. Each chunk has a four-byte length, a four-byte type, a variable-length data payload, and a four-byte CRC32 of the type and data.&lt;/p&gt;
&lt;p&gt;The PNG standard distinguishes between critical chunks (IHDR, IDAT, IEND, and others), which are mandatory for decoding the image, and ancillary chunks, which are optional and can be safely ignored by decoders that do not recognize them.&lt;/p&gt;
&lt;aside aria-label=&quot;PNG chunk casing convention&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; PNG chunk casing convention &lt;/p&gt;  &lt;div&gt;&lt;p&gt;A chunk type is four ASCII characters. The case of each character carries meaning:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;First character&lt;/strong&gt;: uppercase = critical (mandatory), lowercase = ancillary (optional)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Second character&lt;/strong&gt;: uppercase = public standard, lowercase = private&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Third character&lt;/strong&gt;: reserved, must always be uppercase&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fourth character&lt;/strong&gt;: lowercase = safe to copy when editing, uppercase = strip on unrecognized&lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;This 30-year-old convention is respected by every serious image editor: Adobe, GIMP, Photoshop, Affinity, ImageMagick. Unknown ancillary chunks are preserved unless explicitly stripped.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;We chose the type code &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt; for our chunk:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lowercase &lt;code dir=&quot;auto&quot;&gt;o&lt;/code&gt;: ancillary, not critical&lt;/li&gt;
&lt;li&gt;Uppercase &lt;code dir=&quot;auto&quot;&gt;P&lt;/code&gt;: private to OculiX&lt;/li&gt;
&lt;li&gt;Uppercase &lt;code dir=&quot;auto&quot;&gt;L&lt;/code&gt;: reserved bit&lt;/li&gt;
&lt;li&gt;Lowercase &lt;code dir=&quot;auto&quot;&gt;x&lt;/code&gt;: safe to copy&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Decoded: an optional, private, safe-to-copy chunk identified by &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt;. Other PNG tools encountering an OculiX-modified file see something they do not recognize, preserve it untouched on save, and ignore it on load. Compatibility is total.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-format-of-the-oplx-chunk&quot;&gt;The format of the oPLx chunk&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The internal layout of the &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt; chunk’s data payload is deliberately small. The goal was a fixed-size, fast-to-parse, easy-to-debug binary structure. The total payload is exactly 34 bytes:&lt;/p&gt;




































































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Offset&lt;/th&gt;&lt;th&gt;Size&lt;/th&gt;&lt;th&gt;Type&lt;/th&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Notes&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;0&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;ASCII&lt;/td&gt;&lt;td&gt;Magic &lt;code dir=&quot;auto&quot;&gt;OPL\0&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Redundant identifier, prevents misinterpretation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;uint16 (BE)&lt;/td&gt;&lt;td&gt;Version&lt;/td&gt;&lt;td&gt;Currently &lt;code dir=&quot;auto&quot;&gt;1&lt;/code&gt;. Bump on breaking format change.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;int32 (BE)&lt;/td&gt;&lt;td&gt;X&lt;/td&gt;&lt;td&gt;Pixel coordinate, last successful match&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;10&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;int32 (BE)&lt;/td&gt;&lt;td&gt;Y&lt;/td&gt;&lt;td&gt;Pixel coordinate, last successful match&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;14&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;int32 (BE)&lt;/td&gt;&lt;td&gt;Width&lt;/td&gt;&lt;td&gt;Match rectangle width, pixels&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;18&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;int32 (BE)&lt;/td&gt;&lt;td&gt;Height&lt;/td&gt;&lt;td&gt;Match rectangle height, pixels&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;22&lt;/td&gt;&lt;td&gt;8&lt;/td&gt;&lt;td&gt;int64 (BE)&lt;/td&gt;&lt;td&gt;Timestamp&lt;/td&gt;&lt;td&gt;UNIX epoch milliseconds, last update&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;30&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;int32 (BE)&lt;/td&gt;&lt;td&gt;Run count&lt;/td&gt;&lt;td&gt;Total successful matches since file creation&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Total: 34 bytes of payload, plus the standard 12 bytes of PNG chunk framing (4 bytes length, 4 bytes type, 4 bytes CRC32). Each pattern gains 46 bytes of metadata embedded in its PNG file. For a project with 500 patterns, this represents 23 kilobytes of additional space across the entire pattern library. Effectively negligible.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;design-choices-in-this-format&quot;&gt;Design choices in this format&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A few decisions deserve commentary.&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M0.127 6.839L0.127 0.608L16.929 0.608L16.929 6.839L0.127 6.839ZM7.040 15.116L7.040 8.885L23.873 8.885L23.873 15.116L7.040 15.116ZM17.053 23.393L17.053 17.162L23.873 17.162L23.873 23.393L17.053 23.393Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Big-endian byte order&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Non-negotiable. The PNG standard mandates big-endian for all multi-byte integers in chunk fields. Following the same convention inside our payload simplifies parsing and removes confusion with future tooling.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M21 14h-1V7a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v7H3a1 1 0 0 0-1 1v2a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-2a1 1 0 0 0-1-1ZM6 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v7H6V7Zm14 10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-1h16v1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;32-bit signed for coordinates&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Generous. 16-bit unsigned would have sufficed for single-screen resolutions. We chose 32 bits to leave room for multi-monitor setups where coordinates extend into negative space and tens of thousands of pixels.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3.06a1.3 1.3 0 0 0-.06-.27v-.09c-.05-.1-.11-.2-.19-.28l-6-6a1.07 1.07 0 0 0-.28-.19h-.09a.88.88 0 0 0-.33-.11H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V8.94Zm-6-3.53L16.59 8H15a1 1 0 0 1-1-1V5.41ZM18 19a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;64-bit timestamp&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Standard UNIX epoch milliseconds. No bytes saved here. Audit trails that span years require room.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Run counter&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Allows detecting dead patterns (counter at zero), unstable patterns (high counter on young file), and locked-in patterns (counter grows without timestamp updates).&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;The chunk is plaintext at this stage. The integrity check is the standard CRC32 that PNG mandates at the end of every chunk; it catches accidental corruption but is not cryptographically strong.&lt;/p&gt;
&lt;aside aria-label=&quot;Future versions&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; Future versions &lt;/p&gt;  &lt;div&gt;&lt;p&gt;A future version with the magic version field bumped will add an Ed25519 signature for environments that require tamper-evident position trails. For now, the chunk relies on the same trust model as the rest of the source code: git history, code review, and the integrity guarantees of the version control system.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;implementation-three-micro-modifications&quot;&gt;Implementation: three micro-modifications&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The actual change inside OculiX comes down to three modifications in two existing files, plus one new utility class. Total addition: roughly 250 lines of Java. No external dependencies. No new Maven coordinates. The standard JDK classes &lt;code dir=&quot;auto&quot;&gt;DataInputStream&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;DataOutputStream&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;ByteBuffer&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;java.util.zip.CRC32&lt;/code&gt; cover all the needs.&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3.06a1.3 1.3 0 0 0-.06-.27v-.09c-.05-.1-.11-.2-.19-.28l-6-6a1.07 1.07 0 0 0-.28-.19h-.09a.88.88 0 0 0-.33-.11H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V8.94Zm-6-3.53L16.59 8H15a1 1 0 0 1-1-1V5.41ZM18 19a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;1. Image.load() reads the chunk&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;After &lt;code dir=&quot;auto&quot;&gt;ImageIO.read()&lt;/code&gt; decodes the pixels, a separate streaming read parses the PNG chunks, locates &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt;, and calls &lt;code dir=&quot;auto&quot;&gt;setLastSeen(rect, 1.0)&lt;/code&gt; on the current &lt;code dir=&quot;auto&quot;&gt;Image&lt;/code&gt; instance.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M8.7 10a1 1 0 0 0 1.41 0 1 1 0 0 0 0-1.41l-6.27-6.3a1 1 0 0 0-1.42 1.42ZM21 14a1 1 0 0 0-1 1v3.59L15.44 14A1 1 0 0 0 14 15.44L18.59 20H15a1 1 0 0 0 0 2h6a1 1 0 0 0 .38-.08 1 1 0 0 0 .54-.54A1 1 0 0 0 22 21v-6a1 1 0 0 0-1-1Zm.92-11.38a1 1 0 0 0-.54-.54A1 1 0 0 0 21 2h-6a1 1 0 0 0 0 2h3.59L2.29 20.29a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0L20 5.41V9a1 1 0 0 0 2 0V3a1 1 0 0 0-.08-.38Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;2. doCheckLastSeenAndCreateFinder expands the search&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Instead of creating a &lt;code dir=&quot;auto&quot;&gt;Region&lt;/code&gt; exactly the size of the previous match, it now creates a search box 2.5x larger, clamped to screen bounds. Tolerates UI drift between runs.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;3. find() writes the chunk after match&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;After updating in-memory &lt;code dir=&quot;auto&quot;&gt;lastSeen&lt;/code&gt;, the chunk in the PNG file is streamed through a temp file and atomic-renamed. The position persists to disk.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;modification-1-imageload-reads-the-chunk&quot;&gt;Modification 1: Image.load() reads the chunk&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;In &lt;code dir=&quot;auto&quot;&gt;Image.java&lt;/code&gt;, the existing &lt;code dir=&quot;auto&quot;&gt;load&lt;/code&gt; method reads the PNG file from disk into a &lt;code dir=&quot;auto&quot;&gt;BufferedImage&lt;/code&gt; using &lt;code dir=&quot;auto&quot;&gt;ImageIO.read&lt;/code&gt;. This call decodes only the image data (the &lt;code dir=&quot;auto&quot;&gt;IDAT&lt;/code&gt; chunks). It does not parse other chunks. Our addition opens the same file separately, in streaming mode, walks through its chunks until it finds the &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt; chunk if present, parses the position metadata, and calls &lt;code dir=&quot;auto&quot;&gt;setLastSeen(rect, 1.0)&lt;/code&gt; on the current &lt;code dir=&quot;auto&quot;&gt;Image&lt;/code&gt; instance.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// In Image.java, after ImageIO.read(fileURL) at line 1018:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;try&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;File&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;pngFile&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;File&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;fileURL&lt;/span&gt;&lt;span&gt;.toURI&lt;/span&gt;&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;[] &lt;/span&gt;&lt;span&gt;chunk&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PngChunk&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;pngFile, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;oPLx&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (chunk &lt;/span&gt;&lt;span&gt;!=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;chunk&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;34&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;ByteBuffer&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ByteBuffer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;wrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;chunk&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;[] &lt;/span&gt;&lt;span&gt;magic&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;4&lt;/span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;magic&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (magic[&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;] &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;O&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; magic[&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;] &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;P&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; magic[&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;] &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;L&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; magic[&lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;] &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getShort&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getInt&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cy&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getInt&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getInt&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ch&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getInt&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;setLastSeen&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; Rectangle&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;cx, cy, cw, ch&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;1.0&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;catch&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Exception&lt;/span&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ignored&lt;/span&gt;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The streaming parser is deliberately optimized for the common case where the chunk is present and is one of the first non-critical chunks in the file. It skips the PNG signature (8 bytes), then enters a loop: read the chunk length, read the four-byte chunk type, compare it to &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt;, return the payload if matched, return null if &lt;code dir=&quot;auto&quot;&gt;IEND&lt;/code&gt; is reached, or skip the chunk data and CRC and continue otherwise.&lt;/p&gt;
&lt;aside aria-label=&quot;No bytes wasted&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; No bytes wasted &lt;/p&gt;  &lt;div&gt;&lt;p&gt;On a small PNG (a typical pattern of one to ten kilobytes), this parsing completes in well under one millisecond. On a large PNG, it remains under two milliseconds because the parser never reads the IDAT pixel data. It only walks the chunk headers.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;This is the architectural detail that matters most. The chunk reading is a strict addition that fills a gap in the existing system. It does not replace anything. It does not modify the contract of any existing method. If the chunk is absent (a legacy PNG that has never been processed by OculiX, a PNG whose chunk was stripped by an aggressive optimizer, a PNG generated by an external tool), the code falls through to &lt;code dir=&quot;auto&quot;&gt;lastSeen&lt;/code&gt; being null, which is exactly the situation the existing codebase has handled for sixteen years. The fall-through path is the cold-start path. It still works. It is just slower.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;modification-2-dochecklastseenandcreatefinder-expands-the-search&quot;&gt;Modification 2: doCheckLastSeenAndCreateFinder expands the search&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;In &lt;code dir=&quot;auto&quot;&gt;Region.java&lt;/code&gt;, the existing method creates a small &lt;code dir=&quot;auto&quot;&gt;Region&lt;/code&gt; exactly the size of the previous match rectangle. This works well for in-session use, where the image has just been matched at the exact same position. It works less well for cross-process use, where the UI may have drifted slightly between runs.&lt;/p&gt;
&lt;p&gt;Our modification expands the search rectangle by a factor of 2.5 around the stored center, clamped to the screen bounds.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Replace at line 2891:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Rectangle&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ls&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;img&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getLastSeen&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt;) (&lt;/span&gt;&lt;span&gt;ls&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;width&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;2.5&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sh&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt;) (&lt;/span&gt;&lt;span&gt;ls&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;height&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;2.5&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (sw &lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;screen&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt;) sw &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;screen&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (sh &lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;screen&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt;) sh &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;screen&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ls&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ls&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;width&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cy&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ls&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ls&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;height&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; cx &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt; sw &lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sy&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; cy &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt; sh &lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Translate to stay inside screen, do not truncate&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (sx &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;) sx &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (sy &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;) sy &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (sx &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; sw &lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;screen&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt;) sx &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;screen&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt; sw;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (sy &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; sh &lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;screen&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt;) sy &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;screen&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt; sh;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Region&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Region&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;sx, sy, sw, sh&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The 2.5 multiplier was chosen empirically:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Multiplier&lt;/th&gt;&lt;th&gt;Effect&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;1.5x&lt;/td&gt;&lt;td&gt;Occasionally misses drifted patterns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2.0x&lt;/td&gt;&lt;td&gt;Marginal improvement, still occasional misses&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;2.5x&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Sweet spot: tolerates drift without ambiguity&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3.0x&lt;/td&gt;&lt;td&gt;No safety improvement, starts introducing ambiguity on dense UIs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;4.0x&lt;/td&gt;&lt;td&gt;Multiple visually similar elements get reached, confusing the matcher&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The clamping logic preserves the search box size when the pattern is near a screen edge. A pattern at (0, 1022) still gets a full 250 by 145 search box, just positioned at (0, 935) instead of being centered. The match rectangle for the original pattern still fits inside the search box.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;modification-3-find-writes-the-chunk-after-a-successful-match&quot;&gt;Modification 3: find() writes the chunk after a successful match&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;In &lt;code dir=&quot;auto&quot;&gt;Region.java&lt;/code&gt;, the existing &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; method already calls &lt;code dir=&quot;auto&quot;&gt;img.setLastSeen(lastMatch.getRect(), lastMatch.getScore())&lt;/code&gt; after a successful match. This updates the in-memory &lt;code dir=&quot;auto&quot;&gt;lastSeen&lt;/code&gt; field. Our addition extends this call with a write to the PNG file’s &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt; chunk.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// At line 2284 of Region.find(), after setLastSeen:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;img&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;setLastSeen&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;lastMatch&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getRect&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;lastMatch&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getScore&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// New: persist to PNG chunk&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;try&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;File&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;pngFile&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;File&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;img&lt;/span&gt;&lt;span&gt;.getFileURL&lt;/span&gt;&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.toURI&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;ByteBuffer&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ByteBuffer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;allocate&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;34&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;put&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;O&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;put&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;P&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;put&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;L&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;put&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;putShort&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;short&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;Rectangle&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;lastMatch&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getRect&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;putInt&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;putInt&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;putInt&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;width&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;putInt&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;height&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;putLong&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;System&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;currentTimeMillis&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;putInt&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;getRunCount&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;img&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;PngChunk&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;write&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;pngFile, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;oPLx&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;array&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;catch&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Exception&lt;/span&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ignored&lt;/span&gt;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The chunk-writing logic streams through the PNG file once, copying every chunk through to a temporary file, replacing the &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt; chunk in place if it already exists, or inserting a fresh &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt; chunk before the &lt;code dir=&quot;auto&quot;&gt;IEND&lt;/code&gt; marker if not. At the end, the temporary file replaces the original via an atomic file system rename.&lt;/p&gt;
&lt;p&gt;This streaming approach is more complex than reading the whole file into memory, modifying the byte array, and writing the result back, but it is meaningfully more robust:&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Property&lt;/th&gt;&lt;th&gt;In-memory&lt;/th&gt;&lt;th&gt;Streaming (chosen)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Memory cost&lt;/td&gt;&lt;td&gt;Proportional to PNG size&lt;/td&gt;&lt;td&gt;Constant (~8 KB buffer)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Crash safety&lt;/td&gt;&lt;td&gt;Risk of partial write&lt;/td&gt;&lt;td&gt;Atomic rename: old or new, never half&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cost on small PNG&lt;/td&gt;&lt;td&gt;&amp;#x3C; 1 ms&lt;/td&gt;&lt;td&gt;1-2 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cost on large PNG (1 MB+)&lt;/td&gt;&lt;td&gt;20-50 ms + allocation&lt;/td&gt;&lt;td&gt;4-8 ms, no allocation spike&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;div&gt;&lt;h3 id=&quot;the-pngchunk-utility-class&quot;&gt;The PngChunk utility class&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Reading and writing PNG chunks does not require an external library. The format is simple enough that a 200-line utility class handles both operations with zero dependencies. The class exposes two methods:&lt;/p&gt;
&lt;starlight-tabs&gt; &lt;div&gt; &lt;ul role=&quot;tablist&quot;&gt; &lt;li role=&quot;presentation&quot;&gt; &lt;a role=&quot;tab&quot; href=&quot;#tab-panel-6&quot; id=&quot;tab-6&quot; aria-selected=&quot;true&quot; tabindex=&quot;0&quot;&gt;  Read API &lt;/a&gt; &lt;/li&gt;&lt;li role=&quot;presentation&quot;&gt; &lt;a role=&quot;tab&quot; href=&quot;#tab-panel-7&quot; id=&quot;tab-7&quot; aria-selected=&quot;false&quot; tabindex=&quot;-1&quot;&gt;  Write API &lt;/a&gt; &lt;/li&gt;&lt;li role=&quot;presentation&quot;&gt; &lt;a role=&quot;tab&quot; href=&quot;#tab-panel-8&quot; id=&quot;tab-8&quot; aria-selected=&quot;false&quot; tabindex=&quot;-1&quot;&gt;  Constants &lt;/a&gt; &lt;/li&gt; &lt;/ul&gt; &lt;/div&gt; &lt;div id=&quot;tab-panel-6&quot; aria-labelledby=&quot;tab-6&quot; role=&quot;tabpanel&quot;&gt; &lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;[] &lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;File&lt;/span&gt;&lt;span&gt; png, &lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt; type&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; throws &lt;/span&gt;&lt;span&gt;IOException&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;p&gt;Returns the payload bytes if the chunk is found, &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt; otherwise. The read uses streaming &lt;code dir=&quot;auto&quot;&gt;DataInputStream&lt;/code&gt;, with &lt;code dir=&quot;auto&quot;&gt;skip()&lt;/code&gt; to bypass non-target chunks. Exits as soon as the target chunk is located.&lt;/p&gt; &lt;/div&gt;&lt;div id=&quot;tab-panel-7&quot; aria-labelledby=&quot;tab-7&quot; role=&quot;tabpanel&quot; hidden&gt; &lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;write&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;File&lt;/span&gt;&lt;span&gt; png, &lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt; type, &lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;[] payload&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; throws &lt;/span&gt;&lt;span&gt;IOException&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;p&gt;Streams the source PNG to a temp file, replacing or inserting the chunk, then atomic-renames the temp file over the original. CRC32 computed via &lt;code dir=&quot;auto&quot;&gt;java.util.zip.CRC32&lt;/code&gt; (hardware-accelerated on modern CPUs).&lt;/p&gt; &lt;/div&gt;&lt;div id=&quot;tab-panel-8&quot; aria-labelledby=&quot;tab-8&quot; role=&quot;tabpanel&quot; hidden&gt; &lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;final&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;[] &lt;/span&gt;&lt;span&gt;SIGNATURE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;0x89&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;P&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;N&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;G&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;\r&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;\n&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;0x1a&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;\n&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;final&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;byte&lt;/span&gt;&lt;span&gt;[] &lt;/span&gt;&lt;span&gt;IEND_BYTES&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;I&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;E&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;N&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;D&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;p&gt;The PNG file signature is 8 well-known bytes. The IEND chunk type terminates every PNG.&lt;/p&gt; &lt;/div&gt;  &lt;/starlight-tabs&gt;  
&lt;p&gt;The total code in &lt;code dir=&quot;auto&quot;&gt;PngChunk.java&lt;/code&gt; is 218 lines including comments, blank lines, and the class declaration. The file has no imports outside the &lt;code dir=&quot;auto&quot;&gt;java.io&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;java.nio&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;java.util.Arrays&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;java.util.zip.CRC32&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;java.nio.charset.StandardCharsets&lt;/code&gt; packages, all standard JDK.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;benchmark-methodology&quot;&gt;Benchmark methodology&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A benchmark is only useful if its methodology is described in enough detail that someone else can reproduce it.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;hardware-and-runtime&quot;&gt;Hardware and runtime&lt;/h3&gt;&lt;/div&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Item&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;CPU&lt;/td&gt;&lt;td&gt;Intel Core i3-7100 (2 cores, 4 threads, 3.9 GHz)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;RAM&lt;/td&gt;&lt;td&gt;8 GB DDR4-2400&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Storage&lt;/td&gt;&lt;td&gt;NVMe SSD&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;OS&lt;/td&gt;&lt;td&gt;Windows 10 build 19045&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Java&lt;/td&gt;&lt;td&gt;OpenJDK 25 from Eclipse Temurin&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;OculiX&lt;/td&gt;&lt;td&gt;3.0.3 release, feature branch with modifications&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Screen&lt;/td&gt;&lt;td&gt;1920 × 1080, no DPI scaling&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Pattern&lt;/td&gt;&lt;td&gt;Windows search bar fragment, 12 × 58 pixels, at (0, 1022)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;div&gt;&lt;h3 id=&quot;harness-logic&quot;&gt;Harness logic&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Each benchmark run consisted of fifty independent JVM invocations, each:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Starting a fresh process&lt;/li&gt;
&lt;li&gt;Loading OculiX and its native dependencies&lt;/li&gt;
&lt;li&gt;Performing exactly one &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; call against the screen&lt;/li&gt;
&lt;li&gt;Printing the elapsed time in milliseconds to standard output&lt;/li&gt;
&lt;li&gt;Exiting&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The elapsed time was measured using &lt;code dir=&quot;auto&quot;&gt;System.nanoTime()&lt;/code&gt; immediately before and after the &lt;code dir=&quot;auto&quot;&gt;screen.find(pattern)&lt;/code&gt; call. This excludes JVM startup, library loading, and framework initialization. It includes only the find operation itself, including the screen capture inside the find.&lt;/p&gt;
&lt;p&gt;A separate post-processing class read the fifty timing lines from the captured log and computed: arithmetic mean, median, minimum, maximum, range, and standard deviation.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;two-scenarios-measured&quot;&gt;Two scenarios measured&lt;/h3&gt;&lt;/div&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Scenario&lt;/th&gt;&lt;th&gt;Initial state of PNG&lt;/th&gt;&lt;th&gt;Expected behavior&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Baseline&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;No &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt; chunk&lt;/td&gt;&lt;td&gt;All 50 runs in full-screen scan&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Optimized&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt; chunk pre-written&lt;/td&gt;&lt;td&gt;All 50 runs in small-region scan&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Both scenarios were measured cold-start (50 separate JVM invocations) to ensure the comparison reflects the CI environment.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The numbers are the headline of this post. Let me state them precisely.&lt;/p&gt;





















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th&gt;Baseline (FULL)&lt;/th&gt;&lt;th&gt;Optimized (ROI)&lt;/th&gt;&lt;th&gt;Improvement&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;n&lt;/td&gt;&lt;td&gt;50&lt;/td&gt;&lt;td&gt;50&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Mean&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;502 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;77.7 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;×6.46&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Median&lt;/td&gt;&lt;td&gt;502 ms&lt;/td&gt;&lt;td&gt;77 ms&lt;/td&gt;&lt;td&gt;×6.5&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Minimum&lt;/td&gt;&lt;td&gt;480 ms&lt;/td&gt;&lt;td&gt;63 ms&lt;/td&gt;&lt;td&gt;×7.6&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Maximum&lt;/td&gt;&lt;td&gt;545 ms&lt;/td&gt;&lt;td&gt;113 ms&lt;/td&gt;&lt;td&gt;×4.8&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Range&lt;/td&gt;&lt;td&gt;65 ms&lt;/td&gt;&lt;td&gt;50 ms&lt;/td&gt;&lt;td&gt;comparable&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Standard deviation&lt;/td&gt;&lt;td&gt;18 ms&lt;/td&gt;&lt;td&gt;11 ms&lt;/td&gt;&lt;td&gt;tighter&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Speedup factor on the find call alone: 6.46.&lt;/strong&gt;&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;observations-on-these-numbers&quot;&gt;Observations on these numbers&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Low variance in both conditions&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Standard deviation is 4% of mean in baseline, 14% in optimized. Neither is noisy enough to require repeated measurement.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 16a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm10.67 1.47-8.05-14a3 3 0 0 0-5.24 0l-8 14A3 3 0 0 0 3.94 22h16.12a3 3 0 0 0 2.61-4.53Zm-1.73 2a1 1 0 0 1-.88.51H3.94a1 1 0 0 1-.88-.51 1 1 0 0 1 0-1l8-14a1 1 0 0 1 1.78 0l8.05 14a1 1 0 0 1 .05 1.02v-.02ZM12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Optimized scenario has a floor&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Roughly 60-70 ms cannot be reduced further by this technique. Dominated by screen capture (20-40 ms) and OpenCV setup (20-40 ms).&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M0.127 6.839L0.127 0.608L16.929 0.608L16.929 6.839L0.127 6.839ZM7.040 15.116L7.040 8.885L23.873 8.885L23.873 15.116L7.040 15.116ZM17.053 23.393L17.053 17.162L23.873 17.162L23.873 23.393L17.053 23.393Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Speedup depends on pattern size&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Small patterns (under 50×50 px) give the largest speedup. Large patterns (300×300+ px) give a smaller speedup because the 2.5x search region itself becomes substantial.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path fill-rule=&quot;evenodd&quot; d=&quot;M1.44 8.855v-.001l3.527-3.516c.34-.344.802-.541 1.285-.548h6.649l.947-.947c3.07-3.07 6.207-3.072 7.62-2.868a1.821 1.821 0 0 1 1.557 1.557c.204 1.413.203 4.55-2.868 7.62l-.946.946v6.649a1.845 1.845 0 0 1-.549 1.286l-3.516 3.528a1.844 1.844 0 0 1-3.11-.944l-.858-4.275-4.52-4.52-2.31-.463-1.964-.394A1.847 1.847 0 0 1 .98 10.693a1.843 1.843 0 0 1 .46-1.838Zm5.379 2.017-3.873-.776L6.32 6.733h4.638l-4.14 4.14Zm8.403-5.655c2.459-2.46 4.856-2.463 5.89-2.33.134 1.035.13 3.432-2.329 5.891l-6.71 6.71-3.561-3.56 6.71-6.711Zm-1.318 15.837-.776-3.873 4.14-4.14v4.639l-3.364 3.374Z&quot; clip-rule=&quot;evenodd&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M9.318 18.345a.972.972 0 0 0-1.86-.561c-.482 1.435-1.687 2.204-2.934 2.619a8.22 8.22 0 0 1-1.23.302c.062-.365.157-.79.303-1.229.415-1.247 1.184-2.452 2.62-2.935a.971.971 0 1 0-.62-1.842c-.12.04-.236.084-.35.13-2.02.828-3.012 2.588-3.493 4.033a10.383 10.383 0 0 0-.51 2.845l-.001.016v.063c0 .536.434.972.97.972H2.24a7.21 7.21 0 0 0 .878-.065c.527-.063 1.248-.19 2.02-.447 1.445-.48 3.205-1.472 4.033-3.494a5.828 5.828 0 0 0 .147-.407Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Faster hardware preserves ratio&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;On modern CPUs, absolute timings shrink but the relative speedup factor stays at ×6-8. The overhead floor is proportionally less significant.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;hardware-projection&quot;&gt;Hardware projection&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;If we extrapolate to other typical hardware:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Hardware&lt;/th&gt;&lt;th&gt;Baseline (FULL)&lt;/th&gt;&lt;th&gt;Optimized (ROI)&lt;/th&gt;&lt;th&gt;Speedup&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;i3 7th gen (measured)&lt;/td&gt;&lt;td&gt;502 ms&lt;/td&gt;&lt;td&gt;77 ms&lt;/td&gt;&lt;td&gt;×6.5&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;i7-13700K (projected)&lt;/td&gt;&lt;td&gt;~150-180 ms&lt;/td&gt;&lt;td&gt;~22-25 ms&lt;/td&gt;&lt;td&gt;×8&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Apple M2/M3 (projected)&lt;/td&gt;&lt;td&gt;~80-120 ms&lt;/td&gt;&lt;td&gt;~10-15 ms&lt;/td&gt;&lt;td&gt;×8&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;GitHub Actions standard runner (projected)&lt;/td&gt;&lt;td&gt;~200-250 ms&lt;/td&gt;&lt;td&gt;~28-35 ms&lt;/td&gt;&lt;td&gt;×7&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;div&gt;&lt;h2 id=&quot;what-this-means-for-ci-suites&quot;&gt;What this means for CI suites&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The benchmark measures one &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; operation in isolation. A real-world test suite performs many operations, each containing at least one &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt;. Translating the per-operation speedup into a suite-level wall-clock improvement requires a few assumptions.&lt;/p&gt;
&lt;p&gt;Consider a moderately sized regression suite: 100 test cases, each performing 20 visual interactions on average, for &lt;strong&gt;2000 total &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; calls&lt;/strong&gt;.&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Scenario&lt;/th&gt;&lt;th&gt;Time per find&lt;/th&gt;&lt;th&gt;Total find time&lt;/th&gt;&lt;th&gt;Savings&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Baseline (i3)&lt;/td&gt;&lt;td&gt;500 ms&lt;/td&gt;&lt;td&gt;16 min 40 s&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Optimized (i3)&lt;/td&gt;&lt;td&gt;77 ms&lt;/td&gt;&lt;td&gt;2 min 34 s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;14 min 6 s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Optimized (modern i7)&lt;/td&gt;&lt;td&gt;22 ms&lt;/td&gt;&lt;td&gt;44 seconds&lt;/td&gt;&lt;td&gt;&lt;strong&gt;15 min 56 s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;div&gt;&lt;h3 id=&quot;economic-projection&quot;&gt;Economic projection&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;If the suite runs once per pull request on a CI runner billed at $0.008 per minute (the rough GitHub Actions standard runner cost):&lt;/p&gt;

















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Activity&lt;/th&gt;&lt;th&gt;Cost per run (baseline)&lt;/th&gt;&lt;th&gt;Cost per run (optimized)&lt;/th&gt;&lt;th&gt;Annual saving (50 PR/day, 250 days)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Suite execution&lt;/td&gt;&lt;td&gt;$0.13&lt;/td&gt;&lt;td&gt;$0.02&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~$1 375 per suite&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;For organizations running multiple suites in parallel across many projects, the saving compounds quickly.&lt;/p&gt;
&lt;aside aria-label=&quot;The non-cost benefit&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The non-cost benefit &lt;/p&gt;  &lt;div&gt;&lt;p&gt;The wall-clock improvement also changes how developers interact with the suite. A 17-minute suite that becomes a 3-minute suite invites local execution before push. A 17-minute loop discourages it. Team behavior shifts when the cost shifts.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;div&gt;&lt;h3 id=&quot;two-caveats&quot;&gt;Two caveats&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M12 16a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm10.67 1.47-8.05-14a3 3 0 0 0-5.24 0l-8 14A3 3 0 0 0 3.94 22h16.12a3 3 0 0 0 2.61-4.53Zm-1.73 2a1 1 0 0 1-.88.51H3.94a1 1 0 0 1-.88-.51 1 1 0 0 1 0-1l8-14a1 1 0 0 1 1.78 0l8.05 14a1 1 0 0 1 .05 1.02v-.02ZM12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Find is not the only cost&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;The speedup applies to find time only. The full suite also contains network calls, server processing, browser rendering, mouse and keyboard events, and waits. A suite where finds dominate (visual regression, end-to-end smoke) sees larger wall-clock improvement than a suite waiting on slow back-ends.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Optimization rewards stability&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;Teams that regenerate patterns frequently see the chunk reset on each regeneration. The first run after regeneration pays the full-screen scan cost. The optimization rewards UI stability and frequent test execution — both characteristics of mature codebases.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;why-this-survives-git-clone-and-ci-runners&quot;&gt;Why this survives git clone and CI runners&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The structural argument for embedding the position in the PNG itself, rather than in a sidecar file or a central index, is best expressed by considering the CI lifecycle.&lt;/p&gt;
&lt;p&gt;A CI build typically starts from a clean state. A fresh container is provisioned, the repository is cloned from origin, dependencies are installed, the build runs, the tests run, the artifacts are collected, and the container is destroyed. There is no persistent state between builds. There is no shared file system. There is no cache that can be relied upon to be hot.&lt;/p&gt;
&lt;aside aria-label=&quot;The lifecycle problem&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The lifecycle problem &lt;/p&gt;  &lt;div&gt;&lt;p&gt;In this environment, any cache that lives outside the cloned repository is wasted on the first build. A sidecar file at &lt;code dir=&quot;auto&quot;&gt;patterns/submit_button.png.position&lt;/code&gt; works only if it was committed to the repository. A central &lt;code dir=&quot;auto&quot;&gt;.oculix-positions.toml&lt;/code&gt; works only if it was committed. Both files require explicit author discipline to ensure they are committed alongside the PNG.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;oPLx&lt;/code&gt; chunk inside the PNG removes this discipline burden entirely. The chunk is part of the PNG file itself. When a developer runs the test suite locally and the chunk is updated, the modified PNG appears in &lt;code dir=&quot;auto&quot;&gt;git status&lt;/code&gt; automatically. Git tracks PNG files because they are committed in the project. The developer cannot commit “the update” without committing “the chunk inside the update”, because they are the same file.&lt;/p&gt;
&lt;p&gt;This means that when a CI build clones the repository, it gets the PNG file with the chunk already inside. The first test run in CI is already in the fast path. The optimization is active from the first second. No warm-up phase is needed. No cache must be primed. The position metadata is in the file, and the file is in the repository, and the repository is in the clone.&lt;/p&gt;
&lt;aside aria-label=&quot;The structural property that matters most&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The structural property that matters most &lt;/p&gt;  &lt;div&gt;&lt;p&gt;This transforms a per-machine cache into a portable, version-controlled, distribution-friendly artifact. The optimization survives the most aggressive lifecycle: a brand new CI runner, a fresh clone, a one-time test execution, all benefit from a year of prior matches that were committed by a developer somewhere else.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;A team running the suite locally, committing the regenerated PNGs, and pushing to CI is effectively pre-warming the CI cache through normal development activity. There is no separate “cache warm-up” step. The cache warms itself as a side effect of using the tool.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-broader-pattern-png-chunks-as-runtime-context&quot;&gt;The broader pattern: PNG chunks as runtime context&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The technique of embedding metadata in image files is not new. Adobe XMP, EXIF, IPTC, GIMP layer information, and many other systems use this mechanism. What is new, or at least underused, is the embedding of runtime state — not authoring metadata, not provenance information, but actual operational data that evolves as the file is used.&lt;/p&gt;
&lt;p&gt;This pattern generalizes beyond visual automation:&lt;/p&gt;
&lt;div&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.640 13.480L18.640 13.480Q19.560 13.480 20.220 12.840Q20.880 12.200 20.880 11.260Q20.880 10.320 20.220 9.660Q19.560 9 18.620 9Q17.680 9 17.020 9.660Q16.360 10.320 16.360 11.260Q16.360 12.200 17.020 12.840Q17.680 13.480 18.640 13.480ZM23.880 6.640L4.760 6.640L4.760 22.120L23.880 22.120L23.880 6.640ZM5.640 19L5.640 7.480L23 7.480L23 21L18.520 15.880L15.760 19.120L10.240 13.240L5.640 19ZM17.880 4L17.880 1.880L0.120 1.880L0.120 15.360L2.520 15.360L2.520 4L17.880 4Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Texture caches for graphics applications&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A texture file could embed the time it was last loaded, the GPU memory pool it was allocated from, and the average frame time when it was active. A renderer could use this metadata to predict and pre-load textures.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M0.127 6.839L0.127 0.608L16.929 0.608L16.929 6.839L0.127 6.839ZM7.040 15.116L7.040 8.885L23.873 8.885L23.873 15.116L7.040 15.116ZM17.053 23.393L17.053 17.162L23.873 17.162L23.873 23.393L17.053 23.393Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Build artifacts for incremental compilation&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A compiled object file could embed the hash of the source it was compiled from, the compiler version, and the optimization level. An incremental build tool could detect when recompilation is actually necessary.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3.06a1.3 1.3 0 0 0-.06-.27v-.09c-.05-.1-.11-.2-.19-.28l-6-6a1.07 1.07 0 0 0-.28-.19h-.09a.88.88 0 0 0-.33-.11H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V8.94Zm-6-3.53L16.59 8H15a1 1 0 0 1-1-1V5.41ZM18 19a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Machine learning datasets&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;An image used in a vision model training set could embed its last classification result, the confidence score, and the model version. A cleaning tool could identify mislabeled samples without re-running the model.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;article&gt; &lt;p&gt; &lt;svg aria-hidden=&quot;true&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot;&gt;&lt;path d=&quot;M18.71 7.21a1 1 0 0 0-1.42 0l-7.45 7.46-3.13-3.14A1.02 1.02 0 1 0 5.29 13l3.84 3.84a1.001 1.001 0 0 0 1.42 0l8.16-8.16a1 1 0 0 0 0-1.47Z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;span&gt;Audit logs for compliance&lt;/span&gt; &lt;/p&gt; &lt;div&gt;&lt;p&gt;A document image stored in a regulated workflow could embed a signed audit trail of the operations performed on it. The Ed25519 signing pattern from the OculiX MCP module would apply directly.&lt;/p&gt;&lt;/div&gt; &lt;/article&gt;&lt;/div&gt;
&lt;p&gt;The common thread across all these examples is the same: keep operational state with the artifact, in a format the artifact’s primary tools will preserve, so that the state survives every distribution mechanism the artifact ever encounters.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-this-doesnt-solve&quot;&gt;What this doesn’t solve&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A 6.5× speedup on the find is significant but not exhaustive. Several costs remain untouched by this optimization:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Cost component&lt;/th&gt;&lt;th&gt;Status&lt;/th&gt;&lt;th&gt;Approximate weight&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Screen capture&lt;/td&gt;&lt;td&gt;Unchanged&lt;/td&gt;&lt;td&gt;20-40 ms per find&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;OpenCV setup&lt;/td&gt;&lt;td&gt;Unchanged&lt;/td&gt;&lt;td&gt;20-40 ms per find&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Tesseract OCR loading&lt;/td&gt;&lt;td&gt;Unchanged&lt;/td&gt;&lt;td&gt;~300 ms per JVM cold start&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;OculiX framework init&lt;/td&gt;&lt;td&gt;Unchanged&lt;/td&gt;&lt;td&gt;~1.5 seconds per JVM cold start&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;JVM startup itself&lt;/td&gt;&lt;td&gt;Unchanged&lt;/td&gt;&lt;td&gt;~1-2 seconds per cold start&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;aside aria-label=&quot;Honest framing&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; Honest framing &lt;/p&gt;  &lt;div&gt;&lt;p&gt;This optimization eliminates a specific avoidable cost (the full-screen scan when a small-region scan would suffice) by closing a specific gap in the existing codebase (the inability to persist &lt;code dir=&quot;auto&quot;&gt;Image.lastSeen&lt;/code&gt; across JVM invocations). It does not promise that all other costs will collapse alongside.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;Reducing these other costs would require deeper structural changes: pre-built native images via GraalVM (the JVM startup), incremental OpenCV initialization (the OpenCV setup), lazy OCR loading (Tesseract), and possibly an alternative screen capture API on each platform. Each of these is a separate optimization project, none of which is in scope for the current change.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;closing-context-engineering-as-a-discipline&quot;&gt;Closing: context engineering as a discipline&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The lesson of this whole investigation is not “PNG chunks are cool” or “spatial memoization wins”. Both of those are true but uninteresting on their own.&lt;/p&gt;
&lt;p&gt;The lesson is structural.&lt;/p&gt;
&lt;aside aria-label=&quot;The general principle&quot;&gt; &lt;p aria-hidden=&quot;true&quot;&gt; The general principle &lt;/p&gt;  &lt;div&gt;&lt;p&gt;A large fraction of perceived “performance problems” in mature codebases are not algorithmic problems. They are problems of context that fails to cross a boundary. The boundary may be a process boundary (the JVM exits), a file system boundary (the cache is on the wrong machine), a network boundary (the data is in another datacenter), or a time boundary (yesterday’s state is gone today). The algorithm inside the boundary may be perfectly optimized, but the context that would let it shine never reaches it.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt;
&lt;p&gt;Finding these gaps requires a specific habit: benchmarking systems in the configuration users actually experience, not in the configuration that is convenient for developers. In a tight in-process loop, the OculiX &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt; is fast. In a cold-start CI environment, it is slow. The same algorithm, the same code path, the same operating system. The only difference is whether the context built by the previous match has survived to inform the next match.&lt;/p&gt;
&lt;p&gt;The fix is rarely a new algorithm. It is usually a new place to store something that already existed, in a form that can travel through the boundaries the user’s actual workflow imposes.&lt;/p&gt;
&lt;p&gt;The PNG ancillary chunk happens to be a particularly elegant place to store visual automation context, because the artifact whose context we want to preserve is itself a PNG file, and the standard that defines PNG includes a mechanism specifically designed for this kind of metadata, and the tooling ecosystem has respected that mechanism for thirty years.&lt;/p&gt;
&lt;p&gt;The result is an optimization that did not require inventing a new algorithm, did not require breaking any existing contract, did not require any external dependency, and is fully backward compatible with files that do not yet have the chunk. It just needed someone to measure the actual baseline, identify the gap, and close it.&lt;/p&gt;
&lt;p&gt;For anyone maintaining a similar tool, my parting suggestion would be: go measure your cold-start performance against your warm-cache performance, in the same configuration your users actually run. If the gap is large, the optimization opportunity is probably not where you think it is. It is probably in the space between two of your existing components, in the form of a state that briefly exists and then disappears.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Repository: &lt;a href=&quot;https://github.com/oculix-org/Oculix&quot;&gt;github.com/oculix-org/Oculix&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Issue tracking the implementation of the persistent locator chunk: &lt;a href=&quot;https://github.com/oculix-org/Oculix/issues/353&quot;&gt;oculix-org/Oculix#353&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;</content:encoded><category>performance</category><category>visual-matching</category><category>benchmark</category></item><item><title>Rebuilding DOMAutopsy in four hours, and the five things AI couldn&apos;t decide</title><link>https://oculix.org/blog/2026-05-18-rebuilding-domautopsy-in-four-hours/</link><guid isPermaLink="true">https://oculix.org/blog/2026-05-18-rebuilding-domautopsy-in-four-hours/</guid><description>A 4-hour Claude Code session refactored DOMAutopsy from a desktop tool to a web-streaming runner. AI shipped about 4000 lines. Here are the five architectural calls it couldn&apos;t make.

</description><pubDate>Mon, 18 May 2026 08:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I spent a Sunday afternoon rebuilding &lt;a href=&quot;https://github.com/julienmerconsulting/DOMAutopsy&quot;&gt;DOMAutopsy&lt;/a&gt;, the visual locator harvester I maintain, from a PySide6 desktop tool into a FastAPI server with a live web UI. The work took roughly four hours. Claude Code wrote most of the ~4 000 lines that landed.&lt;/p&gt;
&lt;p&gt;That number, on its own, says nothing. It’s the kind of figure people quote on LinkedIn to sell a service. What it actually means depends entirely on which decisions you let the model take, and which decisions you keep for yourself.&lt;/p&gt;
&lt;p&gt;Five decisions stuck with me from that session. None of them were technically hard. All of them would have produced a noticeably worse codebase if I had accepted the model’s first suggestion.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;1-the-system-did-not-need-rag&quot;&gt;1. The system did not need RAG&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The agent’s first proposal for capturing context across a long browsing session was to embed the page snapshots, store them in a vector index, and retrieve top-k chunks when the LLM needed to reason about the trajectory. It is a reasonable suggestion in 2026. It would also have added a dependency on a vector database, an embeddings model, and a retrieval pipeline that nobody on the user side asked for.&lt;/p&gt;
&lt;p&gt;The real need was simpler. The agent observes the DOM live, the listener captures the selectors, the report aggregates everything at the end. There is no retrieval problem. There is a context augmentation problem, and it is solved by a flat structured payload passed to the LLM at each step.&lt;/p&gt;
&lt;p&gt;The first architectural decision was to remove an entire layer that the model would have built without flinching.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;2-cdp-screencast-not-novnc&quot;&gt;2. CDP screencast, not noVNC&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;For the live web UI, two viable options surfaced: stream the Chromium headless instance over CDP screencast, or wrap it in a VNC server and serve it over noVNC. Both work. The model recommended noVNC because the literature it’s trained on shows it as the standard.&lt;/p&gt;
&lt;p&gt;In practice, CDP screencast is lighter, runs in the same Playwright process, and skips an entire VNC server installation in the Docker image. The noVNC route would have added moving parts that nobody needs once Playwright already exposes everything via CDP.&lt;/p&gt;
&lt;p&gt;This is the kind of call that takes thirty seconds of judgment and that the model has no way of making on its own. It optimizes for the average answer in its training set, not for the architecture you happen to be building today.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;3-a-seven-tier-selector-cascade-beats-a-single-llm-matcher&quot;&gt;3. A seven-tier selector cascade beats a single LLM matcher&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Several open source projects from the last twelve months propose to delegate selector generation entirely to an LLM at each interaction. The model handles the DOM, picks the most stable selector, returns the result. Elegant on paper.&lt;/p&gt;
&lt;p&gt;DOMAutopsy uses a different approach: a deterministic seven-tier cascade in a 372-line JavaScript listener, with the LLM only intervening at cleanup time. Tier 1 is &lt;code dir=&quot;auto&quot;&gt;data-testid&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;id&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;name&lt;/code&gt;. Tier 7 is short CSS or text XPath. The LLM never sees a selector unless the cascade fails or produces ambiguous matches.&lt;/p&gt;
&lt;p&gt;The reason is straightforward. A pure LLM-based matcher is non-deterministic, expensive at scale, and creates a hard dependency on a third party at every interaction. A static cascade is auditable, replayable offline, and cheap. The LLM adds value only where deterministic logic stops being effective.&lt;/p&gt;
&lt;p&gt;This is a structural decision that no agent will make for you, because every example it has read describes the opposite approach.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;4-regex-parser-not-ast-parser-for-legacy-scripts&quot;&gt;4. Regex parser, not AST parser, for legacy scripts&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;DOMAutopsy can reverse-engineer existing test scripts (Katalon, Playwright, Cypress, Selenium) into structured tasks for the AI agent. The proposal from the model was to write a proper AST parser per language: Tree-sitter for Groovy, ts-morph for TypeScript, and so on.&lt;/p&gt;
&lt;p&gt;For about 95 percent of real-world production test scripts, regex is enough. They are linear, mechanically generated, repetitive. AST parsing would have multiplied the implementation cost by ten and shifted the failure surface from “regex misses a syntax case” to “AST parser crashes on a malformed legacy file”.&lt;/p&gt;
&lt;p&gt;I picked regex. I also added a clear note in the documentation: ~95 percent coverage, the rest goes to manual fallback. Honest constraint, manageable scope.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;5-no-saas-lock-in-even-when-it-would-have-been-faster&quot;&gt;5. No SaaS lock-in, even when it would have been faster&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;At one point during the session, the model proposed to integrate a third-party hosted screenshot diff service to offload the visual comparison work. It would have shaved off some implementation time.&lt;/p&gt;
&lt;p&gt;OculiX and DOMAutopsy both live in regulated environments. Banks, defense contractors, healthcare providers. Adding a third-party SaaS dependency without conscious decision would close doors I would later have to pry back open. I declined, the model accepted, the screenshot diff happens locally.&lt;/p&gt;
&lt;p&gt;This is a non-technical call dressed up as a technical one. The model does not know that adding &lt;code dir=&quot;auto&quot;&gt;external-api.com&lt;/code&gt; to the data flow will make the procurement file twice as long for a CISO in a regulated sector. I do.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-ai-actually-does-well-and-what-it-doesnt&quot;&gt;What AI actually does well, and what it doesn’t&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;In four hours, AI produced clean, working, mostly idiomatic code. The throughput is real. The output is not magic. It is the result of structured prompting, a clear mental model on the architect side, and a continuous flow of small judgments that close off bad branches before they grow.&lt;/p&gt;
&lt;p&gt;The interesting question is not “can AI replace senior architects”. It is: which decisions are you still going to take yourself, and which are you ready to delegate. The five above happen to be calls I would refuse to delegate even with another twenty years of experience. They are domain-specific, context-aware, and they shape the rest of the project for years.&lt;/p&gt;
&lt;p&gt;The rest, AI shipped it.&lt;/p&gt;
&lt;p&gt;—&lt;/p&gt;
&lt;p&gt;Repo: &lt;a href=&quot;https://github.com/julienmerconsulting/DOMAutopsy&quot;&gt;github.com/julienmerconsulting/DOMAutopsy&lt;/a&gt;&lt;/p&gt;</content:encoded><category>agentic-ai</category><category>qa-automation</category><category>claude-code</category></item></channel></rss>