From 17e93513717b04fa1db05ba853d9bd7ea7cb166c Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Mon, 5 Oct 2026 12:55:35 +0700 Subject: [PATCH] feat(bios): decode LZ77 streams through ARM instructions Add byte and buffered-halfword output, overlapping references, argument diagnostics, and a reusable output routine. --- src/bios.rs | 7 + src/bios/decompression.rs | 23 +++ src/bios/lz77.rs | 82 ++++++++ tests/bios.rs | 2 + tests/bios/lz77.rs | 417 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 531 insertions(+) create mode 100644 src/bios/decompression.rs create mode 100644 src/bios/lz77.rs create mode 100644 tests/bios/lz77.rs diff --git a/src/bios.rs b/src/bios.rs index c5b5fda..34e0e99 100644 --- a/src/bios.rs +++ b/src/bios.rs @@ -1,11 +1,14 @@ //! Original, optional ARM BIOS replacement. No Nintendo firmware is included. //! Supports interrupt waits and memory copy/fill. //! Includes integer arithmetic services. +//! Includes LZ77 decoding. //! This is a functional subset, not a complete boot ROM or a timing-compatible BIOS. use std::collections::BTreeMap; mod arithmetic; +mod decompression; +mod lz77; use crate::{ cpu::Cpu, @@ -117,6 +120,8 @@ pub fn image() -> Vec { (8, "sqrt"), (0x0b, "cpu_set"), (0x0c, "cpu_fast_set"), + (0x11, "lz_wram"), + (0x12, "lz_vram"), ] { a.emit(0xe35c_0000 | number); // CMP r12,#service a.branch(0, target); @@ -209,6 +214,8 @@ pub fn image() -> Vec { ); arithmetic::emit(&mut a); + lz77::emit(&mut a); + decompression::emit_writer(&mut a); a.label("return"); a.emit(0xe10f_3000); // MRS r3,CPSR diff --git a/src/bios/decompression.rs b/src/bios/decompression.rs new file mode 100644 index 0000000..1346f85 --- /dev/null +++ b/src/bios/decompression.rs @@ -0,0 +1,23 @@ +//! Shared output routine for the original ARM decompression services. + +use super::ArmImage; + +/// Emit a leaf routine called with BL. r5 holds a zero-extended byte; +/// r1 is the destination, r2 the remaining output count, and r12 the write mode. +/// Halfword mode uses r9 as a pending value and r10 as bit position (0 or 8). +/// The routine changes flags, r1/r2, and r9/r10, but preserves the input byte. +pub(super) fn emit_writer(a: &mut ArmImage) { + a.label("decompress_store_byte"); + a.emit(0xe35c_0000); // CMP r12,#0 + a.emit(0x05c1_5000); // STRBEQ r5,[r1] (normal byte-bus rules apply) + a.branch(0, "decompress_stored"); + a.emit(0xe189_9a15); // ORR r9,r9,r5,LSL r10 + a.emit(0xe22a_a008); // EOR r10,r10,#8 + a.emit(0xe35a_0000); // CMP r10,#0 + a.emit(0x0141_90b1); // STRHEQ r9,[r1,#-1] (commit the completed halfword) + a.emit(0x03a0_9000); // MOVEQ r9,#0 + a.label("decompress_stored"); + a.emit(0xe281_1001); // ADD r1,r1,#1 + a.emit(0xe242_2001); // SUB r2,r2,#1 + a.emit(0xe12f_ff1e); // BX lr +} diff --git a/src/bios/lz77.rs b/src/bios/lz77.rs new file mode 100644 index 0000000..df73492 --- /dev/null +++ b/src/bios/lz77.rs @@ -0,0 +1,82 @@ +//! Original LZ77 decoder emitted as ARM instructions. +//! Invalid streams trap after any earlier successful writes. VRAM output requires +//! an even size and halfword alignment; distance-one references are rejected. + +use super::ArmImage; + +pub(super) fn emit(a: &mut ArmImage) { + a.label("lz_wram"); + a.emit(0xe3a0_c000); // MOV r12,#0 (byte stores) + a.branch(14, "lz_start"); + a.label("lz_vram"); + a.emit(0xe3a0_c001); // MOV r12,#1 (buffer bytes into halfword stores) + a.label("lz_start"); + a.emit(0xe92d_0ff0); // STMDB sp!,{r4-r11} (60 bytes total SVC stack use) + a.emit(0xe310_0003); // TST r0,#3 (header must be word-aligned) + a.branch(1, "invalid_argument"); + a.emit(0xe350_0402); // CMP r0,#0x02000000 (do not decompress protected BIOS bytes) + a.branch(3, "invalid_argument"); + a.emit(0xe490_2004); // LDR r2,[r0],#4 (header) + a.emit(0xe202_b0ff); // AND r11,r2,#0xff + a.emit(0xe35b_0010); // CMP r11,#0x10 (LZ77 type, reserved bits zero) + a.branch(1, "invalid_argument"); + a.emit(0xe1a0_2422); // MOV r2,r2,LSR #8 (24-bit output byte count) + a.emit(0xe35c_0000); // CMP r12,#0 + a.branch(0, "lz_validate_end"); + a.emit(0xe181_b002); // ORR r11,r1,r2 (both destination and size must be even) + a.emit(0xe31b_0001); // TST r11,#1 + a.branch(1, "invalid_argument"); + a.label("lz_validate_end"); + a.emit(0xe091_b002); // ADDS r11,r1,r2 + a.branch(2, "invalid_argument"); // Reject output address wrap. + a.emit(0xe352_0000); // CMP r2,#0 + a.branch(0, "lz_done"); + a.emit(0xe1a0_8001); // MOV r8,r1 (start of produced output) + a.emit(0xe3a0_9000); // MOV r9,#0 (pending halfword) + a.emit(0xe3a0_a000); // MOV r10,#0 (bit position 0 or 8) + + a.label("lz_group"); + a.emit(0xe4d0_3001); // LDRB r3,[r0],#1 (flags, most significant bit first) + a.emit(0xe3a0_4080); // MOV r4,#0x80 + a.label("lz_token"); + a.emit(0xe113_0004); // TST r3,r4 + a.branch(1, "lz_reference"); + a.emit(0xe4d0_5001); // LDRB r5,[r0],#1 (literal) + a.call("decompress_store_byte"); + a.branch(14, "lz_next_token"); + + a.label("lz_reference"); + a.emit(0xe4d0_5001); // LDRB r5,[r0],#1 (length nibble and high displacement bits) + a.emit(0xe1a0_7225); // MOV r7,r5,LSR #4 + a.emit(0xe287_7003); // ADD r7,r7,#3 (length 3..18) + a.emit(0xe205_600f); // AND r6,r5,#0xf + a.emit(0xe4d0_5001); // LDRB r5,[r0],#1 (low displacement bits) + a.emit(0xe185_6406); // ORR r6,r5,r6,LSL #8 + a.emit(0xe286_6001); // ADD r6,r6,#1 (distance 1..4096) + a.emit(0xe041_b008); // SUB r11,r1,r8 (bytes already produced) + a.emit(0xe156_000b); // CMP r6,r11 + a.branch(8, "invalid_argument"); // BHI: reference before start of output. + a.emit(0xe157_0002); // CMP r7,r2 + a.branch(8, "invalid_argument"); // BHI: run would exceed the declared size. + a.emit(0xe35c_0000); // CMP r12,#0 + a.branch(0, "lz_distance_ok"); + a.emit(0xe356_0001); // CMP r6,#1 + a.branch(0, "invalid_argument"); // VRAM firmware cannot use its uncommitted previous byte. + a.label("lz_distance_ok"); + a.emit(0xe041_6006); // SUB r6,r1,r6 (reference pointer) + a.label("lz_reference_byte"); + a.emit(0xe4d6_5001); // LDRB r5,[r6],#1 (overlaps observe newly produced data) + a.call("decompress_store_byte"); + a.emit(0xe257_7001); // SUBS r7,r7,#1 + a.branch(1, "lz_reference_byte"); + + a.label("lz_next_token"); + a.emit(0xe352_0000); // CMP r2,#0 + a.branch(0, "lz_done"); + a.emit(0xe1b0_40a4); // MOVS r4,r4,LSR #1 + a.branch(1, "lz_token"); + a.branch(14, "lz_group"); + a.label("lz_done"); + a.emit(0xe8bd_0ff0); // LDMIA sp!,{r4-r11} + a.branch(14, "return"); +} diff --git a/tests/bios.rs b/tests/bios.rs index bbf4af5..ced6d45 100644 --- a/tests/bios.rs +++ b/tests/bios.rs @@ -14,6 +14,8 @@ const ROM_DATA: u32 = ROM_START + 13 * 4; #[path = "bios/arithmetic.rs"] mod arithmetic; +#[path = "bios/lz77.rs"] +mod lz77; fn words(code: &[u32]) -> Vec { code.iter().flat_map(|word| word.to_le_bytes()).collect() diff --git a/tests/bios/lz77.rs b/tests/bios/lz77.rs new file mode 100644 index 0000000..440f1bd --- /dev/null +++ b/tests/bios/lz77.rs @@ -0,0 +1,417 @@ +use super::*; +use gba_rust::bios::INVALID_ARGUMENT_TRAP; + +#[derive(Clone, Copy)] +enum Token { + Literal(u8), + Reference { distance: usize, length: usize }, +} + +/// Test-only token encoder and byte-vector reference. Neither decodes the emitted ARM code. +fn stream(tokens: &[Token]) -> (Vec, Vec) { + let mut compressed = vec![0x10, 0, 0, 0]; + let mut expected = Vec::new(); + for group in tokens.chunks(8) { + let flags_index = compressed.len(); + compressed.push(0); + for (index, token) in group.iter().enumerate() { + match *token { + Token::Literal(value) => { + compressed.push(value); + expected.push(value); + } + Token::Reference { distance, length } => { + assert!((1..=4096).contains(&distance)); + assert!((3..=18).contains(&length)); + assert!(distance <= expected.len()); + compressed[flags_index] |= 0x80 >> index; + compressed.push((((length - 3) << 4) | ((distance - 1) >> 8)) as u8); + compressed.push((distance - 1) as u8); + for _ in 0..length { + expected.push(expected[expected.len() - distance]); + } + } + } + } + } + let length = expected.len() as u32; + compressed[..4].copy_from_slice(&(length << 8 | 0x10).to_le_bytes()); + (compressed, expected) +} + +fn prepare(service: u8, thumb: bool, destination: u32, compressed: &[u8]) -> (Machine, u32) { + call_status_data( + service, + thumb, + [ROM_DATA, destination, 0x55aa_1234], + 0x1f, + compressed, + ) +} + +fn complete(machine: &mut Machine, return_pc: u32) { + machine.step().unwrap(); + reach(machine, return_pc, 500_000); +} + +fn verify(service: u8, thumb: bool, destination: u32, compressed: &[u8], expected: &[u8]) { + let (mut machine, return_pc) = prepare(service, thumb, destination, compressed); + let before = machine.cpu().clone(); + // Halfword sentinels also work in VRAM without byte-write duplication. + machine + .memory_mut() + .write16((destination - 2) & !1, 0xa55a) + .unwrap(); + let after = (destination + expected.len() as u32 + 1) & !1; + machine.memory_mut().write16(after, 0xa55a).unwrap(); + for address in [destination - 1, destination + expected.len() as u32] { + machine.memory_mut().write16(address & !1, 0xa55a).unwrap(); + } + machine + .memory_mut() + .write32(bios::IRQ_STACK, 0x5a5a_a5a5) + .unwrap(); + complete(&mut machine, return_pc); + for (index, value) in expected.iter().enumerate() { + assert_eq!( + machine.memory().read8(destination + index as u32).unwrap(), + *value, + "output byte {index}" + ); + } + assert_eq!( + machine.memory().read16((destination - 2) & !1).unwrap(), + 0xa55a + ); + assert_eq!(machine.memory().read16(after).unwrap(), 0xa55a); + for address in [destination - 1, destination + expected.len() as u32] { + assert_eq!( + machine.memory().read8(address).unwrap(), + if address & 1 == 0 { 0x5a } else { 0xa5 } + ); + } + assert_eq!( + machine.memory().read32(bios::IRQ_STACK).unwrap(), + 0x5a5a_a5a5 + ); + assert_eq!(&machine.cpu().registers()[..15], &before.registers()[..15]); + assert_eq!(machine.cpu().cpsr(), before.cpsr()); +} + +fn fail(machine: &mut Machine) -> MachineError { + for _ in 0..1000 { + let before = machine.cpu().clone(); + let cycles = machine.cycles(); + let timing = machine.last_timing(); + if let Err(error) = machine.step() { + assert_eq!(machine.cpu(), &before); + assert_eq!(machine.cycles(), cycles); + assert_eq!(machine.last_timing(), timing); + assert_eq!(machine.step(), Err(error.clone())); + return error; + } + } + panic!("invalid stream did not produce a bounded diagnostic"); +} + +fn assert_invalid(error: MachineError) { + assert!(matches!( + error, + MachineError::Cpu(CpuError::UnsupportedInstruction { + instruction: INVALID_ARGUMENT_TRAP, + .. + }) + )); +} + +#[test] +fn literals_cross_flag_groups_in_arm_and_thumb_for_both_write_widths() { + let tokens: Vec<_> = (0..34).map(|value| Token::Literal(value * 7)).collect(); + let (compressed, expected) = stream(&tokens); + for thumb in [false, true] { + verify(0x11, thumb, DEST, &compressed, &expected); + verify(0x12, thumb, VRAM_START + 4, &compressed, &expected); + verify(0x12, thumb, DEST, &compressed, &expected); // Halfword variant also works in RAM. + } +} + +#[test] +fn literal_and_reference_flags_are_read_most_significant_bit_first() { + let tokens = [ + Token::Literal(b'A'), + Token::Literal(b'B'), + Token::Reference { + distance: 2, + length: 3, + }, + Token::Literal(b'C'), + Token::Reference { + distance: 4, + length: 4, + }, + Token::Literal(b'D'), + Token::Literal(b'E'), + Token::Reference { + distance: 3, + length: 6, + }, + Token::Reference { + distance: 8, + length: 4, + }, + Token::Literal(b'F'), + Token::Literal(b'G'), + ]; + let (compressed, expected) = stream(&tokens); + assert_eq!(compressed[4], 0x29); // References in token positions 2,4,7. + assert_eq!(expected.len() % 2, 0); + verify(0x11, false, DEST, &compressed, &expected); + verify(0x12, true, VRAM_START + 4, &compressed, &expected); +} + +#[test] +fn overlapping_references_support_every_run_length() { + for length in 3..=18 { + let mut tokens = vec![ + Token::Literal(0x12), + Token::Literal(0xab), + Token::Reference { + distance: 2, + length, + }, + ]; + if length % 2 != 0 { + tokens.push(Token::Literal(0x99)); + } + let (compressed, expected) = stream(&tokens); + verify(0x11, false, DEST + 1, &compressed, &expected); // Byte destination need not be aligned. + verify(0x12, true, VRAM_START + 4, &compressed, &expected); + } +} + +#[test] +fn wram_variant_supports_distance_one_runs() { + let (compressed, expected) = stream(&[ + Token::Literal(0x5a), + Token::Reference { + distance: 1, + length: 18, + }, + ]); + verify(0x11, false, DEST, &compressed, &expected); + verify(0x11, true, DEST + 1, &compressed, &expected); +} + +#[test] +fn full_twelve_bit_displacements_reach_back_4096_bytes() { + for distance in [4095, 4096] { + let mut tokens: Vec<_> = (0..4096) + .map(|index| Token::Literal((index * 37 % 251) as u8)) + .collect(); + tokens.push(Token::Reference { + distance, + length: 18, + }); + let (compressed, expected) = stream(&tokens); + verify(0x11, false, DEST, &compressed, &expected); + verify(0x12, true, VRAM_START + 4, &compressed, &expected); + } +} + +#[test] +fn seeded_valid_streams_match_independent_token_expansion() { + let mut state = 0x1234_abcd_u32; + for seed in 0..16 { + let mut tokens = vec![Token::Literal(0x13), Token::Literal(0x57)]; + let mut length = 2; + for _ in 0..40 { + state = state.wrapping_mul(1664525).wrapping_add(1013904223); + if state & 3 == 0 { + tokens.push(Token::Literal((state >> 16) as u8)); + length += 1; + } else { + let run = 3 + (state >> 8) as usize % 16; + let distance = 2 + (state >> 16) as usize % (length - 1).min(4095); + tokens.push(Token::Reference { + distance, + length: run, + }); + length += run; + } + } + if length % 2 != 0 { + tokens.push(Token::Literal(0)); + } + let (compressed, expected) = stream(&tokens); + verify(0x11, seed & 1 == 0, DEST, &compressed, &expected); + verify(0x12, seed & 1 != 0, VRAM_START + 4, &compressed, &expected); + } +} + +#[test] +fn compressed_sources_can_be_read_from_either_work_ram_region() { + let (compressed, expected) = stream(&[ + Token::Literal(0x55), + Token::Literal(0xaa), + Token::Reference { + distance: 2, + length: 4, + }, + ]); + for source in [SOURCE, 0x0300_0100] { + for service in [0x11, 0x12] { + let (mut machine, return_pc) = call(service, false, [source, DEST, 0]); + for (offset, byte) in compressed.iter().enumerate() { + machine + .memory_mut() + .write8(source + offset as u32, *byte) + .unwrap(); + } + complete(&mut machine, return_pc); + for (offset, byte) in expected.iter().enumerate() { + assert_eq!(machine.memory().read8(DEST + offset as u32).unwrap(), *byte); + } + } + } +} + +#[test] +fn decompression_restores_user_mode_masks_and_all_caller_registers() { + let (compressed, _) = stream(&[ + Token::Literal(0x55), + Token::Literal(0xaa), + Token::Reference { + distance: 2, + length: 4, + }, + ]); + for service in [0x11, 0x12] { + for thumb in [false, true] { + for status in [0x10, 0x90, 0x9f, 0xdf] { + let (mut machine, return_pc) = call_status_data( + service, + thumb, + [ROM_DATA, DEST, 0x12345678], + status, + &compressed, + ); + let before = machine.cpu().clone(); + complete(&mut machine, return_pc); + assert_eq!(machine.cpu().cpsr(), before.cpsr()); + assert_eq!(&machine.cpu().registers()[..15], &before.registers()[..15]); + } + } + } +} + +#[test] +fn zero_output_length_does_not_read_flags_or_touch_destination() { + for service in [0x11, 0x12] { + let (mut machine, return_pc) = prepare(service, false, 0x0e00_0000, &[0x10, 0, 0, 0]); + complete(&mut machine, return_pc); // Destination and next source byte are unmapped. + } +} + +#[test] +fn bad_headers_alignment_and_wrapping_destinations_are_rejected() { + for header in [0_u32, 0x0200, 0x0211, 0x0220, 0x02ff] { + let (mut machine, _) = prepare(0x11, false, DEST, &header.to_le_bytes()); + assert_invalid(fail(&mut machine)); + assert_eq!(machine.memory().read32(DEST).unwrap(), 0); + } + for source in [0, 0x3ffc, ROM_DATA + 1, ROM_DATA + 2, ROM_DATA + 3] { + let (mut machine, _) = + call_status_data(0x11, false, [source, DEST, 0], 0x1f, &[0x10, 4, 0, 0]); + assert_invalid(fail(&mut machine)); + } + let (mut machine, _) = prepare(0x12, false, 0xffff_fffe, &[0x10, 4, 0, 0]); + assert_invalid(fail(&mut machine)); +} + +#[test] +fn references_before_output_or_beyond_declared_length_are_rejected() { + for compressed in [ + vec![0x10, 3, 0, 0, 0x80, 0, 0], // No preceding output. + vec![0x10, 4, 0, 0, 0x40, b'A', 0, 1], // Only one preceding byte, distance two. + vec![0x10, 3, 0, 0, 0x40, b'A', 0, 0], // Length three exceeds two remaining bytes. + ] { + let (mut machine, _) = prepare(0x11, false, DEST, &compressed); + machine.memory_mut().write32(DEST, 0xcccc_cccc).unwrap(); + assert_invalid(fail(&mut machine)); + assert_eq!( + machine.memory().read8(DEST).unwrap(), + if compressed[4] == 0x80 { 0xcc } else { b'A' } + ); + assert_eq!(machine.memory().read8(DEST + 1).unwrap(), 0xcc); + } +} + +#[test] +fn vram_variant_rejects_odd_sizes_odd_destinations_and_distance_one() { + for (destination, compressed) in [ + (VRAM_START + 4, vec![0x10, 1, 0, 0, 0, 0x77]), + (VRAM_START + 5, vec![0x10, 2, 0, 0, 0, 0x77, 0x88]), + (VRAM_START + 4, vec![0x10, 4, 0, 0, 0x40, 0x77, 0, 0]), + ] { + let (mut machine, _) = prepare(0x12, true, destination, &compressed); + machine + .memory_mut() + .write32(VRAM_START + 4, 0xcccc_cccc) + .unwrap(); + assert_invalid(fail(&mut machine)); + assert_eq!( + machine.memory().read32(VRAM_START + 4).unwrap(), + 0xcccc_cccc + ); + } +} + +#[test] +fn truncated_rom_inputs_produce_memory_diagnostics() { + for compressed in [ + vec![], + vec![0x10, 2], + vec![0x10, 2, 0, 0], + vec![0x10, 2, 0, 0, 0], + vec![0x10, 3, 0, 0, 0x80, 0], + ] { + let (mut machine, _) = prepare(0x11, false, DEST, &compressed); + assert_eq!( + fail(&mut machine), + MachineError::Cpu(MemoryError::Unmapped(ROM_DATA + compressed.len() as u32).into()) + ); + } +} + +#[test] +fn halfword_output_never_commits_a_lone_pending_byte_on_failure() { + for service in [0x11, 0x12] { + let compressed = [0x10, 2, 0, 0, 0, b'A']; // Second literal is missing. + let (mut machine, _) = prepare(service, false, DEST, &compressed); + machine.memory_mut().write16(DEST, 0xcccc).unwrap(); + assert!(matches!( + fail(&mut machine), + MachineError::Cpu(CpuError::Memory(_)) + )); + assert_eq!( + machine.memory().read16(DEST).unwrap(), + if service == 0x11 { 0xcc41 } else { 0xcccc } + ); + } +} + +#[test] +fn destination_failure_preserves_preceding_completed_halfword() { + let (compressed, _) = stream(&[ + Token::Literal(0x12), + Token::Literal(0x34), + Token::Literal(0x56), + Token::Literal(0x78), + ]); + let (mut machine, _) = prepare(0x12, false, 0x07ff_fffe, &compressed); + assert_eq!( + fail(&mut machine), + MachineError::Cpu(MemoryError::ReadOnly(0x0800_0000).into()) + ); + assert_eq!(machine.memory().read16(0x07ff_fffe).unwrap(), 0x3412); +}