diff --git a/tools/cheat-builder/build.js b/tools/cheat-builder/build.js new file mode 100644 index 0000000..8939281 --- /dev/null +++ b/tools/cheat-builder/build.js @@ -0,0 +1,184 @@ +const readxml = require("./xml"); +const readCht = require("./cht"); +const { readFile, writeFile } = require("fs/promises"); + +const XML = node => { + const helper = { + get: function(obj, key) { + if( !(key in obj) ){ + return map => { + if( !map ) return XML( obj.children.find( n => n.name==key ) ); + else return XML( obj.children.find( n => n.name == key && Object.keys(map).every(k=>n.attributes[k]==map[k]) ) ); + } + } + else return obj[key] + } + } + return node ? new Proxy(node, helper) : null; +} + +const slotmap = (type,size) => { + const map = { + gba_eeprom_4k:0, gba_yoshiug: 0, gba_eeprom: 0, + gba_eeprom_64k: 2, gba_boktai: 2, + gba_flash_rtc: 8, + gba_flash: 9, gba_flash_512: 9, + gba_flash_1m_rtc: 10, + gba_flash_1m: 11, + gba_sram: 14, gba_drilldoz: 14, gba_wariotws: 14 + } + return type in map ? (0 == (map[type] & 4) ? (size>0x1000000 ? map[type]+1 : map[type]) : map[type] ) : 15 +} + +const romdesc = data => { + const getserial = tr => ["AGB-","AGP-"].includes(tr.slice(0,4).toUpperCase())?tr.slice(4,8).toUpperCase() : ""; + const getslot = n => n ? slotmap( n.attributes.value ) : 15 + + const rom = XML(data); + + // title + let title = rom.info( {name: "alt_title"} ); + // description + let desc = rom.description(); + // serial + let serial = rom.info( {name: "serial"} ); + // slot + let part = rom.part( {name: "cart"} ); + + return { + title: title ? title.attributes.value : desc.children[0], + desc: desc.children[0], + serial: serial ? getserial(serial.attributes.value) : "", + slot: part ? getslot( part.feature({name: "slot"}), part.dataarea().attributes.size ) : 15, + hash: part ? part.dataarea().rom().attributes.crc : "", + } +} + +const loadroms = async () => { + const xmlfile = await readFile("./gba.xml") + const xmldata = readxml( xmlfile.toString("utf-8") ); + return xmldata.children.map( romdesc ).filter( n => !!n ) +} + +const loadlist = async () => { + const datfile = await readFile("./gba.dat"); + const records = datfile.toString("utf-8").split("\r\n"); + return records.filter( rec => rec.includes(";") ).map( rec => rec.split(";") ); +} + +const trimcheat = (dat, rom, order) => { + let parts = [], start = 0, done = false; + while( !done ){ + let pos = dat.indexOf("[GameInfo]", start); + if( pos > 0 ) { + pos = dat.indexOf("[", pos + "[GameInfo]".length); + if( pos > 0 ) { + parts.push( dat.slice( start, pos ) ); + start = pos; + } + else { + parts.push( dat.slice( start, dat.length ) ); + done = true; + } + } + else { + parts.push( dat.slice( start, dat.length ) ); + done = true; + } + } + return parts.map( tr => readCht(tr, rom, order) ); +} + +const loadcheat = async (rom, order) => { + try{ + const chtfile = await readFile(`./gba/${order.padStart(4,"0").slice(-4)}.u8`); + const cheats = chtfile.toString("utf-8"); + if( cheats.indexOf("[GameInfo]") != cheats.lastIndexOf("[GameInfo]") ) + rom.cheat = trimcheat( cheats, rom, order ); + else rom.cheat = [ readCht( cheats, rom, order ) ]; + } + catch( e ) { console.log( "bad cheat: %s[order=%d]\n%s", rom.title, order, e.stack ) }; +} + +const format = roms => { + roms.sort( (a, b) => a.serial.localeCompare(b.serial) ); + const valid = roms.filter( r => r.serial.length == 4 && !!r.cheat ); + console.info( `all rom has ${roms.length}, valid rom has ${valid.length}` ); + + const enc = new TextEncoder(); + const align = (n, base) => base * Math.floor( (n+base-1) / base ); + const concat = (a,b) => { + const n = new ( Object.getPrototypeOf(a).constructor )( a.length+b.length ); + n.set( a, 0 ) + n.set( b, a.length ); + return n; + } + const getnametable = list => list.reduce( (r,{serial, cheat}, idx, arr) => { + const val = serial.slice(0, -1); + if( val != r[3] ){ + r[0] = concat( r[0], enc.encode(val) ) + r[1] = concat( r[1], [ r[2] ] ); + r[3] = val; + } + r[2] = r[2] + cheat.length; + if( idx+1 == arr.length ) r[1] = concat( r[1], [ r[2] ] ); + return r; + }, [new Uint8Array(), new Uint16Array(), 0, ""] ); + + const expandcheat = (list, base) => list.reduce( (r, {cheat, serial}) => { + const val = serial.charCodeAt(3); + const [c, e] = cheat.reduce( (r,{id, bin}) => { + const off = r[2] + r[1].length; + r[0] = concat( r[0], [val | ( off<<3 ), id] ) + r[1] = concat( r[1], bin ); + return r; + }, r ); + return [c, e, r[2]]; + }, [new Uint32Array(), new Uint8Array(), align(base, 32)] ); + + const [sers, offs, chtc] = getnametable( valid ); + const [cheats, expanded] = expandcheat( valid, 8+sers.length+offs.length*2+chtc*8 ); + console.info( `name: ${sers.length} cheats: ${chtc}` ); + + const serialbase = 8; + const offsetbase = serialbase + sers.length; + const cheatbase = offsetbase + offs.length * 2; + const expandbase = align(cheatbase + cheats.length * 4, 32); + const total = expandbase + expanded.length; + + const output = new ArrayBuffer( total ); + // header: magic number: ACL\1-serial length(u32) + const writter = new DataView( output ); + writter.setUint8(0, "A".charCodeAt() ); + writter.setUint8(1, "C".charCodeAt() ); + writter.setUint8(2, "L".charCodeAt() ); + writter.setUint8(3, 1); + + const slen = sers.length / 3; + writter.setUint32(4, slen); + + let ret = new Uint8Array( output ); + ret.set( sers, serialbase ); + ret.set( offs, offsetbase ); + ret.set( cheats, cheatbase ); + ret.set( expanded, expandbase ); + + return ret; +} + +const start = async () => { + let roms = await loadroms(); + let indx = roms.reduce( (r,v) => (v.hash && r.set(v.hash,v), r), new Map() ); + let list = await loadlist(); + await Promise.all( list.map( game => { + if( !indx.has(game[8].toLowerCase()) ) { + return Promise.resolve(); + } + else return loadcheat( indx.get(game[8].toLowerCase()), game[0] ); + } ) ); + + const content = await format( roms ); + await writeFile( "gba.acl", content ); +} + +start() diff --git a/tools/cheat-builder/cheats.tar.bz2 b/tools/cheat-builder/cheats.tar.bz2 new file mode 100644 index 0000000..33d6c3b Binary files /dev/null and b/tools/cheat-builder/cheats.tar.bz2 differ diff --git a/tools/cheat-builder/cht.js b/tools/cheat-builder/cht.js new file mode 100644 index 0000000..454c875 --- /dev/null +++ b/tools/cheat-builder/cht.js @@ -0,0 +1,333 @@ + +const WAIT_LOCK = 1 +const READ_LOCK = 2 +const WAIT_HOLE = 3 +const READ_HOLE = 4 +const WAIT_KEY = 5 +const READ_KEY = 6 + +const STATE = ["", "WAIT_LOCK", "READ_LOCK", "WAIT_HOLE", "READ_HOLE", "WAIT_KEY", "READ_KEY"] +const PASSHOLE = ["text", "off"] + +const read = (cht, info, order) => { + cht = cht.replace( /[-]{4,}/g, "" ); + const space = " ".charCodeAt(); + const tab = "\t".charCodeAt(); + const zero = "0".charCodeAt(); + const nine = "9".charCodeAt(); + const a = "a".charCodeAt(); + const f = "f".charCodeAt(); + const size = cht.length; + let state = WAIT_LOCK, token = [], line = 1; + + let locks = [], currlock = null; + const token_str = () => { let r = String.fromCharCode.apply( String, token ); return (token.length=0,r);} + const error = msg => { throw new SyntaxError(`${msg} at line: ${line} in lock ${currlock?.name}`) } + for( let i=0; i <= size; ++i ){ + const ch = i == size ? "" : cht.charAt(i); + switch( ch ){ + case "[": + if( state == WAIT_LOCK || state == READ_HOLE ){ + state = READ_LOCK; + if( currlock ) locks.push( { name: currlock.name, keys: currlock.holes } ); + currlock = {} + } + else error(`error occur [ on ${STATE[state]}`) + break; + case "]": + if( state == READ_LOCK ){ + state = WAIT_HOLE; + currlock.name = token_str(); + currlock.holes = []; + if( currlock.name.toLowerCase() == "gameinfo" ) + i = size; // break; + } + else error(`error occur ] on ${STATE[state]}`) + break; + case "\r": case "\n": + if( state == WAIT_HOLE ){ + state = READ_HOLE; + } + else if( state == READ_KEY ){ + state = READ_HOLE; + if( token.length ) currlock.keys.at(-1).push( token_str() ); + if( !PASSHOLE.includes(currlock.hole.toLowerCase()) ) { + currlock.holes.push( {name: currlock.hole, cmd: currlock.keys} ); + } + } + else if( state == READ_LOCK ){ + error(`error occur newline on ${STATE[state]}`); + } + else if( state == READ_HOLE ){ + if( token.length > 0 && token.find( c => c!=space && c!=tab ) ){ + error(`error occur newline on ${STATE[state]}`); + } + else token.length = 0; + } + if( ch == "\n" ) ++line; + break; + case "=": + if( state == READ_HOLE ){ + state = READ_KEY; + currlock.hole = token_str(); + currlock.keys = [ new Array() ]; + } + else if( state == READ_LOCK ){ + token.push( ch.charCodeAt() ); + } + else error(`error occur = on ${STATE[state]}`); + break; + case ",": + if( state == READ_KEY ){ + if( token.length ) currlock.keys.at(-1).push( token_str() ); + } + else if( state == READ_HOLE ){// MULTILINE KEY + let {name, cmd} = currlock.holes.pop(); + currlock.hole = name; + currlock.keys = cmd; + if( token.length ) currlock.keys.at(-1).push( token_str() ); + state = READ_KEY; + } + else if( state == READ_LOCK ){ + token.push( ch.charCodeAt() ); + } + else error(`error occur , on ${STATE[state]}`); + break; + case ";": + if( state == READ_KEY ){ + if( token.length ) currlock.keys.at(-1).push( token_str() ); + currlock.keys.push( new Array() ); + } + else if( state == READ_LOCK ){ + token.push( ch.charCodeAt() ); + } + else if( state == READ_HOLE ){// MULTILINE KEY + let {name, cmd} = currlock.holes.pop(); + currlock.hole = name; + currlock.keys = cmd; + if( token.length ) currlock.keys.at(-1).push( token_str() ); + currlock.keys.push( new Array() ); + state = READ_KEY; + } + else error(`error occur ; on ${STATE[state]}`); + break; + case "": // END OF FILE + if( state == READ_KEY ){ + if( token.length ) currlock.keys.at(-1).push( token_str() ); + } + else if( state == READ_HOLE && token.length == 0 ){ + // safe + } + else error(`error occur eof on ${STATE[state]}`) + break; + case " ": + if( state == READ_LOCK || state == READ_HOLE ){ + token.push( ch.charCodeAt() ); + } + else if( state == READ_KEY ){ + if( currlock.name.toLowerCase() == "gameinfo" ){ + token.push( ch.charCodeAt() ); + } + } + break; + default: + if( state == READ_LOCK || state == READ_HOLE ){ + token.push( ch.charCodeAt() ); + } + else if( state == READ_KEY ){ + let digit = ch.toLowerCase().charCodeAt(); + if( (zero <= digit && digit <= nine) || + (a <= digit && digit <= f) || + currlock.name.toLowerCase() == "gameinfo" || + currlock.hole.toLowerCase() == "text" ) + { + token.push( digit ); + } + else error( `error occur ${ch}[${digit}] on ${STATE[state]}` ); + } + else error(`error occur ${ch} on ${STATE[state]}`); + break; + } + } + return lade( locks, info, order ); +} + +const assembleCheat = (list, dup, hole) => { + // 建立 地址:数值 的映射关系 + const fromTaddr = taddr => { + const n = parseInt(taddr, 16); + if( n==0 || n>0x41FFFFF ) throw new Error( `get a invalid address: ${taddr}` ); + if( n <= 0x3ffff ) return n | 0x2000000; + else if( 0 == (n & 0xf000000) ) return (n & 0xffff) | 0x3000000; + else return n; + }; + const addrval = list.reduce( (r, command) => { + let [taddr, ...vals] = command; + let addr = fromTaddr( taddr ); + vals.forEach( (val, pos) => { + let dest = addr + pos; + let value = parseInt(val, 16); + if( r.has(dest) && r.get(dest) != value ) + console.warn( `duplicated address assign ${dest.toString(16)}` ); + else r.set( dest, value ); + } ); + return r; + }, new Map() ); + const ordered = [...addrval].sort( (a,b) => a[0] - b[0] ); + const blocks = ordered.reduce( (arr, [addr, value]) => { + if( dup.has(addr) && dup.get(addr) != hole ) + console.warn( `conflict address in different hole: orig=${dup.get(addr)} confl=${hole}` ); + dup.set(addr, hole); + if( arr.length == 0 ) { + arr.push({addr, value, count: 1});// cnt addr value + } + else { + let cur = arr.at(-1); + if( cur.value == value ){ + ++cur.count; + } + else arr.push( {addr, value, count: 1} ); + } + return arr; + }, [] ); + return Uint32Array.from( blocks.reduce( (r,block)=>[...r, block.addr, (block.count<<8)|block.value], []) ) +} + +const pack = (entlist, strlist, idxlist, cmdlist, xvalue) => { + const align = (n, base) => base * Math.floor( (n+base-1) / base ); + const strbase = 2 + entlist.length * 4 * 3; + const idxbase = strbase + strlist.length; + const cmdbase = idxbase + idxlist.length * 2; + const size = cmdbase + cmdlist.length*4; + let result = new ArrayBuffer( align(size, 32) ); + + // entry count + let view = new DataView(result); + view.setUint16(0, entlist.length, true); + + // entry data + entlist.forEach( (entry,index) => { + let offset = 2 + index*4*3; + view.setUint32( offset, entry[0], true ); + view.setUint32( offset+4, entry[1] + (entry[0] > 0xffff ? cmdbase : idxbase), true ); + view.setUint32( offset+8, entry[2], true ); + }); + + // string table + let strings = new Uint8Array( result, strbase, strlist.length ); + strings.set( strlist, 0 ); + + // indexes table + let indexes = new Uint8Array( result, idxbase, idxlist.length*2 ); + indexes.set( new Uint8Array(idxlist.buffer), 0 ); + + // instruction table + let commands = new Uint8Array( result, cmdbase, cmdlist.length*4 ); + commands.set( new Uint8Array(cmdlist.buffer), 0 ); + + return { id: xvalue, bin: new Uint8Array( result ) }; +} + +const lade = (list, info, order) => { + let enttable = []; // 保存hole/key数据 + + const makeID = (a,b) => a|b<<16; + const makeEntry = (id, str) => ({ id, data: str }); + + // collect + const collectEntry = (id, keys, list) => { + keys.forEach( ({name,cmd}, index) => { + list.push( name ); + enttable.push( makeEntry( makeID(id,index+1), cmd ) ); + }); + } + + let rootstr = []; + list.forEach( (hole,index) => { + rootstr.push( hole.name ); + + let holestr = []; + collectEntry( index+1, hole.keys, holestr ); + + let entry = makeEntry( makeID(index+1,0), holestr ); + enttable.push( entry ); + } ); + enttable.push( makeEntry( makeID(0,0), rootstr ) ); + + enttable.sort( (a,b) => a.id - b.id ); + + const conv = new TextEncoder(); + let strtable = Uint8Array.of(); // 保存字符串常量 + let idxtable = Uint16Array.of(); // 从字符串常量索引出来的字符串列表 + let cmdtable = Uint32Array.of(); // 保存选项对应指令 + const addString = list => { + return list.map( str => { + let code = conv.encode( str ); + let off = strtable.findIndex( (s,i,t) => { + if( code.every( (_,j) => code[j]==t[j+i] ) && strtable[i+code.length]==0 ){ + return true; + } + return false; + } ); + if( off < 0 ){ + off = strtable.length; + let nt = new Uint8Array( off+code.length+1 ); + nt.set( strtable, 0 ); + nt.set( code, off ); + strtable = nt; + } + str = str.toLowerCase(); + if( str.indexOf("off") >= 0 || str.indexOf("关") >= 0 ){ + //console.log(`似乎是一个单独开关项[label=${str} file=${order}]`); + } + return off; + }); + } + + const addStrIndex = list => { + if( list.length == 0 ) return 0; + + if( strtable.length > 0xffff ) throw new RangeError(`string table is too big.`); + let ret = idxtable.length; + let nt = new Uint16Array( ret + list.length ); + nt.set( idxtable, 0 ); + nt.set( list, ret ); + idxtable = nt; + return ret; + } + + const addCommand = list => { + let ret = cmdtable.length; + let nt = new Uint32Array( ret + list.length ); + nt.set( cmdtable, 0 ); + nt.set( list, ret ); + cmdtable = nt; + return ret; + } + + if( enttable.length > 0xffff ){ + console.warn(`entry 数量超过上限[file=${order}]`); + } + + // pack + let holedup = new Map(); + let entbytelist = enttable.map( entry => { + let {id, data} = entry; + if( id < 0x10000 ){ // 收集str + let idxlist = addString(data.length > 1 ? data : []); // length==1就是开启 + let location = addStrIndex( idxlist ); + return Uint32Array.of( id, location, idxlist.length ); + } + else { + let armbytecode = assembleCheat( data, holedup, id & 0xffff ); + let location = addCommand( armbytecode ); + return Uint32Array.of( id, location, armbytecode.length ); + } + }); + + let n = info.serial.split("").reduce( (r,v)=>(v.charCodeAt() | r<<8), 0 ); + let xv = cmdtable.reduce( (r,v) => r^v, n ); + return pack( entbytelist, strtable, idxtable, cmdtable, xv ); +} + +module.exports = read; diff --git a/tools/cheat-builder/xml.js b/tools/cheat-builder/xml.js new file mode 100644 index 0000000..15c6604 --- /dev/null +++ b/tools/cheat-builder/xml.js @@ -0,0 +1,157 @@ + +let cursor, tr; + +const is_space = ch => " \r\t\n\H".includes(ch); + +const eat_space = () => {while( is_space( tr.charAt(cursor) ) ) ++cursor;} + +const match_next = (str, ignore_space) => { + let ch = next_char( ignore_space ); + if( ch != str ) + throw new SyntaxError(`dismatch symbol, expert ${str}, get ${ch} pos: ${tr.slice(cursor-10, cursor+20)}`) +} + +const next_char = ignore_space => { + if( cursor == tr.length ) + throw new SyntaxError(`invalid end of file`) + + if( !ignore_space ) + return tr.charAt( cursor++ ); + else { + let ch = "" + do{ + ch = tr.charAt(cursor++); + }while( is_space(ch) ); + return ch; + } +} + +const read_token = () => { + let chs = [] + do{ + let ch = peek_char(1) + if( is_space(ch) || ch == "=" || ch == ">" || ch == "/" ) + break; + else chs.push( ch ); + + next_char(); + } while( true ); + if( chs.length == 0 ) + throw new SyntaxError(`invalid token without any char`) + return chs.join("") +} + +const peek_char = (n,ignore_space=0) => { + let t=cursor; + if( t == tr.length ) + return ""; + + if( !ignore_space ) + return tr.slice( t, t+n ); + else { + let chs = [] + while( n>0 ){ + let ch = tr.charAt(t++); + if( is_space(ch) ) + continue; + chs.push( ch ); + n--; + } + return chs.join("") + } +} + +const read_attr = () => { + match_next('"', 1); + let chs = [], ch = ""; + do{ + ch = next_char(); + if( ch=="\\" && peek_char(1) == '\"' ){ + chs.push( next_char() ) + } + else if( ch != '"' ) chs.push( ch ); + }while( ch != '"' ); + return chs.join("") +} + +const read_text = () => { + let ch = next_char(1); + let chs = [ch]; + do{ + ch = peek_char(1); + if( ch == "<" ) break; + chs.push( next_char() ); + }while( true ) + return chs.join(""); +} + +const read_node = () => { + let token, attr, children; + let result = {name: "", attributes: {}, children: []}, node_name = ""; + match_next( "<", 1 ); + node_name = read_token(); + result.name = node_name; + + let peekch = peek_char(1,1); + while( peekch != "/" && peekch != ">" ){ + eat_space(); + token = read_token(); + if( peek_char(1) == "=" ){ + next_char(); + attr = read_attr(); + result.attributes[token] = attr; + } + else { + result.attributes[token] = true; + } + + peekch = peek_char(1,1); + } + if( peekch == "/" ){ + match_next("/",1) + match_next(">") + return result; + } + else if( peekch == ">" ){ + children = [] + next_char(1) + } + else throw new SyntaxError(`should not come here ${peekch}`) + + while( true ){ + if( peek_char(2,1) == "",1); + if( node_name != result.name ) + throw new SyntaxError(`dismatch close tag for ${node_name}`) + else break; + } + else if( peek_char(1,1) == "<") { + children.push( read_node() ); + } + else children.push( read_text() ) + } + + result.children = children; + return result; +} + +const trim_comment = tr => { + let pos = tr.indexOf("", pos+4); + tr = tr.slice(0, pos) + tr.slice(end+3); + pos = tr.indexOf("