Initial release: Orca VM, TUI, MIDI/OSC/UDP output, and release pipeline
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod io;
|
||||
pub mod sim;
|
||||
pub mod tui;
|
||||
+102
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user