1
0
mirror of https://github.com/sehugg/8bitworkshop.git synced 2024-11-25 03:34:05 +00:00

arm32: platform, vasm and armips, unicorn.js

This commit is contained in:
Steven Hugg 2021-06-03 18:17:06 -05:00
parent 2b5ec48940
commit 7725884eb0
22 changed files with 9749 additions and 49 deletions

3
.gitmodules vendored
View File

@ -16,3 +16,6 @@
[submodule "nanoasm"]
path = nanoasm
url = https://github.com/sehugg/nanoasm
[submodule "unicorn.js"]
path = unicorn.js
url = https://github.com/AlexAltea/unicorn.js

View File

@ -15,6 +15,7 @@ all:
cp node_modules/localforage/dist/localforage.min.js ./lib/
cp node_modules/jszip/dist/jszip.min.js ./lib/
cp node_modules/file-saver/dist/*.min.js ./lib/
cp unicorn.js/dist/unicorn-arm.min.js ./unicorn.js/demos/externals/capstone-arm.min.js ./lib/
cp gif.js/dist/* ./lib/
cd jsnes && npm i
$(TSC) -v

View File

@ -437,8 +437,8 @@ $( ".dropdown-submenu" ).click(function(event) {
<!-- Sentry error reporting -->
<script
src="https://browser.sentry-cdn.com/5.29.0/bundle.min.js"
integrity="sha384-/dYT/04VSU9ItKRPTkWeVZ0kqRsVh/T/5rNCjzBwpx7sYeeueKgJzGMNXSal3xoo"
src="https://browser.sentry-cdn.com/6.4.1/bundle.min.js"
integrity="sha384-THoc7rflwZFKTdZNgv6jLFFDn299Uv3t1SW5B4yGLvLiCRTYP9ys6vXZcMl95TQF"
crossorigin="anonymous"
></script>
<script>

View File

@ -597,6 +597,7 @@ function require(modname) {
<script src="gen/common/devices.js"></script>
<script src="gen/common/cpu/MOS6502.js"></script>
<script src="gen/common/cpu/ZilogZ80.js"></script>
<script src="gen/common/cpu/ARM.js"></script>
<script src="gen/machine/vdp_z80.js"></script>
<script>

View File

@ -0,0 +1,15 @@
.set VIDMEM, 0x20000000
mov r0, #0x884400 ; RGB value
mov r1, #VIDMEM ; memory start
LOOP2:
mov r2, #160*128 ; word count
LOOP:
str r0, [r1, r2, lsl #2]
sub r2, r2, #1
cmp r2, #0
bge LOOP
sub r0, r0, #0xff11
cmp r0, #0
bgt LOOP2

View File

@ -21,6 +21,8 @@
*
* The data bus is implemented as separate read/write buses. Combine them
* on the output pads if external memory is required.
*
* Also see: https://github.com/sehugg/mango_one
*/
module cpu6502( clk, reset, AB, DI, DO, WE, IRQ, NMI, RDY );
@ -1352,6 +1354,10 @@ wire RDY=1; // Ready signal. Pauses CPU when RDY=0
end
reg [7:0] rom[0:15];
// LDY #$13
// .loop: DEY
// BNE .loop
// BRK
initial begin
rom = '{
8'ha0,8'h13,

View File

@ -75,7 +75,7 @@ export interface Platform {
isRunning() : boolean;
getToolForFilename(s:string) : string;
getDefaultExtension() : string;
getPresets() : Preset[];
getPresets?() : Preset[];
pause() : void;
resume() : void;
loadROM(title:string, rom:any); // TODO: Uint8Array
@ -1086,7 +1086,6 @@ export abstract class BaseMachinePlatform<T extends Machine> extends BaseDebugPl
constructor(mainElement : HTMLElement) {
super();
this.mainElement = mainElement;
this.machine = this.newMachine();
}
reset() { this.machine.reset(); }
@ -1099,8 +1098,13 @@ export abstract class BaseMachinePlatform<T extends Machine> extends BaseDebugPl
loadControlsState(s) { this.machine.loadControlsState(s); }
saveControlsState() { return this.machine.saveControlsState(); }
start() {
async start() {
this.machine = this.newMachine();
const m = this.machine;
// block on WASM loading
if (m instanceof BaseWASMMachine) {
await m.loadWASM();
}
var videoFrequency;
if (hasVideo(m)) {
var vp = m.getVideoParams();

3942
src/common/cpu/ARM.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -83,11 +83,9 @@ export class RasterVideo {
}
canvas : HTMLCanvasElement;
ctx;
imageData;
arraybuf;
buf8;
datau32;
ctx : CanvasRenderingContext2D;
imageData : ImageData;
datau32 : Uint32Array;
vcanvas : JQuery;
paddle_x = 255;

View File

@ -84,6 +84,7 @@ var TOOL_TO_SOURCE_STYLE = {
'basic': 'basic',
'silice': 'verilog',
'wiz': 'text/x-wiz',
'vasmarm': '6502'
}
function gaEvent(category:string, action:string, label?:string, value?:string) {
@ -2048,7 +2049,7 @@ async function startPlatform() {
platform = new PLATFORMS[platform_id]($("#emuscreen")[0]);
setPlatformUI();
stateRecorder = new StateRecorderImpl(platform);
PRESETS = platform.getPresets();
PRESETS = platform.getPresets ? platform.getPresets() : [];
if (!qs['file']) {
// try to load last file (redirect)
var lastid;

View File

@ -499,41 +499,40 @@ export class DisassemblerView implements ProjectView {
// TODO: too many globals
refresh(moveCursor: boolean) {
var state = lastDebugState || platform.saveState(); // TODO?
var pc = state.c ? state.c.PC : 0;
var curline = 0;
var selline = 0;
var addr2symbol = (platform.debugSymbols && platform.debugSymbols.addr2symbol) || {};
let state = lastDebugState || platform.saveState(); // TODO?
let pc = state.c ? state.c.PC : 0;
let curline = 0;
let selline = 0;
let addr2symbol = (platform.debugSymbols && platform.debugSymbols.addr2symbol) || {};
// TODO: not perfect disassembler
var disassemble = (start, end) => {
if (start < 0) start = 0;
if (end > 0xffff) end = 0xffff;
let disassemble = (start, len) => {
// TODO: use pc2visits
var a = start;
var s = "";
while (a < end) {
var disasm = platform.disassemble(a, platform.readAddress.bind(platform));
let s = "";
let ofs = 0;
while (ofs < len) {
let a = (start + ofs) | 0;
let disasm = platform.disassemble(a, platform.readAddress.bind(platform));
/* TODO: look thru all source files
var srclinenum = sourcefile && this.sourcefile.offset2line[a];
let srclinenum = sourcefile && this.sourcefile.offset2line[a];
if (srclinenum) {
var srcline = getActiveEditor().getLine(srclinenum);
let srcline = getActiveEditor().getLine(srclinenum);
if (srcline && srcline.trim().length) {
s += "; " + srclinenum + ":\t" + srcline + "\n";
curline++;
}
}
*/
var bytes = "";
var comment = "";
for (var i=0; i<disasm.nbytes; i++)
let bytes = "";
let comment = "";
for (let i=0; i<disasm.nbytes; i++)
bytes += hex(platform.readAddress(a+i));
while (bytes.length < 14)
bytes += ' ';
var dstr = disasm.line;
let dstr = disasm.line;
if (addr2symbol && disasm.isaddr) { // TODO: move out
dstr = dstr.replace(/([^#])[$]([0-9A-F]+)/, (substr:string, ...args:any[]):string => {
var addr = parseInt(args[1], 16);
var sym = addr2symbol[addr];
let addr = parseInt(args[1], 16);
let sym = addr2symbol[addr];
if (sym) return (args[0] + sym);
sym = addr2symbol[addr-1];
if (sym) return (args[0] + sym + "+1");
@ -541,20 +540,20 @@ export class DisassemblerView implements ProjectView {
});
}
if (addr2symbol) {
var sym = addr2symbol[a];
let sym = addr2symbol[a];
if (sym) {
comment = "; " + sym;
}
}
var dline = hex(parseInt(a), 4) + "\t" + rpad(bytes,14) + "\t" + rpad(dstr,30) + comment + "\n";
let dline = hex(a, 4) + "\t" + rpad(bytes,14) + "\t" + rpad(dstr,30) + comment + "\n";
s += dline;
if (a == pc) selline = curline;
curline++;
a += disasm.nbytes || 1;
ofs += disasm.nbytes || 1;
}
return s;
}
var text = disassemble(pc-disasmWindow, pc) + disassemble(pc, pc+disasmWindow);
let text = disassemble(pc-disasmWindow, disasmWindow) + disassemble(pc, disasmWindow);
this.disasmview.setValue(text);
if (moveCursor) {
this.disasmview.setCursor(selline, 0);

248
src/platform/arm32.ts Normal file
View File

@ -0,0 +1,248 @@
"use strict";
import { BaseDebugPlatform, CpuState, EmuState, Platform, DisasmLine, Debuggable } from "../common/baseplatform";
import { AnimationTimer, EmuHalt, padBytes, PLATFORMS, RasterVideo } from "../common/emu";
import { loadScript } from "../ide/ui";
import { hex, lpad } from "../common/util";
import { ARM32CPU } from "../common/cpu/ARM";
declare var uc, cs : any; // Unicorn module
const ARM32_PRESETS = [
{ id: 'vidfill.vasm', name: 'Video Memory Fill' },
];
const SCREEN_WIDTH = 160;
const SCREEN_HEIGHT = 128;
const ROM_START_ADDR = 0x0;
const HIROM_START_ADDR = 0xff800000;
const ROM_SIZE = 512*1024;
const RAM_START_ADDR = 0x20000000;
const RAM_SIZE = 512*1024;
const CLOCKS_PER_FRAME = 10000;
interface ARM32State extends EmuState {
r: Uint32Array; // registers
}
class ARM32Platform extends BaseDebugPlatform implements Platform, Debuggable {
u : any; // Unicorn
d : any; // Capstone
mainElement : HTMLElement;
video : RasterVideo;
timer : AnimationTimer;
romSize = 0;
halted = false;
state : ARM32State;
cpu : ARM32CPU;
constructor(mainElement: HTMLElement) {
super();
this.mainElement = mainElement;
}
getPresets() { return ARM32_PRESETS };
async start() {
console.log("Loading Unicorn/Capstone");
await loadScript('./lib/unicorn-arm.min.js');
await loadScript('./lib/capstone-arm.min.js');
//this.cpu = new ARM32CPU();
this.u = new uc.Unicorn(uc.ARCH_ARM, uc.MODE_ARM);
this.u.mem_map(ROM_START_ADDR, ROM_SIZE, uc.PROT_READ | uc.PROT_EXEC);
this.u.mem_map(HIROM_START_ADDR, ROM_SIZE, uc.PROT_READ | uc.PROT_EXEC);
this.u.mem_map(RAM_START_ADDR, RAM_SIZE, uc.PROT_READ | uc.PROT_WRITE | uc.PROT_EXEC);
this.d = new cs.Capstone(cs.ARCH_ARM, cs.MODE_ARM);
this.video = new RasterVideo(this.mainElement, SCREEN_WIDTH, SCREEN_HEIGHT);
this.video.create();
this.timer = new AnimationTimer(60, this.nextFrame.bind(this));
}
reset() {
//this.cpu.reset();
this.u.reg_write_i32(uc.ARM_REG_PC, 0);
var cpsr = this.u.reg_read_i32(uc.ARM_REG_CPSR);
this.u.reg_write_i32(uc.ARM_REG_CPSR, (cpsr & 0xffffff00) | 0b11010011);
this.u.mem_write(RAM_START_ADDR, new Uint8Array(RAM_SIZE)); // clear RAM
this.halted = false;
this.state = null;
}
pause(): void {
this.timer.stop();
}
resume(): void {
this.timer.start();
console.log('resume')
}
isRunning() {
return this.timer.isRunning();
}
isBlocked() {
return this.halted;
}
checkPCOverflow(pc) {
}
disassemble(pc:number, read:(addr:number)=>number) : DisasmLine {
try {
var b = this.u.mem_read(pc, 8);
var insns = this.d.disasm(b, pc, 8);
var i0 = insns[0];
return {
nbytes: i0.size,
line: i0.mnemonic + " " + i0.op_str,
isaddr: i0.address > 0
};
} catch (e) {
return {
nbytes: 4,
line: "???",
isaddr: false
};
}
}
advance(novideo?: boolean): number {
this.state = null;
var pc = this.getPC();
var endpc = this.romSize;
if (pc >= endpc) {
this.halted = true;
this.haltAndCatchFire("ROM overrun at PC 0x" + hex(pc));
this.pause();
return 1;
}
var debugCond = this.getDebugCallback();
try {
if (debugCond != null) {
for (var i=0; i<CLOCKS_PER_FRAME && pc <= endpc; i++) {
if (debugCond()) {
break;
}
this.u.emu_start(pc, endpc, 0, 1);
pc = this.getPC();
}
} else {
this.u.emu_start(pc, endpc, 0, CLOCKS_PER_FRAME); // TODO
}
} catch (e) {
throw new EmuHalt(e + " at PC=0x" + hex(this.getPC()));
}
if (!novideo) {
this.updateVideo();
}
return CLOCKS_PER_FRAME; //throw new Error("Method not implemented.");
}
updateVideo() {
var vmem8 : Uint8Array = this.u.mem_read(RAM_START_ADDR, SCREEN_WIDTH * SCREEN_HEIGHT * 4);
var vmem32 = new Uint32Array(vmem8.buffer);
var pixels = this.video.getFrameData();
for (var i=0; i<vmem32.length; i++)
pixels[i] = vmem32[i] | 0xff000000;
this.video.updateFrame();
}
getToolForFilename() {
return "vasmarm";
}
getDefaultExtension() {
return ".asm";
}
loadROM(title, data: Uint8Array) {
this.romSize = data.length;
data = padBytes(data, ROM_SIZE);
this.u.mem_write(ROM_START_ADDR, data);
this.u.mem_write(HIROM_START_ADDR, data);
this.state = null;
this.reset();
}
readAddress(addr: number) {
// TODO: slow
try {
return this.u.mem_read(addr, 1)[0];
} catch (e) {
return 0;
}
}
getCPUState(): CpuState {
return {
PC: this.getPC(),
SP: this.getSP(),
};
}
isStable(): boolean {
return true;
}
getPC() {
return this.u.reg_read_i32(uc.ARM_REG_PC);
}
getSP() {
return this.u.reg_read_i32(uc.ARM_REG_SP);
}
loadState(state: ARM32State): void {
for (var i=0; i<uc.ARM_REG_ENDING; i++) {
this.u.reg_write_i32(i, state.r[i]);
}
this.u.mem_write(RAM_START_ADDR, state.b);
}
saveState(): ARM32State {
var regs = new Uint32Array(uc.ARM_REG_ENDING);
for (var i=0; i<uc.ARM_REG_ENDING; i++) {
regs[i] = this.u.reg_read_i32(i);
}
this.state = {
c: this.getCPUState(),
b: this.u.mem_read(RAM_START_ADDR, RAM_SIZE),
r: regs
};
return this.state;
}
showHelp(tool: string) {
if (tool == 'vasmarm') {
window.open('http://sun.hasenbraten.de/vasm/release/vasm.html');
}
}
getDebugCategories() {
return ["CPU"];
}
getDebugInfo?(category:string, state:ARM32State) : string {
var s = '';
for (var i=0; i<13; i++) {
s += lpad('r'+i, 3) + ' ' + hex(state.r[i+uc.ARM_REG_R0],8) + '\n';
}
s += ' SP ' + hex(state.r[uc.ARM_REG_SP],8) + '\n';
s += ' LR ' + hex(state.r[uc.ARM_REG_LR],8) + '\n';
s += ' PC ' + hex(state.r[uc.ARM_REG_PC],8) + '\n';
s += 'CPSR ' + hex(state.r[uc.ARM_REG_CPSR],8) + '\n';
return s;
}
}
/*
export abstract class BaseARMMachinePlatform<T extends Machine> extends BaseMachinePlatform<T> {
//getOpcodeMetadata = getOpcodeMetadata_z80;
getToolForFilename() { return "armips"; }
}
class ARM32Platform extends BaseARMMachinePlatform<ARM32Console> implements Platform {
async start() {
console.log("Loading Unicorn");
await loadScript('./unicorn.js/dist/unicorn-arm.min.js');
return super.start();
}
newMachine() { return new ARM32Console(); }
getPresets() { return ARM32_PRESETS; }
getDefaultExtension() { return ".asm"; };
readAddress(a) { return this.machine.read(a); }
getMemoryMap = function() { return { main:[
{name:'Frame Buffer',start:0x2400,size:7168,type:'ram'},
] } };
}
*/
PLATFORMS['arm32'] = ARM32Platform;

View File

@ -42,11 +42,6 @@ class C64WASMPlatform extends Base6502MachinePlatform<C64_WASMMachine> implement
newMachine() { return new C64_WASMMachine('c64'); }
async start() {
// TODO: start() needs to block
await this.machine.loadWASM();
super.start();
}
getPresets() { return C64_PRESETS; }
getDefaultExtension() { return ".c"; };
readAddress(a) { return this.machine.readConst(a); }

View File

@ -39,6 +39,7 @@ var VERILOG_PRESETS = [
{id:'cpu16.v', name:'16-Bit CPU'},
{id:'cpu_platform.v', name:'CPU Platform'},
{id:'test2.asm', name:'16-bit ASM Game'},
{id:'cpu6502.v', name:'6502 CPU'},
];
var VERILOG_KEYCODE_MAP = makeKeycodeMap([

View File

@ -23,11 +23,6 @@ class ZXWASMPlatform extends BaseZ80MachinePlatform<ZX_WASMMachine> implements P
newMachine() { return new ZX_WASMMachine('zx'); }
async start() {
// TODO: start() needs to block
await this.machine.loadWASM();
super.start();
}
getPresets() { return ZX_PRESETS; }
getDefaultExtension() { return ".asm"; };
readAddress(a) { return this.machine.readConst(a); }

22
src/worker/wasm/armips.js Normal file

File diff suppressed because one or more lines are too long

BIN
src/worker/wasm/armips.wasm Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -6,7 +6,7 @@ declare var WebAssembly;
declare function importScripts(path:string);
declare function postMessage(msg);
const emglobal : any = this['window'] || this['global'] || this;
const emglobal : any = (this as any)['window'] || (this as any)['global'] || this;
const ENVIRONMENT_IS_WEB = typeof window === 'object';
const ENVIRONMENT_IS_WORKER = typeof importScripts === 'function';
@ -2861,6 +2861,159 @@ function compileWiz(step:BuildStep) {
}
}
function assembleARMIPS(step:BuildStep) {
loadNative("armips");
var errors = [];
gatherFiles(step, {mainFilePath:"main.asm"});
var objpath = step.prefix+".bin";
if (staleFiles(step, [objpath])) {
var args = [ step.path ];
var armips = emglobal.armips({
instantiateWasm: moduleInstFn('armips'),
noInitialRun:true,
print:(s:string) => {
console.log(s);
},
printErr:print_fn,
});
var FS = armips['FS'];
console.log("init FS", FS);
populateFiles(step, FS);
execMain(step, armips, args);
if (errors.length)
return {errors:errors};
var objout = FS.readFile(objpath, {encoding:'binary'});
putWorkFile(objpath, objout);
if (!anyTargetChanged(step, [objpath]))
return;
var symbolmap = {};
var segments = [];
var listings : CodeListingMap = {};
return {
output:objout, //.slice(0),
listings:listings,
errors:errors,
symbolmap:symbolmap,
segments:segments
};
}
}
function assembleVASMARM(step:BuildStep) {
loadNative("vasmarm_std");
/// error 2 in line 8 of "gfxtest.c": unknown mnemonic <ew>
/// error 3007: undefined symbol <XXLOOP>
/// TODO: match undefined symbols
var re_err1 = /^(fatal error|error|warning)? (\d+) in line (\d+) of "(.+)": (.+)/;
var re_err2 = /^(fatal error|error|warning)? (\d+): (.+)/;
var errors : WorkerError[] = [];
function match_fn(s) {
var matches = re_err1.exec(s);
if (matches) {
errors.push({
line:parseInt(matches[3]),
path:matches[2],
msg:matches[5],
});
} else {
matches = re_err2.exec(s);
if (matches) {
errors.push({
line:0,
msg:s,
});
} else {
console.log(s);
}
}
}
gatherFiles(step, {mainFilePath:"main.asm"});
var objpath = step.prefix+".bin";
var lstpath = step.prefix+".lst";
if (staleFiles(step, [objpath])) {
var args = [ '-Fbin', '-x', '-wfail', step.path, '-o', objpath, '-L', lstpath ];
var vasm = emglobal.vasm({
instantiateWasm: moduleInstFn('vasmarm_std'),
noInitialRun:true,
print:match_fn,
printErr:match_fn,
});
var FS = vasm['FS'];
populateFiles(step, FS);
execMain(step, vasm, args);
if (errors.length) {
return {errors:errors};
}
var objout = FS.readFile(objpath, {encoding:'binary'});
putWorkFile(objpath, objout);
if (!anyTargetChanged(step, [objpath]))
return;
var lstout = FS.readFile(lstpath, {encoding:'utf8'});
//console.log(lstout);
// F00:0001 mov r0, #0x884400 ; RGB value
// S01:00000000: 11 0B A0 E3 22 07 80 E3
// S01 .text
// F00 vidfill.vasm
// LOOP LAB (0x10) sec=.text
var symbolmap = {};
var segments = [];
var listings : CodeListingMap = {};
// TODO: parse listings
var re_lstline = /^F(\d+):(\d+)\s+(.+)/;
var re_secline = /^\s+S(\d+):([0-9A-F]+):\s*([0-9A-F ]+)/;
var re_nameline = /^([SF])(\d+)\s+(.+)/;
var files = {};
var sections = {};
// map file and section indices -> names
var lines = lstout.split(re_crlf);
for (var line of lines) {
var m;
if (m = re_nameline.exec(line)) {
if (m[1] == 'F') {
files[m[2]] = m[3];
} else {
sections[m[2]] = m[3];
}
}
}
//console.log(files, sections);
// parse lines
var lstlines : SourceLine[] = [];
var linenum = 0;
for (var line of lines) {
var m;
if (m = re_lstline.exec(line)) {
linenum = parseInt(m[2]);
} else if (m = re_secline.exec(line)) {
lstlines.push({
line: linenum,
offset: parseInt(m[2], 16),
path: step.path,
insns: m[3].replaceAll(' ','')
});
}
}
listings[lstpath] = {lines:lstlines};
return {
output:objout, //.slice(0),
listings:listings,
errors:errors,
symbolmap:symbolmap,
segments:segments
};
}
}
////////////////////////////
var TOOLS = {
@ -2896,6 +3049,8 @@ var TOOLS = {
'basic': compileBASIC,
'silice': compileSilice,
'wiz': compileWiz,
'armips': assembleARMIPS,
'vasmarm': assembleVASMARM,
}
var TOOL_PRELOADFS = {

View File

@ -85,7 +85,7 @@ describe('Worker', function() {
compile('dasm', '\tprocessor 6502\n\torg $f000\n MAC mack\n lda #0\n ENDM\nfoo: mack\n mack\n', 'vcs.mame', done, 4, 4);
});
it('should NOT assemble DASM', function(done) {
compile('dasm', '\tprocessor 6502\n\torg $f000 ; this is a comment\nfoo asl a\n', 'vcs', done, 0, 0, 3);
compile('dasm', '\tprocessor 6502\n\torg $f000 ; this is a comment\nfoo asl a\n', 'vcs', done, 0, 0, 2);
});
/*
it('should assemble ACME', function(done) {
@ -142,7 +142,7 @@ describe('Worker', function() {
});
it('should compile galaxian skeleton', function(done) {
var csource = ab2str(fs.readFileSync('presets/galaxian-scramble/skeleton.sdcc'));
compile('sdcc', csource, 'galaxian-scramble', done, 20512, 29, 0);
compile('sdcc', csource, 'galaxian-scramble', done, 20512, 34, 0);
});
it('should compile vector skeleton', function(done) {
var csource = ab2str(fs.readFileSync('presets/vector-z80color/skeleton.sdcc'));
@ -150,7 +150,7 @@ describe('Worker', function() {
});
it('should compile williams skeleton', function(done) {
var csource = ab2str(fs.readFileSync('presets/williams-z80/skeleton.sdcc'));
compile('sdcc', csource, 'williams-z80', done, 38912, 38, 0);
compile('sdcc', csource, 'williams-z80', done, 38912, 40, 0);
});
it('should compile williams_sound skeleton', function(done) {
var csource = ab2str(fs.readFileSync('presets/sound_williams-z80/skeleton.sdcc'));

1
unicorn.js Submodule

@ -0,0 +1 @@
Subproject commit a872af72bc1c538a1f9ab73a030192f366a0912e