diff --git a/crates/console/prometeu-bytecode/src/assembler.rs b/crates/console/prometeu-bytecode/src/assembler.rs new file mode 100644 index 00000000..6740c496 --- /dev/null +++ b/crates/console/prometeu-bytecode/src/assembler.rs @@ -0,0 +1,190 @@ +//! Minimal deterministic assembler for the canonical disassembly format. +//! +//! This is intended primarily for roundtrip tests: `bytes -> disassemble -> assemble -> bytes`. +//! It supports all mnemonics emitted by `disassembler.rs` and their operand formats. + +use crate::isa::core::CoreOpCode; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AsmError { + EmptyLine, + UnknownMnemonic(String), + UnexpectedOperand(String), + MissingOperand(String), + InvalidOperand(String), +} + +fn emit_u16(v: u16, out: &mut Vec) { out.extend_from_slice(&v.to_le_bytes()); } +fn emit_u32(v: u32, out: &mut Vec) { out.extend_from_slice(&v.to_le_bytes()); } +fn emit_i32(v: i32, out: &mut Vec) { out.extend_from_slice(&v.to_le_bytes()); } +fn emit_i64(v: i64, out: &mut Vec) { out.extend_from_slice(&v.to_le_bytes()); } +fn emit_f64_bits(bits: u64, out: &mut Vec) { out.extend_from_slice(&bits.to_le_bytes()); } + +fn parse_u32_any(s: &str) -> Result { + let s = s.trim(); + if let Some(rest) = s.strip_prefix("0x") { u32::from_str_radix(rest, 16).map_err(|_| AsmError::InvalidOperand(s.into())) } else { s.parse::().map_err(|_| AsmError::InvalidOperand(s.into())) } +} + +fn parse_i32_any(s: &str) -> Result { + s.trim().parse::().map_err(|_| AsmError::InvalidOperand(s.into())) +} + +fn parse_i64_any(s: &str) -> Result { + s.trim().parse::().map_err(|_| AsmError::InvalidOperand(s.into())) +} + +fn parse_f64_bits(s: &str) -> Result { + let s = s.trim(); + let s = s.strip_prefix("f64:").ok_or_else(|| AsmError::InvalidOperand(s.into()))?; + let hex = s.strip_prefix("0x").ok_or_else(|| AsmError::InvalidOperand(s.into()))?; + if hex.len() != 16 { return Err(AsmError::InvalidOperand(s.into())); } + u64::from_str_radix(hex, 16).map_err(|_| AsmError::InvalidOperand(s.into())) +} + +fn parse_keyvals(s: &str) -> Result<(&str, &str), AsmError> { + // Parses formats like: "fn=123, captures=2" or "fn=3, argc=1" + let mut parts = s.split(','); + let a = parts.next().ok_or_else(|| AsmError::MissingOperand(s.into()))?.trim(); + let b = parts.next().ok_or_else(|| AsmError::MissingOperand(s.into()))?.trim(); + if parts.next().is_some() { return Err(AsmError::InvalidOperand(s.into())); } + Ok((a, b)) +} + +fn parse_pair<'a>(a: &'a str, ka: &str, b: &'a str, kb: &str) -> Result<(u32,u32), AsmError> { + let (ka_l, va_s) = a.split_once('=').ok_or_else(|| AsmError::InvalidOperand(a.into()))?; + let (kb_l, vb_s) = b.split_once('=').ok_or_else(|| AsmError::InvalidOperand(b.into()))?; + if ka_l.trim() != ka || kb_l.trim() != kb { return Err(AsmError::InvalidOperand(format!("expected keys {} and {}", ka, kb))); } + let va = parse_u32_any(va_s)?; + let vb = parse_u32_any(vb_s)?; + Ok((va, vb)) +} + +fn parse_mnemonic(line: &str) -> (&str, &str) { + let line = line.trim(); + if let Some(sp) = line.find(char::is_whitespace) { + let (mn, rest) = line.split_at(sp); + (mn, rest.trim()) + } else { + (line, "") + } +} + +pub fn assemble(src: &str) -> Result, AsmError> { + let mut out = Vec::new(); + for raw_line in src.lines() { + let line = raw_line.trim(); + if line.is_empty() { continue; } + let (mn, ops) = parse_mnemonic(line); + match mn { + // Zero-operand + "NOP" => { emit_u16(CoreOpCode::Nop as u16, &mut out); } + "HALT" => { emit_u16(CoreOpCode::Halt as u16, &mut out); } + "TRAP" => { emit_u16(CoreOpCode::Trap as u16, &mut out); } + "DUP" => { emit_u16(CoreOpCode::Dup as u16, &mut out); } + "SWAP" => { emit_u16(CoreOpCode::Swap as u16, &mut out); } + "ADD" => { emit_u16(CoreOpCode::Add as u16, &mut out); } + "SUB" => { emit_u16(CoreOpCode::Sub as u16, &mut out); } + "MUL" => { emit_u16(CoreOpCode::Mul as u16, &mut out); } + "DIV" => { emit_u16(CoreOpCode::Div as u16, &mut out); } + "MOD" => { emit_u16(CoreOpCode::Mod as u16, &mut out); } + "NEG" => { emit_u16(CoreOpCode::Neg as u16, &mut out); } + "EQ" => { emit_u16(CoreOpCode::Eq as u16, &mut out); } + "NEQ" => { emit_u16(CoreOpCode::Neq as u16, &mut out); } + "LT" => { emit_u16(CoreOpCode::Lt as u16, &mut out); } + "LTE" => { emit_u16(CoreOpCode::Lte as u16, &mut out); } + "GT" => { emit_u16(CoreOpCode::Gt as u16, &mut out); } + "GTE" => { emit_u16(CoreOpCode::Gte as u16, &mut out); } + "AND" => { emit_u16(CoreOpCode::And as u16, &mut out); } + "OR" => { emit_u16(CoreOpCode::Or as u16, &mut out); } + "NOT" => { emit_u16(CoreOpCode::Not as u16, &mut out); } + "BIT_AND" => { emit_u16(CoreOpCode::BitAnd as u16, &mut out); } + "BIT_OR" => { emit_u16(CoreOpCode::BitOr as u16, &mut out); } + "BIT_XOR" => { emit_u16(CoreOpCode::BitXor as u16, &mut out); } + "SHL" => { emit_u16(CoreOpCode::Shl as u16, &mut out); } + "SHR" => { emit_u16(CoreOpCode::Shr as u16, &mut out); } + "RET" => { emit_u16(CoreOpCode::Ret as u16, &mut out); } + "YIELD" => { emit_u16(CoreOpCode::Yield as u16, &mut out); } + "FRAME_SYNC" => { emit_u16(CoreOpCode::FrameSync as u16, &mut out); } + + // One u32 immediate (decimal or hex accepted for SYSCALL only; others decimal ok) + "JMP" => { + if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::Jmp as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "JMP_IF_FALSE" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::JmpIfFalse as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "JMP_IF_TRUE" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::JmpIfTrue as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "PUSH_CONST" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::PushConst as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "PUSH_I64" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::PushI64 as u16, &mut out); emit_i64(parse_i64_any(ops)?, &mut out); + } + "PUSH_F64" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::PushF64 as u16, &mut out); emit_f64_bits(parse_f64_bits(ops)?, &mut out); + } + "PUSH_BOOL" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + let v = parse_u32_any(ops)? as u8; emit_u16(CoreOpCode::PushBool as u16, &mut out); out.push(v); + } + "PUSH_I32" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::PushI32 as u16, &mut out); emit_i32(parse_i32_any(ops)?, &mut out); + } + "POP_N" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::PopN as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "PUSH_BOUNDED" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::PushBounded as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "GET_GLOBAL" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::GetGlobal as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "SET_GLOBAL" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::SetGlobal as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "GET_LOCAL" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::GetLocal as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "SET_LOCAL" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::SetLocal as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "CALL" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::Call as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "CALL_CLOSURE" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + let (k, v) = ops.split_once('=').ok_or_else(|| AsmError::InvalidOperand(ops.into()))?; if k.trim() != "argc" { return Err(AsmError::InvalidOperand(ops.into())); } + emit_u16(CoreOpCode::CallClosure as u16, &mut out); emit_u32(parse_u32_any(v)?, &mut out); + } + "MAKE_CLOSURE" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + let (a,b) = parse_keyvals(ops)?; + // Accept either order but require exact key names + let (fn_id, captures) = if a.starts_with("fn=") && b.starts_with("captures=") { + parse_pair(a, "fn", b, "captures")? + } else if a.starts_with("captures=") && b.starts_with("fn=") { + let (cap, fid) = parse_pair(a, "captures", b, "fn")?; (fid, cap) + } else { return Err(AsmError::InvalidOperand(ops.into())); }; + emit_u16(CoreOpCode::MakeClosure as u16, &mut out); emit_u32(fn_id, &mut out); emit_u32(captures, &mut out); + } + "SPAWN" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + let (a,b) = parse_keyvals(ops)?; + let (fn_id, argc) = if a.starts_with("fn=") && b.starts_with("argc=") { + parse_pair(a, "fn", b, "argc")? + } else if a.starts_with("argc=") && b.starts_with("fn=") { + let (ac, fid) = parse_pair(a, "argc", b, "fn")?; (fid, ac) + } else { return Err(AsmError::InvalidOperand(ops.into())); }; + emit_u16(CoreOpCode::Spawn as u16, &mut out); emit_u32(fn_id, &mut out); emit_u32(argc, &mut out); + } + "SLEEP" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::Sleep as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + "SYSCALL" => { if ops.is_empty() { return Err(AsmError::MissingOperand(line.into())); } + emit_u16(CoreOpCode::Syscall as u16, &mut out); emit_u32(parse_u32_any(ops)?, &mut out); + } + + other => return Err(AsmError::UnknownMnemonic(other.into())), + } + } + Ok(out) +} diff --git a/crates/console/prometeu-bytecode/src/disassembler.rs b/crates/console/prometeu-bytecode/src/disassembler.rs new file mode 100644 index 00000000..ef0e2297 --- /dev/null +++ b/crates/console/prometeu-bytecode/src/disassembler.rs @@ -0,0 +1,115 @@ +//! Deterministic disassembler for Prometeu Bytecode (PBC). +//! +//! Goals: +//! - Stable formatting across platforms (snapshot-friendly). +//! - Complete coverage of the Core ISA, including closures/coroutines. +//! - Roundtrip-safe with the paired `assembler` module. +//! +//! Format (one instruction per line): +//! - `MNEMONIC` for zero-operand instructions. +//! - `MNEMONIC ` for 1-operand instructions (decimal unless stated). +//! - Special operand formats: +//! - `PUSH_F64 f64:0xhhhhhhhhhhhhhhhh` — exact IEEE-754 bits in hex (little-endian to_bits()). +//! - `MAKE_CLOSURE fn=, captures=` +//! - `SPAWN fn=, argc=` +//! - `CALL_CLOSURE argc=` +//! - `SYSCALL` is printed as `SYSCALL 0xhhhh` (numeric id in hex) to avoid cross-crate deps. +//! +//! Notes: +//! - All integers are printed in base-10 except where explicitly noted. +//! - Floats use exact bit-pattern format to prevent locale/rounding differences. +//! - Ordering is the canonical decode order; no address prefixes are emitted. + +use crate::decode_next; +use crate::isa::core::{CoreOpCode, CoreOpCodeSpecExt}; +use crate::DecodeError; + +fn fmt_f64_bits(bits: u64) -> String { + // Fixed-width 16 hex digits, lowercase. + format!("f64:0x{bits:016x}") +} + +fn format_operand(op: CoreOpCode, imm: &[u8]) -> String { + match op { + CoreOpCode::Jmp | CoreOpCode::JmpIfFalse | CoreOpCode::JmpIfTrue => { + let v = u32::from_le_bytes(imm.try_into().unwrap()); + format!("{}", v) + } + CoreOpCode::PushI64 => { + let v = i64::from_le_bytes(imm.try_into().unwrap()); + format!("{}", v) + } + CoreOpCode::PushF64 => { + let v = u64::from_le_bytes(imm.try_into().unwrap()); + fmt_f64_bits(v) + } + CoreOpCode::PushBool => { + let v = imm[0]; + format!("{}", v) + } + CoreOpCode::PushI32 => { + let v = i32::from_le_bytes(imm.try_into().unwrap()); + format!("{}", v) + } + CoreOpCode::PopN + | CoreOpCode::PushConst + | CoreOpCode::PushBounded + | CoreOpCode::GetGlobal + | CoreOpCode::SetGlobal + | CoreOpCode::GetLocal + | CoreOpCode::SetLocal + | CoreOpCode::Call + | CoreOpCode::Sleep => { + let v = u32::from_le_bytes(imm.try_into().unwrap()); + format!("{}", v) + } + CoreOpCode::MakeClosure => { + let fn_id = u32::from_le_bytes(imm[0..4].try_into().unwrap()); + let cap = u32::from_le_bytes(imm[4..8].try_into().unwrap()); + format!("fn={}, captures={}", fn_id, cap) + } + CoreOpCode::CallClosure => { + let argc = u32::from_le_bytes(imm.try_into().unwrap()); + format!("argc={}", argc) + } + CoreOpCode::Spawn => { + let fn_id = u32::from_le_bytes(imm[0..4].try_into().unwrap()); + let argc = u32::from_le_bytes(imm[4..8].try_into().unwrap()); + format!("fn={}, argc={}", fn_id, argc) + } + CoreOpCode::Syscall => { + let id = u32::from_le_bytes(imm.try_into().unwrap()); + // Hex id stable, avoids dependency on HAL metadata. + format!("0x{:04x}", id) + } + _ => { + // Fallback: raw immediate hex (little-endian, as encoded) + let mut s = String::with_capacity(2 + imm.len() * 2); + s.push_str("0x"); + for b in imm { + use core::fmt::Write as _; + let _ = write!(&mut s, "{:02x}", b); + } + s + } + } +} + +/// Disassembles a contiguous byte slice (single function body) into deterministic text. +pub fn disassemble(bytes: &[u8]) -> Result { + let mut pc = 0usize; + let mut out = Vec::new(); + while pc < bytes.len() { + let instr = decode_next(pc, bytes)?; + let name = instr.opcode.spec().name; + let imm_len = instr.opcode.spec().imm_bytes as usize; + if imm_len == 0 { + out.push(name.to_string()); + } else { + let ops = format_operand(instr.opcode, instr.imm); + out.push(format!("{} {}", name, ops)); + } + pc = instr.next_pc; + } + Ok(out.join("\n")) +} diff --git a/crates/console/prometeu-bytecode/src/lib.rs b/crates/console/prometeu-bytecode/src/lib.rs index e98b5c0e..70e19eb8 100644 --- a/crates/console/prometeu-bytecode/src/lib.rs +++ b/crates/console/prometeu-bytecode/src/lib.rs @@ -7,12 +7,16 @@ mod opcode_spec; mod program_image; mod value; pub mod isa; // canonical ISA boundary (core and future profiles) +mod disassembler; +mod assembler; pub use abi::{ TrapInfo, TRAP_BAD_RET_SLOTS, TRAP_DIV_ZERO, TRAP_ILLEGAL_INSTRUCTION, TRAP_INVALID_FUNC, TRAP_INVALID_LOCAL, TRAP_INVALID_SYSCALL, TRAP_OOB, TRAP_STACK_UNDERFLOW, TRAP_TYPE, }; pub use decoder::{decode_next, DecodeError}; +pub use disassembler::disassemble; +pub use assembler::{assemble, AsmError}; pub use layout::{compute_function_layouts, FunctionLayout}; pub use model::{BytecodeLoader, FunctionMeta, LoadError}; pub use program_image::ProgramImage; diff --git a/crates/console/prometeu-bytecode/tests/disasm_roundtrip.rs b/crates/console/prometeu-bytecode/tests/disasm_roundtrip.rs new file mode 100644 index 00000000..645ca12a --- /dev/null +++ b/crates/console/prometeu-bytecode/tests/disasm_roundtrip.rs @@ -0,0 +1,42 @@ +use prometeu_bytecode::{assemble, disassemble}; +use prometeu_bytecode::isa::core::CoreOpCode; + +fn emit(op: CoreOpCode, imm: Option<&[u8]>, out: &mut Vec) { + out.extend_from_slice(&(op as u16).to_le_bytes()); + if let Some(bytes) = imm { out.extend_from_slice(bytes); } +} + +#[test] +fn roundtrip_disasm_assemble_byte_equal_with_closures_and_coroutines() { + // Program: PUSH_I32 7; MAKE_CLOSURE fn=1,captures=0; CALL_CLOSURE argc=1; + // SPAWN fn=2,argc=1; YIELD; SLEEP 3; SYSCALL 0x1003; FRAME_SYNC; HALT + let mut prog = Vec::new(); + + emit(CoreOpCode::PushI32, Some(&7i32.to_le_bytes()), &mut prog); + // MAKE_CLOSURE (fn=1, captures=0) + let mut mc = [0u8; 8]; + mc[0..4].copy_from_slice(&1u32.to_le_bytes()); + mc[4..8].copy_from_slice(&0u32.to_le_bytes()); + emit(CoreOpCode::MakeClosure, Some(&mc), &mut prog); + // CALL_CLOSURE argc=1 + emit(CoreOpCode::CallClosure, Some(&1u32.to_le_bytes()), &mut prog); + // SPAWN (fn=2, argc=1) + let mut sp = [0u8; 8]; + sp[0..4].copy_from_slice(&2u32.to_le_bytes()); + sp[4..8].copy_from_slice(&1u32.to_le_bytes()); + emit(CoreOpCode::Spawn, Some(&sp), &mut prog); + // YIELD + emit(CoreOpCode::Yield, None, &mut prog); + // SLEEP 3 + emit(CoreOpCode::Sleep, Some(&3u32.to_le_bytes()), &mut prog); + // SYSCALL gfx.draw_line (0x1003) + emit(CoreOpCode::Syscall, Some(&0x1003u32.to_le_bytes()), &mut prog); + // FRAME_SYNC + emit(CoreOpCode::FrameSync, None, &mut prog); + // HALT + emit(CoreOpCode::Halt, None, &mut prog); + + let text = disassemble(&prog).expect("disasm ok"); + let rebuilt = assemble(&text).expect("assemble ok"); + assert_eq!(rebuilt, prog, "re-assembled bytes must match original"); +} diff --git a/crates/console/prometeu-bytecode/tests/disasm_snapshot.rs b/crates/console/prometeu-bytecode/tests/disasm_snapshot.rs new file mode 100644 index 00000000..c15054e0 --- /dev/null +++ b/crates/console/prometeu-bytecode/tests/disasm_snapshot.rs @@ -0,0 +1,39 @@ +use prometeu_bytecode::disassemble; +use prometeu_bytecode::isa::core::CoreOpCode; + +fn emit(op: CoreOpCode, imm: Option<&[u8]>, out: &mut Vec) { + out.extend_from_slice(&(op as u16).to_le_bytes()); + if let Some(bytes) = imm { out.extend_from_slice(bytes); } +} + +#[test] +fn snapshot_representative_program_is_stable() { + let mut prog = Vec::new(); + emit(CoreOpCode::PushI32, Some(&7i32.to_le_bytes()), &mut prog); + // MAKE_CLOSURE (fn=1, captures=0) + let mut mc = [0u8; 8]; + mc[0..4].copy_from_slice(&1u32.to_le_bytes()); + mc[4..8].copy_from_slice(&0u32.to_le_bytes()); + emit(CoreOpCode::MakeClosure, Some(&mc), &mut prog); + // CALL_CLOSURE argc=1 + emit(CoreOpCode::CallClosure, Some(&1u32.to_le_bytes()), &mut prog); + // SPAWN (fn=2, argc=1) + let mut sp = [0u8; 8]; + sp[0..4].copy_from_slice(&2u32.to_le_bytes()); + sp[4..8].copy_from_slice(&1u32.to_le_bytes()); + emit(CoreOpCode::Spawn, Some(&sp), &mut prog); + // YIELD + emit(CoreOpCode::Yield, None, &mut prog); + // SLEEP 3 + emit(CoreOpCode::Sleep, Some(&3u32.to_le_bytes()), &mut prog); + // SYSCALL 0x1003 + emit(CoreOpCode::Syscall, Some(&0x1003u32.to_le_bytes()), &mut prog); + // FRAME_SYNC + emit(CoreOpCode::FrameSync, None, &mut prog); + // HALT + emit(CoreOpCode::Halt, None, &mut prog); + + let text = disassemble(&prog).expect("disasm ok"); + let expected = "PUSH_I32 7\nMAKE_CLOSURE fn=1, captures=0\nCALL_CLOSURE argc=1\nSPAWN fn=2, argc=1\nYIELD\nSLEEP 3\nSYSCALL 0x1003\nFRAME_SYNC\nHALT"; + assert_eq!(text, expected); +} diff --git a/files/TODOs.md b/files/TODOs.md index f5611d5a..1a5fdef1 100644 --- a/files/TODOs.md +++ b/files/TODOs.md @@ -1,61 +1,3 @@ -# PR-8.1 — Disassembler (Roundtrip + Snapshot Reliability) - -## Briefing - -The disassembler must be: - -* Deterministic -* Complete -* Roundtrip-safe (encode → decode → encode) -* Snapshot-friendly - -It must fully support: - -* New opcodes (MAKE_CLOSURE, CALL_CLOSURE, SPAWN, YIELD, SLEEP) -* Updated syscall representation -* Closure and coroutine instructions - -## Target - -1. Update disassembler to support all new opcodes. -2. Guarantee stable formatting. -3. Add roundtrip validation tests. - -Formatting rules: - -* No unstable ordering. -* No implicit formatting differences across platforms. -* Explicit numeric representation where required. - -## Acceptance Checklist - -* [ ] All opcodes supported. -* [ ] Deterministic formatting. -* [ ] Roundtrip encode/decode test passes. -* [ ] Snapshot tests stable. - -## Tests - -1. Encode → disasm → reassemble → byte-equal check. -2. Snapshot test for representative programs. -3. Closure/coroutine disasm coverage. - -## Junie Instructions - -You MAY: - -* Modify disassembler module. -* Add snapshot tests. - -You MUST NOT: - -* Change bytecode encoding format. -* Introduce formatting randomness. - -If any opcode semantics unclear, STOP and ask. - ---- - # PR-8.3 — Layered Test Suite Architecture ## Briefing