diff --git a/README.md b/README.md index 12568f0..994264c 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,27 @@ loading in future. src/settings.rs That's it. Now you can start to make your own custom genetic code. -The `giles` genotype (in genotypes/giles.rs) is a fully worked example of this +The `giles` genotype (in genotypes/giles/) is a fully worked example of this that you can look at. +## Inspecting evolved creatures + +Because a `giles` genome is just a block of byte-code, there is tooling to read +what evolution actually produced: + +- A disassembler and assembler live in `eyes2_lib::giles::asm` + (`disassemble` / `assemble`), converting between raw genome bytes and a + readable listing. `GilesGenotype::disassemble()` dumps a creature's code and + `GilesGenotype::from_genome()` builds a creature from hand-written assembly. +- In the TUI, press `i` to open the inspector. It shows the selected creature's + registers (IP, accumulator, I/O registers, energy, breed/mutation rate) and a + disassembly of its genome with the current instruction highlighted. Use `n`/`p` + to cycle through creatures and `.` to single-step the world one tick at a time. + +A genotype can opt into the inspector by implementing the `Genotype::inspect()` +method (returning its registers and a listing); genotypes that don't just show +no detail. + # Still to do - DONE Save and Restore of worlds and individual creatures diff --git a/TODO.md b/TODO.md index b8e8ff5..2b4cbf5 100644 --- a/TODO.md +++ b/TODO.md @@ -27,9 +27,13 @@ to create the framework for evolving creatures: - NIRVANA: implement multi host architecture and deploy with kubernetes - I think I won't do this. Distributing across processes for the trivial work a creature does will not scale -- STRETCH provide a debug architecture - - needs to implement a GUI for representing the state of the creature - - e.g. assembler / disassembler and debugger +- DONE provide a debug architecture + - DONE a GUI for representing the state of the creature (the TUI inspector, + press `i` - shows the selected creature's registers and disassembled code, + `n`/`p` to cycle creatures, `.` to single-step) + - DONE assembler / disassembler (eyes2_lib::giles::asm) + - the disassembler doubles as the "debugger" code view; full breakpoint style + debugging is not implemented Stage 2 ------- diff --git a/eyes2-lib/src/entity/creature.rs b/eyes2-lib/src/entity/creature.rs index a179c7d..74ef9bb 100644 --- a/eyes2-lib/src/entity/creature.rs +++ b/eyes2-lib/src/entity/creature.rs @@ -24,7 +24,7 @@ use crate::utils::move_pos; -use super::genotype::genotype::GenotypeActions; +use super::genotype::genotype::{GenotypeActions, GenotypeInspect}; use super::vision::{look_world, Vision}; use super::Genotype; use super::Update; @@ -35,6 +35,21 @@ use fastrand::Rng as FastRng; use serde::Deserialize; use serde::Serialize; +/// A snapshot of a creature (and its genotype) for the TUI inspector. +#[derive(Debug, Clone)] +pub struct CreatureInspect { + /// the creature's unique id + pub id: u64, + /// the sigil used to render it in the world + pub sigil: char, + /// its (x, y) position in the world + pub coord: (i32, i32), + /// its current energy + pub energy: i32, + /// inspectable internal state of its genotype, if any + pub genotype: Option, +} + #[derive(Serialize, Deserialize, Clone)] pub struct Creature { // the unique id of the creature used to identify it in the world @@ -96,6 +111,9 @@ impl Creature { } pub fn set_config(&mut self, config: Settings) { + // keep the genotype's copy of the settings in step (it is skipped during + // serialization, so this restores it after a world is loaded) + self.genotype.set_config(config.clone()); self.config = config; } @@ -147,6 +165,17 @@ impl Creature { self.sigil } + /// Produce a snapshot of this creature for the TUI inspector. + pub fn inspect(&self) -> CreatureInspect { + CreatureInspect { + id: self.id, + sigil: self.sigil, + coord: (self.coord.x, self.coord.y), + energy: self.energy, + genotype: self.genotype.inspect(), + } + } + pub fn vision(&mut self, vision: Vision) { self.genotype.vision(vision); } diff --git a/eyes2-lib/src/entity/genotype/genotype.rs b/eyes2-lib/src/entity/genotype/genotype.rs index 12afd34..a8db0bb 100644 --- a/eyes2-lib/src/entity/genotype/genotype.rs +++ b/eyes2-lib/src/entity/genotype/genotype.rs @@ -8,6 +8,32 @@ pub enum BadGenomeError { InvalidGenome, } +/// One line of a genotype's program listing, for the inspector. +#[derive(Debug, Clone)] +pub struct InspectLine { + /// the address of the line within the genome + pub addr: usize, + /// the rendered instruction text + pub text: String, +} + +/// A genotype-agnostic snapshot of a creature's "brain" for the TUI inspector. +/// +/// Genotypes that have inspectable internal state (such as the `giles` byte-code +/// VM) return one of these from [`Genotype::inspect`]; the GUI renders it +/// without needing to know anything about the specific genotype. +#[derive(Debug, Clone)] +pub struct GenotypeInspect { + /// a short name for the kind of genotype, e.g. "giles" + pub kind: &'static str, + /// labelled register / state values, e.g. ("R", "0x2a") + pub state: Vec<(String, String)>, + /// the program listing (may be empty for genotypes without code) + pub listing: Vec, + /// the index into `listing` of the instruction about to execute, if any + pub active: Option, +} + // Every creature has a Genotype which defines their behaviour. It is // expected that the Genotype will be defined by a genome, and that the // genome (with mutations as appropriate) will be passed to the @@ -27,6 +53,12 @@ pub trait Genotype: DynClone + Send { // the canonical energy level in in Creature itself) fn set_energy(&mut self, energy: i32); + // update the global settings held by the genotype. This is called by the + // world after deserialization so that a loaded genotype uses the loaded + // world's settings rather than the defaults filled in by `#[serde(skip)]`. + // Genotypes that do not read the config can ignore this. + fn set_config(&mut self, _config: Settings) {} + // return the sigil used to represent this creature in the world fn get_sigil(&self) -> char { 'D' @@ -36,6 +68,12 @@ pub trait Genotype: DynClone + Send { // the last Look(Direction) action. The value is a 1D array of 4 // Cells. With the nearest cell the first in the array. fn vision(&mut self, _vision: Vision) {} + + // Return a snapshot of internal state for the TUI inspector, or None for + // genotypes that have nothing interesting to show. + fn inspect(&self) -> Option { + None + } } clone_trait_object!(Genotype); diff --git a/eyes2-lib/src/entity/genotype/genotypes/giles/asm.rs b/eyes2-lib/src/entity/genotype/genotypes/giles/asm.rs new file mode 100644 index 0000000..7c6d091 --- /dev/null +++ b/eyes2-lib/src/entity/genotype/genotypes/giles/asm.rs @@ -0,0 +1,369 @@ +//! Disassembler and assembler for the `giles` virtual machine genome. +//! +//! [`disassemble`] turns a raw genome (a block of bytes) into a human readable +//! listing, decoding each instruction the way the VM does (every opcode and +//! operand selector is reduced modulo its range, so any random byte block is a +//! valid - if nonsensical - program). [`assemble`] is the inverse: it parses a +//! listing back into genome bytes. Together they are the counterpart of the +//! original project's `DisAssemble` / `Assemble`. +//! +//! The two are exact inverses on canonical programs: for any genome `g`, +//! `disassemble(assemble(disassemble(g))) == disassemble(g)`. + +use super::isa::*; + +/// One decoded instruction in a disassembly listing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisasmLine { + /// the address (genome offset) of the instruction's opcode byte + pub addr: usize, + /// the instruction mnemonic, e.g. `MOVC` + pub mnemonic: &'static str, + /// the rendered operand, if the instruction takes one (e.g. `V1`, `0x2a`) + pub operand: Option, +} + +impl std::fmt::Display for DisasmLine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.operand { + Some(operand) => write!(f, "{:04x} {:<6}{}", self.addr, self.mnemonic, operand), + None => write!(f, "{:04x} {}", self.addr, self.mnemonic), + } + } +} + +/// read a 16 bit little-endian constant starting at `index` +fn constant_at(code: &[u8], index: usize) -> u16 { + let lo = code[index] as u16; + let hi = code[index + 1] as u16; + lo.wrapping_add(hi.wrapping_mul(256)) +} + +/// Disassemble a genome into a list of decoded instructions. +/// +/// Instructions are decoded sequentially from the start. Any trailing bytes +/// that are too few to form a complete instruction (with its operand) are left +/// off the listing, so the listing always describes whole instructions. +pub fn disassemble(code: &[u8]) -> Vec { + let mut listing = Vec::new(); + let mut ip = 0usize; + + while ip < code.len() { + let addr = ip; + let opcode = code[ip] % NUMBER_OF_INSTRUCTIONS; + // stop if this instruction's operand would run off the end + if ip + 1 + operand_size(opcode) > code.len() { + break; + } + ip += 1; + let mnemonic = MNEMONICS[opcode as usize]; + + let operand = match operand_kind(opcode) { + Operand::None => None, + Operand::Variable => { + let var = code[ip] % NUMBER_OF_VARS; + ip += 1; + Some(VAR_NAMES[var as usize].to_string()) + } + Operand::IoVariable => { + let var = code[ip] as usize % NUMBER_OF_IO_VARS; + ip += 1; + Some(VAR_NAMES[VAR_I1 as usize + var].to_string()) + } + Operand::Constant => { + let value = constant_at(code, ip); + ip += 2; + // MOVC only uses the constant modulo 8 (the eight directions) + let value = if opcode == MOVC { value % 8 } else { value }; + Some(format!("{:#x}", value)) + } + Operand::Jump => { + let target = (addr + constant_at(code, ip) as usize) % CODE_SIZE; + ip += 2; + Some(format!("{:04x}", target)) + } + }; + + listing.push(DisasmLine { + addr, + mnemonic, + operand, + }); + } + + listing +} + +/// Disassemble a genome into a newline separated string listing. +pub fn disassemble_to_string(code: &[u8]) -> String { + disassemble(code) + .iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n") +} + +/// An error encountered while assembling a listing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssembleError { + /// an unrecognised instruction mnemonic + UnknownMnemonic { line: usize, token: String }, + /// an unrecognised variable / register name + UnknownVariable { line: usize, token: String }, + /// a numeric operand that could not be parsed as a (hex) number + BadNumber { line: usize, token: String }, + /// an instruction that requires an operand but none was given + MissingOperand { line: usize, mnemonic: String }, + /// an explicit address that could not be parsed as a (hex) number + BadAddress { line: usize, token: String }, + /// the assembled program does not fit within a genome + ProgramTooLong { line: usize }, +} + +/// parse a hex number, tolerating a leading `0x` +fn parse_hex(token: &str) -> Option { + usize::from_str_radix(token.trim_start_matches("0x").trim_start_matches("0X"), 16).ok() +} + +/// Assemble a listing into genome bytes. +/// +/// Each non-empty line is `[ADDR] MNEMONIC [OPERAND]`, where the optional +/// leading `ADDR` (hex, as emitted by [`disassemble`]) places the instruction +/// at that genome offset; otherwise instructions are laid down sequentially. +/// Text after `;` or `#` is treated as a comment. Gaps between addressed +/// instructions are filled with `NOP`. The returned vector is exactly long +/// enough to hold the assembled program (it is not padded to [`CODE_SIZE`]); +/// use [`super::GilesGenotype::from_genome`] to turn it into a full genome. +pub fn assemble(source: &str) -> Result, AssembleError> { + let mut code = vec![NOP; 0]; + let mut ip = 0usize; + + for (i, raw_line) in source.lines().enumerate() { + let line = i + 1; + // strip comments and surrounding whitespace + let text = raw_line.split([';', '#']).next().unwrap_or("").trim(); + if text.is_empty() { + continue; + } + + let parts: Vec<&str> = text.split_whitespace().collect(); + + // A line is `[ADDR] MNEMONIC [OPERAND]`. The first token is an explicit + // address only when it is followed by a recognised mnemonic; otherwise + // the first token is taken to be the mnemonic (so a typo is reported as + // an unknown mnemonic rather than a bad address). + let rest: &[&str] = if opcode_from_mnemonic(parts[0]).is_some() { + &parts + } else if parts.len() >= 2 && opcode_from_mnemonic(parts[1]).is_some() { + ip = parse_hex(parts[0].trim_end_matches(':')).ok_or_else(|| { + AssembleError::BadAddress { + line, + token: parts[0].to_string(), + } + })?; + &parts[1..] + } else { + &parts + }; + + let mnemonic_token = rest[0]; + let operand_token = rest.get(1).copied(); + + let opcode = opcode_from_mnemonic(mnemonic_token).ok_or_else(|| { + AssembleError::UnknownMnemonic { + line, + token: mnemonic_token.to_string(), + } + })?; + + // grow the buffer (with NOP fill for any gap) so we can write at ip + let end = ip + 1 + operand_size(opcode); + if end > CODE_SIZE { + return Err(AssembleError::ProgramTooLong { line }); + } + if end > code.len() { + code.resize(end, NOP); + } + + let addr = ip; + code[ip] = opcode; + ip += 1; + + match operand_kind(opcode) { + Operand::None => {} + Operand::Variable => { + let token = operand_token.ok_or_else(|| AssembleError::MissingOperand { + line, + mnemonic: mnemonic_token.to_string(), + })?; + let var = variable_from_name(token).ok_or_else(|| { + AssembleError::UnknownVariable { + line, + token: token.to_string(), + } + })?; + code[ip] = var; + ip += 1; + } + Operand::IoVariable => { + let token = operand_token.ok_or_else(|| AssembleError::MissingOperand { + line, + mnemonic: mnemonic_token.to_string(), + })?; + let var = io_variable_from_name(token).ok_or_else(|| { + AssembleError::UnknownVariable { + line, + token: token.to_string(), + } + })?; + code[ip] = var; + ip += 1; + } + Operand::Constant => { + let token = operand_token.ok_or_else(|| AssembleError::MissingOperand { + line, + mnemonic: mnemonic_token.to_string(), + })?; + let value = parse_hex(token).ok_or_else(|| AssembleError::BadNumber { + line, + token: token.to_string(), + })? as u16; + code[ip] = (value & 0xff) as u8; + code[ip + 1] = (value >> 8) as u8; + ip += 2; + } + Operand::Jump => { + let token = operand_token.ok_or_else(|| AssembleError::MissingOperand { + line, + mnemonic: mnemonic_token.to_string(), + })?; + let target = parse_hex(token).ok_or_else(|| AssembleError::BadNumber { + line, + token: token.to_string(), + })?; + // store the offset relative to this instruction's own address, + // the inverse of how disassemble() computes the target + let offset = ((target + CODE_SIZE - (addr % CODE_SIZE)) % CODE_SIZE) as u16; + code[ip] = (offset & 0xff) as u8; + code[ip + 1] = (offset >> 8) as u8; + ip += 2; + } + } + } + + Ok(code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_each_operand_kind() { + // LOADC 0x1234 ; LOADV V1 ; SAVEV I1 ; MOVC East(2) ; NOP ; JZ ... + let code = vec![ + LOADC, 0x34, 0x12, // LOADC 0x1234 + LOADV, VAR_V1, // LOADV V1 + SAVEV, 0, // SAVEV I1 + MOVC, 2, 0, // MOVC 0x2 (East) + NOP, // NOP + JZ, 5, 0, // JZ -> (addr 11 + 5) = 0x10 + ]; + let listing = disassemble(&code); + + assert_eq!(listing[0].to_string(), "0000 LOADC 0x1234"); + assert_eq!(listing[1].to_string(), "0003 LOADV V1"); + assert_eq!(listing[2].to_string(), "0005 SAVEV I1"); + assert_eq!(listing[3].to_string(), "0007 MOVC 0x2"); + assert_eq!(listing[4].to_string(), "000a NOP"); + assert_eq!(listing[5].mnemonic, "JZ"); + assert_eq!(listing[5].operand.as_deref(), Some("0010")); + } + + #[test] + fn opcode_and_selectors_are_reduced_modulo() { + // a byte of 8 + opcode aliases to the same opcode; 8 == NOP here + let code = vec![NUMBER_OF_INSTRUCTIONS + NOP, 0, 0]; + assert_eq!(disassemble(&code)[0].mnemonic, "NOP"); + } + + #[test] + fn trailing_partial_instruction_is_dropped() { + // LOADC needs two operand bytes but only one is present + let code = vec![NOP, LOADC, 0x01]; + let listing = disassemble(&code); + assert_eq!(listing.len(), 1); + assert_eq!(listing[0].mnemonic, "NOP"); + } + + #[test] + fn assembles_and_disassembles_a_hand_written_program() { + let source = " + ; head east while there is grass ahead + LOADV V3 # look east + JNZ 0x0 # (re)start if something is there + MOVC 0x2 ; move east + "; + let code = assemble(source).expect("assembles"); + let listing = disassemble(&code); + assert_eq!(listing[0].to_string(), "0000 LOADV V3"); + assert_eq!(listing[1].mnemonic, "JNZ"); + assert_eq!(listing[1].operand.as_deref(), Some("0000")); + assert_eq!(listing[2].to_string(), "0005 MOVC 0x2"); + } + + #[test] + fn round_trips_random_genomes() { + // disassemble -> assemble -> disassemble is stable for any genome + for _ in 0..200 { + let code: Vec = (0..CODE_SIZE).map(|_| fastrand::u8(..)).collect(); + let listing1 = disassemble(&code); + let text = disassemble_to_string(&code); + let reassembled = assemble(&text).expect("reassembles"); + let listing2 = disassemble(&reassembled); + assert_eq!(listing1, listing2); + } + } + + #[test] + fn jump_targets_survive_round_trip() { + // a backward jump and a forward jump + let source = "0010 JZ 0x4\n0004 JNZ 0x20\n"; + let code = assemble(source).unwrap(); + let listing = disassemble(&code); + let jz = listing.iter().find(|l| l.addr == 0x10).unwrap(); + let jnz = listing.iter().find(|l| l.addr == 0x04).unwrap(); + assert_eq!(jz.operand.as_deref(), Some("0004")); + assert_eq!(jnz.operand.as_deref(), Some("0020")); + } + + #[test] + fn reports_errors() { + assert!(matches!( + assemble("BOGUS V1"), + Err(AssembleError::UnknownMnemonic { line: 1, .. }) + )); + assert!(matches!( + assemble("LOADV ZZ"), + Err(AssembleError::UnknownVariable { line: 1, .. }) + )); + assert!(matches!( + assemble("LOADC xyz"), + Err(AssembleError::BadNumber { line: 1, .. }) + )); + assert!(matches!( + assemble("MOVC"), + Err(AssembleError::MissingOperand { line: 1, .. }) + )); + } + + #[test] + fn never_panics_on_random_genomes() { + for _ in 0..100 { + let code: Vec = (0..CODE_SIZE).map(|_| fastrand::u8(..)).collect(); + let listing = disassemble(&code); + assert!(!listing.is_empty()); + let _ = disassemble_to_string(&code); + } + } +} diff --git a/eyes2-lib/src/entity/genotype/genotypes/giles.rs b/eyes2-lib/src/entity/genotype/genotypes/giles/genotype.rs similarity index 82% rename from eyes2-lib/src/entity/genotype/genotypes/giles.rs rename to eyes2-lib/src/entity/genotype/genotypes/giles/genotype.rs index 0bd2c8c..0464a18 100644 --- a/eyes2-lib/src/entity/genotype/genotypes/giles.rs +++ b/eyes2-lib/src/entity/genotype/genotypes/giles/genotype.rs @@ -25,46 +25,14 @@ //! the world and refresh it after every move. The eight vision variables //! (`V1`..`V8`) read from that cache. -use super::{Genotype, GenotypeActions}; +use super::super::{Genotype, GenotypeActions, GenotypeInspect, InspectLine}; +use super::asm; +use super::isa::*; use crate::utils::int_to_dir; use crate::{entity::Vision, Cell, Settings}; use direction::Direction; use serde::{Deserialize, Serialize}; -/// number of bytes in a genome (matches the original `CODE_SIZE`) -const CODE_SIZE: usize = 1000; - -/// number of distinct instructions in the VM -const NUMBER_OF_INSTRUCTIONS: u8 = 12; -/// total number of readable variables (vision + state + registers) -const NUMBER_OF_VARS: u8 = 18; -/// number of writable I/O registers (`I1`..`I5`) -const NUMBER_OF_IO_VARS: usize = 5; - -// the instruction set, values must match the byte interpreted by the VM -const LOADC: u8 = 0; // load a constant into the accumulator -const LOADV: u8 = 1; // load a variable into the accumulator -const ANDV: u8 = 2; // bitwise AND the accumulator with a variable -const ORV: u8 = 3; // bitwise OR the accumulator with a variable -const JZ: u8 = 4; // jump if the accumulator is zero -const JNZ: u8 = 5; // jump if the accumulator is non-zero -const MOVV: u8 = 6; // move in the direction held in a variable -const MOVC: u8 = 7; // move in a constant direction -const NOP: u8 = 8; // do nothing -const SAVEV: u8 = 9; // save the accumulator into an I/O register -const ADDV: u8 = 10; // add a variable to the accumulator -const SUBV: u8 = 11; // subtract a variable from the accumulator - -// the readable variable indices (V1..V8 are the eight vision directions) -const VAR_V1: u8 = 0; // first vision direction -const VAR_V8: u8 = 7; // last vision direction -const VAR_E: u8 = 8; // energy -const VAR_X: u8 = 9; // x position (internal dead-reckoning) -const VAR_Y: u8 = 10; // y position (internal dead-reckoning) -const VAR_B: u8 = 11; // breed threshold -const VAR_M: u8 = 12; // mutation rate -const VAR_I1: u8 = 13; // first I/O register - // bounds the evolving breed threshold and mutation rate are clamped to so that // the population can never freeze (mutation_rate of 0) or breed for free const MIN_MUTATION_RATE: u32 = 1; @@ -138,9 +106,49 @@ impl Genotype for GilesGenotype { self.energy = energy; } + fn set_config(&mut self, config: Settings) { + self.config = config; + } + fn get_sigil(&self) -> char { 'G' } + + fn inspect(&self) -> Option { + let lines = asm::disassemble(&self.code); + // the instruction about to execute is the last one at or before ip + let active = lines.iter().rposition(|line| line.addr <= self.ip); + + let listing = lines + .iter() + .map(|line| InspectLine { + addr: line.addr, + text: match &line.operand { + Some(operand) => format!("{:<6}{}", line.mnemonic, operand), + None => line.mnemonic.to_string(), + }, + }) + .collect(); + + let mut state = vec![ + ("IP".to_string(), format!("{:#06x}", self.ip)), + ("R".to_string(), format!("{:#06x}", self.r)), + ]; + for (i, value) in self.vars.iter().enumerate() { + state.push((format!("I{}", i + 1), format!("{:#06x}", value))); + } + state.push(("energy".to_string(), self.energy.to_string())); + state.push(("breed".to_string(), self.breed_after.to_string())); + state.push(("mutate%".to_string(), self.mutation_rate.to_string())); + state.push(("pos".to_string(), format!("{},{}", self.x, self.y))); + + Some(GenotypeInspect { + kind: "giles", + state, + listing, + active, + }) + } } impl GilesGenotype { @@ -165,6 +173,26 @@ impl GilesGenotype { } } + /// The raw genome bytes that drive this creature. + pub fn genome(&self) -> &[u8] { + &self.code + } + + /// Disassemble this creature's genome into a human readable listing. + pub fn disassemble(&self) -> String { + asm::disassemble_to_string(&self.code) + } + + /// Build a genotype from explicit genome bytes, for example bytes produced + /// by [`asm::assemble`]. The genome is padded with `NOP` (or truncated) to + /// the required [`CODE_SIZE`]. + pub fn from_genome(config: Settings, mut code: Vec) -> Self { + code.resize(CODE_SIZE, NOP); + let mut genotype = Self::new(config); + genotype.code = code; + genotype + } + /// Produce a child genotype, mutating its genome with probability /// `mutation_rate`%. The world is the authority on creature energy and /// splits it between parent and child in [`Creature::reproduce`]; we halve @@ -428,6 +456,31 @@ mod tests { assert!(g.mutation_rate <= MAX_MUTATION_RATE); } + #[test] + fn set_config_restores_settings() { + // config is #[serde(skip)] so it must be restorable after a load + let mut g = test_genotype(); + let mut settings = Settings::default(); + settings.size = 123; + settings.creature_reproduction_energy = 42; + g.set_config(settings); + assert_eq!(g.config.size, 123); + assert_eq!(g.config.creature_reproduction_energy, 42); + } + + #[test] + fn assembled_program_executes() { + // a hand-written program that simply heads east should move East + let code = asm::assemble("MOVC 0x2").expect("assembles"); + let mut g = GilesGenotype::from_genome(Settings::default(), code); + g.pending_look = false; + g.breed_after = i32::MAX; + match g.tick() { + GenotypeActions::Move(dir) => assert_eq!(dir, Direction::East), + _ => panic!("expected a Move action"), + } + } + #[test] fn random_genomes_never_panic() { // run many random genomes for many ticks to flush out any indexing or diff --git a/eyes2-lib/src/entity/genotype/genotypes/giles/isa.rs b/eyes2-lib/src/entity/genotype/genotypes/giles/isa.rs new file mode 100644 index 0000000..41c9a7d --- /dev/null +++ b/eyes2-lib/src/entity/genotype/genotypes/giles/isa.rs @@ -0,0 +1,148 @@ +//! The instruction set architecture (ISA) of the `giles` virtual machine. +//! +//! These definitions are the single source of truth shared by the VM +//! ([`super::genotype`]), the disassembler and the assembler ([`super::asm`]). +//! The numeric values must not change without regenerating any saved genomes, +//! as they are the meaning of the raw genome bytes. + +/// number of bytes in a genome (matches the original `CODE_SIZE`) +pub const CODE_SIZE: usize = 1000; + +/// number of distinct instructions in the VM +pub const NUMBER_OF_INSTRUCTIONS: u8 = 12; +/// total number of readable variables (vision + state + registers) +pub const NUMBER_OF_VARS: u8 = 18; +/// number of writable I/O registers (`I1`..`I5`) +pub const NUMBER_OF_IO_VARS: usize = 5; + +// the instruction set, values must match the byte interpreted by the VM +pub const LOADC: u8 = 0; // load a constant into the accumulator +pub const LOADV: u8 = 1; // load a variable into the accumulator +pub const ANDV: u8 = 2; // bitwise AND the accumulator with a variable +pub const ORV: u8 = 3; // bitwise OR the accumulator with a variable +pub const JZ: u8 = 4; // jump if the accumulator is zero +pub const JNZ: u8 = 5; // jump if the accumulator is non-zero +pub const MOVV: u8 = 6; // move in the direction held in a variable +pub const MOVC: u8 = 7; // move in a constant direction +pub const NOP: u8 = 8; // do nothing +pub const SAVEV: u8 = 9; // save the accumulator into an I/O register +pub const ADDV: u8 = 10; // add a variable to the accumulator +pub const SUBV: u8 = 11; // subtract a variable from the accumulator + +// the readable variable indices (V1..V8 are the eight vision directions) +pub const VAR_V1: u8 = 0; // first vision direction +pub const VAR_V8: u8 = 7; // last vision direction +pub const VAR_E: u8 = 8; // energy +pub const VAR_X: u8 = 9; // x position (internal dead-reckoning) +pub const VAR_Y: u8 = 10; // y position (internal dead-reckoning) +pub const VAR_B: u8 = 11; // breed threshold +pub const VAR_M: u8 = 12; // mutation rate +pub const VAR_I1: u8 = 13; // first I/O register + +/// mnemonic for each instruction, indexed by opcode +pub const MNEMONICS: [&str; NUMBER_OF_INSTRUCTIONS as usize] = [ + "LOADC", "LOADV", "ANDV", "ORV", "JZ", "JNZ", "MOVV", "MOVC", "NOP", "SAVEV", "ADDV", "SUBV", +]; + +/// name for each readable variable, indexed by variable number +pub const VAR_NAMES: [&str; NUMBER_OF_VARS as usize] = [ + "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "E", "X", "Y", "B", "M", "I1", "I2", "I3", "I4", + "I5", +]; + +/// The kind of operand (if any) that follows an instruction in the genome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Operand { + /// no operand byte(s) + None, + /// a 16 bit little-endian constant (two bytes) + Constant, + /// a one byte index selecting one of the readable variables + Variable, + /// a one byte index selecting one of the writable I/O registers + IoVariable, + /// a 16 bit little-endian value added to the instruction's own address to + /// give a (wrapped) absolute jump target + Jump, +} + +/// The number of operand bytes that follow a given opcode in the genome. +pub fn operand_size(opcode: u8) -> usize { + match operand_kind(opcode) { + Operand::None => 0, + Operand::Variable | Operand::IoVariable => 1, + Operand::Constant | Operand::Jump => 2, + } +} + +/// Return the operand kind for a given opcode. The opcode must already be +/// reduced modulo [`NUMBER_OF_INSTRUCTIONS`]. +pub fn operand_kind(opcode: u8) -> Operand { + match opcode { + LOADC | MOVC => Operand::Constant, + JZ | JNZ => Operand::Jump, + LOADV | ANDV | ORV | MOVV | ADDV | SUBV => Operand::Variable, + SAVEV => Operand::IoVariable, + NOP => Operand::None, + // opcode is always reduced % NUMBER_OF_INSTRUCTIONS so this is unreachable + _ => Operand::None, + } +} + +/// Look up an opcode by (case-insensitive) mnemonic. +pub fn opcode_from_mnemonic(name: &str) -> Option { + MNEMONICS + .iter() + .position(|m| m.eq_ignore_ascii_case(name)) + .map(|i| i as u8) +} + +/// Look up a readable variable index by (case-insensitive) name (`V1`..`I5`). +pub fn variable_from_name(name: &str) -> Option { + VAR_NAMES + .iter() + .position(|v| v.eq_ignore_ascii_case(name)) + .map(|i| i as u8) +} + +/// Look up an I/O register index (0..[`NUMBER_OF_IO_VARS`]) by name (`I1`..`I5`). +pub fn io_variable_from_name(name: &str) -> Option { + variable_from_name(name).and_then(|v| { + if v >= VAR_I1 { + Some(v - VAR_I1) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tables_match_counts() { + assert_eq!(MNEMONICS.len(), NUMBER_OF_INSTRUCTIONS as usize); + assert_eq!(VAR_NAMES.len(), NUMBER_OF_VARS as usize); + } + + #[test] + fn mnemonic_round_trips() { + for (i, m) in MNEMONICS.iter().enumerate() { + assert_eq!(opcode_from_mnemonic(m), Some(i as u8)); + assert_eq!(opcode_from_mnemonic(&m.to_lowercase()), Some(i as u8)); + } + assert_eq!(opcode_from_mnemonic("NOPE"), None); + } + + #[test] + fn variable_lookups() { + assert_eq!(variable_from_name("V1"), Some(VAR_V1)); + assert_eq!(variable_from_name("e"), Some(VAR_E)); + assert_eq!(variable_from_name("I5"), Some(VAR_I1 + 4)); + assert_eq!(io_variable_from_name("I1"), Some(0)); + assert_eq!(io_variable_from_name("I5"), Some(4)); + // E is readable but not a writable I/O register + assert_eq!(io_variable_from_name("E"), None); + } +} diff --git a/eyes2-lib/src/entity/genotype/genotypes/giles/mod.rs b/eyes2-lib/src/entity/genotype/genotypes/giles/mod.rs new file mode 100644 index 0000000..4cd9cba --- /dev/null +++ b/eyes2-lib/src/entity/genotype/genotypes/giles/mod.rs @@ -0,0 +1,14 @@ +//! The `giles` creature controller. +//! +//! A faithful port of the evolving RISC byte-code genome from the original 1999 +//! `eyes` project, split across: +//! +//! - [`isa`] - the instruction set definitions shared by all parts +//! - [`genotype`] - the virtual machine / [`Genotype`](super::super::Genotype) implementation +//! - [`asm`] - the disassembler and assembler for the genome + +pub mod asm; +pub mod genotype; +pub mod isa; + +pub use genotype::GilesGenotype; diff --git a/eyes2-lib/src/entity/genotype/genotypes/looker.rs b/eyes2-lib/src/entity/genotype/genotypes/looker.rs index e2e1dde..1c60b0c 100644 --- a/eyes2-lib/src/entity/genotype/genotypes/looker.rs +++ b/eyes2-lib/src/entity/genotype/genotypes/looker.rs @@ -83,6 +83,10 @@ impl Genotype for LookerGenotype { self.energy = energy; } + fn set_config(&mut self, config: Settings) { + self.config = config; + } + fn get_sigil(&self) -> char { 'L' } diff --git a/eyes2-lib/src/entity/genotype/genotypes/mod.rs b/eyes2-lib/src/entity/genotype/genotypes/mod.rs index 3a9c8ae..5602a01 100644 --- a/eyes2-lib/src/entity/genotype/genotypes/mod.rs +++ b/eyes2-lib/src/entity/genotype/genotypes/mod.rs @@ -5,3 +5,4 @@ pub mod random; use super::genotype::Genotype; use super::genotype::GenotypeActions; +use super::genotype::{GenotypeInspect, InspectLine}; diff --git a/eyes2-lib/src/entity/genotype/genotypes/noop.rs b/eyes2-lib/src/entity/genotype/genotypes/noop.rs index 50f6ca3..119bb1a 100644 --- a/eyes2-lib/src/entity/genotype/genotypes/noop.rs +++ b/eyes2-lib/src/entity/genotype/genotypes/noop.rs @@ -24,6 +24,10 @@ impl Genotype for NoopGenotype { self.energy = energy; } + fn set_config(&mut self, config: Settings) { + self.config = config; + } + fn get_sigil(&self) -> char { 'N' } diff --git a/eyes2-lib/src/entity/genotype/genotypes/random.rs b/eyes2-lib/src/entity/genotype/genotypes/random.rs index 993bcb0..7cde117 100644 --- a/eyes2-lib/src/entity/genotype/genotypes/random.rs +++ b/eyes2-lib/src/entity/genotype/genotypes/random.rs @@ -37,6 +37,10 @@ impl Genotype for RandomGenotype { self.energy = energy; } + fn set_config(&mut self, config: Settings) { + self.config = config; + } + fn get_sigil(&self) -> char { 'R' } diff --git a/eyes2-lib/src/entity/mod.rs b/eyes2-lib/src/entity/mod.rs index 8c87a58..82a45b9 100644 --- a/eyes2-lib/src/entity/mod.rs +++ b/eyes2-lib/src/entity/mod.rs @@ -1,9 +1,9 @@ pub mod creature; -mod genotype; +pub mod genotype; pub mod update; pub mod vision; -pub use self::creature::Creature; -pub use self::genotype::genotype::{new_genotype, Genotype}; +pub use self::creature::{Creature, CreatureInspect}; +pub use self::genotype::genotype::{new_genotype, Genotype, GenotypeInspect, InspectLine}; pub use self::update::{Update, UpdateQueue}; pub use self::vision::{get_vision_in_direction, look_world, Vision}; diff --git a/eyes2-lib/src/lib.rs b/eyes2-lib/src/lib.rs index a10332b..b96ae35 100644 --- a/eyes2-lib/src/lib.rs +++ b/eyes2-lib/src/lib.rs @@ -9,5 +9,13 @@ pub mod world; pub mod utils; // these are the public API structures +pub use crate::entity::{CreatureInspect, GenotypeInspect, InspectLine}; pub use crate::settings::Settings; pub use crate::world::{save_world, Cell, World, WorldGrid}; + +/// Tooling for the `giles` genome: its instruction set ([`giles::isa`]) and a +/// disassembler / assembler ([`giles::asm`]) for converting between the raw +/// genome bytes and a human readable listing. +pub mod giles { + pub use crate::entity::genotype::genotypes::giles::{asm, isa, GilesGenotype}; +} diff --git a/eyes2-lib/src/world/grid.rs b/eyes2-lib/src/world/grid.rs index 438a4cf..c5646fb 100644 --- a/eyes2-lib/src/world/grid.rs +++ b/eyes2-lib/src/world/grid.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; +use crate::entity::creature::CreatureInspect; use direction; use serde::{Deserialize, Serialize}; @@ -28,6 +29,10 @@ pub struct WorldGrid { pub start_time: DateTime, // next unique id to assign to an Entity pub next_id: u64, + // a snapshot of the currently inspected creature, for the TUI inspector + // (not persisted - rebuilt from the live world each tick) + #[serde(skip)] + pub inspect: Option, } // represent the contents of a single cell in the world @@ -59,6 +64,7 @@ impl WorldGrid { restarts, start_time: Utc::now(), next_id: 0, + inspect: None, } } diff --git a/eyes2-lib/src/world/world.rs b/eyes2-lib/src/world/world.rs index e16352e..df772c4 100644 --- a/eyes2-lib/src/world/world.rs +++ b/eyes2-lib/src/world/world.rs @@ -39,6 +39,8 @@ pub struct World { // creatures. Below it the rayon fork/join cost outweighs the work and a // plain serial loop is much faster - see DESIGN_MULTITHREAD.md. parallel_threshold: usize, + // the id of the creature currently being inspected in the TUI, if any + selected: Option, } // Default creature count at/above which the per-tick "think" phase is run in @@ -62,6 +64,7 @@ impl World { next_grass_tick: 0, rng: FastRng::new(), parallel_threshold: DEFAULT_PARALLEL_THRESHOLD, + selected: None, } } @@ -78,6 +81,7 @@ impl World { next_grass_tick, rng: FastRng::new(), parallel_threshold: DEFAULT_PARALLEL_THRESHOLD, + selected: None, } } } @@ -99,6 +103,42 @@ impl World { self.parallel_threshold = threshold; } + /// Toggle the TUI inspector: select the first creature if none is selected, + /// otherwise clear the selection. + pub fn toggle_inspect(&mut self) { + self.selected = match self.selected { + Some(_) => None, + None => self.sorted_ids().first().copied(), + }; + self.refresh_inspection(); + } + + /// Select the next creature (by id) for inspection, wrapping around. + pub fn select_next(&mut self) { + self.step_selection(1); + } + + /// Select the previous creature (by id) for inspection, wrapping around. + pub fn select_prev(&mut self) { + self.step_selection(-1); + } + + /// Rebuild the inspection snapshot for the selected creature (if any) and + /// store it on the grid so it is sent to the GUI. If the selected creature + /// has died, advance to the next surviving one. + pub fn refresh_inspection(&mut self) { + if let Some(id) = self.selected { + if !self.id_index.contains_key(&id) { + // the inspected creature died; fall back to the first survivor + self.selected = self.sorted_ids().first().copied(); + } + } + self.grid.inspect = self + .selected + .and_then(|id| self.id_index.get(&id)) + .map(|&index| self.creatures[index].inspect()); + } + pub fn populate(&mut self) { for _ in 0..self.config.grass_count as usize { let x = self.rng.i32(0..self.config.size as i32 - 1); @@ -311,6 +351,31 @@ impl World { self.grid.next_id } + /// the live creature ids in ascending order, for stable inspector cycling + fn sorted_ids(&self) -> Vec { + let mut ids: Vec = self.id_index.keys().copied().collect(); + ids.sort_unstable(); + ids + } + + /// move the inspector selection by `delta` positions through the sorted ids + fn step_selection(&mut self, delta: isize) { + let ids = self.sorted_ids(); + if ids.is_empty() { + self.selected = None; + self.refresh_inspection(); + return; + } + let current = self + .selected + .and_then(|id| ids.iter().position(|&i| i == id)) + .unwrap_or(0) as isize; + let len = ids.len() as isize; + let next = (current + delta).rem_euclid(len) as usize; + self.selected = Some(ids[next]); + self.refresh_inspection(); + } + fn validate_creature(&self, id: u64, coord: Coord) { let cell = self.grid.get_cell(coord); match cell { diff --git a/eyes2/src/gui.rs b/eyes2/src/gui.rs index d1f2d16..821222f 100644 --- a/eyes2/src/gui.rs +++ b/eyes2/src/gui.rs @@ -2,7 +2,7 @@ //! and handles user input. //! use chrono::Utc; -use eyes2_lib::{Cell, WorldGrid}; +use eyes2_lib::{Cell, CreatureInspect, WorldGrid}; use num_format::{Locale, ToFormattedString}; use std::error::Error; @@ -40,6 +40,11 @@ pub enum GuiCmd { SpeedMax, GrassUp, GrassDown, + // inspector controls + Inspect, + SelectNext, + SelectPrev, + Step, } pub struct EyesGui { @@ -47,10 +52,13 @@ pub struct EyesGui { left_pane: pancurses::Window, right_pane: pancurses::Window, help_pane: pancurses::Window, + inspect_pane: pancurses::Window, y_max: i32, x_max: i32, last_tick: u64, last_tick_time: Instant, + // whether the inspector overlay is currently shown + inspecting: bool, } const DATE_FMT: &'static str = "%y-%m-%d %H:%M:%S"; @@ -70,6 +78,7 @@ impl EyesGui { let left_pane = pancurses::newwin(1, 1, 0, 0); let right_pane = pancurses::newwin(1, 1, 0, 3); let help_pane = pancurses::newwin(20, 44, 3, 10); + let inspect_pane = pancurses::newwin(30, 48, 1, 2); start_color(); init_pair(RED as i16, COLOR_RED, COLOR_BLACK); @@ -93,10 +102,12 @@ impl EyesGui { left_pane, right_pane, help_pane, + inspect_pane, y_max: 0, x_max: 0, last_tick: 0, last_tick_time: time::Instant::now(), + inspecting: false, } } @@ -160,7 +171,24 @@ impl EyesGui { self.status(inc!(y), "speed:", &grid.speed.to_string()); self.status(inc!(y), "grass rate:", &grid.grass_rate.to_string()); - self.footer(" q: quit, h: help "); + self.footer(" q: quit, h: help, i: inspect "); + + // draw (or clear) the inspector overlay on top of everything else + match &grid.inspect { + Some(inspect) => { + self.render_inspect(inspect); + self.inspecting = true; + } + None => { + if self.inspecting { + self.inspect_pane.erase(); + self.inspect_pane.refresh(); + self.inspecting = false; + // force a full redraw to repaint the world under the overlay + self.y_max = 0; + } + } + } } pub fn get_cmd(&mut self) -> GuiCmd { @@ -174,6 +202,10 @@ impl EyesGui { Some(pancurses::Input::KeyDown) => GuiCmd::SpeedDown, Some(pancurses::Input::KeyRight) => GuiCmd::GrassUp, Some(pancurses::Input::KeyLeft) => GuiCmd::GrassDown, + Some(pancurses::Input::Character('i')) => GuiCmd::Inspect, + Some(pancurses::Input::Character('n')) => GuiCmd::SelectNext, + Some(pancurses::Input::Character('p')) => GuiCmd::SelectPrev, + Some(pancurses::Input::Character('.')) => GuiCmd::Step, Some(pancurses::Input::Character('h')) => { self.show_help(); GuiCmd::None @@ -268,6 +300,73 @@ impl EyesGui { self.right_pane.refresh(); } + fn render_inspect(&mut self, inspect: &CreatureInspect) { + let win = &self.inspect_pane; + win.erase(); + win.draw_box(0, 0); + + let (height, _width) = win.get_max_yx(); + let mut row = 1; + + win.mvaddstr( + row, + 2, + format!("INSPECT {} #{}", inspect.sigil, inspect.id), + ); + row += 1; + win.mvaddstr( + row, + 2, + format!( + "pos {},{} energy {}", + inspect.coord.0, inspect.coord.1, inspect.energy + ), + ); + row += 2; + + match &inspect.genotype { + None => { + win.mvaddstr(row, 2, "(no inspectable genotype)"); + } + Some(genotype) => { + // register / state values + for (label, value) in &genotype.state { + if row >= height - 2 { + break; + } + win.mvaddstr(row, 2, format!("{:<9}{}", label, value)); + row += 1; + } + row += 1; + if row < height - 2 { + win.mvaddstr(row, 2, "---- code ----"); + row += 1; + } + + // a window of the disassembly centred on the active instruction + let visible = (height - 1 - row).max(0) as usize; + if visible > 0 && !genotype.listing.is_empty() { + let active = genotype.active.unwrap_or(0); + let start = active.saturating_sub(visible / 2); + for (i, line) in genotype + .listing + .iter() + .enumerate() + .skip(start) + .take(visible) + { + let marker = if Some(i) == genotype.active { '>' } else { ' ' }; + win.mvaddstr(row, 2, format!("{} {:04x} {}", marker, line.addr, line.text)); + row += 1; + } + } + } + } + + win.mvaddstr(height - 1, 2, " n/p:sel .:step i:close "); + win.refresh(); + } + fn show_help(&mut self) { let help = " -------------- COMMANDS --------------- @@ -277,6 +376,9 @@ impl EyesGui { space: pause the world up/down: speed up/down left/right: grass up/down + i: inspect creatures + n/p: next/prev creature + .: single-step (when inspecting) h: show this help --------------------------------------- diff --git a/eyes2/src/main.rs b/eyes2/src/main.rs index dfbe330..f690df3 100644 --- a/eyes2/src/main.rs +++ b/eyes2/src/main.rs @@ -125,8 +125,18 @@ fn do_tick( GuiCmd::Load => { *world = load_world(); } + GuiCmd::Inspect => world.toggle_inspect(), + GuiCmd::SelectNext => world.select_next(), + GuiCmd::SelectPrev => world.select_prev(), + GuiCmd::Step => { + // single-step: pause, then advance exactly one tick + *paused = true; + world.tick(); + } _ => {} }; + // refresh the inspector snapshot so the grid we send is current + world.refresh_inspection(); tx_grid.send(world.grid.clone()).unwrap(); }