Initial release: Orca VM, TUI, MIDI/OSC/UDP output, and release pipeline

This commit is contained in:
Bendik Aagaard Lynghaug
2026-07-07 17:40:00 +02:00
commit bf388576a3
19 changed files with 3009 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
//! Headless runner, equivalent to orca-c's cli_main.c:
//! load a grid file, simulate N ticks, print the resulting grid to stdout.
use std::io::Write;
use std::process::exit;
use orca_rs::sim::{orca_run, Field, MarkBuffer, Oevent};
const USAGE: &str = "\
Usage: orca-cli [options] infile
Options:
-t <n> Number of timesteps to simulate (default: 1)
-h, --help Print this message and exit
";
fn die(msg: &str) -> ! {
eprintln!("{msg}");
exit(1);
}
fn main() {
let mut ticks: usize = 1;
let mut path: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"-t" => {
ticks = args
.next()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| die("-t requires a non-negative integer"));
}
"-h" | "--help" => {
print!("{USAGE}");
return;
}
_ if path.is_none() && !arg.starts_with('-') => path = Some(arg),
_ => die(USAGE),
}
}
let path = path.unwrap_or_else(|| die(USAGE));
let mut field = match Field::load_file(&path) {
Ok(f) => f,
Err(e) => die(&format!("File load error: {}", e.as_str())),
};
let height = field.height as usize;
let width = field.width as usize;
let mut mbuf = MarkBuffer::new();
mbuf.ensure_size(height, width);
let mut events: Vec<Oevent> = Vec::new();
for tick in 0..ticks {
mbuf.clear(height, width);
orca_run(
&mut field.buffer,
&mut mbuf.buffer[..height * width],
height,
width,
tick,
&mut events,
0,
);
events.clear();
}
let stdout = std::io::stdout();
let mut out = stdout.lock();
if field.write_to(&mut out).and_then(|_| out.flush()).is_err() {
exit(1);
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Event output backends: UDP strings (`;`), OSC integer messages (`=`),
//! and MIDI notes/CC/pitchbend (`:` `%` `!` `?`) — the counterpart of the
//! PortMidi/oosc code in orca-c's tui_main.c.
use std::io;
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
use midir::{MidiOutput, MidiOutputConnection};
use crate::sim::Oevent;
fn resolve(host: &str, port: u16) -> io::Result<(UdpSocket, SocketAddr)> {
let dest = (host, port)
.to_socket_addrs()?
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot resolve host"))?;
let bind_addr = if dest.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" };
let socket = UdpSocket::bind(bind_addr)?;
Ok((socket, dest))
}
pub struct UdpOut {
socket: UdpSocket,
dest: SocketAddr,
}
impl UdpOut {
pub fn new(host: &str, port: u16) -> io::Result<Self> {
let (socket, dest) = resolve(host, port)?;
Ok(Self { socket, dest })
}
pub fn send(&self, payload: &[u8]) {
let _ = self.socket.send_to(payload, self.dest);
}
}
pub struct OscOut {
socket: UdpSocket,
dest: SocketAddr,
}
impl OscOut {
pub fn new(host: &str, port: u16) -> io::Result<Self> {
let (socket, dest) = resolve(host, port)?;
Ok(Self { socket, dest })
}
pub fn send_ints(&self, path: &str, ints: &[i32]) {
let mut msg = Vec::with_capacity(path.len() + ints.len() * 5 + 8);
msg.extend_from_slice(path.as_bytes());
msg.push(0);
while msg.len() % 4 != 0 {
msg.push(0);
}
msg.push(b',');
msg.extend(std::iter::repeat_n(b'i', ints.len()));
msg.push(0);
while msg.len() % 4 != 0 {
msg.push(0);
}
for i in ints {
msg.extend_from_slice(&i.to_be_bytes());
}
let _ = self.socket.send_to(&msg, self.dest);
}
}
// A sounding note; `remaining` counts down one per tick, note-off at zero.
struct SusNote {
channel: u8,
pitch: u8,
remaining: u8,
}
pub struct MidiOut {
conn: MidiOutputConnection,
sustained: Vec<SusNote>,
}
impl MidiOut {
pub fn list_ports() -> Vec<String> {
match MidiOutput::new("orca-rs") {
Ok(out) => out
.ports()
.iter()
.filter_map(|p| out.port_name(p).ok())
.collect(),
Err(_) => Vec::new(),
}
}
/// With a name hint, connect to the first real port whose name contains it
/// (case-insensitive). Without one, create a virtual ALSA port named
/// "orca-rs" that synths can connect to.
pub fn open(name_hint: Option<&str>) -> Result<Self, String> {
let out = MidiOutput::new("orca-rs").map_err(|e| e.to_string())?;
let conn = match name_hint {
Some(hint) => {
let needle = hint.to_lowercase();
let port = out
.ports()
.into_iter()
.find(|p| {
out.port_name(p)
.map(|n| n.to_lowercase().contains(&needle))
.unwrap_or(false)
})
.ok_or_else(|| format!("no MIDI output port matching '{hint}'"))?;
out.connect(&port, "orca-rs").map_err(|e| e.to_string())?
}
None => {
#[cfg(unix)]
{
use midir::os::unix::VirtualOutput;
out.create_virtual("orca-rs").map_err(|e| e.to_string())?
}
#[cfg(not(unix))]
{
let ports = out.ports();
let port = ports.first().ok_or("no MIDI output ports available")?;
out.connect(port, "orca-rs").map_err(|e| e.to_string())?
}
}
};
Ok(Self { conn, sustained: Vec::new() })
}
fn send(&mut self, msg: &[u8]) {
let _ = self.conn.send(msg);
}
/// Advance one tick: expire sustained notes and emit their note-offs.
pub fn advance_tick(&mut self) {
let mut offs: Vec<(u8, u8)> = Vec::new();
self.sustained.retain_mut(|n| {
n.remaining -= 1;
if n.remaining == 0 {
offs.push((n.channel, n.pitch));
false
} else {
true
}
});
for (channel, pitch) in offs {
self.send(&[0x80 | channel, pitch, 0]);
}
}
pub fn note_on(&mut self, channel: u8, pitch: u8, velocity: u8, duration: u8, mono: bool) {
// Retrigger rules from orca-c: mono kills the whole channel, and a
// repeated pitch is released before sounding again.
let mut offs: Vec<(u8, u8)> = Vec::new();
self.sustained.retain(|n| {
if n.channel == channel && (mono || n.pitch == pitch) {
offs.push((n.channel, n.pitch));
false
} else {
true
}
});
for (ch, p) in offs {
self.send(&[0x80 | ch, p, 0]);
}
self.send(&[0x90 | channel, pitch, velocity]);
self.sustained.push(SusNote {
channel,
pitch,
remaining: duration.max(1),
});
}
pub fn control_change(&mut self, channel: u8, control: u8, value: u8) {
self.send(&[0xB0 | channel, control, value]);
}
pub fn pitch_bend(&mut self, channel: u8, lsb: u8, msb: u8) {
self.send(&[0xE0 | channel, lsb, msb]);
}
/// Release everything immediately (used on pause and on exit).
pub fn all_notes_off(&mut self) {
let notes: Vec<(u8, u8)> = self
.sustained
.drain(..)
.map(|n| (n.channel, n.pitch))
.collect();
for (channel, pitch) in notes {
self.send(&[0x80 | channel, pitch, 0]);
}
}
}
#[derive(Default)]
pub struct EventOutputs {
pub udp: Option<UdpOut>,
pub osc: Option<OscOut>,
pub midi: Option<MidiOut>,
}
impl EventOutputs {
/// Call once at the start of every tick, before running the sim.
pub fn begin_tick(&mut self) {
if let Some(midi) = &mut self.midi {
midi.advance_tick();
}
}
/// Deliver the events produced by one sim tick.
pub fn dispatch(&mut self, events: &mut Vec<Oevent>) {
for ev in events.drain(..) {
match ev {
Oevent::MidiNote { channel, octave, note, velocity, duration, mono } => {
if let Some(midi) = &mut self.midi {
let pitch = (octave as u16 * 12 + note as u16).min(127) as u8;
midi.note_on(channel & 15, pitch, velocity.min(127), duration, mono);
}
}
Oevent::MidiCc { channel, control, value } => {
if let Some(midi) = &mut self.midi {
midi.control_change(channel & 15, control & 0x7f, value & 0x7f);
}
}
Oevent::MidiPb { channel, lsb, msb } => {
if let Some(midi) = &mut self.midi {
midi.pitch_bend(channel & 15, lsb & 0x7f, msb & 0x7f);
}
}
Oevent::OscInts { glyph, numbers } => {
if let Some(osc) = &self.osc {
let path = format!("/{}", glyph as char);
let ints: Vec<i32> = numbers.iter().map(|&n| n as i32).collect();
osc.send_ints(&path, &ints);
}
}
Oevent::UdpString { chars } => {
if let Some(udp) = &self.udp {
udp.send(&chars);
}
}
}
}
}
/// Silence all sustained MIDI notes (pause / quit).
pub fn silence(&mut self) {
if let Some(midi) = &mut self.midi {
midi.all_notes_off();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod io;
pub mod sim;
pub mod tui;
+102
View File
@@ -0,0 +1,102 @@
use std::process::exit;
use orca_rs::io::{EventOutputs, MidiOut, OscOut, UdpOut};
use orca_rs::tui::{self, TuiConfig};
const USAGE: &str = "\
Usage: orca-rs [options] [file.orca]
Open the Orca live-coding environment. If the file does not exist it will
be created on first save; with no file, edits save to untitled.orca.
Options:
--bpm <n> Tempo (default: 120)
--seed <n> Random seed for the R operator (default: 1)
--udp-port <n> Send ';' operator strings over UDP to this port
--udp-host <h> UDP destination host (default: 127.0.0.1)
--osc-port <n> Send '=' operator messages over OSC to this port
--osc-host <h> OSC destination host (default: 127.0.0.1)
--no-midi Disable MIDI output (on by default: creates a virtual
ALSA port named 'orca-rs', visible in PipeWire)
--midi-name <s> Connect to the MIDI output port whose name contains <s>
instead of creating a virtual port
--list-midi List available MIDI output ports and exit
-h, --help Print this message and exit
";
fn die(msg: &str) -> ! {
eprintln!("{msg}");
exit(1);
}
fn parse_num(args: &mut impl Iterator<Item = String>, flag: &str) -> usize {
args.next()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| die(&format!("{flag} requires a number")))
}
fn main() {
let mut path: Option<String> = None;
let mut bpm: usize = 120;
let mut seed: usize = 1;
let mut udp_host = "127.0.0.1".to_string();
let mut udp_port: Option<u16> = None;
let mut osc_host = "127.0.0.1".to_string();
let mut osc_port: Option<u16> = None;
let mut midi = true;
let mut midi_name: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--bpm" => bpm = parse_num(&mut args, "--bpm").clamp(10, 999),
"--seed" => seed = parse_num(&mut args, "--seed"),
"--udp-port" => udp_port = Some(parse_num(&mut args, "--udp-port") as u16),
"--udp-host" => udp_host = args.next().unwrap_or_else(|| die("--udp-host requires a value")),
"--osc-port" => osc_port = Some(parse_num(&mut args, "--osc-port") as u16),
"--osc-host" => osc_host = args.next().unwrap_or_else(|| die("--osc-host requires a value")),
"--midi" => midi = true,
"--no-midi" => midi = false,
"--midi-name" => {
midi = true;
midi_name = Some(args.next().unwrap_or_else(|| die("--midi-name requires a value")));
}
"--list-midi" => {
let ports = MidiOut::list_ports();
if ports.is_empty() {
println!("no MIDI output ports found");
} else {
for p in ports {
println!("{p}");
}
}
return;
}
"-h" | "--help" => {
print!("{USAGE}");
return;
}
_ if path.is_none() && !arg.starts_with('-') => path = Some(arg),
_ => die(USAGE),
}
}
let mut outputs = EventOutputs::default();
if let Some(port) = udp_port {
outputs.udp =
Some(UdpOut::new(&udp_host, port).unwrap_or_else(|e| die(&format!("UDP setup failed: {e}"))));
}
if let Some(port) = osc_port {
outputs.osc =
Some(OscOut::new(&osc_host, port).unwrap_or_else(|e| die(&format!("OSC setup failed: {e}"))));
}
if midi {
outputs.midi = Some(MidiOut::open(midi_name.as_deref()).unwrap_or_else(|e| {
die(&format!("MIDI setup failed: {e} (use --no-midi to run without MIDI)"))
}));
}
if let Err(e) = tui::run(TuiConfig { path, bpm, seed, outputs }) {
die(&e);
}
}
+145
View File
@@ -0,0 +1,145 @@
use std::io::{self, BufRead, Write};
use std::fs::File;
use super::{Glyph, Mark};
pub struct Field {
pub buffer: Vec<Glyph>,
pub width: u16,
pub height: u16,
}
pub struct MarkBuffer {
pub buffer: Vec<Mark>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldLoadError {
CantOpen,
TooManyColumns,
TooManyRows,
NoRowsRead,
NotARectangle,
}
impl FieldLoadError {
pub fn as_str(self) -> &'static str {
match self {
Self::CantOpen => "cannot open file",
Self::TooManyColumns => "too many columns",
Self::TooManyRows => "too many rows",
Self::NoRowsRead => "no rows read",
Self::NotARectangle => "not a rectangle",
}
}
}
impl Field {
pub fn new() -> Self {
Self { buffer: Vec::new(), width: 0, height: 0 }
}
pub fn with_fill(height: usize, width: usize, fill: Glyph) -> Self {
Self {
buffer: vec![fill; height * width],
width: width as u16,
height: height as u16,
}
}
pub fn resize(&mut self, height: usize, width: usize) {
let mut new_buffer = vec![b'.'; height * width];
super::copy_subrect(
&self.buffer,
&mut new_buffer,
self.height as usize,
self.width as usize,
height,
width,
0,
0,
0,
0,
(self.height as usize).min(height),
(self.width as usize).min(width),
);
self.buffer = new_buffer;
self.width = width as u16;
self.height = height as u16;
}
pub fn load_file(path: &str) -> Result<Self, FieldLoadError> {
let file = File::open(path).map_err(|_| FieldLoadError::CantOpen)?;
let reader = io::BufReader::new(file);
let mut rows: Vec<Vec<u8>> = Vec::new();
let mut row_width: Option<usize> = None;
for line in reader.lines() {
let line = line.map_err(|_| FieldLoadError::CantOpen)?;
let row: Vec<u8> = line.trim_end_matches('\r').bytes().collect();
if rows.len() >= u16::MAX as usize {
return Err(FieldLoadError::TooManyRows);
}
if row.len() > u16::MAX as usize {
return Err(FieldLoadError::TooManyColumns);
}
match row_width {
None => row_width = Some(row.len()),
Some(w) if w != row.len() => return Err(FieldLoadError::NotARectangle),
_ => {}
}
rows.push(row);
}
if rows.is_empty() {
return Err(FieldLoadError::NoRowsRead);
}
let width = row_width.unwrap_or(0);
let height = rows.len();
let buffer = rows.into_iter().flatten().collect();
Ok(Self { buffer, width: width as u16, height: height as u16 })
}
pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
let h = self.height as usize;
let wd = self.width as usize;
for iy in 0..h {
w.write_all(&self.buffer[iy * wd..(iy + 1) * wd])?;
writeln!(w)?;
}
Ok(())
}
}
impl Default for Field {
fn default() -> Self {
Self::new()
}
}
impl MarkBuffer {
pub fn new() -> Self {
Self { buffer: Vec::new() }
}
pub fn ensure_size(&mut self, height: usize, width: usize) {
let needed = height * width;
if self.buffer.len() < needed {
self.buffer.resize(needed, 0);
}
}
pub fn clear(&mut self, height: usize, width: usize) {
let n = height * width;
if self.buffer.len() >= n {
self.buffer[..n].fill(0);
}
}
}
impl Default for MarkBuffer {
fn default() -> Self {
Self::new()
}
}
+126
View File
@@ -0,0 +1,126 @@
// Coordinate-heavy signatures deliberately mirror the C gbuffer API.
#![allow(clippy::too_many_arguments)]
use super::{Glyph, Mark};
#[inline]
pub fn peek_rel(
gbuf: &[Glyph],
height: usize,
width: usize,
y: usize,
x: usize,
dy: isize,
dx: isize,
) -> Glyph {
let y0 = y as isize + dy;
let x0 = x as isize + dx;
if y0 < 0 || x0 < 0 || y0 as usize >= height || x0 as usize >= width {
return b'.';
}
gbuf[y0 as usize * width + x0 as usize]
}
#[inline]
pub fn poke(gbuf: &mut [Glyph], height: usize, width: usize, y: usize, x: usize, g: Glyph) {
debug_assert!(y < height && x < width);
let _ = height;
gbuf[y * width + x] = g;
}
#[inline]
pub fn poke_rel(
gbuf: &mut [Glyph],
height: usize,
width: usize,
y: usize,
x: usize,
dy: isize,
dx: isize,
g: Glyph,
) {
let y0 = y as isize + dy;
let x0 = x as isize + dx;
if y0 < 0 || x0 < 0 || y0 as usize >= height || x0 as usize >= width {
return;
}
gbuf[y0 as usize * width + x0 as usize] = g;
}
#[inline]
pub fn mbuf_poke_rel_or(
mbuf: &mut [Mark],
height: usize,
width: usize,
y: usize,
x: usize,
dy: isize,
dx: isize,
flags: Mark,
) {
let y0 = y as isize + dy;
let x0 = x as isize + dx;
if y0 < 0 || x0 < 0 || y0 as usize >= height || x0 as usize >= width {
return;
}
mbuf[y0 as usize * width + x0 as usize] |= flags;
}
pub fn copy_subrect(
src: &[Glyph],
dest: &mut [Glyph],
src_h: usize,
src_w: usize,
dest_h: usize,
dest_w: usize,
src_y: usize,
src_x: usize,
dest_y: usize,
dest_x: usize,
height: usize,
width: usize,
) {
if width == 0 || height == 0 {
return;
}
for row in 0..height {
let sy = src_y + row;
let dy = dest_y + row;
if sy >= src_h || dy >= dest_h {
break;
}
for col in 0..width {
let sx = src_x + col;
let dx = dest_x + col;
if sx >= src_w || dx >= dest_w {
break;
}
dest[dy * dest_w + dx] = src[sy * src_w + sx];
}
}
}
pub fn fill_subrect(
gbuf: &mut [Glyph],
grid_h: usize,
grid_w: usize,
y: usize,
x: usize,
height: usize,
width: usize,
fill: Glyph,
) {
for row in 0..height {
let gy = y + row;
if gy >= grid_h {
break;
}
for col in 0..width {
let gx = x + col;
if gx >= grid_w {
break;
}
gbuf[gy * grid_w + gx] = fill;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
mod field;
mod gbuffer;
mod vmio;
#[allow(clippy::module_inception)]
mod sim;
pub use field::{Field, MarkBuffer, FieldLoadError};
pub use gbuffer::{copy_subrect, fill_subrect};
pub use vmio::Oevent;
pub use sim::orca_run;
pub type Glyph = u8;
pub type Mark = u8;
pub const MARK_INPUT: Mark = 1 << 0;
pub const MARK_OUTPUT: Mark = 1 << 1;
pub const MARK_HASTE: Mark = 1 << 2;
pub const MARK_LOCK: Mark = 1 << 3;
pub const MARK_SLEEP: Mark = 1 << 4;
+832
View File
@@ -0,0 +1,832 @@
use super::vmio::Oevent;
use super::{MARK_HASTE, MARK_INPUT, MARK_LOCK, MARK_OUTPUT, MARK_SLEEP};
use super::gbuffer::{mbuf_poke_rel_or, peek_rel, poke, poke_rel};
// Port flag aliases matching the C macros
const IN: u8 = MARK_INPUT;
const OUT: u8 = MARK_OUTPUT;
const PARAM: u8 = MARK_HASTE;
const NONLOCKING: u8 = MARK_LOCK;
// glyph_table[i] = the glyph for numeric index i (0-35)
const GLYPH_TABLE: [u8; 36] = *b"0123456789abcdefghijklmnopqrstuvwxyz";
// index_table[ascii] = numeric index of that glyph (0-35); 0 for invalid
#[rustfmt::skip]
const INDEX_TABLE: [u8; 128] = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 32-47
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 0, 0, 0, 0, 0, // 48-63
0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, // 64-79
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 0, 0, 0, 0, 0, // 80-95
0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, // 96-111
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 0, 0, 0, 0, 0, // 112-127
];
#[inline]
fn index_of(g: u8) -> usize {
INDEX_TABLE[(g & 0x7f) as usize] as usize
}
#[inline]
fn glyph_of(idx: usize) -> u8 {
GLYPH_TABLE[idx % 36]
}
// Preserve the alphabetic case of `caser` in `g`; digits are returned unchanged.
// Mirrors the C bit-manipulation formula in sim.c.
#[inline]
fn glyph_with_case(g: u8, caser: u8) -> u8 {
const CASE_BIT: u8 = 1 << 5;
const ALPHA_BIT: u8 = 1 << 6;
(g & !CASE_BIT) | ((!g & ALPHA_BIT) >> 1) | (caser & CASE_BIT)
}
// Returns MIDI pitch class (0-11 + optional sharp) for letter glyphs A-Z/a-z.
// Uppercase = natural, lowercase = sharp. A=H, B=I (i.e. C is the base note).
fn midi_note_number_of(g: u8) -> Option<u8> {
let sharp = (g >> 5) & 1;
let g = g & !0x20u8; // make uppercase
if !g.is_ascii_uppercase() {
return None;
}
let deg = if g <= b'B' {
(b'G' - b'B' + g - b'A') as usize
} else {
(g - b'C') as usize
};
let semitones: [u8; 7] = [0, 2, 4, 5, 7, 9, 11];
Some(deg as u8 / 7 * 12 + semitones[deg % 7] + sharp)
}
// ── Operator context ─────────────────────────────────────────────────────────
struct Ctx<'a> {
gbuf: &'a mut [u8],
mbuf: &'a mut [u8],
height: usize,
width: usize,
y: usize,
x: usize,
tick: usize,
vars: &'a mut [u8; 36],
events: &'a mut Vec<Oevent>,
random_seed: usize,
this_char: u8,
}
impl<'a> Ctx<'a> {
#[inline]
fn peek(&self, dy: isize, dx: isize) -> u8 {
peek_rel(self.gbuf, self.height, self.width, self.y, self.x, dy, dx)
}
#[inline]
fn poke(&mut self, dy: isize, dx: isize, g: u8) {
poke_rel(self.gbuf, self.height, self.width, self.y, self.x, dy, dx, g);
}
#[inline]
fn stun(&mut self, dy: isize, dx: isize) {
mbuf_poke_rel_or(self.mbuf, self.height, self.width, self.y, self.x, dy, dx, MARK_SLEEP);
}
// Write glyph and immediately mark it as sleeping (won't execute this tick).
#[inline]
fn poke_stunned(&mut self, dy: isize, dx: isize, g: u8) {
let y0 = self.y as isize + dy;
let x0 = self.x as isize + dx;
if y0 < 0 || x0 < 0 || y0 as usize >= self.height || x0 as usize >= self.width {
return;
}
let offs = y0 as usize * self.width + x0 as usize;
self.gbuf[offs] = g;
self.mbuf[offs] |= MARK_SLEEP;
}
// Mark a neighboring cell's port flags. XOR with LOCK matches the C PORT macro:
// INPUT/OUTPUT ports are automatically locked; NONLOCKING cancels that lock.
#[inline]
fn port(&mut self, dy: isize, dx: isize, flags: u8) {
mbuf_poke_rel_or(self.mbuf, self.height, self.width, self.y, self.x, dy, dx, flags ^ MARK_LOCK);
}
#[inline]
fn lock(&mut self, dy: isize, dx: isize) {
mbuf_poke_rel_or(self.mbuf, self.height, self.width, self.y, self.x, dy, dx, MARK_LOCK);
}
#[inline]
fn has_neighboring_bang(&self) -> bool {
let base = self.y * self.width + self.x;
let gbuf = &self.gbuf;
if self.x + 1 < self.width && gbuf[base + 1] == b'*' {
return true;
}
if self.x > 0 && gbuf[base - 1] == b'*' {
return true;
}
if self.y + 1 < self.height && gbuf[base + self.width] == b'*' {
return true;
}
if self.y > 0 && gbuf[base - self.width] == b'*' {
return true;
}
false
}
// Returns true when the operator should bail out early (lowercase but not banged).
#[inline]
fn lowercase_requires_bang(&self) -> bool {
(self.this_char & (1 << 5) != 0) && !self.has_neighboring_bang()
}
// Returns true when the operator should bail out (not banged at all).
#[inline]
fn stop_if_not_banged(&self) -> bool {
!self.has_neighboring_bang()
}
}
// ── Operator implementations ─────────────────────────────────────────────────
fn oper_movement(ctx: &mut Ctx) {
if (ctx.this_char & (1 << 5) != 0) && !ctx.has_neighboring_bang() {
return;
}
let (dy, dx): (isize, isize) = match ctx.this_char | 0x20 {
b'n' => (-1, 0),
b'e' => (0, 1),
b's' => (1, 0),
b'w' => (0, -1),
_ => (0, 0),
};
let y0 = ctx.y as isize + dy;
let x0 = ctx.x as isize + dx;
let cur = ctx.y * ctx.width + ctx.x;
if y0 < 0 || x0 < 0 || y0 as usize >= ctx.height || x0 as usize >= ctx.width {
ctx.gbuf[cur] = b'*';
return;
}
let dest = y0 as usize * ctx.width + x0 as usize;
if ctx.gbuf[dest] == b'.' {
ctx.gbuf[dest] = ctx.this_char;
ctx.gbuf[cur] = b'.';
ctx.mbuf[dest] |= MARK_SLEEP;
} else {
ctx.gbuf[cur] = b'*';
}
}
fn oper_midicc(ctx: &mut Ctx) {
ctx.port(0, 1, IN);
ctx.port(0, 2, IN);
ctx.port(0, 3, IN);
if ctx.stop_if_not_banged() { return; }
let channel_g = ctx.peek(0, 1);
let control_g = ctx.peek(0, 2);
let value_g = ctx.peek(0, 3);
if channel_g == b'.' || control_g == b'.' { return; }
let channel = index_of(channel_g);
if channel > 15 { return; }
ctx.port(0, 0, OUT);
ctx.events.push(Oevent::MidiCc {
channel: channel as u8,
control: index_of(control_g) as u8,
value: (index_of(value_g) * 127 / 35) as u8,
});
}
fn oper_comment(ctx: &mut Ctx) {
let max_x = (ctx.x + 255).min(ctx.width);
for x0 in (ctx.x + 1)..max_x {
ctx.mbuf[ctx.y * ctx.width + x0] |= MARK_LOCK;
if ctx.gbuf[ctx.y * ctx.width + x0] == b'#' {
break;
}
}
}
fn oper_bang(ctx: &mut Ctx) {
poke(ctx.gbuf, ctx.height, ctx.width, ctx.y, ctx.x, b'.');
}
fn oper_midi(ctx: &mut Ctx) {
for i in 1isize..=5 {
ctx.port(0, i, IN);
}
if ctx.stop_if_not_banged() { return; }
let channel_g = ctx.peek(0, 1);
let octave_g = ctx.peek(0, 2);
let note_g = ctx.peek(0, 3);
let velocity_g = ctx.peek(0, 4);
let length_g = ctx.peek(0, 5);
if octave_g == b'.' { return; }
let mut octave_num = index_of(octave_g) as u8;
if octave_num > 9 { octave_num = 9; }
let note_num = match midi_note_number_of(note_g) {
Some(n) => n,
None => return,
};
let channel_num = {
let c = index_of(channel_g);
if c > 15 { 15 } else { c as u8 }
};
let vel_num: u8 = if velocity_g == b'.' {
127
} else {
let v = index_of(velocity_g);
if v == 0 { return; }
let v = v * 8 - 1;
if v > 127 { 127 } else { v as u8 }
};
ctx.port(0, 0, OUT);
ctx.events.push(Oevent::MidiNote {
channel: channel_num,
octave: octave_num,
note: note_num,
velocity: vel_num,
duration: (index_of(length_g) & 0x7f) as u8,
mono: ctx.this_char == b'%',
});
}
fn oper_udp(ctx: &mut Ctx) {
let n = (ctx.width - ctx.x - 1).min(super::vmio::UDP_STR_COUNT);
let row_base = ctx.y * ctx.width + ctx.x + 1;
let mut chars: Vec<u8> = Vec::new();
for i in 0..n {
let g = ctx.gbuf[row_base + i];
if g == b'.' { break; }
chars.push(g);
ctx.mbuf[row_base + i] |= MARK_LOCK;
}
if ctx.stop_if_not_banged() { return; }
ctx.port(0, 0, OUT);
ctx.events.push(Oevent::UdpString { chars });
}
fn oper_osc(ctx: &mut Ctx) {
ctx.port(0, 1, IN | PARAM);
ctx.port(0, 2, IN | PARAM);
let len = index_of(ctx.peek(0, 2)).min(super::vmio::OSC_INT_COUNT);
for i in 0..len {
ctx.port(0, i as isize + 3, IN);
}
if ctx.stop_if_not_banged() { return; }
let g = ctx.peek(0, 1);
if g == b'.' { return; }
ctx.port(0, 0, OUT);
let numbers: Vec<u8> = (0..len)
.map(|i| index_of(ctx.peek(0, i as isize + 3)) as u8)
.collect();
ctx.events.push(Oevent::OscInts { glyph: g, numbers });
}
fn oper_midipb(ctx: &mut Ctx) {
ctx.port(0, 1, IN);
ctx.port(0, 2, IN);
ctx.port(0, 3, IN);
if ctx.stop_if_not_banged() { return; }
let channel_g = ctx.peek(0, 1);
let msb_g = ctx.peek(0, 2);
let lsb_g = ctx.peek(0, 3);
if channel_g == b'.' { return; }
let channel = index_of(channel_g);
if channel > 15 { return; }
ctx.port(0, 0, OUT);
ctx.events.push(Oevent::MidiPb {
channel: channel as u8,
msb: (index_of(msb_g) * 127 / 35) as u8,
lsb: (index_of(lsb_g) * 127 / 35) as u8,
});
}
fn oper_add(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let a = ctx.peek(0, -1);
let b = ctx.peek(0, 1);
let g = glyph_of((index_of(a) + index_of(b)) % 36);
ctx.poke(1, 0, glyph_with_case(g, b));
}
fn oper_subtract(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let a = ctx.peek(0, -1);
let b = ctx.peek(0, 1);
let val = (index_of(b) as isize - index_of(a) as isize).unsigned_abs();
ctx.poke(1, 0, glyph_with_case(glyph_of(val), b));
}
fn oper_clock(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let b = ctx.peek(0, 1);
let rate = {
let r = index_of(ctx.peek(0, -1));
if r == 0 { 1 } else { r }
};
let mod_num = {
let m = index_of(b);
if m == 0 { 8 } else { m }
};
let g = glyph_of(ctx.tick / rate % mod_num);
ctx.poke(1, 0, glyph_with_case(g, b));
}
fn oper_delay(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let rate = {
let r = index_of(ctx.peek(0, -1));
if r == 0 { 1 } else { r }
};
let mod_num = {
let m = index_of(ctx.peek(0, 1));
if m == 0 { 8 } else { m }
};
let g = if ctx.tick.is_multiple_of(rate * mod_num) { b'*' } else { b'.' };
ctx.poke(1, 0, g);
}
fn oper_if(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let g0 = ctx.peek(0, -1);
let g1 = ctx.peek(0, 1);
ctx.poke(1, 0, if g0 == g1 { b'*' } else { b'.' });
}
fn oper_generator(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let out_x = index_of(ctx.peek(0, -3)) as isize;
let out_y = index_of(ctx.peek(0, -2)) as isize + 1;
let len = index_of(ctx.peek(0, -1)) as isize;
ctx.port(0, -3, IN | PARAM);
ctx.port(0, -2, IN | PARAM);
ctx.port(0, -1, IN | PARAM);
for i in 0..len {
ctx.port(0, i + 1, IN);
ctx.port(out_y, out_x + i, OUT | NONLOCKING);
let g = ctx.peek(0, i + 1);
ctx.poke_stunned(out_y, out_x + i, g);
}
}
fn oper_halt(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(1, 0, IN | PARAM);
}
fn oper_increment(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, IN | OUT);
let ga = ctx.peek(0, -1);
let gb = ctx.peek(0, 1);
let rate = if ga == b'.' || ga == b'*' { 1 } else { index_of(ga) };
let max = { let m = index_of(gb); if m == 0 { 36 } else { m } };
let val = (index_of(ctx.peek(1, 0)) + rate) % max;
ctx.poke(1, 0, glyph_with_case(glyph_of(val), gb));
}
fn oper_jump(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let g = ctx.peek(-1, 0);
if g == b'J' { return; }
ctx.port(-1, 0, IN);
for i in 1isize..=256 {
if ctx.peek(i, 0) != ctx.this_char {
ctx.port(i, 0, OUT);
ctx.poke(i, 0, g);
break;
}
ctx.stun(i, 0);
}
}
fn oper_konkat(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let len = {
let l = index_of(ctx.peek(0, -1)) as isize;
if l == 0 { 1 } else { l }
};
ctx.port(0, -1, IN | PARAM);
for i in 0..len {
ctx.port(0, i + 1, IN);
let var = ctx.peek(0, i + 1);
if var != b'.' {
let result = ctx.vars[index_of(var)];
ctx.port(1, i + 1, OUT);
ctx.poke(1, i + 1, result);
}
}
}
fn oper_lesser(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let ga = ctx.peek(0, -1);
let gb = ctx.peek(0, 1);
if ga == b'.' || gb == b'.' {
ctx.poke(1, 0, b'.');
} else {
let ia = index_of(ga);
let ib = index_of(gb);
let out = ia.min(ib);
ctx.poke(1, 0, glyph_with_case(glyph_of(out), gb));
}
}
fn oper_multiply(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let a = ctx.peek(0, -1);
let b = ctx.peek(0, 1);
let g = glyph_of((index_of(a) * index_of(b)) % 36);
ctx.poke(1, 0, glyph_with_case(g, b));
}
fn oper_offset(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let in_x = index_of(ctx.peek(0, -2)) as isize + 1;
let in_y = index_of(ctx.peek(0, -1)) as isize;
ctx.port(0, -1, IN | PARAM);
ctx.port(0, -2, IN | PARAM);
ctx.port(in_y, in_x, IN);
ctx.port(1, 0, OUT);
let g = ctx.peek(in_y, in_x);
ctx.poke(1, 0, g);
}
fn oper_push(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let key = index_of(ctx.peek(0, -2));
let len = index_of(ctx.peek(0, -1));
ctx.port(0, -1, IN | PARAM);
ctx.port(0, -2, IN | PARAM);
ctx.port(0, 1, IN);
if len == 0 { return; }
let out_x = (key % len) as isize;
for i in 0..len as isize {
ctx.lock(1, i);
}
ctx.port(1, out_x, OUT);
let g = ctx.peek(0, 1);
ctx.poke(1, out_x, g);
}
fn oper_query(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let in_x = index_of(ctx.peek(0, -3)) as isize + 1;
let in_y = index_of(ctx.peek(0, -2)) as isize;
let len = index_of(ctx.peek(0, -1)) as isize;
let out_x = 1 - len;
ctx.port(0, -3, IN | PARAM);
ctx.port(0, -2, IN | PARAM);
ctx.port(0, -1, IN | PARAM);
for i in 0..len {
ctx.port(in_y, in_x + i, IN);
ctx.port(1, out_x + i, OUT);
let g = ctx.peek(in_y, in_x + i);
ctx.poke(1, out_x + i, g);
}
}
fn oper_random(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let gb = ctx.peek(0, 1);
let a = index_of(ctx.peek(0, -1));
let b = { let b = index_of(gb); if b == 0 { 36 } else { b } };
let (min, max) = if a == b {
ctx.poke(1, 0, glyph_of(a));
return;
} else if a < b {
(a, b)
} else {
(b, a)
};
// 32-bit shift-multiply hash — same algorithm as C sim.c
let mut key = (ctx.random_seed + ctx.y * ctx.width + ctx.x)
^ (ctx.tick << 16);
key = (key ^ 61) ^ (key >> 16);
key = key.wrapping_add(key << 3);
key ^= key >> 4;
key = key.wrapping_mul(0x27d4eb2d);
key ^= key >> 15;
let val = key % (max - min) + min;
ctx.poke(1, 0, glyph_with_case(glyph_of(val), gb));
}
fn oper_track(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let key = index_of(ctx.peek(0, -2));
let len = index_of(ctx.peek(0, -1));
ctx.port(0, -2, IN | PARAM);
ctx.port(0, -1, IN | PARAM);
if len == 0 { return; }
let read_x = (key % len) as isize + 1;
for i in 0..len as isize {
ctx.lock(0, i + 1);
}
ctx.port(0, read_x, IN);
ctx.port(1, 0, OUT);
let g = ctx.peek(0, read_x);
ctx.poke(1, 0, g);
}
fn oper_uclid(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, OUT);
let left = ctx.peek(0, -1);
let steps = if left == b'.' || left == b'*' { 1 } else { index_of(left) };
let max = { let m = index_of(ctx.peek(0, 1)); if m == 0 { 8 } else { m } };
let bucket = (steps * (ctx.tick + max - 1)) % max + steps;
ctx.poke(1, 0, if bucket >= max { b'*' } else { b'.' });
}
fn oper_variable(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
let left = ctx.peek(0, -1);
let right = ctx.peek(0, 1);
if left != b'.' {
ctx.vars[index_of(left)] = right;
} else if right != b'.' {
ctx.port(1, 0, OUT);
let result = ctx.vars[index_of(right)];
ctx.poke(1, 0, result);
}
}
fn oper_teleport(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let out_x = index_of(ctx.peek(0, -2)) as isize;
let out_y = index_of(ctx.peek(0, -1)) as isize + 1;
ctx.port(0, -2, IN | PARAM);
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(out_y, out_x, OUT | NONLOCKING);
let g = ctx.peek(0, 1);
ctx.poke_stunned(out_y, out_x, g);
}
fn oper_yump(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
let g = ctx.peek(0, -1);
if g == b'Y' { return; }
ctx.port(0, -1, IN);
for i in 1isize..=256 {
if ctx.peek(0, i) != ctx.this_char {
ctx.port(0, i, OUT);
ctx.poke(0, i, g);
break;
}
ctx.stun(0, i);
}
}
fn oper_lerp(ctx: &mut Ctx) {
if ctx.lowercase_requires_bang() { return; }
ctx.port(0, -1, IN | PARAM);
ctx.port(0, 1, IN);
ctx.port(1, 0, IN | OUT);
let g = ctx.peek(0, -1);
let b = ctx.peek(0, 1);
let rate: isize = if g == b'.' || g == b'*' { 1 } else { index_of(g) as isize };
let goal = index_of(b) as isize;
let val = index_of(ctx.peek(1, 0)) as isize;
let modv = if val <= goal - rate {
rate
} else if val >= goal + rate {
-rate
} else {
goal - val
};
ctx.poke(1, 0, glyph_with_case(glyph_of((val + modv) as usize), b));
}
// ── Dispatch ─────────────────────────────────────────────────────────────────
fn dispatch(ctx: &mut Ctx) {
match ctx.this_char {
b'!' => oper_midicc(ctx),
b'#' => oper_comment(ctx),
b'%' | b':' => oper_midi(ctx),
b'*' => oper_bang(ctx),
b';' => oper_udp(ctx),
b'=' => oper_osc(ctx),
b'?' => oper_midipb(ctx),
b'A' | b'a' => oper_add(ctx),
b'B' | b'b' => oper_subtract(ctx),
b'C' | b'c' => oper_clock(ctx),
b'D' | b'd' => oper_delay(ctx),
b'E' | b'e' => oper_movement(ctx),
b'F' | b'f' => oper_if(ctx),
b'G' | b'g' => oper_generator(ctx),
b'H' | b'h' => oper_halt(ctx),
b'I' | b'i' => oper_increment(ctx),
b'J' | b'j' => oper_jump(ctx),
b'K' | b'k' => oper_konkat(ctx),
b'L' | b'l' => oper_lesser(ctx),
b'M' | b'm' => oper_multiply(ctx),
b'N' | b'n' => oper_movement(ctx),
b'O' | b'o' => oper_offset(ctx),
b'P' | b'p' => oper_push(ctx),
b'Q' | b'q' => oper_query(ctx),
b'R' | b'r' => oper_random(ctx),
b'S' | b's' => oper_movement(ctx),
b'T' | b't' => oper_track(ctx),
b'U' | b'u' => oper_uclid(ctx),
b'V' | b'v' => oper_variable(ctx),
b'W' | b'w' => oper_movement(ctx),
b'X' | b'x' => oper_teleport(ctx),
b'Y' | b'y' => oper_yump(ctx),
b'Z' | b'z' => oper_lerp(ctx),
_ => {}
}
}
// ── Public entry point ────────────────────────────────────────────────────────
/// Run one tick of the Orca VM.
///
/// `gbuf` and `mbuf` must both have length `height * width`.
/// `mbuf` should be zeroed before each call (mark flags are per-tick).
/// Events generated this tick are appended to `events`.
pub fn orca_run(
gbuf: &mut [u8],
mbuf: &mut [u8],
height: usize,
width: usize,
tick: usize,
events: &mut Vec<Oevent>,
random_seed: usize,
) {
debug_assert_eq!(gbuf.len(), height * width);
debug_assert_eq!(mbuf.len(), height * width);
let mut vars = [b'.'; 36];
for iy in 0..height {
for ix in 0..width {
let glyph_char = gbuf[iy * width + ix];
if glyph_char == b'.' {
continue;
}
let cell_flags = mbuf[iy * width + ix] & (MARK_LOCK | MARK_SLEEP);
if cell_flags != 0 {
continue;
}
let mut ctx = Ctx {
gbuf: &mut *gbuf,
mbuf: &mut *mbuf,
height,
width,
y: iy,
x: ix,
tick,
vars: &mut vars,
events,
random_seed,
this_char: glyph_char,
};
dispatch(&mut ctx);
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn run_grid(src: &str, ticks: usize) -> (Vec<u8>, Vec<Oevent>) {
let lines: Vec<&str> = src.lines().collect();
let height = lines.len();
let width = lines.iter().map(|l| l.len()).max().unwrap_or(0);
let mut gbuf: Vec<u8> = vec![b'.'; height * width];
for (iy, line) in lines.iter().enumerate() {
for (ix, b) in line.bytes().enumerate() {
gbuf[iy * width + ix] = b;
}
}
let mut mbuf = vec![0u8; height * width];
let mut events = Vec::new();
for tick in 0..ticks {
mbuf.fill(0);
orca_run(&mut gbuf, &mut mbuf, height, width, tick, &mut events, 1);
}
(gbuf, events)
}
#[test]
fn bang_clears_itself() {
let (grid, _) = run_grid("*", 1);
assert_eq!(grid[0], b'.');
}
#[test]
fn movement_north_hits_wall() {
// N at top row should turn into *
let (grid, _) = run_grid("N..", 1);
assert_eq!(grid[0], b'*');
}
#[test]
fn movement_south_moves_down() {
let (grid, _) = run_grid("S\n.\n.", 1);
// 3×1 grid; S moves to row 1 (index 1)
assert_eq!(grid[0], b'.');
assert_eq!(grid[1], b'S');
}
#[test]
fn movement_east_moves_right() {
let (grid, _) = run_grid("E.", 1);
assert_eq!(grid[0], b'.');
assert_eq!(grid[1], b'E');
}
#[test]
fn clock_basic() {
// .2C. — rate=2, mod=default(8), tick=0 → output=glyph_of(0/2%8)=glyph_of(0)='0'
// C is at x=2, output at (1,2) so the grid needs 2 rows:
let (grid, _) = run_grid(".2C.\n....", 1);
// output cell is row1, col2 = index 1*4+2 = 6
assert_eq!(grid[6], b'0');
}
#[test]
fn add_operator() {
// .A3. where left param='.', right='3' → (0+3)%36 = '3', case of '3' = '3'
// 3A3. — left='3'(=3), right='3'(=3) → (3+3)%36=6='6'
let (grid, _) = run_grid("3A3.\n....", 1);
// output at row1,col1 = index 4+1 = 5
assert_eq!(grid[5], b'6');
}
#[test]
fn delay_fires_at_zero() {
// .D. with rate=1, mod=8 → fires at tick%8==0
// .1D8 — but we need output below
let (grid, _) = run_grid(".1D8\n....", 1);
// tick=0: 0 % (1*8) == 0 → output '*'
assert_eq!(grid[4 + 2], b'*');
}
#[test]
fn midi_note_event() {
// ':' at (0,0) with '*' directly below at (1,0). The '*' is processed
// after row 0, so ':' still sees it as a neighbor during its own tick.
// Params: channel='0'→0, octave='5'→5, note='C'→C natural, vel='.', len='.'
let (_, events) = run_grid(":05C..\n*.....", 1);
assert!(events.iter().any(|e| matches!(e, Oevent::MidiNote { channel: 0, octave: 5, note: 0, .. })));
}
#[test]
fn if_equal_outputs_bang() {
// aFa. — a==a → '*'
let (grid, _) = run_grid("aFa.\n....", 1);
assert_eq!(grid[4 + 1], b'*'); // output at row1,col1
}
#[test]
fn if_unequal_outputs_dot() {
// aFb. — a!=b → '.'
let (grid, _) = run_grid("aFb.\n....", 1);
assert_eq!(grid[4 + 1], b'.');
}
#[test]
fn increment_wraps() {
// .I. with value at row1='9'(=9), no max → max=36
// rate=1, so 9+1=10 → 'a'
let (grid, _) = run_grid(".I.\n.9.", 1);
// output/input at row1,col1 = index 4
assert_eq!(grid[4], b'a');
}
}
+31
View File
@@ -0,0 +1,31 @@
pub const OSC_INT_COUNT: usize = 35;
pub const UDP_STR_COUNT: usize = 16;
#[derive(Debug, Clone, PartialEq)]
pub enum Oevent {
MidiNote {
channel: u8,
octave: u8,
note: u8,
velocity: u8,
duration: u8,
mono: bool,
},
MidiCc {
channel: u8,
control: u8,
value: u8,
},
MidiPb {
channel: u8,
lsb: u8,
msb: u8,
},
OscInts {
glyph: u8,
numbers: Vec<u8>,
},
UdpString {
chars: Vec<u8>,
},
}
+494
View File
@@ -0,0 +1,494 @@
//! Interactive terminal UI — the crossterm counterpart of orca-c's
//! ncurses tui_main.c: grid editor, playback clock, selection, undo,
//! and event delivery to the configured outputs.
use std::fs::File;
use std::io::{self, Write};
use std::time::{Duration, Instant};
use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::style::{Attribute, Print, SetAttribute};
use crossterm::terminal::{
self, disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, Clear, ClearType,
EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::{execute, queue};
use crate::io::EventOutputs;
use crate::sim::{
copy_subrect, fill_subrect, orca_run, Field, MarkBuffer, Oevent, MARK_HASTE, MARK_INPUT,
MARK_LOCK, MARK_OUTPUT, MARK_SLEEP,
};
const MAX_UNDO: usize = 256;
const MAX_DIM: usize = 512;
const HELP_LINE: &str =
"space play ^F step ^S save ^Z undo ^X/^C/^V clip shift+arrows select ^arrows resize < > ( ) bpm ^Q quit";
pub struct TuiConfig {
pub path: Option<String>,
pub bpm: usize,
pub seed: usize,
pub outputs: EventOutputs,
}
struct Clip {
data: Vec<u8>,
h: usize,
w: usize,
}
struct Snapshot {
data: Vec<u8>,
h: usize,
w: usize,
tick: usize,
}
struct App {
field: Field,
mbuf: MarkBuffer,
scratch: Vec<u8>,
tick: usize,
bpm: usize,
playing: bool,
cur_y: usize,
cur_x: usize,
sel_h: usize,
sel_w: usize,
clipboard: Option<Clip>,
undo: Vec<Snapshot>,
outputs: EventOutputs,
events: Vec<Oevent>,
seed: usize,
path: String,
msg: String,
needs_preview: bool,
}
fn is_valid_glyph(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '!' | '#' | '%' | '*' | '.' | ':' | ';' | '=' | '?')
}
fn tick_period(bpm: usize) -> Duration {
// One Orca frame is a 16th note: 60000 ms / bpm / 4.
Duration::from_millis((60_000 / bpm.max(1) / 4).max(1) as u64)
}
impl App {
fn dims(&self) -> (usize, usize) {
(self.field.height as usize, self.field.width as usize)
}
fn clamp_cursor(&mut self) {
let (h, w) = self.dims();
self.cur_y = self.cur_y.min(h - 1);
self.cur_x = self.cur_x.min(w - 1);
self.sel_h = self.sel_h.clamp(1, h - self.cur_y);
self.sel_w = self.sel_w.clamp(1, w - self.cur_x);
}
fn do_tick(&mut self) {
let (h, w) = self.dims();
self.mbuf.ensure_size(h, w);
self.mbuf.clear(h, w);
self.outputs.begin_tick();
orca_run(
&mut self.field.buffer,
&mut self.mbuf.buffer[..h * w],
h,
w,
self.tick,
&mut self.events,
self.seed,
);
self.outputs.dispatch(&mut self.events);
self.tick += 1;
self.needs_preview = false;
}
// Run the sim on a scratch copy so port highlighting stays live while
// paused, without mutating the grid or emitting events.
fn preview(&mut self) {
let (h, w) = self.dims();
self.mbuf.ensure_size(h, w);
self.mbuf.clear(h, w);
self.scratch.clear();
self.scratch.extend_from_slice(&self.field.buffer);
let mut discarded: Vec<Oevent> = Vec::new();
orca_run(
&mut self.scratch,
&mut self.mbuf.buffer[..h * w],
h,
w,
self.tick,
&mut discarded,
self.seed,
);
self.needs_preview = false;
}
fn push_undo(&mut self) {
if self.undo.len() >= MAX_UNDO {
self.undo.remove(0);
}
let (h, w) = self.dims();
self.undo.push(Snapshot {
data: self.field.buffer.clone(),
h,
w,
tick: self.tick,
});
}
fn undo(&mut self) {
if let Some(s) = self.undo.pop() {
self.field.buffer = s.data;
self.field.height = s.h as u16;
self.field.width = s.w as u16;
self.tick = s.tick;
self.clamp_cursor();
self.needs_preview = true;
self.msg = "undid".into();
} else {
self.msg = "nothing to undo".into();
}
}
fn fill_selection(&mut self, g: u8) {
self.push_undo();
let (h, w) = self.dims();
fill_subrect(
&mut self.field.buffer,
h,
w,
self.cur_y,
self.cur_x,
self.sel_h,
self.sel_w,
g,
);
self.needs_preview = true;
}
fn copy_selection(&mut self) {
let (h, w) = self.dims();
let mut data = vec![b'.'; self.sel_h * self.sel_w];
copy_subrect(
&self.field.buffer,
&mut data,
h,
w,
self.sel_h,
self.sel_w,
self.cur_y,
self.cur_x,
0,
0,
self.sel_h,
self.sel_w,
);
self.clipboard = Some(Clip {
data,
h: self.sel_h,
w: self.sel_w,
});
self.msg = "copied".into();
}
fn paste(&mut self) {
let Some(clip) = self.clipboard.take() else {
self.msg = "clipboard empty".into();
return;
};
self.push_undo();
let (h, w) = self.dims();
copy_subrect(
&clip.data,
&mut self.field.buffer,
clip.h,
clip.w,
h,
w,
0,
0,
self.cur_y,
self.cur_x,
clip.h,
clip.w,
);
self.msg = "pasted".into();
self.clipboard = Some(clip);
self.needs_preview = true;
}
fn resize_grid(&mut self, dy: isize, dx: isize) {
let (h, w) = self.dims();
let nh = (h as isize + dy).clamp(1, MAX_DIM as isize) as usize;
let nw = (w as isize + dx).clamp(1, MAX_DIM as isize) as usize;
if (nh, nw) == (h, w) {
return;
}
self.push_undo();
self.field.resize(nh, nw);
self.mbuf.ensure_size(nh, nw);
self.clamp_cursor();
self.needs_preview = true;
self.msg = format!("grid {nw}x{nh}");
}
fn save(&mut self) {
let result = File::create(&self.path).and_then(|mut f| self.field.write_to(&mut f));
self.msg = match result {
Ok(()) => format!("saved {}", self.path),
Err(e) => format!("save failed: {e}"),
};
}
}
// Restore the terminal even if we exit via error or panic.
struct TermGuard;
impl Drop for TermGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, Show);
}
}
pub fn run(cfg: TuiConfig) -> Result<(), String> {
let (path, field) = match cfg.path {
Some(p) => {
if std::path::Path::new(&p).exists() {
let f = Field::load_file(&p)
.map_err(|e| format!("cannot load {p}: {}", e.as_str()))?;
(p, f)
} else {
(p, default_field()?)
}
}
None => ("untitled.orca".to_string(), default_field()?),
};
let mut app = App {
mbuf: MarkBuffer::new(),
scratch: Vec::new(),
tick: 0,
bpm: cfg.bpm,
playing: false,
cur_y: 0,
cur_x: 0,
sel_h: 1,
sel_w: 1,
clipboard: None,
undo: Vec::new(),
outputs: cfg.outputs,
events: Vec::new(),
seed: cfg.seed,
path,
msg: String::new(),
needs_preview: true,
field,
};
enable_raw_mode().map_err(|e| e.to_string())?;
let _guard = TermGuard;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, Hide).map_err(|e| e.to_string())?;
main_loop(&mut app, &mut stdout).map_err(|e| e.to_string())?;
app.outputs.silence();
Ok(())
}
fn default_field() -> Result<Field, String> {
let (cols, rows) = terminal::size().unwrap_or((80, 25));
let w = (cols as usize).clamp(20, 200);
let h = (rows.saturating_sub(3) as usize).clamp(10, 200);
Ok(Field::with_fill(h, w, b'.'))
}
fn main_loop(app: &mut App, stdout: &mut io::Stdout) -> io::Result<()> {
let mut next_tick = Instant::now();
loop {
if app.needs_preview && !app.playing {
app.preview();
}
draw(stdout, app)?;
let timeout = if app.playing {
let now = Instant::now();
if now >= next_tick {
app.do_tick();
next_tick += tick_period(app.bpm);
if next_tick < now {
next_tick = now + tick_period(app.bpm);
}
continue;
}
next_tick - now
} else {
Duration::from_millis(200)
};
if event::poll(timeout)? {
match event::read()? {
Event::Key(key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !handle_key(app, key, &mut next_tick) =>
{
return Ok(());
}
_ => {}
}
}
}
}
// Returns false when the app should quit.
fn handle_key(app: &mut App, key: KeyEvent, next_tick: &mut Instant) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let (h, w) = app.dims();
match key.code {
KeyCode::Char('q') if ctrl => return false,
KeyCode::Char('s') if ctrl => app.save(),
KeyCode::Char('z') | KeyCode::Char('u') if ctrl => app.undo(),
KeyCode::Char('c') if ctrl => app.copy_selection(),
KeyCode::Char('x') if ctrl => {
app.copy_selection();
app.fill_selection(b'.');
app.msg = "cut".into();
}
KeyCode::Char('v') if ctrl => app.paste(),
KeyCode::Char('f') if ctrl
&& !app.playing => {
app.do_tick();
app.needs_preview = true;
}
KeyCode::Char('r') if ctrl => {
app.tick = 0;
app.needs_preview = true;
app.msg = "frame reset".into();
}
KeyCode::Char(' ') => {
app.playing = !app.playing;
if app.playing {
*next_tick = Instant::now();
} else {
app.outputs.silence();
app.needs_preview = true;
}
}
KeyCode::Char('<') => app.bpm = (app.bpm.saturating_sub(1)).max(10),
KeyCode::Char('>') => app.bpm = (app.bpm + 1).min(999),
KeyCode::Char('(') => app.bpm = (app.bpm.saturating_sub(10)).max(10),
KeyCode::Char(')') => app.bpm = (app.bpm + 10).min(999),
KeyCode::Esc => {
app.sel_h = 1;
app.sel_w = 1;
app.msg.clear();
}
KeyCode::Backspace | KeyCode::Delete => app.fill_selection(b'.'),
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => {
let (dy, dx): (isize, isize) = match key.code {
KeyCode::Up => (-1, 0),
KeyCode::Down => (1, 0),
KeyCode::Left => (0, -1),
_ => (0, 1),
};
if ctrl {
app.resize_grid(dy, dx);
} else if shift {
app.sel_h = (app.sel_h as isize + dy).clamp(1, (h - app.cur_y) as isize) as usize;
app.sel_w = (app.sel_w as isize + dx).clamp(1, (w - app.cur_x) as isize) as usize;
} else {
let step: isize = if alt { 8 } else { 1 };
app.cur_y = (app.cur_y as isize + dy * step).clamp(0, h as isize - 1) as usize;
app.cur_x = (app.cur_x as isize + dx * step).clamp(0, w as isize - 1) as usize;
app.clamp_cursor();
}
}
KeyCode::Char(c) if !ctrl && !alt && is_valid_glyph(c) => {
app.fill_selection(c as u8);
}
_ => {}
}
true
}
fn draw(out: &mut io::Stdout, app: &App) -> io::Result<()> {
let (tw, th) = terminal::size()?;
let (gh, gw) = app.dims();
let grid_rows = th.saturating_sub(2) as usize;
let vis_h = gh.min(grid_rows);
let vis_w = gw.min(tw as usize);
queue!(out, BeginSynchronizedUpdate, Hide, Clear(ClearType::All))?;
for y in 0..vis_h {
queue!(out, MoveTo(0, y as u16))?;
for x in 0..vis_w {
let g = app.field.buffer[y * gw + x];
let m = app.mbuf.buffer.get(y * gw + x).copied().unwrap_or(0);
let is_cursor = y == app.cur_y && x == app.cur_x;
let in_sel = y >= app.cur_y
&& y < app.cur_y + app.sel_h
&& x >= app.cur_x
&& x < app.cur_x + app.sel_w;
let ch = if g == b'.' {
if is_cursor {
'@'
} else if y % 8 == 0 && x % 8 == 0 {
'+'
} else {
'.'
}
} else {
g as char
};
queue!(out, SetAttribute(Attribute::Reset))?;
if is_cursor {
queue!(out, SetAttribute(Attribute::Reverse), SetAttribute(Attribute::Bold))?;
} else if in_sel {
queue!(out, SetAttribute(Attribute::Reverse))?;
} else if m & MARK_OUTPUT != 0 {
queue!(out, SetAttribute(Attribute::Reverse))?;
} else if m & (MARK_INPUT | MARK_HASTE) != 0 {
queue!(out, SetAttribute(Attribute::Bold))?;
} else if g == b'.' || m & (MARK_LOCK | MARK_SLEEP) != 0 {
queue!(out, SetAttribute(Attribute::Dim))?;
}
queue!(out, Print(ch))?;
}
}
let state = if app.playing { "playing" } else { "paused" };
let status = format!(
"{} {}f {}bpm {} {},{} {}x{} {}",
app.path, app.tick, app.bpm, state, app.cur_x, app.cur_y, gw, gh, app.msg
);
let mut line1: String = status.chars().take(tw as usize).collect();
line1 = format!("{:<width$}", line1, width = tw as usize);
let help: String = HELP_LINE.chars().take(tw as usize).collect();
queue!(
out,
SetAttribute(Attribute::Reset),
MoveTo(0, th.saturating_sub(2)),
SetAttribute(Attribute::Reverse),
Print(line1),
SetAttribute(Attribute::Reset),
MoveTo(0, th.saturating_sub(1)),
SetAttribute(Attribute::Dim),
Print(help),
SetAttribute(Attribute::Reset),
EndSynchronizedUpdate
)?;
out.flush()
}