2016-09-26 01:24:16 +00:00
|
|
|
//
|
|
|
|
// Drive.cpp
|
|
|
|
// Clock Signal
|
|
|
|
//
|
|
|
|
// Created by Thomas Harte on 25/09/2016.
|
2018-05-13 19:19:52 +00:00
|
|
|
// Copyright 2016 Thomas Harte. All rights reserved.
|
2016-09-26 01:24:16 +00:00
|
|
|
//
|
|
|
|
|
|
|
|
#include "Drive.hpp"
|
2017-09-10 21:33:01 +00:00
|
|
|
|
2017-11-10 03:04:49 +00:00
|
|
|
#include "Track/UnformattedTrack.hpp"
|
2017-09-10 21:33:01 +00:00
|
|
|
|
2016-09-26 01:24:16 +00:00
|
|
|
#include <algorithm>
|
2017-09-10 21:33:01 +00:00
|
|
|
#include <cassert>
|
2018-05-15 00:01:20 +00:00
|
|
|
#include <cmath>
|
|
|
|
#include <chrono>
|
|
|
|
#include <random>
|
2016-09-26 01:24:16 +00:00
|
|
|
|
|
|
|
using namespace Storage::Disk;
|
|
|
|
|
2017-10-07 01:45:12 +00:00
|
|
|
Drive::Drive(unsigned int input_clock_rate, int revolutions_per_minute, int number_of_heads):
|
2017-09-10 18:43:20 +00:00
|
|
|
Storage::TimedEventLoop(input_clock_rate),
|
2017-09-16 01:18:36 +00:00
|
|
|
rotational_multiplier_(60, revolutions_per_minute),
|
2018-05-15 00:03:32 +00:00
|
|
|
available_heads_(number_of_heads) {
|
2018-04-25 03:16:14 +00:00
|
|
|
rotational_multiplier_.simplify();
|
2018-05-15 00:01:20 +00:00
|
|
|
|
|
|
|
const auto seed = static_cast<std::default_random_engine::result_type>(std::chrono::system_clock::now().time_since_epoch().count());
|
|
|
|
std::default_random_engine randomiser(seed);
|
|
|
|
|
|
|
|
// Get at least 64 bits of random information; rounding is likey to give this a slight bias.
|
|
|
|
random_source_ = 0;
|
|
|
|
auto half_range = (randomiser.max() - randomiser.min()) / 2;
|
|
|
|
for(int bit = 0; bit < 64; ++bit) {
|
|
|
|
random_source_ <<= 1;
|
|
|
|
random_source_ |= ((randomiser() - randomiser.min()) >= half_range) ? 1 : 0;
|
|
|
|
}
|
2017-09-10 18:43:20 +00:00
|
|
|
}
|
2016-09-26 01:24:16 +00:00
|
|
|
|
2019-06-05 01:41:54 +00:00
|
|
|
Drive::Drive(unsigned int input_clock_rate, int number_of_heads) : Drive(input_clock_rate, 300, number_of_heads) {}
|
|
|
|
|
|
|
|
void Drive::set_rotation_speed(float revolutions_per_minute) {
|
|
|
|
// TODO: probably I should look into
|
|
|
|
// whether doing all this with quotients is really a good idea.
|
|
|
|
rotational_multiplier_ = Time(60.0f / revolutions_per_minute);
|
|
|
|
}
|
|
|
|
|
2017-10-07 23:14:18 +00:00
|
|
|
Drive::~Drive() {
|
|
|
|
if(disk_) disk_->flush_tracks();
|
|
|
|
}
|
|
|
|
|
2017-03-26 18:34:47 +00:00
|
|
|
void Drive::set_disk(const std::shared_ptr<Disk> &disk) {
|
2017-10-07 23:14:18 +00:00
|
|
|
if(disk_) disk_->flush_tracks();
|
2016-12-03 16:59:28 +00:00
|
|
|
disk_ = disk;
|
2017-08-20 14:55:08 +00:00
|
|
|
has_disk_ = !!disk_;
|
2016-12-26 19:24:33 +00:00
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
invalidate_track();
|
2018-05-28 03:17:06 +00:00
|
|
|
update_clocking_observer();
|
2016-09-26 01:24:16 +00:00
|
|
|
}
|
|
|
|
|
2017-03-26 18:34:47 +00:00
|
|
|
bool Drive::has_disk() {
|
2017-08-20 14:55:08 +00:00
|
|
|
return has_disk_;
|
2016-09-26 01:24:16 +00:00
|
|
|
}
|
|
|
|
|
2018-05-28 03:17:06 +00:00
|
|
|
ClockingHint::Preference Drive::preferred_clocking() {
|
|
|
|
return (!motor_is_on_ || !has_disk_) ? ClockingHint::Preference::None : ClockingHint::Preference::JustInTime;
|
2017-08-20 15:54:54 +00:00
|
|
|
}
|
|
|
|
|
2017-03-26 18:34:47 +00:00
|
|
|
bool Drive::get_is_track_zero() {
|
2018-05-07 03:17:36 +00:00
|
|
|
return head_position_ == HeadPosition(0);
|
2016-09-26 01:24:16 +00:00
|
|
|
}
|
|
|
|
|
2018-05-07 03:17:36 +00:00
|
|
|
void Drive::step(HeadPosition offset) {
|
|
|
|
HeadPosition old_head_position = head_position_;
|
|
|
|
head_position_ += offset;
|
2018-05-11 01:58:14 +00:00
|
|
|
if(head_position_ < HeadPosition(0)) {
|
|
|
|
head_position_ = HeadPosition(0);
|
|
|
|
if(observer_) observer_->announce_drive_event(drive_name_, Activity::Observer::DriveEvent::StepBelowZero);
|
2018-05-12 01:44:08 +00:00
|
|
|
} else {
|
|
|
|
if(observer_) observer_->announce_drive_event(drive_name_, Activity::Observer::DriveEvent::StepNormal);
|
2018-05-11 01:58:14 +00:00
|
|
|
}
|
2017-09-10 18:43:20 +00:00
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
// If the head moved, flush the old track.
|
|
|
|
if(head_position_ != old_head_position) {
|
|
|
|
track_ = nullptr;
|
|
|
|
}
|
2019-06-05 01:41:54 +00:00
|
|
|
|
|
|
|
// Allow a subclass to react, if desired.
|
|
|
|
did_step(head_position_);
|
2017-09-10 21:33:01 +00:00
|
|
|
}
|
|
|
|
|
2018-06-09 16:51:53 +00:00
|
|
|
std::shared_ptr<Track> Drive::step_to(HeadPosition offset) {
|
|
|
|
HeadPosition old_head_position = head_position_;
|
|
|
|
head_position_ = std::max(offset, HeadPosition(0));
|
|
|
|
|
|
|
|
if(head_position_ != old_head_position) {
|
|
|
|
track_ = nullptr;
|
|
|
|
setup_track();
|
|
|
|
}
|
|
|
|
|
|
|
|
return track_;
|
|
|
|
}
|
|
|
|
|
2017-10-07 01:45:12 +00:00
|
|
|
void Drive::set_head(int head) {
|
2017-09-16 01:18:36 +00:00
|
|
|
head = std::min(head, available_heads_ - 1);
|
2017-09-10 21:33:01 +00:00
|
|
|
if(head != head_) {
|
|
|
|
head_ = head;
|
2017-09-10 18:43:20 +00:00
|
|
|
track_ = nullptr;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-06-05 01:41:54 +00:00
|
|
|
int Drive::get_head_count() {
|
|
|
|
return available_heads_;
|
|
|
|
}
|
|
|
|
|
2019-06-06 22:32:11 +00:00
|
|
|
bool Drive::get_tachometer() {
|
2019-06-07 01:36:19 +00:00
|
|
|
// I have made a guess here that the tachometer is a symmetric square wave;
|
|
|
|
// if that is correct then around 60 beats per rotation appears to be correct
|
|
|
|
// to proceed beyond the speed checks I've so far uncovered.
|
|
|
|
const float ticks_per_rotation = 60.0f; // 56 was too low; 64 too high.
|
|
|
|
return int(get_rotation() * 2.0f * ticks_per_rotation) & 1;
|
2019-06-06 22:32:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
float Drive::get_rotation() {
|
|
|
|
return get_time_into_track().get<float>();
|
|
|
|
}
|
|
|
|
|
2017-09-10 18:43:20 +00:00
|
|
|
Storage::Time Drive::get_time_into_track() {
|
|
|
|
// `result` will initially be amount of time since the index hole was seen as a
|
|
|
|
// proportion of a second; convert it into proportion of a rotation, simplify and return.
|
2017-10-22 01:00:40 +00:00
|
|
|
Time result(cycles_since_index_hole_, static_cast<int>(get_input_clock_rate()));
|
2017-09-10 18:43:20 +00:00
|
|
|
result /= rotational_multiplier_;
|
|
|
|
result.simplify();
|
2018-04-25 02:44:45 +00:00
|
|
|
// assert(result <= Time(1));
|
2017-09-10 18:43:20 +00:00
|
|
|
return result;
|
2016-09-26 01:24:16 +00:00
|
|
|
}
|
|
|
|
|
2017-03-26 18:34:47 +00:00
|
|
|
bool Drive::get_is_read_only() {
|
2016-12-25 03:11:31 +00:00
|
|
|
if(disk_) return disk_->get_is_read_only();
|
2017-08-13 15:50:49 +00:00
|
|
|
return true;
|
2016-12-25 03:11:31 +00:00
|
|
|
}
|
|
|
|
|
2017-08-09 01:15:56 +00:00
|
|
|
bool Drive::get_is_ready() {
|
2017-09-12 02:27:50 +00:00
|
|
|
return ready_index_count_ == 2;
|
2017-09-10 21:33:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
void Drive::set_motor_on(bool motor_is_on) {
|
2018-08-04 01:11:22 +00:00
|
|
|
if(motor_is_on_ != motor_is_on) {
|
|
|
|
motor_is_on_ = motor_is_on;
|
|
|
|
|
|
|
|
if(observer_) {
|
|
|
|
observer_->set_drive_motor_status(drive_name_, motor_is_on_);
|
|
|
|
if(announce_motor_led_) {
|
|
|
|
observer_->set_led_status(drive_name_, motor_is_on_);
|
|
|
|
}
|
2018-05-11 01:54:10 +00:00
|
|
|
}
|
|
|
|
|
2018-08-04 01:11:22 +00:00
|
|
|
if(!motor_is_on) {
|
|
|
|
ready_index_count_ = 0;
|
|
|
|
if(disk_) disk_->flush_tracks();
|
|
|
|
}
|
|
|
|
update_clocking_observer();
|
2017-09-12 02:27:50 +00:00
|
|
|
}
|
2017-09-10 21:33:01 +00:00
|
|
|
}
|
|
|
|
|
2017-09-10 23:23:23 +00:00
|
|
|
bool Drive::get_motor_on() {
|
|
|
|
return motor_is_on_;
|
|
|
|
}
|
|
|
|
|
|
|
|
void Drive::set_event_delegate(Storage::Disk::Drive::EventDelegate *delegate) {
|
|
|
|
event_delegate_ = delegate;
|
|
|
|
}
|
|
|
|
|
2017-09-11 00:51:05 +00:00
|
|
|
void Drive::advance(const Cycles cycles) {
|
2017-10-21 23:49:04 +00:00
|
|
|
cycles_since_index_hole_ += static_cast<unsigned int>(cycles.as_int());
|
2017-09-11 00:51:05 +00:00
|
|
|
if(event_delegate_) event_delegate_->advance(cycles);
|
|
|
|
}
|
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
void Drive::run_for(const Cycles cycles) {
|
|
|
|
if(has_disk_ && motor_is_on_) {
|
2017-09-15 02:32:13 +00:00
|
|
|
Time zero(0);
|
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
int number_of_cycles = cycles.as_int();
|
|
|
|
while(number_of_cycles) {
|
2017-10-22 01:00:40 +00:00
|
|
|
int cycles_until_next_event = static_cast<int>(get_cycles_until_next_event());
|
2017-09-10 21:33:01 +00:00
|
|
|
int cycles_to_run_for = std::min(cycles_until_next_event, number_of_cycles);
|
|
|
|
if(!is_reading_ && cycles_until_bits_written_ > zero) {
|
2018-04-25 23:54:39 +00:00
|
|
|
int write_cycles_target = cycles_until_bits_written_.get<int>();
|
2017-09-10 21:33:01 +00:00
|
|
|
if(cycles_until_bits_written_.length % cycles_until_bits_written_.clock_rate) write_cycles_target++;
|
|
|
|
cycles_to_run_for = std::min(cycles_to_run_for, write_cycles_target);
|
|
|
|
}
|
|
|
|
|
|
|
|
number_of_cycles -= cycles_to_run_for;
|
2017-09-11 00:51:05 +00:00
|
|
|
if(!is_reading_) {
|
2017-09-10 21:33:01 +00:00
|
|
|
if(cycles_until_bits_written_ > zero) {
|
|
|
|
Storage::Time cycles_to_run_for_time(cycles_to_run_for);
|
|
|
|
if(cycles_until_bits_written_ <= cycles_to_run_for_time) {
|
|
|
|
if(event_delegate_) event_delegate_->process_write_completed();
|
|
|
|
if(cycles_until_bits_written_ <= cycles_to_run_for_time)
|
|
|
|
cycles_until_bits_written_.set_zero();
|
|
|
|
else
|
|
|
|
cycles_until_bits_written_ -= cycles_to_run_for_time;
|
|
|
|
} else {
|
|
|
|
cycles_until_bits_written_ -= cycles_to_run_for_time;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
TimedEventLoop::run_for(Cycles(cycles_to_run_for));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-11-12 20:59:11 +00:00
|
|
|
// MARK: - Track timed event loop
|
2017-09-10 21:33:01 +00:00
|
|
|
|
|
|
|
void Drive::get_next_event(const Time &duration_already_passed) {
|
2017-09-11 02:44:14 +00:00
|
|
|
// Grab a new track if not already in possession of one. This will recursively call get_next_event,
|
|
|
|
// supplying a proper duration_already_passed.
|
|
|
|
if(!track_) {
|
2018-05-14 23:17:34 +00:00
|
|
|
random_interval_.set_zero();
|
2017-09-11 02:44:14 +00:00
|
|
|
setup_track();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-05-14 23:17:34 +00:00
|
|
|
// If gain has now been turned up so as to generate noise, generate some noise.
|
|
|
|
if(random_interval_ > Time(0)) {
|
|
|
|
current_event_.type = Track::Event::IndexHole;
|
|
|
|
current_event_.length.length = 2 + (random_source_&1);
|
|
|
|
current_event_.length.clock_rate = 1000000;
|
|
|
|
random_source_ = (random_source_ >> 1) | (random_source_ << 63);
|
|
|
|
|
|
|
|
if(random_interval_ < current_event_.length) {
|
|
|
|
current_event_.length = random_interval_;
|
|
|
|
random_interval_.set_zero();
|
|
|
|
} else {
|
|
|
|
random_interval_ -= current_event_.length;
|
|
|
|
}
|
|
|
|
set_next_event_time_interval(current_event_.length);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
if(track_) {
|
|
|
|
current_event_ = track_->get_next_event();
|
|
|
|
} else {
|
|
|
|
current_event_.length.length = 1;
|
|
|
|
current_event_.length.clock_rate = 1;
|
|
|
|
current_event_.type = Track::Event::IndexHole;
|
|
|
|
}
|
|
|
|
|
|
|
|
// divide interval, which is in terms of a single rotation of the disk, by rotation speed to
|
|
|
|
// convert it into revolutions per second; this is achieved by multiplying by rotational_multiplier_
|
2019-06-15 20:08:54 +00:00
|
|
|
// assert(current_event_.length <= Time(1) && current_event_.length >= Time(0));
|
|
|
|
// assert(current_event_.length > duration_already_passed);
|
2017-09-10 21:33:01 +00:00
|
|
|
Time interval = (current_event_.length - duration_already_passed) * rotational_multiplier_;
|
2018-05-14 23:17:34 +00:00
|
|
|
|
|
|
|
// An interval greater than 15ms => adjust gain up the point where noise starts happening.
|
|
|
|
// Seed that up and leave a 15ms gap until it starts.
|
|
|
|
const Time safe_gain_period(15, 1000000);
|
|
|
|
if(interval >= safe_gain_period) {
|
|
|
|
random_interval_ = interval - safe_gain_period;
|
|
|
|
interval = safe_gain_period;
|
|
|
|
}
|
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
set_next_event_time_interval(interval);
|
2017-08-09 01:15:56 +00:00
|
|
|
}
|
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
void Drive::process_next_event() {
|
2017-09-11 02:44:14 +00:00
|
|
|
if(current_event_.type == Track::Event::IndexHole) {
|
2018-04-25 02:44:45 +00:00
|
|
|
// assert(get_time_into_track() == Time(1) || get_time_into_track() == Time(0));
|
2017-09-12 02:27:50 +00:00
|
|
|
if(ready_index_count_ < 2) ready_index_count_++;
|
2017-09-11 02:44:14 +00:00
|
|
|
cycles_since_index_hole_ = 0;
|
|
|
|
}
|
2017-09-15 02:32:13 +00:00
|
|
|
if(
|
|
|
|
event_delegate_ &&
|
|
|
|
(current_event_.type == Track::Event::IndexHole || is_reading_)
|
|
|
|
){
|
|
|
|
event_delegate_->process_event(current_event_);
|
|
|
|
}
|
2017-09-10 21:33:01 +00:00
|
|
|
get_next_event(Time(0));
|
|
|
|
}
|
|
|
|
|
2017-11-12 20:59:11 +00:00
|
|
|
// MARK: - Track management
|
2017-09-10 21:33:01 +00:00
|
|
|
|
2017-03-26 18:34:47 +00:00
|
|
|
std::shared_ptr<Track> Drive::get_track() {
|
2017-10-07 01:45:12 +00:00
|
|
|
if(disk_) return disk_->get_track_at_position(Track::Address(head_, head_position_));
|
2016-09-26 01:24:16 +00:00
|
|
|
return nullptr;
|
|
|
|
}
|
2016-12-25 03:11:31 +00:00
|
|
|
|
2017-03-26 18:34:47 +00:00
|
|
|
void Drive::set_track(const std::shared_ptr<Track> &track) {
|
2017-10-07 01:45:12 +00:00
|
|
|
if(disk_) disk_->set_track_at_position(Track::Address(head_, head_position_), track);
|
2016-12-25 03:11:31 +00:00
|
|
|
}
|
2017-09-10 18:43:20 +00:00
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
void Drive::setup_track() {
|
|
|
|
track_ = get_track();
|
|
|
|
if(!track_) {
|
|
|
|
track_.reset(new UnformattedTrack);
|
|
|
|
}
|
|
|
|
|
|
|
|
Time offset;
|
|
|
|
Time track_time_now = get_time_into_track();
|
|
|
|
assert(track_time_now >= Time(0) && current_event_.length <= Time(1));
|
|
|
|
|
|
|
|
Time time_found = track_->seek_to(track_time_now);
|
|
|
|
|
2018-05-03 01:26:39 +00:00
|
|
|
// time_found can be greater than track_time_now if limited precision caused rounding
|
|
|
|
if(time_found <= track_time_now) {
|
|
|
|
offset = track_time_now - time_found;
|
|
|
|
} else {
|
|
|
|
offset.set_zero();
|
|
|
|
}
|
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
get_next_event(offset);
|
2017-09-10 18:43:20 +00:00
|
|
|
}
|
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
void Drive::invalidate_track() {
|
2019-06-05 18:43:17 +00:00
|
|
|
random_interval_.set_zero();
|
2017-09-10 21:33:01 +00:00
|
|
|
track_ = nullptr;
|
2017-09-10 23:23:23 +00:00
|
|
|
if(patched_track_) {
|
|
|
|
set_track(patched_track_);
|
|
|
|
patched_track_ = nullptr;
|
|
|
|
}
|
2017-09-10 18:43:20 +00:00
|
|
|
}
|
|
|
|
|
2017-11-12 20:59:11 +00:00
|
|
|
// MARK: - Writing
|
2017-09-10 21:33:01 +00:00
|
|
|
|
|
|
|
void Drive::begin_writing(Time bit_length, bool clamp_to_index_hole) {
|
|
|
|
is_reading_ = false;
|
|
|
|
clamp_writing_to_index_hole_ = clamp_to_index_hole;
|
|
|
|
|
2017-09-10 23:23:23 +00:00
|
|
|
cycles_per_bit_ = Storage::Time(get_input_clock_rate()) * bit_length;
|
|
|
|
cycles_per_bit_.simplify();
|
|
|
|
|
2017-09-10 21:33:01 +00:00
|
|
|
write_segment_.length_of_a_bit = bit_length / rotational_multiplier_;
|
|
|
|
write_segment_.data.clear();
|
|
|
|
|
|
|
|
write_start_time_ = get_time_into_track();
|
|
|
|
}
|
|
|
|
|
|
|
|
void Drive::write_bit(bool value) {
|
2018-07-01 16:05:41 +00:00
|
|
|
write_segment_.data.push_back(value);
|
2017-09-10 21:33:01 +00:00
|
|
|
cycles_until_bits_written_ += cycles_per_bit_;
|
|
|
|
}
|
|
|
|
|
|
|
|
void Drive::end_writing() {
|
2018-07-03 01:51:53 +00:00
|
|
|
// If the user modifies a track, it's scaled up to a "high" resolution and modifications
|
|
|
|
// are plotted on top of that.
|
2018-07-02 02:49:57 +00:00
|
|
|
static const size_t high_resolution_track_rate = 500000;
|
|
|
|
|
2017-09-16 21:07:36 +00:00
|
|
|
if(!is_reading_) {
|
|
|
|
is_reading_ = true;
|
2017-09-10 21:33:01 +00:00
|
|
|
|
|
|
|
if(!patched_track_) {
|
2017-09-16 21:07:36 +00:00
|
|
|
// Avoid creating a new patched track if this one is already patched
|
2018-07-02 02:49:57 +00:00
|
|
|
patched_track_ = std::dynamic_pointer_cast<PCMTrack>(track_);
|
|
|
|
if(!patched_track_ || !patched_track_->is_resampled_clone()) {
|
|
|
|
Track *tr = track_.get();
|
|
|
|
patched_track_.reset(PCMTrack::resampled_clone(tr, high_resolution_track_rate));
|
2017-09-16 21:07:36 +00:00
|
|
|
}
|
2017-09-10 21:33:01 +00:00
|
|
|
}
|
2017-09-16 21:07:36 +00:00
|
|
|
patched_track_->add_segment(write_start_time_, write_segment_, clamp_writing_to_index_hole_);
|
|
|
|
cycles_since_index_hole_ %= get_input_clock_rate();
|
|
|
|
invalidate_track();
|
2017-09-10 21:33:01 +00:00
|
|
|
}
|
2017-09-10 18:43:20 +00:00
|
|
|
}
|
2018-05-11 01:54:10 +00:00
|
|
|
|
|
|
|
void Drive::set_activity_observer(Activity::Observer *observer, const std::string &name, bool add_motor_led) {
|
|
|
|
observer_ = observer;
|
|
|
|
announce_motor_led_ = add_motor_led;
|
|
|
|
if(observer) {
|
|
|
|
drive_name_ = name;
|
|
|
|
|
|
|
|
observer->register_drive(drive_name_);
|
|
|
|
observer->set_drive_motor_status(drive_name_, motor_is_on_);
|
|
|
|
|
|
|
|
if(add_motor_led) {
|
|
|
|
observer->register_led(drive_name_);
|
|
|
|
observer->set_led_status(drive_name_, motor_is_on_);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|