session 4: Phase A — StyleSpans consumption + rich-text rendering (#42)

First Phase A session. pmacs-gpu now sends FrontendEvent::Viewport
back to the daemon after BufferSnapshot lands, receives the resulting
InstanceMessage::StyleSpans frames, and renders the rope with
per-span colors via cosmic-text's set_rich_text.

What's wired:
- AttachClient gains a write-side Arc<Mutex<UnixStream>> and the
  assigned FrontendId from Hello; new send_viewport method emits
  FrontendEvent::Viewport. The mutex is over-cautious for our
  single-threaded event loop but future-proofs against multi-window
  emission.
- State adds current_buffer_id and current_spans (sorted by
  range.start). BufferSnapshot bootstraps both, then the App's
  user_event handler emits a follow-up Viewport via
  AttachClient::send_viewport.
- StyleSpans handling distinguishes full vs incremental:
  * full=true: replace_style_spans drops prior, takes segments as
    authoritative for the declared viewport.
  * full=false: merge_style_spans applies the M11.4 dirty-segment
    rule — spans fully inside a dirty range drop; spans straddling
    a dirty edge get clipped to outside the range (with the
    straddles-both-edges case splitting into two); new spans append;
    re-sort by start.
- reshape() walks current_text + current_spans, emits (substr,
  Attrs) chunks at every span boundary (with .min(text_len) clamps
  for safety against stale spans past EOF), calls set_rich_text.
- cell_color_to_glyphon converts cell::Color to glyphon::Color via
  the standard xterm-style 256-color palette (16 ANSI + 6x6x6 cube +
  24-step grayscale). Default → None so the renderer's default
  Attrs color stays.

Adversarial verification scope (Phase A probes):
- #1 non-ASCII source: exercised through the UTF-16 col/byte
  conversion already in pmacs's producer side; pmacs-gpu just renders
  what the wire delivers. Non-ASCII files should show correct
  styling at the right byte positions.
- #3 viewport-boundary tokens: the merge_style_spans path is exactly
  bet #1 from the framing pass ('StyleSpans/Decorations dirty-segment
  edges at viewport boundaries — headless-test-blind-spot probe').
  Edits near a span edge exercise the clip-and-merge logic.
- #6 CRLF line endings: implicit — pmacs's rope uses byte offsets so
  styling spans naturally include or exclude the \r as the producer
  decided. pmacs-gpu doesn't special-case line endings.

Known limitation (session 4 acceptable artifact, documented in
set_text): CrdtOp + StyleSpans arrive separately. CrdtOp updates text;
StyleSpans for the new generation comes one tick later. Between the
two, current_spans points at pre-edit byte positions while the text
is post-edit — visually stale for one frame. The .min(text_len) clamp
in reshape() keeps it safe; the artifact is brief.

Headless test gap: there's no Rust-level test of merge_style_spans
or the rich-text segmentation. Phase A's framing intentionally
chose manual validation over headless tests for these paths (the
adversarial probes are visual). A Phase A audit doc lands at session
close with the predicted-vs-actual scoring; per-method unit tests
for the merge logic could land then if findings argue for them.

Gates: fmt; clippy --all-targets -D warnings clean across the whole
workspace; lib 1303 + pmacs-protocol 11 = 1314; m4_acceptance 83;
m11_5_semantic_acceptance --features crdt 2.

Manual validation pending — same daemon+TUI+pmacs-gpu setup as
session 3, now showing colored text in the GPU window.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-21 00:28:53 +00:00 committed by GitHub
parent f59c022d64
commit 32b529eea5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 388 additions and 73 deletions

View File

@ -2,12 +2,13 @@
//! socket, negotiate `semantic_render + crdt_replica`, then pump
//! `InstanceMessage` frames onto the winit event loop.
//!
//! Session 3 of the pmacs-gpu arc — see `docs/pmacs-gpu-design.md`.
//! Scope: handshake + decode the message stream. Importing the CRDT
//! snapshot, applying live ops, and reconstructing the rope happen
//! on the main thread, where the `LoroDoc` lives (it isn't trivially
//! `Send`; cross-thread shipping is the *decoded* `InstanceMessage`,
//! not the doc state).
//! Session 3+ of the pmacs-gpu arc — see `docs/pmacs-gpu-design.md`.
//! Scope: handshake + decode the message stream + send a few
//! `FrontendEvent`s back (currently just `Viewport`; session 5+ adds
//! cursor/edit/focus). Importing the CRDT snapshot, applying live
//! ops, and reconstructing the rope happen on the main thread, where
//! the `LoroDoc` lives (it isn't trivially `Send`; cross-thread
//! shipping is the *decoded* `InstanceMessage`, not the doc state).
//!
//! The reader thread blocks on a single `read_message` per iteration;
//! every received message becomes an [`AttachEvent`] forwarded
@ -17,13 +18,13 @@
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::thread;
use pmacs_protocol::{
AttachRequest, FrontendCapabilities, Hello, InstanceMessage, PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS, TransportError, is_supported_protocol_version, read_message,
write_message,
AttachRequest, BufferId, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello,
InstanceMessage, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, TransportError,
is_supported_protocol_version, read_message, write_message,
};
use winit::event_loop::EventLoopProxy;
@ -181,16 +182,50 @@ pub fn connect(
.expect("spawn attach reader thread");
Ok(AttachClient {
_write_stream: Arc::new(write_stream),
write_stream: Arc::new(Mutex::new(write_stream)),
frontend_id: hello.assigned_frontend_id,
})
}
/// Handle the main loop keeps after `connect` returns. Session 3
/// holds only the write half (unused yet — session 4 wires events
/// back); the read half lives in the spawned reader thread. The
/// `Arc` is so the handle is `Clone` for future per-window cloning,
/// not because we need shared ownership today.
#[allow(dead_code)]
/// Handle the main loop keeps after `connect` returns. Session 4
/// wires the write side for `FrontendEvent::Viewport` emission;
/// future sessions will add cursor / edit / focus / detach.
///
/// The write half is wrapped in `Arc<Mutex<...>>` because, while
/// pmacs-gpu's event loop is single-threaded, a future multi-window
/// shape might emit events from several places concurrently. The
/// lock cost is one mutex per emitted frame — negligible.
pub struct AttachClient {
_write_stream: Arc<UnixStream>,
write_stream: Arc<Mutex<UnixStream>>,
/// Assigned by the daemon in the `Hello` response. Every
/// `FrontendEvent` carries this so the daemon can route input back
/// to the per-session `SemanticRenderState`.
frontend_id: FrontendId,
}
impl AttachClient {
/// Send a `FrontendEvent::Viewport` to the daemon. The daemon's
/// `SemanticRenderState::set_viewport` feeds the spans producer;
/// without this call the daemon ships no `StyleSpans` for the
/// buffer (no declared viewport ⇒ no scoped styling).
pub fn send_viewport(
&self,
buffer_id: BufferId,
visible: ByteRange,
generation: u64,
) -> Result<(), TransportError> {
let mut stream = self
.write_stream
.lock()
.expect("attach write-stream mutex poisoned");
write_message(
&mut *stream,
&FrontendEvent::Viewport {
frontend_id: self.frontend_id,
buffer_id,
visible,
generation,
},
)
}
}

View File

@ -6,15 +6,18 @@
//! Opens a window and renders "hello, pmacs" in the bundled
//! `JetBrains` Mono. Used to confirm the wgpu/winit/glyphon stack
//! without depending on a daemon.
//! - **Attach** (`--attach <unix-socket-path>`; session 3). Connects
//! - **Attach** (`--attach <unix-socket-path>`; session 3+). Connects
//! to a running pmacs daemon, negotiates `semantic_render +
//! crdt_replica`, imports the daemon's `BufferSnapshot` into a
//! local loro replica, and renders the resulting rope text. Live
//! `CrdtOp` updates from the daemon are applied as they arrive.
//! local loro replica, sends a `Viewport` back to request scoped
//! styling, and consumes the `StyleSpans` stream — rendering the
//! rope with per-span colors via cosmic-text's `set_rich_text`.
//! Live `CrdtOp` updates apply to the doc; subsequent `StyleSpans`
//! frames re-style.
//!
//! See `docs/pmacs-gpu-design.md` for the arc framing. Session 3's
//! gate at close is "Daemon ↔ frontend handshake; rope reconstruction
//! matches" — handled by the attach mode below.
//! See `docs/pmacs-gpu-design.md` for the arc framing. Phase A's
//! adversarial-verification framing applies from session 4 forward;
//! findings classified per rule (iii) at surface-time.
//!
//! The bundled font is `JetBrains` Mono Regular, distributed under
//! the SIL Open Font License 1.1 (see `fonts/OFL.txt`).
@ -28,7 +31,9 @@ use glyphon::{
Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
};
use pmacs_protocol::InstanceMessage;
use pmacs_protocol::{
BufferId, ByteRange, InstanceMessage, StyleSegment, StyleSpan, cell::Color as CellColor,
};
use wgpu::MultisampleState;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, KeyEvent, WindowEvent};
@ -147,8 +152,9 @@ struct App {
/// a non-Option in a borrow.
proxy: Option<winit::event_loop::EventLoopProxy<AppEvent>>,
state: Option<State>,
/// Held for lifetime; session 3 doesn't write back yet.
#[allow(dead_code)]
/// Held both for stream lifetime and for the main loop's
/// `send_viewport` write-back path. Session 4 uses this; later
/// sessions will add cursor/edit/focus emissions.
attach_client: Option<AttachClient>,
}
@ -172,6 +178,17 @@ struct State {
/// hello-world mode or before the first snapshot arrives in
/// attach mode.
loro_doc: Option<loro::LoroDoc>,
/// Buffer the current rope text + spans interpret. Set when a
/// `BufferSnapshot` arrives; used as the routing key for
/// `StyleSpans` updates (drop those for other buffers).
current_buffer_id: Option<BufferId>,
/// Sorted-by-`range.start` styling spans for `current_buffer_id`.
/// Replaced wholesale on `StyleSpans { full: true, .. }`; merged
/// per the M11.4 dirty-segment rule on `full: false` (segments'
/// ranges authoritatively replace styling within them; spans
/// straddling a dirty edge get clipped to outside the dirty
/// range).
current_spans: Vec<StyleSpan>,
}
impl ApplicationHandler<AppEvent> for App {
@ -231,7 +248,23 @@ impl ApplicationHandler<AppEvent> for App {
return;
};
match event {
AppEvent::Attach(AttachEvent::Message(msg)) => state.apply_attach_message(*msg),
AppEvent::Attach(AttachEvent::Message(msg)) => {
let follow_up = state.apply_attach_message(*msg);
// If the message triggered a follow-up Viewport
// (currently: every BufferSnapshot does), emit it back
// to the daemon. The daemon's `SemanticRenderState`
// produces no styling until a viewport is declared.
if let Some(ViewportSend {
buffer_id,
visible,
generation,
}) = follow_up
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(buffer_id, visible, generation)
{
eprintln!("pmacs-gpu: send Viewport failed: {e}");
}
}
AppEvent::Attach(AttachEvent::Disconnected(reason)) => {
eprintln!("pmacs-gpu: daemon disconnected ({reason})");
state.set_text("(daemon disconnected)");
@ -240,6 +273,16 @@ impl ApplicationHandler<AppEvent> for App {
}
}
/// Follow-up event the main loop fires back to the daemon after
/// processing a message. Right now only Viewport (post-snapshot);
/// later sessions extend this enum.
#[derive(Debug, Clone, Copy)]
struct ViewportSend {
buffer_id: BufferId,
visible: ByteRange,
generation: u64,
}
impl State {
fn new(event_loop: &ActiveEventLoop, initial_text: &str) -> Self {
let window = Arc::new(
@ -342,22 +385,242 @@ impl State {
buffer,
current_text: initial_text.to_owned(),
loro_doc: None,
current_buffer_id: None,
current_spans: Vec::new(),
}
}
/// Replace the rendered text with `text` and request a redraw.
/// No-op when `text` is byte-identical to the current rendering
/// (avoids the re-shape cost when an unchanged buffer ticks).
///
/// Replaces the rope text and routes through `reshape` so the
/// rich-text rendering uses the current `current_spans`. When
/// called from the `CrdtOp` path (text shifted under existing
/// spans) the spans are momentarily stale relative to the new
/// byte positions — `reshape` clamps via `range.end.min(text_len)`
/// so rendering is safe, but visual styling may be off until the
/// daemon's next `StyleSpans` frame catches up. A real artifact;
/// classified as a session-4 known limitation rather than a bug.
fn set_text(&mut self, text: &str) {
if self.current_text == text {
return;
}
self.current_text.clear();
self.current_text.push_str(text);
self.buffer.set_text(
self.reshape();
}
/// Apply one `InstanceMessage`; return a follow-up
/// `ViewportSend` if the message requires the main loop to fire
/// one back at the daemon.
///
/// Session 4 handles four variants:
/// - `BufferSnapshot` — bootstrap a fresh `LoroDoc`, extract text,
/// request the daemon scope styling to the new buffer (return a
/// Viewport send-back).
/// - `CrdtOp` — apply incremental updates to the doc; text
/// re-extracted.
/// - `StyleSpans` — replace or merge per the M11.4 dirty-segment
/// rule; reshape the rich-text rendering.
/// - `Goodbye` — surfaced via the reader thread's clean-EOF path,
/// not handled here.
///
/// Other `SemanticFrame` variants (`Decorations`, `InlineAdornments`,
/// `FileStyleSummary`) plus the grid variants (`CellDelta`,
/// `Cursor`, `CursorByte`) and presence updates are ignored in
/// session 4 — they land in subsequent Phase A sessions.
fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option<ViewportSend> {
match msg {
InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
} => {
let doc = loro::LoroDoc::new();
if let Err(e) = doc.import(&crdt_snapshot) {
eprintln!("pmacs-gpu: BufferSnapshot import failed: {e:?}");
return None;
}
let text = doc.get_text(LORO_TEXT_CONTAINER).to_string();
let text_len = text.len() as u64;
self.loro_doc = Some(doc);
self.current_buffer_id = Some(buffer_id);
// New buffer ⇒ drop any prior styling; the next
// StyleSpans frame for this buffer is authoritative.
self.current_spans.clear();
self.set_text(&text);
Some(ViewportSend {
buffer_id,
visible: ByteRange {
start: 0,
end: text_len,
},
generation: 0,
})
}
InstanceMessage::CrdtOp { buffer_id, op } => {
if self.current_buffer_id != Some(buffer_id) {
// Edit op for a different buffer than we currently
// render. Ignore for now (multi-buffer is a future
// session); when buffer-switching lands we'll
// index ops by buffer.
return None;
}
let Some(doc) = self.loro_doc.as_ref() else {
// Mid-attach race: ops before snapshot. The
// snapshot will have the ops baked in.
return None;
};
if let Err(e) = doc.import(&op.bytes) {
eprintln!("pmacs-gpu: CrdtOp import failed: {e:?}");
return None;
}
let text = doc.get_text(LORO_TEXT_CONTAINER).to_string();
self.set_text(&text);
None
}
InstanceMessage::StyleSpans {
buffer_id,
generation: _,
full,
segments,
} => {
if self.current_buffer_id != Some(buffer_id) {
return None;
}
if full {
self.replace_style_spans(segments);
} else {
self.merge_style_spans(segments);
}
self.reshape();
None
}
_ => None,
}
}
/// `full = true` path: discard prior styling, take the segments'
/// spans as authoritative for the declared viewport.
fn replace_style_spans(&mut self, segments: Vec<StyleSegment>) {
self.current_spans.clear();
for seg in segments {
self.current_spans.extend(seg.spans);
}
self.current_spans.sort_by_key(|s| s.range.start);
}
/// `full = false` path: each segment's `range` authoritatively
/// replaces styling within it. Spans fully inside any dirty range
/// drop; spans straddling a dirty edge get clipped to outside the
/// range; the new spans are appended; finally everything sorts.
///
/// This is exactly the surface bet #1 from the framing pass
/// predicted ("dirty-segment edges at viewport boundaries —
/// headless-test-blind-spot probe"). Per-byte adversarial
/// behavior here lives in the user-side validation, not in unit
/// tests — that's the design-doc framing's whole point.
fn merge_style_spans(&mut self, segments: Vec<StyleSegment>) {
for seg in &segments {
let dirty = seg.range;
let mut kept = Vec::with_capacity(self.current_spans.len());
for sp in self.current_spans.drain(..) {
if sp.range.end <= dirty.start || sp.range.start >= dirty.end {
// Outside the dirty range entirely — keep as-is.
kept.push(sp);
} else if sp.range.start < dirty.start && sp.range.end > dirty.end {
// Straddles both edges: split into two clipped halves.
kept.push(StyleSpan {
range: ByteRange {
start: sp.range.start,
end: dirty.start,
},
style: sp.style,
});
kept.push(StyleSpan {
range: ByteRange {
start: dirty.end,
end: sp.range.end,
},
style: sp.style,
});
} else if sp.range.start < dirty.start {
// Straddles the left edge only — clip to the left.
kept.push(StyleSpan {
range: ByteRange {
start: sp.range.start,
end: dirty.start,
},
style: sp.style,
});
} else if sp.range.end > dirty.end {
// Straddles the right edge only — clip to the right.
kept.push(StyleSpan {
range: ByteRange {
start: dirty.end,
end: sp.range.end,
},
style: sp.style,
});
}
// else: fully inside the dirty range ⇒ drop.
}
self.current_spans = kept;
}
for seg in segments {
self.current_spans.extend(seg.spans);
}
self.current_spans.sort_by_key(|s| s.range.start);
}
/// Re-build the cosmic-text Buffer from `current_text` +
/// `current_spans`. Walks the text byte-by-byte, emitting
/// `(substr, Attrs)` chunks at every span boundary — text not
/// covered by any span uses the default Attrs (terminal default
/// color). Final call: `set_rich_text` + `shape_until_scroll`.
fn reshape(&mut self) {
let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
let text_len = self.current_text.len() as u64;
let mut chunks: Vec<(String, Attrs<'static>)> = Vec::new();
let mut pos: u64 = 0;
for sp in &self.current_spans {
// Unstyled gap before this span (could be empty).
if pos < sp.range.start {
let end = sp.range.start.min(text_len) as usize;
chunks.push((
self.current_text[pos as usize..end].to_owned(),
default_attrs.clone(),
));
pos = sp.range.start;
}
// Styled run, clipped to text_len so a stale span past EOF
// can't index out.
let end = sp.range.end.min(text_len) as usize;
if end > pos as usize {
let mut attrs = default_attrs.clone();
if let Some(color) = cell_color_to_glyphon(sp.style.fg) {
attrs = attrs.color(color);
}
chunks.push((self.current_text[pos as usize..end].to_owned(), attrs));
pos = sp.range.end;
}
}
// Trailing unstyled tail.
if pos < text_len {
chunks.push((
self.current_text[pos as usize..].to_owned(),
default_attrs.clone(),
));
}
// No spans + empty text ⇒ feed one empty chunk so set_rich_text
// has something to draw.
if chunks.is_empty() {
chunks.push((String::new(), default_attrs.clone()));
}
self.buffer.set_rich_text(
&mut self.font_system,
&self.current_text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
chunks.iter().map(|(s, a)| (s.as_str(), a.clone())),
&default_attrs,
Shaping::Advanced,
None,
);
@ -365,48 +628,6 @@ impl State {
self.window.request_redraw();
}
/// Apply one `InstanceMessage` to the local replica. Session 3
/// handles the two variants that matter for rope reconstruction:
/// `BufferSnapshot` (bootstrap) and `CrdtOp` (live updates). Every
/// other variant is ignored (logged at debug) — the `SemanticFrame`
/// family will be consumed in later sessions.
fn apply_attach_message(&mut self, msg: InstanceMessage) {
match msg {
InstanceMessage::BufferSnapshot { crdt_snapshot, .. } => {
let doc = loro::LoroDoc::new();
if let Err(e) = doc.import(&crdt_snapshot) {
eprintln!("pmacs-gpu: BufferSnapshot import failed: {e:?}");
return;
}
let text = doc.get_text(LORO_TEXT_CONTAINER).to_string();
self.loro_doc = Some(doc);
self.set_text(&text);
}
InstanceMessage::CrdtOp { op, .. } => {
let Some(doc) = self.loro_doc.as_ref() else {
// No snapshot yet — drop. The daemon's send order
// is snapshot-then-ops, so this case is rare
// (mid-attach race only). When the snapshot lands
// it'll have the ops baked in anyway.
return;
};
if let Err(e) = doc.import(&op.bytes) {
eprintln!("pmacs-gpu: CrdtOp import failed: {e:?}");
return;
}
let text = doc.get_text(LORO_TEXT_CONTAINER).to_string();
self.set_text(&text);
}
_ => {
// Other variants (Cursor, CellDelta, semantic frame
// family, presence, goodbye, etc.) are ignored in
// session 3. Goodbye in particular surfaces via the
// reader thread's clean-EOF path as a Disconnected
// event — not handled here.
}
}
}
fn resize(&mut self, width: u32, height: u32) {
self.config.width = width;
self.config.height = height;
@ -495,3 +716,62 @@ impl State {
self.atlas.trim();
}
}
/// Convert a `pmacs-protocol::cell::Color` to a `glyphon::Color`.
/// Returns `None` for `Default` so the renderer falls back to the
/// `Attrs` default color (white-ish in our render) rather than
/// stomping with an arbitrary RGB.
///
/// `Indexed` uses the standard ANSI 16-color + 256-color cube
/// palette. The TUI interprets these via terminal-level color codes;
/// the GPU has no equivalent layer, so the palette mapping lives
/// here. Picked to roughly match `xterm-256color` defaults so
/// existing pmacs themes look consistent across both frontends.
fn cell_color_to_glyphon(c: CellColor) -> Option<glyphon::Color> {
match c {
CellColor::Default => None,
CellColor::Rgb(r, g, b) => Some(glyphon::Color::rgb(r, g, b)),
CellColor::Indexed(idx) => Some(indexed_to_glyphon(idx)),
}
}
/// Standard xterm-style 256-color palette: 16 base colors + 6×6×6
/// RGB cube (16..=231) + 24-step grayscale (232..=255). Values
/// pulled from the conventional xterm defaults; the 6×6×6 cube uses
/// the standard step values {0, 95, 135, 175, 215, 255}.
fn indexed_to_glyphon(idx: u8) -> glyphon::Color {
const ANSI16: [(u8, u8, u8); 16] = [
(0, 0, 0), // 0 black
(205, 49, 49), // 1 red
(13, 188, 121), // 2 green
(229, 229, 16), // 3 yellow
(36, 114, 200), // 4 blue
(188, 63, 188), // 5 magenta
(17, 168, 205), // 6 cyan
(229, 229, 229), // 7 white
(102, 102, 102), // 8 bright black
(241, 76, 76), // 9 bright red
(35, 209, 139), // 10 bright green
(245, 245, 67), // 11 bright yellow
(59, 142, 234), // 12 bright blue
(214, 112, 214), // 13 bright magenta
(41, 184, 219), // 14 bright cyan
(255, 255, 255), // 15 bright white
];
if idx < 16 {
let (r, g, b) = ANSI16[idx as usize];
return glyphon::Color::rgb(r, g, b);
}
if (16..=231).contains(&idx) {
// 6×6×6 cube.
const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
let i = idx - 16;
let r = STEPS[(i / 36) as usize];
let g = STEPS[((i / 6) % 6) as usize];
let b = STEPS[(i % 6) as usize];
return glyphon::Color::rgb(r, g, b);
}
// 232..=255: 24-step grayscale, evenly spaced 8..=238.
let level = 8 + 10 * (idx - 232);
glyphon::Color::rgb(level, level, level)
}