fix(ysync): wire dialect == file dialect, uuid churn, drift noise

The Y.Doc is the source of truth for the FILE, but both live wires wrote
KiCad's CLIPBOARD dialect — a lossy, paste-oriented format. Every
difference was permanent, unfixable drift.

- pcbnew: serialize footprint blobs with CTL_FOR_BOARD, not CLIPBOARD_IO's
  CTL_FOR_CLIPBOARD, which emitted (version)(generator)(generator_version)
  inside every (footprint …). Keep (locked yes).
- eeschema: aForClipboard=false — clipboard mode collapsed every symbol's
  (instances … (path "/sheet")) to (path "").
- Re-supply (version) at PARSE time only (withFootprintVersion): the token
  is invalid file content but load-bearing on decode — without it the
  parser starts at m_requiredVersion=0 and stamps (hide yes) on every
  mandatory field.
- FOOTPRINT copy ctor: restore mandatory-field uuids (EDA_ITEM::operator=
  keeps the target's const m_Uuid, so Clone() rerolled all four).
- drift: classify order-only diffs as `reordered` — y-sexpr v2 reorders
  legitimately; excluded from counts, report-worthiness and dedupe hashes.
  Migration 0017.
- fpedit from eeschema: AsyncLoad()+BlockUntilLoaded() in initLibraryTree —
  FACE_PCB starts lazily there and never preloaded its libraries.

Guards: wire-vs-file round-trip tests (pcbnew + eeschema),
fpedit-from-eeschema (verified red without the fix), symedit-from-eeschema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016p9kjdGBdcpwUSjJ3q5xg2
This commit is contained in:
Gergő Törcsvári 2026-07-20 18:29:52 +02:00
commit f92266fcee
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 528 additions and 44 deletions

View file

@ -355,10 +355,22 @@ void emitSheetChanged()
}, s.c_str() );
}
// Serialize one live schematic item to its native s-expr via the clipboard
// formatter (the exact path Ctrl-C uses: a one-item SCH_SELECTION through
// SCH_IO_KICAD_SEXPR::Format). For a symbol the output also carries its
// (lib_symbols …) definition, just like a copy does.
// Serialize one live schematic item to its native s-expr via a one-item
// SCH_SELECTION through SCH_IO_KICAD_SEXPR::Format. For a symbol the output also
// carries its (lib_symbols …) definition (that prelude is emitted for any symbol
// in the selection, independent of aForClipboard).
//
// aForClipboard MUST be false. Clipboard mode is a LOSSY, paste-oriented dialect:
// it rewrites `(instances (project … (path …)))` relative to aRelativePath — so a
// symbol on the current sheet collapses to `(path "")` — takes the REFERENCE field
// from the per-sheet instance instead of the ordinal one, and keeps orphaned
// instance data (sch_io_kicad_sexpr.cpp saveSymbol: ~758-766, ~791-806, ~903).
// The Y.Doc is the source of truth for the FILE, so a wire blob must be byte-equal
// to that item's subtree in a full file save; clipboard form would silently strip
// every symbol's sheet path and unit/reference on materialize.
//
// aRelativePath is still required (Format wxCHECKs it non-null) but is unread on
// the aForClipboard=false path.
std::string itemBlob( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem )
{
SCH_SELECTION sel;
@ -368,7 +380,7 @@ std::string itemBlob( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem )
STRING_FORMATTER fmt;
SCH_IO_KICAD_SEXPR plugin;
plugin.Format( &sel, &aFrame->GetCurrentSheet(), aFrame->Schematic(), &fmt,
/*aForClipboard*/ true );
/*aForClipboard*/ false );
return fmt.GetString();
}

View file

@ -294,27 +294,55 @@ json itemToJson( BOARD_ITEM* aItem )
return j;
}
// ── s-expr clipboard blob (the generic `added` mechanism) ────────────────────────────────────
// ── s-expr item blob (the generic `added` mechanism) ─────────────────────────────────────────
//
// For added items beyond the natively-reconstructed PCB_TRACK (footprints, vias, zones, graphic
// shapes/text…), reuse KiCad's own copy/paste serializer, CLIPBOARD_IO. It Format()s a one-item
// selection exactly as Ctrl-C does — a bare `(footprint …)` for a footprint, or a fake
// `(kicad_pcb … <layers> <item>)` envelope for everything else (the bare item tokens like
// `(segment`/`(via`/`(zone` are NOT accepted by the parser top-level, so the envelope is
// required). CLIPBOARD_IO normally talks to the system clipboard; SetWriter/SetReader redirect
// it to a string so it works headless / in wasm.
// shapes/text…), serialize one item with the BOARD writer's control set: a bare `(footprint …)`
// for a footprint, or a fake `(kicad_pcb … <layers> <item>)` envelope for everything else (the
// bare item tokens like `(segment`/`(via`/`(zone` are NOT accepted by the parser top-level, so
// the envelope is required).
//
// The envelope still comes from CLIPBOARD_IO::SaveSelection, which is also what supplies the
// `(layers …)` block the parser needs; CLIPBOARD_IO normally talks to the system clipboard, so
// SetWriter/SetReader redirect it to a string to work headless / in wasm. Footprints take the
// dedicated path below instead — see blobForItem for why the clipboard dialect is wrong here.
// Serialize one live board item to a clipboard blob (used only for `added` payloads — NOT the
// A board writer we can point at a BOARD. PCB_IO_KICAD_SEXPR's default control set is
// CTL_FOR_BOARD — exactly what a .kicad_pcb save uses — but only CLIPBOARD_IO exposes a
// public SetBoard(); m_board is protected on PCB_IO, so a two-line subclass gets us the
// file writer without a fork change.
class WIRE_BOARD_IO : public PCB_IO_KICAD_SEXPR
{
public:
explicit WIRE_BOARD_IO( BOARD* aBoard ) : PCB_IO_KICAD_SEXPR( CTL_FOR_BOARD )
{
m_board = aBoard;
}
};
// Serialize one live board item to a wire blob (used only for `added` payloads — NOT the
// diff unit, so `changed`/`removed` stay light and the blob never drives change detection).
//
// Footprints DON'T go through SaveSelection: its "make safe to transfer" step copies the
// The blob MUST be byte-equal to that item's subtree in a full .kicad_pcb save: the Y.Doc is
// the source of truth for the FILE, so any writer difference is a permanent, unfixable drift
// and corrupts what a server-side materialize writes.
//
// That is why footprints use CTL_FOR_BOARD and not CLIPBOARD_IO's CTL_FOR_CLIPBOARD. The two
// differ by exactly CTL_OMIT_FOOTPRINT_VERSION (pcb_io_kicad_sexpr.h), so clipboard form emits
// (footprint "Lib:C1206" (version 20260206) (generator "pcbnew") (generator_version "10.0") …)
// — three tokens a board-embedded footprint never has (pcb_io_kicad_sexpr.cpp ~1201). Every
// footprint of every board drifted on those three lines. For non-footprints the two control
// sets are identical (the bit only gates footprint output), so the SaveSelection path below
// is already file-equivalent and keeps its `(kicad_pcb … <layers> <item>)` envelope, which the
// parser requires — bare `(segment`/`(via`/`(zone` are not accepted at top level.
//
// Footprints also DON'T go through SaveSelection: its "make safe to transfer" step copies the
// footprint, and FOOTPRINT's copy ctor ASSIGNS the mandatory fields into the new footprint's
// freshly-constructed ones (`*existingField = *field`; EDA_ITEM::operator= keeps the target's
// uuid) — so Reference/Value/Datasheet/Description would carry NEW uuids in every blob,
// breaking the wire's identity-by-uuid (every emit would read as field remove+add, and round
// trips lose the field uuids). Instead we make the same safety copy ourselves, RESTORE the
// mandatory-field uuids from the source, and Format it directly — the same Format machinery
// SaveSelection uses internally, so asyncify behavior is identical.
// trips lose the field uuids). The copy ctor now restores those uuids itself (footprint.cpp),
// but we keep making the safety copy here so the live item is never mutated.
std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
{
if( aItem->Type() == PCB_FOOTPRINT_T )
@ -322,26 +350,17 @@ std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
const FOOTPRINT* src = static_cast<const FOOTPRINT*>( aItem );
FOOTPRINT copy( *src );
for( PCB_FIELD* field : copy.GetFields() )
{
if( field->IsMandatory() )
{
if( const PCB_FIELD* srcField = src->GetField( field->GetId() ) )
const_cast<KIID&>( field->m_Uuid ) = srcField->m_Uuid;
}
}
// The rest of SaveSelection's footprint safety steps, minus the refPoint move
// (the wire carries absolute positions) and minus SetNetCode(0): zeroing pad
// nets is a paste-into-FOREIGN-board safety, but collab peers edit the SAME
// board — nets must survive the wire. KiCad 10 formats pad nets by NAME and
// the parser resolves by name against the receiver's board (creating the net
// if missing), so no code remapping is needed on apply.
copy.SetLocked( false );
CLIPBOARD_IO io;
//
// NOTE: unlike SaveSelection we do NOT SetLocked( false ) — `(locked yes)` is
// real file content and dropping it would drift against the save.
WIRE_BOARD_IO io( aBoard );
STRING_FORMATTER fmt;
io.SetBoard( aBoard );
io.SetOutputFormatter( &fmt );
io.Format( &copy );
@ -365,15 +384,58 @@ std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
return out;
}
// Reconstruct a board item from a clipboard blob. Parse() returns a bare FOOTPRINT*, or a BOARD*
// A bare `(footprint …)` blob carries no `(version …)`, but the parser NEEDS one: it starts at
// m_requiredVersion = 0, and several format decisions are gated on it — most visibly
// `if( m_requiredVersion < 20230620 ) field->SetVisible( false )` in the T_property case
// (pcb_io_kicad_sexpr_parser.cpp), which silently stamps `(hide yes)` onto every mandatory
// field of an applied footprint.
//
// The clipboard dialect got this for free because CTL_FOR_CLIPBOARD emits the version INSIDE
// the footprint form — but that token is not valid board-file content (see blobForItem), so we
// can't keep it in the Y.Doc. Instead re-supply it here, at parse time only: splice
// `(version N)` in right after `(footprint "<lib id>"`, which is exactly where the clipboard
// writer put it. The Y.Doc body stays byte-identical to the file; only the wire→model decode
// sees the token.
static std::string withFootprintVersion( const std::string& aBlob )
{
static const std::string kHead = "(footprint";
if( aBlob.compare( 0, kHead.size(), kHead ) != 0 )
return aBlob; // envelope blob — its (kicad_pcb …) carries a version
if( aBlob.find( "(version " ) != std::string::npos )
return aBlob; // already versioned (older peer, clipboard dialect)
// Skip the quoted lib id that follows the head keyword, then inject.
size_t open = aBlob.find( '"', kHead.size() );
if( open == std::string::npos )
return aBlob;
size_t close = open + 1;
while( close < aBlob.size() && aBlob[close] != '"' )
close += ( aBlob[close] == '\\' ) ? 2 : 1;
if( close >= aBlob.size() )
return aBlob;
return aBlob.substr( 0, close + 1 )
+ " (version " + std::to_string( SEXPR_BOARD_FILE_VERSION ) + ")"
+ aBlob.substr( close + 1 );
}
// Reconstruct a board item from a wire blob. Parse() returns a bare FOOTPRINT*, or a BOARD*
// (the `(kicad_pcb …)` envelope) holding the single item — in which case detach that item from
// the throw-away board and hand back ownership. Returns nullptr on a parse failure (Parse catches
// internally) or if no item is found. Runs inside the apply COROUTINE.
BOARD_ITEM* makeFromBlob( BOARD& aBoard, const std::string& aBlob )
BOARD_ITEM* makeFromBlob( BOARD& aBoard, const std::string& aBlobIn )
{
if( aBlob.empty() )
if( aBlobIn.empty() )
return nullptr;
const std::string aBlob = withFootprintVersion( aBlobIn );
CLIPBOARD_IO io;
io.SetBoard( &aBoard );