export default class Plugin extends Patch{ name = "Fumen File Format" version = "22.09.04" description = "Adds support for using Fumen files in the custom song list" author = "Katie Frogs" async load(){ this.addEdits( new EditFunction(ImportSongs.prototype, "init").load(str => { return plugins.insertBefore(str, `this.musicInfoXml = this.otherFiles.musicInfoXml || {} this.songStars = this.otherFiles.songStars || {} this.starsRegex = /^\\d+,\\d+,\\d+,\\d+(?:,\\d+)?$/ this.duetRegex = /_[enhmx]_[12]\\.bin$/ this.notFumen = [ "tuning.bin", "tuning_ext.bin", "music_attribute.bin", "music_order.bin", "musicinfo.bin", "wordlist.bin", "songinfo.bin" ] this.nshPreview = this.otherFiles.nshPreview || {} this.otherFilenames = this.otherFiles.otherFilenames || {} this.binSongs = {} this.binFiles = [] `, 'this.songs = []') }), new EditFunction(ImportSongs.prototype, "load").load(str => { str = plugins.insertBefore(str, `this.otherFilenames[name] = file `, 'this.otherFiles[path] = file') str = plugins.insertBefore(str, `if(name.endsWith(".bin")){ var pathNoName = path.slice(0, -name.length) if(this.notFumen.indexOf(name) === -1 && !pathNoName.endsWith("/duet/") && pathNoName.indexOf("/fumen_hitnarrow/") === -1 && pathNoName.indexOf("/fumen_hitwide/") === -1 && !this.duetRegex.test(name)){ this.binFiles.push({ file: file }) this.otherFiles[path] = file } }else `, 'if(name.endsWith(".tja")') str = plugins.insertBefore(str, `name.startsWith("song_") && name.endsWith(".nsh") || `, 'name === "genre.ini"') str = plugins.insertBefore(str, `name.endsWith(".xml") || name === "songstars.txt" || `, 'name === "songtitle.txt"') str = plugins.insertBefore(str, `var validDiff = ["_e", "_n", "_h", "_m", "_x"] var getSort = input => { var name = input.slice(0, -4) var index = validDiff.indexOf(name.slice(-2)) if(index !== -1){ var song = name.slice(0, -2) var diff = index }else{ var song = name var diff = 3 } if(song.startsWith("ex_")){ song = song.slice(3) diff += 5 } return [song, diff] } this.binFiles.sort((a, b) => { var [aSong, aDiff] = getSort(a.file.name) var [bSong, bDiff] = getSort(b.file.name) if(aSong === bSong){ return aDiff > bDiff ? 1 : -1 }else{ return aSong > bSong ? 1 : -1 } }) this.binFiles.forEach((a, i) => a.index = i) `, 'var metaPromises = []') return plugins.insertBefore(str, `this.binFiles.forEach(fileObj => { songPromises.push(this.addBin(fileObj).catch(e => console.warn(e))) }) `, 'this.tjaFiles.forEach(fileObj => {') }), new EditFunction(ImportSongs.prototype, "loaded").load(str => { str = plugins.insertBefore(str, `assets.otherFiles.musicInfoXml = this.musicInfoXml assets.otherFiles.songStars = this.songStars assets.otherFiles.nshPreview = this.nshPreview assets.otherFiles.otherFilenames = this.otherFilenames `, 'assets.otherFiles.songTitle = this.songTitle') return plugins.insertBefore(str, `var lastUra = null for(var i in this.songs){ var song = this.songs[i] if(lastUra){ if(song.chart.oni && !song.chart.easy && !song.chart.normal && !song.chart.hard && !song.chart.ura){ var name = song.chart.oni.name.toLowerCase() if(name.startsWith("ex_") && name.endsWith("_m.bin") && name.slice(3, -6) === lastUra){ delete this.songs[i] lastUra = null continue } } lastUra = null } if(song.courses.ura && song.chart.ura){ var name = song.chart.ura.name.toLowerCase() if(name.endsWith("_x.bin")){ name = name.slice(0, -6) }else if(name.startsWith("ex_") && name.endsWith("_m.bin")){ name = name.slice(3, -6) } lastUra = name } if(song.md5){ var hash = song.md5.base64().slice(0, -2) delete song.md5 song.hash = hash scoreStorage.songTitles[song.title] = song.hash var score = scoreStorage.get(hash, false, true) if(score){ score.title = song.title } } } `,'this.songs = this.songs.filter') }), new EditFunction(ImportSongs.prototype, "addMeta").load(str => { str = plugins.insertBefore(str, `this.binFiles.forEach(filesLoop) `, 'this.tjaFiles.forEach(filesLoop)') str = plugins.insertBefore(str, `(name.endsWith(".xml") || name.startsWith("song_") && name.endsWith(".nsh")) ? data : `, 'data.replace(/\\0/g, "").split("\\n")') str = plugins.insertBefore(str, `if(name.endsWith(".xml")){ var fullToHalf = { "\\u2010": "-", "\\u2015": "-", "\\u2019": "'", "\\u2032": "'" } var chr = String.fromCharCode for(var i = 0; i < 26; i++){ if(i < 10){ fullToHalf[chr(0xff10 + i)] = chr(0x30 + i) } fullToHalf[chr(0xff21 + i)] = chr(0x41 + i) fullToHalf[chr(0xff41 + i)] = chr(0x61 + i) } var fullWidthRegex = /[\\u2010\\u2015\\u2019\\u2032\\uff10-\\uff19\\uff21-\\uff3a\\uff41-\\uff5a]/g var xmlDoc = new DOMParser().parseFromString(data, "text/xml") var getXmlVal = (element, name) => { var tag = element.getElementsByTagName(name)[0] if(tag && tag.firstChild){ return tag.firstChild.data } } var firstTag = xmlDoc.firstElementChild if(firstTag.tagName === "boost_serialization"){ var musicInfo = firstTag.firstElementChild var dataTags = musicInfo.getElementsByTagName("Data") for(var i = 0; i < dataTags.length; i++){ var id = getXmlVal(dataTags[i], "musicid") var genre = getXmlVal(dataTags[i], "genrename") if(genre === "ボーカロイド"){ genre += "™曲" } var title = getXmlVal(dataTags[i], "musicname").replace(fullWidthRegex, a => fullToHalf[a]).trim() var info = this.musicInfoXml[id] || {} if(title){ info.title = title } if(genre){ info.genre = genre } this.musicInfoXml[id] = info } }else if(firstTag.tagName === "songs"){ var songTags = firstTag.getElementsByTagName("song") for(var i = 0; i < songTags.length; i++){ var id = getXmlVal(songTags[i], "id") var title = getXmlVal(songTags[i], "japaneseText") || "" title = title.replace(fullWidthRegex, a => fullToHalf[a]).trim() var ura = false if(this.uraRegex.test(title)){ ura = true title = title.replace(this.uraRegex, "") if(id.startsWith("ex_")){ id = id.slice(3) } } var stars = this.songStars[id] || {} if(!ura){ var easy = +getXmlVal(songTags[i], "starEasy") if(easy){ stars.easy = easy } var normal = +getXmlVal(songTags[i], "starNormal") if(normal){ stars.normal = normal } var hard = +getXmlVal(songTags[i], "starHard") if(hard){ stars.hard = hard } } var oni = +getXmlVal(songTags[i], "starMania") if(oni){ if(ura){ stars.ura = oni }else{ stars.oni = oni } } if(id){ this.songStars[id] = stars if(title){ var info = this.musicInfoXml[id] || {} info.title = title this.musicInfoXml[id] = info } } } } }else if(name.startsWith("song_") && name.endsWith(".nsh")){ var nsh = new Uint8Array(data) this.nshPreview[name.slice(5, -4)] = struct.Unpack(">I", nsh.slice(0xe0, 0xe0 + struct.CalcLength(">I")))[0] / 1000 }else if(name === "songstars.txt"){ var diffs = ["easy", "normal", "hard", "oni", "ura"] var lastTitle for(var i = 0; i < data.length; i++){ var line = data[i].trim() if(line){ if(this.starsRegex.test(line) && lastTitle){ var stars = {} var array = line.split(",") for(var j in array){ stars[diffs[j]] = parseInt(array[j]) } this.songStars[lastTitle] = stars }else{ lastTitle = line } } } }else `, 'if(name === "genre.ini"){') return plugins.strReplace(str, 'return file.read(name === "songtitle.txt" ? undefined : "sjis")', ` if(name.startsWith("song_") && name.endsWith(".nsh")){ var readPromise = file.arrayBuffer() }else{ var unicodeFile = name.endsWith(".xml") || name === "songstars.txt" || name === "songtitle.txt" var readPromise = file.read(unicodeFile ? undefined : "sjis") } return readPromise`) }), new EditFunction(LoadSong.prototype, "run").load(str => { str = plugins.insertBefore(str, `song.type === "bin" ? data : `, 'data.replace(/\\0/g, "").split("\\n")') return plugins.strReplace(str, 'this.addPromise(chart.read(song.type === "tja" ? "sjis" : "")', `if(song.type === "bin"){ var chartRead = chart.arrayBuffer() }else{ var chartRead = chart.read(song.type === "tja" ? "sjis" : "") } this.addPromise(chartRead`) }), new EditFunction(Controller.prototype, "init").load(str => { return plugins.insertBefore(str, `if(selectedSong.type === "bin"){ try{ this.parsedSongData = new ParseBin(songData, selectedSong.difficulty, selectedSong.stars, selectedSong.offset, false, selectedSong.originalDiff) }catch(e){ this.game = {} this.keyboard = this.view = {clean: () => {}} this.run = () => {} var title = this.selectedSong.title if(title !== this.selectedSong.originalTitle){ title += " (" + this.selectedSong.originalTitle + ")" } setTimeout(() => this.songSelection(false, { name: "loadSongError", title: title, id: this.selectedSong.folder, error: e }), 500) return } if(this.parsedSongData.branches && selectedSong.originalDiff){ selectedSong.difficulty = selectedSong.originalDiff } }else `, 'if(selectedSong.type === "tja"){') }), new EditFunction(Controller.prototype, "restartSong").load(str => { str = plugins.insertBefore(str, `this.selectedSong.type === "bin" ? data : `, 'data.replace(/\\0/g, "").split("\\n")') return plugins.strReplace(str, 'this.addPromise(promises, chart.read(this.selectedSong.type === "tja" ? "sjis" : undefined)', `if(this.selectedSong.type === "bin"){ var chartRead = chart.arrayBuffer() }else{ var chartRead = chart.read(this.selectedSong.type === "tja" ? "sjis" : undefined) } this.addPromise(promises, chartRead`) }), new EditFunction(SongSelect.prototype, "getUnloaded").load(str => { str = plugins.strReplace(str, 'return file.read(currentSong.type', `if(currentSong.type === "bin"){ var diffPromises = [] for(let diff in file){ if(diff !== "separateDiff"){ diffPromises.push(file[diff].arrayBuffer().then(data => { currentSong.chart[diff] = new CachedFile(data, file[diff]) return importSongs.addBin({ file: currentSong.chart[diff], index: currentSong.id }) })) } } var importPromise = Promise.all(diffPromises) }else{ var importPromise = file.read(currentSong.type`) return plugins.strReplace(str, '}).then(() => {\n\t\t\tvar imported', `}) } return importPromise.then(() => { var imported`) }), new EditFunction(Game.prototype, "init").load(str => { return plugins.insertBefore(str, `this.songData.soulPoints || `, 'this.rules.soulPoints(combo)') }), new EditFunction(SoundBuffer.prototype, "load").load(str => { return plugins.strReplace(str, 'var decoder = file.name.endsWith(".ogg") ? this.oggDecoder : this.audioDecoder', `var decoder = this.audioDecoder if(file.name.endsWith(".ogg")){ decoder = this.oggDecoder }else{ for(var i in this.vgmExt){ if(file.name.endsWith(this.vgmExt[i])){ return this.vgmDecoder(file).catch(error => { return Promise.reject([error, file.url]) }).then(buffer => { return new Sound(gain || {soundBuffer: this}, buffer) }) } } }`) }), new EditFunction(CustomSongs.prototype, "localFolder").load(str => { return plugins.insertBefore(str, `false && `, 'typeof showDirectoryPicker === "function"') }), new EditValue(SoundBuffer.prototype, "vgmDecoder").load(() => this.vgmDecoder.bind(this)), new EditValue(SoundBuffer.prototype, "vgmExt").load(() => this.vgmExt), new EditValue(window, "ParseBin").load(() => this.ParseBin), new EditValue(window, "FileObj").load(() => this.FileObj), new EditValue(window, "struct").load(() => new JSPack()), new EditValue(ImportSongs.prototype, "addBin").load(() => this.addBin), new EditValue(ImportSongs.prototype, "findMusic").load(() => this.findMusic), new EditValue(ImportSongs.prototype, "vgmExt").load(() => this.vgmExt) ) this.douyou = { aliases: ["童謡"], id: assets.categories.length + 1, title: "Children/Folk", title_lang: { en: "Children/Folk", ja: "どうよう" }, songSkin: { background: "#ff4e8a", bg_img: "bg_genre_6.png", border: ["#ffc1da", "#c6004e"], outline: "#c3005c", infoFill: "#a20024", sort: 3.5 } } this.vgmExt = [".nus3bank", ".nub", ".acb", ".idsp", ".at3"] var workerScript = await (await fetch("https://katiefrogs.github.io/vgmstream-web/js/cli-worker.js")).text() var workerUrl = URL.createObjectURL(new Blob([workerScript], { type: "application/javascript" })) this.cliWorker = new this.WorkerWrapper(workerUrl) return this.cliWorker.load().finally(() => { URL.revokeObjectURL(workerUrl) }) } ParseBin = class{ constructor(file, difficulty, stars, offset, metaOnly, originalDiff){ this.difficulty = difficulty this.stars = stars this.offset = (offset || 0) * -1000 this.originalDiff = originalDiff this.soundOffset = 0 this.branchNames = ["normal", "advanced", "master"] this.metadata = {} var fumen = this.readFumen(new Uint8Array(file), metaOnly) if(!metaOnly){ this.measures = [] this.beatInfo = {} this.circles = [] this.events = [] this.writeCircles(fumen) } } readFumen(inputFile, metaOnly){ var file = new FileObj(inputFile) var size = file.array.length var noteTypes = { 0x1: "don", // ドン 0x2: "don", // ド 0x3: "don", // コ 0x4: "ka", // カッ 0x5: "ka", // カ 0x6: "drumroll", 0x7: "daiDon", 0x8: "daiKa", 0x9: "daiDrumroll", 0xa: "balloon", 0xb: "daiDon", // hands 0xc: "balloon", // kusudama 0xd: "daiKa", // hands 0xe: "daiDon", // wii huge 0xf: "daiKa", // wii huge 0x10: "don", 0x11: "don", 0x12: "don", // wii hidden, good hit on the huge note 0x13: "ka", 0x14: "ka", 0x15: "don", 0x16: "don", 0x17: "don", // ok hit on the huge note 0x18: "ka", 0x19: "ka", 0x1a: "don", 0x1b: "don", 0x1c: "don", // bomb 0x62: "drumroll" // ? } var song = {} var readStruct = function(format, seek){ if(seek){ file.seek(seek) } return struct.Unpack(order + format, file.read(struct.CalcLength(order + format))) || [] } var order = "" var measuresBig = readStruct(">I", 0x200)[0] var measuresLittle = readStruct("