/** * Furnace Tracker - multi-system chiptune tracker * Copyright (C) 2021-2025 tildearrow and contributors * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #include "zsm.h" #include "../engine.h" #include "../ta-log.h" #include /// DivZSM definitions #define ZSM_HEADER_SIZE 16 #define ZSM_VERSION 1 #define ZSM_YM_CMD 0x40 #define ZSM_DELAY_CMD 0x80 #define ZSM_YM_MAX_WRITES 63 #define ZSM_SYNC_MAX_WRITES 31 #define ZSM_DELAY_MAX 127 #define ZSM_EOF ZSM_DELAY_CMD #define ZSM_EXT ZSM_YM_CMD #define ZSM_EXT_PCM 0x00 #define ZSM_EXT_CHIP 0x40 #define ZSM_EXT_SYNC 0x80 #define ZSM_EXT_CUSTOM 0xC0 enum YM_STATE { ym_PREV, ym_NEW, ym_STATES }; enum PSG_STATE { psg_PREV, psg_NEW, psg_STATES }; class DivZSM { private: struct S_pcmInst { int geometry; unsigned int offset, length, loopPoint; bool isLooped; }; SafeWriter* w; int ymState[ym_STATES][256]; int psgState[psg_STATES][64]; int pcmRateCache; int pcmCtrlRVCache; int pcmCtrlDCCache; unsigned int pcmLoopPointCache; bool pcmIsLooped; std::vector ymwrites; std::vector pcmMeta; std::vector pcmData; std::vector pcmCache; std::vector pcmInsts; std::vector syncCache; int loopOffset; int numWrites; int ticks; int tickRate; int ymMask; int psgMask; bool optimize; public: DivZSM(); ~DivZSM(); void init(unsigned int rate = 60); int getoffset(); void writeYM(unsigned char a, unsigned char v); void writePSG(unsigned char a, unsigned char v); void writePCM(unsigned char a, unsigned char v); void writeSync(unsigned char a, unsigned char v); void setOptimize(bool o); void tick(int numticks = 1); void setLoopPoint(); SafeWriter* finish(); private: void flushWrites(); void flushTicks(); }; /// DivZSM implementation DivZSM::DivZSM() { w=NULL; init(); } DivZSM::~DivZSM() { } void DivZSM::init(unsigned int rate) { if (w!=NULL) delete w; w=new SafeWriter; w->init(); // write default ZSM data header w->write("zm",2); // magic header w->writeC(ZSM_VERSION); // no loop offset w->writeS(0); w->writeC(0); // no PCM w->writeS(0x00); w->writeC(0x00); // FM channel mask w->writeC(0x00); // PSG channel mask w->writeS(0x00); w->writeS((unsigned short)rate); // 2 reserved bytes (set to zero) w->writeS(0x00); tickRate=rate; loopOffset=-1; numWrites=0; ticks=0; // Initialize YM/PSG states memset(&ymState,-1,sizeof(ymState)); memset(&psgState,-1,sizeof(psgState)); // Initialize PCM states pcmRateCache=-1; pcmCtrlRVCache=-1; pcmCtrlDCCache=-1; pcmIsLooped=false; pcmLoopPointCache=0; // Channel masks ymMask=0; psgMask=0; // Optimize writes optimize=true; } int DivZSM::getoffset() { return w->tell(); } void DivZSM::writeYM(unsigned char a, unsigned char v) { int lastMask=ymMask; if (a==0x19 && v>=0x80) a=0x1a; // AMD/PSD use same reg addr. store PMD as 0x1a if (a==0x08 && (v&0xf8)) ymMask|=(1<<(v&0x07)); // mark chan as in-use if keyDN if (a!=0x08) ymState[ym_NEW][a]=v; // cache the newly-written value bool writeit=false; // used to suppress spurious writes to unused channels if (a<0x20) { if (a==0x08) { // write keyUPDN messages if channel is active. writeit=(ymMask&(1<<(v&0x07)))>0; } else { // do not suppress global registers writeit=true; } } else { writeit=(ymMask&(1<<(a&0x07)))>0; // a&0x07 = chan ID for regs >=0x20 } if (lastMask!=ymMask) { // if the ymMask just changed, then the channel has become active. // This can only happen on a KeyDN event, so voice=v&0x07 // insert a keyUP just to be safe. ymwrites.push_back(DivRegWrite(0x08,v&0x07)); numWrites++; // flush the ym_NEW cached states for this channel into the ZSM.... for (int i=0x20+(v&0x07); i<=0xff; i+=8) { if (ymState[ym_NEW][i]!=ymState[ym_PREV][i]) { ymwrites.push_back(DivRegWrite(i,ymState[ym_NEW][i])); numWrites++; // ...and update the shadow ymState[ym_PREV][i]=ymState[ym_NEW][i]; } } } // Handle the current write if channel is active if (writeit && ((ymState[ym_NEW][a]!=ymState[ym_PREV][a]) || a==0x08)) { // update YM shadow if not the KeyUPDN register. if (a!=8) ymState[ym_PREV][a]=ymState[ym_NEW][a]; // if reg=PMD, then change back to real register 0x19 if (a==0x1a) a=0x19; ymwrites.push_back(DivRegWrite(a,v)); numWrites++; } } void DivZSM::writeSync(unsigned char a, unsigned char v) { return syncCache.push_back(DivRegWrite(a,v)); } void DivZSM::writePSG(unsigned char a, unsigned char v) { if (a>=69) { logD("ZSM: ignoring VERA PSG write a=%02x v=%02x",a,v); return; } else if (a==68) { // Sync event numWrites++; return writeSync(0x00,v); } else if (a>=64) { return writePCM(a-64,v); } if (psgState[psg_PREV][a]==v) { if (psgState[psg_NEW][a]!=v) { // NEW value is being reset to the same as PREV value // so it is no longer a new write. numWrites--; } } else { if (psgState[psg_PREV][a]==psgState[psg_NEW][a]) { // if this write changes the NEW cached value to something other // than the PREV value, then this is a new write. numWrites++; } } psgState[psg_NEW][a]=v; // mark channel as used in the psgMask if volume is set>0. if ((a&3)==2 && (v&0x3f)) psgMask|=(1<<(a>>2)); } void DivZSM::writePCM(unsigned char a, unsigned char v) { if (a==0) { // PCM Ctrl // cache the depth and channels but don't write it to the // register queue pcmCtrlDCCache=v&0x30; // save only the reset bit and volume (if it isn't a dupe) if (pcmCtrlRVCache!=(v&0x8f)) { pcmMeta.push_back(DivRegWrite(a,(v&0x8f))); pcmCtrlRVCache=v&0x8f; numWrites++; } } else if (a==1) { // PCM Rate if (pcmRateCache!=v) { pcmMeta.push_back(DivRegWrite(a,v)); pcmRateCache=v; numWrites++; } } else if (a==2) { // PCM data pcmCache.push_back(v); numWrites++; } else if (a==3) { // PCM loop point pcmLoopPointCache=(pcmLoopPointCache>>8)|(v<<16); pcmIsLooped=true; } } void DivZSM::tick(int numticks) { flushWrites(); ticks+=numticks; } void DivZSM::setLoopPoint() { tick(0); // flush any ticks+writes flushTicks(); // flush ticks incase no writes were pending logI("ZSM: loop at file offset %d bytes",w->tell()); loopOffset=w->tell(); // update the ZSM header's loop offset value w->seek(0x03,SEEK_SET); w->writeS((short)(loopOffset&0xffff)); w->writeC((unsigned char)((loopOffset>>16)&0xff)); w->seek(loopOffset,SEEK_SET); // reset the PSG shadow and write cache memset(&psgState,-1,sizeof(psgState)); // reset the PCM caches that would inhibit dupes pcmRateCache=-1; pcmCtrlRVCache=-1; // reset the YM shadow.... memset(&ymState[ym_PREV],-1,sizeof(ymState[ym_PREV])); // ... and cache (except for unused channels) memset(&ymState[ym_NEW],-1,0x20); for (int chan=0; chan<8; chan++) { // do not clear state for as-yet-unused channels if (!(ymMask&(1<writeC(ZSM_EOF); if (pcmInsts.size()>256) { logE("ZSM: more than the maximum number of PCM instruments exist. Skipping PCM export entirely."); pcmData.clear(); pcmInsts.clear(); } else if (pcmData.size()) { // if exists, write PCM instruments and blob to the end of file unsigned int pcmOff=w->tell(); w->writeC('P'); w->writeC('C'); w->writeC('M'); w->writeC((unsigned char)pcmInsts.size()-1); int i=0; for (S_pcmInst& inst: pcmInsts) { // write out the instruments // PCM playback location follows: // // // of PCM data offset // of length w->writeC((unsigned char)i&0xff); w->writeC((unsigned char)inst.geometry&0x30); w->writeC((unsigned char)inst.offset&0xff); w->writeC((unsigned char)(inst.offset>>8)&0xff); w->writeC((unsigned char)(inst.offset>>16)&0xff); w->writeC((unsigned char)inst.length&0xff); w->writeC((unsigned char)(inst.length>>8)&0xff); w->writeC((unsigned char)(inst.length>>16)&0xff); // Feature mask: Lxxxxxxx // L = Loop enabled w->writeC((unsigned char)inst.isLooped<<7); // Sample loop point w->writeC((unsigned char)inst.loopPoint&0xff); w->writeC((unsigned char)(inst.loopPoint>>8)&0xff); w->writeC((unsigned char)(inst.loopPoint>>16)&0xff); // Reserved for future use w->writeS(0); w->writeS(0); i++; } for (unsigned char& c: pcmData) { w->writeC(c); } pcmData.clear(); // update PCM offset in file w->seek(0x06,SEEK_SET); w->writeC((unsigned char)pcmOff&0xff); w->writeC((unsigned char)(pcmOff>>8)&0xff); w->writeC((unsigned char)(pcmOff>>16)&0xff); } // update channel use masks. w->seek(0x09,SEEK_SET); w->writeC((unsigned char)(ymMask&0xff)); w->writeS((short)(psgMask&0xffff)); return w; } void DivZSM::flushWrites() { logD("ZSM: flushWrites.... numwrites=%d ticks=%d ymwrites=%d pcmMeta=%d pcmCache=%d pcmData=%d syncCache=%d",numWrites,ticks,ymwrites.size(),pcmMeta.size(),pcmCache.size(),pcmData.size(),syncCache.size()); if (numWrites==0) return; bool hasFlushed=false; for (unsigned char i=0; i<64; i++) { if (psgState[psg_NEW][i]==psgState[psg_PREV][i]) continue; // if optimize=true, suppress writes to PSG voices that are not audible (volume=0 or R+L=0) // ZSMKit has a feature that can benefit from having silent channels // updated, so this is something that can be toggled off or on for export if (optimize && (i&3)!=2 && (psgState[psg_NEW][(i&0x3c)+2]&0x3f)==0) continue; // vol if (optimize && (i&3)!=2 && (psgState[psg_NEW][(i&0x3c)+2]&0xc0)==0) continue; // R+L psgState[psg_PREV][i]=psgState[psg_NEW][i]; if (!hasFlushed) { flushTicks(); hasFlushed=true; } w->writeC(i); w->writeC(psgState[psg_NEW][i]); } int n=0; // n=completed YM writes. used to determine when to write the CMD byte... for (DivRegWrite& write: ymwrites) { if (!hasFlushed) { flushTicks(); hasFlushed=true; } if (n%ZSM_YM_MAX_WRITES==0) { if (ymwrites.size()-n>ZSM_YM_MAX_WRITES) { w->writeC((unsigned char)(ZSM_YM_CMD+ZSM_YM_MAX_WRITES)); logD("ZSM: YM-write: %d (%02x) [max]",ZSM_YM_MAX_WRITES,ZSM_YM_MAX_WRITES+ZSM_YM_CMD); } else { w->writeC((unsigned char)(ZSM_YM_CMD+ymwrites.size()-n)); logD("ZSM: YM-write: %d (%02x)",ymwrites.size()-n,ZSM_YM_CMD+ymwrites.size()-n); } } n++; w->writeC(write.addr); w->writeC(write.val); } ymwrites.clear(); unsigned int pcmInst=0; unsigned int pcmOff=0; unsigned int pcmLen=0; int extCmd0Len=pcmMeta.size()*2; if (pcmCache.size()) { // collapse stereo data to mono if both channels are fully identical // which cuts PCM data size in half for center-panned PCM events if (pcmCtrlDCCache&0x10) { // stereo bit is on unsigned int e; if (pcmCtrlDCCache&0x20) { // 16-bit // for 16-bit PCM data, the size must be a multiple of 4 if (pcmCache.size()%4==0) { // check for identical L+R channels for (e=0; e>1; e+=2) { pcmCache[e]=pcmCache[e<<1]; pcmCache[e+1]=pcmCache[(e<<1)+1]; } pcmCache.resize(pcmCache.size()>>1); pcmCtrlDCCache&=(unsigned char)~0x10; // clear stereo bit pcmLoopPointCache>>=1; // halve the loop point } } } else { // 8-bit // for 8-bit PCM data, the size must be a multiple of 2 if (pcmCache.size()%2==0) { // check for identical L+R channels for (e=0; e>1; e++) { pcmCache[e]=pcmCache[e<<1]; } pcmCache.resize(pcmCache.size()>>1); pcmCtrlDCCache&=(unsigned char)~0x10; // clear stereo bit pcmLoopPointCache>>=1; // halve the loop point } } } } // check to see if the most recent received blob matches any of the previous data // and reuse it if there is a match, otherwise append the cache to the rest of // the PCM data std::vector::iterator it; it=std::search(pcmData.begin(),pcmData.end(),pcmCache.begin(),pcmCache.end()); pcmOff=std::distance(pcmData.begin(),it); pcmLen=pcmCache.size(); logD("ZSM: pcmOff: %d pcmLen: %d",pcmOff,pcmLen); if (it==pcmData.end()) { pcmData.insert(pcmData.end(),pcmCache.begin(),pcmCache.end()); } pcmCache.clear(); extCmd0Len+=2; // search for a matching PCM instrument definition for (S_pcmInst& inst: pcmInsts) { if (inst.offset==pcmOff && inst.length==pcmLen && inst.geometry==pcmCtrlDCCache && inst.isLooped==pcmIsLooped && inst.loopPoint==pcmLoopPointCache) break; pcmInst++; } if (pcmInst==pcmInsts.size()) { S_pcmInst inst; inst.geometry=pcmCtrlDCCache; inst.offset=pcmOff; inst.length=pcmLen; inst.loopPoint=pcmLoopPointCache; inst.isLooped=pcmIsLooped; pcmInsts.push_back(inst); } pcmIsLooped=false; pcmLoopPointCache=0; } if (extCmd0Len>63) { // this would be bad, but will almost certainly never happen logE("ZSM: extCmd 0 exceeded maximum length of 63: %d",extCmd0Len); extCmd0Len=0; pcmMeta.clear(); } if (extCmd0Len) { // we have some PCM events to write if (!hasFlushed) { flushTicks(); hasFlushed=true; } w->writeC(ZSM_EXT); w->writeC(ZSM_EXT_PCM|(unsigned char)extCmd0Len); for (DivRegWrite& write: pcmMeta) { w->writeC(write.addr); w->writeC(write.val); } pcmMeta.clear(); if (pcmLen) { w->writeC(0x02); // 0x02 = Instrument trigger w->writeC((unsigned char)pcmInst&0xff); } } n=0; for (DivRegWrite& write: syncCache) { if (!hasFlushed) { flushTicks(); hasFlushed=true; } if (n%ZSM_SYNC_MAX_WRITES==0) { w->writeC(ZSM_EXT); if (syncCache.size()-n>ZSM_SYNC_MAX_WRITES) { w->writeC((unsigned char)(ZSM_EXT_SYNC|(ZSM_SYNC_MAX_WRITES<<1))); } else { w->writeC((unsigned char)(ZSM_EXT_SYNC|((syncCache.size()-n)<<1))); } } n++; w->writeC(write.addr); w->writeC(write.val); } syncCache.clear(); numWrites=0; } void DivZSM::flushTicks() { while (ticks>ZSM_DELAY_MAX) { logD("ZSM: write delay %d (max)",ZSM_DELAY_MAX); w->writeC((unsigned char)(ZSM_DELAY_CMD+ZSM_DELAY_MAX)); ticks-=ZSM_DELAY_MAX; } if (ticks>0) { logD("ZSM: write delay %d",ticks); w->writeC(ZSM_DELAY_CMD+ticks); } ticks=0; } /// ZSM export constexpr int MASTER_CLOCK_PREC=(sizeof(void*)==8)?8:0; constexpr int MASTER_CLOCK_MASK=(sizeof(void*)==8)?0xff:0; void DivExportZSM::run() { // settings unsigned int zsmrate=conf.getInt("zsmrate",60); bool loop=conf.getBool("loop",true); bool optimize=conf.getBool("optimize",true); // system IDs int VERA=-1; int YM=-1; int IGNORED=0; // find indexes for YM and VERA. Ignore other systems. for (int i=0; isong.systemLen; i++) { switch (e->song.system[i]) { case DIV_SYSTEM_VERA: if (VERA>=0) { IGNORED++; break; } VERA=i; logAppendf("VERA detected as chip id %d",i); break; case DIV_SYSTEM_YM2151: if (YM>=0) { IGNORED++; break; } YM=i; logAppendf("YM detected as chip id %d",i); break; default: IGNORED++; logAppendf("Ignoring chip %d systemID %d",i,(int)e->song.system[i]); break; } } if (VERA<0 && YM<0) { logAppend("ERROR: No supported systems for ZSM"); failed=true; running=false; return; } if (IGNORED>0) { logAppendf("ZSM export ignoring %d unsupported system%c",IGNORED,IGNORED>1?'s':' '); } DivZSM zsm; e->stop(); e->repeatPattern=false; e->setOrder(0); e->synchronizedSoft([&]() { double origRate=e->got.rate; e->got.rate=zsmrate&0xffff; // determine loop point int loopOrder=0; int loopRow=0; int loopEnd=0; e->walkSong(loopOrder,loopRow,loopEnd); logAppendf("loop point: %d %d",loopOrder,loopRow); zsm.init(zsmrate); // reset the playback state e->curOrder=0; e->freelance=false; e->playing=false; e->extValuePresent=false; e->remainingLoops=-1; // Prepare to write song data e->playSub(false); //size_t tickCount=0; bool done=false; bool loopNow=false; int loopPos=-1; int fracWait=0; // accumulates fractional ticks if (VERA>=0) e->disCont[VERA].dispatch->toggleRegisterDump(true); if (YM>=0) { e->disCont[YM].dispatch->toggleRegisterDump(true); // emit LFO initialization commands zsm.writeYM(0x18,0); // freq=0 zsm.writeYM(0x19,0x7F); // AMD =7F zsm.writeYM(0x19,0xFF); // PMD =7F // TODO: incorporate the Furnace meta-command for init data and filter // out writes to otherwise-unused channels. } // Indicate the song's tuning as a sync meta-event // specified in terms of how many 1/256th semitones // the song is offset from standard A-440 tuning. // This is mainly to benefit visualizations in players // for non-standard tunings so that they can avoid // displaying the entire song held in pitch bend. // Tunings offsets that exceed a half semitone // will simply be represented in a different key // by nature of overflowing the signed char value signed char tuningoffset=(signed char)(round(3072*(log(e->song.tuning/440.0)/log(2))))&0xff; zsm.writeSync(0x01,tuningoffset); // Set optimize flag, which mainly buffers PSG writes // whenever the channel is silent zsm.setOptimize(optimize); while (!done) { if (loopPos==-1) { if (loopOrder==e->curOrder && loopRow==e->curRow && loop) loopNow=true; if (loopNow) { // If Virtual Tempo is in use, our exact loop point // might be skipped due to quantization error. // If this happens, the tick immediately following is our loop point. if (e->ticks==1 || !(loopOrder==e->curOrder && loopRow==e->curRow)) { loopPos=zsm.getoffset(); zsm.setLoopPoint(); loopNow=false; } } } if (e->nextTick() || !e->playing) { done=true; if (!loop) { for (int i=0; isong.systemLen; i++) { e->disCont[i].dispatch->getRegisterWrites().clear(); } break; } if (!e->playing) { loopPos=-1; } } // get register dumps for (int j=0; j<2; j++) { int i=0; // dump YM writes first if (j==0) { if (YM<0) { continue; } else { i=YM; } } // dump VERA writes second if (j==1) { if (VERA<0) { continue; } else { i=VERA; } } std::vector& writes=e->disCont[i].dispatch->getRegisterWrites(); if (writes.size()>0) logD("zsmOps: Writing %d messages to chip %d",writes.size(),i); for (DivRegWrite& write: writes) { if (i==YM) { if (done && write.addr==0x08 && (write.val&0x78)>0) continue; // don't process keydown on lookahead zsm.writeYM(write.addr&0xff,write.val); } if (i==VERA) { if (done && write.addr>=64) continue; // don't process any PCM or sync events on the loop lookahead zsm.writePSG(write.addr&0xff,write.val); } } writes.clear(); } // write wait int totalWait=e->cycles>>MASTER_CLOCK_PREC; fracWait+=e->cycles&MASTER_CLOCK_MASK; totalWait+=fracWait>>MASTER_CLOCK_PREC; fracWait&=MASTER_CLOCK_MASK; if (totalWait>0 && !done) { zsm.tick(totalWait); //tickCount+=totalWait; } } // end of song // done - close out. e->got.rate=origRate; if (VERA>=0) e->disCont[VERA].dispatch->toggleRegisterDump(false); if (YM>=0) e->disCont[YM].dispatch->toggleRegisterDump(false); e->remainingLoops=-1; e->playing=false; e->freelance=false; e->extValuePresent=false; }); progress[0].amount=1.0f; logAppend("finished!"); output.push_back(DivROMExportOutput("out.zsm",zsm.finish())); running=false; } /// DivExpottZSM - FRONTEND bool DivExportZSM::go(DivEngine* eng) { progress[0].name="Generate"; progress[0].amount=0.0f; e=eng; running=true; failed=false; mustAbort=false; exportThread=new std::thread(&DivExportZSM::run,this); return true; } void DivExportZSM::wait() { if (exportThread!=NULL) { exportThread->join(); delete exportThread; } } void DivExportZSM::abort() { mustAbort=true; wait(); } bool DivExportZSM::isRunning() { return running; } bool DivExportZSM::hasFailed() { return failed; } DivROMExportProgress DivExportZSM::getProgress(int index) { if (index<0 || index>1) return progress[1]; return progress[index]; }