How to build your own emulator with the SPECTRUSTY library.
This project is maintained by royaltm
This is a part of the tutorial for the SPECTRUSTY library.
In this step, you can learn how to load and save data using .tap
files.
After finishing the previous steps, you can now enjoy the whole magnificence of the Spectrum’s BASIC. You may draw CIRCLEs, change the BORDER color, or even BEEP some tunes. That is if you have enough patience to write everything from scratch every time you boot the emulator. A ZX Spectrum BASIC mandala. Perfect!
So what if you could SAVE programs… and LOAD them back? I’m speaking, of course, in terms of magnetic tapes here, cassettes, yeah those things. Are you still with me? Great!
As your new Spectrum lives in a digital world only, we have to forget about those reel-to-reels. The magnetic fields generated by the recorder’s head is getting replaced by the digital TAP format.
Spectrum communicates with the tape recorder via two lines:
EAR IN
to LOAD data from tape, andMIC OUT
to SAVE data.The communication signal has a form of pulses. Longer pulses are ones, and shorter are zeros. There are also lead and synchronization pulses, but that is not so important right now.
We need those emitted pulses to be decoded into TAP bytes, and vice-versa, TAP bytes encoded as pulses.
The good news is, the part of SPECTRUSTY is also … spectrusty-formats crate! You can parse and construct some popular data formats. For TAPs, you can “play” and “record” their content as pulses.
First, let’s add some more imports…
use core::convert::TryFrom;
use core::fmt::Write;
use std::fs::{File, OpenOptions};
// to log some actions
#[allow(unused_imports)]
use log::{error, warn, info, debug, trace};
// more audio raleted imports, here we go...
use spectrusty::audio::{
AudioSample, EarMicAmps4, EarOutAmps4, EarInAmps2,
Blep, AudioFrame, FromSample, EarMicOutAudioFrame, EarInAudioFrame
synth::BandLimited,
host::cpal::AudioHandleAnyFormat
};
use spectrusty::clock::FTs;
// we need two more control traits for handling EAR/MIC lines
use spectrusty::chip::{
FrameState, ControlUnit, MemoryAccess, MicOut, EarIn,
ThreadSyncTimer,
ula::UlaPAL
};
// yes, the highlight of our imports
use spectrusty::formats::tap::{
read_tap_pulse_iter, TapChunkRead, TapChunkInfo
};
// with some sugar on top
use spectrusty_utils::{
tap::{Tape, Tap},
keyboard::minifb::update_keymap
};
Next, you need to update your emulator and add the TAPE cassette recorder to the ZxSpectrum
struct… The tape recorder is emulated, of course. You can also add a boolean flag if you want to turn on and off the TAPE sound being audible when played or recorded.
#[derive(Default)]
struct ZxSpectrum<C: Cpu, M: ZxMemory> {
cpu: C,
ula: UlaPAL<M>,
nmi_request: bool,
// the state of the emulator program
state: EmulatorState
}
#[derive(Default)]
struct EmulatorState {
// the TAPE recorder, maybe a tape is inside?
tape: Tape<File>,
// do we want to hear the tape signal?
audible_tape: bool,
}
For this purpose we’ll use the Tape organizer with the fs::File as its data provider. You could also use io::Cursor<Vec<u8>>
or anything that implements io::Read + io::Write + io::Seek as the Tape backend.
We have a new property (state
) added to the struct. So its content needs to be moved when the user changes the model.
Here is a proposed refactoring of ZxSpectrumModel
implementation:
impl<C: Cpu> ZxSpectrumModel<C>
{
fn into_cpu_and_state(self) -> (C, EmulatorState) {
match self {
ZxSpectrumModel::Spectrum16(spec16) =>
(spec16.cpu, spec16.state),
ZxSpectrumModel::Spectrum48(spec48) =>
(spec48.cpu, spec48.state),
}
}
//... ✂
// hot-swap hardware models
fn change_model(self, request: ModelReq) -> Self {
use ZxSpectrumModel::*;
match (&self, request) {
(Spectrum16(..), ModelReq::Spectrum16)|
(Spectrum48(..), ModelReq::Spectrum48) => return self,
_ => {}
}
match request {
ModelReq::Spectrum16 => Spectrum16(
ZxSpectrum16k::<C>::from(self)),
ModelReq::Spectrum48 => Spectrum48(
ZxSpectrum48k::<C>::from(self))
}
}
}
The cloning part, delegated to the From implementation, now looks like this:
impl<C: Cpu, M: ZxMemory> From<ZxSpectrumModel<C>> for ZxSpectrum<C, M>
where ZxSpectrum<C, M>: Default
{
fn from(model: ZxSpectrumModel<C>) -> Self {
let border = model.border_color();
let other_mem = model.as_mem_ref();
let mut spectrum = ZxSpectrum::<C, M>::default();
let my_mem = spectrum.ula.memory_mut().mem_mut();
let len = other_mem.len().min(my_mem.len());
my_mem[..len].copy_from_slice(&other_mem[..len]);
let (cpu, state) = model.into_cpu_and_state();
spectrum.cpu = cpu;
spectrum.state = state;
spectrum.ula.set_border_color(border);
spectrum
}
}
Oh, and you may ask, what is this nmi_request
thingy? I’ll address this in a moment. But for now, let’s get back to TAPEs.
For the sake of simplicity, we’ll get the TAP file name from the first command argument that a user passed to your program.
fn main() -> Result<()> {
// initialize logger
simple_logger::SimpleLogger::new()
.with_level(log::LevelFilter::Debug).init()?;
let mut args = std::env::args().skip(1);
// parsing the 1st command argument as path to the TAP file
let tap_file_name = args.next();
//... ✂
//... later in main
// if the user provided us with the file name
if let Some(file_name) = tap_file_name {
info!("Loading TAP file: {}", file_name);
// open the .tap file for reading and writing
let tap_file = OpenOptions::new().read(true)
.write(true)
.create(true)
.open(file_name)?;
// wrap the file into the TapChunkPulseIter
let iter_pulse = read_tap_pulse_iter(tap_file);
spec16.state.tape.tap = Some(Tap::Reader(iter_pulse));
// or instead we could just write:
// spec16.tape.insert_as_reader(tap_file);
// by default we'd like to hear the tape sound, woudn't we?
spec16.state.audible_tape = true;
}
I almost forgot. We need to add spectrusty-utils to cargo deps, this time for sure:
[dependencies]
#... ✂
spectrusty-utils = "0.1"
That’s it, for starters. Next, we need to extend the run_frame
method so the TAPE can be played or recorded:
// returns Ok(true) if the tape playback has ended
fn run_frame(&mut self) -> Result<bool> {
// get the writer if the tape is inserted and is being recorded
if let Some(ref mut writer) = self.tape.recording_tap() {
// extract the MIC OUT state changes as a pulse iterator
let pulses_iter = self.ula.mic_out_pulse_iter();
// decode pulses as TAPE data and write it as a TAP chunk
let chunks = writer.write_pulses_as_tap_chunks(pulses_iter)?;
if chunks != 0 {
info!("Saved: {} TAP chunks", chunks);
}
}
let mut state_changed = false;
// get the reader if the tape is inserted and is being played
if let Some(ref mut feeder) = self.tape.playing_tap() {
// clean up the internal buffers of ULA so we won't append
// the EAR IN data to the previous frame's data
self.ula.ensure_next_frame();
// check if any pulse is still left in the feeder
let mut feeder = feeder.peekable();
if feeder.peek().is_some() {
// feed EAR IN line with pulses from our pulse iterator
// only up to the end of a single frame
self.ula.feed_ear_in(&mut feeder, Some(1));
}
else {
// end of tape
info!("Auto STOP: End of TAPE");
self.tape.stop();
state_changed = true;
}
}
if self.nmi_request && self.ula.nmi(&mut self.cpu) {
// clear nmi_request only if the triggering succeeded
self.nmi_request = false;
}
self.ula.execute_next_frame(&mut self.cpu);
Ok(state_changed)
}
That’s… a lot of things there. The good thing is, we had a dedicated function for that. Whew!
And you can also see now what the nmi_request
was for. We also need to change the implementation of the method:
fn trigger_nmi(&mut self) {
self.nmi_request = true;
}
Why is this needed?
Triggering NMI takes a few CPU cycles. If we were triggering NMI before we use the MIC OUT data, we’d lose this data. It may happen not because triggering NMI is somehow special but because, when the trigger_nmi
method was invoked, the T-state counter had been near the end of the previous frame. ControlUnit methods forwarding the cycle counter ensure that the counter value doesn’t exceed its frame limit and conditionally invoke ControlUnit::ensure_next_frame. This method clears all temporary frame data. If instead, we insert the ControlUnit::nmi call just before ControlUnit::execute_next_frame, it won’t influence any side effects, as temporary data is being consumed beforehand.
Another reason for this - and this is not strictly emulation related but is also an issue with the real Z80 - is that triggering NMI will fail if the executed instruction was one of the 0xDD
, 0xFD
prefixes or the EI
instruction. If you’d fill the whole memory with one of these instructions, you’ll never be able to trigger the Non-Maskable Interrupt. That’s why the Cpu::nmi method returns a boolean indicating if it succeeded. And nmi_request
is being cleared only when triggering of the NMI is successful.
I have mentioned something about the ability to hear the playback of the TAPE, haven’t I?
Well… here it is, a modified version of the render_audio
method:
// adds pulse steps to the `blep` and returns the number of samples
// ready to be produced.
fn render_audio<B: Blep<SampleDelta=BlepDelta>>(
&mut self, blep: &mut B
) -> usize
{
// (1) add some amplitude steps to the BLEP that correspond to
// the EAR/MIC line changes
if self.state.audible_tape {
// render both EAR/MIC OUT channel
self.ula.render_earmic_out_audio_frame::<
EarMicAmps4<BlepDelta>
>(blep, 0);
// and the EAR IN channel
self.ula.render_ear_in_audio_frame::<EarInAmps2<BlepDelta>>(
blep, 0);
}
else {
// render only EAR OUT channel
self.ula.render_earmic_out_audio_frame::<
EarOutAmps4<BlepDelta>
>(blep, 0);
}
// (2) finalize the BLEP frame
self.ula.end_audio_frame(blep)
}
If audible_tape
is true
the user should hear the tape sound when we LOAD
or SAVE
. If it’s false
only beeper sound can be heard.
When the audible_tape
option is true
, the user should hear sound from the tape, should it be played or recorded to. Otherwise, beeper sound (EAR OUT) alone is being emitted.
Yet, we still need to allow the user to control the TAPE somehow. We can add something like this inside the run loop:
while is_running() {
spectrum.update_keyboard( update_keys );
let mut need_update_info = spectrum.run_frame();
//... ✂
if let Some(input) = get_user_input_request() {
match spectrum.update_on_user_request(input)? {
Some(action) => return Ok(action),
None => { need_update_info = true; }
}
}
if need_update_info {
display_info(&spectrum.info()?);
}
synchronize_frame();
}
Where get_user_input_request
is a hypothetical UI event handler with the signature: () -> Option<InputRequest>
, and the display_info
will help the user to have a clear picture of what is happening with the TAP file.
The method update_on_user_request
could be implemented like this:
fn update_on_user_request(
&mut self,
input: InputRequest
) -> Result<Option<Action>>
{
use InputRequest::*;
match input {
Exit => return Ok(Some(Action::Exit)),
Spectrum16 => return Ok(Some(Action::ChangeModel(
ModelReq::Spectrum16))),
Spectrum48 => return Ok(Some(Action::ChangeModel(
ModelReq::Spectrum48))),
HardReset => self.reset(true),
SoftReset => self.reset(false),
TriggerNmi => { self.trigger_nmi(); }
TapeRewind => { self.state.tape.rewind_nth_chunk(1)?; }
TapePlay => { self.state.tape.play()?; }
TapeRecord => { self.state.tape.record()?; }
TapeStop => { self.state.tape.stop(); }
TapePrevBlock => { self.state.tape.rewind_prev_chunk()?; }
TapeNextBlock => { self.state.tape.forward_chunk()?; }
TapeAudible => {
self.state.audible_tape = !self.state.audible_tape;
}
TapeFlashLoad => {
self.state.flash_tape = !self.state.flash_tape;
}
}
Ok(None)
}
Let’s then implement the info
method that returns a status string. It should be self-explanatory:
fn info(&mut self) -> Result<String> {
let mut info = format!("ZX Spectrum {}k",
self.ula.memory_ref().ram_ref().len() / 1024);
// is the TAPE running?
let running = self.tape.running;
// is there any TAPE inserted at all?
if let Some(tap) = self.tape.tap.as_mut() {
// we'll show if the TAP sound is audible
let audible = if self.audible_tape { '🔊' } else { '🔈' };
match tap {
Tap::Reader(..) if running =>
write!(info, " 🖭 {} ⏵", audible)?,
Tap::Writer(..) if running =>
write!(info, " 🖭 {} ⏺", audible)?,
tap => {
// TAPE is paused so we'll show some TAP block metadata
let mut rd = tap.try_reader_mut()?;
// `rd` when dropped will restore underlying file
// cursor position, so it's perfectly save to use it to
// read the metadata of the current chunk.
let chunk_no = rd.rewind_chunk()?;
let chunk_info = TapChunkInfo::try_from(rd.get_mut())?;
// restore cursor position
rd.done()?;
write!(info, " 🖭 {} {}: {}",
audible, chunk_no, chunk_info)?;
}
}
}
Ok(info)
}
Finally, you may now SAVE
and LOAD
programs and code with the TAP files.
But these games can load for so many minutes. I remember waiting patiently, fingers crossed, fearing the dread of the R Tape Loading Error
. Today we don’t have such a luxury, time flies differently, so it seems.
Let’s not dawdle then, and please follow me to the remaining part of this chapter.
For new capabilities, your EmulatorState
needs new properties:
#[derive(Default)]
struct EmulatorState {
// the TAPE recorder, maybe a tape is inside?
tape: Tape<File>,
// is the emulation paused?
paused: bool,
// do we want to run as fast as possible?
turbo: bool,
// do we want to auto accelerate and enable auto load?
flash_tape: bool,
// do we want to hear the tape signal?
audible_tape: bool,
}
The property paused
will determine if the emulation is paused or if it’s running. The turbo
property will control the TURBO mode - if it’s ON frames are run as fast as possible without any synchronization. Another flash_tape
property will control our new FLASH TAPE LOAD and SAVE feature. I’ll explain it a little bit later. For now, let’s focus on the TURBO mode and the ability to PAUSE your emulator.
For the TURBO mode, create a new method:
// run frames as fast as possible until a single frame duration passes
// in real-time or if the turbo state ends automatically
fn run_frames_accelerated(
&mut self,
time_sync: &mut ThreadSyncTimer
) -> Result<(FTs, bool)>
{
let mut sum: FTs = 0;
let mut state_changed = false;
while time_sync.check_frame_elapsed().is_none() {
let (cycles, schg) = self.run_frame()?;
sum += cycles;
if schg {
state_changed = true;
if !self.state.turbo {
break;
}
}
}
Ok((sum, state_changed))
}
We also need to slightly adjust the run_frame
method’s signature and its implementation. Additionally to the flag, it has returned previously, it’ll now return the number of T-states that our emulated CPU can grind per each frame. This is not really needed to implement the TURBO mode, but it can be used to benchmark the emulator’s performance. You might measure it by dividing the sum
returned from it by the wall time duration of its execution. So you can estimate how fast your emulated Spectrum can run in T-states / time unit.
Additionally, we’ll make the TURBO mode end automatically whenever the TAPE playback ends.
fn run_frame(&mut self) -> Result<(FTs, bool)> {
let mut state_changed = false;
// get the writer if the tape is inserted and is being recorded
if let Some(ref mut writer) = self.state.tape.recording_tap() {
//... ✂
}
// clean up the internal buffers of ULA so we won't append the
// EAR IN data to the previous frame's data
self.ula.ensure_next_frame();
// and we also need the timestamp of the beginning of a frame
let fts_start = self.ula.current_tstate();
// get the reader if the tape is inserted and is being played
if let Some(ref mut feeder) = self.state.tape.playing_tap() {
// check if any pulse is still left in the feeder
let mut feeder = feeder.peekable();
if feeder.peek().is_some() {
// feed EAR IN line with pulses from our pulse iterator
// only up to the end of a single frame
self.ula.feed_ear_in(&mut feeder, Some(1));
}
else {
// end of tape
info!("Auto STOP: End of TAPE");
self.tape.stop();
// always end turbo mode when the tape stops
self.state.turbo = false;
state_changed = true;
}
}
self.ula.execute_next_frame(&mut self.cpu);
let fts_delta = self.ula.current_tstate() - fts_start;
Ok((fts_delta, state_changed))
}
So the next logical step would be to apply some changes to the main emulator loop to actually handle the TURBO mode and the ability to PAUSE your Spectrum.
// we need to change its signature, so we may share the `sync` instance
fn synchronize_frame(sync: &mut ThreadSyncTimer) {
if let Err(missed) = sync.synchronize_thread_to_frame() {
debug!("*** paused for: {} frames ***", missed);
}
}
//... ✂
'main: while is_running() {
spectrum.update_keyboard( update_keys );
let (_, mut state_changed) = if spectrum.state.paused {
loop {
if !is_running() { break 'main; }
match get_user_input_request() {
Some(InputRequest::TogglePaused) => { break; }
Some(InputRequest::Exit) => { break 'main; }
_ => {}
}
// just rest a little bit, don't eat too much hosts' CPU
// cycles while paused
std::thread::sleep(std::time::Duration::from_millis(100));
}
spectrum.state.paused = false;
sync.restart();
(0, true)
}
else if spectrum.state.turbo {
spectrum.run_frames_accelerated(&mut sync)?
}
else {
spectrum.run_frame()?
};
if let Some(input) = get_user_input_request() {
match spectrum.update_on_user_request(input)? {
Some(action) => return Ok(action),
None => { state_changed = true; }
}
}
let (video_buffer, pitch) = acquire_video_buffer(width, height);
spectrum.render_video::<SpectrumPalRGB24>(
video_buffer, pitch, border);
update_display();
if state_changed {
if spectrum.state.turbo || spectrum.state.paused {
// we won't be rendering audio when in TURBO mode
// or when PAUSED
audio.pause();
}
else {
audio.play();
}
window.set_title(&spectrum.info()?);
}
if !spectrum.state.turbo && !spectrum.state.paused {
// no audio in TURBO mode or when PAUSED
spectrum.render_audio(blep);
// (3) render the BLEP frame as audio samples
produce_audio_frame(
audio.channels(), audio.frame_buffer(), &mut blep);
// somehow play the rendered buffer
audio.play_frame()?;
// (4) prepare the BLEP for the next frame.
blep.next_frame();
}
if !spectrum.state.turbo {
synchronize_frame(&mut sync);
}
}
Let’s not forget about TURBO and PAUSE features in the user input handler:
fn update_on_user_request(
&mut self,
input: InputRequest
) -> Result<Option<Action>>
{
//... ✂
ToggleTurbo => { self.state.turbo = !self.state.turbo; }
TogglePaused => { self.state.paused = true; }
//... ✂
}
When the TURBO mode is ON, it’ll call run_frames_accelerated
instead of run_frame
, and no audio will be produced. While the emulator is PAUSED
, it’ll run a simple inner loop, which just handles two conditions to either exit the main program loop or to resume from the paused state.
The TURBO mode can be changed by user Action
or by one of the run_
methods on certain conditions.
Speaking of conditions, we still have another feature to be taken care of: FLASH TAPE LOAD and SAVE.
First, let me describe how I imagine it would work.
When the feature is on, whenever the user requests data from the TAPE e.g. by typing the LOAD ""
command, the TAPE will automatically play. At the same time, the TURBO mode will turn on automatically.
Another condition triggering the TURBO mode would be if the user had started recording and then typed SAVE "name"
and pressed ENTER. TAPE pulses will start being emitted by Spectrum.
The conditions, to turn OFF the TURBO mode, would be either:
EAR IN
signal,The good news is, for our FLASH TAPE implementation, we only need to make some changes in run_frame
. However, I think it’s time to split this method into smaller functions because it starts to become unreadable. Not only by human programmers. The Rust optimizer doesn’t really like large function bodies.
So here’s our final run_frame
:
fn run_frame(&mut self) -> Result<(FTs, bool)> {
// for tracking an effective change
let (turbo, running) = (self.state.turbo, self.state.tape.running);
if !self.record_tape_from_mic_out()? &&
(self.state.flash_tape || self.state.turbo) {
self.auto_detect_load_from_tape()?;
}
// clean up the internal buffers of ULA so we won't append the
// EAR IN data to the previous frame's data
self.ula.ensure_next_frame();
// and we also need the timestamp of the beginning of a frame
let fts_start = self.ula.current_tstate();
if self.feed_ear_in_or_stop_tape()? && running {
// only report it when the tape was running before
info!("Auto STOP: End of TAPE");
}
self.ula.execute_next_frame(&mut self.cpu);
let fts_delta = self.ula.current_tstate() - fts_start;
let state_changed = running != self.state.tape.running ||
turbo != self.state.turbo;
Ok((fts_delta, state_changed))
}
…and methods that the new run_frame
uses:
// returns `Ok(is_recording)`
fn record_tape_from_mic_out(&mut self) -> Result<bool> {
// get the writer if the tape is inserted and is being recorded
if let Some(ref mut writer) = self.state.tape.recording_tap() {
// extract the MIC OUT state changes as a pulse iterator
let pulses_iter = self.ula.mic_out_pulse_iter();
// decode pulses as TAPE data and write it as a TAP chunk
let chunks = writer.write_pulses_as_tap_chunks(pulses_iter)?;
if chunks != 0 {
info!("Saved: {} TAP chunks", chunks);
}
if self.state.turbo || self.state.flash_tape {
// is the state of the pulse decoder idle?
self.state.turbo = !writer.get_ref().is_idle();
}
return Ok(true)
}
Ok(false)
}
The above function not only writes the decoded MIC OUT signal but also controls the state of the TURBO mode whenever the pulse decoder changes state from IDLE to any other. Thus if the flash_tape
is enabled, it speeds up the recording and returns to standard speed when the recording finishes.
Next is the part where we feed the EAR IN
line from the TAPE pulses. This method returns Ok(true)
if the TAPE has reached the end.
// returns `Ok(end_of_tape)`
fn feed_ear_in_or_stop_tape(&mut self) -> Result<bool> {
// get the reader if the tape is inserted and is being played
if let Some(ref mut feeder) = self.state.tape.playing_tap() {
// check if any pulse is still left in the feeder
let mut feeder = feeder.peekable();
if feeder.peek().is_some() {
// feed EAR IN line with pulses from our pulse iterator
// only up to the end of a single frame
self.ula.feed_ear_in(&mut feeder, Some(1));
}
else {
// end of tape
self.state.tape.stop();
// always end turbo mode when the tape stops
self.state.turbo = false;
return Ok(true)
}
}
Ok(false)
}
And last but not least is the function for detecting if the TAPE should play or stop playing.
// simple heuristics for detecting if spectrum needs some TAPE data
fn auto_detect_load_from_tape(&mut self) -> Result<()> {
let count = self.ula.read_ear_in_count();
if count != 0 {
// if turbo is on and the tape is playing
if self.state.turbo && self.state.tape.is_playing() {
const IDLE_THRESHOLD: u32 = 10;
// stop the tape and slow down
// if the EAR IN probing falls below the threshold
if count < IDLE_THRESHOLD {
self.state.tape.stop();
self.state.turbo = false;
}
}
// if flash loading is enabled and a tape isn't running
else if self.state.flash_tape && self.state.tape.is_inserted()
&& !self.state.tape.running
{
const PROBE_THRESHOLD: u32 = 1000;
// play the tape and speed up
// if the EAR IN probing exceeds the threshold
if count > PROBE_THRESHOLD {
self.state.tape.play()?;
self.state.turbo = true;
}
}
}
Ok(())
}
The upside of this implementation is that we don’t need to know anything about tape loading routines other than they eagerly probe the EAR IN line. So let’s define some experimentally derived thresholds of the probing count per frame. If the count is above the PROBE threshold, we assume the Spectrum software is waiting for the TAPE data. And if it drops below the IDLE threshold, the TAPE data is no longer needed.
The downside is that it may sometimes render some false-positive and false-negative results. But there is always plenty of room to improve this algorithm. The very first step would be to experiment with the threshold values.
You may also check out another implementation that is a more sophisticated version of this method. As a bonus, it can also detect if the ROM loading routine is being called and loads data instantly.
The SAVE detection mechanism is less complex, but it solely depends on the ability of the TAP writer to decode pulses.
As the final touch, you may update the info
method to show the status of the new features to the user.
fn info(&mut self) -> Result<String> {
let mut info = format!("ZX Spectrum {}k",
self.ula.memory_ref().ram_ref().len() / 1024);
if self.state.paused {
info.push_str(" ⏸ ");
}
else if self.state.turbo {
info.push_str(" 🏎️ ");
}
// is the TAPE running?
let running = self.state.tape.running;
// is there any TAPE inserted at all?
if let Some(tap) = self.state.tape.tap.as_mut() {
let flash = if self.state.flash_tape { '⚡' } else { ' ' };
// we'll show if the TAP sound is audible
let audible = if self.state.audible_tape { '🔊' } else { '🔈' };
match tap {
Tap::Reader(..) if running =>
write!(info, " 🖭{}{} ⏵", flash, audible)?,
Tap::Writer(..) if running =>
write!(info, " 🖭{}{} ⏺", flash, audible)?,
tap => {
// TAPE is paused so we'll show some TAP block metadata
let mut rd = tap.try_reader_mut()?;
// `rd` when dropped will restore underlying file
// cursor position, so it's perfectly save to use it to
// read the metadata of the current chunk.
let chunk_no = rd.rewind_chunk()?;
let chunk_info = TapChunkInfo::try_from(rd.get_mut())?;
// restore cursor position
rd.done()?;
write!(info, " 🖭{}{} {}: {}",
flash, audible, chunk_no, chunk_info)?;
}
}
}
Ok(info)
}
Aaaand it’s done. You may now enjoy in your emulator some of ZX Spectrum software that wasn’t written exclusively by you.
The example program using minifb and cpal, covering the scope of this tutorial can be run with:
cargo run --bin step3 --release -- resources/hskiing.tap
and type LOAD ""
to load the game.
To create a new TAP file just give a name of a file that doesn’t exist:
cargo run --bin step3 --release -- my_new.tap
and you may actually SAVE
your program this time, just select Tape -> Record from menu.
Back to index.