Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
K-Shoot MANIA to EffectDrive/SDVXviewer converter
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>K-Shoot MANIA to EffectDrive/SDVXviewer converter</title>
</head>
<body>
<h1><a href="http://kshoot.client.jp/">K-Shoot MANIA</a> to <a href="http://effectdrive.web.fc2.com/">EffectDrive</a>/<a href="http://sdvxviewer.wiki.fc2.com/">SDVXviewer</a> converter</h1>
<table border="1" width="80%" style="text-align:center">
<tr><th width="50%">K-Shoot MANIA pattern (<code>*.txt</code>) here</th><th width="50%">Save this to EffectDrive's <code>Drv\*\*.drv</code></th></tr>
<tr><td width="50%" rowspan="3"><textarea id="ksm" style="width:99%;height:30em"></textarea></td><td width="50%"><textarea id="drv" style="width:99%;height:13.5em"></textarea></td></tr>
<tr><th width="50%">Save this to EffectDrive's <code>Drv\*\*_<span id="difficulty">DIFFICULTY</span>.prd</code><br />(<code>#NOTES</code> parts can also be used for SDVXviewer)</th></tr>
<tr><td width="50%"><textarea id="prd" style="width:99%;height:13.5em"></textarea></td></tr>
<tr><td colspan="2"><input type="checkbox" id="effectdrive-compat" /><label for="effectdrive-compat"> Use EffectDrive-compatible syntax for long BT notes and short FX notes</label> <input type="submit" value="Convert &rarr;" id="convert" /></td></tr>
</table>
<script src="ksm2ed.js"></script>
<h2>What the heck is it?</h2>
<p>This is a converter for pattern formats used by various free music video games sharing the same game system (I know there is a better description but I'm intentionally avoiding it here). It is not perfect due to the various restrictions either explicitly or implicitly imposed by games (e.g. 32nd or quicker notes, out-of-position perpendicular notes, new-style long BT notes and short FX notes), but I was able to convert some songs perfectly so it would be useful somehow.</p>
<h2>How to use</h2>
<p>The current version of the convert is only able to convert K-Shoot MANIA pattern to SDVXviewer pattern, which is also used as a part of EffectDrive pattern. Follow the following instructions:</p>
<ol>
<li>Copy and paste the K-Shoot MANIA pattern file, e.g. <code>example_ex.txt</code>, to the left side.</li>
<li>Check the "Use EffectDrive-compatible syntax ..." if your pattern makes use of them and you want it to run in EffectDrive. The long BT notes and short FX notes are relatively recent additions and currently EffectDrive and SDVXviewer uses different syntaxes for them. If you don't have such notes then this option has no effect.</li>
<li>Click the "Convert" button. If your pattern file had an error it will notify you.</li>
<li>If you want to use SDVXviewer, find the line <code>#NOTES:</code> in the second box in the right side, and copy and paste everything below that line to SDVXviewer.</li>
<li>If you want to use EffectDrive, make a subdirectory (say it'd be <code>example</code>) to EffectDrive's <code>Drv\</code> directory, and make sure that the following files are in that subdirectory:<ul>
<li><code>example.drv</code> (fill this file from the first box in the right side)</li>
<li><code>example_EASY.prd</code>, <code>example_NORMAL.prd</code> or <code>example_HARD.prd</code> depending on your pattern's difficulty (fill this file from the second box in the right side)</li>
<li>A jacket image file used by K-Shoot MANIA</li>
<li>A non-effected music file used by K-Shoot MANIA (it is the first file specified in <code>m=...</code> line)</li>
</ul>Note that <code>*.drv</code> and <code>*.prd</code> files should be saved as Shift_JIS encoding and DOS newline convention. If you use Japanese Windows you'd mostly fine (just use Notepad), otherwise you should find a text editor with the proper encoding and newline support.</li>
</ol>
<p>Due to the difference of difficulty system in K-Shoot MANIA and EffectDrive, K-Shoot MANIA's INFINITE difficulty is temporarily mapped to EffectDrive's HARD difficulty. You also need to manually fix <code>*.drv</code> file to convert a set of patterns of multiple difficulties.</p>
<h2>Known limitations</h2>
<ul>
<li>The converted file only uses default effectors with a fixed parameter for EffectDrive. For example, the left laser/rail always uses a high-pass filter. Manual modification is required for the better-sounding result.</li>
<li>A laser/rail moving within 1/32&ndash;1/16 measures does not convert perfectly. Both K-Shoot MANIA and EffectDrive recognizes a laser/rail moving within no greater than 1/32 measures as a perpendicular (chokkaku) note, so it is not a significant concern however.</li>
<li>I haven't investigated K-Shoot MANIA's variable BPM feature yet, so it is left unsupported.</li>
</ul>
<hr />
<address>A part of <a href="http://cosmic.mearie.org/">cosmic.mearie.org</a>. Source code available in <a href="https://gist.github.com/lifthrasiir/5816162">Gist</a>.</address>
</body>
</html>
/* K-Shoot MANIA to EffectDrive/SDVXviewer converter
* Copyright (c) 2013, Kang Seonghoon. Some Rights Reserved.
*
* This code can be freely used in terms of the MIT/X11 license
* available at <http://opensource.org/licenses/MIT>.
*/
var KSM2ED = (function() {
"use strict";
function assert(c) {
if (!c) throw 'assertion failure';
}
function from_base50(c) {
if ('0' <= c && c <= '9') {
return c.charCodeAt(0) - 48;
} else if ('A' <= c && c <= 'Z') {
return c.charCodeAt(0) - 55;
} else if ('a' <= c && c <= 'o') {
return c.charCodeAt(0) - 61;
} else {
assert(false);
}
}
function to_base10(v) { // assuming v in 0..50
return '0123456789A'.charAt((v + 2) / 5 | 0);
}
function parse_ksm(lines) {
var i;
var nlines = lines.length;
var meta = {};
// metadata section
for (i = 0; i < nlines; ++i) {
if (lines[i] === '--') break;
var m = lines[i].match(/^([a-z][^=]*)=(.*)$/i);
if (!m) throw ['invalid metadata line: ' + lines[i], i];
var key = m[1].toLowerCase();
var value = m[2];
if (key === 'difficulty') {
if (!(value === 'light' || value === 'challenge' || value === 'extended' || value === 'infinite')) throw ['invalid difficulty: ' + m[2], i];
} else if (key === 'm') {
value = value.split(/;/g);
if (value.length != 4) throw ['invalid music paths: ' + m[2], i];
} else if (key === 'level') {
value = Number(value);
if (isNaN(value)) throw ['invalid level: ' + m[2], i];
} else if (key === 't' || key === 'plength') {
value = Number(value);
if (isNaN(value) || value <= 0) throw ['invalid ' + (key === 't' ? 'BPM' : 'preview length') + ': ' + m[2], i];
} else if (key === 'o' || key === 'po') {
value = Number(value);
if (isNaN(value) || value < 0) throw ['invalid ' + (key === 'o' ? 'music offset' : 'preview offset') + ': ' + m[2], i];
} else if (key === 'chokkakuvol') {
value = Number(value);
if (isNaN(value) || value < 0 || value > 100) throw ['invalid perpendicular(chokkaku) volume: ' + m[2], i];
}
meta[key] = value;
}
if (typeof meta.title === 'undefined') throw ['missing title', i];
if (typeof meta.artist === 'undefined') throw ['missing artist', i];
if (typeof meta.effect === 'undefined') throw ['missing effect creator', i];
if (typeof meta.illustrator === 'undefined') throw ['missing illustrator', i];
if (typeof meta.jacket === 'undefined') throw ['missing jacket path', i];
if (typeof meta.difficulty === 'undefined') throw ['missing difficulty', i];
if (typeof meta.level === 'undefined') throw ['missing level', i];
if (typeof meta.t === 'undefined') throw ['missing BPM', i];
if (typeof meta.m === 'undefined') throw ['missing music paths', i];
if (typeof meta.o === 'undefined') throw ['missing music offset', i];
if (typeof meta.bg === 'undefined') throw ['missing background', i];
if (typeof meta.po === 'undefined') throw ['missing preview offset', i];
if (typeof meta.plength === 'undefined') throw ['missing preview length', i];
if (typeof meta.chokkakuvol === 'undefined') throw ['missing perpendicular(chokkaku) volume', i];
if (i == nlines) throw ['premature end of pattern', i];
// converts K-Shoot MANIA native convention to endpoint-oriented convention
var measure = 1;
var last = [0, 0, 0, 0, 0, 0, null, null];
var notes = []; // ha, we sometimes need to modify this
var convert_rows = function(rows, rowsat) {
// columns consists of nine elements:
// - first four for BT, null or [0(short)/1(start)/2(end), original value]]
// - next two for FX, null or [0(short)/1(start)/2(end), original value]]
// - next two for lasers, null or [0(short)/1(start)/2(end)/3(tick), start position 0..50, end position 0..50]
// perpendiculars are indicated by different start/end positions; for convenience, non-perpendicular type 0 is ignored too.
var denom = rows.length;
for (var j = 0; j < denom; ++j) {
var t = measure + j / denom;
var newrow = [];
for (var k = 0; k < 4; ++k) {
if (last[k]) { // inside LN
if (rows[j][k] == 2) {
newrow.push(null); // continuation
} else if (rows[j][k] == 1) {
throw ['colliding short note and long note end in measure ' + measure, rowsat[j]];
} else if (rows[j][k] == 0) {
last[k] = 0;
newrow.push([2, rows[j][k]]);
} else {
throw ['unknown BT character in measure ' + measure, rowsat[j]];
}
} else {
if (rows[j][k] == 2) { // LN start
last[k] = t;
newrow.push([1, rows[j][k]]);
} else if (rows[j][k] == 1) {
newrow.push([0, rows[j][k]]);
} else if (rows[j][k] == 0) {
newrow.push(null); // empty
} else {
throw ['unknown BT character in measure ' + measure, rowsat[j]];
}
}
}
for (var k = 4; k < 6; ++k) {
if (last[k]) { // inside LN
if (rows[j][k] >= 10) {
newrow.push(null); // continuation
} else if (rows[j][k] > 0) {
throw ['colliding short note and long note end in measure ' + measure, rowsat[j]];
} else if (rows[j][k] == 0) {
last[k] = 0;
newrow.push([2, rows[j][k]]);
} else {
throw ['unknown FX character in measure ' + measure, rowsat[j]];
}
} else {
if (rows[j][k] >= 10) { // LN start
last[k] = t;
newrow.push([1, rows[j][k]]);
} else if (rows[j][k] > 0) {
newrow.push([0, rows[j][k]]);
} else if (rows[j][k] == 0) {
newrow.push(null); // empty
} else {
throw ['unknown FX character in measure ' + measure, rowsat[j]];
}
}
}
for (var k = 6; k < 8; ++k) {
if (last[k]) { // there is an ongoing laser
if (rows[j][k] === null) { // laser end
// e.g. "o" continued by ":" continued by "-", "o" here remains as type 3 so we can detect it
if (last[k][0] == 1) throw ['missing laser end in measure ' + measure, rowsat[j]];
assert(last[k][0] == 3);
last[k][0] = 2;
last[k] = null;
newrow.push(null); // empty
} else if (rows[j][k] < 0) {
newrow.push(null); // continuation
} else {
last[k] = [3, rows[j][k], rows[j][k]];
newrow.push(last[k]);
}
} else {
if (rows[j][k] === null) {
newrow.push(null); // empty
} else if (rows[j][k] < 0) {
throw ['missing laser start in measure ' + measure, rowsat[j]];
} else {
last[k] = [1, rows[j][k], rows[j][k]];
newrow.push(last[k]);
}
}
}
newrow.push(rows[j][8]);
rows[j] = newrow;
}
return rows;
};
// parse notes
while (i < nlines) {
if (lines[i] === '') { // skip empty lines (for now)
++i;
continue;
}
if (lines[i] !== '--') throw ['unexpected measure separator: ' + lines[i], i];
var rows = [];
var rowsat = []; // for error reporting
while (++i < nlines && lines[i] !== '--') {
if (lines[i] === '') { // skip empty lines (for now)
continue;
}
var m = lines[i].match(/^([0-9])([0-9])([0-9])([0-9])\|([0-9A-Z])([0-9A-Z])\|([\-:0-9A-Za-o])([\-:0-9A-Za-o])(?:@([()])([0-9]+))?$/);
if (!m) throw ['invalid pattern in measure ' + measure + ': ' + lines[i], i];
var more = {};
if (m[9]) {
more['turnto'] = (m[9] === '(' ? 'left' : 'right');
more['turnin'] = m[10] / 10000; // (measures)
}
rows.push([
from_base50(m[1]), from_base50(m[2]), from_base50(m[3]), from_base50(m[4]),
from_base50(m[5]), from_base50(m[6]),
(m[7] === '-' ? null : (m[7] === ':' ? -1 : from_base50(m[7]))),
(m[8] === '-' ? null : (m[8] === ':' ? -1 : from_base50(m[8]))),
more
]);
rowsat.push(i);
}
if (rows.length > 0 && i == nlines) throw ['premature end of measure ' + measure, i];
if (rows.length == 0 && i != nlines) throw ['empty measure ' + measure, i];
if (rows.length > 0) notes.push(convert_rows(rows, rowsat));
++measure;
}
// we need to flush out the measure states, so we add dummy rows
notes.push(convert_rows([[0, 0, 0, 0, 0, 0, null, null, {}]], [i]));
// perpendicular correction: laser moving within 1/32 measures converted to perpendicular
for (var k = 6; k < 8; ++k) {
var last = null, lastt = -1;
for (var i = 0; i < notes.length; ++i) {
var denom = notes[i].length;
for (var j = 0; j < denom; ++j) {
var col = notes[i][j][k];
if (col === null) continue;
var t = i + j / denom;
if (col[0] == 1) { // start
last = col;
lastt = t;
} else {
assert(last);
if (t - lastt <= 1/32) {
// we need to disable this since EffectDrive requires a starting point of perps anyway
//last[0] &= col[0]; // merge the start and end: 1&3->1, 1&2->0, 3&3->3, 3&2->2
last[2] = col[2];
if (col[0] == 2) col[0] = 0; // make a redundant end point invisible
}
if (col[0] == 2) {
last = null;
lastt = -1;
} else {
last = col;
lastt = t;
}
}
}
}
}
return {'meta': meta, 'notes': notes};
}
function make_offset_negative(result) {
// K-Shoot MANIA: offset (msec) is where the first measure of the pattern starts
// EffectDrive: offset (sec) is where the music starts (while the measure is played in the invisible state)
// we need to make the offset for K-Shoot MANIA negative in order to make the offset for EffectDrive positive.
var measurelen = 240000 / result.meta.t;
while (result.meta.o > 0) {
result.notes.unshift([[null, null, null, null, null, null, null, null, {}]]);
result.meta.o -= measurelen;
}
return result;
}
function to_drv(result) {
var diffno = {'light': 1, 'challenge': 2, 'extended': 3, 'infinite': 3/*XXX*/}[result.meta.difficulty];
var drv = [];
var conv_ws = function (s) { return s.replace(/\s+/g, '\u3000'); }; // since normal whitespace does not work...
drv.push('#TITLE:' + conv_ws(result.meta.title));
drv.push('#ARTIST:' + conv_ws(result.meta.artist));
for (var i = 1; i <= 3; ++i) drv.push('#EFFECTED' + i + ':' + (i == diffno ? conv_ws(result.meta.effect) : 'N/A'));
for (var i = 1; i <= 3; ++i) drv.push('#JACKET' + i + ':' + (i == diffno ? result.meta.jacket : ''));
for (var i = 1; i <= 3; ++i) drv.push('#PAINTED' + i + ':' + (i == diffno ? conv_ws(result.meta.illustrator) : 'N/A'));
drv.push('#MUSIC:' + result.meta.m[0]);
drv.push('#OFFSET:' + (-result.meta.o / 1000));
for (var i = 1; i <= 3; ++i) drv.push('#LEVEL' + i + ':' + (i == diffno ? result.meta.level : 0));
drv.push('#BPMS:' + result.meta.t);
drv.push('0.000'); // XXX no variable BPM support yet
drv.push(result.meta.t);
drv.push('#SAMPLESTART:' + result.meta.po / 1000);
drv.push('#SAMPLELENGTH:' + result.meta.plength / 1000);
return drv;
}
function to_prd(result, options) {
options = options || {};
var rails = [[], []], longs = [[], []], notes = [];
var inside = [false, false, false, false, false, false, false, false];
for (var i = 0; i < result.notes.length; ++i) {
var denom = result.notes[i].length;
for (var k = 0; k < 4; ++k) {
var line = [];
for (var j = 0; j < denom; ++j) {
var col = result.notes[i][j][k];
if (col === null) {
if (options.effectdrive_compat) {
line.push(inside[k] ? '2' : '0');
} else {
line.push(inside[k] ? 'L' : '0');
}
} else if (col[0] == 0) {
line.push('1');
} else if (col[0] == 1) {
inside[k] = true;
if (options.effectdrive_compat) {
line.push('2');
} else {
line.push('L');
}
} else {
inside[k] = false;
line.push('0');
}
}
notes.push(line.join(''));
}
for (var k = 4; k < 6; ++k) {
var line = [];
for (var j = 0; j < denom; ++j) {
var col = result.notes[i][j][k];
if (col === null) {
line.push(inside[k] ? '1' : '0');
} else if (col[0] == 0) {
if (options.effectdrive_compat) {
line.push('2');
longs[k-4].push('NO 0 0 0');
} else {
line.push('S');
}
} else if (col[0] == 1) {
inside[k] = true;
line.push('1');
longs[k-4].push('FL 0 0 50');
} else {
inside[k] = false;
line.push('0');
}
}
notes.push(line.join(''));
}
for (var k = 6; k < 8; ++k) {
var line = [];
for (var j = 0; j < denom; ++j) {
var col = result.notes[i][j][k];
if (col === null) {
line.push(inside[k] ? 'o' : 'x');
} else {
if (col[0] == 1) inside[k] = true;
else if (col[0] == 2 || col[0] == 0) inside[k] = false;
// well, we don't use col[2] since we are almost ignoring perpendicular correction...
line.push(to_base10(col[1]));
// ...except for the generation of #RAIL_{L,R} sections.
if ((col[0] & 1) == 1) { // starting position (types 1 or 3) only
if (col[1] != col[2]) {
var more = result.notes[i][j][8];
var hz = 0;
if (more.turnto == (col[1] < col[2] ? 'right' : 'left')) {
// k measures per rotation = 1 / (k * 240 / bpm) seconds^-1
hz = result.meta.t / more.turnin / 240;
}
rails[k-6].push('CN 0 ' + result.meta.chokkakuvol + ' ' + (60 * hz | 0));
} else {
rails[k-6].push((k==6 ? 'HF' : 'LF') + ' 700 4000 1000');
}
}
}
}
notes.push(line.join(''));
}
notes.push((i == result.notes.length-1 ? ';' : ',') + '//measure' + (i+1));
}
rails[0].push('ED 0 0 0');
rails[1].push('ED 0 0 0');
longs[0].push('ED 0 0 0');
longs[1].push('ED 0 0 0');
return [].concat(['#RAIL_L:'], rails[0], ['', '#RAIL_R:'], rails[1], ['', '#LONG_L:'], longs[0], ['', '#LONG_R:'], longs[1], ['', '#NOTES:'], notes);
}
var ksm_text = document.getElementById('ksm');
var drv_text = document.getElementById('drv');
var prd_text = document.getElementById('prd');
var difficulty_lbl = document.getElementById('difficulty');
var effectdrive_compat_chk = document.getElementById('effectdrive-compat');
var convert_btn = document.getElementById('convert');
convert_btn.addEventListener('click', function() {
var lines = ksm_text.value.split(/\r?\n/g);
for (var i = 0; i < lines.length; ++i) lines[i] = lines[i].replace(/^\s+/, '').replace(/\s+$/, '');
try {
var result = make_offset_negative(parse_ksm(lines));
var drv = to_drv(result);
var prd = to_prd(result, {effectdrive_compat: effectdrive_compat_chk.checked});
difficulty_lbl.innerHTML = {'light': 'EASY', 'challenge': 'NORMAL', 'extended': 'HARD', 'infinite': 'HARD'/*XXX*/}[result.meta.difficulty];
drv_text.value = drv.join('\n');
prd_text.value = prd.join('\n');
} catch (e) {
if (Object.prototype.toString.call(e) === '[object Array]') {
// this is our custom exceptions
alert('Parsing error at line ' + (e[1]+1) + '\n' + e[0]);
} else {
alert('Unknown error\n' + e.toString());
}
}
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment