Files
VGMPLAY.WMF/src_sdcc/main.c
T
alex 54112347f5 Add large-file (>1MB) OPL4 loading and fix track-end hang
- One-shot cold loader (asm/opl4_loader.asm, overlaid into plugin
  page 5) uploads embedded PCM sample blocks into ZXM-MoonSound SRAM
  and compacts the VGM stream in place, letting files over the normal
  64-page megabuffer budget play back through the existing pipeline.
- instant_abort() was filling only one of the two ISR command buffers
  with the shutdown chain; the chain (SILENCE_OPL + WAVE_OFF/TL +
  CMD_ISR_DONE) doesn't fit in one buffer, so after the first
  CMD_END_BUF the ISR swapped into stale data and never reached
  CMD_ISR_DONE, hanging at the end of long tracks instead of
  advancing to the next one. Now fills both buffers while frozen and
  syncs update_buffer()'s own ready-tracking state.
- Switch to 14MHz before the SRAM upload (previously ran at whatever
  speed WC's file I/O left active) and show "Loading..." for .vgz and
  files >= 1MB, since the busy-waited upload takes several seconds.
- Confirmed on real hardware: long OPL4 tracks (previously stuck
  looping at the end) now finish and advance normally, ESC exits
  cleanly.
2026-09-16 20:14:06 +03:00

1376 lines
50 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* main.c — VGM Player Plugin для Wild Commander (SDCC/Z80)
*
* Поддерживаемые форматы:
* VGM (Raw) — OPL1/OPL2/OPL3/OPL4(YMF278B, cmd 0xD0), AY-3-8910/YM2149/YM2203, SAA1099
* VGZ (gzip) — автораспаковка через inflate
*
* Функции:
* - Отображение метаданных (GD3: автор, игра, трек, система)
* - Воспроизведение с двойной буферизацией (ISR 1367 Гц)
* - OPL3/OPL4: NEW=1 (L/R через C0-C8); OPL1/OPL2: NEW=0 (compat mode)
* - OPL4 (ZXM-MoonSound): NEW2=1 дополнительно разблокирует wave-часть
* (порты #7E/#7F); FM-часть (порты 0/1 команды 0xD0) идёт через те же
* регистры/порты #C4-#C7, что и OPL3
*
* Управление (PS/2 клавиатура):
* ESC — выход
* SPACE — следующий файл в каталоге
* P — предыдущий файл
* 1/2 — SAA режим (chip0 / chip1) для dual музыки
*/
#include "inc/types.h"
#include "inc/tsconf.h"
#include "inc/wc_api.h"
#include "inc/txtlib.h"
#include "inc/vgm.h"
#include "inc/isr.h"
#include "inc/keys.h"
#include "inc/version.h"
#include <string.h>
/* Хелперы stringify для compile-time ISR_FREQ / budget в заголовке */
#define _STR(x) #x
#define STR(x) _STR(x)
/* ── Параметры progress bar для inflate (определены в inflate_call.s _DATA) ── */
extern uint8_t ifl_pb_text_pg;
extern uint16_t ifl_pb_attr_ofs;
extern uint8_t ifl_pb_total;
extern uint8_t ifl_pb_bw;
extern uint8_t ifl_pb_green;
/* ── Бордюр-отладка ─────────────────────────────────────────────────── */
/* Включить DEBUG_BORDER для отображения времени fill_buffer через цвет бордюра */
/* #define DEBUG_BORDER */
#ifdef DEBUG_BORDER
static void border(uint8_t c) { sfr_zx_fe = c; }
#endif
/* ── Отладка через порт (emulator "Illegal port!" log) ────────────── */
/* Включите DBG_ENABLE для отладочного вывода в порт; по умолчанию выкл */
/* #define DBG_ENABLE */
#ifdef DBG_ENABLE
#define DBG_STATUS 0xFFAF
#define DBG_DATA1 0xFEAF
#define DBG_DATA2 0xFDAF
static void dbg_trace(uint8_t code) __naked {
(void)code;
__asm
; A = code (sdcccall)
ld bc, #0xFFAF
out (c), a
ret
__endasm;
}
static void dbg_trace2(uint8_t code, uint8_t d1, uint8_t d2) {
(void)d1; (void)d2;
__asm
; A=code, stack: d1, d2 (sdcccall: A=first, stack rest)
ld bc, #0xFFAF
out (c), a
; d1 = 2(sp), d2 = 3(sp) — behind ret addr
ld hl, #2
add hl, sp
ld a, (hl)
ld bc, #0xFEAF
out (c), a
inc hl
ld a, (hl)
ld bc, #0xFDAF
out (c), a
__endasm;
}
#else
#define dbg_trace(code) ((void)0)
#define dbg_trace2(code, d1, d2) ((void)0)
#endif
/* ── Кешированные флаги активных чипов ────────────────────────────── */
static uint8_t s_has_opl, s_has_ay, s_has_ay2;
static uint8_t s_has_saa, s_has_saa2;
static uint8_t s_has_ym2203, s_has_ym2203_2;
/* SAA dual mode config (defined in vgm.c) */
extern uint8_t cfg_saa_mode;
static uint8_t s_saa_mode_user; /* user setting from INI (preserved across tracks) */
static uint8_t s_board_dual_saa; /* 0=одночиповая(default), 1=двухчиповая (INI param 4) */
static void detect_active_chips(void) __naked
{
__asm
; The seven cache bytes are declared consecutively above.
xor a
ld hl, #_s_has_opl
ld b, #7
dac_clear:
ld (hl), a
inc hl
djnz dac_clear
ld a, (_vgm_chip_count)
or a
ret z
ld b, a
ld de, #_vgm_chip_list
dac_loop:
ld a, (de) ; id
ld l, a
inc de
ld a, (de) ; flags (bit 0 = dual)
ld c, a
inc de ; skip flags + clock_khz (2 bytes)
inc de
inc de
ld a, l
cp a, #0x44 ; YM2203
jr z, dac_ym2203
cp a, #0x74 ; AY8910
jr z, dac_ay
cp a, #0xC8 ; SAA1099
jr z, dac_saa
; OPL ids are 50h,54h,58h,5Ch,60h: one aligned range check.
cp a, #0x50
jr c, dac_next
cp a, #0x61
jr nc, dac_next
and a, #0x03
jr nz, dac_next
ld a, #1
ld (_s_has_opl), a
jr dac_next
dac_ym2203:
ld a, #1
ld (_s_has_ym2203), a
bit 0, c
jr z, dac_ay
ld (_s_has_ym2203_2), a
; YM2203 also contains an AY-compatible PSG, so fall through.
dac_ay:
ld a, #1
ld (_s_has_ay), a
bit 0, c
jr z, dac_next
ld (_s_has_ay2), a
jr dac_next
dac_saa:
ld a, #1
ld (_s_has_saa), a
bit 0, c
jr z, dac_next
ld (_s_has_saa2), a
dac_next:
djnz dac_loop
ret
__endasm;
}
/* cfg_loop_rewinds, cfg_min_duration, cfg_max_duration определены в vgm.c */
/* ── HL queue helpers ────────────────────────────────────────────────
* The queue itself and its state live in vgm.c (vgm_hl_queue etc.)
* vgm_fill_buffer() reads them internally; main.c only builds
* the queue and sets vgm_hl_pos on abort (Q/N/P). */
static void hl_push(uint8_t cmd, uint8_t param)
{
if (vgm_hl_len < HL_QUEUE_MAX) {
vgm_hl_queue[vgm_hl_len].cmd = cmd;
vgm_hl_queue[vgm_hl_len].param = param;
vgm_hl_len++;
}
}
/* CMDBLK_SILENCE_OPL (+ CMDBLK_SILENCE_WAVE для OPL4) — общий кусок,
* нужен и в начале трека, и в abort-последовательности выхода. */
static void hl_push_opl_silence(void)
{
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_OPL);
if (vgm_chip_type == VGM_CHIP_OPL4) {
/* Два блока по 24 записи: вместе с OPL silence они не помещаются
* в один ISR-буфер, а cmdblock нельзя продолжать с середины. */
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_WAVE_OFF);
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_WAVE_TL);
}
}
/* ── Построение очереди воспроизведения по обнаруженным чипам / loop info ─ */
static void build_playback_queue(void)
{
vgm_hl_len = 0;
/* Фаза инициализации: silence (очистка остаточного состояния) + init */
if (s_has_opl) {
hl_push_opl_silence();
hl_push(HLCMD_CMDBLK,
(vgm_chip_type == VGM_CHIP_OPL4) ? CMDBLK_INIT_OPL4
: (vgm_chip_type == VGM_CHIP_OPL3) ? CMDBLK_INIT_OPL3
: CMDBLK_INIT_OPL2);
}
if (s_has_ym2203)
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_YM2203);
if (s_has_ym2203_2)
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_YM2203_2);
/* SAA: гасим saa2, потом saa1 (даже на одночиповой плате),
* затем clock ON, затем Sound Enable по режиму.
* cmdblk bypass гарантирует прохождение команд chip2. */
if (s_has_saa) {
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_SAA2);
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_SAA);
hl_push(HLCMD_CMDBLK, CMDBLK_SAA_CLK_ON);
hl_push(HLCMD_CMDBLK, CMDBLK_SAA_INIT);
if (cfg_saa_mode == 2)
hl_push(HLCMD_CMDBLK, CMDBLK_SAA2_INIT);
}
/* Основное воспроизведение */
hl_push(HLCMD_PLAY, 0);
/* Loop repeats — только если vgm_parse_header разрешил loop.
* Policy: без full-track loop, без коротких (<10с), без длинных (>4мин). */
if (vgm_loop_enabled) {
for (uint8_t i = 0; i < cfg_loop_rewinds; i++)
hl_push(HLCMD_LOOP, 0);
}
/* ── Последовательность завершения (abort target) ──────────────── */
vgm_hl_abort_pos = vgm_hl_len;
if (s_has_opl)
hl_push_opl_silence();
if (s_has_ym2203)
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_YM2203);
else if (s_has_ay)
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_AY);
if (s_has_ym2203_2)
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_YM2203_2);
else if (s_has_ay2)
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_AY2);
if (s_has_saa) {
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_SAA2);
hl_push(HLCMD_CMDBLK, CMDBLK_SILENCE_SAA);
hl_push(HLCMD_CMDBLK, CMDBLK_SAA_CLK_OFF);
}
hl_push(HLCMD_ISR_DONE, 0);
vgm_hl_pos = 0;
}
/* ── Размеры и цвета окна ───────────────────────────────────────────── */
/* Координаты вычисляются динамически в main() через wc_get_height().
* TextMode=0: 80×25, TextMode=1: 80×30, TextMode=2: 90×36. */
#define WND_W 62
#define WND_H 22
#define CLR_WIN WC_COLOR(WC_WHITE, WC_BLACK)
#define CLR_TITLE WC_COLOR(WC_BLUE, WC_YELLOW)
static const char s_win_title[] = " VGM Player " APP_VERSION " ";
/* ── Фиксированные строки окна (1-based content rows) ───────────────
* Высота=23 → строки контента 1..21, рамка 0 и 22.
* Стр 1: Заголовок
* Стр 2-4: Информация о файле
* Стр 5-6: (пусто)
* Стр 7: ← разделитель 1
* Стр 8-14: Информация VGM (ver, чипы, луп, freq, GD3)
* Стр 15: ← разделитель 2
* Стр 16: Прогресс-бар
* Стр 17-20: Спектро-анализатор (4 строки)
* Стр 21: Подсказки клавиш */
#define ROW_TITLE 1
#define ROW_FILE_START 2
#define ROW_VGM_START 6
#define ROW_VGM_END 13 /* максимальная строка для VGM/GD3 info */
#define ROW_PROGRESS 15
#define ROW_SPECTRUM 16 /* 4 rows: 16,17,18,19 */
#define ROW_HELP 20
#define DIV1_FROM_BOTTOM 16 /* row 7 = 23-16 */
#define DIV2_FROM_BOTTOM 7 /* row 15 = 23-8 */
static wc_window_t s_wnd;
static uint8_t s_pages;
/* pbinfo_start_row заменён константой ROW_PROGRESS */
static uint8_t s_playback_inited = 0;
static uint8_t s_last_active_buf = 0;
static uint8_t s_buf_ready[2] = {0, 0};
static uint8_t s_last_loop_count = 0;
static uint16_t s_last_displayed_sec = 0; /* 0 = совпадает с начальным sec */
/* BCD-цифры времени (инкремент вместо делений) ─────────────────────── */
static uint8_t s_pb_d_min10 = '0';
static uint8_t s_pb_d_min1 = '0';
static uint8_t s_pb_d_sec10 = '0';
static uint8_t s_pb_d_sec1 = '0';
/* ── Progress bar state (предвычисленные параметры) ───────────────── */
uint8_t s_pb_text_pg; /* физ. страница текстового экрана (не static: ASM) */
static uint16_t s_pb_char_ofs; /* смещение символов в текстовой странице */
static uint16_t s_pb_attr_ofs; /* смещение атрибутов в текстовой странице */
static uint8_t s_pb_width; /* ширина полосы в колонках */
static uint16_t s_pb_err; /* аккумулятор ошибки Bresenham */
static uint8_t s_pb_col; /* текущее кол-во зелёных колонок */
/* Позиции изменяемых символов внутри строки progress bar:
* "Playing MM:SS / MM:SS Loop N"
* 0 9 17 27 29 */
#define PB_OFS_MIN10 9
#define PB_OFS_MIN1 10
#define PB_OFS_SEC10 12
#define PB_OFS_SEC1 13
#define PB_OFS_LOOP 29
/* ── Рендеринг spectrum analyzer (ASM: asm/spectrum.s) ──────────────
* 16 полос, 4 строки (ROW_SPECTRUM..ROW_SPECTRUM+3), снизу вверх.
* spectrum_render() и хелперы горячего цикла — в asm/spectrum.s.
* Переменные ниже не static, чтобы ASM мог обращаться по _symbol. */
/* Предвычисленные адреса для прямого доступа к текстовой странице */
uint16_t s_spec_char_ofs[4]; /* смещения символов для 4 строк */
uint16_t s_spec_attr_ofs[4]; /* смещения атрибутов для 4 строк */
/* spectrum_render/font/decay() находятся в asm/spectrum.s */
void spectrum_render(void);
void spectrum_decay(void);
void spectrum_decay_reset(void);
void spectrum_font_init(void);
void spectrum_font_restore(void);
/* Информация VGZ для отображения */
static uint8_t s_is_vgz;
static uint32_t s_vgm_unpacked_size;
static uint8_t s_vgz_compressed_pages;
static inline void ints_disable(void)
{
__asm di
__endasm;
}
static inline void ints_enable(void)
{
__asm ei
__endasm;
}
/* ── Имя OPL-типа ────────────────────────────────────────────────────── */
static const char *opl_name(void)
{
if (vgm_chip_type == VGM_CHIP_OPL)
return "OPL1 YM3526";
if (vgm_chip_type == VGM_CHIP_OPL2)
return "OPL2 YM3812";
if (vgm_chip_type == VGM_CHIP_OPL3)
return "OPL3 YMF262";
if (vgm_chip_type == VGM_CHIP_OPL4)
return "OPL4 YMF278B";
return "--- (no OPL)";
}
char work_buf[96 + 2];
char error_buf[96 + 2];
enum state_t
{
STATE_INIT,
STATE_LOADING,
STATE_ERROR,
STATE_PARSE_HEADER,
STATE_PLAYBACK
} state;
/* ── Предварительный вывод информации (до загрузки/распаковки) ──────── */
static void draw_pre_load_info(void)
{
uint8_t row = ROW_FILE_START;
buf_clear(work_buf);
buf_append_str(work_buf, "File name : ");
buf_append_str(work_buf, wc_file_name);
print_line(&s_wnd, row++, work_buf, CLR_WIN);
buf_clear(work_buf);
buf_append_str(work_buf, "File size : ");
buf_append_u32_dec(work_buf, wc_file_size);
print_line(&s_wnd, row++, work_buf, CLR_WIN);
buf_clear(work_buf);
buf_append_str(work_buf, "16Kb pages : ");
buf_append_u16_dec(work_buf, (uint16_t)((wc_file_size + 16383U) >> 14));
print_line(&s_wnd, row, work_buf, CLR_WIN);
}
uint8_t drow_ui(void)
{
uint8_t row;
uint8_t gd3_count = 0;
/* ── Секция 1: Информация о файле (строки 1-3) ────────────── */
row = ROW_FILE_START;
buf_clear(work_buf);
buf_append_str(work_buf, "File name : ");
buf_append_str(work_buf, wc_file_name);
print_line(&s_wnd, row++, work_buf, CLR_WIN);
buf_clear(work_buf);
buf_append_str(work_buf, "File size : ");
buf_append_u32_dec(work_buf, wc_file_size);
if (s_is_vgz) {
buf_append_str(work_buf, " / ");
buf_append_u32_dec(work_buf, s_vgm_unpacked_size);
}
print_line(&s_wnd, row++, work_buf, CLR_WIN);
buf_clear(work_buf);
buf_append_str(work_buf, "16Kb pages : ");
if (s_is_vgz) {
buf_append_u16_dec(work_buf, s_vgz_compressed_pages);
buf_append_str(work_buf, " / ");
}
buf_append_u16_dec(work_buf, s_pages);
print_line(&s_wnd, row, work_buf, CLR_WIN);
/* ── Секция 2: Данные VGM (строки 5..ROW_VGM_END) ────────── */
row = ROW_VGM_START;
buf_clear(work_buf);
buf_append_str(work_buf, "VGM version : ");
buf_append_u8_hex(work_buf, vgm_version >> 8);
buf_append_char(work_buf, '.');
buf_append_u8_hex(work_buf, vgm_version & 0xFF);
print_line(&s_wnd, row++, work_buf, CLR_WIN);
for (uint8_t i = 0; i < vgm_chip_count && row <= ROW_VGM_END; i++)
{
/* Компактно: макс. 2 полных строки чипов; остальные как "+ N more" */
if (i >= 2 && vgm_chip_count > 3) {
buf_clear(work_buf);
buf_append_str(work_buf, " + ");
buf_append_u16_dec(work_buf, (uint16_t)(vgm_chip_count - 2));
buf_append_str(work_buf, " more");
print_line(&s_wnd, row++, work_buf, CLR_WIN);
break;
}
const vgm_chip_entry_t *chip = &vgm_chip_list[i];
buf_clear(work_buf);
buf_append_str(work_buf, "Chip : ");
{
const char *cname = vgm_chip_name(chip->id);
if (cname) {
buf_append_str(work_buf, cname);
} else {
buf_append_str(work_buf, "? (0x");
buf_append_u8_hex(work_buf, chip->id);
buf_append_char(work_buf, ')');
}
}
buf_append_str(work_buf, " @ ");
buf_append_u16_dec(work_buf, chip->clock_khz);
buf_append_str(work_buf, " kHz");
if (chip->flags & VGM_CF_DUAL)
buf_append_str(work_buf, " x2");
print_line(&s_wnd, row++, work_buf, CLR_WIN);
}
/* Информация о loop */
if (row <= ROW_VGM_END) {
buf_clear(work_buf);
buf_append_str(work_buf, "Loop : ");
if (vgm_loop_addr && vgm_loop_enabled)
buf_append_str(work_buf, "Yes");
else if (vgm_loop_addr && !vgm_loop_enabled)
buf_append_str(work_buf, "Skip");
else
buf_append_str(work_buf, "No");
print_line(&s_wnd, row++, work_buf,
(vgm_loop_addr && !vgm_loop_enabled)
? WC_COLOR(WC_YELLOW, WC_BLACK)
: CLR_WIN);
}
/* Информация о режиме частот */
if (row <= ROW_VGM_END) {
buf_clear(work_buf);
buf_append_str(work_buf, "Freq : ");
if (vgm_freq_mode == FREQ_MODE_TABLE) {
buf_append_str(work_buf, "Table");
} else {
buf_append_str(work_buf, "Native");
}
print_line(&s_wnd, row++, work_buf,
(vgm_freq_mode == FREQ_MODE_NATIVE)
? CLR_WIN
: WC_COLOR(WC_WHITE, WC_BLUE));
}
/* ── GD3 метаданные (English, max 4) ──────────────────────────── */
if (vgm_gd3_track[0] && row <= ROW_VGM_END && gd3_count < 4) {
buf_clear(work_buf);
buf_append_str(work_buf, "Title : ");
buf_append_str(work_buf, vgm_gd3_track);
print_line(&s_wnd, row++, work_buf, WC_COLOR(WC_YELLOW, WC_BLACK));
gd3_count++;
}
if (vgm_gd3_game[0] && row <= ROW_VGM_END && gd3_count < 4) {
buf_clear(work_buf);
buf_append_str(work_buf, "Game : ");
buf_append_str(work_buf, vgm_gd3_game);
print_line(&s_wnd, row++, work_buf, WC_COLOR(WC_YELLOW, WC_BLACK));
gd3_count++;
}
if (vgm_gd3_author[0] && row <= ROW_VGM_END && gd3_count < 4) {
buf_clear(work_buf);
buf_append_str(work_buf, "Author : ");
buf_append_str(work_buf, vgm_gd3_author);
print_line(&s_wnd, row++, work_buf, WC_COLOR(WC_YELLOW, WC_BLACK));
gd3_count++;
}
if (vgm_gd3_system[0] && row <= ROW_VGM_END && gd3_count < 4) {
buf_clear(work_buf);
buf_append_str(work_buf, "System : ");
buf_append_str(work_buf, vgm_gd3_system);
print_line(&s_wnd, row++, work_buf, WC_COLOR(WC_YELLOW, WC_BLACK));
gd3_count++;
}
/* ── Строка подсказок (фиксированная позиция) ─────────────── */
buf_clear(work_buf);
buf_append_str(work_buf, " [Space] Next [P]rev [Esc] Exit");
if (s_has_saa2 && !s_board_dual_saa) {
buf_append_str(work_buf, " SAA:");
buf_append_char(work_buf, '1' + cfg_saa_mode);
}
print_line(&s_wnd, ROW_HELP, work_buf, CLR_TITLE);
return ROW_PROGRESS;
}
/* render_static_info() удалена — заменена на drow_ui() */
/* Проверить расширение файла: .vgz / .VGZ */
static uint8_t is_vgz_filename(void) __naked
{
__asm
ld hl, (_wc_file_name)
ld de, #0 ; address of the last dot
ivf_scan:
ld a, (hl)
or a
jr z, ivf_check
cp a, #'.'
jr nz, ivf_next
ld d, h
ld e, l
ivf_next:
inc hl
jr ivf_scan
ivf_check:
ld a, d
or a, e
jr z, ivf_no
inc de
ex de, hl
; ASCII upper/lower case differ by bit 5.
ld a, (hl)
or a, #0x20
cp a, #'v'
jr nz, ivf_no
inc hl
ld a, (hl)
or a, #0x20
cp a, #'g'
jr nz, ivf_no
inc hl
ld a, (hl)
or a, #0x20
cp a, #'z'
jr nz, ivf_no
inc hl
ld a, (hl)
or a
jr nz, ivf_no
inc a ; return 1
ret
ivf_no:
xor a ; return 0
ret
__endasm;
}
/* The unused tail of plugin-relative page 5 (not WC physical page #05)
* contains the one-shot large OPL4 loader at logical address 9800h.
* Offset 1800h is also above WC's 1000h..1447h screen scratch range. */
static uint8_t load_large_opl4(void) __naked
{
__asm
ld a, i
push af ; P/V remembers incoming IFF2
di ; page 5 does not contain the resident ISR
ld a, #5
ld hl, #0x9800
ld de, #ll4_after_cold
push de ; #6020 is a jump, provide its return address
jp 0x6020 ; WC #6028 is position-dependent in some builds
ll4_after_cold:
ld c, a ; bit 7 = ROM, bits 0..6 = final pages
ld d, h ; keep cold-loader return across POP AF
ld e, l
pop af
jp po, ll4_irq_was_off
ei ; safe now: #6028 has restored page 0
ll4_irq_was_off:
ld a, c
and a, #0x7f
jr z, ll4_fail
ld a, c
and a, #0x80
ld (_vgm_opl4_preloaded_rom), a
ld a, c
and a, #0x7f
ld c, a ; final page count
; D/E already hold page/address high byte
ll4_tail_loop:
ld a, d
cp a, #64
jr nc, ll4_success
push bc
push de
ld a, d
call _wc_mngcvpl
pop de
pop bc
xor a ; blocks up to next 16K boundary
sub a, e
srl a
ld b, a
push bc
push de
push af ; one-byte stack argument for wc_load512
inc sp
ld h, e
ld l, #0
call _wc_load512
pop de
pop bc
cp a, #0x0f ; EOF: compacted stream is complete
jr z, ll4_success
or a
jr nz, ll4_fail
inc d
ld e, #0xc0
jr ll4_tail_loop
ll4_success:
push bc
xor a
call _wc_mngcvpl
pop bc
ld a, c
ret
ll4_fail:
xor a
call _wc_mngcvpl ; leave Win3 in a known state on reject
xor a
ret
__endasm;
}
uint8_t load_vgm(void)
{
uint8_t i;
uint8_t large_file = 0;
uint32_t fsize = wc_file_size;
/* Минимальный размер — хотя бы 256 байт */
if (fsize < 256UL)
return 0;
s_pages = (uint8_t)((fsize + 16383UL) >> 14);
if (!s_pages)
s_pages = 1;
if (s_pages > 64) {
s_pages = 64;
large_file = 1;
}
/* vgm_end_page / vgm_end_addr устанавливаются в vgm_parse_header()
из поля EOF offset заголовка VGM — здесь задавать не нужно. */
for (i = 0; i < s_pages; i++)
{
uint8_t rc;
wc_mngcvpl(i);
rc = wc_load512(0xC000, 32);
/* DBG: 0xB0 = loaded page i, rc */
dbg_trace2(0xB0, i, rc);
/* WC_EOF нормален на последнем секторе */
if (rc && rc != WC_EOF)
return 0;
}
wc_mngcvpl(0); /* страница 0 для проверки заголовка */
/* ── VGZ: gzip magic 0x1F 0x8B ──────────────────────────────── */
/* ВАЖНО: между wc_mngcvpl и inflate_vgz НЕ ДОЛЖНО быть WC API *
* вызовов (print_line и др.), иначе WC сбрасывает Win3 mapping *
* и inflate может упасть. */
if (*(volatile uint8_t *)0xC000 == 0x1F &&
*(volatile uint8_t *)0xC001 == 0x8B) {
uint8_t vgz_pages = s_pages;
uint32_t vgm_size;
uint8_t ifl_rc;
s_is_vgz = 1;
s_vgz_compressed_pages = s_pages;
/* DBG: 0xA0 = VGZ detected, pages count */
dbg_trace2(0xA0, vgz_pages, 0);
if (vgz_pages > 31) /* TAP: макс. 31 страница */
return 0;
/* DBG: 0xA1 = before copy_pages_to_tap */
dbg_trace(0xA1);
/* Копировать VGZ из мегабуфера → TAP-страницы (DI/EI внутри) */
copy_pages_to_tap(vgz_pages);
/* Установить total для progress bar (оценка выходных страниц) */
{
uint8_t est = (uint8_t)(vgz_pages * 3u);
if (est > 64) est = 64;
ifl_pb_total = est;
}
/* DBG: 0xA2 = after copy_pages_to_tap, before inflate_vgz */
dbg_trace2(0xA2, WC_PAGE_TAP_START, WC_PAGE_TVBPG);
/* Inflate: TAP (#A1+) → мегабуфер (#20+) */
ifl_rc = inflate_vgz(WC_PAGE_TAP_START, WC_PAGE_TVBPG);
/* DBG: 0xA3 = after inflate_vgz, result */
dbg_trace2(0xA3, ifl_rc, 0);
if (ifl_rc != 0)
return 0;
/* DBG: 0xA4 = inflate OK, reading VGM size */
dbg_trace(0xA4);
/* Прочитать размер распакованного VGM из заголовка */
wc_mngcvpl(0);
{
/* VGM offset 0x04: EOF offset (отн. 0x04) — LE uint32 */
uint16_t lo = *(volatile uint16_t *)0xC004;
uint16_t hi = *(volatile uint16_t *)0xC006;
vgm_size = ((uint32_t)hi << 16) | lo;
vgm_size += 4; /* total = eof_offset + 4 */
}
s_vgm_unpacked_size = vgm_size;
s_pages = (uint8_t)((vgm_size + 16383UL) >> 14);
if (!s_pages)
s_pages = 1;
/* DBG: 0xA5 = VGZ done, final pages */
dbg_trace2(0xA5, s_pages, 0);
}
if (large_file) {
s_pages = load_large_opl4();
if (!s_pages)
return 0;
}
return s_pages;
}
void start_playback(void)
{
ints_disable();
/* Включение 512-байт кэша для Window 2 (#8000-#BFFF).
* Ускоряет code fetch и доступ к static fb_* переменным.
* ДОЛЖНО быть после inflate/load — DMA/page I/O испортят cache.
* SysConfig bit 2 = global enable, CacheConfig bit 2 = win2. */
//tsconf_set_cache(TSCONF_CACHE_EN_8000);
//tsconf_set_cpu_cache(TSCONF_CPU_14MHZ, 1);
vgm_paused = 0;
if (!s_playback_inited)
{
s_playback_inited = 1;
/* Сброс счётчиков */
isr_play_seconds = 0;
s_last_displayed_sec = 0;
s_pb_d_min10 = '0';
s_pb_d_min1 = '0';
s_pb_d_sec10 = '0';
s_pb_d_sec1 = '0';
vgm_loop_count = 0;
/* ISR-состояние */
isr_active_buf = 0;
isr_wait_ctr = 0;
isr_done = 0;
isr_final_pending = 0;
/* MultiSound ctrl: включить FM/SAA только для присутствующих чипов.
* 0xF2 = FM ON + SAA ON + normal read + chip1 (base).
* bit2: 1 = FM OFF (установить если нет YM2203)
* bit3: 1 = SAA OFF (установить если нет SAA1099) */
isr_ms_ctrl = 0xF2
| (s_has_ym2203 ? 0x00 : 0x04)
| (s_has_saa ? 0x00 : 0x08);
/* Применить конфиг MultiSound к железу немедленно
* (нужно для SAA-only музыки, где AY-записей нет) */
__asm
ld bc, #0xFFFD
ld a, (_isr_ms_ctrl)
out (c), a
__endasm;
// SAA1099: effective mode computation.
// saa1/saa2 = микросхемы на плате, chip0/chip1 = чипы в VGM.
// Двухчиповая (saa_chips=2): single→saa1, dual→saa1+saa2 (turbo).
// Одночиповая (saa_chips=1): всегда saa1; dual → chip0 или chip1 по выбору.
if (s_board_dual_saa)
cfg_saa_mode = s_has_saa2 ? 2 : 0;
else
cfg_saa_mode = s_has_saa2 ? s_saa_mode_user : 0;
// Set spectrum dual mode flag (only in turbo mode with dual VGM).
extern uint8_t spec_saa_dual;
spec_saa_dual = (s_has_saa2 && cfg_saa_mode == 2) ? 1 : 0;
/* SAA init/deinit is now handled entirely via cmdblocks
* in build_playback_queue() — no inline asm needed. */
/* Построить очередь HL-команд для этого трека */
build_playback_queue();
/* Инициализация IM2: перехват вектора WC #5BFF */
isr_init();
/* Заполнить оба буфера из HL queue.
* vgm_fill_buffer обрабатывает HL-переходы внутри —
* init cmdblocks + начало VGM-данных могут попасть в buf 0. */
vgm_fill_buffer(0);
s_buf_ready[0] = 1;
vgm_fill_buffer(1);
s_buf_ready[1] = 1;
s_last_active_buf = 0;
}
isr_enabled = 1;
ints_enable();
}
void stop_playback(void)
{
if (!s_playback_inited) {
ints_disable();
ints_enable();
return;
}
ints_disable();
isr_enabled = 0;
isr_deinit(); /* восстановить вектор WC */
/* MultiSound: отключить FM + SAA, выбрать chip1 (железо + переменная) */
isr_ms_ctrl = 0xFE;
__asm
ld bc, #0xFFFD
ld a, #0xFE ;; chip1 + FM OFF + SAA CLK OFF
out (c), a
__endasm;
/* Отключить cache перед возвратом в WC.
* CacheConfig = 0 отключает все per-window caches.
* SysConfig: оставить 14 МГц, очистить global cache bit. */
//tsconf_set_cpu_cache(TSCONF_CPU_14MHZ, 0);
//tsconf_set_cache(TSCONF_CACHE_NONE);
vgm_paused = 0;
s_playback_inited = 0;
s_buf_ready[0] = 0;
s_buf_ready[1] = 0;
s_last_active_buf = 0;
isr_done = 0;
isr_final_pending = 0;
ints_enable();
}
/**
* Мгновенный abort воспроизведения.
*
* При обычном abort через vgm_hl_abort_pos main loop ждёт, пока ISR
* доиграет активный буфер (включая CMD_WAIT, до ~1 сек задержки).
*
* instant_abort() обходит это за ~1.5 мс:
* 1. isr_enabled = 0 → ISR пропускает команды (pos_table работает).
* 2. HALT — CPU спит до следующего INT. После пробуждения ISR
* гарантированно отработал один проход с enabled=0.
* 3. Обнуляем isr_wait_ctr (отменяем CMD_WAIT).
* 4. Заполняем НЕАКТИВНЫЙ буфер shutdown-цепочкой
* (silence + CMD_ISR_DONE) — ISR его не читает.
* 5. Переключаем isr_active_buf + isr_read_ptr на этот буфер.
* 6. isr_enabled = 1 → ISR мгновенно выполняет shutdown.
*
* Вызывать ПОСЛЕ установки vgm_hl_pos = vgm_hl_abort_pos.
* После возврата isr_done будет установлен через 1-2 ISR-тика.
*
* Нулевой overhead на горячем пути ISR — без дополнительных флагов.
*/
static void instant_abort(void)
{
uint8_t free_idx;
/* ── 1. Заморозить ISR (команды не выполняются) ─────────── */
isr_enabled = 0;
/* ── 2. HALT: спать до следующего INT ──────────────────── *
* После HALT ISR гарантированно отработал с enabled=0, *
* не тронул ни один буфер. ~1.5 мс максимум. */
__asm__( "halt" );
/* ── 3. Обнулить wait_ctr (отменить CMD_WAIT) ─────────── */
isr_wait_ctr = 0;
/* ── 4. Заполнить ОБА буфера shutdown-цепочкой ────────── *
* vgm_hl_pos уже установлен на vgm_hl_abort_pos. *
* Цепочка (SILENCE_OPL ≈ 384 байта + WAVE_OFF + WAVE_TL *
* + CMD_ISR_DONE) НЕ помещается в один буфер: лимит на *
* заполнение — CMD_BUF_SIZE-48 = 464 байта, а cmdblock *
* нельзя продолжить с середины — при переполнении он *
* начинается заново со следующего вызова. Поэтому *
* заполняем сразу оба буфера, пока ISR заморожен: иначе *
* после первого CMD_END_BUF он уйдёт во второй буфер со *
* СТАРЫМ содержимым и никогда не дойдёт до CMD_ISR_DONE *
* (трек зависает на месте вместо перехода к следующему). */
free_idx = (isr_active_buf & 1) ^ 1;
vgm_fill_buffer(free_idx); /* часть 1: SILENCE_OPL + ... */
vgm_fill_buffer(free_idx ^ 1); /* часть 2: ... + CMD_ISR_DONE */
/* ── 5. Переключить ISR на первый из них ─────────────── */
isr_active_buf = free_idx;
isr_read_ptr = (uint16_t)(free_idx ? cmd_buf_b : cmd_buf_a);
/* Синхронизировать учёт update_buffer(): оба буфера только что
* заполнены здесь, напрямую, минуя его собственный механизм. */
s_last_active_buf = free_idx;
s_buf_ready[0] = 1;
s_buf_ready[1] = 1;
/* ── 6. Разморозить → ISR обработает shutdown ───────── */
isr_enabled = 1;
}
void update_buffer(void)
{
uint8_t active;
uint8_t free_idx;
/* isr_final_pending: ISR замер на CMD_FINALIZE, ждёт instant_abort()
* из главного цикла. Продолжать обычное упреждающее заполнение буфера
* здесь нельзя: vgm_hl_pos уже не совпадает с тем, что предполагает
* instant_abort() при вызове vgm_fill_buffer() для shutdown-цепочки,
* и повторное/параллельное заполнение может затереть буфер до того,
* как ISR доберётся до настоящего CMD_ISR_DONE — трек тогда никогда
* не завершается (зависает на последних тактах без перехода дальше). */
if (!s_playback_inited || !isr_enabled || isr_done || isr_final_pending)
return;
active = (uint8_t)(isr_active_buf & 1U);
/* Если ISR переключился на другой буфер — старый считается освобождённым */
if (active != s_last_active_buf)
{
s_buf_ready[s_last_active_buf] = 0;
s_last_active_buf = active;
}
/* Всегда держим готовым неактивный буфер */
free_idx = (uint8_t)(active ^ 1U);
if (s_buf_ready[free_idx])
return;
/* Заполнить свободный буфер — vgm_fill_buffer обрабатывает HL-переходы */
#ifdef DEBUG_BORDER
isr_border_color = 2;
border(2);
#endif
vgm_fill_buffer(free_idx);
s_buf_ready[free_idx] = 1;
#ifdef DEBUG_BORDER
isr_border_color = 0;
border(0);
#endif
}
void update_playback_info(void)
{
uint16_t sec;
uint8_t sec_changed, loop_changed;
if (state != STATE_PLAYBACK)
return;
sec = isr_play_seconds;
sec_changed = (sec != s_last_displayed_sec);
loop_changed = (vgm_loop_count != s_last_loop_count);
if (!sec_changed && !loop_changed)
return;
/* ── Переключаем Window3 на текстовую страницу напрямую через порт.
* Не используем wc_mngcvpl — она вызывает WC_ENTRY и обновляет
* WC_SYS_PGC, что может корраптить состояние WC при перехваченном ISR.
* sfr_page3 = прямая запись в порт 0x13AF. */
{
uint8_t saved_pg = sfr_page3;
sfr_page3 = s_pb_text_pg;
volatile uint8_t *base =
(volatile uint8_t *)(0xC000 + s_pb_char_ofs);
/* ── Время: инкрементальный BCD-счётчик.
* Секунды всегда растут на 1 → вместо 6 делений (sec/60,
* sec%60, min/10, min%10, s/10, s%10) — только INC + CP.
* ~20 тактов вместо ~6000. */
if (sec_changed) {
s_last_displayed_sec = sec;
if (++s_pb_d_sec1 > '9') {
s_pb_d_sec1 = '0';
if (++s_pb_d_sec10 > '5') {
s_pb_d_sec10 = '0';
if (++s_pb_d_min1 > '9') {
s_pb_d_min1 = '0';
s_pb_d_min10++;
}
}
}
base[PB_OFS_MIN10] = s_pb_d_min10;
base[PB_OFS_MIN1] = s_pb_d_min1;
base[PB_OFS_SEC10] = s_pb_d_sec10;
base[PB_OFS_SEC1] = s_pb_d_sec1;
}
if (loop_changed) {
s_last_loop_count = vgm_loop_count;
base[PB_OFS_LOOP] = '1' + vgm_loop_count;
}
/* ── Progress bar: Bresenham-аккумулятор.
* Каждую секунду добавляем s_pb_width к ошибке,
* когда ошибка >= total — красим колонку и вычитаем.
* Ноль делений. Точно для любого соотношения sec/width. */
if (sec_changed && vgm_total_seconds) {
s_pb_err += s_pb_width;
while (s_pb_col < s_pb_width &&
s_pb_err >= vgm_total_seconds) {
*(volatile uint8_t *)(0xC000 + s_pb_attr_ofs + s_pb_col) =
WC_COLOR(WC_GREEN, WC_BLACK);
s_pb_col++;
s_pb_err -= vgm_total_seconds;
}
}
sfr_page3 = saved_pg;
}
}
/* ── main() ──────────────────────────────────────────────────────────── */
void main(void)
{
// Инициализация буферов для строк
buf_init(work_buf, 96);
buf_init(error_buf, 96);
state = STATE_INIT;
wc_turbopl(WC_TURBO_CPU, 0x02); /* 14 МГц */
/* ── Чтение INI-параметров ───────────────────────────────────────
* Формат в wc.ini: VGMPLAY.WMF -min_dur=10 -max_dur=4 -loops=1 -saa=1 -saa_chips=1
* param 0: min_duration секунд (default 10)
* param 1: max_duration в минутах (default 4 → 240 сек)
* param 2: loop_count (default 1)
* param 3: saa mode 1=chip0(default), 2=chip1 (только 1-чип + dual музыка)
* param 4: saa_chips 1=один SAA(default), 2=два SAA (эксп. плата)
* wc_prm_pl() возвращает 0xFF если параметр не задан. */
{
uint8_t v;
v = wc_prm_pl(0);
if (v != 0xFF) cfg_min_duration = v;
v = wc_prm_pl(1);
if (v != 0xFF) cfg_max_duration = (uint16_t)v * 60u;
v = wc_prm_pl(2);
if (v != 0xFF) cfg_loop_rewinds = v;
v = wc_prm_pl(3);
if (v >= 1 && v <= 2) cfg_saa_mode = v - 1;
s_saa_mode_user = cfg_saa_mode;
v = wc_prm_pl(4);
s_board_dual_saa = (v == 2) ? 1 : 0;
}
wc_strset((char *)&s_wnd, sizeof(s_wnd), 0);
// SOW — рамка с тенью (TYPE3, заголовок рисуем вручную)
s_wnd.type = WC_WIN_TYPE3 | WC_WIN_SHADOW;
s_wnd.cur_mask = 0x05;
/* Динамическое центрирование по WC text mode */
{
uint8_t scr_h = wc_get_height(); /* 25/30/36 */
uint8_t scr_w = (scr_h >= 36) ? 90 : 80;
s_wnd.x = (scr_w > WND_W) ? (scr_w - WND_W) / 2 : 0;
s_wnd.y = (scr_h > WND_H) ? (scr_h - WND_H) / 2 : 0;
}
s_wnd.width = WND_W;
s_wnd.height = WND_H;
s_wnd.color = CLR_WIN;
s_wnd.buf_addr = 0;
s_wnd.divider1 = DIV1_FROM_BOTTOM;
s_wnd.divider2 = DIV2_FROM_BOTTOM;
/* Дождаться отпускания всех клавиш с точки зрения WC.
* WC ISR ещё работает штатно (PSD2 обновляет TAB нормально),
* поэтому wc_key_wait_release даст WC привести TAB в чистое
* состояние. Только после этого отключаем WC PS/2 handler —
* FIFO переходит под наш контроль без артефактов. */
wc_key_wait_release();
wc_int_pl(WC_INT_NO_PS2);
// Восстановить дисплей WC и нарисовать окно
wc_gedpl();
wc_prwow(&s_wnd);
/* Заголовок окна синим текстом */
buf_clear(work_buf);
buf_append_str(work_buf, s_win_title);
print_line(&s_wnd, ROW_TITLE, work_buf, CLR_TITLE);
/* Предварительный вывод: имя, размер, страницы (до загрузки) */
s_is_vgz = 0;
s_vgm_unpacked_size = 0;
s_vgz_compressed_pages = 0;
draw_pre_load_info();
/* Показать "Loading..." ДО начала загрузки — для .vgz (распаковка)
* и для файлов >= 1 МБ (синхронная заливка сэмплов OPL4 в SRAM
* карты идёт байт за байтом с busy-wait и занимает секунды; без
* надписи выглядит как зависание). Для .vgz это последний WC API
* вызов перед inflate — дальше load_vgm работает только портами. */
if (is_vgz_filename() || ((const uint8_t *)&wc_file_size)[2] >= 0x10) {
buf_clear(work_buf);
buf_append_str(work_buf, " Loading...");
print_line(&s_wnd, ROW_VGM_START, work_buf, WC_COLOR(WC_YELLOW, WC_BLACK));
/* Настройка progress bar: получить адрес текст. экрана для VGM секции col 2 */
{
uint16_t bar_addr = wc_gadrw(&s_wnd, ROW_VGM_START, 2);
ifl_pb_text_pg = wc_get_pgc(); /* физ. страница текст. экрана */
ifl_pb_attr_ofs = (bar_addr & 0x3FFF) | 0x0080; /* SET 7,L = область атрибутов */
ifl_pb_bw = get_content_width(&s_wnd);
ifl_pb_green = WC_COLOR(WC_GREEN, WC_BLACK);
ifl_pb_total = 0; /* будет установлено в load_vgm() */
}
}
uint8_t loaded_pages = load_vgm();
if (!loaded_pages)
{
state = STATE_ERROR;
wc_exit_code = WC_EXIT_ESC;
buf_append_str(error_buf, "Failed to load VGM file.");
}
else
{
state = STATE_PLAYBACK;
if (vgm_parse_header() != VGM_OK)
{
state = STATE_ERROR;
wc_exit_code = WC_EXIT_ESC;
buf_append_str(error_buf, "Invalid VGM header.");
}
else
{
/* Парсим GD3 и восстанавливаем VPL-страницу */
vgm_parse_gd3();
/* ── 0x66 sentinel ───────────────────────────────────
* Пишем VGM end-marker (0x66) сразу после конца данных,
* чтобы hot-loop в vgm_fill_buffer мог работать без
* проверки EOF на каждой итерации. */
wc_mngcvpl(vgm_end_page);
*(volatile uint8_t *)vgm_end_addr = 0x66;
wc_mngcvpl(vgm_cur_page);
/* Определяем типы чипов и длительность трека */
detect_active_chips();
}
}
drow_ui();
/* Кешируем параметры progress bar (строка playback info) */
{
uint16_t bar_addr = wc_gadrw(&s_wnd, ROW_PROGRESS, 2);
s_pb_text_pg = wc_get_pgc();
s_pb_char_ofs = bar_addr & 0x3FFF;
s_pb_attr_ofs = s_pb_char_ofs | 0x0080;
s_pb_width = get_content_width(&s_wnd);
s_pb_col = 0;
s_pb_err = 0;
}
/* Кешируем адреса строк спектроанализатора */
{
for (uint8_t r = 0; r < 4; r++) {
uint16_t a = wc_gadrw(&s_wnd, ROW_SPECTRUM + r, 2);
s_spec_char_ofs[r] = a & 0x3FFF;
s_spec_attr_ofs[r] = s_spec_char_ofs[r] | 0x0080;
}
spectrum_font_init();
spectrum_decay_reset();
}
/* Печатаем полную строку progress bar ОДИН РАЗ через WC API.
* "Playing 00:00 / MM:SS Loop 1" + чёрный фон/белый текст.
* В главном цикле обновляются только 5 символов + атрибуты. */
{
buf_clear(work_buf);
buf_append_str(work_buf, "Playing 00:00 / ");
buf_append_mmss(work_buf, vgm_total_seconds);
buf_append_str(work_buf, " Loop 1");
print_line(&s_wnd, ROW_PROGRESS, work_buf, WC_COLOR(WC_BRIGHT_WHITE, WC_BLACK));
}
if (state == STATE_PLAYBACK)
start_playback();
uint8_t key = 0;
wc_exit_code = (wc_file_idx == wc_file_count) ? WC_EXIT_ESC : WC_EXIT_NEXT;
/* PS/2 FSM: init выполняется один раз в main(), здесь
* только ре-drain для слива событий от предыдущего трека. */
ps2_init();
/* Главный цикл: приоритет — update_buffer (буфер не должен голодать),
клавиатура + UI — по остаточному принципу. */
while (state == STATE_PLAYBACK)
{
/* ── 1. Буфер: максимальный приоритет ──────────────── */
update_buffer();
/* ISR исполнил весь музыкальный хвост и замер на FINALIZE.
* Пересобрать shutdown с начала чистого буфера — тот же путь,
* который используется для надёжного выхода по ESC. */
if (isr_final_pending) {
isr_final_pending = 0;
vgm_hl_pos = vgm_hl_abort_pos;
instant_abort();
}
/* ISR замер на CMD_ISR_DONE → чистый выход */
if (isr_done)
break;
/* ── 2. Клавиатура: PS/2 FIFO (~1 раз в кадр) ────── */
key = ps2_poll();
if (key == KEY_NEXT)
{
vgm_hl_pos = vgm_hl_abort_pos;
wc_exit_code = WC_EXIT_NEXT;
instant_abort();
}
else if (key == KEY_PREV)
{
vgm_hl_pos = vgm_hl_abort_pos;
wc_exit_code = WC_EXIT_PREV;
instant_abort();
}
else if (key == KEY_ESC)
{
vgm_hl_pos = vgm_hl_abort_pos;
wc_exit_code = WC_EXIT_ESC;
instant_abort();
}
else if (key >= KEY_SAA1 && key <= KEY_SAA2 && s_has_saa2 && !s_board_dual_saa)
{
uint8_t new_mode = key - KEY_SAA1; /* 0 or 1 */
s_saa_mode_user = new_mode;
cfg_saa_mode = new_mode;
extern uint8_t spec_saa_dual;
spec_saa_dual = 0;
/* Обновить подсказку SAA mode */
buf_clear(work_buf);
buf_append_str(work_buf, " [Space] Next [P]rev [Esc] Exit SAA:");
buf_append_char(work_buf, '1' + cfg_saa_mode);
print_line(&s_wnd, ROW_HELP, work_buf, CLR_TITLE);
}
if (isr_play_seconds != s_last_displayed_sec || vgm_loop_count != s_last_loop_count)
update_playback_info();
/* ── 3. Спектроанализатор ──────────────────────────── */
spectrum_decay();
spectrum_render();
}
ps2_deinit();
stop_playback();
spectrum_font_restore();
wc_rresb(&s_wnd);
}