574 lines
18 KiB
C++
574 lines
18 KiB
C++
#define _CRT_SECURE_NO_WARNINGS
|
|
#include <stdio.h>
|
|
#include <conio.h>
|
|
|
|
#include <stdint.h>
|
|
#include <portaudio.h>
|
|
#include <math.h>
|
|
#include <vector>
|
|
#include <fstream>
|
|
#include <queue>
|
|
|
|
#include "wavehead.h"
|
|
#include "opmplay.h"
|
|
#include "vgm.h"
|
|
#include "rss.h" // YM2608 internal ADPCM-A ROM dump
|
|
#include "ymfm/src/ymfm_opn.h"
|
|
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#define WIN32_EXTRA_LEAN
|
|
#define NOMINMAX
|
|
#include <Windows.h>
|
|
|
|
enum {
|
|
CHANNELS = 2,
|
|
FRAMES_PER_BUFFER = 512,
|
|
MAX_FRAMES_PER_BUFFER = 4096,
|
|
};
|
|
|
|
//const double OPN_CLOCK_RATE = 3500000;
|
|
const double OPN_CLOCK_RATE = 3540000;
|
|
|
|
PaStreamParameters outputParameters;
|
|
PaStream* stream;
|
|
|
|
// console stuff
|
|
struct {
|
|
HANDLE hStdout, hScreenBuffer;
|
|
COORD bufcoord, bufsize;
|
|
SMALL_RECT bufDestRect;
|
|
CHAR_INFO* buffer; // the main buffer to write to
|
|
} console;
|
|
|
|
struct opm_context_t {
|
|
// full context
|
|
opmplay_context_t opm;
|
|
opmplay_io_t io;
|
|
|
|
// delay count relative to sample rate
|
|
int32_t delay_count;
|
|
int32_t delay_count_reload;
|
|
};
|
|
|
|
struct vgm_context_t {
|
|
std::vector<uint8_t> vgmfile;
|
|
std::vector<uint8_t>::iterator vgmfile_it;
|
|
VGMHeader* header;
|
|
uint32_t loop_pos;
|
|
uint32_t start, end; // offsets
|
|
|
|
// delay count
|
|
int32_t delay_count;
|
|
|
|
// rescaler for 44100hz delays
|
|
double rescaler;
|
|
};
|
|
|
|
vgm_context_t vgmctx;
|
|
opm_context_t opmctx;
|
|
|
|
class opna_interface_t : public ymfm::ymfm_interface {
|
|
protected:
|
|
int32_t timer_a_count, timer_a_reload;
|
|
public:
|
|
opna_interface_t() : timer_a_count(0), timer_a_reload(0) {};
|
|
uint8_t ymfm_external_read(ymfm::access_class type, uint32_t address) {
|
|
switch (type) {
|
|
case ymfm::ACCESS_ADPCM_A: return YM2608_ADPCM_ROM[address & 0x1FFF];
|
|
default: return 0;
|
|
}
|
|
}
|
|
void ymfm_set_timer(uint32_t tnum, int32_t duration_in_clocks) {
|
|
if (tnum == 0) {
|
|
timer_a_reload = duration_in_clocks;
|
|
}
|
|
}
|
|
void timer_advance(int32_t ticks) {
|
|
// ripped from furnace :grins:
|
|
if (timer_a_reload >= 0) {
|
|
timer_a_count -= ticks;
|
|
if (timer_a_count < 0) {
|
|
m_engine->engine_timer_expired(0);
|
|
timer_a_count += timer_a_reload;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// register write queue
|
|
class opnx_register_queue_t {
|
|
private:
|
|
uint64_t write_delay;
|
|
uint64_t clock;
|
|
struct reg_entry_t {
|
|
uint64_t clock;
|
|
int reg, data;
|
|
};
|
|
std::queue<reg_entry_t> reg_queue;
|
|
ymfm::ym2203* chip;
|
|
public:
|
|
opnx_register_queue_t() : opnx_register_queue_t(nullptr) {}
|
|
opnx_register_queue_t(ymfm::ym2203* _chip) : clock(0), write_delay(0), chip(_chip) {}
|
|
void set_chip(ymfm::ym2203* _chip) { chip = _chip; }
|
|
void reset() {
|
|
clock = 0;
|
|
write_delay = 0;
|
|
while (!reg_queue.empty()) reg_queue.pop(); // flush queue
|
|
}
|
|
void add(int chip, int reg, int data, uint64_t delay) {
|
|
reg_entry_t r;
|
|
r.clock = clock + write_delay;
|
|
r.reg = reg;
|
|
r.data = data;
|
|
reg_queue.push(r);
|
|
write_delay += delay;
|
|
}
|
|
void pop_clock() {
|
|
if (!reg_queue.empty() && (reg_queue.front().clock <= clock)) {
|
|
auto &r = reg_queue.front();
|
|
if (chip) {
|
|
chip->write_address(r.reg);
|
|
chip->write_data(r.data);
|
|
}
|
|
reg_queue.pop();
|
|
}
|
|
clock++; write_delay = 0;
|
|
}
|
|
};
|
|
|
|
opna_interface_t opna_interface[2];
|
|
ymfm::ym2203 *opnachip[2];
|
|
opnx_register_queue_t opna_regqueue[2];
|
|
ymfm::ym2203::output_data opna_out[MAX_FRAMES_PER_BUFFER][2];
|
|
|
|
// hack af
|
|
uint8_t opn_reg_view[2][256];
|
|
|
|
// generic output routine
|
|
void opn_write_reg(int chip, int reg, int data) {
|
|
if (chip >= 2) return;
|
|
opn_reg_view[chip][reg] = data;
|
|
opna_regqueue[chip].add(0, reg, data, 4);
|
|
}
|
|
|
|
// ------------------
|
|
|
|
// draw plain string
|
|
void drawstring(const char* str, unsigned long x, unsigned long y, unsigned char attr) {
|
|
CHAR_INFO* p = (CHAR_INFO*)console.buffer + (console.bufsize.X * y) + x;
|
|
|
|
while (*str != '\0') {
|
|
p->Char.AsciiChar = *str++;
|
|
p->Attributes = attr;
|
|
p++;
|
|
}
|
|
}
|
|
|
|
// draw string with attributes
|
|
// '\0' - end, '\xFF\xaa' - set attribute byte 'aa'
|
|
void drawastring(const char* str, unsigned long x, unsigned long y) {
|
|
CHAR_INFO* p = (CHAR_INFO*)console.buffer + (console.bufsize.X * y) + x;
|
|
|
|
unsigned short attr = 0x07;
|
|
|
|
while (*str != '\0') if (*str == '\xFF') {
|
|
attr = (*++str); str++;
|
|
}
|
|
else {
|
|
p->Char.AsciiChar = *str++;
|
|
p->Attributes = attr;
|
|
p++;
|
|
}
|
|
}
|
|
|
|
// printf
|
|
int tprintf(uint32_t x, uint32_t y, const char* format, ...) {
|
|
char buffer[1024]; // large enough
|
|
va_list arglist;
|
|
|
|
va_start(arglist, format);
|
|
int rtn = vsnprintf(buffer, sizeof(buffer), format, arglist);
|
|
drawastring(buffer, x, y);
|
|
va_end(arglist);
|
|
|
|
return rtn;
|
|
};
|
|
|
|
// -------------------
|
|
|
|
|
|
int console_open() {
|
|
// Get a handle to the STDOUT screen buffer to copy from and
|
|
// create a new screen buffer to copy to.
|
|
|
|
console.hStdout = GetStdHandle(STD_OUTPUT_HANDLE);
|
|
console.hScreenBuffer = CreateConsoleScreenBuffer(
|
|
GENERIC_READ | // read/write access
|
|
GENERIC_WRITE,
|
|
FILE_SHARE_READ |
|
|
FILE_SHARE_WRITE, // shared
|
|
NULL, // default security attributes
|
|
CONSOLE_TEXTMODE_BUFFER, // must be TEXTMODE
|
|
NULL); // reserved; must be NULL
|
|
if (console.hStdout == INVALID_HANDLE_VALUE ||
|
|
console.hScreenBuffer == INVALID_HANDLE_VALUE)
|
|
{
|
|
printf("CreateConsoleScreenBuffer failed - (%d)\n", GetLastError());
|
|
return 1;
|
|
}
|
|
|
|
// resize
|
|
console.bufsize.X = 132;
|
|
console.bufsize.Y = 40;
|
|
SetConsoleScreenBufferSize(console.hScreenBuffer, console.bufsize);
|
|
|
|
// allocate console buffer
|
|
console.buffer = new CHAR_INFO[console.bufsize.X * console.bufsize.Y];
|
|
memset(console.buffer, 0, sizeof(CHAR_INFO) * console.bufsize.X * console.bufsize.Y);
|
|
|
|
// Make the new screen buffer the active screen buffer.
|
|
if (!SetConsoleActiveScreenBuffer(console.hScreenBuffer))
|
|
{
|
|
printf("SetConsoleActiveScreenBuffer failed - (%d)\n", GetLastError());
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
void console_update() {
|
|
console.bufDestRect.Top = 0;
|
|
console.bufDestRect.Left = 0;
|
|
console.bufDestRect.Bottom = console.bufsize.Y - 1;
|
|
console.bufDestRect.Right = console.bufsize.X - 1;
|
|
|
|
console.bufcoord.X = console.bufcoord.Y = 0;
|
|
|
|
WriteConsoleOutput(
|
|
console.hScreenBuffer, // screen buffer to write to
|
|
console.buffer, // buffer to copy from
|
|
console.bufsize, // col-row size of chiBuffer
|
|
console.bufcoord, // top left src cell in chiBuffer
|
|
&console.bufDestRect); // dest. screen buffer rectangle
|
|
}
|
|
|
|
void console_done() {
|
|
SetConsoleActiveScreenBuffer(console.hStdout);
|
|
}
|
|
|
|
// -------------------
|
|
// synth render
|
|
int synth_render(int16_t* buffer, uint32_t num_samples) {
|
|
int samples_to_render = num_samples;
|
|
|
|
memset(buffer, 0, sizeof(int16_t) * 2 * num_samples);
|
|
|
|
while (samples_to_render > 0) {
|
|
if (samples_to_render < opmctx.delay_count) {
|
|
for (int i = 0; i < samples_to_render; i++) {
|
|
for (int chip = 0; chip < 2; chip++) {
|
|
opna_regqueue[chip].pop_clock();
|
|
opnachip[chip]->generate(opna_out[chip] + i, 1);
|
|
opna_interface[chip].timer_advance(24);
|
|
opna_out[chip][i].clamp16();
|
|
buffer[2*i+0] += 0.5*(0.5 * opna_out[chip][i].data[0] + 0.5 * (1.0*opna_out[chip][i].data[1] + 0.5*opna_out[chip][i].data[2] + 0.0*opna_out[chip][i].data[3])); // mix FM and SSG
|
|
buffer[2*i+1] += 0.5*(0.5 * opna_out[chip][i].data[0] + 0.5 * (0.0*opna_out[chip][i].data[1] + 0.5*opna_out[chip][i].data[2] + 1.0*opna_out[chip][i].data[3]));
|
|
}
|
|
}
|
|
|
|
opmctx.delay_count -= samples_to_render;
|
|
buffer += 2 * samples_to_render;
|
|
break;
|
|
}
|
|
else {
|
|
// calculate new delay
|
|
for (int i = 0; i < opmctx.delay_count; i++) {
|
|
for (int chip = 0; chip < 2; chip++) {
|
|
opna_regqueue[chip].pop_clock();
|
|
opnachip[chip]->generate(opna_out[chip] + i, 1);
|
|
opna_interface[chip].timer_advance(24);
|
|
opna_out[chip][i].clamp16();
|
|
buffer[2*i+0] += 0.5*(0.5 * opna_out[chip][i].data[0] + 0.5 * (1.0*opna_out[chip][i].data[1] + 0.5*opna_out[chip][i].data[2] + 0.0*opna_out[chip][i].data[3])); // mix FM and SSG
|
|
buffer[2*i+1] += 0.5*(0.5 * opna_out[chip][i].data[0] + 0.5 * (0.0*opna_out[chip][i].data[1] + 0.5*opna_out[chip][i].data[2] + 1.0*opna_out[chip][i].data[3]));
|
|
}
|
|
}
|
|
samples_to_render -= opmctx.delay_count;
|
|
buffer += 2 * opmctx.delay_count;
|
|
|
|
opmplay_tick(&opmctx.opm);
|
|
opmctx.delay_count = opmctx.delay_count_reload;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
|
|
int pa_init(double sample_rate) {
|
|
PaError err;
|
|
|
|
// init portaudio
|
|
err = Pa_Initialize();
|
|
if (err != paNoError) return 1;
|
|
|
|
outputParameters.device = Pa_GetDefaultOutputDevice(); /* default output device */
|
|
if (outputParameters.device == paNoDevice) {
|
|
fprintf(stderr, "Error: No default output device.\n");
|
|
return 1;
|
|
}
|
|
outputParameters.channelCount = CHANNELS;
|
|
outputParameters.sampleFormat = paInt16;
|
|
outputParameters.suggestedLatency = 0.04;
|
|
outputParameters.hostApiSpecificStreamInfo = NULL;
|
|
|
|
err = Pa_OpenStream(
|
|
&stream,
|
|
NULL, /* no input */
|
|
&outputParameters,
|
|
sample_rate,
|
|
FRAMES_PER_BUFFER,
|
|
0, /* we won't output out of range samples so don't bother clipping them */
|
|
NULL, /* no callback, use blocking API */
|
|
NULL); /* no callback, so no callback userData */
|
|
if (err != paNoError) return 1;
|
|
|
|
err = Pa_StartStream(stream);
|
|
if (err != paNoError) return 1;
|
|
|
|
return 0;
|
|
}
|
|
|
|
int pa_write(void* data, int32_t count) {
|
|
PaError err;
|
|
err = Pa_WriteStream(stream, data, count);
|
|
return 0;
|
|
}
|
|
|
|
int pa_done() {
|
|
PaError err;
|
|
err = Pa_StopStream(stream);
|
|
if (err != paNoError) return 1;
|
|
|
|
// deinit portaudio
|
|
err = Pa_CloseStream(stream);
|
|
if (err != paNoError) return 1;
|
|
|
|
Pa_Terminate();
|
|
return 0;
|
|
}
|
|
|
|
int main(int argc, char* argv[])
|
|
{
|
|
bool render_to_wave = (argc >= 3);
|
|
|
|
uint32_t sample_rate;
|
|
for (int chip = 0; chip < 2; chip++) {
|
|
opnachip[chip] = new ymfm::ym2203(opna_interface[chip]);
|
|
if (opnachip[chip] == nullptr) {
|
|
printf("error: unable to init ymfm!\n");
|
|
return 1;
|
|
}
|
|
opnachip[chip]->reset();
|
|
opnachip[chip]->set_fidelity(ymfm::OPN_FIDELITY_MIN);
|
|
sample_rate = opnachip[chip]->sample_rate(OPN_CLOCK_RATE);
|
|
vgmctx.rescaler = ((double)sample_rate / 44100.0);
|
|
printf("sample rate - %d hz\n", sample_rate);
|
|
|
|
opna_regqueue[chip].reset();
|
|
opna_regqueue[chip].set_chip(opnachip[chip]);
|
|
}
|
|
|
|
if (!render_to_wave) {
|
|
if (pa_init(sample_rate) != 0) {
|
|
printf("error: unable to init PortAudio!\n");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
FILE* f = fopen(argv[1], "rb");
|
|
if (f == NULL) {
|
|
printf("error: unable to open file!\n");
|
|
return 1;
|
|
}
|
|
|
|
#if 1
|
|
opmctx.io.type = OPMPLAY_IO_FILE;
|
|
opmctx.io.io = f;
|
|
|
|
int rtn;
|
|
|
|
if ((rtn = opmplay_init(&opmctx.opm)) != OPMPLAY_ERR_OK) {
|
|
printf("unable to init OPMPlay (error = %d)\n", rtn);
|
|
return 1;
|
|
}
|
|
if ((rtn = opmplay_load_header(&opmctx.opm, &opmctx.io)) != OPMPLAY_ERR_OK) {
|
|
printf("unable to load OPM header (error = %d)\n", rtn);
|
|
return 1;
|
|
};
|
|
if ((rtn = opmplay_load_module(&opmctx.opm, &opmctx.io)) != OPMPLAY_ERR_OK) {
|
|
printf("unable to load OPM module (error = %d)\n", rtn);
|
|
return 1;
|
|
};
|
|
opmctx.delay_count_reload = opmctx.delay_count = ((double)sample_rate / 50.0);
|
|
#else
|
|
// open VGM file, ready to parse
|
|
std::ifstream infile(argv[1], std::ios::in | std::ios::binary);
|
|
infile.unsetf(std::ios::skipws);
|
|
|
|
// get filesize
|
|
infile.seekg(0, std::ios::end);
|
|
uint64_t fsize = infile.tellg();
|
|
infile.seekg(0, std::ios::beg);
|
|
|
|
// read whole file
|
|
vgmctx.vgmfile.reserve(fsize);
|
|
vgmctx.vgmfile.insert(vgmctx.vgmfile.begin(), std::istream_iterator<uint8_t>(infile), std::istream_iterator<uint8_t>());
|
|
|
|
// get header
|
|
vgmctx.header = reinterpret_cast<VGMHeader*>(vgmctx.vgmfile.data());
|
|
|
|
// check header
|
|
if (memcmp(vgmctx.header->id, "Vgm\x20", sizeof(vgmctx.header->id)) != 0) {
|
|
printf("not a vaild VGM file!\n");
|
|
return 1;
|
|
}
|
|
|
|
// parse basic VGM structure
|
|
printf("VGM %d.%d file found\n", (vgmctx.header->version >> 8) & 0xFF, vgmctx.header->version & 0xFF);
|
|
if (vgmctx.header->loopOffset != 0) vgmctx.loop_pos = vgmctx.header->loopOffset + offsetof(VGMHeader, loopOffset);
|
|
vgmctx.end = vgmctx.header->eofOffset + offsetof(VGMHeader, eofOffset);
|
|
vgmctx.start = ((vgmctx.header->version < 0x150) ? 0x40 : vgmctx.header->dataOffset + offsetof(VGMHeader, dataOffset));
|
|
vgmctx.vgmfile_it = vgmctx.vgmfile.begin() + vgmctx.start;
|
|
vgmctx.delay_count = 0;
|
|
#endif
|
|
|
|
console_open();
|
|
std::vector<int16_t> wavedata;
|
|
|
|
int ff_pos = 0, ff_counter = 0;
|
|
int16_t buf[FRAMES_PER_BUFFER * CHANNELS] = { 0 };
|
|
while (1) {
|
|
int rtn = synth_render(buf, FRAMES_PER_BUFFER);
|
|
if (render_to_wave) {
|
|
wavedata.insert(wavedata.end(), buf, buf + FRAMES_PER_BUFFER * CHANNELS);
|
|
}
|
|
else {
|
|
pa_write(buf, FRAMES_PER_BUFFER);
|
|
}
|
|
ff_pos += FRAMES_PER_BUFFER;
|
|
|
|
// update console
|
|
memset(console.buffer, 0, sizeof(CHAR_INFO) * console.bufsize.X * console.bufsize.Y);
|
|
|
|
tprintf(0, 0, "frame = %d", opmctx.opm.pos.frame);
|
|
|
|
{
|
|
int yy = 2;
|
|
for (int ch = 0; ch < 6; ch++) {
|
|
int cc = ch / 3;
|
|
int co = ch % 3;
|
|
tprintf(0, yy, "FM%d: [%02X %02X %02X %02X %02X %02X %02X] [%02X %02X %02X %02X %02X %02X %02X] [%02X %02X %02X %02X %02X %02X %02X] [%02X %02X %02X %02X %02X %02X %02X] - %02X %02X %02X",
|
|
ch,
|
|
opn_reg_view[cc][0x30 + co],
|
|
opn_reg_view[cc][0x40 + co],
|
|
opn_reg_view[cc][0x50 + co],
|
|
opn_reg_view[cc][0x60 + co],
|
|
opn_reg_view[cc][0x70 + co],
|
|
opn_reg_view[cc][0x80 + co],
|
|
opn_reg_view[cc][0x90 + co],
|
|
|
|
opn_reg_view[cc][0x34 + co],
|
|
opn_reg_view[cc][0x44 + co],
|
|
opn_reg_view[cc][0x54 + co],
|
|
opn_reg_view[cc][0x64 + co],
|
|
opn_reg_view[cc][0x74 + co],
|
|
opn_reg_view[cc][0x84 + co],
|
|
opn_reg_view[cc][0x94 + co],
|
|
|
|
opn_reg_view[cc][0x38 + co],
|
|
opn_reg_view[cc][0x48 + co],
|
|
opn_reg_view[cc][0x58 + co],
|
|
opn_reg_view[cc][0x68 + co],
|
|
opn_reg_view[cc][0x78 + co],
|
|
opn_reg_view[cc][0x88 + co],
|
|
opn_reg_view[cc][0x98 + co],
|
|
|
|
opn_reg_view[cc][0x3C + co],
|
|
opn_reg_view[cc][0x4C + co],
|
|
opn_reg_view[cc][0x5C + co],
|
|
opn_reg_view[cc][0x6C + co],
|
|
opn_reg_view[cc][0x7C + co],
|
|
opn_reg_view[cc][0x8C + co],
|
|
opn_reg_view[cc][0x9C + co],
|
|
|
|
opn_reg_view[cc][0xA0 + co],
|
|
opn_reg_view[cc][0xA4 + co],
|
|
opn_reg_view[cc][0xB0 + co]
|
|
);
|
|
|
|
yy++;
|
|
|
|
if ((ch % 3) == 2) {
|
|
tprintf(0, yy, "EC%d: [%02X %02X %02X %02X %02X %02X %02X %02X]",
|
|
cc,
|
|
opn_reg_view[cc][0xAD],
|
|
opn_reg_view[cc][0xA9],
|
|
opn_reg_view[cc][0xAC],
|
|
opn_reg_view[cc][0xA8],
|
|
opn_reg_view[cc][0xAE],
|
|
opn_reg_view[cc][0xAA],
|
|
opn_reg_view[cc][0xA6],
|
|
opn_reg_view[cc][0xA2]
|
|
);
|
|
yy++;
|
|
}
|
|
}
|
|
}
|
|
|
|
console_update();
|
|
|
|
if (_kbhit()) {
|
|
_getch();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// write wave file
|
|
if (render_to_wave) {
|
|
// create headers
|
|
RIFF_Header riffHeader;
|
|
memcpy(&riffHeader.id, "RIFF", sizeof(riffHeader.id));
|
|
memcpy(&riffHeader.fourcc, "WAVE", sizeof(riffHeader.fourcc));
|
|
riffHeader.size = sizeof(riffHeader.fourcc) + sizeof(fmt_Header) + sizeof(chunk_Header) + (wavedata.size() * sizeof(decltype(wavedata)::value_type));
|
|
|
|
fmt_Header fmtHeader;
|
|
memcpy(&fmtHeader.id, "fmt ", sizeof(fmtHeader.id));
|
|
fmtHeader.size = sizeof(fmtHeader) - 8;
|
|
fmtHeader.wFormatTag = 1; // plain uncompressed PCM
|
|
fmtHeader.nSamplesPerSec = sample_rate;
|
|
fmtHeader.nBlockAlign = CHANNELS;
|
|
fmtHeader.nAvgBytesPerSec = sample_rate * CHANNELS;
|
|
fmtHeader.nChannels = CHANNELS;
|
|
fmtHeader.wBitsPerSample = 8;
|
|
|
|
chunk_Header dataHeader;
|
|
memcpy(&dataHeader.id, "data", sizeof(dataHeader.id));
|
|
dataHeader.size = (wavedata.size() * sizeof(decltype(wavedata)::value_type));
|
|
|
|
// write wave file
|
|
FILE* outfile = fopen("out.wav", "wb");
|
|
|
|
fwrite(&riffHeader, sizeof(riffHeader), 1, outfile);
|
|
fwrite(&fmtHeader, sizeof(fmtHeader), 1, outfile);
|
|
fwrite(&dataHeader, sizeof(dataHeader), 1, outfile);
|
|
fwrite(wavedata.data(), (wavedata.size() * sizeof(decltype(wavedata)::value_type)), 1, outfile);
|
|
|
|
fclose(outfile);
|
|
} else
|
|
pa_done();
|
|
|
|
console_done();
|
|
|
|
return 0;
|
|
}
|