Chapter 12: The Command-Line Interface
In this chapter, we’ll build a usable command-line tool for our assembler.
CLI Design
byte_asm [OPTIONS] <input.s>
OPTIONS:
-o, --output <file> Output binary file (default: a.out)
-v, --verbose Show assembly progress
--hex Output as hex dump instead of binary
-h, --help Show help message
Argument Parsing
We’ll parse arguments manually for simplicity:
#![allow(unused)]
fn main() {
struct Args {
input: PathBuf,
output: PathBuf,
verbose: bool,
hex_dump: bool,
}
fn parse_args() -> Result<Args, String> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
return Err(format!(
"Usage: {} [OPTIONS] <input.s>\n\n\
OPTIONS:\n\
-o, --output <file> Output binary file (default: a.out)\n\
-v, --verbose Show assembly progress\n\
--hex Output as hex dump",
args[0]
));
}
let mut input: Option<PathBuf> = None;
let mut output = PathBuf::from("a.out");
let mut verbose = false;
let mut hex_dump = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-o" | "--output" => {
i += 1;
if i >= args.len() {
return Err("Expected output file after -o".to_string());
}
output = PathBuf::from(&args[i]);
}
"-v" | "--verbose" => verbose = true,
"--hex" => hex_dump = true,
"-h" | "--help" => {
return Err(format!(
"ByteASM - 6502 Assembler\n\n\
Usage: {} [OPTIONS] <input.s>\n\n\
OPTIONS:\n\
-o, --output <file> Output file (default: a.out)\n\
-v, --verbose Show progress\n\
--hex Output hex dump\n\
-h, --help Show help",
args[0]
));
}
arg if arg.starts_with('-') => {
return Err(format!("Unknown option: {}", arg));
}
_ => {
input = Some(PathBuf::from(&args[i]));
}
}
i += 1;
}
let input = input.ok_or("No input file specified")?;
Ok(Args { input, output, verbose, hex_dump })
}
}
Main Function
fn main() {
let args = match parse_args() {
Ok(args) => args,
Err(msg) => {
eprintln!("{}", msg);
std::process::exit(1);
}
};
if args.verbose {
eprintln!("Assembling: {}", args.input.display());
}
// Read source file
let source = match fs::read_to_string(&args.input) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {}", args.input.display(), e);
std::process::exit(1);
}
};
// Parse
let program = match parser::parse(&source) {
Ok(p) => p,
Err(errors) => {
for error in errors {
eprintln!("{}", format_error(&error, &source, &args.input));
}
std::process::exit(1);
}
};
if args.verbose {
eprintln!("Parsed {} statements", program.statements.len());
}
// Assemble
let mut assembler = Assembler::new();
let binary = match assembler.assemble(&program) {
Ok(b) => b,
Err(e) => {
eprintln!("{}", format_assembler_error(&e, &source, &args.input));
std::process::exit(1);
}
};
if args.verbose {
eprintln!("Generated {} bytes", binary.len());
eprintln!("Defined {} symbols", assembler.symbols().len());
}
// Output
if args.hex_dump {
print_hex_dump(&binary);
} else {
if let Err(e) = fs::write(&args.output, &binary) {
eprintln!("Error writing {}: {}", args.output.display(), e);
std::process::exit(1);
}
if args.verbose {
eprintln!("Wrote: {}", args.output.display());
}
}
}
Hex Dump Output
For debugging, we can output a hex dump instead of binary:
#![allow(unused)]
fn main() {
fn print_hex_dump(binary: &[u8]) {
const BYTES_PER_LINE: usize = 16;
for (i, chunk) in binary.chunks(BYTES_PER_LINE).enumerate() {
// Address
print!("{:04X} ", i * BYTES_PER_LINE);
// Hex bytes
for (j, byte) in chunk.iter().enumerate() {
print!("{:02X} ", byte);
if j == 7 { print!(" "); } // Extra space in middle
}
// Padding for incomplete lines
if chunk.len() < BYTES_PER_LINE {
let missing = BYTES_PER_LINE - chunk.len();
for j in 0..missing {
print!(" ");
if chunk.len() + j == 7 { print!(" "); }
}
}
// ASCII representation
print!(" |");
for byte in chunk {
if *byte >= 0x20 && *byte < 0x7F {
print!("{}", *byte as char);
} else {
print!(".");
}
}
println!("|");
}
}
}
Example Output
$ byte_asm --hex example.s
0000 A9 42 85 00 4C 00 80 00 00 00 00 00 00 00 00 00 |.B..L...........|
0010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
Integration with Byte Emulator
The output binary is ready to load into the emulator:
# Assemble
cargo run -p byte_asm -- game.s -o game.bin
# Run
cargo run -p byte_emu -- game.bin
Setting Up the Reset Vector
The emulator reads the reset vector from 0xFFFC-0xFFFD:
.equ VID_PTR 0xFD
.equ VID_PAGE 0x01 ; Video page 1 (0x1000 >> 12)
.org 0x8000
reset:
; Initialize hardware
lda #VID_PAGE
sta VID_PTR ; Set video page
main_loop:
; Game logic
rti
; Set up vectors
.org 0xFFFC
.dw reset ; Reset vector - where to start
.dw main_loop ; IRQ vector - called on VBLANK
Verbose Output Example
$ byte_asm -v game.s -o game.bin
Assembling: game.s
Parsed 42 statements
Generated 156 bytes
Defined 12 symbols
Wrote: game.bin
Error Output Example
$ byte_asm broken.s
error: undefined symbol 'sprit_x'
--> broken.s:15:9
|
15 | lda sprit_x
| ^^^^^^^ symbol not defined
error: branch target out of range
--> broken.s:42:5
|
42 | bne far_label
| ^^^^^^^^^^^^^ offset -150, must be -128 to +127
Found 2 errors.
Usage Workflow
A typical workflow:
# Edit source
vim game.s
# Assemble
byte_asm game.s -o game.bin
# Test in emulator
byte_emu game.bin
# Debug with hex dump
byte_asm game.s --hex | less
# Verbose build
byte_asm -v game.s -o game.bin
Exit Codes
#![allow(unused)]
fn main() {
// Success
std::process::exit(0);
// Error (parse, assembly, I/O)
std::process::exit(1);
}
Summary
In this chapter, we built a command-line interface that:
- Parses command-line arguments
- Reads source files
- Invokes the parser and assembler
- Outputs binary or hex dump
- Reports errors with context
- Integrates with the byte emulator
In the next chapter, we’ll write tests to verify our assembler works correctly.
Previous: Chapter 11 - Error Handling | Next: Chapter 13 - Testing