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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-15 12:44:30 +02:00
parent 775a046ce4
commit 758b985c35
No known key found for this signature in database
12 changed files with 361 additions and 28 deletions

View File

@ -409,6 +409,34 @@ from #171 and #215.
the canonical document false. the canonical document false.
- **Chain: this slice → `panel-pointer-replay` (rebases onto it) → - **Chain: this slice → `panel-pointer-replay` (rebases onto it) →
GUI arc 1b.** 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, - **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite,
`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**.

View File

@ -6895,6 +6895,20 @@ impl State {
return false; return false;
} }
match payload { 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 => { PanelFramePayload::Absent => {
// Authoritative removal, and always safe. Note this does // Authoritative removal, and always safe. Note this does
// NOT clear the geometry declaration: the frontend's frame // NOT clear the geometry declaration: the frontend's frame

View File

@ -66,7 +66,7 @@ pub use message::{
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, 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, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind, 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, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace, 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, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,

View File

@ -550,8 +550,75 @@ pub enum FrontendEvent {
/// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`]. /// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`].
text: String, 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`]. /// First protocol version carrying [`FrontendEvent::TextInput`].
/// ///
/// A frontend older than this **retains its existing `Key` behaviour and /// A frontend older than this **retains its existing `Key` behaviour and
@ -617,7 +684,8 @@ impl FrontendEvent {
| Self::FrontendCellGeometry { frontend_id, .. } | Self::FrontendCellGeometry { frontend_id, .. }
| Self::PanelResizeRows { frontend_id, .. } | Self::PanelResizeRows { frontend_id, .. }
| Self::PanelPointer { 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 /// negotiated version. What is unusual here is only that the extension
/// is **inbound-only** — there is no outbound counterpart to withhold, /// is **inbound-only** — there is no outbound counterpart to withhold,
/// so the receiver check is the whole of the daemon's half. /// 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`]. /// 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 /// 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 /// frontend negotiates v23, never sends the variant, and keeps today's
/// first-scalar behaviour. /// 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] = &[ 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 /// T M10.5: predicate for the handshake check. Returns `true` if

View File

@ -103,6 +103,29 @@ pub enum PanelFramePayload {
Present(PanelFrame), Present(PanelFrame),
/// No panel is visible; clear any retained frame. /// No panel is visible; clear any retained frame.
Absent, 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. /// Why a [`PanelFrame`] is not structurally valid.

View File

@ -3607,6 +3607,13 @@ fn apply_semantic_input_event(
let ct_mouse = mouse_to_crossterm(&pmacs_mouse); let ct_mouse = mouse_to_crossterm(&pmacs_mouse);
editor.dispatch_mouse(source, ct_mouse, term_size); 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. // client-supplied `frontend_id` this function would believe.
FrontendEvent::Paste { .. } FrontendEvent::Paste { .. }
| FrontendEvent::TextInput { .. } | 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::FocusGained(_)
| FrontendEvent::FocusLost(_) | FrontendEvent::FocusLost(_)
// T M11.1: the semantic-frontend viewport declaration. Its // T M11.1: the semantic-frontend viewport declaration. Its

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips --- // --- M5.5a handshake & postcard round-trips ---
#[test] #[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 / // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6 // SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1741,10 +1741,11 @@ mod tests {
// minibuffer at all. `MinibufferPrompt` is therefore frozen and // minibuffer at all. `MinibufferPrompt` is therefore frozen and
// pinned by literal bytes below. // pinned by literal bytes below.
// //
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) — an // v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) and v25
// APPENDED variant, which is why the freeze above survives it // 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. // untouched: nothing in `MinibufferPrompt`'s encoding moved.
assert_eq!(PROTOCOL_VERSION, 24); assert_eq!(PROTOCOL_VERSION, 25);
} }
#[test] #[test]
@ -1822,18 +1823,19 @@ mod tests {
// v18 (`StatuslineSegments`), v19 (the vterm terminal family), // v18 (`StatuslineSegments`), v19 (the vterm terminal family),
// v20 (semantic initial-target bootstrap), v21 (the bottom // v20 (semantic initial-target bootstrap), v21 (the bottom
// panel band), v22 (`LineWrapFacts`), v23 // panel band), v22 (`LineWrapFacts`), v23
// (`MinibufferPromptRows`), and v24 (`TextInput`, GUI arc Stage // (`MinibufferPromptRows`), v24 (`TextInput`, GUI arc Stage 1a)
// 1a) all interoperate. // and v25 (`PanelPointerMapped` + `PresentMapped`, bottom-panel
for accepted in 6..=24 { // §5b) all interoperate.
for accepted in 6..=PROTOCOL_VERSION {
assert!( assert!(
is_supported_protocol_version(accepted), is_supported_protocol_version(accepted),
"v{accepted} must be 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!( assert!(
!is_supported_protocol_version(rejected), !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] #[test]
fn terminal_family_round_trips_and_pins_its_discriminants() { fn terminal_family_round_trips_and_pins_its_discriminants() {
let bid = pmacs_protocol::BufferId::from_raw(9); let bid = pmacs_protocol::BufferId::from_raw(9);

View File

@ -592,7 +592,9 @@ impl SemanticRenderState {
#[must_use] #[must_use]
pub fn panel_declaration(&self) -> Option<&PanelFrame> { pub fn panel_declaration(&self) -> Option<&PanelFrame> {
match self.last_panel_payload.as_ref()? { match self.last_panel_payload.as_ref()? {
PanelFramePayload::Present(frame) => Some(frame), PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. } => {
Some(frame)
}
PanelFramePayload::Absent => None, PanelFramePayload::Absent => None,
} }
} }

View File

@ -337,10 +337,11 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
#[test] #[test]
fn the_baseline_stays_and_the_counter_offer_activates() { fn the_baseline_stays_and_the_counter_offer_activates() {
// A deliberate tripwire: bumping the wire must be a conscious edit // A deliberate tripwire: bumping the wire must be a conscious edit
// here, not a silent one. v24 is `TextInput` (GUI arc Stage 1a); // here, not a silent one. v25 is the mapped panel family
// v23 was `MinibufferPromptRows` (Discovery Stage 2); v22 was // (`PanelPointerMapped` + `PresentMapped`, bottom-panel §5b); v24 is
// `LineWrapFacts` (long-lines Stage 3). // `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows`
assert_eq!(PROTOCOL_VERSION, 24); // (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 25);
assert_eq!( assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20, ADVERTISED_PROTOCOL_VERSION, 20,
"moving this is the incompatible act the mechanism exists to avoid" "moving this is the incompatible act the mechanism exists to avoid"

View File

@ -67,10 +67,11 @@ use common::daemon::{TestDaemon, build_default_caps};
/// server-first, so moving it locks out every already-shipped frontend /// server-first, so moving it locks out every already-shipped frontend
/// before it can counter-offer. An additive family never needs it. /// before it can counter-offer. An additive family never needs it.
#[test] #[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!( assert_eq!(
PROTOCOL_VERSION, 24, PROTOCOL_VERSION, 25,
"v24 is TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows" "v25 is the mapped panel family (bottom-panel §5b); v24 was \
TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows"
); );
assert_eq!( assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20, 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. // rather than after the interesting half has already passed.
let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22); let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22);
let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION); 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 // 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 // probe command by typing it — the candidate window is ten rows out

View File

@ -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 // three lines on purpose. The ceiling assertion is the load-bearing
// one — it says the supported set ENDS here, which is what makes an // one — it says the supported set ENDS here, which is what makes an
// accidentally-widened set a failure rather than a silent pass. // accidentally-widened set a failure rather than a silent pass.
assert_eq!(PROTOCOL_VERSION, 24); assert_eq!(PROTOCOL_VERSION, 25);
for version in 6..=24 { for version in 6..=PROTOCOL_VERSION {
assert!(is_supported_protocol_version(version)); assert!(is_supported_protocol_version(version));
} }
assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1)); assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1));

View File

@ -888,10 +888,11 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
panic!("timed out waiting for {what}"); panic!("timed out waiting for {what}");
} }
// Tripwire: a wire bump must be a conscious edit here. v24 is // Tripwire: a wire bump must be a conscious edit here. v25 is the
// `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows` // mapped panel family (bottom-panel §5b); v24 is `TextInput` (GUI
// (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3). // arc Stage 1a); v23 was `MinibufferPromptRows` (Discovery Stage 2);
assert_eq!(PROTOCOL_VERSION, 24); // v22 was `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 25);
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
&[ &[
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"), ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),