# 06 — Part 2: implementation record & current state > **Status: Part 2 IMPLEMENTED (2026-07-02).** As-built companion to the > [`04`](04-part2-single-app-merge.md) plan: the pcbnew and eeschema kifaces are now > statically linked into **one** WASM executable, `kicad_editor`, and all four editors > (PCB / Footprint / Schematic / Symbol) are runtime `--frame` choices of that single > bundle. One editor per page load (unchanged UX); multi-frame/cross-probing remains a > follow-up this merge lays the groundwork for. ## One-paragraph summary `kicad_editor.wasm` (178 MB at `-O1` debug vs 147 + 82 for the two engines separately — shared wx/common/boost linked once) serves every editor tool. The frontend maps all four editor tools onto the one bundle (`TOOL_BUNDLE`) and passes `--frame=pcb|fpedit|sch| symedit`. The merge's real work was symbol-collision surgery: each engine's `Kiface()` accessor and `KIFACE_1` getter got per-engine names via CMake compile definitions (zero call-site edits), **21 additional ODR collisions found by a symbol audit** (upstream reuses class names like `DIALOG_TEXT_PROPERTIES` for *different* classes per module) were renamed on the PCB side the same way, six shared `common/` call sites now resolve their owning frame's kiface exactly, and the duplicated embind layer became per-editor entries behind a single dispatching registration. ## What changed ### C++ — `kicad` submodule (commit `a542e264`; ALL gated on `KICAD_WASM_MERGED_EDITOR`, OFF by default — option-OFF trees and native builds are byte-identical) | File | Change | |---|---| | `include/kiway.h` | `KIFACE_GETTER` macro `#ifndef`-guarded so each kiface can carry a distinct getter symbol. | | `CMakeLists.txt` | `KICAD_WASM_MERGED_EDITOR` option; `KICAD_WASM_PCB_SIDE_RENAMES` (the `Kiface=PcbKiface` static binding + the 21 audited ODR renames, see below); gated `add_subdirectory(${KICAD_WASM_LAYER}/editor)` after both engines. | | `pcbnew/CMakeLists.txt`, `eeschema/CMakeLists.txt`, `common/CMakeLists.txt` | Apply the renames to `pcbnew_kiface_objects` + `pcbcommon` / `Kiface=SchKiface` to `eeschema_kiface_objects`; per-source `KIFACE_GETTER=_kiface_getter` on the hoisted `pcbnew.cpp`/`eeschema.cpp`; both `*_KIFACE_LIBRARIES` lists exported `CACHE INTERNAL` for the out-of-fork merged target. | | `common/single_top.cpp` | `KICAD_MERGED_KIFACES` branch registers **both** faces (`set_kiface(FACE_PCB/FACE_SCH, …)`, `OnKifaceStart` stays lazy — the native project manager's model); **latent Part 1 bug fixed**: `PreloadLibraries` now uses the runtime-resolved frame, not compile-time `TOP_FRAME` (a `--frame=sch` boot on the PCB-default image preloaded the wrong face). | | `common/eda_base_frame.cpp`, `common/dialogs/dialog_color_picker.cpp`, `common/design_block_tree_model_adapter.cpp` | The six real `Kiface()` call sites in shared code (`config()`/`sys_search()`/`help_name()` + color picker ×2 + design-block tree) resolve their owning frame's kiface exactly (`m_ident` → `KifaceType` → `KiFACE`), falling back to the global accessor. `__EMSCRIPTEN__`-only; standalone-tree behavior identical. | ### Why static per-engine binding (not one "active kiface" pointer) PCB code runs while a **schematic** frame is active — e.g. the symbol chooser's footprint preview reaches `KiFACE(FACE_PCB)` via `common/project.cpp`, after which `pcbcommon` painter/IO code (`pcb_painter.cpp`, `pad.cpp`, …) calls `Kiface()` with no frame context and must get the **PCB** kiface. A focus-based global would hand it the schematic one. Hence: module code binds statically (`Kiface=PcbKiface`/`SchKiface`); only the merged image's fallback `Kiface()` (in `wasm/editor/merged_kiface_dispatch.cpp`) does a focus/top-window walk, and the six common/ sites resolve per-frame. ### The ODR audit (Stage 0 gate — the research doc's list was incomplete) `llvm-nm` strong-symbol intersection of the two kiface object sets + weak-symbol intersection minus shared-lib symbols found, beyond `Kiface()`/`KIFACE_1`: - Same-name-**different-class** pairs (vtables/typeinfo/inline members would silently cross-bind under `--allow-multiple-definition` → memory corruption, not link errors): `DIALOG_TEXT_PROPERTIES`, `DIALOG_SHAPE_PROPERTIES`, `DIALOG_TABLE_PROPERTIES`, `DIALOG_TABLECELL_PROPERTIES`, `DIALOG_GLOBAL_EDIT_TEXT_AND_GRAPHICS`, `PANEL_SETUP_FORMATTING` (each + `_BASE`), `TEXT_SEARCH_HANDLER`, `GROUP_SEARCH_HANDLER`, and (weak-only, invisible to a strong-symbol scan) `TEXTBOX_POINT_EDIT_BEHAVIOR`, `RECTANGLE_POINT_EDIT_BEHAVIOR`, `FILEDLG_HOOK_SAVE_PROJECT`, `FOOTPRINT_INFO_GENERATOR`. - Colliding free fn/data: `checkOverwriteDb()`, `allowedActions[]`, `g_excludedLayers`. - Benign (no symbol intersection): PADS importer structs, `CONNECTION_GRAPH` (namespaced), all same-name classes defined once in `include/`/`common/`. All renamed one-side (PCB_ prefix) via the CMake define list. **Re-run `scripts/kicad/audit-merged-symbols.sh` (in the build container) on every kicad submodule bump** — a new upstream same-name class ships as silent corruption otherwise. ### WASM layer & build — root repo (commit `6bf0b76`) - `wasm/editor/CMakeLists.txt` — the merged executable: `single_top.cpp` (`TOP_FRAME=FRAME_PCB_EDITOR;KICAD_MERGED_KIFACES`, no `PGM_DATA_FILE_EXT` — Part 1's runtime extension map covers all four frames) + `merged_kiface_dispatch.cpp`; links both exported library lists (deduped) with `--allow-multiple-definition` + `--whole-archive pcbcommon`; binary dir `kicad_editor/` makes docker/build.sh's `${subdir}/${app}.js` copy work unmodified. - `wasm/bindings/` — both per-editor TUs' six shared collab entries renamed `pcbCollab*` / `schCollab*` (JS-facing names unchanged); their duplicate `kicadOpenFile` / `extern "C" kicadCollabOnSave` definitions and shared-name `EMSCRIPTEN_BINDINGS` registrations compiled out under `KICAD_MERGED_EMBIND`; new `kicad_editor_embind.cpp` registers each shared JS name once and dispatches on `pcbEditorActive()`/`schEditorActive()` (the same top-window `dynamic_cast` probe the impls always started with). Standalone pcbnew/eeschema builds compile without the define and register the same JS names as before. *(War story: `pcbCollab*/schCollab*` in a block comment — the `*/` terminates the comment. Cost one build cycle.)* - `scripts/kicad/build-kicad-target.sh` — `kicad_editor` case arms; `STUB_APP=pcbnew` (eeschema's frame stub arrives via CMake `target_sources`); `-DKICAD_WASM_MERGED_ EDITOR=ON` for this app only; Step 7.1 refactored into `compile_embind_tu` and compiles **three** embind objects for the merged app (both per-editor TUs with `-DKICAD_MERGED_EMBIND` + the dispatcher), all with the ABI-critical `KICAD_TU_ABI_FLAGS`; multi-object force-relink guard. - `docker/build.sh` — `all` = `kicad_editor calculator pl_editor gerbview` (kicad_editor first: largest bundle = the wasm-opt critical path). `pcbnew`/`eeschema` remain valid **explicit** apps (standalone debug aids in their own option-OFF trees; not deployed). `scripts/kicad/build-kicad_editor.sh` wrapper added. - `scripts/kicad/audit-merged-symbols.sh` — the repeatable ODR audit. - `.github/workflows/wasm-opt-bench.yml` — left building `eeschema` deliberately (still a valid explicit app; cheaper fixture, representative for pass sweeps). ### Frontend & deploy - `web/standalone/src/wasm/constants.ts` — new `Bundle` union type (a bundle is a delivery artifact, NOT a `Tool`; `kicad_editor` is deliberately not in the `TOOLS` enum). `TOOL_BUNDLE: Record` maps all four editors → `kicad_editor`; `TOOL_FRAME` gains explicit `pcb`/`sch` for pcbnew/eeschema (the merged bundle can only default to one frame — PCB). `TOOL_ARGV0`/config-seed/lib-kind stay per-tool. - `boot.ts` (`pthreadWorkerScript` param → `Bundle`; dead `?? tool` fallbacks dropped) and `wasm-assets.ts` — already bundle-aware from Part 1; typecheck green. - `scripts/deploy/publish-wasm.mjs` — `TOOLS = [kicad_editor, pl_editor, gerbview, calculator]`. NOTE: run one full publish before any `--from-registry` snapshot (the registry needs a kicad_editor entry); old CDN folders are immutable so deployed frontends keep working. ### Tests & CI - All five editor harness HTMLs load `kicad_editor.js`; pcbnew/pcbnew-collab/eeschema gained explicit `--frame=pcb`/`--frame=sch` in `Module.arguments`. - `tests/scripts/setup-kicad-wasm.sh` copies `kicad_editor` instead of pcbnew+eeschema. - `tests/playwright-kicad.config.ts` — `PCBNEW_FAMILY_SPECS` → `BIG_MODULE_SPECS` + the 8 eeschema-family globs (eeschema.spec, -collab, -crosshair, -load, -subschema, -ui, -url-regex, symbol_editor.spec): every spec booting the ~190 MB merged module routes to `chromium-ci` on CI (SpiderMonkey x86 code-budget OOM). Firefox keeps the small bundles. Verified via `--list`: CI-firefox 0 merged-module tests, chromium-ci 43. - `tests/kicad/frame-runtime.spec.ts` — extended to all four frames; also asserts no duplicate embind registration ("Cannot register public name"). ## Validation (2026-07-02, local ARM Mac, Firefox, `-O1` debug bundle) 1. **frame-runtime 4/4** — each harness lands on its own title from the one bundle (`PCB Editor`, `[no footprint loaded] — Footprint Editor`, `untitled [Unsaved] — Schematic Editor`, `[no symbol loaded] — Symbol Editor`), no aborts, no duplicate embind registration. 17 s total. 2. **Merged-module regression subset (16 spec files): 24 passed**, 1 flaky-passed (3d-viewer open/render under 4-worker load), 1 failed — see known-failure below. 3D raytracer renders the pic_programmer board correctly (screenshot checked). No tracked-baseline screenshot diffs. 3. **Collab bridge: 10 passed, 2 pre-existing skips** — eeschema + pcbnew collab (snapshot/apply/two-tab BroadcastChannel propagation/native-EH double apply) all through the merged embind dispatcher, in both frame types. 4. **Symbol/link audit of the built tree** — `PcbKiface`/`pcbnew_kiface_getter` and `SchKiface`/`eeschema_kiface_getter` in their own objects; plain `Kiface()` only in the dispatch TU; embind JS names present exactly once in the wasm. 5. **Full suite (all bundles): 67 passed, 3 pre-existing skips, 1 failed** — the single failure is the known pre-existing 3d-viewer drag deadlock below (its follow-on resize test "did not run"). calculator/pl_editor/gerbview rebuilt and green alongside the merged bundle. 6. **Cross-face probe (`xface-probe.spec.ts`, kept as a permanent guard): PASSED** — in a `--frame=sch` session, opening Preferences lazy-starts the PCB kiface and its pages appear in the tree (**"Footprint Editor", "PCB Editor", "3D Viewer"** — absent on the old single-kiface bundle, where `KiFACE(FACE_PCB)` returned nullptr). This is the same path the symbol chooser's footprint preview uses, and a user-visible improvement: the other engine's preference pages now populate. ### Pre-existing failure, RESOLVED by the rebase (was NOT a merge regression) `3d-viewer.spec.ts` "draggable, X-closable DOM title bar" failed during initial validation: dragging the 3D viewer re-renders via the raytracer, which needs fresh pthread workers from a drained pool — the on-demand worker boot deadlocks the main thread. The fix pre-dated on `origin/main` as `7630c7e` ("raytrace deadlock — pre-warm 2N+8 pthread Workers", a `PTHREAD_POOL_SIZE` link-flag change the branch base predated). The branch has since been **rebased onto main** (root + kicad; conflicts were the kicad gitlink, the boot.ts/WasmTool.tsx additive hunks vs main's lazy-3D-model work, and the BIG_MODULE_SPECS rename vs main's added 3D specs/reformat) and `kicad_editor` relinked — **3d-viewer + frame-runtime rerun: all green** (7 passed + 1 flaky-passed under parallel load, 0 failures). ## Build & size facts - `output/kicad_editor.wasm` **178 MB** (`-O1` debug; sidecar `.wasm.debug.wasm` 2.3 GB) vs 147 + 82 MB for the separate engines — the dedup of wx/common/boost is real. Deployed set is now 4 bundles instead of 5. - Host postprocess (dyncall shims → finalize → asyncify → `wasm-opt -O1`) completed on a local Mac without drama — the `-O2` cost/RSS concerns in `04` apply only to release-level wasm-opt. - Build: `./docker/build.sh kicad_editor` (own tree `kicad-kicad_editor`, configured with `-DKICAD_WASM_MERGED_EDITOR=ON`; standalone pcbnew/eeschema trees untouched). ## What's next - Run `tests/web/tools-open.spec.ts` against the real web app once ports 3048/3060 are free (at validation time another worktree's dev stack held them; the frontend path itself — `resolveWasmBase`→`TOOL_BUNDLE`→boot→`--frame` — is unchanged from Part 1's validated flow, only the map values differ, typecheck green). First deploy needs one full `publish-wasm.mjs` run (registry entry) before any snapshot deploy. - Follow-ups (out of scope): multi-frame/cross-probing in one instance (both faces are now registered under one KIWAY — the groundwork exists); folding gerbview/pl_editor/calculator into the image; size levers (wasm-EH, JSPI).