ブログ

  • Rebuilding Microsoft Word’s Layout Engine in Rust+WASM: The Oxi Project at SSIM 0.89 After Six Weeks

    TL;DR

    Oxi is a Rust + WebAssembly OSS suite that parses, renders, and edits .docx / .xlsx / .pptx / PDF entirely in the browser — no server, no proprietary fonts at runtime, no DLL disassembly. The premise is simple: “LibreOffice breaks Word layouts, Google Docs requires a server, there is an empty seat between them.” Six weeks into the empirical Word-compatibility loop, the engine sits at SSIM mean = 0.8932 over 235 real-world .docx documents (410 pages). This post is the engineering postmortem of how we got here — including the methodology axioms that were empirically falsified along the way.

    • Clean-room Word compatibility via COM API black-box measurement (no disassembly)
    • Why the “single SSIM gate” methodology broke and got redesigned into phase-based gates
    • The GDI integer-rounding cascade: a 0.18pt/char drift becomes 10.8pt over 60 characters
    • Founding axioms that survived vs. those that were falsified (R30 measurement bug, R33 41-page regression)

    Why “Word in the browser”

    As of 2026, EU public-sector de-Microsoft-365 has moved from aspiration to policy. The French DINUM directive (2026-04-08) mandates that 2.5M public-sector PCs migrate to free-software stacks by 2027. The Swiss Federal Chancellery officially announced phased M365 reduction on 2026-04-18. Germany’s ZenDiS OpenDesk is already in production at Schleswig-Holstein, Thüringen, Baden-Württemberg, and — after US sanctions blocked Microsoft access — the International Criminal Court.

    Every one of these transitions is missing the same piece: a rendering engine that opens existing .docx files indistinguishably from Microsoft Word. Without that, “migration” stops being “switch the app” and becomes “audit every document for visual divergence” — which is what no organization can staff. LibreOffice’s 20-year struggle in public-sector rollouts isn’t a feature problem; it’s a per-document visual-divergence problem that forces every site into a per-file audit.

    Oxi’s goal is not “a better migration tool.” It’s the dissolution of the migration problem, via a mechanical convergence loop toward SSIM = 1.0 against Microsoft Word. Internally we call this loop Ra.

    Architecture — why Rust + WASM

    crates/
      oxi-common/      Shared OOXML utilities (ZIP, XML, relationships)
      oxidocs-core/    .docx engine — parser, IR, layout, font metrics, editor
      oxicells-core/   .xlsx engine — parser, IR, editor
      oxislides-core/  .pptx engine — parser, IR, editor
      oxipdf-core/     PDF 1.7 engine — parser, text extraction, generator
      oxihanko/        Japanese digital stamp (hanko) generator + PAdES signer
      oxi-wasm/        WebAssembly bindings (wasm-bindgen)
    web/               Web demo (vanilla JS + Canvas)

    The IR (Intermediate Representation) is deliberately format-agnostic:

    Document → Page → Block (Paragraph | Table | Image) → Run

    LibreOffice treats ODF as native and OOXML as an import, so OOXML degrades on round-trip. Word does the inverse to ODF. Oxi was built from day one so that neither format owns the IR — a prerequisite for the v2 dual-format goal (.docx + .odt as equally first-class citizens).

    Why WASM, practically:

    • Zero server cost — all processing runs client-side. Viable without a SaaS business model
    • Privacy by construction — documents never leave the device. Essential for legal / medical / government
    • Binary size — the whole suite is ~1.4 MB compiled .wasm
    • Memory safety — Word documents can be adversarial inputs (zip-bombs in public-sector .xlsx are a thing). Rust’s safety is a practical, not theoretical, benefit

    “100% clean-room” — building compatibility without reading the DLL

    Every layout specification in Oxi is derived from exactly two sources:

    • Published standards — OOXML (ISO/IEC 29500 / ECMA-376), PDF (ISO 32000)
    • Black-box measurement — observed via the Microsoft Office COM API

    A typical measurement script looks like this (Word VBA or pywin32):

    y1 = doc.Paragraphs(1).Range.Information(6)  # wdVerticalPositionRelativeToPage
    y2 = doc.Paragraphs(2).Range.Information(6)
    gap = y2 - y1  # = line_height + spacing (in twips)

    The critical rule: never use Format.LineSpacing. It returns the configured value, not the rendered line height. Word composes the actual line height from font metrics, docGrid settings, table-cell context, etc., and the result rarely equals the configured setting. The right answer is always subtract two rendered positions.

    Microsoft’s Open Specification Promise covers OOXML implementations against patent assertion. Observing only the COM outputs (never the DLL) keeps the project legally and technically clean-room.

    Findings I actually enjoyed

    1. GDI’s integer-pixel rounding accumulates to 10.8pt over 60 characters

    Word’s text engine is built on GDI — a 30-year-old API that rounds advance widths to integer pixels and computes line height by rounding ascent and descent separately before summing. At Calibri 11pt, a 0.18pt-per-character drift accumulates to 10.8pt over 60 characters. That’s enough to change where lines wrap and where pages break.

    The implication: a Word-compatible renderer cannot use modern floating-point metrics. It must reproduce GDI’s integer rounding bug-for-bug. Oxi uses a dual font engine — GDI for .docx, DirectWrite for .odt and PDF export — behind a shared FontEngine trait:

    FontEngine trait
    ├── GdiEngine     — Word-compatible (integer px rounding)
    └── DWriteEngine  — Cross-platform (floating-point precision)

    2. Word rounds character widths to 10 twips (0.5pt)

    Not in the OOXML spec, but COM-confirmed across 13 font/size combinations and 181 characters: Word rounds the computed advance to 10 twips (0.5pt):

    width_twips = round(advance × fontSize × 20 / UPM)
    width_10tw  = round(width_twips / 10) × 10

    Implementing this triggered a cascade of improvements (mid-paragraph page break: +0.041 SSIM; table-cell line_height reset: +0.66) that took the average from 0.7884 to 0.8584 in three weeks.

    3. Multiple line spacing accumulates with CEIL, not ROUND

    Also undocumented. MS Mincho 10.5pt × 1.15 line spacing nominally computes to 310.5 twips, but Word actually uses 320 twips (16.0pt) — ceiling, not round. And the cumulative position math inherits CEIL through all subsequent lines. COM-confirmed across 8 of 9 measured positions before we shipped the fix.

    4. is_fullwidth is broader than OOXML says

    Pulling Unicode East_Asian_Width=F/W isn’t enough — Word treats 7 additional blocks (Arrows, Math Operators, Letterlike Symbols, etc.) as fullwidth. Without this fix, “→” gets Western advance width and visually overlaps the adjacent CJK glyph.

    5. Information(6) does not return paragraph-start Y

    This one was a measurement-side bug and is the core of the R30 incident below. doc.Paragraphs(N).Range.Information(6) returns the active-end position. For paragraphs that span pages, you get “Y on the next page” and the per-paragraph page index is silently off by one. The fix is to collapse the range to zero width first:

    y = doc.Range(rng.Start, rng.Start).Information(6)

    Founding axioms that got falsified

    The Ra loop was originally built on a strong “No Excuses by Design” premise:

    • No layout difference has a valid excuse
    • Every value is measurable via COM API
    • The spec space is finite; measurement results are permanent assets
    • Fixing one spec gap improves multiple documents simultaneously (convergent structure)
    • Not “cannot do,” only “not yet done”

    Sessions 38-45 (about a month of empirical pressure) falsified three of these.

    R21 plateau — the bottom-5 gate stops moving

    The initial merge gate required the bottom-5 documents’ SSIM sum to strictly increase. Reasonable on its face, but the bottom-5 each carried different structural problems (table charGrid, in-textbox wrap, vertical writing, …), each of which needed a multi-week refactor before any single document moved. The gate locked, and no PRs could land.

    R30 — the measurement API itself had a bug

    The Information(6) issue above. The axiom “every value is measurable via COM” assumed the API returns what you think it returns. It does not — for multi-page paragraphs it returns the active-end position. Months of measurement data carried a silent one-page drift. The corollary: “measurement results are permanent assets” is wrong; measurement methodology has to be re-validated periodically, not just the values.

    R33 — a “minimal-case spec-correct” fix regressed 41 pages

    A spec was derived from a minimal repro, the implementation matched the repro exactly, and the production baseline got worse by 41 pages. The cause: the spec was derived in a single context (a standalone paragraph), but Word actually composes the rendering from font-cascade × szCs × per-context wrap rules. What’s correct in the minimal repro can be wrong in other contexts.

    The rule that survived: no EXCEPTION stacking. If a “confirmed” spec needs per-document, per-font, or per-context carve-outs, the spec isn’t incomplete — it’s wrong. Re-derive from a richer input space rather than stacking exceptions, because every exception is a future R33 in the making.

    Redesign: phase-based gate

    On 2026-04-28 we redesigned the methodology. The core admission: SSIM = 1.0 is an outcome, not a signal. Gating on the outcome directly produces R21-style plateaus, because outcomes don’t decompose into per-PR work. Instead, gate on cause-attributable signals staged by phase:

    PhasePrimary gateWhy this signal
    1 (current)Pagination correctness — does Word’s paragraph N land on Oxi’s page N?One page-break bug can cascade into 47 low-SSIM pages. Fixing the root needs a gate that won’t punish you for it
    2Element IoU mean ≥ 0.99 — bbox IoU per text block / image / cellPosition accuracy (not pixel). The dominant structural error left after pagination is correct
    3SSIM mean ≥ 0.99 + bottom-N floorOnce positions match, residual pixel diff = font hinting / sub-pixel rendering

    SSIM is tracked at every phase but is the gate only at Phase 3. During Phase 1 it acts as a regression sentinel (any drop > 0.005 requires review). This unblocks fixes whose payoff is “47 low-SSIM pages all shift simultaneously after the root page-break bug is fixed” — exactly the shape of fix the old gate refused.

    Five weeks after the redesign:

    • Phase 1 pass rate: 25/55 → 46/55 (83.6%)
    • SSIM mean: 0.8699 → 0.8932 (+0.0233)
    • Individual PRs are merge-eligible when pagination or IoU moves — SSIM doesn’t have to budge

    In this window, root-cause fixes that were unmergeable under the old gate landed as one- or two-line changes: vMerge cell height exclusion, fixed table column width preservation, widow control inheritance, grid-snapped lines extending into the bottom margin.

    The verify pipeline (stale-binary incidents)

    We made the same mistake on 2026-04-26 and again on 2026-05-07. Oxi has three render paths:

    • WASM (crates/oxi-wasm/pkg/) — used by the browser editor
    • Native GDI renderer (tools/oxi-gdi-renderer/) — used by the pagination measurer (Phase 1 gate)
    • Native DWrite renderer (tools/oxi-dwrite-renderer/) — the default for pipeline.verify

    Running cargo build in the gdi-renderer directory does not rebuild the dwrite-renderer crate. We forgot this, ran verify against a stale DWrite binary, and got a false-positive “0 improved / 0 regressed” report — because pre-patch baseline was being compared to pre-patch output. The actual delta turned out to be -0.0911 and we had to revert.

    The hygiene is now hard-coded in CLAUDE.md:

    • Rebuild both native renderers before every verify, and delete oxi_png/ + pagination_oxi/ caches for affected docs
    • WASM rebuild alone is insufficient — pipeline.verify does not use WASM
    • “0 improved, 0 regressed” is a red flag. Stale binaries return identical numbers, so 0 deltas are a stale-output signature

    Methodology bugs are nastier than spec bugs: if undetected for a few days, they invalidate the entire measurement corpus accumulated during that window. The only working defense is to write project-specific hygiene rules somewhere enforcement can actually reach them — for an AI-driven loop, that’s the CLAUDE.md the agent reads on every session start.

    Where this sits in the landscape

    SolutionApproachLimitation
    LibreOffice / CollaboraC++ server-side renderingBreaks Word layouts. Requires server. No pixel-fidelity goal
    ZetaOfficeLibreOffice compiled to WASM100MB+ download. Accuracy = LibreOffice. A port, not a rewrite
    ONLYOFFICEJS canvas renderingClosest architecture, but AGPL and no COM-measured Word compat
    Apryse (PDFTron)C++ → WASMProprietary. Converts to internal format — not native OOXML render
    docMentisRust+WASM viewerWASM engine proprietary. Telemetry on by default
    Google DocsServer-renderedProprietary. Requires server. Intentionally diverges from Word
    docx-rs / rdocxRust DOCX librariesR/W only. No browser layout engine

    Oxi’s intersection is “OSS (MIT) + Rust/WASM client-side + dual-format first-class + COM-measured pixel-perfect + zero server cost.” No other project occupies this seat.

    Current numbers and roadmap

    • Parse success: 100% across 504 documents (Japanese government .docx/.xlsx/.pptx + generated). LibreOffice: 99.2% (4 large .xlsx files timed out > 45s)
    • SSIM mean: 0.8932 (235 docs, 410 pages, GDI baseline)
    • Phase 1 pass rate: 46/55 (83.6%)
    • .wasm size: ~1.4 MB for the full suite

    Next milestones:

    • v1.x — .docx SSIM 0.95+, IME (Japanese/CJK input), .xlsx/.pptx layout engines, vertical writing + ruby
    • v2 — .odt parity. The Ra loop transfers to ODF; the reference renderer changes (LibreOffice headless) but the methodology does not
    • v2.x — oxi-hyde (TPM 2.0 + ML-KEM outer envelope). .docx.hyde / .odt.hyde are PGP-encrypted-PDF style: encryption is an outer wrapper, decryption restores a plain .docx / .odt openable in any client

    Try it

    • Live demo: https://ryujiyasu.gitlab.io/oxi/
    • License: MIT
    • Contributing: every merged PR must improve pixel accuracy of at least one document. That’s the entire acceptance criterion

    The methodologically interesting part — and a topic for another post — is that the Ra loop runs autonomously with an AI agent (Claude) in the inner loop: root-cause analysis, COM measurement script generation, fix implementation, verification. The human role is phase-gate review and direction. The falsified axioms above (spec space open-ended, single-context derivation is a trap) are not just layout-engine lessons; they are also a record of where human review must remain when running an AI-driven engineering loop on a hard convergence problem.

    If you work on EU public-sector migration, ODF parity, or hardware-anchored document encryption — I’d particularly like to hear from you.

  • ブラウザで Microsoft Word を pixel 単位再現する Oxi プロジェクト — 方法論の罠と SSIM 0.89 までの記録

    要約

    Oxi は、Microsoft Word / Excel / PowerPoint / PDF をブラウザ上で サーバーレスに パース・レンダリング・編集する OSS スイートを Rust + WebAssembly で書いている個人プロジェクトだ。「LibreOffice はレイアウトを壊す、Google Docs はサーバーが要る、その間に空席がある」という素直な問題意識から始めて、現在は実 .docx 235 ドキュメント・410 ページに対し Word との pixel-level SSIM 平均 0.8932 まで到達している。本記事は、そこに至るまでに踏んだ方法論上の罠と、技術的に面白かった発見をエンジニア向けに記録するためのものだ。

    • DLL 逆アセンブルなしで Word 互換を作るとはどういうことか (clean-room + COM API 黒箱測定)
    • 「SSIM 単一ゲート」が破綻し Phase-based 方法論に再設計した経緯
    • GDI の整数ピクセル丸めが 60 文字で 10.8pt 累積する、という古典的な罠
    • “全て測れる” というプロジェクト初期の axiom が経験的に falsify された (R30, R33 事件)

    なぜ “ブラウザで Word” なのか

    2026 年現在、EU 公共セクターの脱 Microsoft 365 が 制度として 動き出している。フランス DINUM 指令 (2026-04-08) は 250 万台の公共 PC を 2027 年までに自由ソフトウェアスタックに移行することを義務化、スイス連邦官房は 2026-04-18 に Microsoft 365 依存削減を公式表明、ドイツの ZenDiS OpenDesk は Schleswig-Holstein / Thüringen / Baden-Württemberg、そして米国制裁で MS アクセスを止められた国際刑事裁判所で既に本番稼働している。

    これら全ての移行に共通する 欠けているピース は同じだ:「既存の .docx を Word と 見分けがつかないように 開けるレンダリングエンジン」。これが無いと、移行は「アプリ切り替え」ではなく「全文書の見え方監査」になり、組織は人員を充てられない。LibreOffice が 20 年公共セクター展開で苦戦している理由は、機能が弱いからではなく、per-document の視覚差分がプロジェクト化を強制する からだ。

    Oxi のゴールは「より良い移行ツール」ではなく 移行問題そのものの溶解 で、その手段が SSIM = 1.0 への機械的収束ループ (内部では Ra と呼んでいる) だ。

    アーキテクチャ — なぜ Rust + WASM か

    crates/
      oxi-common/      Shared OOXML utilities (ZIP, XML, relationships)
      oxidocs-core/    .docx engine — parser, IR, layout, font metrics, editor
      oxicells-core/   .xlsx engine — parser, IR, editor
      oxislides-core/  .pptx engine — parser, IR, editor
      oxipdf-core/     PDF 1.7 engine — parser, text extraction, generator
      oxihanko/        Japanese digital stamp (hanko) generator + PAdES signer
      oxi-wasm/        WebAssembly bindings (wasm-bindgen)
    web/               Web demo (vanilla JS + Canvas)

    IR (Intermediate Representation) は意図的に format-agnostic に設計してある:

    Document → Page → Block (Paragraph | Table | Image) → Run

    LibreOffice は ODF をネイティブ、OOXML を import として扱うから round-trip で OOXML が壊れる。Word は逆の理由で ODF を壊す。Oxi は最初から「どちらの format も IR を所有しない」設計にしてあり、これは v2 で計画している .odt 並列対応の前提でもある。

    WASM を選んだ実利的な理由:

    • サーバー費用ゼロ: 全処理が client-side。SaaS でなく成立する
    • プライバシー: 文書がデバイスから出ない。法務 / 医療 / 政府文書で本質的
    • バイナリサイズ: Suite 全体で約 1.4 MB の .wasm
    • メモリ安全: Word ドキュメントは敵対的入力になりうる (公官庁の zip-bomb 級 .xlsx 等)。Rust の safety は実利

    “100% Clean-Room” — DLL を読まずに互換を作る方法

    Oxi の仕様は 2 つのソースだけ から導出している:

    • 公開標準 — OOXML (ISO/IEC 29500 / ECMA-376), PDF (ISO 32000)
    • 黒箱測定 — Microsoft Office COM API で出力値を直接観測

    典型的な測定スクリプトはこんな形 (Word VBA / pywin32 で COM 経由):

    y1 = doc.Paragraphs(1).Range.Information(6)  # wdVerticalPositionRelativeToPage
    y2 = doc.Paragraphs(2).Range.Information(6)
    gap = y2 - y1  # = line_height + spacing (twips 単位で)

    大事なのは 「Format.LineSpacing は使うな」 という点。これは「設定値」を返すだけで、Word が実際にレンダリングした line height ではない。Word はフォントメトリクス・docGrid・table cell 文脈などを合成した結果として「実 line height」を決めており、設定値とは一致しない。実描画位置の差分 から逆算するのが正しい方法だ。

    Microsoft の Open Specification Promise により OOXML 実装に対する特許不行使が宣言されている。DLL を触らずに COM の出力だけを見るアプローチは、法的にも技術的にも clean-room を維持できる。

    面白かった技術的発見

    1. GDI の整数ピクセル丸めは 60 文字で 10.8pt 累積する

    Word のテキストエンジンは GDI ベースで、文字幅を整数ピクセルに丸め、line height は ascent と descent を 別々に丸めてから加算する。Calibri 11pt で 1 文字あたり 0.18pt の差が、60 文字で 10.8pt の累積誤差になる。これは「行が折り返す位置」「ページが切れる位置」を変える程度の大きさだ。

    つまり Word 互換レンダラを作るには、現代的な float メトリクスで真面目に組版した結果を返すのは 不正解 で、GDI と同じ整数丸めをそのまま再現する必要がある。Oxi は .docx 用に GDI engine、.odt と PDF 出力用に DirectWrite engine の dual font engine を持ち、同じ FontEngine trait で差し替えている。

    FontEngine trait
    ├── GdiEngine     — Word-compatible (integer px rounding)
    └── DWriteEngine  — Cross-platform (floating-point precision)

    2. Word は文字幅を 10 twips (0.5pt) 単位に丸める

    OOXML 仕様には書いていないが、COM 測定で確認した経験則: Word は文字幅を計算したあと 10 twips (= 0.5pt) 単位に丸めている。13 のフォント / サイズ組合せ・181 文字で確認済み。式は:

    width_twips = round(advance × fontSize × 20 / UPM)
    width_10tw  = round(width_twips / 10) × 10

    これを実装した瞬間に net +0.041 SSIM (mid-paragraph page break) や net +0.66 (table cell line_height reset) のような 段差的改善 が連鎖して、3 週間で 0.7884 → 0.8584 に上がった。

    3. 複数行間隔は CEIL で累積する

    これも仕様には無い。MS Mincho 10.5pt × 1.15 倍を line spacing に指定したとき、計算上は 310.5 twips になるが Word は 切り上げで 320 twips (16.0pt) として扱う。ROUND ではなく CEIL。さらに複数行にわたって累積する位置計算も CEIL を継承する。これも 8/9 位置で COM 確認した上で実装した。

    4. is_fullwidth は OOXML が言うより広い

    Unicode の East_Asian_Width=F/W だけ拾うと不足で、Word は実際には Arrows / Math Operators / Letterlike Symbols 等 7 つの追加ブロックを fullwidth 扱いする。これを直さないと「→」が西欧字幅で扱われて隣の CJK 文字に 重なる という症状が出る。Oxi は判定テーブルに 7 ブロックを追加して直した。

    5. Information(6) は段落開始 Y を返さない

    これは方法論側の罠で、後述の R30 事件のコア。doc.Paragraphs(N).Range.Information(6) は段落の active end 位置 を返す。複数ページにまたがる段落だと「次のページの Y」が返ってきて、per-paragraph page index が 1 ページずれる。正解は doc.Range(rng.Start, rng.Start).Information(N) で範囲を 0 幅に collapse してから問い合わせること。

    プロジェクト初期の axiom が経験で falsify された話

    Oxi の Ra loop は当初「No Excuses by Design」という強い前提で設計されていた:

    • レイアウト差分に正当な excuse は存在しない
    • 全ての値は COM API で測定できる
    • 仕様空間は有限、測定結果は永続資産になる
    • 1 つの仕様修正は複数文書を同時に改善する (収束的構造)
    • 「できない」のではなく「まだやっていないだけ」

    これは Session 38-45 (約 1 ヶ月) で 3 つが経験的に偽だと判った

    R21 plateau — bottom-5 ゲートが動かなくなる

    初期の merge gate は「文書ごとの SSIM 下位 5 文書の合計が単調増加すること」だった。これは合理的に見えるが、bottom-5 の各文書がそれぞれ別の構造的問題 (table charGrid、textbox 内 wrap、vertical writing、…) を抱えていて、マルチ週の refactor が無いと 1 文書も動かない。結果として gate がブロックし、PR が積めない期間が続いた。

    R30 — 測定 API そのものにバグがあった

    前述の Information(6) 問題。COM API は「全て測れる」前提だったが、API そのものが multi-page 段落に対して active end を返す挙動だったため、月単位の測定データに 1 ページ ずれが混入していた。「測定は永続資産」も 測定方法ごと再検査される 必要があることが判った。

    R33 — 「minimal-case 仕様正しい修正」が 41 ページ regress

    仕様を minimal repro で確認して仕様通りに直したのに、本番 baseline で 41 ページが 劣化 した。原因は「仕様が単一 context (例: 単独段落) でしか derive されていなかった」こと。実際には Word は font-cascade × szCs × per-context wrap rule の 合成 でレンダリングしており、minimal-case の正解が他 context の不正解になる。

    この教訓から、Oxi の Critical Rules に追加されたのが「EXCEPTION stacking 禁止」: 仕様に per-doc / per-font の carve-out が必要になったら、その仕様は 不完全 ではなく 間違っている ので、richer input space から re-derive する。例外を積むと R33 が再生産される。

    Phase-based gate への再設計

    2026-04-28 に方法論を再設計した。SSIM = 1.0 は 結果指標 (outcome) であって 信号 (signal) ではない、と認めた。結果指標を直接 gate にすると上記 R21 のように動けなくなる。代わりに cause-attributable な信号 を段階的に積む:

    PhasePrimary gateWhy this signal
    1 (現在)Pagination correctness — Word の段落 N が Oxi で同じ page N に乗るかページ割れ 1 個で 47 ページ低 SSIM になる cascade を root から切る
    2Element IoU mean ≥ 0.99 — 要素 bbox の IoU位置 (≠ pixel) の精度。pagination 後の唯一の構造誤差
    3SSIM mean ≥ 0.99 + bottom-N floor位置が合った後、pixel diff が残る = font hinting / sub-pixel

    SSIM は 全 phase で常に追跡 するが、gate になるのは Phase 3。Phase 1 中は SSIM の > 0.005 劣化 だけ regression sentinel として効かせる。これで「root の page-break バグを直す = 47 ページが同時に SSIM 動く」のような複合的改善が gate を通せるようになる。

    進捗を見ると、再設計後 5 週間で:

    • Phase 1 pass_rate: 開始時 25/55 → 46/55 (83.6%)
    • SSIM mean: 0.8699 → 0.8932 (+0.0233)
    • 個別 PR は「pagination が動いた」「IoU が増えた」で merge 可能、SSIM が必ずしも動かなくても良い

    この期間に Word の vMerge セル高さ計算・fixed table 幅保持・widow control 継承・grid-snapped 行が下マージンに食い込む挙動など、SSIM gate なら絶対に着手できなかった root cause を 1〜2 行で 直せた PR が連続している。

    verify pipeline で踏んだ罠 (stale binary 事件)

    2026-04-26 と 2026-05-07 に同じパターンの事故を起こした。Oxi は 3 つの render path を持っている:

    • WASM (crates/oxi-wasm/pkg/) — browser editor 用
    • Native GDI renderer (tools/oxi-gdi-renderer/) — Phase 1 gate (pagination 測定) 用
    • Native DWrite renderer (tools/oxi-dwrite-renderer/) — pipeline.verify の default

    cargo build を gdi-renderer dir で走らせても dwrite-renderer は 再ビルドされない。これに気付かず古い DWrite binary で verify を回し、pre-patch baseline と pre-patch output を比較して「0 改善 / 0 劣化」という偽陽性レポートを出した。実際の差分は -0.0911 で、後で revert する羽目になった。

    教訓は単純で、CLAUDE.md にハードコード:

    • verify の前は 両方の renderer を rebuild、cache (oxi_png/, pagination_oxi/) を 消す
    • WASM rebuild だけでは不十分 (verify pipeline は WASM を使わない)
    • “0 改善” は疑え。stale binary は同じ値を返すから 0 になる
    $ dmesg | tail
    [...] usb 1-3: New USB device found, idVendor=1546, idProduct=01a9
    [...] usb 1-3: Product: u-blox GNSS receiver
    [...] cdc_acm 1-3:1.0: ttyACM0: USB ACM device

    VID 0x1546 は u-blox AG、PID 0x01a9 は u-blox generation 9 (NEO-D9C もここ)。/dev/ttyACM0 として u-blox CDC ACM デバイスが即座に見える。UART よりずっと素直で速い。

    教訓: チップ datasheet の “USB なし” は「チップ単体での話」かつ「IDD バージョン依存」かもしれない。Plugin / 評価ボードのリビジョンによっては USB-UART ブリッジを足して micro USB に引き出してくれている。現物の基板写真を眺めて全コネクタを数えるべき

    罠 2: UBX-RXM-QZSSL6 が version 0x00 → 0x01 で payload 構造ごと差し替わってた

    parser を当てたら全フレームが “unsupported version: 0x1” で reject。コードを書いた時に参照した古い IDD (version 0x00、payload 262 byte、channelName が ASCII ‘a’ .. ‘d’ で channel を表す) のレイアウトと、実機 firmware が吐く version 0x01 (payload 264 byte、channel 情報が bitfield) が違う。

    最新の u-blox D9 QZS 1.01 Interface Description (UBX-21031777-R02) に当たって layout を取り直す:

    offset  size   field
      0      1    version (= 0x01)
      1      1    svId
      2      2    cno (raw × 2^-8 = dBHz)
      4      4    timeTag (ms, ローカル時刻タグ)
      8      1    groupDelay (ns, L2 に対する L6 群遅延)
      9      1    bitErrCorr (Reed-Solomon で訂正したビット数)
     10      2    chInfo (bitfield、下記)
     12      2    reserved0
     14    250    msgBytes (生 L6 メッセージ)

    chInfo bitfield (X2、リトルエンディアン):

    • bits 9..8: chn (受信機チャネル 0/1)
    • bit 10: msgName (0=L6D, 1=L6E)
    • bits 13..12: errStatus (0=unknown, 1=error-free, 2=erroneous)
    • bits 15..14: chName (0=channel A, 1=channel B)

    旧 v0 では「channel 名 = 1 byte ASCII で ‘a’/’b’ = L6D ch1/ch2, ‘c’/’d’ = L6E ch1/ch2」だったのが、v1 では (msgName, chName) の 2 bit ペアで同じ情報を表す。さらに errStatuscno が追加されて、パーサで観測品質が直接見えるようになった。

    Rust の parser を書き直す:

    pub const UBX_RXM_QZSSL6_PAYLOAD_LEN: usize = 264;
    pub const L6_RAW_LEN: usize = 250;
    
    impl QzssL6Frame {
        pub fn parse(payload: &[u8]) -> Result<Self, QzssL6Error> {
            if payload.len() != UBX_RXM_QZSSL6_PAYLOAD_LEN {
                return Err(QzssL6Error::BadLength(payload.len()));
            }
            if payload[0] != 0x01 {
                return Err(QzssL6Error::UnsupportedVersion(payload[0]));
            }
            let sv_id = payload[1];
            let cno_dbhz = (u16::from_le_bytes([payload[2], payload[3]]) as f32) / 256.0;
            let time_tag_ms = u32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]);
            let ch_info = u16::from_le_bytes([payload[10], payload[11]]);
    
            let msg_name = (ch_info >> 10) & 1;
            let err_status = (ch_info >> 12) & 0b11;
            let ch_name = (ch_info >> 14) & 0b11;
    
            let band = match (msg_name, ch_name) {
                (0, 0) => L6Band::L6dCh1,
                (0, 1) => L6Band::L6dCh2,
                (1, 0) => L6Band::L6eCh1,
                (1, 1) => L6Band::L6eCh2,
                _ => L6Band::L6dCh1, // reserved
            };
    
            let raw = payload[14..14 + L6_RAW_LEN].to_vec();
            // ...
        }
    }

    教訓: u-blox の IDD はリビジョン番号が静かに上がる。自分の parser を書く時は version field を厳密に拒否する側に倒して、拒否ログから IDD 追従漏れを検知できるようにしておいた方がいい。今回は厳密 reject にしておいたおかげで 5 秒で気付いた。緩く受けてしまっていたら、後段の CLAS / QZNMA デコーダで意味不明な落ち方をして探すのに半日溶かしていた。

    罠 3: 屋内窓際だと bytes は出るが errStatus=Erroneous で CRC が通らない

    parser を直して走らせると、60 秒間で:

    final stats: L6D ch1=60 ch2=60, L6E ch1=0 ch2=0, total=120

    QZS-2 (svId=2) と QZS-7 (svId=7) から L6D の A/B 両チャネルが綺麗な 1Hz で届く。CNO は 32〜34 dBHz、bitErrCorr は 0。L6E (QZNMA) は今回ゼロだが、QZNMA は内閣府側で試験運用中のフェーズなので、観測タイミングや SV 依存で見えないことがある (運用情報を確認する必要あり)。

    ただし全フレームで errStatus=2 (Erroneous)。bytes は届いているが L6 フレーム内部の CRC が pass していない、という状態。屋内の窓際 + bias-T 給電のパッチアンテナ机置きでは、CNO が「届くが decode 品質には足りない」レンジに入りやすい。ANN-MB2-00 を屋外の開けた場所に持ち出した別検証では ErrorFree が立つ。

    用途別の判断:

    • 生バイト dump、parser 検証、UBX フレーミング検証: 屋内窓際で十分。バイト列は正しい構造で届く
    • CLAS デコード (raw → SSR/OSR 補正適用): CRC pass が前提なので屋外アンテナ必須
    • QZNMA 公開鍵検証: 同上、加えて L6E が実際に放送されているタイミングを CAO 公開資料で確認

    教訓: GNSS 評価はとりあえず屋内窓際から始めて「電気は来てる、UART は喋ってる」を確認しつつ、本格的な信号品質評価は早めに屋外に移すこと。「bytes が届く」と「decode が成立する」は別の状態であって、errStatus / CRC フラグはそれを区別してくれる重要な指標。

    まとめ

    • ArduSimple NEO-D9C Plugin で L6 raw 取り出しは、Plugin 本体の micro USB に直差しが最短。simpleRTK3B Compass の USB-C ルートは DIP スイッチ周りで詰まりやすい
    • UBX-RXM-QZSSL6 は version 0x01、payload 264 byte が現行 (UBX-21031777-R02)。自前 parser を書くなら version 厳密チェックで IDD 追従漏れを検知できるように
    • 屋内窓際は errStatus=Erroneous が普通。decode 成立を見たいなら屋外

    次は屋外でアンテナを開けた空に向けて errStatus=ErrorFree を取り、その上で CLAS decoder と QZNMA 公開鍵検証層を実装する。「自前で受信機を作る」モチベーションについてはまた別の記事で。

  • 連載 #0 ドラゴンレーダーを真面目に作る

    何を作るか

    ドラゴンボールに出てくる「ドラゴンレーダー」を、現代の UWB (Ultra-Wide Band) 測距技術で本当に動くハードウェアとして作る

    具体的に:

    • プレイヤーが持つ円形 LCD 端末に、周囲のドラゴンボールが距離と方向と共に表示される
    • ドラゴンボール側は小型タグ (UWB トランスポンダ) で、複数個 (理想は 7 個) を会場に散らせる
    • LCD は玩具版ドラゴンレーダー (1980 年代バンダイ) の画面をオマージュした緑グリッド + コーラル三角ポインタ + ゴールド光点
    • 押せばボタン音、近づけば効果音 (SE)、見つけたら BGM

    これを DigiKey Make ONE Challenge 2026 (応募締切 2026-06-22) に出展、その後 9 月の Maker Faire Tokyo にデモ持ち込みする計画で動いている。

    なぜ UWB か

    ドラゴンレーダー作品は過去にもファンメイドで何度か試みられているが、ほぼ全て GPSBLE RSSI画像認識 ベースで作られていて、

    • GPS は屋内で精度が出ない、しかも数 m 単位の誤差
    • BLE RSSI は壁 1 枚で値が乱れる、向き判定不可
    • 画像認識はカメラ視野内しか見えない、隠されたら終わり

    5 cm 精度で距離が分かる、向きも 1〜2° で分かる、見通し外でも壁 1 枚なら抜ける」という条件を満たすのは UWB が唯一の現実解。スマホ (iPhone 11 以降、Pixel 6 Pro 等) にも入っている技術で、AirTag が「家具の裏のものを正確に指し示す」のはこれ。

    ハード的には NXP の SR150 (ホスト + AoA 対応) と SR040 (タグ専用、超低消費電力) を採用する。Murata から評価ボード (Type 2BP / Type 2DK) が出ていて、国内技適も取られている。

    なぜバンダイ風 UI か

    「Apple Find My っぽい現代的な UI」も検討したが、面白くない。

    ドラゴンレーダーは 1984 年に玩具として実物が存在する (バンダイ商品)。緑の LCD に緑のドット、ゴールドの数字、コーラル色の三角ポインタ、円形フィルムを回すスイープ。子供だった世代が大人になった今でも、この画面を見せると「あっ」となる

    技術が当時のフィクションに追いついた瞬間を、当時のビジュアル言語のまま届ける。これが本作品のテーゼ。

    構成の全体像

    [ プレイヤー手持ち端末 ]
      ESP32-P4 (RISC-V 400MHz, PSRAM 32MB)
        + 800x800 円形 IPS LCD (Waveshare 3.4C)
        + LVGL 9 (Bandai 風レーダー UI)
        + microSD (BGM/SE/ボイス)
        + I2S Codec ES8311 + スピーカー
        +─[UART 3Mbps 有線]─ Type 2BP EVK (QN9090 + SR150 UWB)
    
                                        ↕ UWB 6.5GHz (Ch5/9)
    
    [ ドラゴンボール (タグ) × 7 個 ]
      Type 2DK EVK (QN9090 + SR040 UWB)
        + 樹脂筐体 (3D プリント、内側に光ファイバー散らし)
        + 単 4 電池 × 2

    ESP32-P4 と Type 2BP は 物理 UART で直接配線する。WiFi / BLE は技適まわりの判断から使わない。

    工程と現状

    Phase 内容 状態
    0 部品入手、SDK 入手、開発環境構築
    1 ESP32-P4 上で LCD + Bandai UI が動く (ダミーデータ)
    2a NXP UWBIOT SDK の MCUXpresso ヘッドレスビルド検証
    2b QN9090 ファーム改造で UWB ranging 結果を ASCII で吐かせる
    2c 改造ファームを焼いて、UART 3Mbps で距離 + 方位が流れることを実機確認
    2d ESP32-P4 ↔ Type 2BP の物理 UART 配線 + LVGL に実データ反映 進行中
    3 ドラゴンボール側 (Type 2DK) のタグ化 ファーム + 筐体 これから
    4 音声 / 効果音 / BGM これから
    5 全体筐体 CAD + 組立 これから

    開発開始は 2026-04-29、応募締切は 2026-06-22。今のところスケジュールに 1 週間以上の余裕がある。

    なぜここに書くか

    この作品は趣味として作るが、過程で得た技術知見 (UWB / NXP UWBIOT SDK / ESP-IDF / LVGL / 組込ハード設計 / 技適) は 同じことをやろうとする人にも役に立つはず。なので進行中の発見を分割して記事にしていく。

    最初に切り出すのは以下:

    1. dk6prog で QN9090 を USB ISP だけで焼く ← SWD ハード無しでもファーム書ける話
    2. MCUXpresso headless build でファームを 10 秒で回す ← IDE を立ち上げずにビルド
    3. UWB ranging が動かない原因は UCI 世代差だった ← 異種 UWB チップを組ませる時のハマりどころ
    4. ESP32-P4 × LVGL で Bandai ドラゴンレーダー UI を作る ← 玩具っぽい温かみある UI を LVGL で
    5. 技適未確認ボードをコンテストで使うなら ← 国内出展のための設計判断

    本作品の進捗は完成までこのブログで時系列に記録する。完成しなかったらそれもまた記録として残す。

    期待値の調整

    「ドラゴンレーダーが動く」とは何か、最初に明確にしておく:

    • ✅ 5 cm 程度の距離精度で近くにあるボールの距離が分かる
    • ✅ AoA でボールの方向 (左右) が分かる (前後判定は補助的)
    • ✅ 7 個まで同時表示できる (round-robin で測距)
    • ❌ 壁を貫通する透視能力は無い (UWB は壁 2 枚以上で減衰する)
    • ❌ 「ピッピッ」音は出るが、原作のサーチ音とは違う (著作権)

    現代の物理法則の上で実装可能な範囲で最大限フィクションに近づける、それが本作品の制約。

    完成記事 (動作デモ動画 + 全工程まとめ) は 6/22 直前に公開予定。それまでは過程の知見を順次共有する。

  • 技適未確認の WiFi ボードを国内コンテストに出すなら何を捨てるか

    要約

    中国メーカーから個人輸入した ESP32 系ボード (含む WiFi6 モジュール) を、国内のメイカーコンテストに展示することを考えるとき、最初にチェックすべきは「そのボード上の無線モジュールに技適マークがあるか」。

    技適マーク (Telec) は電波法に基づく型式認定で、これ無しの無線機を電波を出す状態で国内使用すると電波法違反になる。特例制度 (180 日試験運用) はあるが、公開イベント常時運用には合わない

    技適未確認ボードを「使うかどうか」を悩むのではなく、技適なしを前提にハードウェアアーキテクチャを設計し直すのが正しい意思決定。本記事ではその意思決定を最近実例ベースで整理する。

    何が問題なのか

    ESP32 系ボード、特に 「ESP32-P4 + ESP32-C6 (WiFi6 コンパニオン)」を組み合わせた製品は、Espressif 単体モジュールでは技適済の variant があるが、第三者メーカーが基板に載せて組み立てた完成品は技適マーク (基板上のシールド表面に R 003-XXXXXX の刻印) がそのまま継承されないことが多い。

    具体例:

    • Espressif の ESP32-C6-WROOM-1 モジュールには技適済 SKU が存在する
    • ところが Waveshare 系の「ESP32-P4-WIFI6-Touch-LCD-XC」のような完成品ボードに載っている ESP32-C6 が、技適済 SKU を使っているとは限らない
    • ボード上のシールドに R 番号が刻印されているか目視確認するしか手段がない

    筆者の手元の Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C は 目視で技適マーク確認できずだった。

    電波法の建付け

    総務省の電波法では、技適マーク (技術基準適合証明、特定無線設備の技術基準適合認定) 未取得の無線設備を国内で電波を発射する状態で使用すると違反となる (第 4 条 / 第 38 条系)。

    ただし救済として 電波法第 4 条の 2 に基づく「実験等無線局」/「特例制度」 があり、開発・実験目的に限り 180 日間届出ベースで使用できる枠が用意されている。

    ただし これは「自宅・自社の閉じた環境で開発する」想定の制度で、

    • 公開イベント (Maker Faire、コンテスト出展、商業展示) で「常時動いている」状態は実験運用とみなされにくい
    • 違反扱いになると刑事罰 (1 年以下の懲役または 100 万円以下の罰金) の可能性がある
    • 主催者側 (大型イベントは大概スタッフが電波法を理解している) から会場ルールでも止められる

    「コンテストに出す」という目的なら、特例制度を当てにせず WiFi/BT を最初から使わない設計にするのが現実解。

    設計上の意思決定

    筆者の場合は ESP32-P4 系ボードを LCD ホストとして使う前提があり、「ボード上の WiFi/BT を一切初期化せず、有線通信に切り替える」設計を採用した。

    具体的に何を犠牲にして何を残したか:

    機能 当初の構想 技適なし前提後
    無線測距 (UWB) UWB チップ (Murata SR150/SR040) で実装 同じ (UWB は別途技適済モジュールを使用)
    デバッグログ転送 WiFi で開発端末に飛ばす USB シリアルのみ
    センサノードとの通信 BLE / WiFi で散発接続 物理 UART 配線
    OTA ファーム更新 WiFi 経由 SD カード差し替え / USB
    Web 設定 UI スマホブラウザから WiFi 経由 LCD タッチ UI のみ

    物理配線で全部やる」と決めると、設計はむしろシンプルになる。ペアリング・認証・電波輻射の干渉考慮が消える。

    チェックリスト: 自分のボードで何を確認すべきか

    国内コンテスト用に中華製評価ボードを選ぶときは以下を順に:

    1. 基板上の無線モジュール (銀色シールド) 表面に技適マーク or R 003-XXXXXX の刻印があるか目視
    2. メーカー商品ページに「Japan / 技適 / Japanese certification」明示があるか
    3. 国内代理店経由なら、その代理店が技適済バージョンとして売っているか
    4. どれもダメなら → そのボードの WiFi/BT は使わない設計にするか、技適済の別ボード (ESP32-DevKitC-32E など) に置き換える

    技適済 ESP32 系の入手:

    • 国内代理店 (秋月電子、スイッチサイエンス、マルツ等) から正規流通品を買えば技適マーク済 (R 003-XXXXXX 刻印あり)
    • AliExpress / Taobao から個人輸入したものは大概グレー

    まとめ

    • 公開イベント展示が前提なら、WiFi/BT を使う設計を最初から避けるのが安全
    • 技適マークの有無は ボード本体ではなく、基板上の無線モジュールのシールドに刻印されているかで判断
    • ハードウェア構成を「技適なしを前提に再設計」する方が、特例制度を当てにするより早くて確実
    • 開発生産性的にも、有線通信に絞ると ペアリング・認証・電波干渉が消えてシンプルになることが多い

    最近の組込デバイスは「WiFi/BT が付いてるのがデフォルト」になっていて、それを使わない設計を最初から組むのは少し勇気がいるが、コンテスト出展という目的を考えると、技適まわりは設計上の制約ではなく前提として扱うべき項目。

    電波法は古い法律だが、まだ生きている。

  • ESP32-P4 × LVGL で「バンダイ ドラゴンレーダー」風 UI を本気で再現する

    要約

    ESP32-P4 + 800×800 円形 LCD (Waveshare 3.4C / IPS) という贅沢なハードで、1980 年代バンダイから出ていた玩具版「ドラゴンレーダー」の画面表示を再現してみた記録。

    ポイントは 4 つ:

    1. ── 緑 CRT 背景 + 太い黒グリッド + ゴールド数字 + コーラル三角ポインタ
    2. 三角ポインタ ── 「現在地」を表す塗り潰し三角を LVGL の lv_obj 矩形スタックで描く (LVGL 9 にはネイティブの三角プリミティブが無いため)
    3. スイープ ── 円弧 30 度幅の lv_arc を回し続ける
    4. 光点 (ドラゴンボール) ── 距離→対数スケール、AoA 方位→極座標変換で配置

    LVGL は工業 HMI に最適化されていて「アニメ・玩具風の温かみある画面」を作るのが意外と難しい。本記事はその難易度をどう越えたかの記録。

    ハードと前提

    • MCU: ESP32-P4 (RISC-V dual-core 400MHz、PSRAM 32MB)
    • LCD: 800×800 round IPS (Waveshare 3.4C)
    • フレームワーク: ESP-IDF v5.5.4、LVGL 9.5.0、esp_lvgl_adapter 経由
    • 描画モード: triple partial buffer + tear-avoid

    LCD は MIPI-DSI で繋がっていて、フレームバッファは PSRAM に乗る。Waveshare BSP (waveshare/esp32_p4_wifi6_touch_lcd_xc) を managed component で取り込めば LVGL までの配線は数行で済む。

    カラーパレット

    玩具版の写真を観察してパレットを取った:

    // theme.h
    #define DR_COLOR_BG       lv_color_hex(0x2A9040)  // 緑 CRT
    #define DR_COLOR_GRID     lv_color_hex(0x001008)  // ほぼ黒
    #define DR_COLOR_SWEEP    lv_color_hex(0xA0FFB0)  // スイープのトレイル
    #define DR_COLOR_TEXT     lv_color_hex(0xFFD700)  // ゴールド (数字 / ラベル)
    #define DR_COLOR_DOT      lv_color_hex(0xFFC800)  // ドラゴンボール (オレンジゴールド)
    #define DR_COLOR_POINTER  lv_color_hex(0xFF6E50)  // コーラル (中央三角)

    ポイントは 背景の緑をくすませる こと。サチった #00FF00 を使うと「LED マトリクス」っぽくなり玩具感が消える。 #2A9040 くらいのやや暗くて青みのある緑が CRT 蛍光体っぽくて良い。

    グリッド

    LVGL の標準では「画面全体に等間隔の格子線」を引くプリミティブは無いので lv_line_create を縦横ループで配置する。

    #define GRID_STEP 50
    for (int x = GRID_STEP; x < DR_SCREEN_SIZE; x += GRID_STEP) {
        static lv_point_precise_t pts[20][2];
        int i = x / GRID_STEP - 1;
        pts[i][0].x = x; pts[i][0].y = 0;
        pts[i][1].x = x; pts[i][1].y = DR_SCREEN_SIZE;
        lv_obj_t *line = lv_line_create(parent);
        lv_line_set_points(line, pts[i], 2);
        lv_obj_set_style_line_color(line, DR_COLOR_GRID, 0);
        lv_obj_set_style_line_width(line, 2, 0);
        lv_obj_set_style_line_opa(line, LV_OPA_80, 0);
    }

    lv_point_precise_tstatic にしないと描画タイミングでスタック上のメモリが消えて画面に変な線が現れる。LVGL のプリミティブはポインタを保持するだけで、内部コピーしないことが多いので注意。

    三角ポインタ ── 一番苦戦したパーツ

    LVGL 9 に 塗り潰し三角プリミティブは無いlv_canvas で自前描画してもいいが、800×800 のフルキャンバスを PSRAM に保持するのは重い。

    そこで採用したのが 水平スキャンラインで矩形を積む方式:

    #define PT_SIZE 16     // 三角の半幅
    #define PT_STEP 2      // スキャンラインの厚さ
    const int y_top    = DR_CENTER - PT_SIZE;
    const int y_bottom = DR_CENTER + PT_SIZE / 2;
    const int height   = y_bottom - y_top;
    
    for (int y = y_top; y <= y_bottom; y += PT_STEP) {
        int dy = y - y_top;
        int half_w = (dy * PT_SIZE) / height;
        if (half_w < 1) half_w = 1;
    
        lv_obj_t *seg = lv_obj_create(parent);
        lv_obj_remove_style_all(seg);
        lv_obj_set_size(seg, half_w * 2, PT_STEP);
        lv_obj_set_pos(seg, DR_CENTER - half_w, y);
        lv_obj_set_style_bg_color(seg, DR_COLOR_POINTER, 0);
        lv_obj_set_style_bg_opa(seg, LV_OPA_COVER, 0);
    }

    頂点が上、底辺が下の塗り潰し三角を、PT_STEP=2 ピクセル幅の矩形を ~12 個積んで再現する。メモリは矩形 12 個分しか食わない (内部的に lv_obj は数百バイト)。LVGL の合成エンジンが矩形描画はべらぼうに速いので描画コストもタダ同然。

    回転させたい場合は lv_obj_set_style_transform_angle でグループ全体を回せばよい。三角ポインタを「機体の向きに合わせて回転」させる用途にも使える。

    スイープアーク

    円弧を回すのは LVGL のお家芸:

    lv_obj_t *arc = lv_arc_create(parent);
    lv_obj_set_size(arc, DR_RADAR_RING_R2 * 2, DR_RADAR_RING_R2 * 2);
    lv_obj_align(arc, LV_ALIGN_CENTER, 0, 0);
    lv_arc_set_bg_angles(arc, 0, 360);
    lv_arc_set_rotation(arc, 270);
    lv_arc_set_angles(arc, 0, 30);  // 30 度幅
    lv_obj_set_style_arc_color(arc, DR_COLOR_SWEEP, LV_PART_INDICATOR);
    lv_obj_set_style_arc_width(arc, DR_RADAR_RING_R2, LV_PART_INDICATOR);
    lv_obj_set_style_arc_opa(arc, LV_OPA_30, LV_PART_INDICATOR);
    
    lv_anim_t a;
    lv_anim_init(&a);
    lv_anim_set_var(&a, arc);
    lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_arc_set_rotation);
    lv_anim_set_values(&a, 270, 270 + 360);
    lv_anim_set_duration(&a, 1500);
    lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
    lv_anim_start(&a);

    arc_width = R2 (リング全径) にして、塗り部分の不透明度を 30% に落とすと「スポットライトが回転する」風になる。トレイル感を出したいなら 2 本目のアークを反対側に置いて opa=10% にする手もある。

    光点 (ドラゴンボール) 配置

    UWB ranging から流れてくる distance_mmazimuth_deg を画面座標に変換する:

    static int32_t distance_to_radius_px(uint16_t distance_mm) {
        if (distance_mm == 0) return 0;
        if (distance_mm >= DR_RANGE_MAX_MM) return DR_RADAR_RING_R2;
        float ratio = logf(1.0f + (float)distance_mm / 1000.0f)
                    / logf(1.0f + (float)DR_RANGE_MAX_MM / 1000.0f);
        return (int32_t)(DR_RADAR_RING_R2 * ratio);
    }
    
    static void polar_to_cartesian(uint16_t distance_mm, int16_t azimuth_deg,
                                   int32_t *x, int32_t *y) {
        int32_t r = distance_to_radius_px(distance_mm);
        float az_rad = azimuth_deg * M_PI / 180.0f;
        *x = DR_CENTER + (int32_t)(r * sinf(az_rad));
        *y = DR_CENTER - (int32_t)(r * cosf(az_rad));
    }

    距離 → 半径は 対数スケールにする。線形だと「近い時は中心にくっつく、遠い時はリングの端に張り付く」となるが、人間の空間感覚に合うのは対数。1m での解像度を大きく取り、10m の精度を犠牲にする。

    光点本体は丸オブジェクトに シャドウで発光感を出す:

    lv_obj_t *dot = lv_obj_create(parent);
    lv_obj_set_size(dot, 30, 30);
    lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0);
    lv_obj_set_style_bg_color(dot, DR_COLOR_DOT, 0);
    lv_obj_set_style_shadow_color(dot, DR_COLOR_DOT, 0);
    lv_obj_set_style_shadow_width(dot, 28, 0);
    lv_obj_set_style_shadow_opa(dot, LV_OPA_70, 0);

    shadow_width = 28 (本体径とほぼ同じ) で等方ハローができ、ドラゴンボールの「内側から光ってる」雰囲気が出る。

    ハマりどころメモ

    症状 原因 / 対処
    起動時にリンカが --enable-non-contiguous-regions discards section ... 21KB ESP32-P4 v1.x シリコンで CONFIG_SPIRAM_XIP_FROM_PSRAM 有効だと IRAM 溢れ → 無効化
    LCD が真っ黒 CONFIG_LV_USE_PERF_MONITOR=nCOMPILER_OPTIMIZATION_SIZE=y、L2 cache 128KB に落とす
    描画がカクつく LV_COLOR_DEPTH=16、frame buffer を PSRAM に置く、triple_partial モード
    緑がサチった LED 色になる パレットを #2A9040 系のくすんだ緑に。#00FF00 は使わない

    動かしてみた

    LCD のフォトを撮って横で玩具のドラゴンレーダーと比較した時、「電池入れて 30 年経った玩具」っぽい質感が出てしまった (緑が少しくすんでいる + 黒グリッドが太い + フォントがゴールド) ── 結果としてその”古色感”が逆に良かった。

    LVGL は HMI 用ライブラリだけど、プリミティブを工夫すれば「玩具・アニメ的な温かみのある UI」も十分作れる。三角プリミティブが無いから諦める前に、矩形スタックで作れないか考えてみる価値はある。

  • UWB ranging が動かない真犯人は「UCI 世代差」だった話

    要約

    異なる NXP UWB チップ (SR150 / SR040) を載せた 2 つの評価ボードを ranging させようとしたら、

    • 両者の RANGE_DATA_NTF (測距結果通知) に乗っている session ID が違って見える
    • 一方は 0x00000001、もう一方は 0x11223344

    「session ID が違うから pair しないんだ」と最初は誤診したが、SDK のソースを当たると 両方ともデフォルトで 0x11223344 を設定していることが判明。

    実際に違うのは UCI (UWB Command Interface) のプロトコル世代で、UCI v1.x は Session ID を payload にそのまま乗せるが、UCI v2.0 以降は Session Handle というラッパに置き換わる。通知フィールドが見た目同じバイト位置でも、解釈が違うので一致しない

    両方を UCI v1.31 世代の SDK に揃えた瞬間に ranging が成立した。誤診から本質に辿り着くまでの記録。

    環境

    • Initiator: NXP SR150 ベースの評価ボード (Murata Type 2BP EVK Rev 4.1)
    • Responder: NXP SR040 ベースの評価ボード (Murata Type 2DK EVK)
    • 両者にメーカープリビルド済の standalone ranging デモを焼いて、UART 経由で生の UCI トレースを観察

    最初の観測

    電源を入れて並べると、2 つの EVK はとりあえずお互いの存在は認識する (TX/RX が走る) のだが、ranging が完了しない。Murata 提供の Python テストスクリプトも distance を 1 度も print しないまま終わる。

    両方の UART を cat /dev/ttyUSB* で生キャプチャして UCI フレームを抜き出すと、

    • 2BP (SR150 / 新しい SDK) 側: NTF 内の session 識別フィールドが 01 00 00 00 (リトルエンディアン解釈で 0x00000001)
    • 2DK (SR040 / 古い SDK) 側: 同じ位置が 44 33 22 11 (リトルエンディアン解釈で 0x11223344)

    ぱっと見「2 つの session ID が違う、だから ranging が成立しない」と読みたくなる。これが間違いだった。

    SDK のソースを当たる

    それぞれの SDK のサンプルコードを grep -nrR SESSION_ID する。

    demos/SR1XX/demo_ranging_controller/demo_ranging_controller.c:
      #define RANGING_APP_SESSION_ID 0x11223344
    
    demos/SR040/demo_tracker_sr040/app_Ranging_Cfg.h:
      #define RANGING_APP_SESSION_ID 0x11223344

    両方とも 0x11223344 を設定している。つまりアプリケーション層で投げているセッション ID は一致しているはず。

    ということは、UART で見ている「session 識別フィールド」がそもそも別物を指している可能性が高い。

    UCI 仕様書を読み直す

    NXP の SR150 UCI Specification を当たると、v1.x と v2.0 で RANGE_DATA_NTF の最初の数バイトの定義が変わっている:

    バイト位置 UCI v1.x UCI v2.0
    0-3 Sequence Number Sequence Number
    4-7 Session ID (アプリが設定した 32-bit 値そのまま) Session Handle (UCI スタックが割り当てた識別子)
    8-11 Rcr Block Index Rcr Block Index

    v2.0 で導入された Session Handle は SDK 内部で SessionInit 時に動的に発行される (例: 0x00000001 から順に割り当てる) 識別子で、アプリが設定した Session ID とは別物。アプリ層 API では Session ID を渡すが、UCI バイナリ層では Handle に置き換わる。

    つまり、

    • SR150 + 新しい SDK: UCI v2.0 → NTF の 4-7 バイト目には Session Handle (0x00000001) が乗る
    • SR040 + 古い SDK: UCI v1.x → NTF の 4-7 バイト目には Session ID (0x11223344) がそのまま乗る

    両者は同じバイト位置に別の意味の値を入れていて、当然 ranging session の照合に失敗する。「session ID が違う」のではなく「session 識別の規約が違う」が本質だった。

    揃え方

    選択肢は 2 つ:

    1. SR040 側の SDK を v2.0 世代に上げる
    2. SR150 側の SDK を v1.x 世代に下げる

    SR040 はメーカー (Murata) が校正済の standalone バイナリを v1.x 世代でしか出してくれていなかったので、現実解は (2)。SR150 側を NXP UWBIOT SDK v04.04.03 (UCI v1.31) の bare バイナリに差し替え、両者の UCI 世代を v1.31 に統一した。

    結果: 焼き直して電源入れた瞬間、UART に TWR[0].distance : 26 のような distance 値が湯水のように流れ始めた。20 秒で 200+ サンプル、distance は両端で平均 0.5cm 以内に一致 (DS-TWR が正しく完走している証拠)。

    教訓

    • 「session ID が違う」のように『見える』状態を素直に取らない。NTF のバイト位置と UCI 仕様書の世代を必ず照合する
    • 異種チップを組ませる時は UCI 仕様の世代揃えが必須。SR150 と SR040 は同じ NXP 製でも世代が異なる SDK で出てくる
    • デバッグの第 1 歩は SDK ソースの grep。「定数として何を設定しているか」を直接確認すれば、UART で観測した値との差分が見える

    UCI が業界標準 (FiRa Consortium 仕様) になりつつあるとはいえ、世代差を吸収するレイヤはまだ薄い。UWB を触る時は両端の SDK バージョンを意識して揃えることが、ranging を素直に動かす近道。

  • MCUXpresso IDE の headless build で組込ファームを 10 秒で回す

    要約

    NXP が出している MCUXpresso IDE は Eclipse CDT ベースなので、見た目は GUI でも中身は コマンドラインから単独でビルドを叩ける

    mcuxpressoide -application org.eclipse.cdt.managedbuilder.core.headlessbuild という呪文を覚えてしまえば、IDE を立ち上げる時間も、Eclipse のウィンドウマネージャ周りで詰まる時間もゼロにできて、1 回 9 秒 でビルドが回るようになる。

    「IDE で開いて、メニューから Build をクリックして、終わるのを待って」というループを 10 倍速くしたい人向けの記事。

    なぜ GUI で回すと遅いか

    NXP UWBIOT SDK のようなボリュームある SDK を MCUXpresso GUI で開くと、

    1. ワークスペースを開くだけで 30 秒以上
    2. インデックス再構築で CPU 100% が 2〜3 分
    3. Build のたびに「Project Indexer Update」が走り直して数十秒

    ソースを 1 行直して焼き直したいだけなのに毎回これに付き合うのは消耗する。

    Eclipse CDT は元々 IBM (JDT) から派生した CDT 部分でヘッドレスビルダを持っていて、-application org.eclipse.cdt.managedbuilder.core.headlessbuild を渡せば IDE の UI を起動せずに同じツールチェーンで .project / .cproject をビルドできる

    環境

    • Ubuntu 24.04
    • MCUXpresso IDE for Linux (/usr/local/mcuxpressoide-25.6.136/ide/)
    • 内蔵 JRE (OpenJDK 17) と内蔵 arm-none-eabi-gcc を使う

    ヘッドレスビルド呪文

    xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" \
      /usr/local/mcuxpressoide-25.6.136/ide/mcuxpressoide \
      -nosplash \
      -application org.eclipse.cdt.managedbuilder.core.headlessbuild \
      -data /tmp/mcux-ws \
      -import /path/to/project/MyProject \
      -cleanBuild "MyProject/Debug"

    各オプションの意味:

    オプション 意味
    xvfb-run Linux サーバや CI で X が無くても Eclipse の GTK 依存を満たす仮想ディスプレイを立てる
    -nosplash スプラッシュ画面を出さない (ヘッドレスでは不要)
    -application <id> Eclipse プラットフォームの起動アプリケーションを指定。CDT のヘッドレスビルダはこの ID
    -data <dir> Eclipse ワークスペース。事前に存在しなくても自動作成される
    -import <project> .project のあるディレクトリ。プロジェクト名はその中身から読み取られる
    -cleanBuild "Project/Config" クリーンビルドする対象を「プロジェクト名/ビルドコンフィグ名」で指定

    ポイントは Project/Config がプロジェクト内の .cproject に書かれている名前と完全一致しないとダメ という所。GUI で右クリック → Properties → C/C++ Build → 設定タブのリスト名と一致する。

    .cproject ファイル内で:

    <configuration name="Debug" ...>
    <configuration name="Release" ...>

    これを grep するのが手っ取り早い。

    結果

    筆者のケース (NXP UWBIOT SDK v04.04.03 + Murata Type 2BP 向け demo_ranging_controller) では:

    [1/12] Performing build step for 'bootloader'
    ...
    [10/12] Linking CXX executable RhodesV4_SE.axf
    Memory region         Used Size  Region Size  %age Used
       PROGRAM_FLASH:      353180 B     629471 B     56.11%
                SRAM:       32576 B        87 KB     36.57%
               SRAM1:         45 KB        64 KB     70.31%
    [11/12] Generating binary image
    ...
    Build Finished. 0 errors, 1 warnings. (took 9s.620ms)

    9.6 秒で .axf + .bin まで生成される。GUI で開きっぱなしより速い (差分ビルドなら GUI でも数秒だが、起動時間を含めると勝てない)。

    おまけ: シェル関数化

    毎回タイプするのは流石に辛いので zshrc / bashrc に入れておく:

    mcux_build() {
      local proj="$1"
      local cfg="${2:-Debug}"
      xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" \
        /usr/local/mcuxpressoide-25.6.136/ide/mcuxpressoide \
        -nosplash \
        -application org.eclipse.cdt.managedbuilder.core.headlessbuild \
        -data /tmp/mcux-ws \
        -import "$proj" \
        -cleanBuild "$(basename "$proj")/$cfg"
    }
    
    # 使う側
    mcux_build /path/to/MyProject Debug

    これで mcux_build . のような短い呼び出しで CI でも開発でも使える。

    ハマりどころメモ

    症状 原因 / 対処
    No projects matched -cleanBuild の引数が .cproject のコンフィグ名と一致してない
    DISPLAY 環境変数が設定されてない xvfb-run を使う、もしくは X 転送 (ssh -X)
    Workspace not initialized -data で指定したパスのパーミッションが書込み不可。/tmp/ 以下が無難
    ビルドが空のサイレント終了 -import のパスが間違っているとログも吐かず終わることがある。絶対パス推奨

    何が良くなったか

    • 編集 → ビルド → 焼き のループが体感 1 桁速くなる
    • CI で組込ファームをビルド できる。Pull Request ごとにファームのバイナリ差分を取れる
    • IDE GUI のメモリ食いが消えるので、開発端末のリソースを LLM チャットや別 IDE に回せる

    NXP に限らず Eclipse CDT ベースの組込 IDE (STM32CubeIDE 等) は同じ仕組みで動く。「IDE を立ち上げてビルドする」習慣をやめると、組込開発の生産性は素直に上がる

  • dk6prog (SPSDK) で QN9090 を USB ISP だけで焼く

    要約

    NXP QN9090 を載せた評価ボード (Murata Type 2BP EVK / 2DK EVK 等) のファームを書き換えるとき、SWD (J-Link / MCU-Link Pro) が標準ルートとされるが、ボードのデバッグピンが折れていたり配線できない事情があるとそこで詰まる。

    そこで便利なのが SPSDK に含まれる dk6prog という Python ツールで、USB ケーブル 1 本だけで ISP (In-System Programming) モードに入って書き換えできる。

    本記事では Linux 上で pip install spsdk だけで導入して、QN9090 のフラッシュ全領域を吸い出してバックアップ → カスタムファームを書き込む手順を 1 枚にまとめる。

    なぜこれが効くか

    QN9090 (および兄弟の DK6 シリーズ) は ROM ブートローダに ISP モードを備えていて、特定ピン (PIO_5 / ISP_ENTRY) を引いた状態でリセットすると UART 経由でフラッシュ操作を受け付ける。Murata / NXP の DK6 EVK はオンボードに FTDI FT230X USB-UART ブリッジが載っていて、USB ケーブル 1 本でこの UART に到達できる。

    SWD で書ける環境を持っていない人 (J-Link を持っていない、MCU-Link Pro が手元にない、開発端末が Windows 縛りで動かないなど) でも、Python が動けば書けるのが大きい。

    手順

    0. 前提

    • 開発端末: Ubuntu 24.04 (Linux 一般、macOS でも同様)
    • ターゲット: NXP QN9090 ベースの DK6 EVK (例: Murata Type 2BP / 2DK)
    • USB ケーブル (データ通信対応 micro-USB、充電専用品はダメ)

    1. SPSDK をインストール

    pip install spsdk pyftdi

    (venv 推奨。SPSDK は依存が多めなので素の Python に入れると他プロジェクトと衝突しやすい。)

    2. USB 接続して認識を確認

    EVK と PC を USB でつないだ状態で:

    dk6prog listdev
    List of available devices:
    DEVICE ID: DM86TTWC, VID: 0x403, PID: 0x6015, ...

    DEVICE ID がそのまま -d の引数になる。Murata 出荷品は全 EVK で同じシリアルが振られていることがあり、複数挿していると後続コマンドで衝突するので、作業時は対象 1 個だけ挿す

    3. ISP モードに入れて疎通確認

    EVK の ISP ボタン (PIO_5 / ISP_ENTRY) を押しながら RESET を離すと ISP モードに入る (ボードによっては「ISP」シルク印刷がある専用ボタン、ない場合は基板上のテストパッドを GND に落とす)。

    dk6prog -d DM86TTWC info
    Chip ID: 0x88888888
    ROM Version: 0x140000cc
    
      Memory   Memory ID   Base Address   Length    Sector Size
    ----------------------------------------------------------------
      FLASH    0           0x0            0x9de00   0x200
      PSECT    1           0x0            0x1e0     0x10
      ...

    info が返ってくれば疎通 OK。

    4. 焼く前に「出荷時 FW」をバックアップする

    これが本記事で一番伝えたい部分。Murata の EVK は出荷時にメーカー校正済の FW が入っており、これを潰すと TX パワーや水晶発振の校正が失われる。

    dk6prog -d DM86TTWC read 0 0x9DE00 -o factory_backup.bin

    0x9DE00 (約 632KB) が FLASH 全長。1 度だけ取って、git に上げる代わりに「火事から助かる」場所に保管しておくと心理的に強い。

    5. カスタムファームを書き込む

    dk6prog -d DM86TTWC erase 0 0x9DE00
    dk6prog -d DM86TTWC write 0 my_custom_fw.bin

    (erase は write の中で勝手にやってくれる版もあるが、明示的に erase してから write する方が事故が少ない。)

    6. 復旧したいとき

    dk6prog -d DM86TTWC erase 0 0x9DE00
    dk6prog -d DM86TTWC write 0 factory_backup.bin

    これで step 0 の状態に戻る。安心。

    ハマりどころメモ

    症状 原因 / 回避
    2 USB devices match URL 同じシリアルの EVK が複数挿さっている。物理的に片方を抜く
    no langid permission issue pyftdi backend は libusb で root 権限要求することがある → sudo -E で実行 (環境変数を保持しないと PATH が消えるので -E 必須)
    Failed to enter ISP mode ISP ボタン押しが遅い / 早い。USB を抜いてボタン押しっぱなしで挿し直す → リセットボタンも併用
    Chip ID: 0x88888888 のまま これは正常 (ISP モードで MAC アドレスが返らない仕様)

    何が良くなったか

    • SWD ハードが要らない (J-Link / MCU-Link Pro は 1 台 1〜2 万円)
    • OS を選ばない (Windows 限定の DK6Programmer.exe に縛られない)
    • CI に組み込める (シェルから叩けるので、ビルド → 焼き → テスト を一筆書きできる)

    「SWD ピンが曲がってた」「IDE のドライバが入らない」みたいな ハードウェアやアーキテクチャに本質的でない問題で停滞している人にとって、dk6prog 経由のフラッシュは数時間〜数日を取り戻すポテンシャルがある。

    参考

  • 全世界の車で動く地図ソフトウェアに、たった 4 バイトで攻撃できる脆弱性が見つかりました ── CVE-2026-33524 / CVE-2026-33666

    全世界の車で動く地図ソフトウェアに、たった 4 バイトで攻撃できる脆弱性が見つかりました ── CVE-2026-33524 / CVE-2026-33666

    たった数バイトのデータで、世界中を走る何億台もの車のカーナビ・自動運転支援システムをまとめて停止させられる ── そんな脆弱性 2 件を、トヨタ・BMW・ベンツなど世界 43 社の自動車メーカーが共通で使う地図処理ソフトに発見しました。既に修正済みですが、悪用されていれば全世界規模の被害になり得た重大事案です。

    トヨタ、BMW、VW、メルセデス・ベンツなど世界の主要自動車メーカー 43 社が採用する地図データ規格 NDS (Navigation Data Standard) の中核ライブラリ zserio に、深刻な脆弱性 2 件を発見しました。2026 年 4 月 24 日に CVE-2026-33524CVE-2026-33666 として公開されています(修正済)。

    どのくらいの規模に影響するのか

    NDS 規格を採用している主要自動車メーカーの 年間生産台数を合算するだけで、影響範囲は桁違いです:

    OEM 年間生産台数の目安
    トヨタ(Woven by Toyota) 約 1,000 万台
    Volkswagen Group(CARIAD) 約 900 万台
    Hyundai(現代自動車) 約 700 万台
    Stellantis(クライスラー・プジョー・フィアット・ジープ等 14 ブランド) 約 600 万台
    Renault・日産アライアンス 約 700 万台
    BMW Group 約 250 万台
    Mercedes-Benz Group 約 250 万台
    Volvo Cars / NIO 等 数百万台
    合計(NDS 加盟主要 OEM) 年間 約 4,500 万台超

    NDS は 2010 年代から業界標準として動いてきました。10 年以上にわたって毎年これだけの規模で生産されてきた車のうち、ナビゲーション・ADAS・OTA 地図更新システムが NDS データを処理しているわけです。

    つまり 「何百万台」どころか、すでに何億台規模の車が今この瞬間も路上で zserio によってデータを処理しています。日本国内の保有台数(約 8,200 万台)より遥かに大きい規模です。

    どんな攻撃ができるのか

    攻撃に必要なペイロードは わずか 4-5 バイト。電子メール 1 文字より小さいデータ量で、以下のような攻撃が成立してしまいます。

    攻撃シナリオ 起こりうる結果
    クラウド経由の地図更新に細工データを混入 広域で同時多発的にカーナビが停止
    地図データのサプライチェーンに介入 1 箇所への侵入で、何百万台の車に影響
    地図処理サーバーに不正データを送信 自動車メーカー・地図ベンダーの 基幹インフラがダウン
    32-bit 車載 ECU 上で誘発 ADAS(運転支援機能)が停止

    数字で言うと、たった 4 バイトのデータで 16 GB のメモリを確保させられる(増幅率 約 2 億倍)。5 バイトでサーバーごとシステムクラッシュします。

    この事案で特に怖い点

    通常のサイバー攻撃と比べたとき、この脆弱性には 3 つの「悪条件が揃った」特徴があります:

    • 攻撃データが数バイトで済む:通常の DoS 攻撃のような大量トラフィックを必要とせず、4-5 バイトの細工データを 1 度流すだけで成立する。異常検知システムをすり抜けやすい
    • 業界横断で影響が伝播する:43 社の自動車メーカーが共通の NDS 規格を採用しているため、地図データの配信経路 1 箇所への介入だけで メーカーをまたいで一斉に被害が出うる
    • 路上の車に直接届く経路が存在する:クラウド地図更新(NDS.Live)が攻撃面になっており、サーバー側だけでなく 走行中の車載システムまで攻撃が届く

    修正は責任ある開示プロセス(90 日 coordinated disclosure)を経て、CVE 公開前に完了しています。

    報告から公開までの流れ

    日付 出来事
    2026-03-08 Woven by Toyota PSIRT に報告
    2026-03-10 zserio メンテナに GitHub Security Advisory 経由で報告
    2026-04-23 修正版 zserio v2.18.1 リリース
    2026-04-24 CVE 公開

    90 日の coordinated disclosure(責任ある開示)の枠内で、開発元と協力して修正してから公開する、という手順を踏みました。zserio 開発チームと Woven by Toyota PSIRT には迅速な対応をいただきました。


    以降はもう少し詳しく知りたい方向けの補足情報です。

    影響範囲:NDS Association 加盟企業(主要抜粋)

    zserio が支える NDS 規格に参加している主要企業を、ジャンル別に並べてみます。

    自動車メーカー(OEM):
    – 🇯🇵 Woven by Toyota(トヨタ自動車)
    – 🇯🇵 日産自動車(Nissan)
    – 🇩🇪 BMW Group
    – 🇩🇪 Mercedes-Benz Group(メルセデス・ベンツ)
    – 🇩🇪 CARIAD(フォルクスワーゲン傘下のソフトウェア会社)
    – 🇮🇹🇫🇷 Stellantis(クライスラー・プジョー・シトロエン・フィアット・ジープなど 14 ブランドを擁する世界 4 位 OEM)
    – 🇰🇷 Hyundai(ヒョンデ)
    – 🇸🇪 Volvo Cars
    – 🇫🇷 Renault
    – 🇨🇳 NIO

    地図・ナビデータプロバイダー:
    – 🇯🇵 ZENRIN(ゼンリン)
    – 🇯🇵 Dynamic Map Platform
    – 🇳🇱 HERE Technologies
    – 🇳🇱 TomTom
    – 🇨🇳 NavInfo / Autonavi
    – 🇮🇳 Mappls MapmyIndia

    自動車部品・Tier 1 サプライヤー:
    – 🇯🇵 Denso(デンソー)
    – 🇯🇵 Aisin(アイシン)
    – 🇯🇵 Alps Alpine(アルプスアルパイン)
    – 🇯🇵 Pioneer(パイオニア)
    – 🇯🇵 Mitsubishi Electric(三菱電機)
    – 🇯🇵 Astemo(日立 Astemo)
    – 🇩🇪 Bosch(ボッシュ)
    – 🇩🇪 ZF
    – 🇩🇪 Elektrobit

    カーオーディオ・インフォテインメント:
    – 🇺🇸 Harman(サムスン傘下)
    – 🇩🇪 Bertrandt
    – 🇭🇺 NNG
    – 🇨🇳 JoyNext

    IT・クラウド・半導体:
    – 🇨🇳 Tencent(テンセント)
    – 🇨🇳 Huawei(ファーウェイ)
    – 🇺🇸 NVIDIA

    …など 計 43 社。日本企業だけでもトヨタ・日産・デンソー・アイシン・パイオニア・三菱電機・ゼンリン・アルプスアルパインなど名だたる企業が参加しており、これらの企業の地図・ナビ・運転支援システムの ほぼすべてが zserio を経由してデータを処理しています

    技術的な詳細は

    技術者向けの解説(脆弱なコード、PoC、修正方針)は GitHub の Advisory ページ に詳しく書かれています。

    参照・データの出典

    本記事中の数字・主張の根拠:

IP: 取得中...
216.73.217.150216.73.217.150