mirror of
https://github.com/TomHarte/CLK.git
synced 2025-09-12 17:24:42 +00:00
Compare commits
1 Commits
master
...
Indentatio
Author | SHA1 | Date | |
---|---|---|---|
|
60fbb067c5 |
@@ -24,36 +24,36 @@ namespace Sinclair::ZX8081 {
|
|||||||
and the black level.
|
and the black level.
|
||||||
*/
|
*/
|
||||||
class Video {
|
class Video {
|
||||||
public:
|
public:
|
||||||
/// Constructs an instance of the video feed.
|
/// Constructs an instance of the video feed.
|
||||||
Video();
|
Video();
|
||||||
|
|
||||||
/// Advances time by @c half-cycles.
|
/// Advances time by @c half-cycles.
|
||||||
void run_for(const HalfCycles);
|
void run_for(const HalfCycles);
|
||||||
|
|
||||||
/// Forces output to catch up to the current output position.
|
/// Forces output to catch up to the current output position.
|
||||||
void flush();
|
void flush();
|
||||||
|
|
||||||
/// Sets the current sync output.
|
/// Sets the current sync output.
|
||||||
void set_sync(bool sync);
|
void set_sync(bool sync);
|
||||||
|
|
||||||
/// Causes @c byte to be serialised into pixels and output over the next four cycles.
|
/// Causes @c byte to be serialised into pixels and output over the next four cycles.
|
||||||
void output_byte(uint8_t byte);
|
void output_byte(uint8_t byte);
|
||||||
|
|
||||||
/// Sets the scan target.
|
/// Sets the scan target.
|
||||||
void set_scan_target(Outputs::Display::ScanTarget *scan_target);
|
void set_scan_target(Outputs::Display::ScanTarget *scan_target);
|
||||||
|
|
||||||
/// Gets the current scan status.
|
/// Gets the current scan status.
|
||||||
Outputs::Display::ScanStatus get_scaled_scan_status() const;
|
Outputs::Display::ScanStatus get_scaled_scan_status() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool sync_ = false;
|
bool sync_ = false;
|
||||||
uint8_t *line_data_ = nullptr;
|
uint8_t *line_data_ = nullptr;
|
||||||
uint8_t *line_data_pointer_ = nullptr;
|
uint8_t *line_data_pointer_ = nullptr;
|
||||||
HalfCycles time_since_update_ = 0;
|
HalfCycles time_since_update_ = 0;
|
||||||
Outputs::CRT::CRT crt_;
|
Outputs::CRT::CRT crt_;
|
||||||
|
|
||||||
void flush(bool next_sync);
|
void flush(bool next_sync);
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -48,420 +48,421 @@ enum class Timing {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
template <Timing timing> class Video {
|
template <Timing timing> class Video {
|
||||||
private:
|
private:
|
||||||
struct Timings {
|
struct Timings {
|
||||||
// Number of cycles per line. Will be 224 or 228.
|
// Number of cycles per line. Will be 224 or 228.
|
||||||
int half_cycles_per_line;
|
int half_cycles_per_line;
|
||||||
// Number of lines comprising a whole frame. Will be 311 or 312.
|
// Number of lines comprising a whole frame. Will be 311 or 312.
|
||||||
int lines_per_frame;
|
int lines_per_frame;
|
||||||
|
|
||||||
// Number of cycles before first pixel fetch that contention starts to be applied.
|
// Number of cycles before first pixel fetch that contention starts to be applied.
|
||||||
int contention_leadin;
|
int contention_leadin;
|
||||||
// Period in a line for which contention is applied.
|
// Period in a line for which contention is applied.
|
||||||
int contention_duration;
|
int contention_duration;
|
||||||
|
|
||||||
// Number of cycles after first pixel fetch at which interrupt is first signalled.
|
// Number of cycles after first pixel fetch at which interrupt is first signalled.
|
||||||
int interrupt_time;
|
int interrupt_time;
|
||||||
|
|
||||||
// Contention to apply, in whole cycles, as a function of number of whole cycles since
|
// Contention to apply, in whole cycles, as a function of number of whole cycles since
|
||||||
// contention began.
|
// contention began.
|
||||||
int delays[8];
|
int delays[8];
|
||||||
|
|
||||||
constexpr Timings(
|
constexpr Timings(
|
||||||
const int cycles_per_line,
|
const int cycles_per_line,
|
||||||
const int lines_per_frame,
|
const int lines_per_frame,
|
||||||
const int contention_leadin,
|
const int contention_leadin,
|
||||||
const int contention_duration,
|
const int contention_duration,
|
||||||
const int interrupt_offset,
|
const int interrupt_offset,
|
||||||
const int (&delays)[8])
|
const int (&delays)[8])
|
||||||
noexcept :
|
noexcept :
|
||||||
half_cycles_per_line(cycles_per_line * 2),
|
half_cycles_per_line(cycles_per_line * 2),
|
||||||
lines_per_frame(lines_per_frame),
|
lines_per_frame(lines_per_frame),
|
||||||
contention_leadin(contention_leadin * 2),
|
contention_leadin(contention_leadin * 2),
|
||||||
contention_duration(contention_duration * 2),
|
contention_duration(contention_duration * 2),
|
||||||
interrupt_time((cycles_per_line * lines_per_frame - interrupt_offset - contention_leadin) * 2),
|
interrupt_time((cycles_per_line * lines_per_frame - interrupt_offset - contention_leadin) * 2),
|
||||||
delays{
|
delays{
|
||||||
delays[0] * 2,
|
delays[0] * 2,
|
||||||
delays[1] * 2,
|
delays[1] * 2,
|
||||||
delays[2] * 2,
|
delays[2] * 2,
|
||||||
delays[3] * 2,
|
delays[3] * 2,
|
||||||
delays[4] * 2,
|
delays[4] * 2,
|
||||||
delays[5] * 2,
|
delays[5] * 2,
|
||||||
delays[6] * 2,
|
delays[6] * 2,
|
||||||
delays[7] * 2
|
delays[7] * 2
|
||||||
}
|
|
||||||
{}
|
|
||||||
};
|
|
||||||
|
|
||||||
static constexpr Timings get_timings() {
|
|
||||||
if constexpr (timing == Timing::Plus3) {
|
|
||||||
constexpr int delays[] = {1, 0, 7, 6, 5, 4, 3, 2};
|
|
||||||
return Timings(228, 311, 6, 129, 14361, delays);
|
|
||||||
}
|
}
|
||||||
|
{}
|
||||||
|
};
|
||||||
|
|
||||||
if constexpr (timing == Timing::OneTwoEightK) {
|
static constexpr Timings get_timings() {
|
||||||
constexpr int delays[] = {6, 5, 4, 3, 2, 1, 0, 0};
|
if constexpr (timing == Timing::Plus3) {
|
||||||
return Timings(228, 311, 4, 128, 14361, delays);
|
constexpr int delays[] = {1, 0, 7, 6, 5, 4, 3, 2};
|
||||||
}
|
return Timings(228, 311, 6, 129, 14361, delays);
|
||||||
|
|
||||||
if constexpr (timing == Timing::FortyEightK) {
|
|
||||||
constexpr int delays[] = {6, 5, 4, 3, 2, 1, 0, 0};
|
|
||||||
return Timings(224, 312, 4, 128, 14335, delays);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interrupt should be held for 32 cycles.
|
if constexpr (timing == Timing::OneTwoEightK) {
|
||||||
static constexpr int interrupt_duration = 64;
|
constexpr int delays[] = {6, 5, 4, 3, 2, 1, 0, 0};
|
||||||
|
return Timings(228, 311, 4, 128, 14361, delays);
|
||||||
|
}
|
||||||
|
|
||||||
public:
|
if constexpr (timing == Timing::FortyEightK) {
|
||||||
void run_for(HalfCycles duration) {
|
constexpr int delays[] = {6, 5, 4, 3, 2, 1, 0, 0};
|
||||||
static constexpr auto timings = get_timings();
|
return Timings(224, 312, 4, 128, 14335, delays);
|
||||||
|
}
|
||||||
static constexpr int sync_line = (timings.interrupt_time / timings.half_cycles_per_line) + 1;
|
|
||||||
|
|
||||||
static constexpr int sync_position = (timing == Timing::FortyEightK) ? 164 * 2 : 166 * 2;
|
|
||||||
static constexpr int sync_length = 17 * 2;
|
|
||||||
static constexpr int burst_position = sync_position + 40;
|
|
||||||
static constexpr int burst_length = 17;
|
|
||||||
|
|
||||||
int cycles_remaining = duration.as<int>();
|
|
||||||
while(cycles_remaining) {
|
|
||||||
int line = time_into_frame_ / timings.half_cycles_per_line;
|
|
||||||
int offset = time_into_frame_ % timings.half_cycles_per_line;
|
|
||||||
const int cycles_this_line = std::min(cycles_remaining, timings.half_cycles_per_line - offset);
|
|
||||||
const int end_offset = offset + cycles_this_line;
|
|
||||||
|
|
||||||
if(!offset) {
|
|
||||||
is_alternate_line_ ^= true;
|
|
||||||
|
|
||||||
if(!line) {
|
|
||||||
flash_counter_ = (flash_counter_ + 1) & 31;
|
|
||||||
flash_mask_ = uint8_t(flash_counter_ >> 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(line >= sync_line && line < sync_line + 3) {
|
|
||||||
// Output sync line.
|
|
||||||
crt_.output_sync(cycles_this_line);
|
|
||||||
} else {
|
|
||||||
if(line >= 192) {
|
|
||||||
// Output plain border line.
|
|
||||||
if(offset < sync_position) {
|
|
||||||
const int border_duration = std::min(sync_position, end_offset) - offset;
|
|
||||||
crt_.output_level<uint8_t>(border_duration, border_colour_);
|
|
||||||
offset += border_duration;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Output pixel line.
|
|
||||||
if(offset < 256) {
|
|
||||||
const int pixel_duration = std::min(256, end_offset) - offset;
|
|
||||||
|
|
||||||
if(!offset) {
|
|
||||||
pixel_target_ = crt_.begin_data(256);
|
|
||||||
attribute_address_ = ((line >> 3) << 5) + 6144;
|
|
||||||
pixel_address_ = ((line & 0x07) << 8) | ((line & 0x38) << 2) | ((line & 0xc0) << 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(pixel_target_) {
|
|
||||||
const int start_column = offset >> 4;
|
|
||||||
const int end_column = (offset + pixel_duration) >> 4;
|
|
||||||
for(int column = start_column; column < end_column; column++) {
|
|
||||||
last_fetches_[0] = memory_[pixel_address_];
|
|
||||||
last_fetches_[1] = memory_[attribute_address_];
|
|
||||||
last_fetches_[2] = memory_[pixel_address_+1];
|
|
||||||
last_fetches_[3] = memory_[attribute_address_+1];
|
|
||||||
set_last_contended_area_access(last_fetches_[3]);
|
|
||||||
|
|
||||||
pixel_address_ += 2;
|
|
||||||
attribute_address_ += 2;
|
|
||||||
|
|
||||||
constexpr uint8_t masks[] = {0, 0xff};
|
|
||||||
|
|
||||||
#define Output(n) \
|
|
||||||
{ \
|
|
||||||
const uint8_t pixels = \
|
|
||||||
uint8_t(last_fetches_[n] ^ masks[flash_mask_ & (last_fetches_[n+1] >> 7)]); \
|
|
||||||
\
|
|
||||||
const uint8_t colours[2] = { \
|
|
||||||
palette[(last_fetches_[n+1] & 0x78) >> 3], \
|
|
||||||
palette[((last_fetches_[n+1] & 0x40) >> 3) | (last_fetches_[n+1] & 0x07)], \
|
|
||||||
}; \
|
|
||||||
\
|
|
||||||
pixel_target_[0] = colours[(pixels >> 7) & 1]; \
|
|
||||||
pixel_target_[1] = colours[(pixels >> 6) & 1]; \
|
|
||||||
pixel_target_[2] = colours[(pixels >> 5) & 1]; \
|
|
||||||
pixel_target_[3] = colours[(pixels >> 4) & 1]; \
|
|
||||||
pixel_target_[4] = colours[(pixels >> 3) & 1]; \
|
|
||||||
pixel_target_[5] = colours[(pixels >> 2) & 1]; \
|
|
||||||
pixel_target_[6] = colours[(pixels >> 1) & 1]; \
|
|
||||||
pixel_target_[7] = colours[(pixels >> 0) & 1]; \
|
|
||||||
pixel_target_ += 8; \
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Output(0);
|
// Interrupt should be held for 32 cycles.
|
||||||
Output(2);
|
static constexpr int interrupt_duration = 64;
|
||||||
|
|
||||||
|
public:
|
||||||
|
void run_for(HalfCycles duration) {
|
||||||
|
static constexpr auto timings = get_timings();
|
||||||
|
|
||||||
|
static constexpr int sync_line = (timings.interrupt_time / timings.half_cycles_per_line) + 1;
|
||||||
|
|
||||||
|
static constexpr int sync_position = (timing == Timing::FortyEightK) ? 164 * 2 : 166 * 2;
|
||||||
|
static constexpr int sync_length = 17 * 2;
|
||||||
|
static constexpr int burst_position = sync_position + 40;
|
||||||
|
static constexpr int burst_length = 17;
|
||||||
|
|
||||||
|
int cycles_remaining = duration.as<int>();
|
||||||
|
while(cycles_remaining) {
|
||||||
|
int line = time_into_frame_ / timings.half_cycles_per_line;
|
||||||
|
int offset = time_into_frame_ % timings.half_cycles_per_line;
|
||||||
|
const int cycles_this_line = std::min(cycles_remaining, timings.half_cycles_per_line - offset);
|
||||||
|
const int end_offset = offset + cycles_this_line;
|
||||||
|
|
||||||
|
if(!offset) {
|
||||||
|
is_alternate_line_ ^= true;
|
||||||
|
|
||||||
|
if(!line) {
|
||||||
|
flash_counter_ = (flash_counter_ + 1) & 31;
|
||||||
|
flash_mask_ = uint8_t(flash_counter_ >> 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(line >= sync_line && line < sync_line + 3) {
|
||||||
|
// Output sync line.
|
||||||
|
crt_.output_sync(cycles_this_line);
|
||||||
|
} else {
|
||||||
|
if(line >= 192) {
|
||||||
|
// Output plain border line.
|
||||||
|
if(offset < sync_position) {
|
||||||
|
const int border_duration = std::min(sync_position, end_offset) - offset;
|
||||||
|
crt_.output_level<uint8_t>(border_duration, border_colour_);
|
||||||
|
offset += border_duration;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Output pixel line.
|
||||||
|
if(offset < 256) {
|
||||||
|
const int pixel_duration = std::min(256, end_offset) - offset;
|
||||||
|
|
||||||
|
if(!offset) {
|
||||||
|
pixel_target_ = crt_.begin_data(256);
|
||||||
|
attribute_address_ = ((line >> 3) << 5) + 6144;
|
||||||
|
pixel_address_ = ((line & 0x07) << 8) | ((line & 0x38) << 2) | ((line & 0xc0) << 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(pixel_target_) {
|
||||||
|
const int start_column = offset >> 4;
|
||||||
|
const int end_column = (offset + pixel_duration) >> 4;
|
||||||
|
for(int column = start_column; column < end_column; column++) {
|
||||||
|
last_fetches_[0] = memory_[pixel_address_];
|
||||||
|
last_fetches_[1] = memory_[attribute_address_];
|
||||||
|
last_fetches_[2] = memory_[pixel_address_+1];
|
||||||
|
last_fetches_[3] = memory_[attribute_address_+1];
|
||||||
|
set_last_contended_area_access(last_fetches_[3]);
|
||||||
|
|
||||||
|
pixel_address_ += 2;
|
||||||
|
attribute_address_ += 2;
|
||||||
|
|
||||||
|
constexpr uint8_t masks[] = {0, 0xff};
|
||||||
|
|
||||||
|
#define Output(n) \
|
||||||
|
{ \
|
||||||
|
const uint8_t pixels = \
|
||||||
|
uint8_t(last_fetches_[n] ^ masks[flash_mask_ & (last_fetches_[n+1] >> 7)]); \
|
||||||
|
\
|
||||||
|
const uint8_t colours[2] = { \
|
||||||
|
palette[(last_fetches_[n+1] & 0x78) >> 3], \
|
||||||
|
palette[((last_fetches_[n+1] & 0x40) >> 3) | (last_fetches_[n+1] & 0x07)], \
|
||||||
|
}; \
|
||||||
|
\
|
||||||
|
pixel_target_[0] = colours[(pixels >> 7) & 1]; \
|
||||||
|
pixel_target_[1] = colours[(pixels >> 6) & 1]; \
|
||||||
|
pixel_target_[2] = colours[(pixels >> 5) & 1]; \
|
||||||
|
pixel_target_[3] = colours[(pixels >> 4) & 1]; \
|
||||||
|
pixel_target_[4] = colours[(pixels >> 3) & 1]; \
|
||||||
|
pixel_target_[5] = colours[(pixels >> 2) & 1]; \
|
||||||
|
pixel_target_[6] = colours[(pixels >> 1) & 1]; \
|
||||||
|
pixel_target_[7] = colours[(pixels >> 0) & 1]; \
|
||||||
|
pixel_target_ += 8; \
|
||||||
|
}
|
||||||
|
|
||||||
|
Output(0);
|
||||||
|
Output(2);
|
||||||
|
|
||||||
#undef Output
|
#undef Output
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += pixel_duration;
|
|
||||||
if(offset == 256) {
|
|
||||||
crt_.output_data(256);
|
|
||||||
pixel_target_ = nullptr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(offset >= 256 && offset < sync_position && end_offset > offset) {
|
offset += pixel_duration;
|
||||||
const int border_duration = std::min(sync_position, end_offset) - offset;
|
if(offset == 256) {
|
||||||
crt_.output_level<uint8_t>(border_duration, border_colour_);
|
crt_.output_data(256);
|
||||||
offset += border_duration;
|
pixel_target_ = nullptr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output the common tail to border and pixel lines: sync, blank, colour burst, border.
|
if(offset >= 256 && offset < sync_position && end_offset > offset) {
|
||||||
|
const int border_duration = std::min(sync_position, end_offset) - offset;
|
||||||
if(offset >= sync_position && offset < sync_position + sync_length && end_offset > offset) {
|
crt_.output_level<uint8_t>(border_duration, border_colour_);
|
||||||
const int sync_duration = std::min(sync_position + sync_length, end_offset) - offset;
|
offset += border_duration;
|
||||||
crt_.output_sync(sync_duration);
|
|
||||||
offset += sync_duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(offset >= sync_position + sync_length && offset < burst_position && end_offset > offset) {
|
|
||||||
const int blank_duration = std::min(burst_position, end_offset) - offset;
|
|
||||||
crt_.output_blank(blank_duration);
|
|
||||||
offset += blank_duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(offset >= burst_position && offset < burst_position+burst_length && end_offset > offset) {
|
|
||||||
const int burst_duration = std::min(burst_position + burst_length, end_offset) - offset;
|
|
||||||
|
|
||||||
if constexpr (timing >= Timing::OneTwoEightK) {
|
|
||||||
crt_.output_colour_burst(burst_duration, 116, is_alternate_line_);
|
|
||||||
// The colour burst phase above is an empirical guess. I need to research further.
|
|
||||||
} else {
|
|
||||||
crt_.output_default_colour_burst(burst_duration);
|
|
||||||
}
|
|
||||||
offset += burst_duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(offset >= burst_position+burst_length && end_offset > offset) {
|
|
||||||
crt_.output_level<uint8_t>(end_offset - offset, border_colour_);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cycles_remaining -= cycles_this_line;
|
// Output the common tail to border and pixel lines: sync, blank, colour burst, border.
|
||||||
time_into_frame_ = (time_into_frame_ + cycles_this_line) % (timings.half_cycles_per_line * timings.lines_per_frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
if(offset >= sync_position && offset < sync_position + sync_length && end_offset > offset) {
|
||||||
static constexpr int half_cycles_per_line() {
|
const int sync_duration = std::min(sync_position + sync_length, end_offset) - offset;
|
||||||
if constexpr (timing == Timing::FortyEightK) {
|
crt_.output_sync(sync_duration);
|
||||||
// TODO: determine real figure here, if one exists.
|
offset += sync_duration;
|
||||||
// The source I'm looking at now suggests that the theoretical
|
}
|
||||||
// ideal of 224*2 ignores the real-life effects of separate
|
|
||||||
// crystals, so I've nudged this experimentally.
|
|
||||||
return 224*2 - 1;
|
|
||||||
} else {
|
|
||||||
return 227*2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static constexpr HalfCycles frame_duration() {
|
if(offset >= sync_position + sync_length && offset < burst_position && end_offset > offset) {
|
||||||
const auto timings = get_timings();
|
const int blank_duration = std::min(burst_position, end_offset) - offset;
|
||||||
return HalfCycles(timings.half_cycles_per_line * timings.lines_per_frame);
|
crt_.output_blank(blank_duration);
|
||||||
}
|
offset += blank_duration;
|
||||||
|
}
|
||||||
|
|
||||||
HalfCycles time_since_interrupt() {
|
if(offset >= burst_position && offset < burst_position+burst_length && end_offset > offset) {
|
||||||
const auto timings = get_timings();
|
const int burst_duration = std::min(burst_position + burst_length, end_offset) - offset;
|
||||||
if(time_into_frame_ >= timings.interrupt_time) {
|
|
||||||
return HalfCycles(time_into_frame_ - timings.interrupt_time);
|
|
||||||
} else {
|
|
||||||
return HalfCycles(time_into_frame_) + frame_duration() - HalfCycles(timings.interrupt_time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_time_since_interrupt(const HalfCycles time) {
|
if constexpr (timing >= Timing::OneTwoEightK) {
|
||||||
// Advance using run_for to ensure that all proper CRT interactions occurred.
|
crt_.output_colour_burst(burst_duration, 116, is_alternate_line_);
|
||||||
const auto timings = get_timings();
|
// The colour burst phase above is an empirical guess. I need to research further.
|
||||||
const auto target = (time + timings.interrupt_time) % frame_duration();
|
} else {
|
||||||
const auto now = HalfCycles(time_into_frame_);
|
crt_.output_default_colour_burst(burst_duration);
|
||||||
|
}
|
||||||
|
offset += burst_duration;
|
||||||
|
}
|
||||||
|
|
||||||
// Maybe this is easy?
|
if(offset >= burst_position+burst_length && end_offset > offset) {
|
||||||
if(target == now) return;
|
crt_.output_level<uint8_t>(end_offset - offset, border_colour_);
|
||||||
|
}
|
||||||
// Is the time within this frame?
|
|
||||||
if(time > now) {
|
|
||||||
run_for(target - time);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then it's necessary to finish this frame and run into the next.
|
cycles_remaining -= cycles_this_line;
|
||||||
run_for(frame_duration() - now + time);
|
time_into_frame_ = (time_into_frame_ + cycles_this_line) % (timings.half_cycles_per_line * timings.lines_per_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr int half_cycles_per_line() {
|
||||||
|
if constexpr (timing == Timing::FortyEightK) {
|
||||||
|
// TODO: determine real figure here, if one exists.
|
||||||
|
// The source I'm looking at now suggests that the theoretical
|
||||||
|
// ideal of 224*2 ignores the real-life effects of separate
|
||||||
|
// crystals, so I've nudged this experimentally.
|
||||||
|
return 224*2 - 1;
|
||||||
|
} else {
|
||||||
|
return 227*2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static constexpr HalfCycles frame_duration() {
|
||||||
|
const auto timings = get_timings();
|
||||||
|
return HalfCycles(timings.half_cycles_per_line * timings.lines_per_frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
HalfCycles time_since_interrupt() {
|
||||||
|
const auto timings = get_timings();
|
||||||
|
if(time_into_frame_ >= timings.interrupt_time) {
|
||||||
|
return HalfCycles(time_into_frame_ - timings.interrupt_time);
|
||||||
|
} else {
|
||||||
|
return HalfCycles(time_into_frame_) + frame_duration() - HalfCycles(timings.interrupt_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_time_since_interrupt(const HalfCycles time) {
|
||||||
|
// Advance using run_for to ensure that all proper CRT interactions occurred.
|
||||||
|
const auto timings = get_timings();
|
||||||
|
const auto target = (time + timings.interrupt_time) % frame_duration();
|
||||||
|
const auto now = HalfCycles(time_into_frame_);
|
||||||
|
|
||||||
|
// Maybe this is easy?
|
||||||
|
if(target == now) return;
|
||||||
|
|
||||||
|
// Is the time within this frame?
|
||||||
|
if(time > now) {
|
||||||
|
run_for(target - time);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public:
|
// Then it's necessary to finish this frame and run into the next.
|
||||||
Video() :
|
run_for(frame_duration() - now + time);
|
||||||
crt_(half_cycles_per_line(), 2, Outputs::Display::Type::PAL50, Outputs::Display::InputDataType::Red2Green2Blue2)
|
}
|
||||||
{
|
|
||||||
// Show only the centre 80% of the TV frame.
|
|
||||||
crt_.set_display_type(Outputs::Display::DisplayType::RGB);
|
|
||||||
crt_.set_visible_area(Outputs::Display::Rect(0.1f, 0.1f, 0.8f, 0.8f));
|
|
||||||
|
|
||||||
// Get the CRT roughly into phase.
|
public:
|
||||||
//
|
Video() :
|
||||||
// TODO: this is coupled to an assumption about the initial CRT. Fix.
|
crt_(half_cycles_per_line(), 2, Outputs::Display::Type::PAL50, Outputs::Display::InputDataType::Red2Green2Blue2)
|
||||||
const auto timings = get_timings();
|
{
|
||||||
crt_.output_blank(timings.lines_per_frame*timings.half_cycles_per_line - timings.interrupt_time);
|
// Show only the centre 80% of the TV frame.
|
||||||
|
crt_.set_display_type(Outputs::Display::DisplayType::RGB);
|
||||||
|
crt_.set_visible_area(Outputs::Display::Rect(0.1f, 0.1f, 0.8f, 0.8f));
|
||||||
|
|
||||||
|
// Get the CRT roughly into phase.
|
||||||
|
//
|
||||||
|
// TODO: this is coupled to an assumption about the initial CRT. Fix.
|
||||||
|
const auto timings = get_timings();
|
||||||
|
crt_.output_blank(timings.lines_per_frame*timings.half_cycles_per_line - timings.interrupt_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_video_source(const uint8_t *source) {
|
||||||
|
memory_ = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*!
|
||||||
|
@returns The amount of time until the next change in the interrupt line, that being the only internally-observeable output.
|
||||||
|
*/
|
||||||
|
HalfCycles next_sequence_point() {
|
||||||
|
constexpr auto timings = get_timings();
|
||||||
|
|
||||||
|
// Is the frame still ahead of this interrupt?
|
||||||
|
if(time_into_frame_ < timings.interrupt_time) {
|
||||||
|
return HalfCycles(timings.interrupt_time - time_into_frame_);
|
||||||
}
|
}
|
||||||
|
|
||||||
void set_video_source(const uint8_t *source) {
|
// If not, is it within this interrupt?
|
||||||
memory_ = source;
|
if(time_into_frame_ < timings.interrupt_time + interrupt_duration) {
|
||||||
|
return HalfCycles(timings.interrupt_time + interrupt_duration - time_into_frame_);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
// If not, it'll be in the next batch.
|
||||||
@returns The amount of time until the next change in the interrupt line, that being the only internally-observeable output.
|
return timings.interrupt_time + timings.half_cycles_per_line * timings.lines_per_frame - time_into_frame_;
|
||||||
*/
|
}
|
||||||
HalfCycles next_sequence_point() {
|
|
||||||
constexpr auto timings = get_timings();
|
|
||||||
|
|
||||||
// Is the frame still ahead of this interrupt?
|
/*!
|
||||||
if(time_into_frame_ < timings.interrupt_time) {
|
@returns The current state of the interrupt output.
|
||||||
return HalfCycles(timings.interrupt_time - time_into_frame_);
|
*/
|
||||||
}
|
bool get_interrupt_line() const {
|
||||||
|
constexpr auto timings = get_timings();
|
||||||
|
return time_into_frame_ >= timings.interrupt_time && time_into_frame_ < timings.interrupt_time + interrupt_duration;
|
||||||
|
}
|
||||||
|
|
||||||
// If not, is it within this interrupt?
|
/*!
|
||||||
if(time_into_frame_ < timings.interrupt_time + interrupt_duration) {
|
@returns How many cycles the [ULA/gate array] would delay the CPU for if it were to recognise that contention
|
||||||
return HalfCycles(timings.interrupt_time + interrupt_duration - time_into_frame_);
|
needs to be applied in @c offset half-cycles from now.
|
||||||
}
|
*/
|
||||||
|
HalfCycles access_delay(HalfCycles offset) const {
|
||||||
|
constexpr auto timings = get_timings();
|
||||||
|
const int delay_time = (time_into_frame_ + offset.as<int>() + timings.contention_leadin) % (timings.half_cycles_per_line * timings.lines_per_frame);
|
||||||
|
assert(!(delay_time&1));
|
||||||
|
|
||||||
// If not, it'll be in the next batch.
|
// Check for a time within the no-contention window.
|
||||||
return timings.interrupt_time + timings.half_cycles_per_line * timings.lines_per_frame - time_into_frame_;
|
if(delay_time >= (191*timings.half_cycles_per_line + timings.contention_duration)) {
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
const int time_into_line = delay_time % timings.half_cycles_per_line;
|
||||||
@returns The current state of the interrupt output.
|
if(time_into_line >= timings.contention_duration) {
|
||||||
*/
|
return 0;
|
||||||
bool get_interrupt_line() const {
|
|
||||||
constexpr auto timings = get_timings();
|
|
||||||
return time_into_frame_ >= timings.interrupt_time && time_into_frame_ < timings.interrupt_time + interrupt_duration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
return HalfCycles(timings.delays[(time_into_line >> 1) & 7]);
|
||||||
@returns How many cycles the [ULA/gate array] would delay the CPU for if it were to recognise that contention
|
}
|
||||||
needs to be applied in @c offset half-cycles from now.
|
|
||||||
*/
|
|
||||||
HalfCycles access_delay(HalfCycles offset) const {
|
|
||||||
constexpr auto timings = get_timings();
|
|
||||||
const int delay_time = (time_into_frame_ + offset.as<int>() + timings.contention_leadin) % (timings.half_cycles_per_line * timings.lines_per_frame);
|
|
||||||
assert(!(delay_time&1));
|
|
||||||
|
|
||||||
// Check for a time within the no-contention window.
|
/*!
|
||||||
if(delay_time >= (191*timings.half_cycles_per_line + timings.contention_duration)) {
|
@returns Whatever the ULA or gate array would expose via the floating bus, this cycle.
|
||||||
return 0;
|
*/
|
||||||
}
|
uint8_t get_floating_value() const {
|
||||||
|
constexpr auto timings = get_timings();
|
||||||
|
const uint8_t out_of_bounds = (timing == Timing::Plus3) ? last_contended_access_ : 0xff;
|
||||||
|
|
||||||
const int time_into_line = delay_time % timings.half_cycles_per_line;
|
const int line = time_into_frame_ / timings.half_cycles_per_line;
|
||||||
if(time_into_line >= timings.contention_duration) {
|
if(line >= 192) {
|
||||||
return 0;
|
return out_of_bounds;
|
||||||
}
|
|
||||||
|
|
||||||
return HalfCycles(timings.delays[(time_into_line >> 1) & 7]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
const int time_into_line = time_into_frame_ % timings.half_cycles_per_line;
|
||||||
@returns Whatever the ULA or gate array would expose via the floating bus, this cycle.
|
if(time_into_line >= 256 || (time_into_line&8)) {
|
||||||
*/
|
return out_of_bounds;
|
||||||
uint8_t get_floating_value() const {
|
|
||||||
constexpr auto timings = get_timings();
|
|
||||||
const uint8_t out_of_bounds = (timing == Timing::Plus3) ? last_contended_access_ : 0xff;
|
|
||||||
|
|
||||||
const int line = time_into_frame_ / timings.half_cycles_per_line;
|
|
||||||
if(line >= 192) {
|
|
||||||
return out_of_bounds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int time_into_line = time_into_frame_ % timings.half_cycles_per_line;
|
|
||||||
if(time_into_line >= 256 || (time_into_line&8)) {
|
|
||||||
return out_of_bounds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The +2a and +3 always return the low bit as set.
|
|
||||||
const uint8_t value = last_fetches_[(time_into_line >> 1) & 3];
|
|
||||||
if constexpr (timing == Timing::Plus3) {
|
|
||||||
return value | 1;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
// The +2a and +3 always return the low bit as set.
|
||||||
Relevant to the +2a and +3 only, sets the most recent value read from or
|
const uint8_t value = last_fetches_[(time_into_line >> 1) & 3];
|
||||||
written to contended memory. This is what will be returned if the floating
|
if constexpr (timing == Timing::Plus3) {
|
||||||
bus is accessed when the gate array isn't currently reading.
|
return value | 1;
|
||||||
*/
|
|
||||||
void set_last_contended_area_access([[maybe_unused]] uint8_t value) {
|
|
||||||
if constexpr (timing == Timing::Plus3) {
|
|
||||||
last_contended_access_ = value | 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
Sets the current border colour.
|
Relevant to the +2a and +3 only, sets the most recent value read from or
|
||||||
*/
|
written to contended memory. This is what will be returned if the floating
|
||||||
void set_border_colour(uint8_t colour) {
|
bus is accessed when the gate array isn't currently reading.
|
||||||
border_byte_ = colour;
|
*/
|
||||||
border_colour_ = palette[colour];
|
void set_last_contended_area_access([[maybe_unused]] uint8_t value) {
|
||||||
|
if constexpr (timing == Timing::Plus3) {
|
||||||
|
last_contended_access_ = value | 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the scan target.
|
/*!
|
||||||
void set_scan_target(Outputs::Display::ScanTarget *scan_target) {
|
Sets the current border colour.
|
||||||
crt_.set_scan_target(scan_target);
|
*/
|
||||||
}
|
void set_border_colour(uint8_t colour) {
|
||||||
|
border_byte_ = colour;
|
||||||
|
border_colour_ = palette[colour];
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets the current scan status.
|
/// Sets the scan target.
|
||||||
Outputs::Display::ScanStatus get_scaled_scan_status() const {
|
void set_scan_target(Outputs::Display::ScanTarget *scan_target) {
|
||||||
return crt_.get_scaled_scan_status();
|
crt_.set_scan_target(scan_target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*! Sets the type of display the CRT will request. */
|
/// Gets the current scan status.
|
||||||
void set_display_type(Outputs::Display::DisplayType type) {
|
Outputs::Display::ScanStatus get_scaled_scan_status() const {
|
||||||
crt_.set_display_type(type);
|
return crt_.get_scaled_scan_status();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*! Gets the display type. */
|
/*! Sets the type of display the CRT will request. */
|
||||||
Outputs::Display::DisplayType get_display_type() const {
|
void set_display_type(Outputs::Display::DisplayType type) {
|
||||||
return crt_.get_display_type();
|
crt_.set_display_type(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
/*! Gets the display type. */
|
||||||
int time_into_frame_ = 0;
|
Outputs::Display::DisplayType get_display_type() const {
|
||||||
Outputs::CRT::CRT crt_;
|
return crt_.get_display_type();
|
||||||
const uint8_t *memory_ = nullptr;
|
}
|
||||||
uint8_t border_colour_ = 0;
|
|
||||||
uint8_t border_byte_ = 0;
|
|
||||||
|
|
||||||
uint8_t *pixel_target_ = nullptr;
|
private:
|
||||||
int attribute_address_ = 0;
|
int time_into_frame_ = 0;
|
||||||
int pixel_address_ = 0;
|
Outputs::CRT::CRT crt_;
|
||||||
|
const uint8_t *memory_ = nullptr;
|
||||||
|
uint8_t border_colour_ = 0;
|
||||||
|
uint8_t border_byte_ = 0;
|
||||||
|
|
||||||
uint8_t flash_mask_ = 0;
|
uint8_t *pixel_target_ = nullptr;
|
||||||
int flash_counter_ = 0;
|
int attribute_address_ = 0;
|
||||||
bool is_alternate_line_ = false;
|
int pixel_address_ = 0;
|
||||||
|
|
||||||
uint8_t last_fetches_[4] = {0xff, 0xff, 0xff, 0xff};
|
uint8_t flash_mask_ = 0;
|
||||||
uint8_t last_contended_access_ = 0xff;
|
int flash_counter_ = 0;
|
||||||
|
bool is_alternate_line_ = false;
|
||||||
|
|
||||||
friend struct State;
|
uint8_t last_fetches_[4] = {0xff, 0xff, 0xff, 0xff};
|
||||||
|
uint8_t last_contended_access_ = 0xff;
|
||||||
|
|
||||||
#define RGB(r, g, b) (r << 4) | (g << 2) | b
|
friend struct State;
|
||||||
static constexpr uint8_t palette[] = {
|
|
||||||
RGB(0, 0, 0), RGB(0, 0, 2), RGB(2, 0, 0), RGB(2, 0, 2),
|
static constexpr uint8_t RGB(const uint8_t r, const uint8_t g, const uint8_t b) {
|
||||||
RGB(0, 2, 0), RGB(0, 2, 2), RGB(2, 2, 0), RGB(2, 2, 2),
|
return uint8_t((r << 4) | (g << 2) | b);
|
||||||
RGB(0, 0, 0), RGB(0, 0, 3), RGB(3, 0, 0), RGB(3, 0, 3),
|
}
|
||||||
RGB(0, 3, 0), RGB(0, 3, 3), RGB(3, 3, 0), RGB(3, 3, 3),
|
static constexpr uint8_t palette[] = {
|
||||||
};
|
RGB(0, 0, 0), RGB(0, 0, 2), RGB(2, 0, 0), RGB(2, 0, 2),
|
||||||
#undef RGB
|
RGB(0, 2, 0), RGB(0, 2, 2), RGB(2, 2, 0), RGB(2, 2, 2),
|
||||||
|
RGB(0, 0, 0), RGB(0, 0, 3), RGB(3, 0, 0), RGB(3, 0, 3),
|
||||||
|
RGB(0, 3, 0), RGB(0, 3, 3), RGB(3, 3, 0), RGB(3, 3, 3),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct State: public Reflection::StructImpl<State> {
|
struct State: public Reflection::StructImpl<State> {
|
||||||
|
@@ -22,37 +22,37 @@ namespace Utility {
|
|||||||
necessary to type that character on a given machine.
|
necessary to type that character on a given machine.
|
||||||
*/
|
*/
|
||||||
class CharacterMapper {
|
class CharacterMapper {
|
||||||
public:
|
public:
|
||||||
virtual ~CharacterMapper() = default;
|
virtual ~CharacterMapper() = default;
|
||||||
|
|
||||||
/// @returns The EndSequence-terminated sequence of keys that would cause @c character to be typed.
|
/// @returns The EndSequence-terminated sequence of keys that would cause @c character to be typed.
|
||||||
virtual const uint16_t *sequence_for_character(char character) const = 0;
|
virtual const uint16_t *sequence_for_character(char character) const = 0;
|
||||||
|
|
||||||
/// The typer will automatically reset all keys in between each sequence that it types.
|
/// The typer will automatically reset all keys in between each sequence that it types.
|
||||||
/// By default it will pause for one key's duration when doing so. Character mappers
|
/// By default it will pause for one key's duration when doing so. Character mappers
|
||||||
/// can eliminate that pause by overriding this method.
|
/// can eliminate that pause by overriding this method.
|
||||||
/// @returns @c true if the typer should pause after performing a reset; @c false otherwise.
|
/// @returns @c true if the typer should pause after performing a reset; @c false otherwise.
|
||||||
virtual bool needs_pause_after_reset_all_keys() const { return true; }
|
virtual bool needs_pause_after_reset_all_keys() const { return true; }
|
||||||
|
|
||||||
/// The typer will pause between every entry in a keyboard sequence. On some machines
|
/// The typer will pause between every entry in a keyboard sequence. On some machines
|
||||||
/// that may not be necessary — it'll often depends on whether the machine needs time to
|
/// that may not be necessary — it'll often depends on whether the machine needs time to
|
||||||
/// observe a modifier like shift before it sees the actual keypress.
|
/// observe a modifier like shift before it sees the actual keypress.
|
||||||
/// @returns @c true if the typer should pause after forwarding @c key; @c false otherwise.
|
/// @returns @c true if the typer should pause after forwarding @c key; @c false otherwise.
|
||||||
virtual bool needs_pause_after_key([[maybe_unused]] uint16_t key) const { return true; }
|
virtual bool needs_pause_after_key([[maybe_unused]] uint16_t key) const { return true; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
using KeySequence = std::array<uint16_t, 16>;
|
using KeySequence = std::array<uint16_t, 16>;
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
Provided in the base class as a convenience: given the C array of key sequences @c sequences,
|
Provided in the base class as a convenience: given the C array of key sequences @c sequences,
|
||||||
returns the sequence for character @c character if it exists; otherwise returns @c nullptr.
|
returns the sequence for character @c character if it exists; otherwise returns @c nullptr.
|
||||||
*/
|
*/
|
||||||
template <typename Collection> const uint16_t *table_lookup_sequence_for_character(const Collection &sequences, char character) const {
|
template <typename Collection> const uint16_t *table_lookup_sequence_for_character(const Collection &sequences, char character) const {
|
||||||
std::size_t ucharacter = size_t((unsigned char)character);
|
std::size_t ucharacter = size_t((unsigned char)character);
|
||||||
if(ucharacter >= sizeof(sequences) / sizeof(KeySequence)) return nullptr;
|
if(ucharacter >= sizeof(sequences) / sizeof(KeySequence)) return nullptr;
|
||||||
if(sequences[ucharacter][0] == MachineTypes::MappedKeyboardMachine::KeyNotMapped) return nullptr;
|
if(sequences[ucharacter][0] == MachineTypes::MappedKeyboardMachine::KeyNotMapped) return nullptr;
|
||||||
return sequences[ucharacter].data();
|
return sequences[ucharacter].data();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
|
@@ -56,45 +56,45 @@ template <Action action, typename IteratorT, typename SampleT> void fill(Iterato
|
|||||||
*/
|
*/
|
||||||
template <typename SourceT, bool stereo>
|
template <typename SourceT, bool stereo>
|
||||||
class BufferSource {
|
class BufferSource {
|
||||||
public:
|
public:
|
||||||
/*!
|
/*!
|
||||||
Indicates whether this component will write stereo samples.
|
Indicates whether this component will write stereo samples.
|
||||||
*/
|
*/
|
||||||
static constexpr bool is_stereo = stereo;
|
static constexpr bool is_stereo = stereo;
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
Should 'apply' the next @c number_of_samples to @c target ; application means applying @c action which can be achieved either via the
|
Should 'apply' the next @c number_of_samples to @c target ; application means applying @c action which can be achieved either via the
|
||||||
helper functions above — @c apply and @c fill — or by semantic inspection (primarily, if an obvious quick route for @c Action::Ignore is available).
|
helper functions above — @c apply and @c fill — or by semantic inspection (primarily, if an obvious quick route for @c Action::Ignore is available).
|
||||||
|
|
||||||
No default implementation is provided.
|
No default implementation is provided.
|
||||||
*/
|
*/
|
||||||
template <Action action>
|
template <Action action>
|
||||||
void apply_samples(std::size_t number_of_samples, typename SampleT<stereo>::type *target);
|
void apply_samples(std::size_t number_of_samples, typename SampleT<stereo>::type *target);
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
@returns @c true if it is trivially true that a call to get_samples would just
|
@returns @c true if it is trivially true that a call to get_samples would just
|
||||||
fill the target with zeroes; @c false if a call might return all zeroes or
|
fill the target with zeroes; @c false if a call might return all zeroes or
|
||||||
might not.
|
might not.
|
||||||
*/
|
*/
|
||||||
// bool is_zero_level() const { return false; }
|
// bool is_zero_level() const { return false; }
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
Sets the proper output range for this sample source; it should write values
|
Sets the proper output range for this sample source; it should write values
|
||||||
between 0 and volume.
|
between 0 and volume.
|
||||||
*/
|
*/
|
||||||
// void set_sample_volume_range(std::int16_t volume);
|
// void set_sample_volume_range(std::int16_t volume);
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
Permits a sample source to declare that, averaged over time, it will use only
|
Permits a sample source to declare that, averaged over time, it will use only
|
||||||
a certain proportion of the allocated volume range. This commonly happens
|
a certain proportion of the allocated volume range. This commonly happens
|
||||||
in sample sources that use a time-multiplexed sound output — for example, if
|
in sample sources that use a time-multiplexed sound output — for example, if
|
||||||
one were to output only every other sample then it would return 0.5.
|
one were to output only every other sample then it would return 0.5.
|
||||||
|
|
||||||
This is permitted to vary over time but there is no contract as to when it will be
|
This is permitted to vary over time but there is no contract as to when it will be
|
||||||
used by a speaker. If it varies, it should do so very infrequently and only to
|
used by a speaker. If it varies, it should do so very infrequently and only to
|
||||||
represent changes in hardware configuration.
|
represent changes in hardware configuration.
|
||||||
*/
|
*/
|
||||||
double average_output_peak() const { return 1.0; }
|
double average_output_peak() const { return 1.0; }
|
||||||
};
|
};
|
||||||
|
|
||||||
///
|
///
|
||||||
|
Reference in New Issue
Block a user