mirror of
https://github.com/TomHarte/CLK.git
synced 2024-11-26 08:49:37 +00:00
Merge pull request #447 from TomHarte/DiskIIWriting
Substantially improves Disk II emulation, including write support
This commit is contained in:
commit
d3c5e4267f
@ -25,6 +25,7 @@ DiskII::DiskII() :
|
||||
{
|
||||
drives_[0].set_sleep_observer(this);
|
||||
drives_[1].set_sleep_observer(this);
|
||||
drives_[active_drive_].set_event_delegate(this);
|
||||
}
|
||||
|
||||
void DiskII::set_control(Control control, bool on) {
|
||||
@ -36,14 +37,11 @@ void DiskII::set_control(Control control, bool on) {
|
||||
case Control::P3: stepper_mask_ = (stepper_mask_ & 0x7) | (on ? 0x8 : 0x0); break;
|
||||
|
||||
case Control::Motor:
|
||||
// TODO: does the motor control trigger both motors at once?
|
||||
drives_[0].set_motor_on(on);
|
||||
drives_[1].set_motor_on(on);
|
||||
break;
|
||||
motor_is_enabled_ = on;
|
||||
drives_[active_drive_].set_motor_on(on);
|
||||
return;
|
||||
}
|
||||
|
||||
// printf("%0x: Set control %d %s\n", stepper_mask_, control, on ? "on" : "off");
|
||||
|
||||
// If the stepper magnet selections have changed, and any is on, see how
|
||||
// that moves the head.
|
||||
if(previous_stepper_mask ^ stepper_mask_ && stepper_mask_) {
|
||||
@ -63,53 +61,53 @@ void DiskII::set_control(Control control, bool on) {
|
||||
}
|
||||
}
|
||||
|
||||
void DiskII::set_mode(Mode mode) {
|
||||
// printf("Set mode %d\n", mode);
|
||||
inputs_ = (inputs_ & ~input_mode) | ((mode == Mode::Write) ? input_mode : 0);
|
||||
set_controller_can_sleep();
|
||||
}
|
||||
|
||||
void DiskII::select_drive(int drive) {
|
||||
// printf("Select drive %d\n", drive);
|
||||
active_drive_ = drive & 1;
|
||||
if((drive&1) == active_drive_) return;
|
||||
|
||||
drives_[active_drive_].set_event_delegate(this);
|
||||
drives_[active_drive_^1].set_event_delegate(nullptr);
|
||||
}
|
||||
|
||||
void DiskII::set_data_register(uint8_t value) {
|
||||
// printf("Set data register (?)\n");
|
||||
inputs_ |= input_command;
|
||||
data_register_ = value;
|
||||
set_controller_can_sleep();
|
||||
}
|
||||
|
||||
uint8_t DiskII::get_shift_register() {
|
||||
// if(shift_register_ & 0x80) printf("[%02x] ", shift_register_);
|
||||
inputs_ &= ~input_command;
|
||||
set_controller_can_sleep();
|
||||
return shift_register_;
|
||||
drives_[active_drive_].set_motor_on(false);
|
||||
active_drive_ = drive & 1;
|
||||
drives_[active_drive_].set_motor_on(motor_is_enabled_);
|
||||
}
|
||||
|
||||
void DiskII::run_for(const Cycles cycles) {
|
||||
if(is_sleeping()) return;
|
||||
|
||||
int integer_cycles = cycles.as_int();
|
||||
|
||||
if(!controller_can_sleep_) {
|
||||
int integer_cycles = cycles.as_int();
|
||||
while(integer_cycles--) {
|
||||
const int address = (state_ & 0xf0) | inputs_ | ((shift_register_&0x80) >> 6);
|
||||
inputs_ |= input_flux;
|
||||
state_ = state_machine_[static_cast<std::size_t>(address)];
|
||||
switch(state_ & 0xf) {
|
||||
case 0x0: shift_register_ = 0; break; // clear
|
||||
default: shift_register_ = 0; break; // clear
|
||||
case 0x8: break; // nop
|
||||
|
||||
case 0x9: shift_register_ = static_cast<uint8_t>(shift_register_ << 1); break; // shift left, bringing in a zero
|
||||
case 0xd: shift_register_ = static_cast<uint8_t>((shift_register_ << 1) | 1); break; // shift left, bringing in a one
|
||||
case 0xb: shift_register_ = data_register_; break; // load
|
||||
|
||||
case 0xa: // shift right, bringing in write protected status
|
||||
shift_register_ = (shift_register_ >> 1) | (is_write_protected() ? 0x80 : 0x00);
|
||||
|
||||
// If the controller is in the sense write protect loop but the register will never change,
|
||||
// short circuit further work and return now.
|
||||
if(shift_register_ == is_write_protected() ? 0xff : 0x00) {
|
||||
if(!drive_is_sleeping_[0]) drives_[0].run_for(Cycles(integer_cycles));
|
||||
if(!drive_is_sleeping_[1]) drives_[1].run_for(Cycles(integer_cycles));
|
||||
set_controller_can_sleep();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
case 0xb: shift_register_ = data_input_; break; // load data register from data bus
|
||||
}
|
||||
|
||||
// Currently writing?
|
||||
if(inputs_&input_mode) {
|
||||
// state_ & 0x80 should be the current level sent to the disk;
|
||||
// therefore transitions in that bit should become flux transitions
|
||||
drives_[active_drive_].write_bit(!!((state_ ^ address) & 0x80));
|
||||
}
|
||||
|
||||
// TODO: surely there's a less heavyweight solution than this?
|
||||
@ -127,14 +125,23 @@ void DiskII::run_for(const Cycles cycles) {
|
||||
void DiskII::set_controller_can_sleep() {
|
||||
// Permit the controller to sleep if it's in sense write protect mode, and the shift register
|
||||
// has already filled with the result of shifting eight times.
|
||||
bool controller_could_sleep = controller_can_sleep_;
|
||||
controller_can_sleep_ =
|
||||
(inputs_ == (input_command | input_flux)) &&
|
||||
(shift_register_ == (is_write_protected() ? 0xff : 0x00));
|
||||
if(is_sleeping()) update_sleep_observer();
|
||||
(
|
||||
(inputs_ == input_flux) &&
|
||||
!motor_is_enabled_ &&
|
||||
!shift_register_
|
||||
) ||
|
||||
(
|
||||
(inputs_ == (input_command | input_flux)) &&
|
||||
(shift_register_ == (is_write_protected() ? 0xff : 0x00))
|
||||
);
|
||||
if(controller_could_sleep != controller_can_sleep_)
|
||||
update_sleep_observer();
|
||||
}
|
||||
|
||||
bool DiskII::is_write_protected() {
|
||||
return true;
|
||||
return !!(stepper_mask_ & 2) | drives_[active_drive_].get_is_read_only();
|
||||
}
|
||||
|
||||
void DiskII::set_state_machine(const std::vector<uint8_t> &state_machine) {
|
||||
@ -143,9 +150,9 @@ void DiskII::set_state_machine(const std::vector<uint8_t> &state_machine) {
|
||||
|
||||
state b0, state b2, state b3, pulse, Q7, Q6, shift, state b1
|
||||
|
||||
... and has the top nibble reflected. Beneath Apple Pro-DOS uses a
|
||||
different order and several of the online copies are reformatted
|
||||
into that order.
|
||||
... and has the top nibble of each value stored in the ROM reflected.
|
||||
Beneath Apple Pro-DOS uses a different order and several of the
|
||||
online copies are reformatted into that order.
|
||||
|
||||
So the code below remaps into Beneath Apple Pro-DOS order if the
|
||||
supplied state machine isn't already in that order.
|
||||
@ -202,15 +209,11 @@ bool DiskII::is_sleeping() {
|
||||
return controller_can_sleep_ && drive_is_sleeping_[0] && drive_is_sleeping_[1];
|
||||
}
|
||||
|
||||
void DiskII::set_register(int address, uint8_t value) {
|
||||
trigger_address(address, value);
|
||||
void DiskII::set_data_input(uint8_t input) {
|
||||
data_input_ = input;
|
||||
}
|
||||
|
||||
uint8_t DiskII::get_register(int address) {
|
||||
return trigger_address(address, 0xff);
|
||||
}
|
||||
|
||||
uint8_t DiskII::trigger_address(int address, uint8_t value) {
|
||||
int DiskII::read_address(int address) {
|
||||
switch(address & 0xf) {
|
||||
default:
|
||||
case 0x0: set_control(Control::P0, false); break;
|
||||
@ -222,19 +225,30 @@ uint8_t DiskII::trigger_address(int address, uint8_t value) {
|
||||
case 0x6: set_control(Control::P3, false); break;
|
||||
case 0x7: set_control(Control::P3, true); break;
|
||||
|
||||
case 0x8: set_control(Control::Motor, false); break;
|
||||
case 0x8:
|
||||
shift_register_ = 0;
|
||||
set_control(Control::Motor, false);
|
||||
break;
|
||||
case 0x9: set_control(Control::Motor, true); break;
|
||||
|
||||
case 0xa: select_drive(0); break;
|
||||
case 0xb: select_drive(1); break;
|
||||
|
||||
case 0xc: return get_shift_register();
|
||||
case 0xd: set_data_register(value); break;
|
||||
|
||||
case 0xe: set_mode(Mode::Read); break;
|
||||
case 0xf: set_mode(Mode::Write); break;
|
||||
case 0xc: inputs_ &= ~input_command; break;
|
||||
case 0xd: inputs_ |= input_command; break;
|
||||
case 0xe:
|
||||
if(inputs_ & input_mode)
|
||||
drives_[active_drive_].end_writing();
|
||||
inputs_ &= ~input_mode;
|
||||
break;
|
||||
case 0xf:
|
||||
if(!(inputs_ & input_mode))
|
||||
drives_[active_drive_].begin_writing(Storage::Time(1, 2045454), false);
|
||||
inputs_ |= input_mode;
|
||||
break;
|
||||
}
|
||||
return 0xff;
|
||||
set_controller_can_sleep();
|
||||
return (address & 1) ? 0xff : shift_register_;
|
||||
}
|
||||
|
||||
void DiskII::set_activity_observer(Activity::Observer *observer) {
|
||||
|
@ -33,15 +33,52 @@ class DiskII:
|
||||
public:
|
||||
DiskII();
|
||||
|
||||
void set_register(int address, uint8_t value);
|
||||
uint8_t get_register(int address);
|
||||
/// Sets the current external value of the data bus.
|
||||
void set_data_input(uint8_t input);
|
||||
|
||||
/*!
|
||||
Submits an access to address @c address.
|
||||
|
||||
@returns The 8-bit value loaded to the data bus by the DiskII if any;
|
||||
@c DidNotLoad otherwise.
|
||||
*/
|
||||
int read_address(int address);
|
||||
|
||||
/*!
|
||||
The value returned by @c read_address if accessing that address
|
||||
didn't cause the disk II to place anything onto the bus.
|
||||
*/
|
||||
const int DidNotLoad = -1;
|
||||
|
||||
/// Advances the controller by @c cycles.
|
||||
void run_for(const Cycles cycles);
|
||||
|
||||
/*!
|
||||
Supplies the image of the state machine (i.e. P6) ROM,
|
||||
which dictates how the Disk II will respond to input.
|
||||
|
||||
To reduce processing costs, some assumptions are made by
|
||||
the implementation as to the content of this ROM.
|
||||
Including:
|
||||
|
||||
* If Q6 is set and Q7 is reset, the controller is testing
|
||||
for write protect. If and when the shift register has
|
||||
become full with the state of the write protect value,
|
||||
no further processing is required.
|
||||
|
||||
* If both Q6 and Q7 are reset, the drive motor is disabled,
|
||||
and the shift register is all zeroes, no further processing
|
||||
is required.
|
||||
*/
|
||||
void set_state_machine(const std::vector<uint8_t> &);
|
||||
|
||||
/// Inserts @c disk into the drive @c drive.
|
||||
void set_disk(const std::shared_ptr<Storage::Disk::Disk> &disk, int drive);
|
||||
|
||||
// As per Sleeper.
|
||||
bool is_sleeping() override;
|
||||
|
||||
// The Disk II functions as a potential target for @c Activity::Sources.
|
||||
void set_activity_observer(Activity::Observer *observer);
|
||||
|
||||
private:
|
||||
@ -55,8 +92,6 @@ class DiskII:
|
||||
void set_control(Control control, bool on);
|
||||
void set_mode(Mode mode);
|
||||
void select_drive(int drive);
|
||||
void set_data_register(uint8_t value);
|
||||
uint8_t get_shift_register();
|
||||
|
||||
uint8_t trigger_address(int address, uint8_t value);
|
||||
void process_event(const Storage::Disk::Track::Event &event) override;
|
||||
@ -65,7 +100,6 @@ class DiskII:
|
||||
uint8_t state_ = 0;
|
||||
uint8_t inputs_ = 0;
|
||||
uint8_t shift_register_ = 0;
|
||||
uint8_t data_register_ = 0;
|
||||
|
||||
int stepper_mask_ = 0;
|
||||
int stepper_position_ = 0;
|
||||
@ -76,8 +110,11 @@ class DiskII:
|
||||
bool drive_is_sleeping_[2];
|
||||
bool controller_can_sleep_ = false;
|
||||
int active_drive_ = 0;
|
||||
bool motor_is_enabled_ = false;
|
||||
|
||||
void set_controller_can_sleep();
|
||||
|
||||
uint8_t data_input_ = 0;
|
||||
};
|
||||
|
||||
}
|
||||
|
@ -39,7 +39,9 @@ void BestEffortUpdater::update() {
|
||||
const int64_t integer_duration = std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count();
|
||||
if(integer_duration > 0) {
|
||||
if(delegate_) {
|
||||
const double duration = static_cast<double>(integer_duration) / 1e9;
|
||||
// Cap running at 1/5th of a second, to avoid doing a huge amount of work after any
|
||||
// brief system interruption.
|
||||
const double duration = std::min(static_cast<double>(integer_duration) / 1e9, 0.2);
|
||||
delegate_->update(this, duration, has_skipped_);
|
||||
}
|
||||
has_skipped_ = false;
|
||||
|
@ -38,7 +38,8 @@ class ConcreteMachine:
|
||||
public CPU::MOS6502::BusHandler,
|
||||
public Inputs::Keyboard,
|
||||
public AppleII::Machine,
|
||||
public Activity::Source {
|
||||
public Activity::Source,
|
||||
public AppleII::Card::Delegate {
|
||||
private:
|
||||
struct VideoBusHandler : public AppleII::Video::BusHandler {
|
||||
public:
|
||||
@ -65,9 +66,9 @@ class ConcreteMachine:
|
||||
void update_audio() {
|
||||
speaker_.run_for(audio_queue_, cycles_since_audio_update_.divide(Cycles(audio_divider)));
|
||||
}
|
||||
void update_cards() {
|
||||
for(const auto &card : cards_) {
|
||||
if(card) card->run_for(cycles_since_card_update_, stretched_cycles_since_card_update_);
|
||||
void update_just_in_time_cards() {
|
||||
for(const auto &card : just_in_time_cards_) {
|
||||
card->run_for(cycles_since_card_update_, stretched_cycles_since_card_update_);
|
||||
}
|
||||
cycles_since_card_update_ = 0;
|
||||
stretched_cycles_since_card_update_ = 0;
|
||||
@ -84,10 +85,42 @@ class ConcreteMachine:
|
||||
Cycles cycles_since_audio_update_;
|
||||
|
||||
ROMMachine::ROMFetcher rom_fetcher_;
|
||||
|
||||
// MARK: - Cards
|
||||
std::array<std::unique_ptr<AppleII::Card>, 7> cards_;
|
||||
Cycles cycles_since_card_update_;
|
||||
std::vector<AppleII::Card *> every_cycle_cards_;
|
||||
std::vector<AppleII::Card *> just_in_time_cards_;
|
||||
|
||||
int stretched_cycles_since_card_update_ = 0;
|
||||
|
||||
void install_card(std::size_t slot, AppleII::Card *card) {
|
||||
assert(slot >= 1 && slot < 8);
|
||||
cards_[slot - 1].reset(card);
|
||||
card->set_delegate(this);
|
||||
pick_card_messaging_group(card);
|
||||
}
|
||||
|
||||
bool is_every_cycle_card(AppleII::Card *card) {
|
||||
return !card->get_select_constraints();
|
||||
}
|
||||
|
||||
void pick_card_messaging_group(AppleII::Card *card) {
|
||||
const bool is_every_cycle = is_every_cycle_card(card);
|
||||
std::vector<AppleII::Card *> &intended = is_every_cycle ? every_cycle_cards_ : just_in_time_cards_;
|
||||
std::vector<AppleII::Card *> &undesired = is_every_cycle ? just_in_time_cards_ : every_cycle_cards_;
|
||||
|
||||
if(std::find(intended.begin(), intended.end(), card) != intended.end()) return;
|
||||
auto old_membership = std::find(undesired.begin(), undesired.end(), card);
|
||||
if(old_membership != undesired.end()) undesired.erase(old_membership);
|
||||
intended.push_back(card);
|
||||
}
|
||||
|
||||
void card_did_change_select_constraints(AppleII::Card *card) override {
|
||||
pick_card_messaging_group(card);
|
||||
}
|
||||
|
||||
// MARK: - Memory Map
|
||||
struct MemoryBlock {
|
||||
uint8_t *read_pointer = nullptr;
|
||||
uint8_t *write_pointer = nullptr;
|
||||
@ -174,6 +207,19 @@ class ConcreteMachine:
|
||||
++ cycles_since_card_update_;
|
||||
cycles_since_audio_update_ += Cycles(7);
|
||||
|
||||
// The Apple II has a slightly weird timing pattern: every 65th CPU cycle is stretched
|
||||
// by an extra 1/7th. That's because one cycle lasts 3.5 NTSC colour clocks, so after
|
||||
// 65 cycles a full line of 227.5 colour clocks have passed. But the high-rate binary
|
||||
// signal approximation that produces colour needs to be in phase, so a stretch of exactly
|
||||
// 0.5 further colour cycles is added. The video class handles that implicitly, but it
|
||||
// needs to be accumulated here for the audio.
|
||||
cycles_into_current_line_ = (cycles_into_current_line_ + 1) % 65;
|
||||
const bool is_stretched_cycle = !cycles_into_current_line_;
|
||||
if(is_stretched_cycle) {
|
||||
++ cycles_since_audio_update_;
|
||||
++ stretched_cycles_since_card_update_;
|
||||
}
|
||||
|
||||
/*
|
||||
There are five distinct zones of memory on an Apple II:
|
||||
|
||||
@ -192,8 +238,9 @@ class ConcreteMachine:
|
||||
}
|
||||
else if(address < 0xd000) block = nullptr;
|
||||
else if(address < 0xe000) {block = &memory_blocks_[2]; address -= 0xd000; }
|
||||
else {block = &memory_blocks_[3]; address -= 0xe000; }
|
||||
else { block = &memory_blocks_[3]; address -= 0xe000; }
|
||||
|
||||
bool has_updated_cards = false;
|
||||
if(block) {
|
||||
if(isReadOperation(operation)) *value = block->read_pointer[address];
|
||||
else if(block->write_pointer) block->write_pointer[address] = *value;
|
||||
@ -286,39 +333,60 @@ class ConcreteMachine:
|
||||
break;
|
||||
}
|
||||
|
||||
if(address >= 0xc100 && address < 0xc800) {
|
||||
/*
|
||||
Decode the area conventionally used by cards for ROMs:
|
||||
0xCn00 to 0xCnff: card n.
|
||||
*/
|
||||
const size_t card_number = (address - 0xc100) >> 8;
|
||||
if(cards_[card_number]) {
|
||||
update_cards();
|
||||
cards_[card_number]->perform_bus_operation(operation, address & 0xff, value);
|
||||
/*
|
||||
Communication with cards follows.
|
||||
*/
|
||||
|
||||
if(address >= 0xc090 && address < 0xc800) {
|
||||
// If this is a card access, figure out which card is at play before determining
|
||||
// the totality of who needs messaging.
|
||||
size_t card_number = 0;
|
||||
AppleII::Card::Select select = AppleII::Card::None;
|
||||
|
||||
if(address >= 0xc100) {
|
||||
/*
|
||||
Decode the area conventionally used by cards for ROMs:
|
||||
0xCn00 to 0xCnff: card n.
|
||||
*/
|
||||
card_number = (address - 0xc100) >> 8;
|
||||
select = AppleII::Card::Device;
|
||||
} else {
|
||||
/*
|
||||
Decode the area conventionally used by cards for registers:
|
||||
C0n0 to C0nF: card n - 8.
|
||||
*/
|
||||
card_number = (address - 0xc090) >> 4;
|
||||
select = AppleII::Card::IO;
|
||||
}
|
||||
} else if(address >= 0xc090 && address < 0xc100) {
|
||||
/*
|
||||
Decode the area conventionally used by cards for registers:
|
||||
C0n0 to C0nF: card n - 8.
|
||||
*/
|
||||
const size_t card_number = (address - 0xc090) >> 4;
|
||||
if(cards_[card_number]) {
|
||||
update_cards();
|
||||
cards_[card_number]->perform_bus_operation(operation, 0x100 | (address&0xf), value);
|
||||
|
||||
// If the selected card is a just-in-time card, update the just-in-time cards,
|
||||
// and then message it specifically.
|
||||
const bool is_read = isReadOperation(operation);
|
||||
AppleII::Card *const target = cards_[card_number].get();
|
||||
if(target && !is_every_cycle_card(target)) {
|
||||
update_just_in_time_cards();
|
||||
target->perform_bus_operation(select, is_read, address, value);
|
||||
}
|
||||
|
||||
// Update all the every-cycle cards regardless, but send them a ::None select if they're
|
||||
// not the one actually selected.
|
||||
for(const auto &card: every_cycle_cards_) {
|
||||
card->run_for(Cycles(1), is_stretched_cycle);
|
||||
card->perform_bus_operation(
|
||||
(card == target) ? select : AppleII::Card::None,
|
||||
is_read, address, value);
|
||||
}
|
||||
has_updated_cards = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The Apple II has a slightly weird timing pattern: every 65th CPU cycle is stretched
|
||||
// by an extra 1/7th. That's because one cycle lasts 3.5 NTSC colour clocks, so after
|
||||
// 65 cycles a full line of 227.5 colour clocks have passed. But the high-rate binary
|
||||
// signal approximation that produces colour needs to be in phase, so a stretch of exactly
|
||||
// 0.5 further colour cycles is added. The video class handles that implicitly, but it
|
||||
// needs to be accumulated here for the audio.
|
||||
cycles_into_current_line_ = (cycles_into_current_line_ + 1) % 65;
|
||||
if(!cycles_into_current_line_) {
|
||||
++ cycles_since_audio_update_;
|
||||
++ stretched_cycles_since_card_update_;
|
||||
if(!has_updated_cards && !every_cycle_cards_.empty()) {
|
||||
// Update all every-cycle cards and give them the cycle.
|
||||
const bool is_read = isReadOperation(operation);
|
||||
for(const auto &card: every_cycle_cards_) {
|
||||
card->run_for(Cycles(1), is_stretched_cycle);
|
||||
card->perform_bus_operation(AppleII::Card::None, is_read, address, value);
|
||||
}
|
||||
}
|
||||
|
||||
return Cycles(1);
|
||||
@ -327,7 +395,7 @@ class ConcreteMachine:
|
||||
void flush() {
|
||||
update_video();
|
||||
update_audio();
|
||||
update_cards();
|
||||
update_just_in_time_cards();
|
||||
audio_queue_.perform();
|
||||
}
|
||||
|
||||
@ -391,7 +459,8 @@ class ConcreteMachine:
|
||||
auto *const apple_target = dynamic_cast<const Target *>(target);
|
||||
|
||||
if(apple_target->disk_controller != Target::DiskController::None) {
|
||||
cards_[5].reset(new AppleII::DiskIICard(rom_fetcher_, apple_target->disk_controller == Target::DiskController::SixteenSector));
|
||||
// Apple recommended slot 6 for the (first) Disk II.
|
||||
install_card(6, new AppleII::DiskIICard(rom_fetcher_, apple_target->disk_controller == Target::DiskController::SixteenSector));
|
||||
}
|
||||
|
||||
rom_ = (apple_target->model == Target::Model::II) ? apple2_rom_ : apple2plus_rom_;
|
||||
|
@ -15,16 +15,97 @@
|
||||
|
||||
namespace AppleII {
|
||||
|
||||
/*!
|
||||
This provides a small subset of the interface offered to cards installed in
|
||||
an Apple II, oriented pragmatically around the cards that are implemented.
|
||||
|
||||
The main underlying rule is as it is elsewhere in the emulator: no
|
||||
_inaccurate_ simplifications — no provision of information that shouldn't
|
||||
actually commute, no interfaces that say they do one thing but which by both
|
||||
both sides are coupled through an unwritten understanding of abuse.
|
||||
|
||||
Special notes:
|
||||
|
||||
Devices that announce a select constraint, being interested in acting only
|
||||
when their IO or Device select is active will receive just-in-time @c run_for
|
||||
notifications, as well as being updated at the end of each of the Apple's
|
||||
@c run_for periods, prior to a @c flush.
|
||||
|
||||
Devices that do not announce a select constraint will prima facie receive a
|
||||
@c perform_bus_operation every cycle. They'll also receive a @c flush.
|
||||
It is **highly** recomended that such devices also implement @c Sleeper
|
||||
as they otherwise prima facie require a virtual method call every
|
||||
single cycle.
|
||||
*/
|
||||
class Card {
|
||||
public:
|
||||
/*! Advances time by @c cycles, of which @c stretches were stretched. */
|
||||
enum Select: int {
|
||||
None = 0, // No select line is active
|
||||
IO = 1 << 0, // IO select is active
|
||||
Device = 1 << 1, // Device select is active
|
||||
};
|
||||
|
||||
/*!
|
||||
Advances time by @c cycles, of which @c stretches were stretched.
|
||||
|
||||
This is posted only to cards that announced a select constraint. Cards with
|
||||
no constraints, that want to be informed of every machine cycle, will receive
|
||||
a call to perform_bus_operation every cycle and should use that for time keeping.
|
||||
*/
|
||||
virtual void run_for(Cycles half_cycles, int stretches) {}
|
||||
|
||||
/*! Performs a bus operation; the card is implicitly selected. */
|
||||
virtual void perform_bus_operation(CPU::MOS6502::BusOperation operation, uint16_t address, uint8_t *value) = 0;
|
||||
/// Requests a flush of any pending audio or video output.
|
||||
virtual void flush() {}
|
||||
|
||||
/*! Supplies a target for observers. */
|
||||
/*!
|
||||
Performs a bus operation.
|
||||
|
||||
@param select The state of the card's select lines: indicates whether the Apple II
|
||||
thinks this card should respond as though this were an IO access, a Device access,
|
||||
or it thinks that the card shouldn't respond.
|
||||
@param is_read @c true if this is a read cycle; @c false otherwise.
|
||||
@param address The current value of the address bus.
|
||||
@param value A pointer to the value of the data bus, not accounting input from cards.
|
||||
If this is a read cycle, the card is permitted to replace this value with the value
|
||||
output by the card, if any. If this is a write cycle, the card should only read
|
||||
this value.
|
||||
*/
|
||||
virtual void perform_bus_operation(Select select, bool is_read, uint16_t address, uint8_t *value) = 0;
|
||||
|
||||
/*!
|
||||
Returns the type of bus selects this card is actually interested in.
|
||||
As specified, the default is that cards will ask to receive perform_bus_operation
|
||||
only when their select lines are active.
|
||||
|
||||
There's a substantial caveat here: cards that register to receive @c None
|
||||
will receive a perform_bus_operation every cycle. To reduce the number of
|
||||
virtual method calls, they **will not** receive run_for. run_for will propagate
|
||||
only to cards that register for IO and/or Device accesses only.
|
||||
|
||||
|
||||
*/
|
||||
int get_select_constraints() {
|
||||
return select_constraints_;
|
||||
}
|
||||
|
||||
/*! Cards may supply a target for activity observation if desired. */
|
||||
virtual void set_activity_observer(Activity::Observer *observer) {}
|
||||
|
||||
struct Delegate {
|
||||
virtual void card_did_change_select_constraints(Card *card) = 0;
|
||||
};
|
||||
void set_delegate(Delegate *delegate) {
|
||||
delegate_ = delegate;
|
||||
}
|
||||
|
||||
protected:
|
||||
int select_constraints_ = IO | Device;
|
||||
Delegate *delegate_ = nullptr;
|
||||
void set_select_constraints(int constraints) {
|
||||
if(constraints == select_constraints_) return;
|
||||
select_constraints_ = constraints;
|
||||
if(delegate_) delegate_->card_did_change_select_constraints(this);
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
@ -19,21 +19,29 @@ DiskIICard::DiskIICard(const ROMMachine::ROMFetcher &rom_fetcher, bool is_16_sec
|
||||
});
|
||||
boot_ = std::move(*roms[0]);
|
||||
diskii_.set_state_machine(*roms[1]);
|
||||
set_select_constraints(None);
|
||||
diskii_.set_sleep_observer(this);
|
||||
}
|
||||
|
||||
void DiskIICard::perform_bus_operation(CPU::MOS6502::BusOperation operation, uint16_t address, uint8_t *value) {
|
||||
if(address < 0x100) {
|
||||
if(isReadOperation(operation)) *value = boot_[address];
|
||||
} else {
|
||||
if(isReadOperation(operation)) {
|
||||
*value = diskii_.get_register(address);
|
||||
} else {
|
||||
diskii_.set_register(address, *value);
|
||||
}
|
||||
void DiskIICard::perform_bus_operation(Select select, bool is_read, uint16_t address, uint8_t *value) {
|
||||
diskii_.set_data_input(*value);
|
||||
switch(select) {
|
||||
default: break;
|
||||
case IO: {
|
||||
const int disk_value = diskii_.read_address(address);
|
||||
if(is_read) {
|
||||
if(disk_value != diskii_.DidNotLoad)
|
||||
*value = static_cast<uint8_t>(disk_value);
|
||||
}
|
||||
} break;
|
||||
case Device:
|
||||
if(is_read) *value = boot_[address & 0xff];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void DiskIICard::run_for(Cycles cycles, int stretches) {
|
||||
if(diskii_is_sleeping_) return;
|
||||
diskii_.run_for(Cycles(cycles.as_int() * 2));
|
||||
}
|
||||
|
||||
@ -44,3 +52,8 @@ void DiskIICard::set_disk(const std::shared_ptr<Storage::Disk::Disk> &disk, int
|
||||
void DiskIICard::set_activity_observer(Activity::Observer *observer) {
|
||||
diskii_.set_activity_observer(observer);
|
||||
}
|
||||
|
||||
void DiskIICard::set_component_is_sleeping(Sleeper *component, bool is_sleeping) {
|
||||
diskii_is_sleeping_ = is_sleeping;
|
||||
set_select_constraints(is_sleeping ? (IO | Device) : 0);
|
||||
}
|
||||
|
@ -14,6 +14,7 @@
|
||||
|
||||
#include "../../Components/DiskII/DiskII.hpp"
|
||||
#include "../../Storage/Disk/Disk.hpp"
|
||||
#include "../../ClockReceiver/Sleeper.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
@ -21,19 +22,22 @@
|
||||
|
||||
namespace AppleII {
|
||||
|
||||
class DiskIICard: public Card {
|
||||
class DiskIICard: public Card, public Sleeper::SleepObserver {
|
||||
public:
|
||||
DiskIICard(const ROMMachine::ROMFetcher &rom_fetcher, bool is_16_sector);
|
||||
|
||||
void perform_bus_operation(CPU::MOS6502::BusOperation operation, uint16_t address, uint8_t *value) override;
|
||||
void perform_bus_operation(Select select, bool is_read, uint16_t address, uint8_t *value) override;
|
||||
void run_for(Cycles cycles, int stretches) override;
|
||||
|
||||
void set_activity_observer(Activity::Observer *observer) override;
|
||||
|
||||
void set_disk(const std::shared_ptr<Storage::Disk::Disk> &disk, int drive);
|
||||
|
||||
private:
|
||||
void set_component_is_sleeping(Sleeper *component, bool is_sleeping) override;
|
||||
std::vector<uint8_t> boot_;
|
||||
Apple::DiskII diskii_;
|
||||
bool diskii_is_sleeping_ = false;
|
||||
};
|
||||
|
||||
}
|
||||
|
@ -201,6 +201,7 @@ template <Analyser::Static::Oric::Target::DiskInterface disk_interface> class Co
|
||||
public Utility::TypeRecipient,
|
||||
public Storage::Tape::BinaryTapePlayer::Delegate,
|
||||
public Microdisc::Delegate,
|
||||
public Sleeper::SleepObserver,
|
||||
public Activity::Source,
|
||||
public Machine {
|
||||
|
||||
@ -216,6 +217,10 @@ template <Analyser::Static::Oric::Target::DiskInterface disk_interface> class Co
|
||||
via_port_handler_.set_interrupt_delegate(this);
|
||||
tape_player_.set_delegate(this);
|
||||
Memory::Fuzz(ram_, sizeof(ram_));
|
||||
|
||||
if(disk_interface == Analyser::Static::Oric::Target::DiskInterface::Pravetz) {
|
||||
diskii_.set_sleep_observer(this);
|
||||
}
|
||||
}
|
||||
|
||||
~ConcreteMachine() {
|
||||
@ -405,9 +410,8 @@ template <Analyser::Static::Oric::Target::DiskInterface disk_interface> class Co
|
||||
}
|
||||
}
|
||||
} else {
|
||||
update_diskii();
|
||||
if(isReadOperation(operation)) *value = diskii_.get_register(address);
|
||||
else diskii_.set_register(address, *value);
|
||||
const int disk_value = diskii_.read_address(address);
|
||||
if(isReadOperation(operation) && disk_value != diskii_.DidNotLoad) *value = static_cast<uint8_t>(disk_value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -436,8 +440,15 @@ template <Analyser::Static::Oric::Target::DiskInterface disk_interface> class Co
|
||||
tape_player_.run_for(Cycles(1));
|
||||
switch(disk_interface) {
|
||||
default: break;
|
||||
case Analyser::Static::Oric::Target::DiskInterface::Microdisc: microdisc_.run_for(Cycles(8)); break;
|
||||
case Analyser::Static::Oric::Target::DiskInterface::Pravetz: cycles_since_diskii_update_ += 2; break;
|
||||
case Analyser::Static::Oric::Target::DiskInterface::Microdisc:
|
||||
microdisc_.run_for(Cycles(8));
|
||||
break;
|
||||
case Analyser::Static::Oric::Target::DiskInterface::Pravetz:
|
||||
if(!diskii_is_sleeping_) {
|
||||
diskii_.set_data_input(*value);
|
||||
diskii_.run_for(Cycles(2));
|
||||
}
|
||||
break;
|
||||
}
|
||||
cycles_since_video_update_++;
|
||||
return Cycles(1);
|
||||
@ -446,7 +457,6 @@ template <Analyser::Static::Oric::Target::DiskInterface disk_interface> class Co
|
||||
forceinline void flush() {
|
||||
update_video();
|
||||
via_port_handler_.flush();
|
||||
if(disk_interface == Analyser::Static::Oric::Target::DiskInterface::Pravetz) update_diskii();
|
||||
}
|
||||
|
||||
// to satisfy CRTMachine::Machine
|
||||
@ -561,6 +571,10 @@ template <Analyser::Static::Oric::Target::DiskInterface disk_interface> class Co
|
||||
}
|
||||
}
|
||||
|
||||
void set_component_is_sleeping(Sleeper *component, bool is_sleeping) override final {
|
||||
diskii_is_sleeping_ = diskii_.is_sleeping();
|
||||
}
|
||||
|
||||
private:
|
||||
const uint16_t basic_invisible_ram_top_ = 0xffff;
|
||||
const uint16_t basic_visible_ram_top_ = 0xbfff;
|
||||
@ -605,10 +619,7 @@ template <Analyser::Static::Oric::Target::DiskInterface disk_interface> class Co
|
||||
Apple::DiskII diskii_;
|
||||
std::vector<uint8_t> pravetz_rom_;
|
||||
std::size_t pravetz_rom_base_pointer_ = 0;
|
||||
Cycles cycles_since_diskii_update_;
|
||||
void update_diskii() {
|
||||
diskii_.run_for(cycles_since_diskii_update_.flush());
|
||||
}
|
||||
bool diskii_is_sleeping_ = false;
|
||||
|
||||
// Overlay RAM
|
||||
uint16_t ram_top_ = basic_visible_ram_top_;
|
||||
|
@ -9,7 +9,9 @@
|
||||
#include "AppleDSK.hpp"
|
||||
|
||||
#include "../../Track/PCMTrack.hpp"
|
||||
#include "../../Track/TrackSerialiser.hpp"
|
||||
#include "../../Encodings/AppleGCR/Encoder.hpp"
|
||||
#include "../../Encodings/AppleGCR/SegmentParser.hpp"
|
||||
|
||||
using namespace Storage::Disk;
|
||||
|
||||
@ -42,10 +44,28 @@ HeadPosition AppleDSK::get_maximum_head_position() {
|
||||
return HeadPosition(number_of_tracks);
|
||||
}
|
||||
|
||||
bool AppleDSK::get_is_read_only() {
|
||||
return file_.get_is_known_read_only();
|
||||
}
|
||||
|
||||
long AppleDSK::file_offset(Track::Address address) {
|
||||
return address.position.as_int() * bytes_per_sector * sectors_per_track_;
|
||||
}
|
||||
|
||||
size_t AppleDSK::logical_sector_for_physical_sector(size_t physical) {
|
||||
// DOS and Pro DOS interleave sectors on disk, and they're represented in a disk
|
||||
// image in physical order rather than logical.
|
||||
if(physical == 15) return 15;
|
||||
return (physical * (is_prodos_ ? 8 : 7)) % 15;
|
||||
}
|
||||
|
||||
std::shared_ptr<Track> AppleDSK::get_track_at_position(Track::Address address) {
|
||||
const long file_offset = address.position.as_int() * bytes_per_sector * sectors_per_track_;
|
||||
file_.seek(file_offset, SEEK_SET);
|
||||
const std::vector<uint8_t> track_data = file_.read(static_cast<size_t>(bytes_per_sector * sectors_per_track_));
|
||||
std::vector<uint8_t> track_data;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock_guard(file_.get_file_access_mutex());
|
||||
file_.seek(file_offset(address), SEEK_SET);
|
||||
track_data = file_.read(static_cast<size_t>(bytes_per_sector * sectors_per_track_));
|
||||
}
|
||||
|
||||
Storage::Disk::PCMSegment segment;
|
||||
const uint8_t track = static_cast<uint8_t>(address.position.as_int());
|
||||
@ -53,17 +73,11 @@ std::shared_ptr<Track> AppleDSK::get_track_at_position(Track::Address address) {
|
||||
// In either case below, the code aims for exactly 50,000 bits per track.
|
||||
if(sectors_per_track_ == 16) {
|
||||
// Write the sectors.
|
||||
std::size_t sector_number_ = 0;
|
||||
for(uint8_t c = 0; c < 16; ++c) {
|
||||
segment += Encodings::AppleGCR::six_and_two_sync(10);
|
||||
segment += Encodings::AppleGCR::header(254, track, c);
|
||||
segment += Encodings::AppleGCR::six_and_two_sync(10);
|
||||
segment += Encodings::AppleGCR::six_and_two_data(&track_data[sector_number_ * 256]);
|
||||
|
||||
// DOS and Pro DOS interleave sectors on disk, and they're represented in a disk
|
||||
// image in physical order rather than logical. So that skew needs to be applied here.
|
||||
sector_number_ += is_prodos_ ? 8 : 7;
|
||||
if(sector_number_ > 0xf) sector_number_ %= 15;
|
||||
segment += Encodings::AppleGCR::six_and_two_data(&track_data[logical_sector_for_physical_sector(c) * 256]);
|
||||
}
|
||||
|
||||
// Pad if necessary.
|
||||
@ -71,8 +85,33 @@ std::shared_ptr<Track> AppleDSK::get_track_at_position(Track::Address address) {
|
||||
segment += Encodings::AppleGCR::six_and_two_sync((50000 - segment.number_of_bits) / 10);
|
||||
}
|
||||
} else {
|
||||
|
||||
// TODO: 5 and 3, 13-sector format. If DSK actually supports it?
|
||||
}
|
||||
|
||||
return std::make_shared<PCMTrack>(segment);
|
||||
}
|
||||
|
||||
void AppleDSK::set_tracks(const std::map<Track::Address, std::shared_ptr<Track>> &tracks) {
|
||||
std::map<Track::Address, std::vector<uint8_t>> tracks_by_address;
|
||||
for(const auto &pair: tracks) {
|
||||
// Decode the track.
|
||||
auto sector_map = Storage::Encodings::AppleGCR::sectors_from_segment(
|
||||
Storage::Disk::track_serialisation(*pair.second, Storage::Time(1, 50000)));
|
||||
|
||||
// Rearrange sectors into Apple DOS or Pro-DOS order.
|
||||
std::vector<uint8_t> track_contents(static_cast<size_t>(bytes_per_sector * sectors_per_track_));
|
||||
for(const auto §or_pair: sector_map) {
|
||||
const size_t target_address = logical_sector_for_physical_sector(sector_pair.second.address.sector);
|
||||
memcpy(&track_contents[target_address*256], sector_pair.second.data.data(), bytes_per_sector);
|
||||
}
|
||||
|
||||
// Store for later.
|
||||
tracks_by_address[pair.first] = std::move(track_contents);
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock_guard(file_.get_file_access_mutex());
|
||||
for(const auto &pair: tracks_by_address) {
|
||||
file_.seek(file_offset(pair.first), SEEK_SET);
|
||||
file_.write(pair.second);
|
||||
}
|
||||
}
|
||||
|
@ -31,14 +31,19 @@ class AppleDSK: public DiskImage {
|
||||
*/
|
||||
AppleDSK(const std::string &file_name);
|
||||
|
||||
// implemented to satisfy @c Disk
|
||||
// Implemented to satisfy @c Disk.
|
||||
HeadPosition get_maximum_head_position() override;
|
||||
std::shared_ptr<Track> get_track_at_position(Track::Address address) override;
|
||||
void set_tracks(const std::map<Track::Address, std::shared_ptr<Track>> &tracks) override;
|
||||
bool get_is_read_only() override;
|
||||
|
||||
private:
|
||||
Storage::FileHolder file_;
|
||||
int sectors_per_track_ = 16;
|
||||
bool is_prodos_ = false;
|
||||
|
||||
long file_offset(Track::Address address);
|
||||
size_t logical_sector_for_physical_sector(size_t physical);
|
||||
};
|
||||
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ PCMPatchedTrack::PCMPatchedTrack(const PCMPatchedTrack &original) {
|
||||
active_period_ = periods_.begin();
|
||||
}
|
||||
|
||||
Track *PCMPatchedTrack::clone() {
|
||||
Track *PCMPatchedTrack::clone() const {
|
||||
return new PCMPatchedTrack(*this);
|
||||
}
|
||||
|
||||
@ -202,7 +202,8 @@ Storage::Time PCMPatchedTrack::seek_to(const Time &time_since_index_hole) {
|
||||
else
|
||||
current_time_ = underlying_track_->seek_to(time_since_index_hole);
|
||||
|
||||
assert(current_time_ <= time_since_index_hole);
|
||||
// The assert below is disabled as it assumes too much about total precision.
|
||||
// assert(current_time_ <= time_since_index_hole);
|
||||
return current_time_;
|
||||
}
|
||||
|
||||
|
@ -43,9 +43,9 @@ class PCMPatchedTrack: public Track {
|
||||
void add_segment(const Time &start_time, const PCMSegment &segment, bool clamp_to_index_hole);
|
||||
|
||||
// To satisfy Storage::Disk::Track
|
||||
Event get_next_event();
|
||||
Time seek_to(const Time &time_since_index_hole);
|
||||
Track *clone();
|
||||
Event get_next_event() override;
|
||||
Time seek_to(const Time &time_since_index_hole) override;
|
||||
Track *clone() const override;
|
||||
|
||||
private:
|
||||
std::shared_ptr<Track> underlying_track_;
|
||||
|
@ -46,7 +46,7 @@ PCMTrack::PCMTrack(const PCMTrack &original) : PCMTrack() {
|
||||
segment_event_sources_ = original.segment_event_sources_;
|
||||
}
|
||||
|
||||
Track *PCMTrack::clone() {
|
||||
Track *PCMTrack::clone() const {
|
||||
return new PCMTrack(*this);
|
||||
}
|
||||
|
||||
|
@ -42,9 +42,9 @@ class PCMTrack: public Track {
|
||||
PCMTrack(const PCMTrack &);
|
||||
|
||||
// as per @c Track
|
||||
Event get_next_event();
|
||||
Time seek_to(const Time &time_since_index_hole);
|
||||
Track *clone();
|
||||
Event get_next_event() override;
|
||||
Time seek_to(const Time &time_since_index_hole) override;
|
||||
Track *clone() const override;
|
||||
|
||||
private:
|
||||
// storage for the segments that describe this track
|
||||
|
@ -114,7 +114,7 @@ class Track {
|
||||
/*!
|
||||
The virtual copy constructor pattern; returns a copy of the Track.
|
||||
*/
|
||||
virtual Track *clone() = 0;
|
||||
virtual Track *clone() const = 0;
|
||||
};
|
||||
|
||||
}
|
||||
|
@ -8,11 +8,14 @@
|
||||
|
||||
#include "TrackSerialiser.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
// TODO: if this is a PCMTrack with only one segment and that segment's bit rate is within tolerance,
|
||||
// just return a copy of that segment.
|
||||
Storage::Disk::PCMSegment Storage::Disk::track_serialisation(Track &track, Time length_of_a_bit) {
|
||||
Storage::Disk::PCMSegment Storage::Disk::track_serialisation(const Track &track, Time length_of_a_bit) {
|
||||
unsigned int history_size = 16;
|
||||
DigitalPhaseLockedLoop pll(100, history_size);
|
||||
std::unique_ptr<Track> track_copy(track.clone());
|
||||
|
||||
struct ResultAccumulator: public DigitalPhaseLockedLoop::Delegate {
|
||||
PCMSegment result;
|
||||
@ -29,25 +32,25 @@ Storage::Disk::PCMSegment Storage::Disk::track_serialisation(Track &track, Time
|
||||
length_multiplier.simplify();
|
||||
|
||||
// start at the index hole
|
||||
track.seek_to(Time(0));
|
||||
track_copy->seek_to(Time(0));
|
||||
|
||||
// grab events until the next index hole
|
||||
Time time_error = Time(0);
|
||||
while(true) {
|
||||
Track::Event next_event = track.get_next_event();
|
||||
Track::Event next_event = track_copy->get_next_event();
|
||||
if(next_event.type == Track::Event::IndexHole) break;
|
||||
|
||||
Time extended_length = next_event.length * length_multiplier + time_error;
|
||||
time_error.clock_rate = extended_length.clock_rate;
|
||||
time_error.length = extended_length.length % extended_length.clock_rate;
|
||||
pll.run_for(Cycles(extended_length.get<int>()));
|
||||
pll.run_for(Cycles(static_cast<int>(extended_length.get<int64_t>())));
|
||||
pll.add_pulse();
|
||||
|
||||
// If the PLL is now sufficiently primed, restart, and start recording bits this time.
|
||||
if(history_size) {
|
||||
history_size--;
|
||||
if(!history_size) {
|
||||
track.seek_to(Time(0));
|
||||
track_copy->seek_to(Time(0));
|
||||
time_error.set_zero();
|
||||
pll.set_delegate(&result_accumulator);
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ namespace Disk {
|
||||
@param length_of_a_bit The expected length of a single bit, as a proportion of the
|
||||
track length.
|
||||
*/
|
||||
PCMSegment track_serialisation(Track &track, Time length_of_a_bit);
|
||||
PCMSegment track_serialisation(const Track &track, Time length_of_a_bit);
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,6 @@ Storage::Time UnformattedTrack::seek_to(const Time &time_since_index_hole) {
|
||||
return Time(0);
|
||||
}
|
||||
|
||||
Track *UnformattedTrack::clone() {
|
||||
Track *UnformattedTrack::clone() const {
|
||||
return new UnformattedTrack;
|
||||
}
|
||||
|
@ -19,9 +19,9 @@ namespace Disk {
|
||||
*/
|
||||
class UnformattedTrack: public Track {
|
||||
public:
|
||||
Event get_next_event();
|
||||
Time seek_to(const Time &time_since_index_hole);
|
||||
Track *clone();
|
||||
Event get_next_event() override;
|
||||
Time seek_to(const Time &time_since_index_hole) override;
|
||||
Track *clone() const override;
|
||||
};
|
||||
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user