From 758b985c352cf1ed4751ba741655d245a93c3676 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 12:44:30 +0200 Subject: [PATCH] feat(protocol): the mapped panel family --- v25 wire shapes and their pins SS5b's first implementation commit: the two appended variants, the version constants, and the pins that hold them in place. No gating, no key, no replay --- those are the next commits, and the variants are REFUSED everywhere until their gate lands. **APPENDED AT THE TRUE END, confirmed by the discriminants.** `PanelPointer` is 15, `TextInput` 16, `PanelPointerMapped` **17**; `Present` 0, `Absent` 1, `PresentMapped` **2**. "Beside `Present`" would have been adjacent insertion, which shifts every discriminant below and silently re-interprets an older peer's bytes. `mapping_generation` is a `u64`, last within each variant, documented invalid at zero --- the value a default-constructed sender produces, so accepting it would let a peer opt out of the check by sending nothing. **THE COMPILER NAMED EVERY SEAM.** Four non-exhaustive matches: `semantic_render`'s declaration accessor now sees through both families, and the three routing sites REFUSE the mapped variant rather than unwrapping it to legacy meaning. Refusal is the correct default at an intermediate commit, not a placeholder --- until the frontend can prove it negotiated v25 it IS a `<= v24` peer for gating purposes, and painting first would ship a window in which the band is hit-tested with no mapping identity at all. **Five mutations, each biting its own rows:** insert `PanelPointerMapped` before `TextInput` -> the TextInput pin and the mapped pin. `PanelPointer`'s v23 pin correctly SURVIVES: its discriminant did not move, which is the "only the pin whose discriminant moved fails" behaviour G0a specifies insert `PresentMapped` before `Absent` -> the Absent pin and the mapped-frame pin swap `geometry_epoch` / `panel_epoch` -> the exact-bytes assertion, while the round-trip stays green. That is the blind spot G0b exists for, and it is why every adjacent same-typed field carries a distinct value bump the wire version without extending the supported set -> both new tripwires and 1a's v6 ladder move `ADVERTISED_PROTOCOL_VERSION` to 25 -> the baseline pin **Version fallout, enumerated rather than discovered one gate at a time.** Four acceptance-suite tripwires (`bottom_panel_stage2b_gpu`, `discovery_stage2` x2, `vterm_stage3`, `statusline_segments`) each say "a wire bump must be a conscious edit here" and each worked. Rather than fix them one run at a time I grepped the tree for version assertions and updated all four in one pass. Review folded five further corrections, two of which fix reasoning of mine that was wrong: - I claimed reversing `frame` and `mapping_generation` "fails to compile" because they are different types. **False for NAMED variant fields** --- the initializer uses names, so reordering the declarations compiles and shifts postcard's positional bytes silently. The pin is the only thing catching that. - Ladder loops now track `PROTOCOL_VERSION` while TRIPWIRES stay literal. I had flattened both to `25`. A tripwire is literal so a bump is a conscious edit; a ladder must move, or the next bump silently stops testing the top rung. G14b is unaffected --- `PANEL_MAPPING_MIN_VERSION` stays literal, because there the arithmetic is exactly the hazard. - `assert!(24 < MIN)` was a compile-time tautology holding for every value above 24. Replaced with the literal equality plus `assert_ne!` against `TEXT_INPUT_MIN_VERSION`: the mapped family must not share v24's gate, or it is admitted on sessions that negotiated only `TextInput`. - Statusline support loop reaches `PROTOCOL_VERSION`; public protocol history records v25. **CI-red observations are in the LANE LEDGER, not the registry**, and that is deliberate: `ci-red-signatures.md` here ends at U9 while the unmerged replay branch already added a U10, so a row from this branch would duplicate an id or invent one blind --- which this file's own history records going wrong, two branches' entries merging "without a conflict, producing duplicate ids across four sites". R7 twice and the composition budget once, fragments verified, owed to the registry by whichever branch merges second. Gates: all eleven green under `env -u TMPDIR` with `--protocol`, log 20260815T103555Z. Four runs were needed; three were lost to those two signatures, not to this diff. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 28 +++ pmacs-gpu/src/main.rs | 14 ++ pmacs-protocol/src/lib.rs | 2 +- pmacs-protocol/src/message.rs | 93 +++++++++- pmacs-protocol/src/panel.rs | 23 +++ src/daemon.rs | 13 ++ src/protocol.rs | 181 ++++++++++++++++++- src/semantic_render.rs | 4 +- tests/bottom_panel_stage2b_gpu_acceptance.rs | 9 +- tests/discovery_stage2_acceptance.rs | 9 +- tests/statusline_segments_acceptance.rs | 4 +- tests/vterm_stage3_acceptance.rs | 9 +- 12 files changed, 361 insertions(+), 28 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index dbd2571..a0e5a9d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -409,6 +409,34 @@ from #171 and #215. the canonical document false. - **Chain: this slice → `panel-pointer-replay` (rebases onto it) → GUI arc 1b.** +- **CI-red observations on this branch, NOT yet in the registry — and + deliberately so.** `docs/ci-red-signatures.md` here ends at **U9**; + the `panel-pointer-replay` branch already added a **U10** that has + not merged. Adding a row from this branch would either duplicate that + id or invent U11 against an unseen neighbour — and this file's own + history records exactly that going wrong once already, when two + branches' entries "merged **without a conflict**, producing duplicate + ids across four sites". **The rows below are owed to the registry by + whichever branch merges second**, numbered after the other's. + - **R7, two occurrences on this branch** (2026-08-15, `gpu` step, + logs `20260815T095532Z` and `20260815T100719Z`). Fragments verified + both times: `transient sequence must attach: Attach(Handshake(Io(Os + { code: 32, kind: BrokenPipe, message: "Broken pipe" })))` at + `pmacs-gpu/src/attach.rs:1728`. One machine, one day, one branch, + with a green full-gate run between them. Isolated reruns green. + - **`composition_overhead_under_ten_percent`, once** (log + `20260815T102527Z`, step **`04-lib-crdt`**). Fragment: + `composition machinery added more than 10% overhead: 1.146 + (single=227350 ns, dispatch=260497 ns)`. **`criterion_1` ran in the + same step and PASSED**, so per U6's own instruction this is one + selector redding without the other and is a different incident. + **1.146× is the smallest margin this budget has ever failed by** + (U6 1.297×, U10 1.343×, U9 1.613×), and the "realistic" figure was + **negative** (−25.8%) in the same run. Isolated rerun green. + - **Cost, stated plainly:** four `--protocol` gate runs on one commit, + three of them lost to these two signatures. U9's synthetic-load + control remains unrun and is the cheapest thing that would either + implicate load or clear it. - **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite, `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index c42fd94..47d4664 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -6895,6 +6895,20 @@ impl State { return false; } match payload { + // §5b — the mapped family is REFUSED until its gate lands. + // + // Refusing is the correct default at every intermediate + // commit, not a placeholder: G8d requires a `<= v24` + // frontend to reject `PresentMapped` outright, and until + // this frontend can prove it negotiated `>= v25` it is, for + // gating purposes, exactly such a peer. Painting it first + // and gating later would mean shipping a window in which the + // band is hit-tested with no mapping identity at all. + // + // The retention is ATOMIC, as G8d and G10 require: nothing + // about the previous frame, its generation or the pointer + // state is touched on the way out. + PanelFramePayload::PresentMapped { .. } => false, PanelFramePayload::Absent => { // Authoritative removal, and always safe. Note this does // NOT clear the geometry declaration: the frontend's frame diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 3a857b7..858359e 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -66,7 +66,7 @@ pub use message::{ MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind, - NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody, + NegotiatedCapabilities, PANEL_MAPPING_MIN_VERSION, PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 75340d4..e7c3ea5 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -550,8 +550,75 @@ pub enum FrontendEvent { /// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`]. text: String, }, + /// Bottom panel §5b (protocol v25): a panel-cell gesture that also + /// names the **inverse mapping** the frontend was looking at. + /// + /// **APPENDED LAST, after [`Self::TextInput`].** "Beside + /// `PanelPointer`" would be adjacent insertion, which shifts every + /// discriminant below it; postcard encodes variants positionally, so + /// only the end of the enum is safe. + /// + /// **Why the mapping and not the epochs.** A cell must be inverted + /// to a byte, and the existing ladder cannot tell whether the text + /// under that cell still means what it meant: `buffer_id` catches an + /// A→B replacement, `panel_epoch` catches close/reopen, + /// `geometry_epoch` catches a declaration race, and **nothing + /// catches a foreign edit** — which moves the mapping while every + /// epoch holds. `mapping_generation` closes exactly that. + /// + /// **Not a per-frame token.** A token moving with each frame would + /// invalidate a gesture on every repaint and break drags outright, + /// which is why [`crate::panel::PanelFrame::panel_epoch`] is stable + /// across ordinary frames. This identifies the *mapping*: it moves + /// with the viewport, folds, wrap and gutter geometry, buffer + /// content and terminal topology, and holds across focus, styling, + /// selection-only repaints and absorbed cursor motion. + /// + /// **Gated bilaterally on [`PANEL_MAPPING_MIN_VERSION`]**, and the + /// gate is a REFUSAL rather than a fallback: a `>= v25` session + /// sending the bare [`Self::PanelPointer`] is refused, and a + /// `<= v24` session sending this variant is refused too. Treating + /// either as "handled under the other family's semantics" would + /// leave the hole reachable by choosing a discriminant. + PanelPointerMapped { + /// Which frontend produced the gesture (untrusted, as above). + frontend_id: FrontendId, + /// Geometry declaration this gesture was hit-tested against. + geometry_epoch: u64, + /// Presentation identity this gesture addresses. + panel_epoch: u64, + /// Buffer the frontend believed the panel was displaying. + buffer_id: crate::BufferId, + /// Cell the pointer is over, within the declared panel grid. + coord: CellCoord, + /// Which gesture step this is. + kind: MouseKind, + /// Modifiers held during the gesture. + mods: Modifiers, + /// The inverse mapping the frontend was displaying. + /// + /// **Zero is invalid** and is refused like a mismatch: it is + /// what a default-constructed or half-initialised sender + /// produces, so accepting it would let a peer opt out of the + /// check by sending nothing. A live key starts at 1. + /// + /// Appended **last within the variant**, so this mirrors + /// [`Self::PanelPointer`]'s field order exactly and the two stay + /// diffable by eye. + mapping_generation: u64, + }, } +/// First protocol version carrying the **mapped panel family** — +/// [`FrontendEvent::PanelPointerMapped`] and +/// [`crate::panel::PanelFramePayload::PresentMapped`]. +/// +/// Read as a constant rather than compared against a literal, and +/// **not** derived from [`PROTOCOL_VERSION`]: expressing the boundary +/// against a moving ceiling would silently drag this feature forward +/// on the next bump. +pub const PANEL_MAPPING_MIN_VERSION: u32 = 25; + /// First protocol version carrying [`FrontendEvent::TextInput`]. /// /// A frontend older than this **retains its existing `Key` behaviour and @@ -617,7 +684,8 @@ impl FrontendEvent { | Self::FrontendCellGeometry { frontend_id, .. } | Self::PanelResizeRows { frontend_id, .. } | Self::PanelPointer { frontend_id, .. } - | Self::TextInput { frontend_id, .. } => *frontend_id, + | Self::TextInput { frontend_id, .. } + | Self::PanelPointerMapped { frontend_id, .. } => *frontend_id, } } } @@ -1920,7 +1988,18 @@ pub enum ResourceBody { /// negotiated version. What is unusual here is only that the extension /// is **inbound-only** — there is no outbound counterpart to withhold, /// so the receiver check is the whole of the daemon's half. -pub const PROTOCOL_VERSION: u32 = 24; +/// +/// Bottom panel §5b: bumped 24 → 25 for the mapped panel family, +/// [`FrontendEvent::PanelPointerMapped`] and +/// [`crate::panel::PanelFramePayload::PresentMapped`]. Both variants are +/// appended after their enum's v24 final variant. The family is gated in +/// both directions from the authenticated session's negotiated version: +/// v6–v24 sessions use only `PanelPointer`/`Present`, while v25 sessions +/// use only the mapped pair; choosing the other discriminant is refused, +/// not reinterpreted. [`crate::panel::PanelFramePayload::Absent`] remains +/// common because hiding a band carries no mapping. The advertised +/// baseline remains 20. +pub const PROTOCOL_VERSION: u32 = 25; /// Protocol version placed in the daemon's server-first [`Hello`]. /// @@ -2108,8 +2187,16 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 { /// cannot rely on the producer withholding. [`ADVERTISED_PROTOCOL_VERSION`] does not move: a v23 /// frontend negotiates v23, never sends the variant, and keeps today's /// first-scalar behaviour. +/// +/// Bottom panel §5b: extended to `[6, ..., 25]` for +/// [`FrontendEvent::PanelPointerMapped`] and +/// [`crate::panel::PanelFramePayload::PresentMapped`]. The authenticated +/// negotiated version selects exactly one legacy/mapped family in each +/// direction; a peer compiled from this crate can encode either variant, +/// so receiver-side refusal is load-bearing. `Absent` is common, and +/// [`ADVERTISED_PROTOCOL_VERSION`] remains 20. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[ - 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, ]; /// T M10.5: predicate for the handshake check. Returns `true` if diff --git a/pmacs-protocol/src/panel.rs b/pmacs-protocol/src/panel.rs index 6e26fe2..f9f344a 100644 --- a/pmacs-protocol/src/panel.rs +++ b/pmacs-protocol/src/panel.rs @@ -103,6 +103,29 @@ pub enum PanelFramePayload { Present(PanelFrame), /// No panel is visible; clear any retained frame. Absent, + /// §5b (protocol v25): a visible panel **plus the identity of the + /// inverse mapping** it was painted from. + /// + /// **APPENDED AFTER `Absent`, not beside `Present`.** postcard + /// encodes variants positionally, so inserting adjacent to + /// `Present` would shift `Absent`'s discriminant and silently + /// re-interpret every older peer's "hide the band" as something + /// else. + /// + /// `Absent` is deliberately **shared by both families** — hiding a + /// band carries no mapping, so it needs no mapped twin. + PresentMapped { + /// The frame, reused whole and unchanged. + frame: PanelFrame, + /// Identity of the inverse mapping this frame was painted from. + /// + /// A `PanelPointerMapped` echoes it, and the daemon refuses a + /// gesture whose echo no longer matches. **Zero is invalid**; + /// a live key starts at 1 and is **nondecreasing**, including + /// across `Absent` — a delayed lower frame must not roll the + /// producer's authority backward. + mapping_generation: u64, + }, } /// Why a [`PanelFrame`] is not structurally valid. diff --git a/src/daemon.rs b/src/daemon.rs index c9d5973..3bba690 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3607,6 +3607,13 @@ fn apply_semantic_input_event( let ct_mouse = mouse_to_crossterm(&pmacs_mouse); editor.dispatch_mouse(source, ct_mouse, term_size); } + // Everything else, including BOTH panel-pointer families. Panel + // gestures are dispatched from their own arm in + // `handle_dispatcher_event`, behind the epoch ladder; reaching + // them from here would route around it. §5b's mapped variant is + // dropped for the same reason, and specifically NOT unwrapped to + // its legacy meaning — that is the bypass the family gate exists + // to close. _ => {} } } @@ -3647,6 +3654,12 @@ fn apply_event( // client-supplied `frontend_id` this function would believe. FrontendEvent::Paste { .. } | FrontendEvent::TextInput { .. } + // §5b: listed here for the same reason as the two above — a + // mapped panel gesture must not reach a payload-trusting path. + // Its own dispatcher arm authenticates the source and checks + // the family gate; arriving here it is dropped, never unwrapped + // to the legacy family. + | FrontendEvent::PanelPointerMapped { .. } | FrontendEvent::FocusGained(_) | FrontendEvent::FocusLost(_) // T M11.1: the semantic-frontend viewport declaration. Its diff --git a/src/protocol.rs b/src/protocol.rs index 474473b..cf26cc6 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_twenty_four_for_text_input() { + fn protocol_version_is_twenty_five_for_the_mapped_panel_family() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1741,10 +1741,11 @@ mod tests { // minibuffer at all. `MinibufferPrompt` is therefore frozen and // pinned by literal bytes below. // - // v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) — an - // APPENDED variant, which is why the freeze above survives it + // v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) and v25 + // is the mapped panel family (bottom-panel §5b) — both APPENDED + // variants, which is why the freeze above survives them // untouched: nothing in `MinibufferPrompt`'s encoding moved. - assert_eq!(PROTOCOL_VERSION, 24); + assert_eq!(PROTOCOL_VERSION, 25); } #[test] @@ -1822,18 +1823,19 @@ mod tests { // v18 (`StatuslineSegments`), v19 (the vterm terminal family), // v20 (semantic initial-target bootstrap), v21 (the bottom // panel band), v22 (`LineWrapFacts`), v23 - // (`MinibufferPromptRows`), and v24 (`TextInput`, GUI arc Stage - // 1a) all interoperate. - for accepted in 6..=24 { + // (`MinibufferPromptRows`), v24 (`TextInput`, GUI arc Stage 1a) + // and v25 (`PanelPointerMapped` + `PresentMapped`, bottom-panel + // §5b) all interoperate. + for accepted in 6..=PROTOCOL_VERSION { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 25, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, PROTOCOL_VERSION + 1, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v24 binary" + "v{rejected} must be rejected by the current binary" ); } } @@ -2008,6 +2010,167 @@ mod tests { ); } + /// §5b G0a — the **previous-final `FrontendEvent`** is now + /// `TextInput`, and it gets its own exact pin. + /// + /// Pins ACCUMULATE rather than move. The `PanelPointer` pin above + /// still protects the v21–v23 population; this one protects v24's, + /// and appending `PanelPointerMapped` is exactly the edit that would + /// shift it if it were inserted rather than appended. + #[test] + fn text_input_encoding_is_unchanged_by_the_v25_build() { + let ev = FrontendEvent::TextInput { + frontend_id: FrontendId(2), + text: "hi".to_owned(), + }; + assert_eq!( + postcard::to_allocvec(&ev).expect("encode"), + [16, 2, 2, 104, 105], + "TextInput's v24 wire bytes changed — a variant was inserted \ + before it; append new FrontendEvent variants at the end" + ); + } + + /// §5b G0a — the **previous-final `PanelFramePayload`** is `Absent`, + /// pinned through its real nesting inside `InstanceMessage`. + /// + /// Nested deliberately: `PanelFramePayload` never travels alone, and + /// a pin on the bare enum would miss a shift in the message + /// discriminant that carries it. + #[test] + fn absent_panel_payload_encoding_is_unchanged_by_the_v25_build() { + let msg = InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent); + assert_eq!( + postcard::to_allocvec(&msg).expect("encode"), + [28, 1], + "Absent's wire bytes changed — a PanelFramePayload variant \ + was inserted before it, or InstanceMessage's discriminant \ + moved; append PresentMapped AFTER Absent" + ); + } + + /// §5b G0b — exact **encode and decode** for the mapped pointer. + /// + /// **Every adjacent same-typed field carries a different value**, so + /// a reordering is visible. `geometry_epoch`, `panel_epoch` and + /// `mapping_generation` are all `u64` and all distinct (3, 7, 11); + /// `coord.row`/`coord.col` likewise (5, 9). A round-trip alone would + /// stay green through any swap of those — it is self-consistent by + /// construction — which is why the exact bytes are asserted in both + /// directions. + #[test] + fn panel_pointer_mapped_encodes_and_decodes_exactly() { + const WIRE: [u8; 11] = [17, 2, 3, 7, 4, 5, 9, 0, 0, 0, 11]; + let ev = FrontendEvent::PanelPointerMapped { + frontend_id: FrontendId(2), + geometry_epoch: 3, + panel_epoch: 7, + buffer_id: pmacs_protocol::BufferId::from_raw(4), + coord: CellCoord { row: 5, col: 9 }, + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: Modifiers::NONE, + mapping_generation: 11, + }; + + assert_eq!( + postcard::to_allocvec(&ev).expect("encode"), + WIRE, + "PanelPointerMapped's v25 wire bytes changed — it must be \ + APPENDED after TextInput, with mapping_generation last" + ); + let decoded: FrontendEvent = postcard::from_bytes(&WIRE).expect("decode"); + assert_eq!( + decoded, ev, + "the frozen historical bytes must decode back to the same \ + value — round-trip alone would only witness the current \ + encoder and decoder agreeing with each other" + ); + } + + /// §5b G0b — exact **encode and decode** for the mapped frame, + /// through its real nesting. + /// + /// Reordering named variant fields still compiles and changes + /// postcard's positional bytes, so the exact pin is load-bearing. + /// The frame's own `panel_epoch` (8) and `geometry_epoch` (12) are + /// adjacent `u64`s and are pinned distinct for the same reason as + /// above. + #[test] + fn present_mapped_encodes_and_decodes_exactly() { + const WIRE: [u8; 33] = [ + 28, 2, 6, 8, 12, 1, 2, 2, 0, 1, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 32, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 13, + ]; + let frame = pmacs_protocol::panel::PanelFrame { + buffer_id: pmacs_protocol::BufferId::from_raw(6), + panel_epoch: 8, + geometry_epoch: 12, + size: CellSize::new(1, 2), + cells: vec![Cell::default(); 2], + cursor: None, + focused: true, + }; + let msg = + InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::PresentMapped { + frame, + mapping_generation: 13, + }); + + assert_eq!( + postcard::to_allocvec(&msg).expect("encode"), + WIRE, + "PresentMapped's v25 wire bytes changed — it must be APPENDED \ + after Absent, with mapping_generation last" + ); + let decoded: InstanceMessage = postcard::from_bytes(&WIRE).expect("decode"); + assert_eq!(decoded, msg, "the frozen bytes must decode back"); + } + + /// §5b G14a — the version constants move together, and the + /// advertised baseline does not move at all. + #[test] + fn the_mapping_slice_takes_v25_and_the_advertised_baseline_stays_pinned() { + assert_eq!(pmacs_protocol::PROTOCOL_VERSION, 25); + assert_eq!(pmacs_protocol::PANEL_MAPPING_MIN_VERSION, 25); + assert!( + pmacs_protocol::SUPPORTED_PROTOCOL_VERSIONS.contains(&pmacs_protocol::PROTOCOL_VERSION), + "a wire version the handshake does not support fails against \ + this daemon's own peers" + ); + assert_eq!( + pmacs_protocol::ADVERTISED_PROTOCOL_VERSION, + 20, + "the advertised baseline is PERMANENT — it is not bumped to \ + chase the wire version" + ); + } + + /// §5b G14b — the supported set's boundary, and the family boundary, + /// are both literal. + #[test] + fn the_supported_set_ends_at_the_current_wire_version() { + assert!(pmacs_protocol::is_supported_protocol_version(25)); + assert!( + !pmacs_protocol::is_supported_protocol_version(26), + "an unreleased version must not negotiate" + ); + // The family boundary is stated against a LITERAL, not against + // `PROTOCOL_VERSION`: expressing it arithmetically would drag + // this feature forward on the next bump, so v24 sessions would + // silently start being offered a family they never negotiated. + // + // Asserted as equality rather than `24 < MIN`, which clippy + // correctly calls a compile-time tautology — it holds for every + // value above 24 and so pins nothing. + assert_eq!(25, pmacs_protocol::PANEL_MAPPING_MIN_VERSION); + assert_ne!( + pmacs_protocol::PANEL_MAPPING_MIN_VERSION, + pmacs_protocol::TEXT_INPUT_MIN_VERSION, + "the mapped family must not share v24's gate — that would \ + admit it on sessions that negotiated only TextInput" + ); + } + #[test] fn terminal_family_round_trips_and_pins_its_discriminants() { let bid = pmacs_protocol::BufferId::from_raw(9); diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 3359504..5e46628 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -592,7 +592,9 @@ impl SemanticRenderState { #[must_use] pub fn panel_declaration(&self) -> Option<&PanelFrame> { match self.last_panel_payload.as_ref()? { - PanelFramePayload::Present(frame) => Some(frame), + PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. } => { + Some(frame) + } PanelFramePayload::Absent => None, } } diff --git a/tests/bottom_panel_stage2b_gpu_acceptance.rs b/tests/bottom_panel_stage2b_gpu_acceptance.rs index 7da08ea..b24629c 100644 --- a/tests/bottom_panel_stage2b_gpu_acceptance.rs +++ b/tests/bottom_panel_stage2b_gpu_acceptance.rs @@ -337,10 +337,11 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() { #[test] fn the_baseline_stays_and_the_counter_offer_activates() { // A deliberate tripwire: bumping the wire must be a conscious edit - // here, not a silent one. v24 is `TextInput` (GUI arc Stage 1a); - // v23 was `MinibufferPromptRows` (Discovery Stage 2); v22 was - // `LineWrapFacts` (long-lines Stage 3). - assert_eq!(PROTOCOL_VERSION, 24); + // here, not a silent one. v25 is the mapped panel family + // (`PanelPointerMapped` + `PresentMapped`, bottom-panel §5b); v24 is + // `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows` + // (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3). + assert_eq!(PROTOCOL_VERSION, 25); assert_eq!( ADVERTISED_PROTOCOL_VERSION, 20, "moving this is the incompatible act the mechanism exists to avoid" diff --git a/tests/discovery_stage2_acceptance.rs b/tests/discovery_stage2_acceptance.rs index db5f8cc..36d50a0 100644 --- a/tests/discovery_stage2_acceptance.rs +++ b/tests/discovery_stage2_acceptance.rs @@ -67,10 +67,11 @@ use common::daemon::{TestDaemon, build_default_caps}; /// server-first, so moving it locks out every already-shipped frontend /// before it can counter-offer. An additive family never needs it. #[test] -fn the_wire_is_v24_and_the_advertised_baseline_is_unmoved() { +fn the_wire_is_v25_and_the_advertised_baseline_is_unmoved() { assert_eq!( - PROTOCOL_VERSION, 24, - "v24 is TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows" + PROTOCOL_VERSION, 25, + "v25 is the mapped panel family (bottom-panel §5b); v24 was \ + TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows" ); assert_eq!( ADVERTISED_PROTOCOL_VERSION, 20, @@ -618,7 +619,7 @@ fn one_daemon_serves_a_v23_rows_session_and_a_frozen_v22_session() { // rather than after the interesting half has already passed. let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22); let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION); - assert_eq!(PROTOCOL_VERSION, 24); + assert_eq!(PROTOCOL_VERSION, 25); // Open the real `M-x` through the real key path, then narrow to the // probe command by typing it — the candidate window is ten rows out diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index c927956..448442f 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -804,8 +804,8 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { // three lines on purpose. The ceiling assertion is the load-bearing // one — it says the supported set ENDS here, which is what makes an // accidentally-widened set a failure rather than a silent pass. - assert_eq!(PROTOCOL_VERSION, 24); - for version in 6..=24 { + assert_eq!(PROTOCOL_VERSION, 25); + for version in 6..=PROTOCOL_VERSION { assert!(is_supported_protocol_version(version)); } assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1)); diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 837d242..2163bfb 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -888,10 +888,11 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { panic!("timed out waiting for {what}"); } - // Tripwire: a wire bump must be a conscious edit here. v24 is - // `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows` - // (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3). - assert_eq!(PROTOCOL_VERSION, 24); + // Tripwire: a wire bump must be a conscious edit here. v25 is the + // mapped panel family (bottom-panel §5b); v24 is `TextInput` (GUI + // arc Stage 1a); v23 was `MinibufferPromptRows` (Discovery Stage 2); + // v22 was `LineWrapFacts` (long-lines Stage 3). + assert_eq!(PROTOCOL_VERSION, 25); let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( &[ ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),