; ═══════════════════════════════════════════════════════════════════════════ ; VGM Player Plugin for Wild Commander ; OPL3 (YMF262) playback on TSConfig ; Architecture v2: ISR-driven at 44100 Hz, double-buffered commands ; (C) 2026 ; ═══════════════════════════════════════════════════════════════════════════ DEVICE NOSLOT64K ; плоская 64К память, без ограничений слотов OUTPUT "vgmplay.wmf" PLUGHEAD EQU 0 ; заголовок в начале файла (как в PLUGBP) PLUGSTAR EQU PLUGHEAD+512 ; код с байта #200 в файле PLUGLEN EQU END-PLUGIN PAGES EQU (PLUGLEN/16384)+1 BLKSIZE EQU (PLUGLEN+511)/512 ORG PLUGHEAD ; = 0, заголовок физически с байта 0 в файле ; ═══════════════════════════════════════════════════════════════════════════ ; Plugin Header (512 bytes, exact WC format) ; ═══════════════════════════════════════════════════════════════════════════ ; +0: Reserved (16 bytes) DS 16 ; +16: Magic signature (16 bytes) DB "WildCommanderMDL" ; +32: Plugin system version DB #10 ; +33: Reserved DB 0 ; +34: Number of 16KB pages DB PAGES ; +35: Start page (0-based, mapped to #8000) DB 0 ; +36: Data blocks (6 x 2 bytes = 12 bytes) DB 0,BLKSIZE ; Block 0: plugin code, page 0 DB 0,0 ; Block 1: unused DB 0,0 ; Block 2: unused DB 0,0 ; Block 3: unused DB 0,0 ; Block 4: unused DB 0,0 ; Block 5: unused ; +48: Reserved (15 bytes) DS 15 ; +63: Extension flags (0 = launch by ENTER and F3) DB 0 ; +64: Supported extensions (32 slots x 3 bytes = 96 bytes) DB "VGM" DS 31*3 ; Remaining slots empty ; +160: End marker DB 0 ; +161: Max file size (32-bit LE: DW low_word, high_word) DW #FFFF,#FFFF ; Unlimited ; +165: Plugin name (32 bytes, space-padded) DB "VGM Player for OPL3 v2.0 " ; +197: Activation type (0 = by extension only) DB 0 ; +198: F2/F4 button text (6 bytes) DS 6 ; +204: Viewer menu text (24 bytes) DS 24 ; +228: Reserved (32 bytes) DS 32 ; +260: INI Parameters DB 0 ; No parameters ; Pad to 512 bytes DS PLUGSTAR-$ ; Заголовок занимает 512 байт (PLUGSTAR = #200), ; код физически с байта #200, WC отображает страницу на #8000. ; DISP #8000 — все метки кода резолвятся в диапазон #8000+ DISP #8000 ; ═══════════════════════════════════════════════════════════════════════════ ; Constants ; ═══════════════════════════════════════════════════════════════════════════ WLD EQU #6006 ; WC API entry point VPL EQU 32 ; WC VPL constant ; --- TSConfig Ports (port = reg_number * 256 + #AF) --- PORT_SYSCONFIG EQU #20AF ; SysConfig: bit1:0 = CPU freq, bit2 = cache PORT_HSINT EQU #22AF ; Horizontal INT position (0-223) PORT_VSINTL EQU #23AF ; Vertical INT position low (bits 7:0) PORT_VSINTH EQU #24AF ; Vertical INT position high (bit0=VCNT[8], bits7:4=VINT_INC) PORT_INTMASK EQU #2AAF ; INT mask: bit0=FRAME, bit1=LINE, bit2=DMA ; --- CPU speed values for SysConfig bits 1:0 --- CPU_3_5MHZ EQU %00000000 CPU_7MHZ EQU %00000001 CPU_14MHZ EQU %00000010 ; --- OPL3 Ports --- OPL3_ADDR0 EQU #C4 OPL3_DATA0 EQU #C5 OPL3_ADDR1 EQU #C6 OPL3_DATA1 EQU #C7 ; --- ISR Command Types (код = смещение в странице ISR_HANDLER) --- CMD_WRITE_B0 EQU #40 ; [#40, reg, val, pad] OPL3 банк 0 CMD_WRITE_B1 EQU #80 ; [#80, reg, val, pad] OPL3 банк 1 CMD_WAIT EQU #C0 ; [#C0, lo, hi, pad] ждать N тиков CMD_END_BUF EQU #E0 ; [#E0, pad, pad, pad] переключить буфер ; --- Command Buffer sizes --- CMD_BUF_SIZE EQU 512 ; bytes per buffer (128 commands) ; --- Window layout --- WND_X EQU 9 WND_Y EQU 5 WND_W EQU 62 WND_H EQU 14 ; --- Colors --- CLR_MAIN EQU %01111111 ; --- Коды ошибок для ERROR_BORDER --- ; Формат: биты [5:3]=категория, биты [2:0]=код. Оба поля ≠ 0 (цвета бордюра 1-7) ; Цвета ZX: 1=синий, 2=красный, 3=маджента, 4=зелёный, 5=циан, 6=жёлтый, 7=белый ; ; Категории (старшие 3 бита): ; 1 (синий) = файловые ошибки ; 2 (красный) = ошибки формата VGM ; 3 (маджента) = аппаратные ошибки ; ; Конкретные коды (младшие 3 бита) — внутри категории: ; 1 = первая ошибка, 2 = вторая, и т.д. ; ERR_FILE_SMALL EQU (1<<3)|1 ; файл <= 255 байт: черный, синий(1), синий(1) ERR_VGM_HEADER EQU (2<<3)|1 ; плохой заголовок: черный, синий(1), красный(2) ERR_FILE_LOAD EQU (1<<3)|2 ; ошибка загрузки: черный, красный(2), синий(1) ; --- Memory addresses --- VGMBUF EQU #C000 ; VGM данные через WC VPL (окно 3: #C000-#FFFF) ; ═══════════════════════════════════════════════════════════════════════════ ; Plugin Entry Point ; ═══════════════════════════════════════════════════════════════════════════ PLUGIN: PUSH IX ; Сохранить IX (WC передаёт структуру активной панели) ; Сохранить входные параметры от WC ; A` = индекс расширения, BC = указатель на имя файла, ; HL = размер файла (младшее слово), DE = размер файла (старшее слово) EXA LD (FILE_EXT),A EXA LD (FILE_NAME),BC LD (FILE_SIZE),HL LD (FILE_SIZE+2),DE ; Init variables XOR A LD (EXIT_CODE),A LD (PAUSED),A LD (SONG_ENDED),A LD (ISR_ENABLED),A LD (ISR_ACTIVE_BUF),A LD (UNDERRUN_FLAG),A LD (OPL3_FOUND),A LD (UI_FRAME_CTR),A LD (LAST_ISR_BUF),A LD HL,0 LD (PLAY_SECONDS),HL ; Переключиться на 14 МГц (PORT_SYSCONFIG только на запись) ; При выходе всегда восстанавливаем 3.5 МГц LD BC,PORT_SYSCONFIG LD A,CPU_14MHZ OUT (C),A ; Проверка размера файла: достаточно > 256 байт ; (VGM заголовок минимум 64 байта, но реальные файлы от 8КБ) LD A,(FILE_SIZE+1) ; старший байт младшего слова OR A LD A,ERR_FILE_SMALL JP Z,ERROR_BORDER ; <= 255 байт → ошибка FILE_SIZE_OK: ; === Загрузить VGM файл в VPL-страницы WC (окно #C000, 16KB каждая) === ; Шаг 1: вычислить число страниц B = ceil(FILE_SIZE / 16384) LD HL,(FILE_SIZE) LD DE,(FILE_SIZE+2) LD BC,16383 ; добавляем для округления вверх ADD HL,BC JR NC,VGM_LOAD_NC INC DE VGM_LOAD_NC: ; pages = (E << 2) | (H >> 6) [файлы < 4МБ] LD A,H SRL A:SRL A:SRL A:SRL A:SRL A:SRL A ; A = H >> 6 LD C,A LD A,E ADD A,A:ADD A,A OR C LD B,A ; B = число страниц ; Шаг 2: вычислить VGM_END_PAGE и VGM_END_ADDR DEC A ; номер последней страницы (0-based) LD (VGM_END_PAGE),A ; VGM_END_ADDR = #C000 + (FILE_SIZE & #3FFF) LD HL,(FILE_SIZE) LD A,H AND #3F OR #C0 LD H,A LD (VGM_END_ADDR),HL ; Шаг 3: загружать страницы по одной LD A,B LD (VGM_PAGES_TOTAL),A XOR A LD (VGM_CUR_PAGE),A VGM_LOAD_LOOP: LD A,(VGM_PAGES_TOTAL) LD B,A LD A,(VGM_CUR_PAGE) CP B JR NC,VGM_LOAD_DONE CALL MNGCVPL ; A = VPL-индекс страницы → отобразить на #C000 LD HL,#C000 LD B,32 ; 32 × 512 = 16384 байт CALL LOAD512 LD A,(VGM_CUR_PAGE) INC A LD (VGM_CUR_PAGE),A JR VGM_LOAD_LOOP VGM_LOAD_DONE: ; Отобразить страницу 0 для разбора заголовка XOR A LD (VGM_CUR_PAGE),A CALL MNGCVPL ; Разобрать VGM заголовок CALL VGM_PARSE_HEADER LD A,ERR_VGM_HEADER JP NZ,ERROR_BORDER ; Init OPL3 CALL OPL3_INIT ; Восстановить дисплей (ОБЯЗАТЕЛЬНО перед любыми вызовами WC UI API) CALL GEDPL ; Init UI (uses WC API, still in normal interrupt mode) CALL UI_INIT CALL UI_DRAW_INFO ; Disable WC interrupts (A'=0 via INT_PL) ; WC will auto-restore on plugin exit XOR A EXA ; A' = 0 CALL INT_PL ; Prepare initial data in both buffers (до ISR_INIT!) LD A,0 CALL VGM_FILL_BUFFER ; fill BUF_A LD A,1 CALL VGM_FILL_BUFFER ; fill BUF_B ; Set ISR read pointer to start of BUF_A LD HL,CMD_BUF_A LD (ISR_READ_PTR),HL ; Enable ISR processing LD A,1 LD (ISR_ENABLED),A ; Setup IM2 + start timer (после заполнения буферов!) CALL ISR_INIT EI ; ═══════════════════════════════════════════════════════════════════════════ ; Main Loop ; ═══════════════════════════════════════════════════════════════════════════ MAIN_LOOP: EI ; гарантируем прерывания (WC API мог сделать DI) ; 1. Check if ISR switched buffers -> fill the free one LD A,(ISR_ACTIVE_BUF) LD B,A LD A,(LAST_ISR_BUF) CP B JR Z,ML_NO_FILL ; same buffer, nothing to do ; ISR switched! Update tracking and fill the now-free buffer LD A,B LD (LAST_ISR_BUF),A ; The free buffer is the one ISR is NOT reading XOR 1 ; 0->1, 1->0 (A = free buffer index) LD C,A ; save free buffer index LD A,1:OUT (#FE),A ; DEBUG: синий → входим в VGM_FILL_BUFFER LD A,C ; restore free buffer index CALL VGM_FILL_BUFFER XOR A:OUT (#FE),A ; DEBUG: чёрный → VGM_FILL_BUFFER вернулся ML_NO_FILL: ; 2. Update UI periodically LD A,(UI_FRAME_CTR) INC A LD (UI_FRAME_CTR),A AND #1F ; every 32 iterations JR NZ,ML_NO_UI CALL UI_UPDATE ML_NO_UI: ; 3. Read keyboard (direct ZX matrix, no interrupts needed) CALL KBD_READ OR A JR NZ,GOT_KEY ; 4. Check if song ended LD A,(SONG_ENDED) OR A JP NZ,SONG_DONE JP MAIN_LOOP GOT_KEY: ; A = action code: 1=ESC, 2=next, 3=prev, 4=pause CP 1 JR Z,DO_EXIT CP 2 JR Z,DO_NEXT CP 3 JR Z,DO_PREV CP 4 JR Z,DO_PAUSE JR MAIN_LOOP DO_EXIT: XOR A LD (EXIT_CODE),A JR EXIT_PLAYER DO_NEXT: LD A,2 LD (EXIT_CODE),A JR EXIT_PLAYER DO_PREV: LD A,4 LD (EXIT_CODE),A JR EXIT_PLAYER DO_PAUSE: LD A,(PAUSED) XOR 1 LD (PAUSED),A ; When pausing, ISR keeps running but main loop stops filling buffers ; When unpausing, ISR was just replaying silence/waits CALL UI_DRAW_STATUS JP MAIN_LOOP SONG_DONE: ; Проверить петлю (VGM_LOOP_ADDR = 0 → нет петли) LD HL,(VGM_LOOP_ADDR) LD A,H OR L JR Z,SONG_NO_LOOP ; Переключиться на страницу петли и перезапустить LD A,(VGM_LOOP_PAGE) LD (VGM_CUR_PAGE),A CALL MNGCVPL LD HL,(VGM_LOOP_ADDR) LD (VGM_READ_PTR),HL XOR A LD (SONG_ENDED),A JP MAIN_LOOP SONG_NO_LOOP: LD A,2 ; next file LD (EXIT_CODE),A ; fall through ; ═══════════════════════════════════════════════════════════════════════════ ; Exit ; ═══════════════════════════════════════════════════════════════════════════ EXIT_PLAYER: DI ; Stop ISR XOR A LD (ISR_ENABLED),A ; Silence OPL3 CALL OPL3_RESET ; Restore IM1 IM 1 EI ; Восстановить 3.5 МГц LD BC,PORT_SYSCONFIG LD A,CPU_3_5MHZ OUT (C),A ; Remove UI CALL UI_REMOVE ; Return exit code to WC LD A,(EXIT_CODE) POP IX ; Восстановить IX для WC RET ERROR_EXIT: ; Восстановить 3.5 МГц LD BC,PORT_SYSCONFIG LD A,CPU_3_5MHZ OUT (C),A XOR A POP IX ; Восстановить IX для WC RET ; ─── Отладка: вывод кода ошибки бордюром, зависание ─────────────────── ; Вход: A = код ошибки (биты [2:0] = младший, [5:3] = старший; оба ≠ 0) ; Цикл: чёрный → цвет(биты[2:0]) → цвет(биты[5:3]) → повтор ; Задержка каждого цвета ≈ 300мс при 14МГц ; TODO: убрать в релизной версии ERROR_BORDER: LD C,A ; C = код ошибки на всё время ERR_LOOP: ; 1. Чёрный бордюр (разделитель) XOR A OUT (#FE),A CALL ERR_DELAY ; 2. Младшие 3 бита → цвет LD A,C AND %00000111 OUT (#FE),A CALL ERR_DELAY ; 3. Биты [5:3] → цвет (сдвиг вправо на 3) LD A,C RRCA RRCA RRCA AND %00000111 OUT (#FE),A CALL ERR_DELAY JR ERR_LOOP ERR_DELAY: LD D,4 ERR_DLY_OUT: LD B,0 ERR_DLY_INN: DJNZ ERR_DLY_INN DEC D JR NZ,ERR_DLY_OUT RET ; ═══════════════════════════════════════════════════════════════════════════ ; WC API Wrappers ; CALL WLD + IM 2 + RET: IM2 восстанавливается после каждого вызова WC ; (WC может переключать процессор обратно на IM1) ; ═══════════════════════════════════════════════════════════════════════════ PRWOW: LD A,1: CALL WLD:IM 2:EI:RET ; Print window RRESB: LD A,2: CALL WLD:IM 2:EI:RET ; Remove window PRSRW: LD A,3: CALL WLD:IM 2:EI:RET ; Print string in window PRIAT: EXA:LD A,4:CALL WLD:IM 2:EI:RET ; Print attributes TXTPR: LD A,11: CALL WLD:IM 2:EI:RET ; Print text GEDPL: LD A,15: CALL WLD:IM 2:EI:RET ; Get display LOAD512:LD A,#30:CALL WLD:IM 2:EI:RET ; Load 512-byte blocks MNGCVPL:EXA:LD A,#41:CALL WLD:IM 2:EI:RET; Map VPL page to #C000 INT_PL: EXA:LD A,86:CALL WLD:IM 2:EI:RET ; Interrupt control ; ═══════════════════════════════════════════════════════════════════════════ ; Global Variables ; ═══════════════════════════════════════════════════════════════════════════ FILE_EXT: DB 0 FILE_NAME: DW 0 FILE_SIZE: DW 0,0 ; 32-bit little-endian EXIT_CODE: DB 0 PAUSED: DB 0 SONG_ENDED: DB 0 ; VGM header data VGM_DATA_START: DW 0 ; адрес первого байта VGM команд (#C000-#FFFF) VGM_LOOP_ADDR: DW 0 ; адрес точки петли (#C000-#FFFF; 0 = нет петли) VGM_LOOP_PAGE: DB 0 ; VPL-страница (0-based) точки петли VGM_READ_PTR: DW 0 ; текущий адрес чтения в #C000-#FFFF VGM_CUR_PAGE: DB 0 ; текущая VPL-страница отображённая на #C000 VGM_END_ADDR: DW 0 ; адрес конца данных в последней странице VGM_END_PAGE: DB 0 ; номер последней страницы (VPL, 0-based) VGM_PAGES_TOTAL: DB 0 ; суммарное число загруженных страниц VGM_TOTAL_SAMPLES: DW 0,0 ; 32-bit total samples VGM_CLOCK: DW 0,0 ; OPL3 clock value ; Playback time PLAY_SECONDS: DW 0 ; секунды воспроизведения (обновляется в ISR) ; ISR state (shared between ISR and main loop) ISR_ENABLED: DB 0 ; 0=ISR does nothing, 1=active ISR_ACTIVE_BUF: DB 0 ; 0=reading BUF_A, 1=reading BUF_B ISR_READ_PTR: DW 0 ; current read pointer in active buffer ISR_WAIT_CTR: DW 0 ; tick countdown (0=process commands) ; ISR_STEP_IDX, ISR_HPOS, ISR_LINE are in isr.asm (local to ISR) LAST_ISR_BUF: DB 0 ; main loop tracking UNDERRUN_FLAG: DB 0 ; OPL3 state OPL3_FOUND: DB 0 ; UI UI_FRAME_CTR: DB 0 ; ═══════════════════════════════════════════════════════════════════════════ ; Include Modules ; ═══════════════════════════════════════════════════════════════════════════ INCLUDE "src/isr.asm" INCLUDE "src/vgm.asm" INCLUDE "src/opl3.asm" INCLUDE "src/ui.asm" INCLUDE "src/keyboard.asm" END: