Compare commits

..

No commits in common. "master" and "old-emu" have entirely different histories.

53 changed files with 11679 additions and 2167 deletions

View File

@ -3,73 +3,104 @@
/********************************* Constants *********************************/ /***************************** Utility Functions *****************************/
/* Memory access address masks by data type */
static const uint32_t TYPE_MASKS[] = {
0x07FFFFFF, /* S8 */
0x07FFFFFF, /* U8 */
0x07FFFFFE, /* S16 */
0x07FFFFFE, /* U16 */
0x07FFFFFC /* S32 */
};
/*************************** Sub-Module Functions ****************************/
/* Read a typed value from a buffer in host memory */ /* Read a typed value from a buffer in host memory */
static int32_t busReadBuffer(uint8_t *data, int type) { static int32_t busReadBuffer(
uint8_t *buffer, uint32_t size, uint32_t offset, int type) {
/* Processing by data type */ /* There is no data */
if (buffer == NULL)
return 0;
/* Mirror the buffer across its address range */
offset &= size - 1;
/* Processing by type */
switch (type) { switch (type) {
/* Generic implementation */ /* Generic implementation */
#ifndef VB_LITTLE_ENDIAN #ifndef VB_LITTLE_ENDIAN
case VB_S8 : return ((int8_t *)data)[0]; case VB_S8 :
case VB_U8 : return data [0]; return (int8_t) buffer[offset];
case VB_S16: return (int32_t) ((int8_t *)data)[1] << 8 | data[0]; case VB_U8:
case VB_U16: return (int32_t) data [1] << 8 | data[0]; return buffer[offset];
case VB_S32: return case VB_S16:
(int32_t) data[3] << 24 | (int32_t) data[2] << 16 | return (int32_t) (int8_t)
(int32_t) data[1] << 8 | data[0]; buffer[offset + 1] << 8 | buffer[offset];
case VB_U16:
offset &= 0xFFFFFFFE;
return (int32_t) (int8_t)
buffer[offset + 1] << 8 | buffer[offset];
case VB_S32:
offset &= 0xFFFFFFFC;
return
(int32_t) buffer[offset + 3] << 24 |
(int32_t) buffer[offset + 2] << 16 |
(int32_t) buffer[offset + 1] << 8 |
buffer[offset ]
;
/* Little-endian host */ /* Little-endian implementation */
#else #else
case VB_S8 : return *(int8_t *) data; case VB_S8 : return *(int8_t *)&buffer[offset ];
case VB_U8 : return * data; case VB_U8 : return buffer[offset ];
case VB_S16: return *(int16_t *) data; case VB_S16: return *(int16_t *)&buffer[offset & 0xFFFFFFFE];
case VB_U16: return *(uint16_t *) data; case VB_U16: return *(uint16_t *)&buffer[offset & 0xFFFFFFFE];
case VB_S32: return *(int32_t *) data; case VB_S32: return *(int32_t *)&buffer[offset & 0xFFFFFFFC];
#endif #endif
} }
return 0; /* Unreachable */ /* Invalid type */
return 0;
} }
/* Write a typed value to a buffer in host memory */ /* Write a typed value to a buffer in host memory */
static void busWriteBuffer(uint8_t *data, int type, int32_t value) { static void busWriteBuffer(
uint8_t *buffer, uint32_t size, uint32_t offset, int type, int32_t value) {
/* Processing by data type */ /* There is no data */
if (buffer == NULL)
return;
/* Mirror the buffer across its address range */
offset &= size - 1;
/* Processing by type */
switch (type) { switch (type) {
/* Generic implementation */ /* Generic implementation */
#ifndef VB_LITTLE_ENDIAN #ifndef VB_LITTLE_ENDIAN
case VB_S32: data[3] = value >> 24; case VB_S32:
data[2] = value >> 16; /* Fallthrough */ offset &= 0xFFFFFFFC;
case VB_S16: /* Fallthrough */ buffer[offset ] = value;
case VB_U16: data[1] = value >> 8; /* Fallthrough */ buffer[offset + 1] = value >> 8;
case VB_S8 : /* Fallthrough */ buffer[offset + 2] = value >> 16;
case VB_U8 : data[0] = value; buffer[offset + 3] = value >> 24;
break;
case VB_S16:
case VB_U16:
offset &= 0xFFFFFFFE;
buffer[offset ] = value;
buffer[offset + 1] = value >> 8;
break;
case VB_S8 :
case VB_U8 :
buffer[offset ] = value;
/* Little-endian host */ /* Little-endian implementation */
#else #else
case VB_S8 : /* Fallthrough */ case VB_S8 :
case VB_U8 : * data = value; return; case VB_U8 :
case VB_S16: /* Fallthrough */ buffer[offset ] = value;
case VB_U16: *(uint16_t *) data = value; return; break;
case VB_S32: *(int32_t *) data = value; return; case VB_S16:
case VB_U16:
*(int16_t *)&buffer[offset & 0xFFFFFFFE] = value;
break;
case VB_S32:
*(int32_t *)&buffer[offset & 0xFFFFFFFC] = value;
#endif #endif
} }
@ -78,79 +109,54 @@ static void busWriteBuffer(uint8_t *data, int type, int32_t value) {
/***************************** Library Functions *****************************/ /***************************** Module Functions ******************************/
/* Read a typed value from the simulation bus */ /* Read a typed value from the simulation bus */
static void busRead(VB *sim, uint32_t address, int type, int32_t *value) { static int32_t busRead(VB *sim, uint32_t address, int type) {
/* Working variables */
address &= TYPE_MASKS[type];
*value = 0;
/* Process by address range */
switch (address >> 24) {
case 0: break; /* VIP */
case 1: break; /* VSU */
case 2: break; /* Misc. I/O */
case 3: break; /* Unmapped */
case 4: break; /* Game Pak expansion */
case 5: /* WRAM */
*value = busReadBuffer(&sim->wram[address & 0x0000FFFF], type);
break;
case 6: /* Game Pak RAM */
if (sim->cart.ram != NULL) {
*value = busReadBuffer(
&sim->cart.ram[address & sim->cart.ramMask], type);
}
break;
case 7: /* Game Pak ROM */
if (sim->cart.rom != NULL) {
*value = busReadBuffer(
&sim->cart.rom[address & sim->cart.romMask], type);
}
break;
/* Processing by address region */
switch (address >> 24 & 7) {
case 0: return 0; /* VIP */
case 1: return 0; /* VSU */
case 2: return 0; /* Misc. hardware */
case 3: return 0; /* Unmapped */
case 4: return 0; /* Game pak expansion */
case 5: return /* WRAM */
busReadBuffer(sim->wram , 0x10000 , address, type);
case 6: return /* Game pak RAM */
busReadBuffer(sim->cart.ram, sim->cart.ramSize, address, type);
case 7: return /* Game pak ROM */
busReadBuffer(sim->cart.rom, sim->cart.romSize, address, type);
} }
/* Unreachable */
return 0;
} }
/* Write a typed value to the simulation bus */ /* Write a typed value to the simulation bus */
static void busWrite(VB*sim,uint32_t address,int type,int32_t value,int debug){ static void busWrite(VB*sim,uint32_t address,int type,int32_t value,int debug){
(void) debug;
/* Working variables */ /* Processing by address region */
address &= TYPE_MASKS[type]; switch (address >> 24 & 7) {
/* Process by address range */
switch (address >> 24) {
case 0: break; /* VIP */ case 0: break; /* VIP */
case 1: break; /* VSU */ case 1: break; /* VSU */
case 2: break; /* Misc. I/O */ case 2: break; /* Misc. hardware */
case 3: break; /* Unmapped */ case 3: break; /* Unmapped */
case 4: break; /* Game Pak expansion */ case 4: break; /* Game pak expansion */
case 5: /* WRAM */ case 5: /* WRAM */
busWriteBuffer(&sim->wram[address & 0x0000FFFF], type, value); busWriteBuffer(sim->wram ,0x10000 ,address,type,value);
break; break;
case 6: /* Game pak RAM */
case 6: /* Game Pak RAM */ busWriteBuffer(sim->cart.ram,sim->cart.ramSize,address,type,value);
if (sim->cart.ram != NULL) {
busWriteBuffer(
&sim->cart.ram[address & sim->cart.ramMask], type, value);
}
break; break;
case 7: /* Game pak ROM */
case 7: /* Game Pak ROM */ busWriteBuffer(sim->cart.rom,sim->cart.romSize,address,type,value);
if (debug && sim->cart.rom != NULL) {
busWriteBuffer(
&sim->cart.rom[address & sim->cart.romMask], type, value);
}
break; break;
} }
} }
#endif /* VBAPI */ #endif /* VBAPI */

3206
core/cpu.c

File diff suppressed because it is too large Load Diff

477
core/vb.c
View File

@ -1,129 +1,41 @@
#ifndef VBAPI #ifndef VBAPI
#define VBAPI #define VBAPI
#endif #endif
#include <float.h>
#include <vb.h> #include <vb.h>
/*********************************** Types ***********************************/ /***************************** Utility Functions *****************************/
/* Simulation state */ /* Select the lesser of two unsigned numbers */
struct VB { static uint32_t Min(uint32_t a, uint32_t b) {
return a < b ? a : b;
/* Game Pak */
struct {
uint8_t *ram; /* Save RAM */
uint8_t *rom; /* Program ROM */
uint32_t ramMask; /* Size of SRAM - 1 */
uint32_t romMask; /* Size of ROM - 1 */
} cart;
/* CPU */
struct {
/* Cache Control Word */
struct {
uint8_t ice; /* Instruction Cache Enable */
} chcw;
/* Exception Cause Register */
struct {
uint16_t eicc; /* Exception/Interrupt Cause Code */
uint16_t fecc; /* Fatal Error Cause Code */
} ecr;
/* Program Status Word */
struct {
uint8_t ae; /* Address Trap Enable */
uint8_t cy; /* Carry */
uint8_t ep; /* Exception Pending */
uint8_t fiv; /* Floating Invalid */
uint8_t fov; /* Floating Overflow */
uint8_t fpr; /* Floading Precision */
uint8_t fro; /* Floating Reserved Operand */
uint8_t fud; /* Floading Underflow */
uint8_t fzd; /* Floating Zero Divide */
uint8_t i; /* Interrupt Level */
uint8_t id; /* Interrupt Disable */
uint8_t np; /* NMI Pending */
uint8_t ov; /* Overflow */
uint8_t s; /* Sign */
uint8_t z; /* Zero */
} psw;
/* Other registers */
uint32_t adtre; /* Address Trap Register for Execution */
uint32_t eipc; /* Exception/Interrupt PC */
uint32_t eipsw; /* Exception/Interrupt PSW */
uint32_t fepc; /* Fatal Error PC */
uint32_t fepsw; /* Fatal Error PSW */
uint32_t pc; /* Program Counter */
int32_t program[32]; /* Program registers */
uint32_t sr29; /* System register 29 */
uint32_t sr31; /* System register 31 */
/* Working data */
union {
struct {
uint32_t dest;
uint64_t src;
} bs; /* Arithmetic bit strings */
struct {
uint32_t address;
int32_t value;
} data; /* Data accesses */
} aux;
/* Other state */
uint32_t clocks; /* Master clocks to wait */
uint16_t code[2]; /* Instruction code units */
uint16_t exception; /* Exception cause code */
int halt; /* CPU is halting */
uint16_t irq; /* Interrupt request lines */
int length; /* Instruction code length */
uint32_t nextPC; /* Address of next instruction */
int operation; /* Current operation ID */
int step; /* Operation sub-task ID */
} cpu;
/* Other system state */
uint8_t wram[0x10000]; /* System RAM */
/* Application data */
vbOnException onException; /* CPU exception */
vbOnExecute onExecute; /* CPU instruction execute */
vbOnFetch onFetch; /* CPU instruction fetch */
vbOnRead onRead; /* CPU instruction read */
vbOnWrite onWrite; /* CPU instruction write */
void *tag; /* User data */
};
/***************************** Library Functions *****************************/
/* Sign-extend an integer of variable width */
static int32_t SignExtend(int32_t value, int32_t bits) {
#ifndef VB_SIGNED_PROPAGATE
value &= ~((uint32_t) 0xFFFFFFFF << bits);
bits = (int32_t) 1 << (bits - (int32_t) 1);
return (value ^ bits) - bits;
#else
return value << (32 - bits) >> (32 - bits);
#endif
} }
/* Sign-extend an integer, masking upper bits if positive */
#ifndef VB_SIGNED_PROPAGATE
/* Generic implementation */
static int32_t SignExtend(int32_t value, int bits) {
return value & 1 << (bits - 1) ?
value | ~0 << bits :
value & ((1 << bits) - 1)
;
}
#else
/* Sign-propagating implementation */
#define SignExtend(v, b) ((int32_t) (v) << (32 - (b)) >> (32 - (b)))
#endif
/******************************** Sub-Modules ********************************/
/**************************** Sub-Module Imports *****************************/
#include "bus.c" #include "bus.c"
#include "cpu.c" #include "cpu.c"
/***************************** Library Functions *****************************/ /***************************** Module Functions ******************************/
/* Process a simulation for a given number of clocks */ /* Process a simulation for a given number of clocks */
static int sysEmulate(VB *sim, uint32_t clocks) { static int sysEmulate(VB *sim, uint32_t clocks) {
@ -132,7 +44,7 @@ static int sysEmulate(VB *sim, uint32_t clocks) {
; ;
} }
/* Determine how many clocks are guaranteed to process */ /* Determine how many clocks can be simulated without a breakpoint */
static uint32_t sysUntil(VB *sim, uint32_t clocks) { static uint32_t sysUntil(VB *sim, uint32_t clocks) {
clocks = cpuUntil(sim, clocks); clocks = cpuUntil(sim, clocks);
return clocks; return clocks;
@ -140,140 +52,127 @@ static uint32_t sysUntil(VB *sim, uint32_t clocks) {
/******************************* API Commands ********************************/ /************************************ API ************************************/
/* Process one simulation */ /* Process a simulation */
VBAPI int vbEmulate(VB *sim, uint32_t *clocks) { int vbEmulate(VB *sim, uint32_t *clocks) {
int brk; /* A callback requested a break */ int brk; /* A break was requested */
uint32_t until; /* Clocks guaranteed to process */ uint32_t until; /* Number of clocks during which no break will occur */
while (*clocks != 0) {
until = sysUntil(sim, *clocks); /* Process all clocks */
brk = sysEmulate(sim, until); for (brk = 0; *clocks != 0 && !brk; *clocks -= until) {
*clocks -= until; until = sysUntil (sim, *clocks);
if (brk) brk = sysEmulate(sim, until );
return brk; /* TODO: return 1 */
} }
return 0;
return brk;
} }
/* Process multiple simulations */ /* Process multiple simulations */
VBAPI int vbEmulateEx(VB **sims, int count, uint32_t *clocks) { int vbEmulateEx(VB **sims, int count, uint32_t *clocks) {
int brk; /* A callback requested a break */ int brk; /* A break was requested */
uint32_t until; /* Clocks guaranteed to process */ uint32_t until; /* Number of clocks during which no break will occur */
int x; /* Iterator */ int x; /* Iterator */
while (*clocks != 0) {
/* Process all clocks */
for (brk = 0; *clocks != 0 && !brk; *clocks -= until) {
until = *clocks; until = *clocks;
for (x = count - 1; x >= 0; x--) for (x = 0; x < count; x++)
until = sysUntil(sims[x], until); until = sysUntil (sims[x], until);
for (x = 0; x < count; x++)
brk = 0;
for (x = count - 1; x >= 0; x--)
brk |= sysEmulate(sims[x], until); brk |= sysEmulate(sims[x], until);
*clocks -= until;
if (brk)
return brk; /* TODO: return 1 */
} }
return 0;
return brk;
} }
/* Retrieve the game pack RAM buffer */ /* Retrieve a current breakpoint handler */
VBAPI void* vbGetCartRAM(VB *sim, uint32_t *size) { void* vbGetCallback(VB *sim, int id) {
if (size != NULL) switch (id) {
*size = sim->cart.ram == NULL ? 0 : sim->cart.ramMask + 1; case VB_ONEXCEPTION: return *(void **)&sim->onException;
return sim->cart.ram; case VB_ONEXECUTE : return *(void **)&sim->onExecute;
case VB_ONFETCH : return *(void **)&sim->onFetch;
case VB_ONREAD : return *(void **)&sim->onRead;
case VB_ONWRITE : return *(void **)&sim->onWrite;
}
return NULL;
} }
/* Retrieve the game pack ROM buffer */ /* Retrieve the value of a register */
VBAPI void* vbGetCartROM(VB *sim, uint32_t *size) { int32_t vbGetRegister(VB *sim, int type, int id) {
switch (type) {
case VB_PROGRAM:
return id < 0 || id > 31 ? 0 : sim->cpu.program[id];
case VB_SYSTEM:
return cpuGetSystemRegister(sim, id);
case VB_OTHER:
switch (id) {
case VB_PC: return sim->cpu.pc;
}
}
return 0; /* Invalid type */
}
/* Retrieve a handle to the current cartridge ROM data */
uint8_t* vbGetROM(VB *sim, uint32_t *size) {
if (size != NULL) if (size != NULL)
*size = sim->cart.rom == NULL ? 0 : sim->cart.romMask + 1; *size = sim->cart.romSize;
return sim->cart.rom; return sim->cart.rom;
} }
/* Retrieve the exception callback handle */ /* Retrieve a handle to the current cartridge RAM data */
VBAPI vbOnException vbGetExceptionCallback(VB *sim) { uint8_t* vbGetSRAM(VB *sim, uint32_t *size) {
return sim->onException; if (size != NULL)
*size = sim->cart.ramSize;
return sim->cart.ram;
} }
/* Retrieve the execute callback handle */ /* Prepare a simulation instance for use */
VBAPI vbOnExecute vbGetExecuteCallback(VB *sim) { void vbInit(VB *sim) {
return sim->onExecute;
}
/* Retrieve the fetch callback handle */ /* Breakpoint handlers */
VBAPI vbOnFetch vbGetFetchCallback(VB *sim) { sim->onException = NULL;
return sim->onFetch;
}
/* Retrieve the value of the program counter */
VBAPI uint32_t vbGetProgramCounter(VB *sim) {
return sim->cpu.pc;
}
/* Retrieve the value in a program register */
VBAPI int32_t vbGetProgramRegister(VB *sim, int index) {
return index < 1 || index > 31 ? 0 : sim->cpu.program[index];
}
/* Retrieve the read callback handle */
VBAPI vbOnRead vbGetReadCallback(VB *sim) {
return sim->onRead;
}
/* Retrieve the value in a system register */
VBAPI uint32_t vbGetSystemRegister(VB *sim, int index) {
return index < 0 || index > 31 ? 0 : cpuGetSystemRegister(sim, index);
}
/* Retrieve a simulation's userdata pointer */
VBAPI void* vbGetUserData(VB *sim) {
return sim->tag;
}
/* Retrieve the write callback handle */
VBAPI vbOnWrite vbGetWriteCallback(VB *sim) {
return sim->onWrite;
}
/* Initialize a simulation instance */
VBAPI VB* vbInit(VB *sim) {
sim->cart.ram = NULL;
sim->cart.rom = NULL;
sim->onExecute = NULL; sim->onExecute = NULL;
sim->onFetch = NULL; sim->onFetch = NULL;
sim->onRead = NULL; sim->onRead = NULL;
sim->onWrite = NULL; sim->onWrite = NULL;
/* Game pak */
sim->cart.ram = NULL;
sim->cart.ramSize = 0;
sim->cart.rom = NULL;
sim->cart.romSize = 0;
/* Hardware reset */
vbReset(sim); vbReset(sim);
return sim;
} }
/* Read a value from the memory bus */ /* Read a value from memory */
VBAPI int32_t vbRead(VB *sim, uint32_t address, int type) { int32_t vbRead(VB *sim, uint32_t address, int type) {
int32_t value; return busRead(sim, address, type);
if (type < 0 || type > 4) }
return 0;
busRead(sim, address, type, &value); /* Read multiple bytes from memory */
return value; void vbReadEx(VB *sim, uint32_t address, uint8_t *buffer, uint32_t length) {
while (length--) *buffer++ = busRead(sim, address++, VB_U8);
} }
/* Simulate a hardware reset */ /* Simulate a hardware reset */
VBAPI VB* vbReset(VB *sim) { void vbReset(VB *sim) {
uint32_t x; /* Iterator */ int x; /* Iterator */
/* WRAM (the hardware does not do this) */ /* Reset WRAM (the hardware does not do this) */
for (x = 0; x < 0x10000; x++) for (x = 0; x < 0x10000; x++)
sim->wram[x] = 0x00; sim->wram[x] = 0x00;
/* CPU (normal) */ /* CPU (normal) */
sim->cpu.exception = 0;
sim->cpu.halt = 0;
sim->cpu.irq = 0;
sim->cpu.pc = 0xFFFFFFF0; sim->cpu.pc = 0xFFFFFFF0;
cpuSetSystemRegister(sim, VB_ECR, 0x0000FFF0, 1); cpuSetSystemRegister(sim, VB_ECR, 0x0000FFF0, 1);
cpuSetSystemRegister(sim, VB_PSW, 0x00008000, 1); cpuSetSystemRegister(sim, VB_PSW, 0x00008000, 1);
for (x = 0; x < 5; x++)
sim->cpu.irq[x] = 0;
/* CPU (extra, hardware does not do this) */ /* CPU (extra, hardware doesn't do this) */
sim->cpu.adtre = 0x00000000; sim->cpu.adtre = 0x00000000;
sim->cpu.eipc = 0x00000000; sim->cpu.eipc = 0x00000000;
sim->cpu.eipsw = 0x00000000; sim->cpu.eipsw = 0x00000000;
@ -285,107 +184,99 @@ VBAPI VB* vbReset(VB *sim) {
for (x = 0; x < 32; x++) for (x = 0; x < 32; x++)
sim->cpu.program[x] = 0x00000000; sim->cpu.program[x] = 0x00000000;
/* CPU (other) */ /* CPU (internal) */
sim->cpu.bitstring = 0;
sim->cpu.clocks = 0; sim->cpu.clocks = 0;
sim->cpu.nextPC = 0xFFFFFFF0; sim->cpu.exception = 0;
sim->cpu.operation = CPU_FETCH; sim->cpu.stage = CPU_FETCH;
sim->cpu.step = 0; sim->cpu.step = 0;
return sim;
} }
/* Specify a game pak RAM buffer */ /* Specify a breakpoint handler */
VBAPI int vbSetCartRAM(VB *sim, void *sram, uint32_t size) { void* vbSetCallback(VB *sim, int id, void *proc) {
if (sram != NULL) { void *prev = vbGetCallback(sim, id);
if (size < 16 || size > 0x1000000 || (size & (size - 1)) != 0) switch (id) {
return 1; case VB_ONEXCEPTION: *(void **)&sim->onException = proc; break;
sim->cart.ramMask = size - 1; case VB_ONEXECUTE : *(void **)&sim->onExecute = proc; break;
case VB_ONFETCH : *(void **)&sim->onFetch = proc; break;
case VB_ONREAD : *(void **)&sim->onRead = proc; break;
case VB_ONWRITE : *(void **)&sim->onWrite = proc; break;
} }
sim->cart.ram = sram; return prev;
return 0;
} }
/* Specify a game pak ROM buffer */ /* Specify a value for a register */
VBAPI int vbSetCartROM(VB *sim, void *rom, uint32_t size) { int32_t vbSetRegister(VB *sim, int type, int id, int32_t value) {
if (rom != NULL) { switch (type) {
if (size < 16 || size > 0x1000000 || (size & (size - 1)) != 0) case VB_PROGRAM:
return 1; return id < 1 || id > 31 ? 0 : (sim->cpu.program[id] = value);
sim->cart.romMask = size - 1; case VB_SYSTEM:
return cpuSetSystemRegister(sim, id, value, 1);
case VB_OTHER:
switch (id) {
case VB_PC:
sim->cpu.bitstring = 0;
sim->cpu.clocks = 0;
sim->cpu.exception = 0;
sim->cpu.stage = CPU_FETCH;
return sim->cpu.pc = value & 0xFFFFFFFE;
} }
sim->cart.rom = rom; }
return 0; /* Invalid type or ID */
}
/* Specify a cartridge ROM buffer */
int vbSetROM(VB *sim, uint8_t *data, uint32_t size) {
/* Specifying no ROM */
if (data == NULL) {
sim->cart.rom = NULL;
sim->cart.romSize = 0;
return 0;
}
/* Error checking */
if (
size < 4 ||
size > 0x1000000 ||
(size & (size - 1)) /* Power of 2 */
) return 1;
/* Register the ROM data */
sim->cart.rom = data;
sim->cart.romSize = size;
return 0; return 0;
} }
/* Specify a new exception callback handle */ /* Specify a cartridge RAM buffer */
VBAPI vbOnException vbSetExceptionCallback(VB *sim, vbOnException callback) { int vbSetSRAM(VB *sim, uint8_t *data, uint32_t size) {
vbOnException prev = sim->onException;
sim->onException = callback;
return prev;
}
/* Specify a new execute callback handle */ /* Specifying no SRAM */
VBAPI vbOnExecute vbSetExecuteCallback(VB *sim, vbOnExecute callback) { if (data == NULL) {
vbOnExecute prev = sim->onExecute; sim->cart.ram = NULL;
sim->onExecute = callback; sim->cart.ramSize = 0;
return prev;
}
/* Specify a new fetch callback handle */
VBAPI vbOnFetch vbSetFetchCallback(VB *sim, vbOnFetch callback) {
vbOnFetch prev = sim->onFetch;
sim->onFetch = callback;
return prev;
}
/* Specify a new value for the program counter */
VBAPI uint32_t vbSetProgramCounter(VB *sim, uint32_t value) {
sim->cpu.operation = CPU_FETCH;
sim->cpu.pc = sim->cpu.nextPC = value & 0xFFFFFFFE;
sim->cpu.step = 0;
return sim->cpu.pc;
}
/* Specify a new value for a program register */
VBAPI int32_t vbSetProgramRegister(VB *sim, int index, int32_t value) {
return index < 1 || index > 31 ? 0 : (sim->cpu.program[index] = value);
}
/* Specify a new read callback handle */
VBAPI vbOnRead vbSetReadCallback(VB *sim, vbOnRead callback) {
vbOnRead prev = sim->onRead;
sim->onRead = callback;
return prev;
}
/* Specify a new value for a system register */
VBAPI uint32_t vbSetSystemRegister(VB *sim, int index, uint32_t value) {
return index < 0 || index > 31 ? 0 :
cpuSetSystemRegister(sim, index, value, 1);
}
/* Specify a new write callback handle */
VBAPI vbOnWrite vbSetWriteCallback(VB *sim, vbOnWrite callback) {
vbOnWrite prev = sim->onWrite;
sim->onWrite = callback;
return prev;
}
/* Determine the size of a simulation instance */
VBAPI size_t vbSizeOf() {
return sizeof (VB);
}
/* Specify a simulation's userdata pointer */
VBAPI void* vbSetUserData(VB *sim, void *tag) {
void *prev = sim->tag;
sim->tag = tag;
return prev;
}
/* Write a value to the memory bus */
VBAPI int32_t vbWrite(VB *sim, uint32_t address, int type, int32_t value) {
if (type < 0 || type > 4)
return 0; return 0;
}
/* Error checking */
if (
size < 4 ||
size > 0x1000000 ||
(size & (size - 1)) /* Power of 2 */
) return 1;
/* Register the SRAM data */
sim->cart.ram = data;
sim->cart.ramSize = size;
return 0;
}
/* Write a value to memory */
void vbWrite(VB *sim, uint32_t address, int type, int32_t value) {
busWrite(sim, address, type, value, 1); busWrite(sim, address, type, value, 1);
return vbRead(sim, address, type); }
/* Write multiple values to memory */
void vbWriteEx(VB *sim, uint32_t address, uint8_t *buffer, uint32_t length) {
while (length--) busWrite(sim, address++, VB_U8, *buffer++, 1);
} }

216
core/vb.h
View File

@ -15,15 +15,38 @@ extern "C" {
/**************************** Compilation Control ****************************/
/*
VB_LITTLE_ENDIAN
Accelerates simulated memory reads and writes on hosts with little-endian
byte ordering. If left undefined, a generic implementation is used instead.
*/
/*
VB_SIGNED_PROPAGATE
Accelerates sign extension by assuming the >> operator propagates the sign
bit for signed types and does not for unsigned types. If left undefined, a
generic implementation is used instead.
*/
/********************************* Constants *********************************/ /********************************* Constants *********************************/
/* Callback IDs */ /* Data types */
#define VB_EXCEPTION 0 #define VB_S8 0
#define VB_EXECUTE 1 #define VB_U8 1
#define VB_FETCH 2 #define VB_S16 2
#define VB_FRAME 3 #define VB_U16 3
#define VB_READ 4 #define VB_S32 4
#define VB_WRITE 5
/* Register types */
#define VB_PROGRAM 0
#define VB_SYSTEM 1
#define VB_OTHER 2
/* System registers */ /* System registers */
#define VB_ADTRE 25 #define VB_ADTRE 25
@ -37,62 +60,157 @@ extern "C" {
#define VB_PSW 5 #define VB_PSW 5
#define VB_TKCW 7 #define VB_TKCW 7
/* Memory access data types */ /* Other registers */
#define VB_S8 0 #define VB_PC 0
#define VB_U8 1
#define VB_S16 2 /* Callbacks */
#define VB_U16 3 #define VB_ONEXCEPTION 0
#define VB_S32 4 #define VB_ONEXECUTE 1
#define VB_ONFETCH 2
#define VB_ONREAD 3
#define VB_ONWRITE 4
/*********************************** Types ***********************************/ /*********************************** Types ***********************************/
/* Simulation state */ /* Forward references */
typedef struct VB VB; typedef struct VB VB;
/* Callbacks */ /* Memory access descriptor */
typedef int (*vbOnException)(VB *sim, uint16_t *cause); typedef struct {
typedef int (*vbOnExecute )(VB *sim, uint32_t address, const uint16_t *code, int length); uint32_t address; /* Memory address */
typedef int (*vbOnFetch )(VB *sim, int fetch, uint32_t address, int32_t *value, uint32_t *cycles); uint32_t clocks; /* Number of clocks taken */
typedef int (*vbOnRead )(VB *sim, uint32_t address, int type, int32_t *value, uint32_t *cycles); int32_t value; /* Data loaded/to store */
typedef int (*vbOnWrite )(VB *sim, uint32_t address, int type, int32_t *value, uint32_t *cycles, int *cancel); uint8_t type; /* Data type */
} VBAccess;
/* Exception descriptor */
typedef struct {
uint32_t address; /* Memory address of handler routine */
uint16_t code; /* Cause code */
uint8_t cancel; /* Set to cancel the exception */
} VBException;
/* Instruction descriptor */
typedef struct {
uint32_t address; /* Memory address */
uint16_t code[2]; /* Fetched code units */
uint8_t size; /* Size in halfwords */
} VBInstruction;
/* Breakpoint handlers */
typedef int (*VBExceptionProc)(VB *, VBException *);
typedef int (*VBExecuteProc )(VB *, VBInstruction *);
typedef int (*VBFetchProc )(VB *, int, VBAccess *);
typedef int (*VBMemoryProc )(VB *, VBAccess *);
/* Simulation state */
struct VB {
/* Game pak */
struct {
uint8_t *ram; /* (S)RAM data */
uint8_t *rom; /* ROM data */
uint32_t ramSize; /* Size of RAM in bytes */
uint32_t romSize; /* Size of ROM in bytes */
} cart;
/* CPU */
struct {
/* Main registers */
int32_t program[32]; /* Program registers */
uint32_t pc; /* Program counter */
/* System registers */
uint32_t adtre; /* Address trap target address */
uint32_t eipc; /* Exception return PC */
uint32_t eipsw; /* Exception return PSW */
uint32_t fepc; /* Duplexed exception return PC */
uint32_t fepsw; /* Duplexed exception return PSW */
uint32_t sr29; /* System register 29 */
uint32_t sr31; /* System register 31 */
/* Exception cause register */
struct {
uint16_t eicc; /* Exception/interrupt cause code */
uint16_t fecc; /* Duplexed exception cause code */
} ecr;
/* Cache control word */
struct {
uint8_t ice; /* Instruction cache enable */
} chcw;
/* Program status word */
struct {
uint8_t ae; /* Address trap enable */
uint8_t cy; /* Carry */
uint8_t ep; /* Exception pending */
uint8_t fiv; /* Floating-point invalid operation */
uint8_t fov; /* Floating-point overflow */
uint8_t fpr; /* Floating point precision degradation */
uint8_t fro; /* Floating-point reserved operand */
uint8_t fud; /* Floating-point underflow */
uint8_t fzd; /* Floating-point zero division */
uint8_t i; /* Interrupt level */
uint8_t id; /* Interrupt disable */
uint8_t np; /* Duplexed exception pending */
uint8_t ov; /* Overflow */
uint8_t s; /* Sign */
uint8_t z; /* Zero */
} psw;
/* Instruction */
struct {
int32_t aux[9]; /* Auxiliary storage */
void *def; /* Operation descriptor */
uint16_t code[2]; /* Fetched code units */
uint8_t size; /* Size in bytes */
} inst;
/* Other state */
uint32_t clocks; /* Clocks until next activity */
uint32_t fpFlags; /* Floating-point exception flags */
uint16_t exception; /* Current exception cause code */
uint8_t irq[5]; /* Interrupt requests */
uint8_t bitstring; /* Processing a bit string instruction */
uint8_t stage; /* Pipeline stage handler */
uint8_t step; /* General processing step index */
} cpu;
/* Breakpoint hooks */
VBExceptionProc onException; /* Exception raised */
VBExecuteProc onExecute; /* Instruction execute */
VBFetchProc onFetch; /* Bus read (fetch) */
VBMemoryProc onRead; /* Bus read (execute) */
VBMemoryProc onWrite; /* Bus write */
/* Other state */
uint8_t wram[0x10000]; /* System memory */
};
/************************************ API ************************************/
/******************************* API Commands ********************************/
VBAPI int vbEmulate (VB *sim, uint32_t *clocks); VBAPI int vbEmulate (VB *sim, uint32_t *clocks);
VBAPI int vbEmulateEx (VB **sims, int count, uint32_t *clocks); VBAPI int vbEmulateEx (VB **sims, int count, uint32_t *clocks);
VBAPI void* vbGetCallback (VB *sim, int id); VBAPI void* vbGetCallback(VB *sim, int id);
VBAPI void* vbGetCartRAM (VB *sim, uint32_t *size); VBAPI int32_t vbGetRegister(VB *sim, int type, int id);
VBAPI void* vbGetCartROM (VB *sim, uint32_t *size); VBAPI uint8_t* vbGetROM (VB *sim, uint32_t *size);
VBAPI vbOnException vbGetExceptionCallback(VB *sim); VBAPI uint8_t* vbGetSRAM (VB *sim, uint32_t *size);
VBAPI vbOnExecute vbGetExecuteCallback (VB *sim); VBAPI void vbInit (VB *sim);
VBAPI vbOnFetch vbGetFetchCallback (VB *sim);
VBAPI uint32_t vbGetProgramCounter (VB *sim);
VBAPI int32_t vbGetProgramRegister (VB *sim, int index);
VBAPI vbOnRead vbGetReadCallback (VB *sim);
VBAPI uint32_t vbGetSystemRegister (VB *sim, int index);
VBAPI void* vbGetUserData (VB *sim);
VBAPI vbOnWrite vbGetWriteCallback (VB *sim);
VBAPI VB* vbInit (VB *sim);
VBAPI int32_t vbRead (VB *sim, uint32_t address, int type); VBAPI int32_t vbRead (VB *sim, uint32_t address, int type);
VBAPI VB* vbReset (VB *sim); VBAPI void vbReadEx (VB *sim, uint32_t address, uint8_t *buffer, uint32_t length);
VBAPI int vbSetCartRAM (VB *sim, void *sram, uint32_t size); VBAPI void vbReset (VB *sim);
VBAPI int vbSetCartROM (VB *sim, void *rom, uint32_t size); VBAPI void* vbSetCallback(VB *sim, int id, void *proc);
VBAPI vbOnException vbSetExceptionCallback(VB *sim, vbOnException callback); VBAPI int32_t vbSetRegister(VB *sim, int type, int id, int32_t value);
VBAPI vbOnExecute vbSetExecuteCallback (VB *sim, vbOnExecute callback); VBAPI int vbSetROM (VB *sim, uint8_t *data, uint32_t size);
VBAPI vbOnFetch vbSetFetchCallback (VB *sim, vbOnFetch callback); VBAPI int vbSetSRAM (VB *sim, uint8_t *data, uint32_t size);
VBAPI uint32_t vbSetProgramCounter (VB *sim, uint32_t value); VBAPI void vbWrite (VB *sim, uint32_t address, int type, int32_t value);
VBAPI int32_t vbSetProgramRegister (VB *sim, int index, int32_t value); VBAPI void vbWriteEx (VB *sim, uint32_t address, uint8_t *buffer, uint32_t length);
VBAPI vbOnRead vbSetReadCallback (VB *sim, vbOnRead callback);
VBAPI uint32_t vbSetSystemRegister (VB *sim, int index, uint32_t value);
VBAPI void* vbSetUserData (VB *sim, void *tag);
VBAPI vbOnWrite vbSetWriteCallback (VB *sim, vbOnWrite callback);
VBAPI size_t vbSizeOf ();
VBAPI int32_t vbWrite (VB *sim, uint32_t address, int type, int32_t value);

View File

@ -1,4 +1,4 @@
Copyright (C) 2024 Guy Perfect Copyright (C) 2023 Guy Perfect
This software is provided 'as-is', without any express or implied This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages warranty. In no event will the authors be held liable for any damages

View File

@ -1,7 +1,7 @@
.PHONY: help .PHONY: help
help: help:
@echo @echo
@echo "Virtual Boy Emulator - October 10, 2024" @echo "Virtual Boy Emulator - March 12, 2023"
@echo @echo
@echo "Target build environment is any Debian with the following packages:" @echo "Target build environment is any Debian with the following packages:"
@echo " emscripten" @echo " emscripten"
@ -41,14 +41,14 @@ core:
# GCC compilation control # GCC compilation control
@gcc core/vb.c -I core -c -o /dev/null \ @gcc core/vb.c -I core -c -o /dev/null \
-Werror -std=c90 -Wall -Wextra -Wpedantic \ -Werror -std=c90 -Wall -Wextra -Wpedantic \
-D VB_LITTLE_ENDIAN -D VB_SIGNED_PROPAGATE -D VB_DIV_GENERIC -D VB_LITTLE_ENDIAN -D VB_SIGNED_PROPAGATE
# Clang generic # Clang generic
@emcc core/vb.c -I core -c -o /dev/null \ @emcc core/vb.c -I core -c -o /dev/null \
-Werror -std=c90 -Wall -Wextra -Wpedantic -Werror -std=c90 -Wall -Wextra -Wpedantic
# Clang compilation control # Clang compilation control
@emcc core/vb.c -I core -c -o /dev/null \ @emcc core/vb.c -I core -c -o /dev/null \
-Werror -std=c90 -Wall -Wextra -Wpedantic \ -Werror -std=c90 -Wall -Wextra -Wpedantic \
-D VB_LITTLE_ENDIAN -D VB_SIGNED_PROPAGATE -D VB_DIV_GENERIC -D VB_LITTLE_ENDIAN -D VB_SIGNED_PROPAGATE
.PHONY: wasm .PHONY: wasm
wasm: wasm:

691
web/App.js Normal file
View File

@ -0,0 +1,691 @@
import { Core } from /**/"./core/Core.js";
import { Debugger } from /**/"./debugger/Debugger.js";
import { Disassembler } from /**/"./core/Disassembler.js";
import { Toolkit } from /**/"./toolkit/Toolkit.js";
// Front-end emulator application
class App extends Toolkit.App {
///////////////////////// Initialization Methods //////////////////////////
constructor(bundle) {
super({
style: {
display : "grid",
gridTemplateRows: "max-content auto"
},
visibility: true,
visible : false
});
// Configure instance fields
this.bundle = bundle;
this.debugMode = true;
this.dualMode = false;
this.text = null;
}
async init() {
// Theme
Object.assign(document.body.style, { margin:"0", overflow:"hidden" });
this.stylesheet(/**/"web/theme/kiosk.css", false);
this.stylesheet(/**/"web/theme/vbemu.css", false);
this._theme = "auto";
this.themes = {
dark : this.stylesheet(/**/"web/theme/dark.css" ),
light : this.stylesheet(/**/"web/theme/light.css" ),
virtual: this.stylesheet(/**/"web/theme/virtual.css")
};
// Watch for dark mode preference changes
this.isDark = window.matchMedia("(prefers-color-scheme: dark)");
this.isDark.addEventListener("change", e=>this.onDark());
this.onDark();
// Locales
await this.addLocale(/**/"web/locale/en-US.json");
for (let id of [].concat(navigator.languages, ["en-US"])) {
if (this.setLocale(id))
break;
}
this.setTitle("{app.title}", true);
// Element
document.body.append(this.element);
window.addEventListener("resize", e=>{
this.element.style.height = window.innerHeight + "px";
this.element.style.width = window.innerWidth + "px";
});
window.dispatchEvent(new Event("resize"));
this.addEventListener("keydown", e=>this.onKeyDown(e));
// Menus
this.menuBar = new Toolkit.MenuBar(this);
this.menuBar.setLabel("{menu._}", true);
this.add(this.menuBar);
this.initFileMenu ();
this.initEmulationMenu();
this.initDebugMenu (0, this.debugMode);
this.initDebugMenu (1, this.debugMode && this.dualMode);
this.initThemeMenu ();
// Fallback for bubbled key events
document.body.addEventListener("focusout", e=>this.onBlur(e));
window .addEventListener("keydown" , e=>this.onKey (e));
window .addEventListener("keyup" , e=>this.onKey (e));
// Temporary: Faux game mode display
this.display = new Toolkit.Component(this, {
class : "tk display",
style : { position: "relative" },
visible: !this.debugMode
});
this.image1 = new Toolkit.Component(this, { style: {
background: "#000000",
position : "absolute"
}});
this.display.add(this.image1);
this.image2 = new Toolkit.Component(this, { style: {
background: "#000000",
position : "absolute"
}});
this.display.add(this.image2);
this.display.addEventListener("resize", e=>this.onDisplay());
this.add(this.display);
// Temporary: Faux debug mode display
this.desktop = new Toolkit.Desktop(this, {
visible: this.debugMode
});
this.add(this.desktop);
// Emulation core
this.core = await new Core().init();
let sims = (await this.core.create(2)).sims;
this.core.onsubscription = (k,m)=>this.onSubscription(k, m);
// Debugging managers
this.dasm = new Disassembler();
this.debug = new Array(sims.length);
for (let x = 0; x < sims.length; x++) {
let dbg = this.debug[x] = new Debugger(this, sims[x], x);
if (x == 0 && !this.dualMode) {
dbg.cpu .substitute("#", "");
dbg.memory.substitute("#", "");
}
this.desktop.add(dbg.cpu);
this.desktop.add(dbg.memory);
}
// Reveal the application
this.visible = true;
this.restoreFocus();
console.log(
"CPU window shortcuts:\n" +
" F11 Single step\n" +
" F10 Run to next\n" +
" Ctrl+B Toggle bytes column\n" +
" Ctrl+F Fit columns\n" +
" Ctrl+G Goto"
);
}
// Initialize File menu
initFileMenu() {
let bar = this.menuBar;
let item = bar.file = new Toolkit.MenuItem(this);
item.setText("{menu.file._}");
bar.add(item);
let menu = item.menu = new Toolkit.Menu(this);
item = bar.file.loadROM0 = new Toolkit.MenuItem(this);
item.setText("{menu.file.loadROM}");
item.substitute("#", this.dualMode ? " 1" : "", false);
item.addEventListener("action", e=>this.onLoadROM(0));
menu.add(item);
item = bar.file.loadROM1 = new Toolkit.MenuItem(this,
{ visible: this.dualMode });
item.setText("{menu.file.loadROM}");
item.substitute("#", " 2", false);
item.addEventListener("action", e=>this.onLoadROM(1));
menu.add(item);
item = bar.file.dualMode = new Toolkit.MenuItem(this,
{ checked: this.dualMode, type: "checkbox" });
item.setText("{menu.file.dualMode}");
item.addEventListener("action", e=>this.onDualMode());
menu.add(item);
item = bar.file.debugMode = new Toolkit.MenuItem(this,
{ checked: this.debugMode, disabled: true, type: "checkbox" });
item.setText("{menu.file.debugMode}");
item.addEventListener("action", e=>this.onDebugMode());
menu.add(item);
menu.addSeparator();
item = new Toolkit.MenuItem(this);
item.setText("Export source...", false);
item.addEventListener("action", ()=>this.bundle.save());
menu.add(item);
}
// Initialize Emulation menu
initEmulationMenu() {
let bar = this.menuBar;
let item = bar.emulation = new Toolkit.MenuItem(this);
item.setText("{menu.emulation._}");
bar.add(item);
let menu = item.menu = new Toolkit.Menu(this);
item = bar.emulation.run =
new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.emulation.run}");
menu.add(item);
item = bar.emulation.reset0 = new Toolkit.MenuItem(this);
item.setText("{menu.emulation.reset}");
item.substitute("#", this.dualMode ? " 1" : "", false);
item.addEventListener("action", e=>this.onReset(0));
menu.add(item);
item = bar.emulation.reset1 = new Toolkit.MenuItem(this,
{ visible: this.dualMode });
item.setText("{menu.emulation.reset}");
item.substitute("#", " 2", false);
item.addEventListener("action", e=>this.onReset(1));
menu.add(item);
item = bar.emulation.linkSims = new Toolkit.MenuItem(this,
{ disabled: true, type: "checkbox", visible: this.dualMode });
item.setText("{menu.emulation.linkSims}");
menu.add(item);
}
// Initialize Debug menu
initDebugMenu(index, visible) {
let bar = this.menuBar;
let item = bar["debug" + index] = new Toolkit.MenuItem(this,
{ visible: visible }), top = item;
item.setText("{menu.debug._}");
item.substitute("#",
index == 0 ? this.dualMode ? "1" : "" : " 2", false);
bar.add(item);
let menu = item.menu = new Toolkit.Menu(this);
item = top.console = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.console}");
menu.add(item);
item = top.memory = new Toolkit.MenuItem(this);
item.setText("{menu.debug.memory}");
item.addEventListener("action",
e=>this.showWindow(this.debug[index].memory));
menu.add(item);
item = top.cpu = new Toolkit.MenuItem(this);
item.setText("{menu.debug.cpu}");
item.addEventListener("action",
e=>this.showWindow(this.debug[index].cpu));
menu.add(item);
item=top.breakpoints = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.breakpoints}");
menu.add(item);
menu.addSeparator();
item = top.palettes = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.palettes}");
menu.add(item);
item = top.characters = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.characters}");
menu.add(item);
item = top.bgMaps = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.bgMaps}");
menu.add(item);
item = top.backgrounds = new Toolkit.MenuItem(this, { disabled:true });
item.setText("{menu.debug.backgrounds}");
menu.add(item);
item = top.objects = new Toolkit.MenuItem(this, { disabled: true });
item.setText("{menu.debug.objects}");
menu.add(item);
item = top.frameBuffers = new Toolkit.MenuItem(this, {disabled: true});
item.setText("{menu.debug.frameBuffers}");
menu.add(item);
}
// Initialize Theme menu
initThemeMenu() {
let bar = this.menuBar;
let item = bar.theme = new Toolkit.MenuItem(this);
item.setText("{menu.theme._}");
bar.add(item);
let menu = item.menu = new Toolkit.Menu(this);
item = bar.theme.auto = new Toolkit.MenuItem(this,
{ checked: true, type: "checkbox" });
item.setText("{menu.theme.auto}");
item.theme = "auto";
item.addEventListener("action", e=>this.theme = "auto");
menu.add(item);
item = bar.theme.light = new Toolkit.MenuItem(this,
{ checked: false, type: "checkbox" });
item.setText("{menu.theme.light}");
item.theme = "light";
item.addEventListener("action", e=>this.theme = "light");
menu.add(item);
item = bar.theme.dark = new Toolkit.MenuItem(this,
{ checked: false, type: "checkbox" });
item.setText("{menu.theme.dark}");
item.theme = "dark";
item.addEventListener("action", e=>this.theme = "dark");
menu.add(item);
item = bar.theme.light = new Toolkit.MenuItem(this,
{ checked: false, type: "checkbox" });
item.setText("{menu.theme.virtual}");
item.theme = "virtual";
item.addEventListener("action", e=>this.theme = "virtual");
menu.add(item);
}
///////////////////////////// Event Handlers //////////////////////////////
// All elements have lost focus
onBlur(e) {
if (
e.relatedTarget == null ||
e.relatedTarget == document.body
) this.restoreFocus();
}
// Dark mode preference changed
onDark() {
if (this._theme != "auto")
return;
let isDark = this.isDark.matches;
this.themes.light.disabled = isDark;
this.themes.dark .disabled = !isDark;
}
// Game mode display resized
onDisplay() {
let bounds = this.display.element.getBoundingClientRect();
let width = Math.max(1, bounds.width);
let height = Math.max(1, bounds.height);
let scale, x1, y1, x2, y2;
// Single mode
if (!this.dualMode) {
this.image2.visible = false;
scale = Math.max(1, Math.min(
Math.floor(width / 384),
Math.floor(height / 224)
));
x1 = Math.max(0, Math.floor((width - 384 * scale) / 2));
y1 = Math.max(0, Math.floor((height - 224 * scale) / 2));
x2 = y2 = 0;
}
// Dual mode
else {
this.image2.visible = true;
// Horizontal orientation
if (true) {
scale = Math.max(1, Math.min(
Math.floor(width / 768),
Math.floor(height / 224)
));
let gap = Math.max(0, width - 768 * scale);
gap = gap < 0 ? 0 : Math.floor(gap / 3) + (gap%3==2 ? 1 : 0);
x1 = gap;
x2 = Math.max(384 * scale, width - 384 * scale - gap);
y1 = y2 = Math.max(0, Math.floor((height - 224 * scale) / 2));
}
// Vertical orientation
else {
scale = Math.max(1, Math.min(
Math.floor(width / 384),
Math.floor(height / 448)
));
let gap = Math.max(0, height - 448 * scale);
gap = gap < 0 ? 0 : Math.floor(gap / 3) + (gap%3==2 ? 1 : 0);
x1 = x2 = Math.max(0, Math.floor((width - 384 * scale) / 2));
y1 = gap;
y2 = Math.max(224 * scale, height - 224 * scale - gap);
}
}
width = 384 * scale + "px";
height = 224 * scale + "px";
Object.assign(this.image1.element.style,
{ left: x1+"px", top: y1+"px", width: width, height: height });
Object.assign(this.image2.element.style,
{ left: x2+"px", top: y2+"px", width: width, height: height });
}
// File -> Debug mode
onDebugMode() {
this.debugMode =!this.debugMode;
this.display.visible =!this.debugMode;
this.desktop.visible = this.debugMode;
this.configMenus();
this.onDisplay();
}
// Emulation -> Dual mode
onDualMode() {
this.setDualMode(!this.dualMode);
this.configMenus();
this.onDisplay();
}
// Key press
onKeyDown(e) {
// Take no action
if (!e.altKey || e.key != "F10" ||
this.menuBar.contains(document.activeElement))
return;
// Move focus into the menu bar
this.menuBar.focus();
Toolkit.handle(e);
}
// File -> Load ROM
async onLoadROM(index) {
// Add a file picker to the document
let file = document.createElement("input");
file.type = "file";
file.style.position = "absolute";
file.style.visibility = "hidden";
document.body.appendChild(file);
// Prompt the user to select a file
await new Promise(resolve=>{
file.addEventListener("input", resolve);
file.click();
});
file.remove();
// No file was selected
file = file.files[0];
if (!file)
return;
// Load the file
let data = null;
try { data = new Uint8Array(await file.arrayBuffer()); }
catch {
alert(this.localize("{menu.file.loadROMError}"));
return;
}
// Attempt to process the file as an ISX binary
try { data = Debugger.isx(data).toROM(); } catch { }
// Error checking
if (
data.length < 1024 ||
data.length > 0x1000000 ||
(data.length & data.length - 1)
) {
alert(this.localize("{menu.file.loadROMInvalid}"));
return;
}
// Load the ROM into simulation memory
let rep = await this.core.setROM(this.debug[index].sim, data,
{ refresh: true });
if (!rep.success) {
alert(this.localize("{menu.file.loadROMError}"));
return;
}
this.debug[index].followPC(0xFFFFFFF0);
}
// Emulation -> Reset
async onReset(index) {
await this.core.reset(this.debug[index].sim, { refresh: true });
this.debug[index].followPC(0xFFFFFFF0);
}
// Core subscription
onSubscription(key, msg) {
let target = this.debug; // Handler object
for (let x = 1; x < key.length - 1; x++)
target = target[key[x]];
target[key[key.length - 1]].call(target, msg);
}
///////////////////////////// Public Methods //////////////////////////////
// Specify document title
setTitle(title, localize) {
this.setString("text", title, localize);
}
// Specify the color theme
get theme() { return this._theme; }
set theme(theme) {
switch (theme) {
case "light": case "dark": case "virtual":
this._theme = theme;
for (let entry of Object.entries(this.themes))
entry[1].disabled = entry[0] != theme;
break;
default:
this._theme = "auto";
this.themes["virtual"].disabled = true;
this.onDark();
}
for (let item of this.menuBar.theme.menu.children)
item.checked = item.theme == theme;
}
///////////////////////////// Package Methods /////////////////////////////
// Configure components for automatic localization, or localize a message
localize(a, b) {
// Default behavior
if (a && a != this)
return super.localize(a, b);
// Update localization strings
if (this.text != null) {
let text = this.text;
document.title = !text[1] ? text[0] :
this.localize(text[0], this.substitutions);
}
}
// Return focus to the most recent focused element
restoreFocus() {
// Focus was successfully restored
if (super.restoreFocus())
return true;
// Select the foremost visible window
let wnd = this.desktop.getActiveWindow();
if (wnd) {
wnd.focus();
return true;
}
// Select the menu bar
this.menuBar.focus();
return true;
}
// Perform a Run Next command on one of the simulations
runToNext(index, options) {
let debugs = [ this.debug[index] ];
if (this.dualMode)
debugs.push(this.debug[index ^ 1]);
let ret = this.core.runToNext(debugs.map(d=>d.sim), options);
if (ret instanceof Promise) ret.then(msg=>{
for (let x = 0; x < debugs.length; x++)
debugs[x].followPC(msg.pcs[x]);
});
}
// Perform a Single Step command on one of the simulations
singleStep(index, options) {
let debugs = [ this.debug[index] ];
if (this.dualMode)
debugs.push(this.debug[index ^ 1]);
let ret = this.core.singleStep(debugs.map(d=>d.sim), options);
if (ret instanceof Promise) ret.then(msg=>{
for (let x = 0; x < debugs.length; x++)
debugs[x].followPC(msg.pcs[x]);
});
}
///////////////////////////// Private Methods /////////////////////////////
// Configure menu item visibility
configMenus() {
let bar = this.menuBar;
bar.file.debugMode .checked = this.debugMode;
bar.file.loadROM1 .visible = this.dualMode;
bar.emulation.reset1 .visible = this.dualMode;
bar.emulation.linkSims.visible = this.dualMode;
bar.debug0 .visible = this.debugMode;
bar.debug1 .visible = this.debugMode && this.dualMode;
}
// Specify whether dual mode is active
setDualMode(dualMode) {
// Update state
if (dualMode == this.dualMode)
return;
this.dualMode = dualMode;
// Working variables
let index = dualMode ? " 1" : "";
// Update menus
let bar = this.menuBar;
bar.file.loadROM0 .substitute("#", index, false);
bar.debug0 .substitute("#", index, false);
bar.emulation.reset0.substitute("#", index, false);
bar.file.dualMode .checked = this.dualMode;
// Update sim 1 debug windows
let dbg = this.debug[0];
dbg.cpu .substitute("#", index);
dbg.memory.substitute("#", index);
// Re-show any sim 2 debug windows that were previously visible
if (dualMode) {
for (let wnd of this.desktop.children) {
if (wnd.index == 1 && wnd.wasVisible)
wnd.visible = true;
}
}
// Hide any visible sim 2 debug windows
else for (let wnd of this.desktop.children) {
if (wnd.index == 0)
continue;
wnd.wasVisible = wnd.visible;
wnd.visible = false;
}
}
// Ensure a debugger window is visible
showWindow(wnd) {
let adjust = false;
// The window is already visible
if (wnd.visible) {
// The window is already in the foreground
if (wnd == this.desktop.getActiveWindow())
;//adjust = true;
// Bring the window to the foreground
else this.desktop.bringToFront(wnd);
}
// The window is not visible
else {
adjust = !wnd.shown;
if (adjust && wnd.firstShow)
wnd.firstShow();
wnd.visible = true;
this.desktop.bringToFront(wnd);
}
// Adjust the window position
if (adjust) {
let bounds = this.desktop.element.getBoundingClientRect();
wnd.left = Math.max(0, (bounds.width - wnd.outerWidth ) / 2);
wnd.top = Math.max(0, (bounds.height - wnd.outerHeight) / 2);
}
// Transfer focus into the window
wnd.focus();
}
// Install a stylesheet. Returns the resulting <link> element
stylesheet(filename, disabled = true) {
let ret = document.createElement("link");
ret.href = filename;
ret.rel = "stylesheet";
ret.type = "text/css";
ret.disabled = disabled;
document.head.append(ret);
return ret;
}
///////////////////////////// Program Methods /////////////////////////////
// Program entry point
static main(bundle) {
new App(bundle).init();
}
}
export { App };

203
web/Bundle.java Normal file
View File

@ -0,0 +1,203 @@
import java.awt.image.*;
import java.io.*;
import java.nio.charset.*;
import java.util.*;
import javax.imageio.*;
public class Bundle {
/////////////////////////////// BundledFile ///////////////////////////////
// Individual packaged resource file
static class BundledFile implements Comparable<BundledFile> {
// Instance fields
byte[] data; // File data loaded from disk
File file; // Source file
String filename; // Logical filename
// Constructor
BundledFile(BundledFile parent, File file) {
// Configure instance fields
this.file = file;
filename = parent == null ? "" : parent.filename + file.getName();
// Load file data if file
if (file.isFile()) {
try (var stream = new FileInputStream(file)) {
data = stream.readAllBytes();
} catch (Exception e) { }
}
// Update filename if directory
else if (parent != null) filename += "/";
}
// Comparator
public int compareTo(BundledFile o) {
return
filename.equals("web/_boot.js") ? -1 :
o.filename.equals("web/_boot.js") ? +1 :
filename.compareTo(o.filename)
;
}
// Produce a list of child files or directories
BundledFile[] listFiles(String name, boolean isDirectory) {
// Produce a filtered list of files
var files = this.file.listFiles(f->{
// Does not satisfy the directory requirement
if (f.isDirectory() != isDirectory)
return false;
// Current directory is not root
if (!filename.equals(""))
return true;
// Filter specific files from being bundled
String filename = f.getName();
return !(
filename.startsWith(".git" ) ||
filename.startsWith(name + "_") &&
filename.endsWith (".html" )
);
});
// Process all files for bundling
var ret = new BundledFile[files.length];
for (int x = 0; x < files.length; x++)
ret[x] = new BundledFile(this, files[x]);
return ret;
}
}
///////////////////////////// Program Methods /////////////////////////////
// Program entry point
public static void main(String[] args) {
String name = name(args[0]);
var files = listFiles(args[0]);
var prepend = prepend(name, files);
var bundle = bundle(prepend, files);
var image = image(bundle);
var url = url(image);
patch(name, url);
}
// Produce a buffer of the bundled files
static byte[] bundle(byte[] prepend, BundledFile[] files) {
try (var stream = new ByteArrayOutputStream()) {
stream.write(prepend);
stream.write(files[0].data); // web/_boot.js
stream.write(0);
for (int x = 1; x < files.length; x++)
stream.write(files[x].data);
return stream.toByteArray();
} catch (Exception e) { return null; }
}
// Convert a bundle buffer into a PNG-encoded image buffer
static byte[] image(byte[] bundle) {
int width = (int) Math.ceil(Math.sqrt(bundle.length));
int height = (bundle.length + width - 1) / width;
var pixels = new int[width * height];
// Encode the buffer as a pixel array
for (int x = 0; x < bundle.length; x++) {
int b = bundle[x] & 0xFF;
pixels[x] = 0xFF000000 | b << 16 | b << 8 | b;
}
// Produce an image using the pixels
var image = new BufferedImage(
width, height, BufferedImage.TYPE_INT_RGB);
image.setRGB(0, 0, width, height, pixels, 0, width);
// Encode the image as a PNG buffer
try (var stream = new ByteArrayOutputStream()) {
ImageIO.write(image, "png", stream);
return stream.toByteArray();
} catch (Exception e) { return null; }
}
// List all files
static BundledFile[] listFiles(String name) {
var dirs = new ArrayList<BundledFile>();
var files = new ArrayList<BundledFile>();
// Propagate down the file system tree
dirs.add(new BundledFile(null, new File(".")));
while (!dirs.isEmpty()) {
var dir = dirs.remove(0);
for (var sub : dir.listFiles(name, true ))
dirs.add(sub );
for (var file : dir.listFiles(name, false))
files.add(file);
}
// Return the set of files as a sorted array
Collections.sort(files);
return files.toArray(new BundledFile[files.size()]);
}
// Generate a filename for the bundle
static String name(String name) {
var calendar = Calendar.getInstance();
return String.format("%s_%04d%02d%02d",
name,
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH) + 1,
calendar.get(Calendar.DAY_OF_MONTH)
);
}
// Produce the output HTML from the template
static void patch(String name, String url) {
String markup = null;
try (var stream = new FileInputStream("web/template.html")) {
markup = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (Exception e) { }
markup = markup.replace("src=\"\"", "src=\"" + url + "\"");
try (var stream = new FileOutputStream(name + ".html")) {
stream.write(markup.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) { }
}
// Produce source data to prepend to web/_boot.js
static byte[] prepend(String name, BundledFile[] files) {
var ret = new StringBuilder();
// Arguments
ret.append("let " +
"buffer=arguments[0]," +
"image=arguments[1]," +
"name=\"" + name + "\"" +
";");
// Bundle manifest
ret.append("let manifest=[");
for (var file : files) {
if (file != files[0])
ret.append(",");
ret.append("[\"" +
file.filename + "\", " + file.data.length + "]");
}
ret.append("];");
// Convert to byte array
return ret.toString().getBytes(StandardCharsets.UTF_8);
}
// Convert an image buffer to a data URL
static String url(byte[] image) {
return "data:image/png;base64," +
Base64.getMimeEncoder(0, new byte[0]).encodeToString(image);
}
}

310
web/_boot.js Normal file
View File

@ -0,0 +1,310 @@
// Running as an async function
// Prepended by Bundle.java: buffer, image, manifest, name
///////////////////////////////////////////////////////////////////////////////
// Bundle //
///////////////////////////////////////////////////////////////////////////////
// Resource manager for bundled files
class Bundle extends Array {
//////////////////////////////// Constants ////////////////////////////////
// .ZIP support
static CRC_LOOKUP = new Uint32Array(256);
// Text processing
static DECODER = new TextDecoder();
static ENCODER = new TextEncoder();
static initializer() {
// Generate the CRC32 lookup table
for (let x = 0; x <= 255; x++) {
let l = x;
for (let j = 7; j >= 0; j--)
l = ((l >>> 1) ^ (0xEDB88320 & -(l & 1)));
this.CRC_LOOKUP[x] = l;
}
}
///////////////////////// Initialization Methods //////////////////////////
constructor(name, url, settings, isDebug) {
super();
this.isDebug = isDebug;
this.name = name;
this.settings = settings;
this.url = url;
}
///////////////////////////// Public Methods //////////////////////////////
// Export all bundled resources to a .ZIP file
save() {
let centrals = new Array(this.length);
let locals = new Array(this.length);
let offset = 0;
let size = 0;
// Encode file and directory entries
for (let x = 0; x < this.length; x++) {
let file = this[x];
let sum = Bundle.crc32(file.data);
locals [x] = file.toZipHeader(sum);
centrals[x] = file.toZipHeader(sum, offset);
offset += locals [x].length;
size += centrals[x].length;
}
// Encode end of central directory
let end = [];
Bundle.writeInt(end, 4, 0x06054B50); // Signature
Bundle.writeInt(end, 2, 0); // Disk number
Bundle.writeInt(end, 2, 0); // Central dir start disk
Bundle.writeInt(end, 2, this.length); // # central dir this disk
Bundle.writeInt(end, 2, this.length); // # central dir total
Bundle.writeInt(end, 4, size); // Size of central dir
Bundle.writeInt(end, 4, offset); // Offset of central dir
Bundle.writeInt(end, 2, 0); // .ZIP comment length
// Prompt the user to save the resulting file
let a = document.createElement("a");
a.download = this.name + ".zip";
a.href = URL.createObjectURL(new Blob(
locals.concat(centrals).concat([Uint8Array.from(end)]),
{ type: "application/zip" }
));
a.style.visibility = "hidden";
document.body.appendChild(a);
a.click();
a.remove();
}
///////////////////////////// Package Methods /////////////////////////////
// Add a BundledFile to the collection
add(file) {
file.bundle = this;
this.push(this[file.name] = file);
}
// Write a byte array into an output buffer
static writeBytes(data, bytes) {
for (let b of bytes)
data.push(b);
}
// Write an integer into an output buffer
static writeInt(data, size, value) {
for (; size > 0; size--, value >>= 8)
data.push(value & 0xFF);
}
// Write a string of text as bytes into an output buffer
static writeString(data, text) {
this.writeBytes(data, this.ENCODER.encode(text));
}
///////////////////////////// Private Methods /////////////////////////////
// Calculate the CRC32 checksum for a byte array
static crc32(data) {
let c = 0xFFFFFFFF;
for (let x = 0; x < data.length; x++)
c = ((c >>> 8) ^ this.CRC_LOOKUP[(c ^ data[x]) & 0xFF]);
return ~c & 0xFFFFFFFF;
}
}
Bundle.initializer();
///////////////////////////////////////////////////////////////////////////////
// BundledFile //
///////////////////////////////////////////////////////////////////////////////
// Individual file in the bundled data
class BundledFile {
//////////////////////////////// Constants ////////////////////////////////
// MIME types
static MIMES = {
".css" : "text/css;charset=UTF-8",
".frag" : "text/plain;charset=UTF-8",
".js" : "text/javascript;charset=UTF-8",
".json" : "application/json;charset=UTF-8",
".png" : "image/png",
".svg" : "image/svg+xml;charset=UTF-8",
".txt" : "text/plain;charset=UTF-8",
".vert" : "text/plain;charset=UTF-8",
".wasm" : "application/wasm",
".woff2": "font/woff2"
};
///////////////////////// Initialization Methods //////////////////////////
constructor(name, buffer, offset, length) {
// Configure instance fields
this.data = buffer.slice(offset, offset + length);
this.name = name;
// Resolve the MIME type
let index = name.lastIndexOf(".");
this.mime = index != -1 && BundledFile.MIMES[name.substring(index)] ||
"application/octet-stream";
}
///////////////////////////// Public Methods //////////////////////////////
// Represent the file with a blob URL
toBlobURL() {
return this.blobURL || (this.blobURL = URL.createObjectURL(
new Blob([ this.data ], { type: this.mime })));
}
// Encode the file data as a data URL
toDataURL() {
return "data:" + this.mime + ";base64," + btoa(
Array.from(this.data).map(b=>String.fromCharCode(b)).join(""));
}
// Pre-process URLs in a bundled file's contents
toProcURL(asDataURL = false) {
// The URL has already been computed
if (this.url)
return this.url;
// Working variables
let content = this.toString();
let pattern = /\/\*\*?\*\//g;
let parts = content.split(pattern);
let ret = [ parts.shift() ];
// Process all URLs prefixed with /**/ or /***/
for (let part of parts) {
let start = part.indexOf("\"");
let end = part.indexOf("\"", start + 1);
let filename = part.substring(start + 1, end);
let asData = pattern.exec(content)[0] == "/***/";
// Relative to current file
if (filename.startsWith(".")) {
let path = this.name.split("/");
path.pop(); // Current filename
// Navigate to the path of the target file
for (let dir of filename.split("/")) {
switch (dir) {
case "..": path.pop(); // Fallthrough
case "." : break;
default : path.push(dir);
}
}
// Produce the fully-qualified filename
filename = path.join("/");
}
// Append the file as a data URL
let file = this.bundle[filename];
ret.push(
part.substring(0, start + 1),
file[
file.mime.startsWith("text/javascript") ||
file.mime.startsWith("text/css") ?
"toProcURL" : asData ? "toDataURL" : "toBlobURL"
](asData),
part.substring(end)
);
}
// Represent the transformed source as a URL
return this.url = asDataURL ?
"data:" + this.mime + ";base64," + btoa(ret.join("")) :
URL.createObjectURL(new Blob(ret, { type: this.mime }))
;
}
// Decode the file data as a UTF-8 string
toString() {
return Bundle.DECODER.decode(this.data);
}
///////////////////////////// Package Methods /////////////////////////////
// Produce a .ZIP header for export
toZipHeader(crc32, offset) {
let central = offset !== undefined;
let ret = [];
if (central) {
Bundle.writeInt (ret, 4, 0x02014B50); // Signature
Bundle.writeInt (ret, 2, 20); // Version created by
} else
Bundle.writeInt (ret, 4, 0x04034B50); // Signature
Bundle.writeInt (ret, 2, 20); // Version required
Bundle.writeInt (ret, 2, 0); // Bit flags
Bundle.writeInt (ret, 2, 0); // Compression method
Bundle.writeInt (ret, 2, 0); // Modified time
Bundle.writeInt (ret, 2, 0); // Modified date
Bundle.writeInt (ret, 4, crc32); // Checksum
Bundle.writeInt (ret, 4, this.data.length); // Compressed size
Bundle.writeInt (ret, 4, this.data.length); // Uncompressed size
Bundle.writeInt (ret, 2, this.name.length); // Filename length
Bundle.writeInt (ret, 2, 0); // Extra field length
if (central) {
Bundle.writeInt (ret, 2, 0); // File comment length
Bundle.writeInt (ret, 2, 0); // Disk number start
Bundle.writeInt (ret, 2, 0); // Internal attributes
Bundle.writeInt (ret, 4, 0); // External attributes
Bundle.writeInt (ret, 4, offset); // Relative offset
}
Bundle.writeString (ret, this.name); // Filename
if (!central)
Bundle.writeBytes(ret, this.data); // File data
return Uint8Array.from(ret);
}
}
///////////////////////////////////////////////////////////////////////////////
// Boot Program //
///////////////////////////////////////////////////////////////////////////////
// Produce the application bundle
let bundle = new Bundle(name, image.src, image.getAttribute("settings") || "",
location.protocol != "file:" && location.hash == "#debug");
for (let x=0,offset=buffer.indexOf(0)-manifest[0][1]; x<manifest.length; x++) {
let entry = manifest[x];
bundle.add(new BundledFile(entry[0], buffer, offset, entry[1]));
offset += entry[1] + (x == 0 ? 1 : 0);
}
// Begin program operations
(await import(bundle.isDebug ?
"./web/App.js" :
bundle["web/App.js"].toProcURL()
)).App.main(bundle);

88
web/core/AudioThread.js Normal file
View File

@ -0,0 +1,88 @@
"use strict";
// Dedicated audio output thread
class AudioThread extends AudioWorkletProcessor {
///////////////////////// Initialization Methods //////////////////////////
constructor() {
super();
// Configure instance fields
this.buffers = []; // Input sample buffer queue
this.offset = 0; // Offset into oldest buffer
// Wait for initializer message from parent thread
this.port.onmessage = m=>this.init(m.data);
}
async init(main) {
// Configure message ports
this.core = this.port;
this.core.onmessage = m=>this.onCore(m.data);
this.main = main;
this.main.onmessage = m=>this.onMain(m.data);
// Notify main thread
this.port.postMessage(0);
}
///////////////////////////// Public Methods //////////////////////////////
// Produce output samples (called by the user agent)
process(inputs, outputs, parameters) {
let output = outputs[0];
let length = output [0].length;
let empty = null;
// Process all samples
for (let x = 0; x < length;) {
// No bufferfed samples are available
if (this.buffers.length == 0) {
for (; x < length; x++)
output[0] = output[1] = 0;
break;
}
// Transfer samples from the oldest buffer
let y, buffer = this.buffers[0];
for (y = this.offset; x < length && y < buffer.length; x++, y+=2) {
output[0][x] = buffer[y ];
output[1][x] = buffer[y + 1];
}
// Advance to the next buffer
if ((this.offset = y) == buffer.length) {
if (empty == null)
empty = [];
empty.push(this.buffers.shift());
this.offset = 0;
}
}
// Return emptied sample buffers to the core thread
if (empty != null)
this.core.postMessage(empty, empty.map(e=>e.buffer));
return true;
}
///////////////////////////// Message Methods /////////////////////////////
// Message received from core thread
onCore(msg) {
}
// Message received from main thread
onMain(msg) {
}
}
registerProcessor("AudioThread", AudioThread);

292
web/core/Core.js Normal file
View File

@ -0,0 +1,292 @@
// Interface between application and WebAssembly worker thread
class Core {
//////////////////////////////// Constants ////////////////////////////////
/* Register types */
static VB_PROGRAM = 0;
static VB_SYSTEM = 1;
static VB_OTHER = 2;
/* System registers */
static VB_ADTRE = 25;
static VB_CHCW = 24;
static VB_ECR = 4;
static VB_EIPC = 0;
static VB_EIPSW = 1;
static VB_FEPC = 2;
static VB_FEPSW = 3;
static VB_PIR = 6;
static VB_PSW = 5;
static VB_TKCW = 7;
/* Other registers */
static VB_PC = 0;
///////////////////////// Initialization Methods //////////////////////////
constructor() {
// Configure instance fields
this.promises = [];
}
async init(coreUrl, wasmUrl, audioUrl) {
// Open audio output stream
this.audio = new AudioContext({
latencyHint: "interactive",
sampleRate : 41700
});
await this.audio.suspend();
// Launch the audio thread
await this.audio.audioWorklet.addModule(
Core.url(audioUrl, "AudioThread.js", /***/"./AudioThread.js"));
let node = new AudioWorkletNode(this.audio, "AudioThread", {
numberOfInputs : 0,
numberOfOutputs : 1,
outputChannelCount: [2]
});
node.connect(this.audio.destination);
// Attach a second MessagePort to the audio thread
let channel = new MessageChannel();
this.audio.port = channel.port1;
await new Promise(resolve=>{
node.port.onmessage = resolve;
node.port.postMessage(channel.port2, [channel.port2]);
});
this.audio.port.onmessage = m=>this.onAudio(m.data);
// Launch the core thread
this.core = new Worker(
Core.url(wasmUrl, "CoreThread.js", /***/"./CoreThread.js"));
await new Promise(resolve=>{
this.core.onmessage = resolve;
this.core.postMessage({
audio : node.port,
wasmUrl: Core.url(wasmUrl, "core.wasm", /***/"./core.wasm")
}, [node.port]);
});
this.core.onmessage = m=>this.onCore(m.data);
return this;
}
///////////////////////////// Static Methods //////////////////////////////
// Select a URL in the same path as the current script
static url(arg, name, bundled) {
// The input argument was provided
if (arg)
return arg;
// Running from a bundle distribution
if (bundled.startsWith("blob:") || bundled.startsWith("data:"))
return bundled;
// Compute the URL for the given filename
let url = new URL(import.meta.url).pathname;
return url.substring(0, url.lastIndexOf("/") + 1) + name;
}
///////////////////////////// Event Handlers //////////////////////////////
// Message received from audio thread
onAudio(msg) {
}
// Message received from core thread
onCore(msg) {
// Process subscriptions
if (msg.subscriptions && this.onsubscription instanceof Function) {
for (let sub of msg.subscriptions) {
let key = sub.subscription;
delete sub.subscription;
this.onsubscription(key, sub, this);
}
delete msg.subscriptions;
}
// The main thread is waiting on a reply
if (msg.isReply) {
delete msg.isReply;
// For "create", produce sim objects
if (msg.isCreate) {
delete msg.isCreate;
msg.sims = msg.sims.map(s=>({ pointer: s }));
}
// Notify the caller
this.promises.shift()(msg);
}
}
///////////////////////////// Public Methods //////////////////////////////
// Create and initialize simulations
create(count, options) {
return this.message({
command: "create",
count : count
}, [], options);
}
// Delete a simulation
delete(sim, options) {
return this.message({
command: "delete",
sim : sim.pointer
}, [], options);
}
// Retrieve the value of all CPU registers
getAllRegisters(sim, options) {
return this.message({
command: "getAllRegisters",
sim : sim.pointer
}, [], options);
}
// Retrieve the value of a register
getRegister(sim, type, id, options) {
return this.message({
command: "getRegister",
id : id,
sim : sim.pointer,
type : type
}, [], options);
}
// Read multiple bytes from memory
read(sim, address, length, options) {
return this.message({
command: "read",
address: address,
length : length,
sim : sim.pointer
}, [], options);
}
// Refresh subscriptions
refresh(subscriptions = null, options) {
return this.message({
command : "refresh",
subscriptions: subscriptions
}, [], options);
}
// Simulate a hardware reset
reset(sim, options) {
return this.message({
command: "reset",
sim : sim.pointer
}, [], options);
}
// Execute until the next current instruction
runToNext(sims, options) {
return this.message({
command: "runToNext",
sims : Array.isArray(sims) ?
sims.map(s=>s.pointer) : [ sims.pointer ]
}, [], options);
}
// Specify a value for a register
setRegister(sim, type, id, value, options) {
return this.message({
command: "setRegister",
id : id,
sim : sim.pointer,
type : type,
value : value
}, [], options);
}
// Specify a cartridge ROM buffer
setROM(sim, data, options = {}) {
data = data.slice();
return this.message({
command: "setROM",
data : data,
reset : !("reset" in options) || !!options.reset,
sim : sim.pointer
}, [data.buffer], options);
}
// Execute the current instruction
singleStep(sims, options) {
return this.message({
command: "singleStep",
sims : Array.isArray(sims) ?
sims.map(s=>s.pointer) : [ sims.pointer ]
}, [], options);
}
// Cancel a subscription
unsubscribe(subscription, options) {
return this.message({
command : "unsubscribe",
subscription: subscription
}, [], options);
}
// Write multiple bytes to memory
write(sim, address, data, options) {
data = data.slice();
return this.message({
address: address,
command: "write",
data : data,
sim : sim.pointer
}, [data.buffer], options);
}
///////////////////////////// Private Methods /////////////////////////////
// Send a message to the core thread
message(msg, transfers, options = {}) {
// Configure options
if (!(options instanceof Object))
options = { reply: options };
if (!("reply" in options) || options.reply)
msg.reply = true;
if ("refresh" in options)
msg.refresh = options.refresh;
if ("subscription" in options)
msg.subscription = options.subscription;
if ("tag" in options)
msg.tag = options.tag;
// Send the command to the core thread
return msg.reply ?
new Promise(resolve=>{
this.promises.push(resolve);
this.core.postMessage(msg, transfers);
}) :
this.core.postMessage(msg, transfers);
;
}
}
export { Core };

338
web/core/CoreThread.js Normal file
View File

@ -0,0 +1,338 @@
"use strict";
/* Register types */
const VB_PROGRAM = 0;
const VB_SYSTEM = 1;
const VB_OTHER = 2;
/* System registers */
const VB_ADTRE = 25;
const VB_CHCW = 24;
const VB_ECR = 4;
const VB_EIPC = 0;
const VB_EIPSW = 1;
const VB_FEPC = 2;
const VB_FEPSW = 3;
const VB_PIR = 6;
const VB_PSW = 5;
const VB_TKCW = 7;
/* Other registers */
const VB_PC = 0;
// Dedicated emulation thread
class CoreThread {
///////////////////////// Initialization Methods //////////////////////////
constructor() {
// Configure instance fields
this.subscriptions = new Map();
// Wait for initializer message from parent thread
onmessage = m=>this.init(m.data.audio, m.data.wasmUrl);
}
async init(audio, wasmUrl) {
// Configure message ports
this.audio = audio;
this.audio.onmessage = m=>this.onAudio (m.data);
this.main = globalThis;
this.main .onmessage = m=>this.onMessage(m.data);
// Load and instantiate the WebAssembly module
this.wasm = (await WebAssembly.instantiateStreaming(
fetch(wasmUrl), {
env: { emscripten_notify_memory_growth: ()=>this.onGrowth() }
})).instance;
this.onGrowth();
this.pointerSize = this.PointerSize();
this.pointerType = this.pointerSize == 8 ? Uint64Array : Uint32Array;
// Notify main thread
this.main.postMessage(0);
}
///////////////////////////// Event Handlers //////////////////////////////
// Message received from audio thread
onAudio(frames) {
// Audio processing was suspended
if (frames == 0) {
return;
}
// Wait for more frames
this.audio.postMessage(0);
}
// Emscripten has grown the linear memory
onGrowth() {
Object.assign(this, this.wasm.exports);
}
// Message received from main thread
onMessage(msg) {
// Subscribe to the command
if (msg.subscription && msg.command != "refresh")
this.subscriptions.set(CoreThread.key(msg.subscription), msg);
// Process the command
let rep = this[msg.command](msg);
// Do not send a reply
if (!msg.reply)
return;
// Configure the reply
if (!rep)
rep = {};
if (msg.reply)
rep.isReply = true;
if ("tag" in msg)
rep.tag = msg.tag;
// Send the reply to the main thread
let transfers = rep.transfers;
if (transfers)
delete rep.transfers;
this.main.postMessage(rep, transfers || []);
// Refresh subscriptions
if (msg.refresh && msg.command != "refresh") {
let subs = {};
if (Array.isArray(msg.refresh))
subs.subscriptions = msg.refresh;
this.refresh(subs);
}
}
//////////////////////////////// Commands /////////////////////////////////
// Create and initialize a new simulation
create(msg) {
let sims = new Array(msg.count);
for (let x = 0; x < msg.count; x++)
sims[x] = this.Create();
return {
isCreate: true,
sims : sims
};
}
// Delete all memory used by a simulation
delete(msg) {
this.Delete(msg.sim);
}
// Retrieve the values of all CPU registers
getAllRegisters(msg) {
let program = new Int32Array (32);
let system = new Uint32Array(32);
for (let x = 0; x < 32; x++) {
program[x] = this.vbGetRegister(msg.sim, 0, x);
system [x] = this.vbGetRegister(msg.sim, 1, x);
}
return {
pc : this.vbGetRegister(msg.sim, 2, 0) >>> 0,
program : program,
system : system,
transfers: [ program.buffer, system.buffer ]
};
}
// Retrieve the value of a register
getRegister(msg) {
let value = this.vbGetRegister(msg.sim, msg.type, msg.id);
if (msg.type != VB_PROGRAM)
value >>>= 0;
return { value: value };
}
// Read multiple bytes from memory
read(msg) {
let buffer = this.malloc(msg.length);
this.vbReadEx(msg.sim, msg.address, buffer.pointer, msg.length);
let data = buffer.slice();
this.free(buffer);
return {
address : msg.address,
data : data,
transfers: [data.buffer]
};
}
// Process subscriptions
refresh(msg) {
let subscriptions = [];
let transfers = [];
// Select the key set to refresh
let keys = Array.isArray(msg.subscriptions) ?
msg.subscriptions.map(s=>CoreThread.key(s)) :
this.subscriptions.keys()
;
// Process all subscriptions
for (let key of keys) {
// Process the subscription
let sub = this.subscriptions.get(key);
let rep = this[sub.command](sub);
// There is no result
if (!rep)
continue;
// Add the result to the response
rep.subscription = sub.subscription;
if ("tag" in sub)
rep.tag = sub.tag;
subscriptions.push(rep);
// Add the transfers to the response
if (!rep.transfers)
continue;
transfers = transfers.concat(rep.transfers);
delete rep.transfers;
}
// Send the response to the main thread
if (subscriptions.length == 0)
return;
this.main.postMessage({
subscriptions: subscriptions.sort(CoreThread.REFRESH_ORDER)
}, transfers);
}
// Simulate a hardware reset
reset(msg) {
this.vbReset(msg.sim);
}
// Execute until the next current instruction
runToNext(msg) {
let sims = this.malloc(msg.sims.length, true);
for (let x = 0; x < msg.sims.length; x++)
sims[x] = msg.sims[x];
this.RunToNext(sims.pointer, msg.sims.length);
this.free(sims);
let pcs = new Array(msg.sims.length);
for (let x = 0; x < msg.sims.length; x++)
pcs[x] = this.vbGetRegister(msg.sims[x], 2, 0) >>> 0;
return { pcs: pcs };
}
// Specify a value for a register
setRegister(msg) {
let value = this.vbSetRegister(msg.sim, msg.type, msg.id, msg.value);
if (msg.type != VB_PROGRAM)
value >>>= 0;
return { value: value };
}
// Specify a cartridge ROM buffer
setROM(msg) {
let prev = this.vbGetROM(msg.sim, 0);
let success = true;
// Specify a new ROM
if (msg.data != null) {
let data = this.malloc(msg.data.length);
for (let x = 0; x < data.length; x++)
data[x] = msg.data[x];
success = !this.vbSetROM(msg.sim, data.pointer, data.length);
}
// Operation was successful
if (success) {
// Delete the previous ROM
this.Free(prev);
// Reset the simulation
if (msg.reset)
this.vbReset(msg.sim);
}
return { success: success };
}
// Execute the current instruction
singleStep(msg) {
let sims = this.malloc(msg.sims.length, true);
for (let x = 0; x < msg.sims.length; x++)
sims[x] = msg.sims[x];
this.SingleStep(sims.pointer, msg.sims.length);
this.free(sims);
let pcs = new Array(msg.sims.length);
for (let x = 0; x < msg.sims.length; x++)
pcs[x] = this.vbGetRegister(msg.sims[x], 2, 0) >>> 0;
return { pcs: pcs };
}
// Delete a subscription
unsubscribe(msg) {
this.subscriptions.delete(CoreThread.key(msg.subscription));
}
// Write multiple bytes to memory
write(msg) {
let data = this.malloc(msg.data.length);
for (let x = 0; x < data.length; x++)
data[x] = msg.data[x];
this.vbWriteEx(msg.sim, msg.address, data.pointer, data.length);
this.free(data);
}
///////////////////////////// Private Methods /////////////////////////////
// Delete a byte array in WebAssembly memory
free(buffer) {
this.Free(buffer.pointer);
}
// Format a subscription key as a string
static key(subscription) {
return subscription.map(k=>k.toString()).join("\n");
}
// Allocate a byte array in WebAssembly memory
malloc(length, pointers = false) {
let size = pointers ? length * this.pointerSize : length;
return this.map(this.Malloc(size), length, pointers);
}
// Map a typed array into WebAssembly memory
map(address, length, pointers = false) {
let ret = new (pointers ? this.pointerType : Uint8Array)
(this.memory.buffer, address, length);
ret.pointer = address;
return ret;
}
// Comparator for subscriptions within the refresh command
static REFRESH_ORDER(a, b) {
a = a.subscription[0];
b = b.subscription[0];
return a < b ? -1 : a > b ? 1 : 0;
}
}
new CoreThread();

546
web/core/Disassembler.js Normal file
View File

@ -0,0 +1,546 @@
// Machine code to human readable text converter
class Disassembler {
//////////////////////////////// Constants ////////////////////////////////
// Default settings
static DEFAULTS = {
condCL : "L", // Use C/NC or L/NL for conditions
condEZ : "E", // Use E/NE or Z/NZ for conditions
condNames : true, // Use condition names
condUppercase: false, // Condition names uppercase
hexPrefix : "0x", // Hexadecimal prefix
hexSuffix : "", // Hexadecimal suffix
hexUppercase : true, // Hexadecimal uppercase
instUppercase: true, // Mnemonics uppercase
jumpAddress : true, // Jump/branch shows target address
memInside : false, // Use [reg1 + disp] notation
opDestFirst : false, // Destination operand first
proNames : true, // Use program register names
proUppercase : false, // Program register names uppercase
splitBcond : false, // BCOND condition as an operand
splitSetf : true, // SETF condition as an operand
sysNames : true, // Use system register names
sysUppercase : false // System register names uppercase
};
/////////////////////////// Disassembly Lookup ////////////////////////////
// Opcode descriptors
static OPDEFS = [
[ "MOV" , [ "opReg1" , "opReg2" ] ], // 000000
[ "ADD" , [ "opReg1" , "opReg2" ] ],
[ "SUB" , [ "opReg1" , "opReg2" ] ],
[ "CMP" , [ "opReg1" , "opReg2" ] ],
[ "SHL" , [ "opReg1" , "opReg2" ] ],
[ "SHR" , [ "opReg1" , "opReg2" ] ],
[ "JMP" , [ "opReg1Ind" ] ],
[ "SAR" , [ "opReg1" , "opReg2" ] ],
[ "MUL" , [ "opReg1" , "opReg2" ] ],
[ "DIV" , [ "opReg1" , "opReg2" ] ],
[ "MULU" , [ "opReg1" , "opReg2" ] ],
[ "DIVU" , [ "opReg1" , "opReg2" ] ],
[ "OR" , [ "opReg1" , "opReg2" ] ],
[ "AND" , [ "opReg1" , "opReg2" ] ],
[ "XOR" , [ "opReg1" , "opReg2" ] ],
[ "NOT" , [ "opReg1" , "opReg2" ] ],
[ "MOV" , [ "opImm5S", "opReg2" ] ], // 010000
[ "ADD" , [ "opImm5S", "opReg2" ] ],
null, // SETF: special
[ "CMP" , [ "opImm5S", "opReg2" ] ],
[ "SHL" , [ "opImm5U", "opReg2" ] ],
[ "SHR" , [ "opImm5U", "opReg2" ] ],
[ "CLI" , [ ] ],
[ "SAR" , [ "opImm5U", "opReg2" ] ],
[ "TRAP" , [ "opImm5U" ] ],
[ "RETI" , [ ] ],
[ "HALT" , [ ] ],
null, // Invalid
[ "LDSR" , [ "opReg2" , "opSys" ] ],
[ "STSR" , [ "opSys" , "opReg2" ] ],
[ "SEI" , [ ] ],
null, // Bit string: special
null, // BCOND: special // 100000
null, // BCOND: special
null, // BCOND: special
null, // BCOND: special
null, // BCOND: special
null, // BCOND: special
null, // BCOND: special
null, // BCOND: special
[ "MOVEA", [ "opImm16U" , "opReg1", "opReg2" ] ],
[ "ADDI" , [ "opImm16S" , "opReg1", "opReg2" ] ],
[ "JR" , [ "opDisp26" ] ],
[ "JAL" , [ "opDisp26" ] ],
[ "ORI" , [ "opImm16U" , "opReg1", "opReg2" ] ],
[ "ANDI" , [ "opImm16U" , "opReg1", "opReg2" ] ],
[ "XORI" , [ "opImm16U" , "opReg1", "opReg2" ] ],
[ "MOVHI", [ "opImm16U" , "opReg1", "opReg2" ] ],
[ "LD.B" , [ "opReg1Disp", "opReg2" ] ], // 110000
[ "LD.H" , [ "opReg1Disp", "opReg2" ] ],
null, // Invalid
[ "LD.W" , [ "opReg1Disp", "opReg2" ] ],
[ "ST.B" , [ "opReg2" , "opReg1Disp" ] ],
[ "ST.H" , [ "opReg2" , "opReg1Disp" ] ],
null, // Invalid
[ "ST.W" , [ "opReg2" , "opReg1Disp" ] ],
[ "IN.B" , [ "opReg1Disp", "opReg2" ] ],
[ "IN.H" , [ "opReg1Disp", "opReg2" ] ],
[ "CAXI" , [ "opReg1Disp", "opReg2" ] ],
[ "IN.W" , [ "opReg1Disp", "opReg2" ] ],
[ "OUT.B", [ "opReg2" , "opReg1Disp" ] ],
[ "OUT.H", [ "opReg2" , "opReg1Disp" ] ],
null, // Floating-point/Nintendo: special
[ "OUT.W", [ "opReg2" , "opReg1Disp" ] ]
];
// Bit string sub-opcode descriptors
static BITSTRING = [
"SCH0BSU", "SCH0BSD", "SCH1BSU", "SCH1BSD",
null , null , null , null ,
"ORBSU" , "ANDBSU" , "XORBSU" , "MOVBSU" ,
"ORNBSU" , "ANDNBSU", "XORNBSU", "NOTBSU" ,
null , null , null , null ,
null , null , null , null ,
null , null , null , null ,
null , null , null , null
];
// Floating-point/Nintendo sub-opcode descriptors
static FLOATENDO = [
[ "CMPF.S" , [ "opReg1", "opReg2" ] ],
null, // Invalid
[ "CVT.WS" , [ "opReg1", "opReg2" ] ],
[ "CVT.SW" , [ "opReg1", "opReg2" ] ],
[ "ADDF.S" , [ "opReg1", "opReg2" ] ],
[ "SUBF.S" , [ "opReg1", "opReg2" ] ],
[ "MULF.S" , [ "opReg1", "opReg2" ] ],
[ "DIVF.S" , [ "opReg1", "opReg2" ] ],
[ "XB" , [ "opReg2" ] ],
[ "XH" , [ "opReg2" ] ],
[ "REV" , [ "opReg1", "opReg2" ] ],
[ "TRNC.SW", [ "opReg1", "opReg2" ] ],
[ "MPYHW" , [ "opReg1", "opReg2" ] ],
null, null, null,
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null
];
// Condition mnemonics
static CONDITIONS = [
"V" , "C" , "E" , "NH", "N", "T", "LT", "LE",
"NV", "NC", "NE", "H" , "P", "F", "GE", "GT"
];
// Program register names
static REG_PROGRAM = [
"r0" , "r1" , "hp" , "sp" , "gp" , "tp" , "r6" , "r7" ,
"r8" , "r9" , "r10", "r11", "r12", "r13", "r14", "r15",
"r16", "r17", "r18", "r19", "r20", "r21", "r22", "r23",
"r24", "r25", "r26", "r27", "r28", "r29", "r30", "lp"
];
// System register names
static REG_SYSTEM = [
"EIPC", "EIPSW", "FEPC", "FEPSW", "ECR", "PSW", "PIR", "TKCW",
"8" , "9" , "10" , "11" , "12" , "13" , "14" , "15" ,
"16" , "17" , "18" , "19" , "20" , "21" , "22" , "23" ,
"CHCW", "ADTRE", "26" , "27" , "28" , "29" , "30" , "31"
];
// Other register names
static REG_OTHER = [ "PC", "PSW" ];
///////////////////////////// Static Methods //////////////////////////////
// Determine the bounds of a data buffer to represent all lines of output
static dataBounds(address, line, length) {
let before = 10; // Number of lines before the first line of output
let max = 4; // Maximum number of bytes that can appear on a line
// The reference line is before the preferred earliest line
if (line < -before) {
length = (length - line) * max;
}
// The reference line is before the first line
else if (line < 0) {
address -= (line + before) * max;
length = (length + before) * max;
}
// The reference line is at or after the first line
else {
address -= (line + before) * max;
length = (Math.max(length, line) + before) * max;
}
return {
address: (address & ~1) >>> 0,
length : length
};
}
///////////////////////// Initialization Methods //////////////////////////
constructor() {
Object.assign(this, Disassembler.DEFAULTS);
}
///////////////////////////// Public Methods //////////////////////////////
// Disassemble a region of memory
disassemble(data, dataAddress, refAddress, refLine, length, pc = null) {
let pcOffset = pc === null ? -1 : pc - dataAddress >>> 0;
// Locate the offset of the first line of output in the buffer
let offset = 0;
for (let
addr = dataAddress,
circle = refLine > 0 ? new Array(refLine) : null,
index = 0,
more = [],
remain = null
;;) {
// Determine the size of the current line
if (more.length == 0)
this.more(more, data, offset);
let size = more.shift();
// The current line contains the reference address
if (refAddress - addr >>> 0 < size) {
// The next item in the buffer is the first line of output
if (refLine > 0) {
offset = circle[index];
break;
}
// This line is the first line of output
if (refLine == 0)
break;
// Count more lines for the first line of output
remain = refLine;
}
// Record the offset of the current instruction
if (refLine > 0) {
circle[index] = offset;
index = (index + 1) % circle.length;
}
// Advance to the next line
let sizeToPC = pcOffset - offset >>> 0;
if (offset != pcOffset && sizeToPC < size) {
size = sizeToPC;
more.splice();
}
addr = addr + size >>> 0;
offset += size;
if (remain !== null && ++remain == 0)
break; // The next line is the first line of output
}
// Process all lines of output
let lines = new Array(length);
for (let
addr = dataAddress + offset,
more = [],
x = 0;
x < length; x++
) {
// Determine the size of the current line
if (more.length == 0)
this.more(more, data, offset, pcOffset);
let size = more.shift();
// Add the line to the response
lines[x] = this.format({
rawAddress: addr,
rawBytes : data.slice(offset, offset + size)
});
// Advance to the next line
let sizeToPC = pcOffset - offset >>> 0;
if (offset != pcOffset && sizeToPC < size) {
size = sizeToPC;
more.splice();
}
addr = addr + size >>> 0;
offset += size;
}
return lines;
}
/////////////////////////// Formatting Methods ////////////////////////////
// Format a line as human-readable text
format(line) {
let canReverse = true;
let opcode = line.rawBytes[1] >>> 2;
let opdef;
let code = [
line.rawBytes[1] << 8 | line.rawBytes[0],
line.rawBytes.length == 2 ? null :
line.rawBytes[3] << 8 | line.rawBytes[2]
];
// BCOND
if ((opcode & 0b111000) == 0b100000) {
let cond = code[0] >>> 9 & 15;
opdef =
cond == 13 ? [ "NOP", [ ] ] :
this.splitBcond ? [ "BCOND", [ "opBCond", "opDisp9" ] ] :
[
cond == 5 ? "BR" : "B" + this.condition(cond, true),
[ "opDisp9" ]
]
;
canReverse = false;
}
// Processing by opcode
else switch (opcode) {
// SETF
case 0b010010:
opdef = !this.splitSetf ?
[
"SETF" + Disassembler.CONDITIONS[code[0] & 15],
[ "opReg2" ]
] :
[ "SETF", [ "opCond", "opReg2" ] ]
;
break;
// Bit string
case 0b011111:
opdef = Disassembler.BITSTRING[code[0] & 31];
if (opdef != null)
opdef = [ opdef, [] ];
break;
// Floating-point/Nintendo
case 0b111110:
opdef = Disassembler.FLOATENDO[code[1] >>> 10];
break;
// All others
default: opdef = Disassembler.OPDEFS[opcode];
}
// The opcode is undefined
if (opdef == null)
opdef = [ "---", [] ];
// Format the line's display text
line.address = this.hex(line.rawAddress, 8, false);
line.bytes = new Array(line.rawBytes.length);
line.mnemonic = this.instUppercase ? opdef[0] : opdef[0].toLowerCase();
line.operands = new Array(opdef[1].length);
for (let x = 0; x < line.bytes.length; x++)
line.bytes[x] = this.hex(line.rawBytes[x], 2, false);
for (let x = 0; x < line.operands.length; x++)
line.operands[x] = this[opdef[1][x]](line, code);
if (this.opDestFirst && canReverse)
line.operands.reverse();
return line;
}
// Format a condition operand in a BCOND instruction
opBCond(line, code) {
return this.condition(code[0] >>> 9 & 15);
}
// Format a condition operand in a SETF instruction
opCond(line, code) {
return this.condition(code[0] & 15);
}
// Format a 9-bit displacement operand
opDisp9(line, code) {
let disp = code[0] << 23 >> 23;
return this.jump(line.rawAddress, disp);
}
// Format a 26-bit displacement operand
opDisp26(line, code) {
let disp = (code[0] << 16 | code[1]) << 6 >> 6;
return this.jump(line.rawAddress, disp);
}
// Format a 5-bit signed immediate operand
opImm5S(line, code) {
return (code[0] & 31) << 27 >> 27;
}
// Format a 5-bit unsigned immediate operand
opImm5U(line, code) {
return code[0] & 31;
}
// Format a 16-bit signed immediate operand
opImm16S(line, code) {
let ret = code[1] << 16 >> 16;
return (
ret < -256 ? "-" + this.hex(-ret) :
ret > 256 ? this.hex( ret) :
ret
);
}
// Format a 16-bit unsigned immediate operand
opImm16U(line, code) {
return this.hex(code[1], 4);
}
// Format a Reg1 operand
opReg1(line, code) {
return this.programRegister(code[0] & 31);
}
// Format a disp[reg1] operand
opReg1Disp(line, code) {
let disp = code[1] << 16 >> 16;
let reg1 = this.programRegister(code[0] & 31);
// Do not print the displacement
if (disp == 0)
return "[" + reg1 + "]";
// Format the displacement amount
disp =
disp < -256 ? "-" + this.hex(-disp) :
disp > 256 ? this.hex( disp) :
disp.toString()
;
// [reg1 + disp] notation
if (this.memInside) {
return "[" + reg1 + (disp.startsWith("-") ?
" - " + disp.substring(1) :
" + " + disp
) + "]";
}
// disp[reg1] notation
return disp + "[" + reg1 + "]";
}
// Format a [Reg1] operand
opReg1Ind(line, code) {
return "[" + this.programRegister(code[0] & 31) + "]";
}
// Format a Reg2 operand
opReg2(line, code) {
return this.programRegister(code[0] >> 5 & 31);
}
// Format a system register operand
opSys(line, code) {
return this.systemRegister(code[0] & 31);
}
///////////////////////////// Private Methods /////////////////////////////
// Select the mnemonic for a condition
condition(index, forceUppercase = false) {
if (!this.condNames)
return index.toString();
let ret =
index == 1 ? this.condCL :
index == 2 ? this.condEZ :
index == 9 ? "N" + this.condCL :
index == 10 ? "N" + this.condEZ :
Disassembler.CONDITIONS[index]
;
if (!forceUppercase && !this.condUppercase)
ret = ret.toLowerCase();
return ret;
}
// Format a number as a hexadecimal string
hex(value, digits = null, decorated = true) {
value = value.toString(16);
if (this.hexUppercase)
value = value.toUpperCase();
if (digits != null)
value = value.padStart(digits, "0");
if (decorated) {
value = this.hexPrefix + value + this.hexSuffix;
if (this.hexPrefix == "" && "0123456789".indexOf(value[0]) == -1)
value = "0" + value;
}
return value;
}
// Format a jump or branch destination
jump(address, disp) {
return (
this.jumpAddress ?
this.hex(address + disp >>> 0, 8, false) :
disp < -256 ? "-" + this.hex(-disp) :
disp > 256 ? "+" + this.hex( disp) :
disp.toString()
);
}
// Determine the number of bytes in the next line(s) of disassembly
more(more, data, offset) {
// Error checking
if (offset + 1 >= data.length)
throw new Error("Disassembly error: Unexpected EoF");
// Determine the instruction's size from its opcode
let opcode = data[offset + 1] >>> 2;
more.push(
opcode < 0b101000 || // 16-bit instruction
opcode == 0b110010 || // Illegal opcode
opcode == 0b110110 // Illegal opcode
? 2 : 4);
}
// Format a program register
programRegister(index) {
let ret = this.proNames ?
Disassembler.REG_PROGRAM[index] : "r" + index;
if (this.proUppercase)
ret = ret.toUpperCase();
return ret;
}
// Format a system register
systemRegister(index) {
let ret = this.sysNames ?
Disassembler.REG_SYSTEM[index] : index.toString();
if (!this.sysUppercase && this.sysNames)
ret = ret.toLowerCase();
return ret;
}
}
export { Disassembler };

79
web/core/wasm.c Normal file
View File

@ -0,0 +1,79 @@
#undef VBAPI
#include <stdlib.h>
#include <emscripten/emscripten.h>
#include <vb.h>
/////////////////////////////// Module Commands ///////////////////////////////
// Create and initialize a new simulation
EMSCRIPTEN_KEEPALIVE VB* Create() {
VB *sim = malloc(sizeof (VB));
vbInit(sim);
return sim;
}
// Delete all memory used by a simulation
EMSCRIPTEN_KEEPALIVE void Delete(VB *sim) {
free(sim->cart.ram);
free(sim->cart.rom);
free(sim);
}
// Proxy for free()
EMSCRIPTEN_KEEPALIVE void Free(void *ptr) {
free(ptr);
}
// Proxy for malloc()
EMSCRIPTEN_KEEPALIVE void* Malloc(int size) {
return malloc(size);
}
// Size in bytes of a pointer
EMSCRIPTEN_KEEPALIVE int PointerSize() {
return sizeof (void *);
}
////////////////////////////// Debugger Commands //////////////////////////////
// Execute until the following instruction
static uint32_t RunToNextAddress;
static int RunToNextFetch(VB *sim, int fetch, VBAccess *access) {
return access->address == RunToNextAddress;
}
static int RunToNextExecute(VB *sim, VBInstruction *inst) {
RunToNextAddress = inst->address + inst->size;
vbSetCallback(sim, VB_ONEXECUTE, NULL);
vbSetCallback(sim, VB_ONFETCH, &RunToNextFetch);
return 0;
}
EMSCRIPTEN_KEEPALIVE void RunToNext(VB **sims, int count) {
uint32_t clocks = 20000000; // 1s
vbSetCallback(sims[0], VB_ONEXECUTE, &RunToNextExecute);
vbEmulateEx (sims, count, &clocks);
vbSetCallback(sims[0], VB_ONEXECUTE, NULL);
vbSetCallback(sims[0], VB_ONFETCH , NULL);
}
// Execute the current instruction
static int SingleStepBreak;
static int SingleStepFetch(VB *sim, int fetch, VBAccess *access) {
if (fetch != 0)
return 0;
if (SingleStepBreak == 1)
return 1;
SingleStepBreak = 1;
return 0;
}
EMSCRIPTEN_KEEPALIVE void SingleStep(VB **sims, int count) {
uint32_t clocks = 20000000; // 1s
SingleStepBreak = sims[0]->cpu.stage == 0 ? 0 : 1;
vbSetCallback(sims[0], VB_ONFETCH, &SingleStepFetch);
vbEmulateEx (sims, count, &clocks);
vbSetCallback(sims[0], VB_ONEXECUTE, NULL);
vbSetCallback(sims[0], VB_ONFETCH , NULL);
}

1439
web/debugger/CPU.js Normal file

File diff suppressed because it is too large Load Diff

104
web/debugger/Debugger.js Normal file
View File

@ -0,0 +1,104 @@
import { ISX } from /**/"./ISX.js";
// Debug mode UI manager
class Debugger {
///////////////////////////// Static Methods //////////////////////////////
// Data type conversions
static F32 = new Float32Array(1);
static U32 = new Uint32Array(this.F32.buffer);
// Reinterpret a float32 as a u32
static fxi(x) {
this.F32[0] = x;
return this.U32[0];
}
// Process file data as ISM
static isx(data) {
return new ISX(data);
}
// Reinterpret a u32 as a float32
static ixf(x) {
this.U32[0] = x;
return this.F32[0];
}
// Compute the number of lines scrolled by a WheelEvent
static linesScrolled(e, lineHeight, pageLines, delta) {
let ret = {
delta: delta,
lines: 0
};
// No scrolling occurred
if (e.deltaY == 0);
// Scrolling by pixel
else if (e.deltaMode == WheelEvent.DOM_DELTA_PIXEL) {
ret.delta += e.deltaY;
ret.lines = Math.sign(ret.delta) *
Math.floor(Math.abs(ret.delta) / lineHeight);
ret.delta -= ret.lines * lineHeight;
}
// Scrolling by line
else if (e.deltaMode == WheelEvent.DOM_DELTA_LINE)
ret.lines = Math.trunc(e.deltaY);
// Scrolling by page
else if (e.deltaMode == WheelEvent.DOM_DELTA_PAGE)
ret.lines = Math.trunc(e.deltaY) * pageLines;
// Unknown scrolling mode
else ret.lines = 3 * Math.sign(e.deltaY);
return ret;
}
///////////////////////// Initialization Methods //////////////////////////
constructor(app, sim, index) {
// Configure instance fields
this.app = app;
this.core = app.core;
this.dasm = app.dasm;
this.sim = sim;
// Configure debugger windows
this.cpu = new Debugger.CPU (this, index);
this.memory = new Debugger.Memory(this, index);
}
///////////////////////////// Package Methods /////////////////////////////
// Disassembler configuration has changed
dasmConfigured() {
this.cpu .dasmConfigured();
this.memory.dasmConfigured();
}
// Ensure PC is visible in the disassembler
followPC(pc = null) {
this.cpu.disassembler.followPC(pc);
}
// Format a number as hexadecimal
hex(value, digits = null, decorated = true) {
return this.dasm.hex(value, digits, decorated);
}
}
// Register component classes
(await import(/**/"./CPU.js" )).register(Debugger);
(await import(/**/"./Memory.js")).register(Debugger);
export { Debugger };

177
web/debugger/ISX.js Normal file
View File

@ -0,0 +1,177 @@
// Debug manager for Intelligent Systems binaries
class ISX {
///////////////////////// Initialization Methods //////////////////////////
// Throws on decoding error
constructor(data) {
// Configure instance fields
this.data = data;
this.offset = 0;
this.ranges = [];
this.symbols = [];
this.codes = [];
// Skip any header that may be present
if (data.length >= 32 && this.readInt(3) == 0x585349)
this.offset = 32;
else this.offset = 0;
// Process all records
while (this.offset < this.data.length) {
switch (this.readInt(1)) {
// Virtual Boy records
case 0x11: this.code (); break;
case 0x13: this.range (); break;
case 0x14: this.symbol(); break;
// System records
case 0x20:
case 0x21:
case 0x22:
let length = this.readInt(4);
this.offset += length;
break;
// Other records
default: throw "ISX decode error";
}
}
// Cleanup instance fields
delete this.data;
delete this.decoder;
delete this.offset;
}
///////////////////////////// Public Methods //////////////////////////////
// Produce a .vb format ROM file from the ISX code segments
toROM() {
let head = 0x00000000;
let tail = 0x01000000;
// Inspect all code segments
for (let code of this.codes) {
let start = code.address & 0x00FFFFFF;
let end = start + code.data.length;
// Segment begins in the first half of ROM
if (start < 0x00800000) {
// Segment ends in the second half of ROM
if (end > 0x00800000) {
head = tail = 0;
break;
}
// Segment ends in the first half of ROM
else if (end > head)
head = end;
}
// Segment begins in the second half of ROM
else if (start < tail)
tail = start;
}
// Prepare the output buffer
let min = head + 0x01000000 - tail;
let size = 1;
for (; size < min; size <<= 1);
let rom = new Uint8Array(size);
// Output all code segments
for (let code of this.codes) {
let dest = code.address & rom.length - 1;
for (let src = 0; src < code.data.length; src++, dest++)
rom[dest] = code.data[src];
}
return rom;
}
///////////////////////////// Private Methods /////////////////////////////
// Process a code record
code() {
let address = this.readInt(4);
let length = this.readInt(4);
let data = this.readBytes(length);
if (
length == 0 ||
length > 0x01000000 ||
(address & 0x07000000) != 0x07000000 ||
(address & 0x07000000) + length > 0x08000000
) throw "ISX decode error";
this.codes.push({
address: address,
data : data
});
}
// Process a range record
range() {
let count = this.readInt(2);
while (count--) {
let start = this.readInt(4);
let end = this.readInt(4);
let type = this.readInt(1);
this.ranges.push({
end : end,
start: start,
type : type
});
}
}
// Process a symbol record
symbol() {
let count = this.readInt(2);
while (count--) {
let length = this.readInt(1);
let name = this.readString(length);
let flags = this.readInt(2);
let address = this.readInt(4);
this.symbols.push({
address: address,
flags : flags,
name : name
});
}
}
// Read a byte buffer
readBytes(size) {
if (this.offset + size > this.data.length)
throw "ISX decode error";
let ret = this.data.slice(this.offset, this.offset + size);
this.offset += size;
return ret;
}
// Read an integer
readInt(size) {
if (this.offset + size > this.data.length)
throw "ISX decode error";
let ret = new Uint32Array(1);
for (let shift = 0; size > 0; size--, shift += 8)
ret[0] |= this.data[this.offset++] << shift;
return ret[0];
}
// Read a text string
readString(size) {
return (this.decoder = this.decoder || new TextDecoder()
).decode(this.readBytes(size));
}
}
export { ISX };

574
web/debugger/Memory.js Normal file
View File

@ -0,0 +1,574 @@
import { Toolkit } from /**/"../toolkit/Toolkit.js";
let register = Debugger => Debugger.Memory =
// Debugger memory window
class Memory extends Toolkit.Window {
///////////////////////// Initialization Methods //////////////////////////
constructor(debug, index) {
super(debug.app, {
class: "tk window memory"
});
// Configure instance fields
this.data = null,
this.dataAddress = null,
this.debug = debug;
this.delta = 0;
this.editDigit = null;
this.height = 300;
this.index = index;
this.lines = [];
this.pending = false;
this.shown = false;
this.subscription = [ 0, index, "memory", "refresh" ];
this.width = 400;
// Available buses
this.buses = [
{
editAddress: 0x05000000,
viewAddress: 0x05000000
}
];
this.bus = this.buses[0];
// Window
this.setTitle("{debug.memory._}", true);
this.substitute("#", " " + (index + 1));
if (index == 1)
this.element.classList.add("two");
this.addEventListener("close" , e=>this.visible = false);
this.addEventListener("visibility", e=>this.onVisibility(e));
// Client area
Object.assign(this.client.style, {
display : "grid",
gridTemplateRows: "max-content auto"
});
// Bus drop-down
this.drpBus = new Toolkit.DropDown(debug.app);
this.drpBus.setLabel("{debug.memory.bus}", true);
this.drpBus.setTitle("{debug.memory.bus}", true);
this.drpBus.add("{debug.memory.busMemory}", true, this.buses[0]);
this.drpBus.addEventListener("input", e=>this.busInput());
this.add(this.drpBus);
// Hex editor
this.hexEditor = new Toolkit.Component(debug.app, {
class : "tk mono hex-editor",
role : "application",
tabIndex: "0",
style : {
display : "grid",
gridTemplateColumns: "repeat(17, max-content)",
height : "100%",
minWidth : "100%",
overflow : "hidden",
position : "relative",
width : "max-content"
}
});
this.hexEditor.localize = ()=>{
this.hexEditor.localizeRoleDescription();
this.hexEditor.localizeLabel();
};
this.hexEditor.setLabel("{debug.memory.hexEditor}", true);
this.hexEditor.setRoleDescription("{debug.memory.hexEditor}", true);
this.hexEditor.addEventListener("focusout", e=>this.commit ( ));
this.hexEditor.addEventListener("keydown" , e=>this.hexKeyDown(e));
this.hexEditor.addEventListener("resize" , e=>this.hexResize ( ));
this.hexEditor.addEventListener("wheel" , e=>this.hexWheel (e));
this.hexEditor.addEventListener(
"pointerdown", e=>this.hexPointerDown(e));
this.lastFocus = this.hexEditor;
// Label for measuring text dimensions
this.sizer = new Toolkit.Label(debug.app, {
class : "tk label mono",
visible : false,
visibility: true,
style: {
position: "absolute"
}
});
this.sizer.setText("\u00a0", false); // &nbsp;
this.hexEditor.append(this.sizer);
// Hex editor scroll pane
this.scrHex = new Toolkit.ScrollPane(debug.app, {
overflowX: "auto",
overflowY: "hidden",
view : this.hexEditor
});
this.add(this.scrHex);
// Hide the bus drop-down: Virtual Boy only has one bus
this.drpBus.visible = false;
this.client.style.gridTemplateRows = "auto";
}
///////////////////////////// Event Handlers //////////////////////////////
// Bus drop-down selection
busInput() {
// An edit is in progress
if (this.editDigit !== null)
this.commit(false);
// Switch to the new bus
this.bus = this.drpBus.value;
this.fetch();
}
// Hex editor key press
hexKeyDown(e) {
// Error checking
if (e.altKey || e.ctrlKey)
// Processing by key, scroll lock off
if (!e.getModifierState("ScrollLock")) switch (e.key) {
case "ArrowDown":
this.commit();
this.setEditAddress(this.bus.editAddress + 16);
Toolkit.handle(e);
return;
case "ArrowLeft":
this.commit();
this.setEditAddress(this.bus.editAddress - 1);
Toolkit.handle(e);
return;
case "ArrowRight":
this.commit();
this.setEditAddress(this.bus.editAddress + 1);
Toolkit.handle(e);
return;
case "ArrowUp":
this.commit();
this.setEditAddress(this.bus.editAddress - 16);
Toolkit.handle(e);
return;
case "PageDown":
this.commit();
this.setEditAddress(this.bus.editAddress + this.tall(true)*16);
Toolkit.handle(e);
return;
case "PageUp":
this.commit();
this.setEditAddress(this.bus.editAddress - this.tall(true)*16);
Toolkit.handle(e);
return;
}
// Processing by key, scroll lock on
else switch (e.key) {
case "ArrowDown":
this.setViewAddress(this.bus.viewAddress + 16);
this.fetch();
Toolkit.handle(e);
return;
case "ArrowLeft":
this.scrHex.scrollLeft -= this.scrHex.hscroll.unitIncrement;
Toolkit.handle(e);
return;
case "ArrowRight":
this.scrHex.scrollLeft += this.scrHex.hscroll.unitIncrement;
Toolkit.handle(e);
return;
case "ArrowUp":
this.setViewAddress(this.bus.viewAddress - 16);
this.fetch();
Toolkit.handle(e);
return;
case "PageDown":
this.setViewAddress(this.bus.viewAddress + this.tall(true)*16);
this.fetch();
Toolkit.handle(e);
return;
case "PageUp":
this.setViewAddress(this.bus.viewAddress - this.tall(true)*16);
this.fetch();
Toolkit.handle(e);
return;
}
// Processing by key, editing
switch (e.key) {
case "0": case "1": case "2": case "3": case "4":
case "5": case "6": case "7": case "8": case "9":
case "a": case "A": case "b": case "B": case "c":
case "C": case "d": case "D": case "e": case "E":
case "f": case "F":
let digit = parseInt(e.key, 16);
if (this.editDigit === null) {
this.editDigit = digit;
this.setEditAddress(this.bus.editAddress);
} else {
this.editDigit = this.editDigit << 4 | digit;
this.commit();
this.setEditAddress(this.bus.editAddress + 1);
}
break;
// Commit the current edit
case "Enter":
if (this.editDigit === null)
break;
this.commit();
this.setEditAddress(this.bus.editAddress + 1);
break;
// Cancel the current edit
case "Escape":
if (this.editDigit === null)
return;
this.editDigit = null;
this.setEditAddress(this.bus.editAddress);
break;
default: return;
}
Toolkit.handle(e);
}
// Hex editor pointer down
hexPointerDown(e) {
// Error checking
if (e.button != 0)
return;
// Working variables
let cols = this.lines[0].lblBytes.map(l=>l.getBoundingClientRect());
let y = Math.max(0, Math.floor((e.clientY-cols[0].y)/cols[0].height));
let x = 15;
// Determine which column is closest to the touch point
if (e.clientX < cols[15].right) {
for (let l = 0; l < 15; l++) {
if (e.clientX > (cols[l].right + cols[l + 1].x) / 2)
continue;
x = l;
break;
}
}
// Update the selection address
let address = this.toAddress(this.bus.viewAddress + y * 16 + x);
if (this.editDigit !== null && address != this.bus.editAddress)
this.commit();
this.setEditAddress(address);
}
// Hex editor resized
hexResize() {
let tall = this.tall(false);
let grew = this.lines.length < tall;
// Process all visible lines
for (let y = this.lines.length; y < tall; y++) {
let line = {
lblAddress: document.createElement("div"),
lblBytes : []
};
// Address label
line.lblAddress.className = "addr" + (y == 0 ? " first" : "");
this.hexEditor.append(line.lblAddress);
// Byte labels
for (let x = 0; x < 16; x++) {
let lbl = line.lblBytes[x] = document.createElement("div");
lbl.className = "byte b" + x + (y == 0 ? " first" : "");
this.hexEditor.append(lbl);
}
this.lines.push(line);
}
// Remove lines that are no longer visible
while (tall < this.lines.length) {
let line = this.lines[tall];
line.lblAddress.remove();
for (let lbl of line.lblBytes)
lbl.remove();
this.lines.splice(tall, 1);
}
// Configure components
let lineHeight = this.sizer.element.getBoundingClientRect().height;
this.scrHex.hscroll.unitIncrement = lineHeight;
this.hexEditor.element.style.gridAutoRows = lineHeight + "px";
// Update components
if (grew)
this.fetch();
else this.refresh();
}
// Hex editor mouse wheel
hexWheel(e) {
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey)
return;
// Always handle the event
Toolkit.handle(e);
// Determine how many full lines were scrolled
let scr = Debugger.linesScrolled(e,
this.sizer.element.getBoundingClientRect().height,
this.tall(true),
this.delta
);
this.delta = scr.delta;
scr.lines = Math.max(-3, Math.min(3, scr.lines));
// No lines were scrolled
if (scr.lines == 0)
return;
// Scroll the view
this.setViewAddress(this.bus.viewAddress + scr.lines * 16);
this.fetch();
}
// Window key press
onKeyDown(e) {
super.onKeyDown(e);
// Error checking
if (e.altKey || !e.ctrlKey || e.shiftKey)
return;
// Processing by key
switch (e.key) {
case "g": case "G": this.goto(); break;
default: return;
}
Toolkit.handle(e);
}
// Window visibility
onVisibility(e) {
this.shown = this.shown || e.visible;
if (!e.visible)
this.debug.core.unsubscribe(this.subscription, false);
else this.fetch();
}
///////////////////////////// Package Methods /////////////////////////////
// Disassembler configuration has changed
dasmConfigured() {
this.refresh();
}
// Prompt the user to navigate to a new editing address
goto() {
// Retrieve the value from the user
let addr = prompt(this.app.localize("{debug.memory.goto}"));
if (addr === null)
return;
addr = parseInt(addr.trim(), 16);
if (
!Number.isInteger(addr) ||
addr < 0 ||
addr > 4294967295
) return;
// Commit an outstanding edit
if (this.editDigit !== null && this.bus.editAddress != addr)
this.commit();
// Navigate to the given address
this.hexEditor.focus();
this.setEditAddress(addr, 1/3);
}
///////////////////////////// Private Methods /////////////////////////////
// Write the edited value to the simulation state
commit(refresh = true) {
// Error checking
if (this.editDigit === null)
return;
// The edited value is in the bus's data buffer
if (this.data != null) {
let offset = this.toAddress(this.bus.editAddress-this.dataAddress);
if (offset < this.data.length)
this.data[offset] = this.editDigit;
}
// Write one byte to the simulation state
let data = new Uint8Array(1);
data[0] = this.editDigit;
this.editDigit = null;
this.debug.core.write(this.debug.sim, this.bus.editAddress,
data, { refresh: refresh });
}
// Retrieve data from the simulation state
async fetch() {
// Select the parameters for the simulation fetch
let params = {
address: this.toAddress(this.bus.viewAddress - 10 * 16),
length : (this.tall(false) + 20) * 16
};
// A communication with the core thread is already underway
if (this.pending) {
this.pending = params;
this.refresh();
return;
}
// Retrieve data from the simulation state
this.pending = params;
for (let data=null, promise=null; this.pending instanceof Object;) {
// Wait for a transaction to complete
if (promise != null) {
this.pending = true;
data = await promise;
promise = null;
}
// Initiate a new transaction
if (this.pending instanceof Object) {
params = this.pending;
let options = {};
if (this.isVisible())
options.subscription = this.subscription;
promise = this.debug.core.read(this.debug.sim,
params.address, params.length, options);
}
// Process the result of a transaction
if (data != null) {
this.refresh(data);
data = null;
}
};
this.pending = false;
}
// Update hex editor
refresh(msg = null) {
// Receiving data from the simulation state
if (msg != null) {
this.data = msg.data;
this.dataAddress = msg.address;
}
// Process all lines
for (let y = 0; y < this.lines.length; y++) {
let address = this.toAddress(this.bus.viewAddress + y * 16);
let line = this.lines[y];
// Address label
line.lblAddress.innerText = this.debug.hex(address, 8, false);
// Process all bytes
for (let x = 0; x < 16; x++) {
let label = line.lblBytes[x];
let text = "--";
// Currently editing this byte
if (address+x==this.bus.editAddress && this.editDigit!==null) {
text = this.debug.hex(this.editDigit, 1, false);
}
// Bus data exists
else if (this.data != null) {
let offset = this.toAddress(address-this.dataAddress+x);
// The byte is contained in the bus data buffer
if (offset >= 0 && offset < this.data.length)
text = this.debug.hex(this.data[offset], 2, false);
}
label.innerText = text;
label.classList[address + x == this.bus.editAddress ?
"add" : "remove"]("edit");
}
}
}
// Specify the address of the hex editor's selection
setEditAddress(address, auto = false) {
let col = this.lines[0].lblBytes[address&15].getBoundingClientRect();
let port = this.scrHex.viewport.element.getBoundingClientRect();
let row = this.toAddress(address & ~15);
let scr = this.scrHex.scrollLeft;
let tall = this.tall(true, 0);
// Ensure the data row is fully visible
if (this.toAddress(row - this.bus.viewAddress) >= tall * 16) {
if (!auto) {
this.setViewAddress(
this.toAddress(this.bus.viewAddress - row) <=
this.toAddress(row - (this.bus.viewAddress + tall * 16))
? row : this.toAddress(row - (tall - 1) * 16));
} else this.setViewAddress(row - Math.floor(tall * auto) * 16);
this.fetch();
}
// Ensure the column is fully visible
this.scrHex.scrollLeft =
Math.min(
Math.max(
scr,
scr + col.right - port.right
),
scr - port.x + col.x
)
;
// Refresh the display;
this.bus.editAddress = this.toAddress(address);
this.refresh();
}
// Specify the address of the hex editor's view
setViewAddress(address) {
this.bus.viewAddress = this.toAddress(address);
}
// Measure the number of lines visible in the view
tall(fully = null, plus = 1) {
return Math.max(1, Math[fully===null ? "abs" : fully?"floor":"ceil"](
this.scrHex.viewport.element.getBoundingClientRect().height /
this.sizer .element.getBoundingClientRect().height
)) + plus;
}
// Ensure an address is in the proper range
toAddress(address) {
return address >>> 0;
}
}
export { register };

77
web/locale/en-US.json Normal file
View File

@ -0,0 +1,77 @@
{
"id" : "en-US",
"name": "English (US)",
"app": {
"title": "Virtual Boy Emulator"
},
"menu._": "Main menu",
"menu.file": {
"_" : "File",
"loadROM" : "Load ROM{#}...",
"loadROMError" : "Error loading ROM file",
"loadROMInvalid": "The selected file is not a Virtual Boy ROM.",
"dualMode" : "Dual mode",
"debugMode" : "Debug mode"
},
"menu.emulation": {
"_" : "Emulation",
"run" : "Run",
"pause" : "Pause",
"reset" : "Reset{#}",
"linkSims": "Link sims"
},
"menu.debug": {
"_" : "Debug{#}",
"backgrounds" : "Backgrounds",
"bgMaps" : "BG maps",
"breakpoints" : "Breakpoints",
"characters" : "Characters",
"console" : "Console",
"cpu" : "CPU",
"frameBuffers": "Frame buffers",
"memory" : "Memory",
"objects" : "Objects",
"palettes" : "Palettes"
},
"menu.theme": {
"_" : "Theme",
"auto" : "Auto",
"dark" : "Dark",
"light" : "Light",
"virtual": "Virtual"
},
"window": {
"close": "Close"
},
"debug.cpu": {
"_" : "CPU{#}",
"disassembler" : "Disassembler",
"float" : "Float",
"format" : "Format",
"goto" : "Enter the address to seek to:",
"hex" : "Hex",
"infinity" : "Infinity",
"programRegisters": "Program registers",
"signed" : "Signed",
"systemRegisters" : "System registers",
"unsigned" : "Unsigned",
"value" : "Value"
},
"debug.memory": {
"_" : "Memory{#}",
"bus" : "Bus",
"busMemory": "Memory",
"goto" : "Enter the address to seek to:",
"hexEditor": "Hex editor"
}
}

1
web/template.html Normal file
View File

@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><link rel="icon"href="data:;base64,iVBORw0KGgo="><title>Virtual Boy Emulator</title></head><body><img style="display:none;"onload="let a=this,b=a.width,c=a.height,d=document.createElement('canvas');a.remove();d.width=b;d.height=c;d=d.getContext('2d');d.drawImage(a,0,0);d=d.getImageData(0,0,b,c).data.filter((e,f)=>!(f&3));new(async()=>{}).constructor(Array.from(d.slice(0,d.indexOf(0))).map(e=>String.fromCharCode(e)).join(''))(d,a)"src=""></body></html>

6
web/theme/check.svg Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 2.6458 2.6458" version="1.1">
<g>
<path style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 1.05832,1.653625 1.3229,-1.3229 v 0.66145 l -1.3229,1.3229 -0.79374,-0.79374 v -0.66145 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 459 B

4
web/theme/check2.svg Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 2.6458 2.6458" version="1.1">
<path style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.13229;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none" d="m 1.05832,1.653625 1.3229,-1.3229 v 0.66145 l -1.3229,1.3229 -0.79374,-0.79374 v -0.66145 z" />
</svg>

After

Width:  |  Height:  |  Size: 484 B

6
web/theme/close.svg Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 2.9104166 2.9104167" version="1.1">
<g>
<path style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 0.52916666,0.52916665 0.396875,0 0.52916664,0.52916665 0.5291667,-0.52916665 0.396875,0 0,0.396875 L 1.8520833,1.4552083 2.38125,1.984375 l 0,0.396875 -0.396875,0 L 1.4552083,1.8520834 0.92604166,2.38125 l -0.396875,0 0,-0.396875 L 1.0583333,1.4552083 0.52916666,0.92604165 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 652 B

6
web/theme/collapse.svg Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 2.9104166 2.9104168">
<g>
<rect style="opacity:1;fill:#000000;stroke-width:0.264583;stroke-linecap:square" width="1.3229167" height="0.26458332" x="0.79375005" y="1.3229167" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 367 B

28
web/theme/dark.css Normal file
View File

@ -0,0 +1,28 @@
:root {
--tk-control : #333333;
--tk-control-active : #555555;
--tk-control-border : #cccccc;
--tk-control-highlight : #444444;
--tk-control-shadow : #9b9b9b;
--tk-control-text : #cccccc;
--tk-desktop : #111111;
--tk-selected : #008542;
--tk-selected-blur : #325342;
--tk-selected-blur-text : #ffffff;
--tk-selected-text : #ffffff;
--tk-splitter-focus : #008542c0;
--tk-window : #222222;
--tk-window-blur-close : #d9aeae;
--tk-window-blur-close-text : #eeeeee;
--tk-window-blur-title : #9fafb9;
--tk-window-blur-title2 : #c0b0a0;
--tk-window-blur-title-text : #444444;
--tk-window-close : #ee9999;
--tk-window-close-focus : #99ee99;
--tk-window-close-focus-text: #333333;
--tk-window-close-text : #ffffff;
--tk-window-text : #cccccc;
--tk-window-title : #80ccff;
--tk-window-title2 : #ffb894;
--tk-window-title-text : #000000;
}

7
web/theme/expand.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 2.9104166 2.9104168">
<g>
<rect style="opacity:1;fill:#000000;stroke-width:0.264583;stroke-linecap:square" width="0.26458332" height="1.3229167" x="1.3229167" y="0.79375005" />
<rect style="opacity:1;fill:#000000;stroke-width:0.264583;stroke-linecap:square" width="1.3229167" height="0.26458332" x="0.79375005" y="1.3229167" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 522 B

6
web/theme/expand2.svg Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 2.9104166 2.9104168" version="1.1">
<rect style="fill:#000000;stroke-width:0.264583;stroke-linecap:square" width="0.26458332" height="1.3229167" x="1.8555599" y="-1.1941016" transform="rotate(60)" />
<rect style="fill:#000000;stroke-width:0.264583;stroke-linecap:square" width="1.3229167" height="0.26458332" x="1.3263932" y="0.40035158" transform="rotate(30)" />
<rect style="fill:#000000;stroke-width:0.264583;stroke-linecap:square" width="0.26458332" height="1.3229167" x="-1.5875" y="0.79374999" transform="scale(-1,1)" />
</svg>

After

Width:  |  Height:  |  Size: 710 B

BIN
web/theme/inconsolata.woff2 Normal file

Binary file not shown.

554
web/theme/kiosk.css Normal file
View File

@ -0,0 +1,554 @@
:root {
--tk-font-dialog : "Roboto", sans-serif;
--tk-font-mono : "Inconsolata SemiExpanded Medium", monospace;
--tk-font-size : 12px;
}
@font-face {
font-family: "Roboto";
src : /**/url("./roboto.woff2") format("woff2");
}
@font-face {
font-family: "Inconsolata SemiExpanded Medium";
src : /**/url("./inconsolata.woff2") format("woff2");
}
body {
background: var(--tk-control);
}
.tk {
box-sizing : border-box;
font-family: var(--tk-font-dialog);
font-size : var(--tk-font-size);
line-height: 1em;
margin : 0;
outline : none; /* User agent focus indicator */
padding : 0;
}
table.tk {
border : none;
border-spacing: 0;
}
.tk.mono {
font-family: var(--tk-font-mono);
}
.tk::selection,
.tk *::selection {
background: var(--tk-selected);
color : var(--tk-selected-text);
}
.tk:not(:focus-within)::selection,
.tk *:not(:focus-within)::selection {
background: var(--tk-selected-blur);
color : var(--tk-selected-blur-text);
}
.tk.display {
background: var(--tk-desktop);
}
.tk.desktop {
background: var(--tk-desktop);
}
/********************************** Button ***********************************/
.tk.button {
align-items : stretch;
display : inline-grid;
grid-template-columns: auto;
justify-content : stretch;
padding : 0 1px 1px 0;
}
.tk.button .label {
align-items : center;
background : var(--tk-control);
border : 1px solid var(--tk-control-border);
box-shadow : 1px 1px 0 var(--tk-control-border);
color : var(--tk-control-text);
display : grid;
grid-template-columns: auto;
justify-content : center;
padding : 2px;
}
.tk.button:focus .label {
background: var(--tk-control-active);
}
.tk.button.pushed {
padding: 1px 0 0 1px;
}
.tk.button.pushed .label {
box-shadow: none;
}
.tk.button[aria-disabled="true"] .label {
color : var(--tk-control-shadow);
border : 1px solid var(--tk-control-shadow);
box-shadow: 1px 1px 0 var(--tk-control-shadow);
}
/********************************* Checkbox **********************************/
.tk.checkbox {
column-gap: 2px;
}
.tk.checkbox .box {
border: 1px solid var(--tk-control-shadow);
color : var(--tk-control-text);
}
.tk.checkbox:focus .box {
background: var(--tk-control-active);
}
.tk.checkbox .box:before {
background : transparent;
content : "";
display : block;
height : 10px;
mask : /**/url("./check.svg") center no-repeat;
-webkit-mask: /**/url("./check.svg") center no-repeat;
width : 10px;
}
.tk.checkbox[aria-checked="true"] .box:before {
background: currentcolor;
}
.tk.checkbox[aria-checked="mixed"] .box:before {
background : currentcolor;
mask : /**/url("./check2.svg") center no-repeat;
-webkit-mask: /**/url("./check2.svg") center no-repeat;
}
.tk.checkbox.pushed .box:before {
background: var(--tk-control-shadow);
}
.tk.checkbox[aria-disabled="true"] .box {
background: var(--tk-control);
color : var(--tk-control-shadow);
}
.tk.checkbox[aria-disabled="true"] .label {
color: var(--tk-control-shadow);
}
/********************************* DropDown **********************************/
.tk.drop-down {
background: var(--tk-window);
border : 1px solid var(--tk-control-shadow);
color : var(--tk-window-text);
padding : 2px;
}
.tk.drop-down:focus {
background: var(--tk-control-active);
}
.tk.drop-down[aria-disabled="true"] {
color: var(--tk-control-shadow);
}
/*********************************** Menus ***********************************/
.tk.menu-bar {
background : var(--tk-control);
border-bottom: 1px solid var(--tk-control-border);
color : var(--tk-control-text);
cursor : default;
padding : 2px;
position : relative;
}
.tk.menu {
background: var(--tk-control);
border : 1px solid var(--tk-control-border);
box-shadow: 1px 1px 0 var(--tk-control-border);
color : var(--tk-control-text);
margin : -1px 0 0 1px;
padding : 2px;
}
.tk.menu-item[aria-disabled="true"] {
color: var(--tk-control-shadow);
}
.tk.menu-item > * {
align-items: center;
border : 1px solid transparent;
column-gap : 4px;
display : flex;
margin : 0 1px 1px 0;
padding : 2px;
user-select: none;
}
.tk.menu-item .icon {
box-sizing: border-box;
height : 1em;
width : 1em;
}
.tk.menu-item .icon:before {
content: "";
display: block;
height : 100%;
width : 100%;
}
.tk.menu-bar > .menu-item .icon,
.tk.menu:not(.icons) > .menu-item .icon {
display: none;
}
.tk.menu-item.checkbox .icon {
border: 1px solid currentcolor;
}
.tk.menu-item.checkbox[aria-checked="true"] .icon:before {
background : currentcolor;
mask : /**/url("./check.svg") center no-repeat;
-webkit-mask: /**/url("./check.svg") center no-repeat;
}
.tk.menu-item .label {
flex-grow: 1;
}
.tk.menu-item:not([aria-expanded="true"],
[aria-disabled="true"], .pushed):hover > *,
.tk.menu-item:not([aria-expanded="true"], .pushed):focus > * {
border : 1px solid var(--tk-control-shadow);
box-shadow: 1px 1px 0 var(--tk-control-shadow);
}
.tk.menu-item:focus > * {
background: var(--tk-control-active);
}
.tk.menu-item.pushed > *,
.tk.menu-item[aria-expanded="true"] > * {
background: var(--tk-control-active);
border : 1px solid var(--tk-control-shadow);
box-shadow: none;
margin : 1px 0 0 1px;
}
.tk.menu > [role="separator"] {
border : solid var(--tk-control-shadow);
border-width: 1px 0 0 0;
margin : 4px 2px;
}
/*********************************** Radio ***********************************/
.tk.radio {
column-gap: 2px;
}
.tk.radio .box {
border : 1px solid var(--tk-control-shadow);
border-radius: 50%;
color : var(--tk-control-text);
margin : 1px;
}
.tk.radio:focus .box {
background: var(--tk-control-active);
}
.tk.radio .box:before {
background : transparent;
border-radius: 50%;
content : "";
display : block;
height : 4px;
margin : 2px;
width : 4px;
}
.tk.radio[aria-checked="true"] .box:before {
background: currentcolor;
}
.tk.radio.pushed .box:before {
background: var(--tk-control-shadow);
}
.tk.radio[aria-disabled="true"] .box {
background: var(--tk-control);
color : var(--tk-control-shadow);
}
.tk.radio[aria-disabled="true"] .label {
color: var(--tk-control-shadow);
}
/********************************* ScrollBar *********************************/
.tk.scroll-bar {
border : 1px solid var(--tk-control-shadow);
box-sizing: border-box;
}
.tk.scroll-bar .unit-less,
.tk.scroll-bar .unit-more {
background: var(--tk-control);
border : 0 solid var(--tk-control-shadow);
color : var(--tk-control-text);
height : 11px;
width : 11px;
}
.tk.scroll-bar[aria-orientation="horizontal"] .unit-less {
border-right-width: 1px;
}
.tk.scroll-bar[aria-orientation="horizontal"] .unit-more {
border-left-width: 1px;
}
.tk.scroll-bar[aria-orientation="vertical"] .unit-less {
border-bottom-width: 1px;
}
.tk.scroll-bar[aria-orientation="vertical"] .unit-more {
border-top-width: 1px;
}
.tk.scroll-bar .unit-less:before,
.tk.scroll-bar .unit-more:before {
background : currentColor;
content : "";
display : block;
height : 100%;
mask : /**/url("./scroll.svg") center no-repeat;
-webkit-mask: /**/url("./scroll.svg") center no-repeat;
width : 100%;
}
.tk.scroll-bar .unit-less.pushed:before,
.tk.scroll-bar .unit-more.pushed:before {
mask-size : 9px;
-webkit-mask-size: 9px;
}
.tk.scroll-bar[aria-orientation="horizontal"] .unit-less:before {
transform: rotate(-90deg);
}
.tk.scroll-bar[aria-orientation="horizontal"] .unit-more:before {
transform: rotate(90deg);
}
.tk.scroll-bar[aria-orientation="vertical"] .unit-more:before {
transform: rotate(180deg);
}
.tk.scroll-bar .track {
background: var(--tk-control-highlight);
}
.tk.scroll-bar .thumb {
background: var(--tk-control);
box-shadow: 0 0 0 1px var(--tk-control-shadow);
}
.tk.scroll-bar .block-less.pushed,
.tk.scroll-bar .block-more.pushed {
background: var(--tk-control-shadow);
opacity : 0.5;
}
.tk.scroll-bar:focus .unit-less,
.tk.scroll-bar:focus .unit-more,
.tk.scroll-bar:focus .thumb {
background: var(--tk-control-active);
}
.tk.scroll-bar[aria-disabled="true"] .unit-less,
.tk.scroll-bar[aria-disabled="true"] .unit-more,
.tk.scroll-bar.unneeded .unit-less,
.tk.scroll-bar.unneeded .unit-more,
.tk.scroll-bar[aria-disabled="true"] .thumb {
color: var(--tk-control-shadow);
}
.tk.scroll-bar.unneeded .thumb {
visibility: hidden;
}
/******************************** ScrollPane *********************************/
.tk.scroll-pane {
border: 1px solid var(--tk-control-shadow);
}
.tk.scroll-pane > .scroll-bar[aria-orientation="horizontal"] {
border-width: 1px 1px 0 0;
}
.tk.scroll-pane:not(.vertical) > .scroll-bar[aria-orientation="horizontal"] {
border-width: 1px 0 0 0;
}
.tk.scroll-pane > .scroll-bar[aria-orientation="vertical"] {
border-width: 0 0 1px 1px;
}
.tk.scroll-pane:not(.horizontal) > .scroll-bar[aria-orientation="vertical"] {
border-width: 0 0 0 1px;
}
.tk.scroll-pane > .viewport,
.tk.scroll-pane > .corner {
background: var(--tk-control);
}
/********************************* SplitPane *********************************/
.tk.split-pane > [role="separator"]:focus {
background: var(--tk-splitter-focus);
}
.tk.split-pane > .horizontal[role="separator"] {
width: 3px;
}
.tk.split-pane > .vertical[role="separator"] {
height: 3px;
}
/********************************** TextBox **********************************/
.tk.text-box {
background : var(--tk-window);
border : 1px solid var(--tk-control-border);
color : var(--tk-window-text);
line-height: 1em;
height : calc(1em + 2px);
padding : 0;
margin : 0;
min-width : 0;
}
.tk.text-box.[aria-disabled="true"] {
background: var(--tk-control-shadow);
color : var(--tk-window-text);
}
/********************************** Windows **********************************/
.tk.window {
background: var(--tk-control);
border : 1px solid var(--tk-control-shadow);
box-shadow: 1px 1px 0 var(--tk-control-shadow);
}
.tk.window:focus-within {
border : 1px solid var(--tk-control-border);
box-shadow: 1px 1px 0 var(--tk-control-border);
}
.tk.window > .nw1 { left : -2px; top : -2px; width : 8px; height: 3px; }
.tk.window > .nw2 { left : -2px; top : 1px; width : 3px; height: 5px; }
.tk.window > .n { left : 6px; top : -2px; right : 6px; height: 3px; }
.tk.window > .ne1 { right: -2px; top : -2px; width : 8px; height: 3px; }
.tk.window > .ne2 { right: -2px; top : 1px; width : 3px; height: 5px; }
.tk.window > .w { left : -2px; top : 6px; bottom: 6px; width : 3px; }
.tk.window > .e { right: -2px; top : 6px; bottom: 6px; width : 3px; }
.tk.window > .sw1 { left : -2px; bottom: -2px; width : 8px; height: 3px; }
.tk.window > .sw2 { left : -2px; bottom: 1px; width : 3px; height: 5px; }
.tk.window > .s { left : 6px; bottom: -2px; right : 6px; height: 3px; }
.tk.window > .se1 { right: -2px; bottom: -2px; width : 8px; height: 3px; }
.tk.window > .se2 { right: -2px; bottom: 1px; width : 3px; height: 5px; }
.tk.window > .title {
align-items : center;
background : var(--tk-window-blur-title);
border-bottom: 1px solid var(--tk-control-shadow);
color : var(--tk-window-blur-title-text);
padding : 1px;
user-select : none;
}
.tk.window:focus-within > .title {
background: var(--tk-window-title);
color : var(--tk-window-title-text);
}
.tk.window.two > .title {
background: var(--tk-window-blur-title2);
}
.tk.window.two:focus-within > .title {
background: var(--tk-window-title2);
}
.tk.window > .title .text {
cursor : default;
font-weight : bold;
overflow : hidden;
text-align : center;
text-overflow: ellipsis;
white-space : nowrap;
}
.tk.window > .title .close-button {
background: var(--tk-window-blur-close);
border : 1px solid var(--tk-control-shadow);
box-sizing: border-box;
color : var(--tk-window-close-text);
height : 13px;
width : 13px;
}
.tk.window:focus-within > .title .close-button {
background: var(--tk-window-close);
}
.tk.window > .title .close-button:focus {
background: var(--tk-window-close-focus);
color : var(--tk-window-close-focus-text);
outline : 1px solid var(--tk-control);
}
.tk.window > .title .close-button:before {
background : currentcolor;
content : "";
display : block;
height : 11px;
mask : /**/url("./close.svg") center no-repeat;
-webkit-mask: /**/url("./close.svg") center no-repeat;
width : 11px;
}
.tk.window > .title .close-button.pushed:before {
mask-size : 9px;
-webkit-mask-size: 9px;
}
.tk.window > .client {
overflow: hidden;
}

28
web/theme/light.css Normal file
View File

@ -0,0 +1,28 @@
:root {
--tk-control : #eeeeee;
--tk-control-active : #cccccc;
--tk-control-border : #000000;
--tk-control-highlight : #f8f8f8;
--tk-control-shadow : #6c6c6c;
--tk-control-text : #000000;
--tk-desktop : #cccccc;
--tk-selected : #008542;
--tk-selected-blur : #5e7d70;
--tk-selected-blur-text : #ffffff;
--tk-selected-text : #ffffff;
--tk-splitter-focus : #008542c0;
--tk-window : #ffffff;
--tk-window-blur-close : #d9aeae;
--tk-window-blur-close-text : #eeeeee;
--tk-window-blur-title : #aac4d5;
--tk-window-blur-title2 : #dbc4b8;
--tk-window-blur-title-text : #444444;
--tk-window-close : #ee9999;
--tk-window-close-focus : #99ee99;
--tk-window-close-focus-text: #333333;
--tk-window-close-text : #ffffff;
--tk-window-text : #000000;
--tk-window-title : #80ccff;
--tk-window-title2 : #ffb894;
--tk-window-title-text : #000000;
}

6
web/theme/radio.svg Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 2.6458332 2.6458332" version="1.1">
<g>
<circle style="opacity:1;fill:#000000;stroke-width:0.264583;stroke-linecap:square" cx="1.3229166" cy="1.3229166" r="0.66145831" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 361 B

BIN
web/theme/roboto.woff2 Normal file

Binary file not shown.

6
web/theme/scroll.svg Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 2.9104166 2.9104167" version="1.1">
<g>
<path style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="M 1.4552083,0.66145833 0.52916666,1.5874999 V 2.2489583 L 1.4552083,1.3229166 2.38125,2.2489583 V 1.5874999 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 484 B

194
web/theme/vbemu.css Normal file
View File

@ -0,0 +1,194 @@
/******************************** CPU Window *********************************/
.tk.window.cpu .client {
padding: 1px;
}
.tk.window.cpu .scr-dasm {
border-right-width: 0;
box-shadow : 1px 0 0 var(--tk-control-shadow);
}
.tk.window.cpu .scr-system {
border-width: 1px 1px 0 0;
box-shadow : -0.5px 0.5px 0 0.5px var(--tk-control-shadow);
}
.tk.window.cpu .scr-program {
border-width: 0 1px 1px 0;
box-shadow : -0.5px -0.5px 0 0.5px var(--tk-control-shadow);
}
.tk.window.cpu .disassembler {
background : var(--tk-window);
color : var(--tk-window-text);
}
.tk.window.cpu .disassembler div {
cursor : default;
line-height: calc(1em + 2px);
isolation : isolate;
user-select: none;
}
.tk.window.cpu .disassembler .address {
margin-left: 2px;
}
.tk.window.cpu .disassembler .byte {
margin-left: 0.5em;
text-align : center;
}
.tk.window.cpu .disassembler .operands {
margin-right: 2px;
}
.tk.window.cpu .disassembler .byte.b0,
.tk.window.cpu .disassembler .mnemonic,
.tk.window.cpu .disassembler .operands {
margin-left: 1em;
}
.tk.window.cpu .disassembler .pc {
background: var(--tk-selected-blur);
left : 1px;
right : 1px;
}
.tk.window.cpu .disassembler:focus-within .pc {
background: var(--tk-selected);
}
.tk.window.cpu .disassembler .is-pc {
color: var(--tk-selected-blur-text);
}
.tk.window.cpu .disassembler:focus-within .is-pc {
color: var(--tk-selected-text);
}
.tk.window.cpu .registers {
background: var(--tk-window);
color : var(--tk-window-text);
}
.tk.window.cpu .registers > * {
padding: 0 1px;
}
.tk.window.cpu .registers > *:first-child {
padding-top: 1px;
}
.tk.window.cpu .registers > *:last-child {
padding-bottom: 1px;
}
.tk.window.cpu .registers .expand .box {
border : none;
border-radius: 2px;
margin : 0 1px 0 0;
}
.tk.window.cpu .registers .expand .box:before {
background: transparent;
content : "";
display : block;
height : 11px;
width : 11px;
}
.tk.window.cpu .registers .expand[role="checkbox"] .box:before {
background : currentcolor;
mask : /**/url("./expand.svg") center no-repeat;
-webkit-mask: /**/url("./expand.svg") center no-repeat;
}
.tk.window.cpu .registers .expand[aria-checked="true"] .box:before {
background : currentcolor;
mask : /**/url("./collapse.svg") center no-repeat;
-webkit-mask: /**/url("./collapse.svg") center no-repeat;
}
.tk.window.cpu .registers .expand:focus .box {
background: var(--tk-control-active);
}
.tk.window.cpu .registers .main {
column-gap: 0.5em;
}
.tk.window.cpu .registers .expansion {
gap : 1px 1em;
padding: 2px 2px 2px 1.4em;
}
.tk.window.cpu .registers .main {
column-gap: 0.5em;
}
.tk.window.cpu .registers .text-box {
background: transparent;
border : none;
padding : 0 1px;
}
.tk.window.cpu .registers .text-box:focus {
outline: 1px solid var(--tk-selected);
}
.tk.window.cpu .registers .text-dec {
column-gap: 2px;
}
.tk.window.cpu .registers .text-dec .label {
text-align: center;
min-width : 13px;
}
.tk.window.cpu .registers *[aria-disabled="true"]:is(.label, .text-box) {
color: var(--tk-control-shadow);
}
/******************************* Memory Window *******************************/
.tk.window.memory .client {
gap : 1px;
padding: 1px;
}
.tk.window.memory .hex-editor {
align-items: center;
background : var(--tk-window);
color : var(--tk-window-text);
}
.tk.window.memory .hex-editor div {
cursor : default;
line-height: calc(1em + 2px);
user-select: none;
}
.tk.window.memory .hex-editor .addr {
margin-left: 2px;
}
.tk.window.memory .hex-editor .byte {
margin-left: 0.5em;
text-align : center;
}
.tk.window.memory .hex-editor .b0,
.tk.window.memory .hex-editor .b8 {
margin-left: 1em;
}
.tk.window.memory .hex-editor .b15 {
margin-right: 2px;
}
.tk.window.memory .hex-editor .edit {
background: var(--tk-selected-blur);
color : var(--tk-selected-blur-text);
outline : 1px solid var(--tk-selected-blur);
}
.tk.window.memory .hex-editor:focus-within .edit {
background: var(--tk-selected);
color : var(--tk-selected-text);
outline : 1px solid var(--tk-selected);
}

63
web/theme/virtual.css Normal file
View File

@ -0,0 +1,63 @@
:root {
--tk-control : #000000;
--tk-control-active : #550000;
--tk-control-border : #ff0000;
--tk-control-highlight : #550000;
--tk-control-shadow : #aa0000;
--tk-control-text : #ff0000;
--tk-desktop : #000000;
--tk-selected : #aa0000;
--tk-selected-blur : #550000;
--tk-selected-blur-text : #ff0000;
--tk-selected-text : #000000;
--tk-splitter-focus : #ff0000aa;
--tk-window : #000000;
--tk-window-blur-close : #000000;
--tk-window-blur-close-text : #aa0000;
--tk-window-blur-title : #000000;
--tk-window-blur-title2 : #000000;
--tk-window-blur-title-text : #aa0000;
--tk-window-close : #550000;
--tk-window-close-focus : #ff0000;
--tk-window-close-focus-text: #550000;
--tk-window-close-text : #ff0000;
--tk-window-text : #ff0000;
--tk-window-title : #550000;
--tk-window-title2 : #550000;
--tk-window-title-text : #ff0000;
}
input, select {
filter: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxmaWx0ZXIgaWQ9InYiPjxmZUNvbG9yTWF0cml4IGluPSJTb3VyY2VHcmFwaGljIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMSAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMSAwIiAvPjwvZmlsdGVyPjwvc3ZnPg==#v");
}
.tk.scroll-bar .unit-less,
.tk.scroll-bar .unit-more,
.tk.scroll-bar .thumb {
background: #550000;
}
.tk.scroll-bar .track {
background: #000000;
}
.tk.scroll-bar:focus,
.tk.scroll-bar:focus .unit-less,
.tk.scroll-bar:focus .unit-more,
.tk.scroll-bar:focus .thumb {
background : #aa0000;
border-color: #ff0000;
color : #000000;
}
.tk.scroll-bar:focus .track {
background: #550000;
}
.tk.scroll-bar:focus .thumb {
box-shadow: 0 0 0 1px #ff0000;
}
.tk.window {
box-shadow: 1px 1px 0 #550000;
}
.tk.window:focus-within {
box-shadow: 1px 1px 0 #aa0000;
}

249
web/toolkit/App.js Normal file
View File

@ -0,0 +1,249 @@
let register = Toolkit => Toolkit.App =
// Root application container and localization manager
class App extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(options) {
super(null, Object.assign({
tabIndex: -1
}, options));
// Configure instance fields
this.components = new Set();
this.dragElement = null;
this.lastFocus = null;
this.locale = null;
this.locales = new Map();
// Configure event handlers
this.addEventListener("focusin", e=>this.onFocus(e));
this.addEventListener("keydown", e=>this.onKey (e));
this.addEventListener("keyup" , e=>this.onKey (e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Child element focus gained
onFocus(e) {
// Error checking
if (e.target != document.activeElement)
return;
// Target is self
if (e.target == this.element)
return this.restoreFocus();
// Ensure the child is not contained in a MenuBar
for (let elm = e.target; elm != this.element; elm = elm.parentNode) {
if (elm.getAttribute("role") == "menubar")
return;
}
// Track the (non-menu) element as the most recent focused component
this.lastFocus = e.target;
}
// Key press, key release
onKey(e) {
if (this.dragElement == null || e.rerouted)
return;
this.dragElement.dispatchEvent(Object.assign(new Event(e.type), {
altKey : e.altKey,
ctrlKey : e.ctrlKey,
key : e.key,
rerouted: true,
shiftKey: e.shiftKey
}));
}
///////////////////////////// Public Methods //////////////////////////////
// Install a locale from URL
async addLocale(url) {
let data;
// Load the file as JSON, using UTF-8 with or without a BOM
try { data = JSON.parse(new TextDecoder().decode(
await (await fetch(url)).arrayBuffer() )); }
catch { return null; }
// Error checking
if (!data.id || !data.name)
return null;
// Flatten the object to keys
let locale = new Map();
let entries = Object.entries(data);
let stack = [];
while (entries.length != 0) {
let entry = entries.shift();
// The value is a non-array object
if (entry[1] instanceof Object && !Array.isArray(entry[1])) {
entries = entries.concat(Object.entries(entry[1])
.map(e=>[ entry[0] + "." + e[0], e[1] ]));
}
// The value is a primitive or array
else locale.set(entry[0].toLowerCase(), entry[1]);
}
this.locales.set(data.id, locale);
return data.id;
}
// Specify a localization dictionary
setLocale(id) {
if (!this.locales.has(id))
return false;
this.locale = this.locales.get(id);
for (let comp of this.components)
comp.localize();
}
///////////////////////////// Package Methods /////////////////////////////
// Begin dragging on an element
get drag() { return this.dragElement; }
set drag(event) {
// Begin dragging
if (event) {
this.dragElement = event.currentTarget;
this.dragPointer = event.pointerId;
this.dragElement.setPointerCapture(event.pointerId);
}
// End dragging
else {
if (this.dragElement)
this.dragElement.releasePointerCapture(this.dragPointer);
this.dragElement = null;
this.dragPointer = null;
}
}
// Configure components for automatic localization, or localize a message
localize(a, b) {
return a instanceof Object ? this.localizeComponents(a, b) :
this.localizeMessage(a, b);
}
// Return focus to the most recent focused element
restoreFocus() {
// Error checking
if (!this.lastFocus)
return false;
// Unable to restore focus
if (!this.isVisible(this.lastFocus))
return false;
// Transfer focus to the most recent element
this.lastFocus.focus({ preventScroll: true });
return true;
}
///////////////////////////// Private Methods /////////////////////////////
// Configure components for automatic localization
localizeComponents(comps, add) {
// Process all components
for (let comp of (Array.isArray(comps) ? comps : [comps])) {
// Error checking
if (
!(comp instanceof Toolkit.Component) ||
!(comp.localize instanceof Function)
) continue;
// Update the collection and component text
this.components[add ? "add" : "delete"](comp);
comp.localize();
}
}
// Localize a message
localizeMessage(message, substs, circle = new Set()) {
let parts = [];
// Separate the substitution keys from the literal text
for (let x = 0;;) {
// Locate the start of the next substitution key
let y = message.indexOf("{", x);
let z = y == -1 ? -1 : message.indexOf("}", y + 1);
// No substitution key or malformed substitution expression
if (z == -1) {
parts.push(message.substring(z == -1 ? x : y));
break;
}
// Append the literal text and the substitution key
parts.push(message.substring(x, y), message.substring(y + 1, z));
x = z + 1;
}
// Process all substitutions
for (let x = 1; x < parts.length; x += 2) {
let key = parts[x].toLowerCase();
let value;
// The substitution key is already in the recursion chain
if (circle.has(key)) {
parts[x] = "{\u21ba" + key.toUpperCase() + "}";
continue;
}
// Resolve the substitution key from the argument
if (substs && substs.has(key)) {
value = substs.get(key);
// Do not recurse for this substitution
if (!value[1]) {
parts[x] = value[0];
continue;
}
// Substitution text
value = value[0];
}
// Resolve the substitution from the current locale
else if (this.locale && this.locale.has(key))
value = this.locale.get(key);
// A matching substitution key was not found
else {
parts[x] = "{\u00d7" + key.toUpperCase() + "}";
continue;
}
// Perform recursive substitution
circle.add(key);
parts[x] = this.localizeMessage(value, substs, circle);
circle.delete(key);
}
return parts.join("");
}
};
export { register };

119
web/toolkit/Button.js Normal file
View File

@ -0,0 +1,119 @@
let register = Toolkit => Toolkit.Button =
// Push button
class Button extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk button",
role : "button",
tabIndex: "0"
}, options));
// Configure options
if ("disabled" in options)
this.disabled = options.disabled;
this.doNotFocus = !("doNotFocus" in options) || options.doNotFocus;
// Display text
this.content = new Toolkit.Label(app);
this.add(this.content);
// Event handlers
this.addEventListener("keydown" , e=>this.onKeyDown (e));
this.addEventListener("pointerdown", e=>this.onPointerDown(e));
this.addEventListener("pointermove", e=>this.onPointerMove(e));
this.addEventListener("pointerup" , e=>this.onPointerUp (e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
if (
!(e.altKey || e.ctrlKey || e.shiftKey || this.disabled) &&
(e.key == " " || e.key == "Enter")
) this.activate();
}
// Pointer down
onPointerDown(e) {
// Gain focus
if (
!this.doNotFocus &&
this.isFocusable() &&
this.element != document.activeElement
) this.element.focus();
else e.preventDefault();
// Do not drag
if (
e.button != 0 ||
this.disabled ||
this.element.hasPointerCapture(e.pointerId)
) return;
// Begin dragging
this.element.setPointerCapture(e.pointerId);
this.element.classList.add("pushed");
Toolkit.handle(e);
}
// Pointer move
onPointerMove(e) {
// Do not drag
if (!this.element.hasPointerCapture(e.pointerId))
return;
// Process dragging
this.element.classList[this.isWithin(e) ? "add" : "remove"]("pushed");
Toolkit.handle(e);
}
// Pointer up
onPointerUp(e) {
// Do not activate
if (e.button != 0 || !this.element.hasPointerCapture(e.pointerId))
return;
// End dragging
this.element.releasePointerCapture(e.pointerId);
this.element.classList.remove("pushed");
Toolkit.handle(e);
// Activate the button if applicable
if (this.isWithin(e))
this.activate();
}
///////////////////////////// Public Methods //////////////////////////////
// Simulate a click on the button
activate() {
if (!this.disabled)
this.element.dispatchEvent(new Event("action"));
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
this.localizeText(this.content);
this.localizeLabel();
this.localizeTitle();
}
}
export { register };

157
web/toolkit/Checkbox.js Normal file
View File

@ -0,0 +1,157 @@
let register = Toolkit => Toolkit.Checkbox =
// Check box
class Checkbox extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk checkbox",
role : "checkbox",
tabIndex: "0"
}, options, { style: Object.assign({
alignItems : "center",
display : "inline-grid",
gridTemplateColumns: "max-content auto"
}, options.style || {}) }));
// Configure element
this.element.setAttribute("aria-checked", "false");
this.addEventListener("keydown" , e=>this.onKeyDown (e));
this.addEventListener("pointerdown", e=>this.onPointerDown(e));
this.addEventListener("pointermove", e=>this.onPointerMove(e));
this.addEventListener("pointerup" , e=>this.onPointerUp (e));
// Icon area
this.box = document.createElement("div");
this.box.className = "tk box";
this.append(this.box);
// Display text
this.uiLabel = new Toolkit.Label(app);
this.add(this.uiLabel);
// Configure options
this.checked = options.checked;
this.disabled = !!options.disabled;
this.instant = !!options.instant;
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
if (
!(e.altKey || e.ctrlKey || e.shiftKey || this.disabled) &&
(e.key == " " || e.key == "Enter")
) this.setChecked(!this.checked);
}
// Pointer down
onPointerDown(e) {
// Gain focus
if (!this.disabled)
this.element.focus();
else e.preventDefault();
// Do not drag
if (
e.button != 0 ||
this.disabled ||
this.element.hasPointerCapture(e.pointerId)
) return;
// Begin dragging
this.element.setPointerCapture(e.pointerId);
// Use instant activation
if (this.instant)
return this.onPointerUp(e);
// Do not use instant activation
this.element.classList.add("pushed");
Toolkit.handle(e);
}
// Pointer move
onPointerMove(e) {
// Do not drag
if (!this.element.hasPointerCapture(e.pointerId))
return;
// Process dragging
this.element.classList[this.isWithin(e) ? "add" : "remove"]("pushed");
Toolkit.handle(e);
}
// Pointer up
onPointerUp(e) {
// Do not activate
if (e.button != 0 || !this.element.hasPointerCapture(e.pointerId))
return;
// End dragging
this.element.releasePointerCapture(e.pointerId);
this.element.classList.remove("pushed");
Toolkit.handle(e);
// Activate the check box if applicable
if (this.isWithin(e))
this.setChecked(this.checked !== true);
}
///////////////////////////// Public Methods //////////////////////////////
// The check box is checked
get checked() {
let ret = this.element.getAttribute("aria-checked");
return ret == "mixed" ? ret : ret == "true";
}
set checked(checked) {
checked = checked == "mixed" ? checked : !!checked;
if (checked == this.checked)
return;
this.element.setAttribute("aria-checked", checked);
}
// Specify the display text
setText(text, localize) {
this.uiLabel.setText(text, localize);
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
this.uiLabel.localize();
this.localizeTitle();
}
///////////////////////////// Private Methods /////////////////////////////
// Specify the checked state
setChecked(checked) {
checked = !!checked;
if (checked == this.checked)
return;
let previous = this.checked
this.checked = checked;
this.element.dispatchEvent(
Object.assign(new Event("input"), { previous: previous }));
}
}
export { register };

473
web/toolkit/Component.js Normal file
View File

@ -0,0 +1,473 @@
let register = Toolkit => Toolkit.Component =
// Base class from which all toolkit components are derived
class Component {
//////////////////////////////// Constants ////////////////////////////////
// Non-attributes
static NON_ATTRIBUTES = new Set([
"checked", "disabled", "doNotFocus", "group", "hover", "max", "min",
"name", "orientation", "overflowX", "overflowY", "tag", "text",
"value", "view", "visibility", "visible"
]);
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
// Configure element
this.element = document.createElement(options.tag || "div");
this.element.component = this;
for (let entry of Object.entries(options)) {
if (
Toolkit.Component.NON_ATTRIBUTES.has(entry[0]) ||
entry[0] == "type" && options.tag != "input"
) continue;
if (entry[0] == "style" && entry[1] instanceof Object)
Object.assign(this.element.style, entry[1]);
else this.element.setAttribute(entry[0], entry[1]);
}
// Configure instance fields
this._isLocalized = false;
this.app = app;
this.display = options.style && options.style.display;
this.style = this.element.style;
this.text = null;
this.visibility = !!options.visibility;
this.visible = !("visible" in options) || options.visible;
}
///////////////////////////// Public Methods //////////////////////////////
// Add a child component
add(comp) {
// Error checking
if (
!(comp instanceof Toolkit.Component) ||
comp instanceof Toolkit.App ||
comp.app != (this.app || this)
) return false;
// No components have been added yet
if (!this.children)
this.children = [];
// The child already has a parent: remove it
if (comp.parent) {
comp.parent.children.splice(
comp.parent.children.indexOf(comp), 1);
}
// Add the component to self
this.children.push(comp);
this.append(comp.element);
comp.parent = this;
return true;
}
// Register an event listener on the element
addEventListener(type, listener) {
// No event listeners have been registered yet
if (!this.listeners)
this.listeners = new Map();
if (!this.listeners.has(type))
this.listeners.set(type, []);
// The listener has already been registered for this event
let listeners = this.listeners.get(type);
if (listeners.indexOf(listener) != -1)
return listener;
// Resize events are implemented by a ResizeObserver
if (type == "resize") {
if (!this.resizeObserver) {
this.resizeObserver = new ResizeObserver(()=>
this.element.dispatchEvent(new Event("resize")));
this.resizeObserver.observe(this.element);
}
}
// Visibility events are implemented by an IntersectionObserver
else if (type == "visibility") {
if (!this.visibilityObserver) {
this.visibilityObserver = new IntersectionObserver(
()=>this.element.dispatchEvent(Object.assign(
new Event("visibility"),
{ visible: this.isVisible() }
)),
{ root: document.body }
);
this.visibilityObserver.observe(this.element);
}
}
// Register the listener with the element
listeners.push(listener);
this.element.addEventListener(type, listener);
return listener;
}
// Component cannot be interacted with
get disabled() { return this.element.hasAttribute("disabled"); }
set disabled(disabled) { this.setDisabled(disabled); }
// Move focus into the component
focus() {
this.element.focus({ preventScroll: true });
}
// Specify whether the component is localized
get isLocalized() { return this._isLocalized; }
set isLocalized(isLocalized) {
if (isLocalized == this._isLocalized)
return;
this._isLocalized = isLocalized;
(this instanceof Toolkit.App ? this : this.app)
.localize(this, isLocalized);
}
// Determine whether an element is actually visible
isVisible(element = this.element) {
if (!document.body.contains(element))
return false;
for (; element instanceof Element; element = element.parentNode) {
let style = getComputedStyle(element);
if (style.display == "none" || style.visibility == "hidden")
return false;
}
return true;
}
// Produce an ordered list of registered event listeners for an event type
listEventListeners(type) {
return this.listeners && this.listeners.has(type) &&
this.listeners.get(type).list.slice() || [];
}
// Remove a child component
remove(comp) {
if (comp.parent != this || !this.children)
return false;
let index = this.children.indexOf(comp);
if (index == -1)
return false;
this.children.splice(index, 1);
comp.element.remove();
comp.parent = null;
return true;
}
// Unregister an event listener from the element
removeEventListener(type, listener) {
// Not listening to events of the specified type
if (!this.listeners || !this.listeners.has(type))
return listener;
// Listener is not registered
let listeners = this.listeners.get(type);
let index = listeners.indexOf(listener);
if (index == -1)
return listener;
// Unregister the listener
this.element.removeEventListener(listener);
listeners.splice(index, 1);
// Delete the ResizeObserver
if (
type == "resize" &&
listeners.list.length == 0 &&
this.resizeObserver
) {
this.resizeObserver.disconnect();
delete this.resizeObserver;
}
// Delete the IntersectionObserver
else if (
type == "visibility" &&
listeners.list.length == 0 &&
this.visibilityObserver
) {
this.visibilityObserver.disconnect();
delete this.visibilityObserver;
}
return listener;
}
// Specify accessible name
setLabel(text, localize) {
// Label is another component
if (
text instanceof Toolkit.Component ||
text instanceof HTMLElement
) {
this.element.setAttribute("aria-labelledby",
(text.element || text).id);
this.setString("label", null, false);
}
// Label is the given text
else {
this.element.removeAttribute("aria-labelledby");
this.setString("label", text, localize);
}
}
// Specify role description text
setRoleDescription(text, localize) {
this.setString("roleDescription", text, localize);
}
// Specify inner text
setText(text, localize) {
this.setString("text", text, localize);
}
// Specify tooltip text
setTitle(text, localize) {
this.setString("title", text, localize);
}
// Specify substitution text
substitute(key, text = null, recurse = false) {
if (text === null) {
if (this.substitutions.has(key))
this.substitutions.delete(key);
} else this.substitutions.set(key, [ text, recurse ]);
if (this.localize instanceof Function)
this.localize();
}
// Determine whether the element wants to be visible
get visible() {
let style = this.element.style;
return style.display != "none" && style.visibility != "hidden";
}
// Specify whether the element is visible
set visible(visible) {
visible = !!visible;
// Visibility is not changing
if (visible == this.visible)
return;
let comps = [ this ].concat(
Array.from(this.element.querySelectorAll("*"))
.map(c=>c.component)
).filter(c=>
c instanceof Toolkit.Component &&
c.listeners &&
c.listeners.has("visibility")
)
;
let prevs = comps.map(c=>c.isVisible());
// Allow the component to be shown
if (visible) {
if (!this.visibility) {
if (this.display)
this.element.style.display = this.display;
else this.element.style.removeProperty("display");
} else this.element.style.removeProperty("visibility");
}
// Prevent the component from being shown
else {
this.element.style.setProperty(
this.visibility ? "visibility" : "display",
this.visibility ? "hidden" : "none"
);
}
for (let x = 0; x < comps.length; x++) {
let comp = comps[x];
visible = comp.isVisible();
if (visible == prevs[x])
continue;
comp.element.dispatchEvent(Object.assign(
new Event("visibility"),
{ visible: visible }
));
}
}
///////////////////////////// Package Methods /////////////////////////////
// Add a child component to the primary client region of this component
append(element) {
this.element.append(element instanceof Toolkit.Component ?
element.element : element);
}
// Determine whether a component or element is a child of this component
contains(child) {
return this.element.contains(child instanceof Toolkit.Component ?
child.element : child);
}
// Generate a list of focusable descendant elements
getFocusable(element = this.element) {
let cache;
return Array.from(element.querySelectorAll(
"*:is(a[href],area,button,details,input,textarea,select," +
"[tabindex='0']):not([disabled])"
)).filter(e=>{
for (; e instanceof Element; e = e.parentNode) {
let style =
(cache || (cache = new Map())).get(e) ||
cache.set(e, getComputedStyle(e)).get(e)
;
if (style.display == "none" || style.visibility == "hidden")
return false;
}
return true;
});
}
// Specify the inner text of the primary client region of this component
get innerText() { return this.element.textContent; }
set innerText(text) { this.element.innerText = text; }
// Determine whether an element is focusable
isFocusable(element = this.element) {
return element.matches(
":is(a[href],area,button,details,input,textarea,select," +
"[tabindex='0'],[tabindex='-1']):not([disabled])"
);
}
// Determine whether a pointer event is within the element
isWithin(e, element = this.element) {
let bounds = element.getBoundingClientRect();
return (
e.clientX >= bounds.left && e.clientX < bounds.right &&
e.clientY >= bounds.top && e.clientY < bounds.bottom
);
}
// Common processing for localizing the accessible name
localizeLabel(element = this.element) {
// There is no label or the label is another element
if (!this.label || element.hasAttribute("aria-labelledby")) {
element.removeAttribute("aria-label");
return;
}
// Localize the label
let text = this.label;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.setAttribute("aria-label", text);
}
// Common processing for localizing the accessible role description
localizeRoleDescription(element = this.element) {
// There is no role description
if (!this.roleDescription) {
element.removeAttribute("aria-roledescription");
return;
}
// Localize the role description
let text = this.roleDescription;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.setAttribute("aria-roledescription", text);
}
// Common processing for localizing inner text
localizeText(element = this.element) {
// There is no title
if (!this.text) {
element.innerText = "";
return;
}
// Localize the text
let text = this.text;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.innerText = text;
}
// Common processing for localizing the tooltip text
localizeTitle(element = this.element) {
// There is no title
if (!this.title) {
element.removeAttribute("title");
return;
}
// Localize the title
let text = this.title;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.setAttribute("title", text);
}
// Common handler for configuring whether the component is disabled
setDisabled(disabled, element = this.element) {
element[disabled ? "setAttribute" : "removeAttribute"]
("disabled", "");
element.setAttribute("aria-disabled", disabled ? "true" : "false");
}
// Specify display text
setString(key, value, localize = true) {
// There is no method to update the display text
if (!(this.localize instanceof Function))
return;
// Working variables
let app = this instanceof Toolkit.App ? this : this.app;
// Remove the string
if (value === null) {
if (app && this[key] != null && this[key][1])
app.localize(this, false);
this[key] = null;
}
// Set or replace the string
else {
if (app && localize && (this[key] == null || !this[key][1]))
app.localize(this, true);
this[key] = [ value, localize ];
}
// Update the display text
this.localize();
}
// Retrieve the substitutions map
get substitutions() {
if (!this._substitutions)
this._substitutions = new Map();
return this._substitutions;
}
};
export { register };

86
web/toolkit/Desktop.js Normal file
View File

@ -0,0 +1,86 @@
let register = Toolkit => Toolkit.Desktop =
// Layered window manager
class Desktop extends Toolkit.Component {
//////////////////////////////// Constants ////////////////////////////////
// Comparator for ordering child windows
static CHILD_ORDER(a, b) {
return b.element.style.zIndex - a.element.style.zIndex;
}
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
class: "tk desktop"
}, options, { style: Object.assign({
position: "relative",
zIndex : "0"
}, options.style || {})} ));
// Configure event listeners
this.addEventListener("resize", e=>this.onResize());
}
///////////////////////////// Event Handlers //////////////////////////////
// Element resized
onResize() {
// The element is hidden: its size is indeterminate
if (!this.isVisible())
return;
// Don't allow children to be out-of-frame
if (this.children != null) {
for (let child of this.children) {
child.left = child.left;
child.top = child.top;
}
}
}
///////////////////////////// Public Methods //////////////////////////////
// Add a child component
add(comp) {
super.add(comp);
this.bringToFront(comp);
}
// Retrieve the foremost visible window
getActiveWindow() {
if (this.children != null) {
for (let child of this.children) {
if (child.isVisible())
return child;
}
}
return null;
}
///////////////////////////// Package Methods /////////////////////////////
// Reorder children so that a particular child is in front
bringToFront(child) {
this.children.splice(this.children.indexOf(child), 1);
this.children.push(child);
let z = 1 - this.children.length;
for (let child of this.children)
child.element.style.zIndex = z++;
}
}
export { register };

154
web/toolkit/DropDown.js Normal file
View File

@ -0,0 +1,154 @@
let register = Toolkit => Toolkit.DropDown =
class DropDown extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
class: "tk drop-down",
tag : "select"
}, options));
// Configure instance fields
this.items = [];
}
///////////////////////////// Public Methods //////////////////////////////
// Add an item
add(text, localize, value) {
// Record the item data
this.items.push([ text, localize, value ]);
// Add an <option> element
let option = document.createElement("option");
this.element.append(option);
option.innerText = !localize ? text :
this.app.localize(text, this.substitutions);
}
// Remove all items
clear() {
this.items.splice(0);
this.element.replaceChildren();
this.element.selectedIndex = -1;
}
// Retrieve an item
get(index) {
// Error checking
if (index < 0 || index >= this.items.length)
return null;
// Return the item as an item with properties
let item = this.items[item];
return {
localize: item[1],
text : item[0],
value : item[2]
};
}
// Number of items in the list
get length() { return this.items.length; }
set length(v) { }
// Remove an item
remove(index) {
// Error checking
if (index < 0 || index >= this.length)
return;
// Determine what selectedIndex will be after the operation
let newIndex = index;
if (
newIndex <= this.selectedIndex ||
newIndex == this.length - 1
) newIndex--;
if (
newIndex == -1 &&
this.length != 0
) newIndex = 0;
// Remove the item
this.items.splice(index, 1);
this.element.options[index].remove();
this.element.selectedIndex = newIndex;
}
// Index of the currently selected item
get selectedIndex() { return this.element.selectedIndex; }
set selectedIndex(index) {
if (index < -1 || index >= this.items.length)
return this.element.selectedIndex;
return this.element.selectedIndex = index;
}
// Update an item
set(index, text, localize) {
// Error checking
if (index < 0 || index >= this.items.length)
return;
// Replace the item data
this.items[index] = [ text, localize ];
// Configure the <option> element
this.element.options[index].innerText = !localize ? text :
this.app.localize(text, this.substitutions);
}
// Update the selectedIndex property, firing an event
setSelectedIndex(index) {
// Error checking
if (index < -1 || index >= this.items.length)
return this.selectedIndex;
// Update the element and fire an event
this.element.selectedIndex = index;
this.element.dispatchEvent(new Event("input"));
return index;
}
// Currently selected value
get value() {
return this.selectedIndex == -1 ? null :
this.items[this.selectedIndex][2];
}
set value(value) {
let index = this.items.findIndex(i=>i[2] == value);
if (index != -1)
this.selectedIndex = index;
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
// Label and title
this.localizeLabel();
this.localizeTitle();
// Items
for (let x = 0; x < this.items.length; x++) {
let item = this.items[x];
this.element.options[x].innerText = !item[1] ? item[0] :
this.app.localize(item[0], this.substitutions);
}
}
}
export { register };

46
web/toolkit/Label.js Normal file
View File

@ -0,0 +1,46 @@
let register = Toolkit => Toolkit.Label =
// Presentational text
class Label extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}, autoId = false) {
super(app, Object.assign({
class: "tk label"
}, options, { style: Object.assign({
cursor : "default",
userSelect: "none",
whiteSpace: "nowrap"
}, options.style || {}) }));
// Configure instance fields
if (autoId)
this.id = Toolkit.id();
}
///////////////////////////// Public Methods //////////////////////////////
// Specify the display text
setText(text, localize) {
this.setString("text", text, localize);
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
if (this.text != null) {
let text = this.text;
this.element.innerText = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
}
}
}
export { register };

92
web/toolkit/Menu.js Normal file
View File

@ -0,0 +1,92 @@
let register = Toolkit => Toolkit.Menu =
// Pop-up menu container
class Menu extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
class : "tk menu",
id : Toolkit.id(),
role : "menu",
visibility: true,
visible : false
}, options, { style: Object.assign({
display : "inline-flex",
flexDirection: "column",
position : "absolute",
zIndex : "1"
}, options.style || {}) }));
// Configure instance fields
this.parent = null;
// Configure event handlers
this.addEventListener("focusout", e=>this.onBlur(e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Child blur
onBlur(e) {
if (this.parent instanceof Toolkit.MenuItem)
this.parent.onBlur(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Add a child menu item
add(item) {
if (!(item instanceof Toolkit.MenuItem) || !super.add(item))
return false;
this.detectIcons();
return true;
}
// Add a sepearator
addSeparator() {
let item = new Toolkit.Component(this.app, { role: "separator" });
super.add(item);
return item;
}
// Remove the menu from its parent menu item or remove a child menu item
remove() {
// Remove child
if (arguments.length != 0) {
if (!super.remove.apply(this, arguments))
return false;
this.detectIcons();
return true;
}
// Remove from parent
this.parent = null;
this.element.remove();
this.element.removeAttribute("aria-labelledby");
this.setVisible(false);
return null;
}
///////////////////////////// Package Methods /////////////////////////////
// Show or hide the icons column
detectIcons() {
this.element.classList[
this.children && this.children.find(i=>
i.visible && i.type == "checkbox") ?
"add" : "remove"
]("icons");
}
};
export { register };

106
web/toolkit/MenuBar.js Normal file
View File

@ -0,0 +1,106 @@
let register = Toolkit => Toolkit.MenuBar =
// Application menu bar
class MenuBar extends Toolkit.Menu {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
class : "tk menu-bar",
role : "menubar",
visibility: false,
visible : true
}, options, { style: Object.assign({
display : "flex",
flexDirection: "row",
minWidth : "0",
position : "inline"
}, options.style || {}) }));
// Configure event handlers
this.addEventListener("focusout", e=>this.onBlur (e));
this.addEventListener("focusin" , e=>this.onFocus(e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Child blur
onBlur(e) {
if (
this.contains(e.relatedTarget) ||
!this.children || this.children.length == 0
) return;
this.children.forEach(i=>i.expanded = false);
this.children[0].element.setAttribute("tabindex", "0");
}
// Child focus
onFocus(e) {
if (!this.children || this.children.length == 0)
return;
this.children[0].element.setAttribute("tabindex", "-1");
}
///////////////////////////// Public Methods //////////////////////////////
// Add a menu item
add(item) {
super.add(item);
if (item.menu)
this.element.append(item.menu.element);
this.children[0].element.setAttribute("tabindex", "0");
}
// Move focus into the component
focus() {
if (this.children.length != 0)
this.children[0].focus();
}
// Remove a menu item
remove(item) {
// Remove the menu item
if (item.parent == this && item.menu)
item.menu.remove();
super.remove(item);
// Configure focusability
if (this.children && !this.contains(document.activeElement)) {
for (let x = 0; x < this.children.length; x++) {
this.children[x].element
.setAttribute("tabindex", x == 0 ? "0" : "-1");
}
}
}
///////////////////////////// Package Methods /////////////////////////////
// Return focus to the application
blur() {
if (this.children) {
for (let item of this.children) {
item.expanded = false;
item.element.blur();
}
}
if (!this.parent || !this.parent.restoreFocus())
this.focus();
}
// Update localization strings
localize() {
this.localizeLabel(this.element);
}
}
export { register };

455
web/toolkit/MenuItem.js Normal file
View File

@ -0,0 +1,455 @@
let register = Toolkit => Toolkit.MenuItem =
// Selection within a MenuBar or Menu
class MenuItem extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk menu-item",
id : Toolkit.id(),
role : "menuitem",
tabIndex: "-1"
}, options));
// Configure instance fields
this._expanded = false;
this._menu = null;
// Element
this.content = document.createElement("div");
this.element.append(this.content);
this.disabled = options.disabled;
// Icon column
this.icon = document.createElement("div");
this.icon.className = "icon";
this.content.append(this.icon);
// Label column
this.label = document.createElement("div");
this.label.className = "label";
this.content.append(this.label);
// Control type
switch (options.type) {
case "checkbox":
this.type = "checkbox";
this.checked = !!options.checked;
break;
default:
this.type = "normal";
}
// Event handlers
this.addEventListener("focusout" , e=>this.onBlur (e));
this.addEventListener("keydown" , e=>this.onKeyDown (e));
this.addEventListener("pointerdown", e=>this.onPointerDown(e));
this.addEventListener("pointermove", e=>this.onPointerMove(e));
this.addEventListener("pointerup" , e=>this.onPointerUp (e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Focus lost
onBlur(e) {
if (this.menu && !this.contains(e.relatedTarget))
this.expanded = false;
}
// Key press
onKeyDown(e) {
// Do not process the event
if (e.altKey || e.ctrlKey || e.shiftKey)
return;
// Working variables
let isBar = this.parent && this.parent instanceof Toolkit.MenuBar;
let next = isBar ? "ArrowRight": "ArrowDown";
let prev = isBar ? "ArrowLeft" : "ArrowUp";
let siblings = this.parent && this.parent.children ?
this.parent.children
.filter(i=>i instanceof Toolkit.MenuItem && i.visible) :
[ this ];
let index = siblings.indexOf(this);
let handled = false;
// Process by relative key code
switch (e.key) {
// Select the next sibling
case next:
index = index == -1 ? 0 :
(index + 1) % siblings.length;
if (index < siblings.length) {
let sibling = siblings[index];
if (isBar && sibling.menu && this.expanded)
sibling.expanded = true;
sibling.focus();
}
handled = true;
break;
// Select the previous sibling
case prev:
index = index == -1 ? 0 :
(index + siblings.length - 1) % siblings.length;
if (index < siblings.length) {
let sibling = siblings[index];
if (isBar && sibling.menu && this.expanded)
sibling.expanded = true;
sibling.focus();
}
handled = true;
break;
}
// Process by absolute key code
if (!handled) switch (e.key) {
// Activate the menu item with handling for checks and radios
case " ":
this.activate(false);
break;
// Activate the menu item if in a MenuBar
case "ArrowDown":
if (isBar && this.menu)
this.activate();
break;
// Cycle through menu items in a MenuBar
case "ArrowLeft":
case "ArrowRight": {
let root = this.getRoot();
if (!(root instanceof Toolkit.MenuBar))
break;
let top = root.children.find(i=>i.expanded);
if (top)
return top.onKeyDown(e);
break;
}
// Select the last sibling
case "End":
if (siblings.length != 0)
siblings[siblings.length - 1].focus();
break;
// Activate the menu item
case "Enter":
this.activate();
break;
// Deactivate the menu and return to the parent menu item
case "Escape": {
if (this.expanded)
this.expanded = false;
else if (this.parent &&
this.parent.parent instanceof Toolkit.MenuItem) {
this.parent.parent.expanded = false;
this.parent.parent.focus();
} else if (this.parent instanceof Toolkit.MenuBar)
this.parent.blur();
break;
}
// Select the first sibling
case "Home":
if (siblings.length != 0)
siblings[0].focus();
break;
// Select the next menu item that begins with the typed character
default: {
if (e.key.length != 1)
return;
let key = e.key.toLowerCase();
for (let x = 0; x < siblings.length; x++) {
let sibling = siblings[(index + x + 1) % siblings.length];
if (
(sibling.content.textContent || " ")[0]
.toLowerCase() == key
) {
if (sibling.menu)
sibling.expanded = true;
if (sibling.menu && sibling.menu.children &&
sibling.menu.children[0])
sibling.menu.children[0].focus();
else sibling.focus();
handled = true;
break;
}
}
if (!handled)
return;
}
}
Toolkit.handle(e);
}
// Pointer down
onPointerDown(e) {
this.focus();
// Do not process the event
if (e.button != 0 || this.element.hasPointerCapture(e.pointerId))
return;
if (this.disabled)
return Toolkit.handle(e);
// Does not contain a menu
if (!this.menu) {
this.element.setPointerCapture(e.pointerId);
this.element.classList.add("pushed");
}
// Does contain a menu
else this.expanded = !this.expanded;
Toolkit.handle(e);
}
// Pointer move
onPointerMove(e) {
// Do not process the event
if (this.disabled)
return Toolkit.handle(e);
// Not dragging within element
if (!this.element.hasPointerCapture(e.pointerId)) {
let other = this.parent&&this.parent.children.find(i=>i.expanded);
if (other && other != this) {
this.expanded = true;
this.focus();
}
}
// Dragging within element
else this.element.classList
[this.isWithin(e) ? "add" : "remove"]("pushed");
Toolkit.handle(e);
}
// Pointer up
onPointerUp(e) {
// Do not process the event
if (e.button != 0)
return;
if (this.disabled)
return Toolkit.handle(e);
// Stop dragging
if (this.element.hasPointerCapture(e.pointerId)) {
this.element.releasePointerCapture(e.pointerId);
this.element.classList.remove("pushed");
}
// Activate the menu item
if (!this.menu && this.isWithin(e))
this.activate();
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Specify whether or not the checkbox is checked
get checked() { return this._checked; }
set checked(checked) {
checked = !!checked;
if (checked === this._checked || this._type != "checkbox")
return;
this._checked = checked;
this.element.setAttribute("aria-checked", checked ? "true" : "false");
}
// Determine whether a component or element is a child of this component
contains(child) {
return super.contains(child) || this.menu && this.menu.contains(child);
}
// Enable or disable the control
get disabled() { return this._disabled; }
set disabled(disabled) {
disabled = !!disabled;
// Error checking
if (disabled === this._disabled)
return;
// Enable or disable the control
this._disabled = disabled;
this.element[disabled ? "setAttribute" : "removeAttribute"]
("aria-disabled", "true");
if (disabled)
this.expanded = false;
}
// Expand or collapse the menu
get expanded() { return this._expanded; }
set expanded(expanded) {
expanded = !!expanded;
// Error checking
if (this._expanded == expanded || !this.menu)
return;
// Configure instance fields
this._expanded = expanded;
// Expand the menu
if (expanded) {
let bounds = this.element.getBoundingClientRect();
Object.assign(this.menu.element.style, {
left: bounds.left + "px",
top : bounds.bottom + "px"
});
this.menu.visible = true;
this.element.setAttribute("aria-expanded", "true");
}
// Collapse the menu and all child sub-menus
else {
this.menu.visible = false;
this.element.setAttribute("aria-expanded", "false");
if (this.children)
this.children.forEach(i=>i.expanded = false);
}
}
// Specify a new menu
get menu() { return this._menu; }
set menu(menu) {
// Error checking
if (menu == this._menu || menu && menu.parent && menu.parent != this)
return;
// Remove the current menu
if (this._menu) {
this.expanded = false;
this._menu.remove();
}
// Configure as regular menu item
if (!menu) {
this.element.removeAttribute("aria-expanded");
this.element.removeAttribute("aria-haspopup");
return;
}
// Associate the menu with the item
this._menu = menu;
menu.parent = this;
if (this.parent)
this.element.after(menu.element);
menu.element.setAttribute("aria-labelledby", this.element.id);
}
// Specify display text
setText(text, localize) {
this.setString("text", text, localize);
}
// Specify the menu item type
get type() { return this._type; }
set type(type) {
switch (type) {
case "checkbox":
if (this._type == "checkbox")
break;
this._type = "checkbox";
this.checked = null;
this.element.classList.add("checkbox");
break;
default:
if (this._type == "normal")
break;
this._type = "normal";
this._checked = false;
this.element.classList.remove("checkbox");
this.element.removeAttribute("aria-checked");
}
}
// Specify whether the element is visible
get visible() { return super.visible; }
set visible(visible) {
super.visible = visible = !!visible;
if (this.parent instanceof Toolkit.Menu)
this.parent.detectIcons();
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
if (this.text != null) {
let text = this.text;
this.label.innerText = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
} else this.label.innerText = "";
}
///////////////////////////// Private Methods /////////////////////////////
// Activate the menu item
activate(blur = true) {
// Error checking
if (this.disabled)
return;
// Expand the sub-menu
if (this.menu) {
this.expanded = true;
if (this.menu.children && this.menu.children[0])
this.menu.children[0].focus();
else this.focus();
}
// Activate the menu item
else {
this.element.dispatchEvent(Toolkit.event("action"));
if (this.type == "normal" || blur) {
let root = this.getRoot();
if (root instanceof Toolkit.MenuBar)
root.blur();
}
}
}
// Locate the root Menu component
getRoot() {
for (let comp = this.parent; comp != this.app; comp = comp.parent) {
if (
comp instanceof Toolkit.Menu &&
!(comp.parent instanceof Toolkit.MenuItem)
) return comp;
}
return null;
}
};
export { register };

113
web/toolkit/Radio.js Normal file
View File

@ -0,0 +1,113 @@
let register = Toolkit => Toolkit.Radio =
// Radio button group manager
class Radio extends Toolkit.Checkbox {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options) {
super(app, options = Object.assign({
class: "tk radio",
role : "radio"
}, options || {}));
// Configure instance fields
this._group = null;
// Configure options
if ("group" in options)
this.group = options.group;
if ("checked" in options) {
this.checked = false;
this.checked = options.checked;
}
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey || this.disabled)
return;
// Processing by key
let item = null;
switch (e.key) {
// Activate the radio button
case " ":
case "Enter":
this.checked = true;
break;
// Focus the next button in the group
case "ArrowDown":
case "ArrowRight":
if (this.group == null)
return;
item = this.group.next(this);
break;
// Focus the previous button in the group
case "ArrowLeft":
case "ArrowUp":
if (this.group == null)
return;
item = this.group.previous(this);
break;
default: return;
}
// Select and focus another item in the group
if (item != null && item != this) {
this.group.active = item;
item.focus();
item.element.dispatchEvent(new Event("input"));
}
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// The check box is checked
get checked() { return super.checked; }
set checked(checked) {
super.checked = checked;
if (this.group != null && this != this.group.active && checked)
this.group.active = this;
}
// Managing radio button group
get group() { return this._group; }
set group(group) {
if (group == this.group)
return;
if (group)
group.add(this);
this._group = group ? group : null;
}
///////////////////////////// Private Methods /////////////////////////////
// Specify the checked state
setChecked(checked) {
if (!checked || this.checked)
return;
this.checked = true;
this.element.dispatchEvent(new Event("input"));
}
}
export { register };

109
web/toolkit/RadioGroup.js Normal file
View File

@ -0,0 +1,109 @@
let register = Toolkit => Toolkit.RadioGroup =
// Radio button group manager
class RadioGroup {
///////////////////////// Initialization Methods //////////////////////////
constructor() {
this._active = null;
this.items = [];
}
///////////////////////////// Public Methods //////////////////////////////
// The current active radio button
get active() { return this._active; }
set active(item) {
// Error checking
if (
item == this.active ||
item != null && this.items.indexOf(item) == -1
) return;
// De-select the current active item
if (this.active != null) {
this.active.checked = false;
this.active.element.setAttribute("tabindex", "-1");
}
// Select the new active item
this._active = item ? item : null;
if (item == null)
return;
if (!item.checked)
item.checked = true;
item.element.setAttribute("tabindex", "0");
}
// Add a radio button item
add(item) {
// Error checking
if (
item == null ||
this.items.indexOf(item) != -1
) return;
// Remove the item from its current group
if (item.group != null)
item.group.remove(item);
// Add the item to this group
this.items.push(item);
item.group = this;
item.element.setAttribute("tabindex",
this.items.length == 0 ? "0" : "-1");
return item;
}
// Check whether an element is contained in the radio group
contains(element) {
if (element instanceof Toolkit.Component)
element = element.element;
return !!this.items.find(i=>i.element.contains(element));
}
// Remove a radio button item
remove(item) {
let index = this.items.indexOf(item);
// The item is not in the group
if (index == -1)
return null;
// Remove the item from the group
this.items.splice(index, 1);
item.element.setAttribute("tabindex", "0");
if (this.active == item) {
this.active = null;
if (this.items.length != 0)
this.items[0].element.setAttribute("tabindex", "0");
}
return item;
}
///////////////////////////// Package Methods /////////////////////////////
// Determine the next radio button in the group
next(item) {
let index = this.items.indexOf(item);
return index == -1 ? null :
this.items[(index + 1) % this.items.length];
}
// Determine the previous radio button in the group
previous(item) {
let index = this.items.indexOf(item);
return index == -1 ? null :
this.items[(index + this.items.length - 1) % this.items.length];
}
}
export { register };

380
web/toolkit/ScrollBar.js Normal file
View File

@ -0,0 +1,380 @@
let register = Toolkit => Toolkit.ScrollBar =
// Scrolling control
class ScrollBar extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk scroll-bar",
role : "scrollbar",
tabIndex: "0"
}, options, { style: Object.assign({
display : "inline-grid",
overflow: "hidden"
}, options.style || {}) }));
// Configure instance fields
this._blockIncrement = 50;
this._controls = null;
this._unitIncrement = 1;
// Unit decrement button
this.unitLess = new Toolkit.Button(app,
{ class: "unit-less", role: "", tabIndex: "" });
this.unitLess.addEventListener("action",
e=>this.value -= this.unitIncrement);
this.add(this.unitLess);
// Component
this.addEventListener("keydown" , e=>this.onKeyDown(e));
this.addEventListener("pointerdown", e=>e.preventDefault());
// Track
this.track = new Toolkit.Component(app, {
class: "track",
style: {
display : "grid",
overflow: "hidden"
}
});
this.track.addEventListener("resize", e=>this.onResize());
this.add(this.track);
// Unit increment button
this.unitMore = new Toolkit.Button(app,
{ class: "unit-more", role: "", tabIndex: "" });
this.unitMore.addEventListener("action",
e=>this.value += this.unitIncrement);
this.add(this.unitMore);
// Block decrement track
this.blockLess = new Toolkit.Button(app,
{ class: "block-less", role: "", tabIndex: "" });
this.blockLess.addEventListener("action",
e=>this.value -= this.blockIncrement);
this.track.add(this.blockLess);
// Scroll box
this.thumb = document.createElement("div");
this.thumb.className = "thumb";
this.thumb.addEventListener("pointerdown", e=>this.onThumbDown(e));
this.thumb.addEventListener("pointermove", e=>this.onThumbMove(e));
this.thumb.addEventListener("pointerUp" , e=>this.onThumbUp (e));
this.track.append(this.thumb);
// Block increment track
this.blockMore = new Toolkit.Button(app,
{ class: "block-more", role: "", tabIndex: "" });
this.blockMore.addEventListener("action",
e=>this.value += this.blockIncrement);
this.track.add(this.blockMore);
// Configure options
this.blockIncrement = !("blockIncrement" in options) ?
this._blockIncrement : options.blockIncrement;
this.orientation = !("orientation" in options) ?
"horizontal" : options.orientation;
this.unitIncrement = !("unitIncrement" in options) ?
this._unitIncrement : options.unitIncrement;
// Configure min, max and value
let min = "min" in options ? options.min : 0;
let max = Math.max(min, "max" in options ? options.max : 100);
let value = Math.max(min, Math.min(max,
"value" in options ? options.value : 0));
this.element.setAttribute("aria-valuemax", max);
this.element.setAttribute("aria-valuemin", min);
this.element.setAttribute("aria-valuenow", value);
// Display the element
this.track.element.dispatchEvent(new Event("resize"));
this.onResize();
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
// Take no action
if (e.altKey || e.ctrlKey || e.shiftKey)
return;
// Processing by key
switch (e.key) {
case "ArrowDown":
if (this.orientation != "vertical")
return;
this.value += this.unitIncrement;
break;
case "ArrowLeft":
if (this.orientation != "horizontal")
return;
this.value -= this.unitIncrement;
break;
case "ArrowRight":
if (this.orientation != "horizontal")
return;
this.value += this.unitIncrement;
break;
case "ArrowUp":
if (this.orientation != "vertical")
return;
this.value -= this.unitIncrement;
break;
case "PageDown":
this.value += this.blockIncrement;
break;
case "PageUp":
this.value -= this.blockIncrement;
break;
default: return;
}
Toolkit.handle(e);
}
// Track resized
onResize() {
let metrics = this.metrics();
let add = metrics.horz ? "width" : "height";
let remove = metrics.horz ? "height" : "width" ;
// Resize the widget elements
this.blockLess.style.removeProperty(remove);
this.blockLess.style.setProperty (add, metrics.pos + "px");
this.thumb .style.removeProperty(remove);
this.thumb .style.setProperty (add, metrics.thumb + "px");
// Indicate whether the entire view is visible
this.element.classList
[metrics.unneeded ? "add" : "remove"]("unneeded");
}
// Thumb pointer down
onThumbDown(e) {
// Prevent the user agent from focusing the element
e.preventDefault();
// Error checking
if (
e.button != 0 ||
this.disabled ||
this.unneeded ||
e.target.hasPointerCapture(e.pointerId)
)
return;
// Begin dragging
this.drag = this.metrics();
this.drag.start = this.drag.horz ? e.screenX : e.screenY;
e.target.setPointerCapture(e.pointerId);
Toolkit.handle(e);
}
// Thumb pointer move
onThumbMove(e) {
// Error checking
if (!e.target.hasPointerCapture(e.pointerId))
return;
// Working variables
let add = this.drag.horz ? "width" : "height";
let remove = this.drag.horz ? "height" : "width" ;
let delta = (this.drag.horz?e.screenX:e.screenY) - this.drag.start;
let max = this.drag.track - this.drag.thumb;
let pos = Math.max(0, Math.min(max, this.drag.pos + delta));
let value = Math.round(this.min + (this.max - this.min) * pos / (this.drag.track || 1));
let scroll = value != this.value;
// Drag the thumb
this.blockLess.style.removeProperty(remove);
this.blockLess.style.setProperty (add, pos + "px");
this.element.setAttribute("aria-valuenow", value);
Toolkit.handle(e);
// Raise a scroll event
if (scroll)
this.event();
}
// Thumb pointer up
onThumbUp(e) {
// Error checking
if (e.button != 0 || !e.target.hasPointerCapture(e))
return;
// Stop dragging
this.drag = null;
e.target.releasePointerCapture(e.pointerId);
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Page adjustment amount
get blockIncrement() { return this._blockIncrement; }
set blockIncrement(amount) {
amount = Math.max(1, amount);
if (amount == this.blockIncrement)
return;
this._blockIncrement = amount;
this.onResize();
}
// Controlling target
get controls() { return this._controls; }
set controls(target) {
if (target) {
this.element.setAttribute("aria-controls",
(target.element || target).id);
} else this.element.removeAttribute("aria-controls");
this._controls = target;
}
// Component cannot be interacted with
get disabled() { return super.disabled; }
set disabled(disabled) {
super .disabled = disabled;
this.unitLess .disabled = disabled;
this.blockLess.disabled = disabled;
this.blockMore.disabled = disabled;
this.unitMore .disabled = disabled;
}
// Maximum value
get max() {
return this.blockIncrement +
parseInt(this.element.getAttribute("aria-valuemax"));
}
set max(max) {
if (max == this.max)
return;
if (max < this.min)
this.element.setAttribute("aria-valuemin", max);
if (max < this.value)
this.element.setAttribute("aria-valuenow", max);
this.element.setAttribute("aria-valuemax", max - this.blockIncrement);
this.onResize();
}
// Minimum value
get min() { return parseInt(this.element.getAttribute("aria-valuemin")); }
set min(min) {
if (min == this.min)
return;
if (min > this.max)
this.element.setAttribute("aria-valuemax",min-this.blockIncrement);
if (min > this.value)
this.element.setAttribute("aria-valuenow", min);
this.element.setAttribute("aria-valuemin", min);
this.onResize();
}
// Layout direction
get orientation() { return this.element.getAttribute("aria-orientation"); }
set orientation(orientation) {
// Orientation is not changing
if (orientation == this.orientation)
return;
// Select which CSS properties to modify
let add, remove;
switch (orientation) {
case "horizontal":
add = "grid-template-columns";
remove = "grid-template-rows";
break;
case "vertical":
add = "grid-template-rows";
remove = "grid-template-columns";
break;
default: return; // Invalid orientation
}
// Configure the element
this.element.style.removeProperty(remove);
this.element.style.setProperty(add, "max-content auto max-content");
this.element.setAttribute("aria-orientation", orientation);
this.track .style.removeProperty(remove);
this.track .style.setProperty(add, "max-content max-content auto");
}
// Line adjustment amount
get unitIncrement() { return this._unitIncrement; }
set unitIncrement(amount) {
amount = Math.max(1, amount);
if (amount == this.unitIncrement)
return;
this._unitIncrement = amount;
this.onResize();
}
// The scroll bar is not needed
get unneeded() { return this.element.classList.contains("unneeded"); }
set unneeded(x) { }
// Current value
get value() {return parseInt(this.element.getAttribute("aria-valuenow"));}
set value(value) {
value = Math.min(this.max - this.blockIncrement,
Math.max(this.min, value));
if (value == this.value)
return;
this.element.setAttribute("aria-valuenow", value);
this.onResize();
this.event();
}
///////////////////////////// Private Methods /////////////////////////////
// Raise a scroll event
event() {
this.element.dispatchEvent(
Object.assign(new Event("scroll"), { scroll: this.value }));
}
// Compute pixel dimensions of inner elements
metrics() {
let horz = this.orientation == "horizontal";
let track = this.track.element.getBoundingClientRect()
[horz ? "width" : "height"];
let block = this.blockIncrement;
let range = this.max - this.min || 1;
let thumb = block >= range ? track :
Math.min(track, Math.max(4, Math.round(block * track / range)));
let pos = Math.round((this.value - this.min) * track / range);
return {
block : block,
horz : horz,
pos : pos,
range : range,
thumb : thumb,
track : track,
unneeded: block >= range
};
}
}
export { register };

317
web/toolkit/ScrollPane.js Normal file
View File

@ -0,0 +1,317 @@
let register = Toolkit => Toolkit.ScrollPane =
// Scrolling container for larger internal elements
class ScrollPane extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class: "tk scroll-pane"
}, options, { style: Object.assign({
display : "inline-grid",
gridAutoRows: "auto max-content",
overflow : "hidden",
position : "relative"
}, options.style || {}) }));
// Configure options
this._overflowX = "auto";
this._overflowY = "auto";
if ("overflowX" in options)
this.overflowX = options.overflowX;
if ("overflowY" in options)
this.overflowY = options.overflowY;
// Component
this.addEventListener("wheel", e=>this.onWheel(e));
// Viewport
this.viewport = new Toolkit.Component(app, {
class: "viewport",
style: {
overflow: "hidden"
}
});
this.viewport.element.id = Toolkit.id();
this.viewport.addEventListener("keydown", e=>this.onKeyDown (e));
this.viewport.addEventListener("resize" , e=>this.onResize ( ));
this.viewport.addEventListener("scroll" , e=>this.onInnerScroll( ));
this.viewport.addEventListener("visibility",
e=>{ if (e.visible) this.onResize(); });
this.add(this.viewport);
// View resize manager
this.viewResizer = new ResizeObserver(()=>this.onResize());
// Vertical scroll bar
this.vscroll = new Toolkit.ScrollBar(app, {
orientation: "vertical",
visibility : true
});
this.vscroll.controls = this.viewport;
this.vscroll.addEventListener("scroll",
e=>this.onOuterScroll(e, true));
this.add(this.vscroll);
// Horizontal scroll bar
this.hscroll = new Toolkit.ScrollBar(app, {
orientation: "horizontal",
visibility : true
});
this.hscroll.controls = this.viewport;
this.hscroll.addEventListener("scroll",
e=>this.onOuterScroll(e, false));
this.add(this.hscroll);
// Corner mask (for when both scroll bars are visible)
this.corner = new Toolkit.Component(app, { class: "corner" });
this.add(this.corner);
// Configure view
this.view = options.view || null;
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey || this.disabled)
return;
// Processing by key
switch(e.key) {
case "ArrowDown":
this.viewport.element.scrollTop +=
this.vscroll.unitIncrement;
break;
case "ArrowLeft":
this.viewport.element.scrollLeft -=
this.hscroll.unitIncrement;
break;
case "ArrowRight":
this.viewport.element.scrollLeft +=
this.hscroll.unitIncrement;
break;
case "ArrowUp":
this.viewport.element.scrollTop -=
this.vscroll.unitIncrement;
break;
case "PageDown":
this.viewport.element.scrollTop +=
this.vscroll.blockIncrement;
break;
case "PageUp":
this.viewport.element.scrollTop -=
this.vscroll.blockIncrement;
break;
default: return;
}
Toolkit.handle(e);
}
// Component resized
onResize() {
// Error checking
if (!this.viewport)
return;
// Working variables
let viewport = this.viewport.element.getBoundingClientRect();
let scrollHeight =
this.hscroll.element.getBoundingClientRect().height;
let scrollWidth =
this.vscroll.element.getBoundingClientRect().width;
let viewHeight = this.viewHeight;
let viewWidth = this.viewWidth;
let fullHeight =
viewport.height + (this.hscroll.visible ? scrollHeight : 0);
let fullWidth =
viewport.width + (this.vscroll.visible ? scrollWidth : 0);
// Configure scroll bars
this.hscroll.max = viewWidth;
this.hscroll.blockIncrement = viewport.width;
this.hscroll.value = this.viewport.element.scrollLeft;
this.vscroll.max = viewHeight;
this.vscroll.blockIncrement = viewport.height;
this.vscroll.value = this.viewport.element.scrollTop;
// Determine whether the vertical scroll bar is visible
let vert = false;
if (
this.overflowY == "scroll" ||
this.overflowY == "auto" &&
viewHeight > fullHeight &&
scrollWidth <= viewWidth
) {
fullWidth -= scrollWidth;
vert = true;
}
// Determine whether the horizontal scroll bar is visible
let horz = false;
if (
this.overflowX == "scroll" ||
this.overflowX == "auto" &&
viewWidth > fullWidth &&
scrollHeight <= viewHeight
) {
fullHeight -= scrollHeight;
horz = true;
}
// The horizontal scroll bar necessitates the vertical scroll bar
vert = vert ||
this.overflowY == "auto" &&
viewHeight > fullHeight &&
scrollWidth < viewWidth
;
// Configure scroll bar visibility
this.setScrollBars(horz, vert);
}
// View scrolled
onInnerScroll() {
this.hscroll.value = this.viewport.element.scrollLeft;
this.vscroll.value = this.viewport.element.scrollTop;
}
// Scroll bar scrolled
onOuterScroll(e, vertical) {
this.viewport.element[vertical?"scrollTop":"scrollLeft"] = e.scroll;
}
// Mouse wheel scrolled
onWheel(e) {
let delta = e.deltaY;
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey || delta == 0)
return;
// Scrolling by pixel
if (e.deltaMode == WheelEvent.DOM_DELTA_PIXEL) {
let max = this.vscroll.unitIncrement * 3;
delta = Math.min(max, Math.max(-max, delta));
}
// Scrolling by line
else if (e.deltaMode == WheelEvent.DOM_DELTA_LINE) {
delta = Math[delta < 0 ? "floor" : "ceil"](delta) *
this.vscroll.unitIncrement;
}
// Scrolling by page
else if (e.deltaMode == WheelEvent.DOM_DELTA_PAGE) {
delta = Math[delta < 0 ? "floor" : "ceil"](delta) *
this.vscroll.blockIncrement;
}
this.viewport.element.scrollTop += delta;
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Horizontal scroll bar policy
get overflowX() { return this._overflowX; }
set overflowX(policy) {
switch (policy) {
case "auto":
case "hidden":
case "scroll":
this._overflowX = policy;
this.onResize();
}
}
// Vertical scroll bar policy
get overflowY() { return this._overflowY; }
set overflowY(policy) {
switch (policy) {
case "auto":
case "hidden":
case "scroll":
this._overflowY = policy;
this.onResize();
}
}
// Horizontal scrolling position
get scrollLeft() { return this.viewport.element.scrollLeft; }
set scrollLeft(left) { this.viewport.element.scrollLeft = left; }
// Vertical scrolling position
get scrollTop() { return this.viewport.element.scrollTop; }
set scrollTop(top) { this.viewport.element.scrollTop = top; }
// Represented scrollable region
get view() {
let ret = this.viewport.element.querySelector("*");
return ret && ret.component || ret || null;
}
set view(view) {
view = view instanceof Toolkit.Component ? view.element : view;
if (view == null) {
view = this.viewport.element.querySelector("*");
if (view)
this.viewResizer.unobserve(view);
this.viewport.element.replaceChildren();
} else {
this.viewport.element.replaceChildren(view);
this.viewResizer.observe(view);
}
}
// Height of the view element
set viewHeight(x) { }
get viewHeight() {
let view = this.view && this.view.element || this.view;
return Math.min(
this.viewport.element.scrollHeight,
view ? view.clientHeight : 0
);
}
// width of the view element
set viewWidth(x) { }
get viewWidth() {
let view = this.view && this.view.element || this.view;
return Math.min(
this.viewport.element.scrollWidth,
view ? view.clientWidth : 0
);
}
///////////////////////////// Private Methods /////////////////////////////
// Configure scroll bar visibility
setScrollBars(horz, vert) {
this.element.style.gridTemplateColumns =
"auto" + (vert ? " max-content" : "");
this.hscroll.visible = horz;
this.hscroll.element.style[horz ? "removeProperty" : "setProperty"]
("position", "absolute");
this.vscroll.visible = vert;
this.vscroll.element.style[vert ? "removeProperty" : "setProperty"]
("position", "absolute");
this.corner.visible = vert && horz;
this.element.classList[horz ? "add" : "remove"]("horizontal");
this.element.classList[vert ? "add" : "remove"]("vertical");
}
}
export { register };

363
web/toolkit/SplitPane.js Normal file
View File

@ -0,0 +1,363 @@
let register = Toolkit => Toolkit.SplitPane =
// Presentational text
class SplitPane extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class: "tk split-pane"
}, options, { style: Object.assign({
display : "grid",
gridTemplateColumns: "max-content max-content auto",
overflow : "hidden"
}, options.style || {}) }));
// Configure instance fields
this.drag = null;
this._orientation = "left";
this._primary = null;
this.resizer = new ResizeObserver(()=>this.priResize());
this.restore = null;
this._secondary = null;
// Primary placeholder
this.noPrimary = document.createElement("div");
this.noPrimary.id = Toolkit.id();
// Separator widget
this.splitter = document.createElement("div");
this.splitter.setAttribute("aria-controls", this.noPrimary.id);
this.splitter.setAttribute("aria-valuemin", "0");
this.splitter.setAttribute("role", "separator");
this.splitter.setAttribute("tabindex", "0");
this.splitter.addEventListener("keydown",
e=>this.splitKeyDown (e));
this.splitter.addEventListener("pointerdown",
e=>this.splitPointerDown(e));
this.splitter.addEventListener("pointermove",
e=>this.splitPointerMove(e));
this.splitter.addEventListener("pointerup" ,
e=>this.splitPointerUp (e));
// Secondary placeholder
this.noSecondary = document.createElement("div");
// Configure options
if ("orientation" in options)
this.orientation = options.orientation;
if ("primary" in options)
this.primary = options.primary;
if ("secondary" in options)
this.secondary = options.secondary;
this.revalidate();
}
///////////////////////////// Event Handlers //////////////////////////////
// Primary pane resized
priResize() {
let metrics = this.measure();
this.splitter.setAttribute("aria-valuemax", metrics.max);
this.splitter.setAttribute("aria-valuenow", metrics.value);
}
// Splitter key press
splitKeyDown(e) {
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey || this.disabled)
return;
// Drag is in progress
if (this.drag) {
if (e.key != "Escape")
return;
this.splitter.releasePointerCapture(this.drag.pointerId);
this.value = this.drag.size;
this.drag = null;
Toolkit.handle(e);
return;
}
// Processing by key
let edge = this.orientation;
let horz = edge == "left" || edge == "right";
switch (e.key) {
case "ArrowDown":
if (horz)
return;
this.restore = null;
this.value += edge == "top" ? +10 : -10;
break;
case "ArrowLeft":
if (!horz)
return;
this.restore = null;
this.value += edge == "left" ? -10 : +10;
break;
case "ArrowRight":
if (!horz)
return;
this.restore = null;
this.value += edge == "left" ? +10 : -10;
break;
case "ArrowUp":
if (horz)
return;
this.restore = null;
this.value += edge == "top" ? -10 : +10;
break;
case "End":
this.restore = null;
this.value = this.element.getBoundingClientRect()
[horz ? "width" : "height"];
break;
case "Enter":
if (this.restore !== null) {
this.value = this.restore;
this.restore = null;
} else {
this.restore = this.value;
this.value = 0;
}
break;
case "Home":
this.restore = null;
this.value = 0;
break;
default: return;
}
Toolkit.handle(e);
}
// Splitter pointer down
splitPointerDown(e) {
// Do not obtain focus automatically
e.preventDefault();
// Error checking
if (
e.altKey || e.ctrlKey || e.shiftKey || e.button != 0 ||
this.disabled || this.splitter.hasPointerCapture(e.pointerId)
) return;
// Begin dragging
this.splitter.setPointerCapture(e.poinerId);
let horz = this.orientation == "left" || this.orientation == "right";
let prop = horz ? "width" : "height";
this.drag = {
pointerId: e.pointerId,
size : (this._primary || this.noPrimary)
.getBoundingClientRect()[prop],
start : e[horz ? "clientX" : "clientY" ]
};
Toolkit.handle(e);
}
// Splitter pointer move
splitPointerMove(e) {
// Error checking
if (!this.splitter.hasPointerCapture(e.pointerId))
return;
// Working variables
let horz = this.orientation == "left" || this.orientation == "right";
let delta = e[horz ? "clientX" : "clientY"] - this.drag.start;
let scale = this.orientation == "bottom" ||
this.orientation == "right" ? -1 : 1;
// Resize the primary component
this.restore = null;
this.value = Math.round(this.drag.size + scale * delta);
Toolkit.handle(e);
}
// Splitter pointer up
splitPointerUp(e) {
// Error checking
if (e.button != 0 || !this.splitter.hasPointerCapture(e.pointerId))
return;
// End dragging
this.splitter.releasePointerCapture(e.pointerId);
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Edge containing the primary pane
get orientation() { return this._orientation; }
set orientation(orientation) {
switch (orientation) {
case "bottom":
case "left":
case "right":
case "top":
break;
default: return;
}
if (orientation == this.orientation)
return;
this._orientation = orientation;
this.revalidate();
}
// Primary content pane
get primary() {
return !this._primary ? null :
this._primary.component || this._primary;
}
set primary(element) {
if (element instanceof Toolkit.Component)
element = element.element;
if (!(element instanceof HTMLElement) || element == this.element)
return;
this.resizer.unobserve(this._primary || this.noPrimary);
this._primary = element || null;
this.resizer.observe(element || this.noPrimary);
this.splitter.setAttribute("aria-controls",
(element || this.noPrimary).id);
this.revalidate();
}
// Secondary content pane
get secondary() {
return !this._secondary ? null :
this._secondary.component || this._secondary;
}
set secondary(element) {
if (element instanceof Toolkit.Component)
element = element.element;
if (!(element instanceof HTMLElement) || element == this.element)
return;
this._secondary = element || null;
this.revalidate();
}
// Current splitter position
get value() {
return Math.ceil(
(this._primary || this.primary).getBoundingClientRect()
[this.orientation == "left" || this.orientation == "right" ?
"width" : "height"]
);
}
set value(value) {
value = Math.round(value);
// Error checking
if (value == this.value)
return;
// Working variables
let pri = this._primary || this.noPrimary;
let sec = this._secondary || this.noSecondary;
let prop = this.orientation == "left" || this.orientation == "right" ?
"width" : "height";
// Resize the primary component
pri.style[prop] = Math.max(0, value) + "px";
// Ensure the pane didn't become too large due to margin styles
let propPri = pri .getBoundingClientRect()[prop];
let propSec = sec .getBoundingClientRect()[prop];
let propSplit = this.splitter.getBoundingClientRect()[prop];
let propThis = this.element .getBoundingClientRect()[prop];
if (propPri + propSec + propSplit > propThis) {
pri.style[prop] = Math.max(0, Math.floor(
propThis - propSec - propSplit)) + "px";
}
}
///////////////////////////// Private Methods /////////////////////////////
// Measure the current bounds of the child elements
measure() {
let prop = this.orientation == "top" || this.orientation == "bottom" ?
"height" : "width";
let bndThis = this.element .getBoundingClientRect();
let bndSplit = this.splitter.getBoundingClientRect();
let bndPri = (this._primary || this.noPrimary)
.getBoundingClientRect();
return {
max : bndThis[prop],
property: prop,
value : bndPri[prop]
}
}
// Arrange child components
revalidate() {
let horz = true;
let children = [
this._primary || this.noPrimary,
this.splitter,
this._secondary || this.noSecondary
];
// Select styles by orientation
switch (this.orientation) {
case "bottom":
Object.assign(this.element.style, {
gridAutoColumns : "100%",
gridTemplateRows: "auto max-content max-content"
});
horz = false;
children.reverse();
break;
case "left":
Object.assign(this.element.style, {
gridAutoRows : "100%",
gridTemplateColumns: "max-content max-content auto"
});
break;
case "right":
Object.assign(this.element.style, {
gridAutoRows : "100%",
gridTemplateColumns: "auto max-content max-content"
});
children.reverse();
break;
case "top":
Object.assign(this.element.style, {
gridAutoColumns : "100%",
gridTemplateRows: "max-content max-content auto"
});
horz = false;
break;
}
// Update element
if (horz) {
this.element.style.removeProperty("grid-auto-columns");
this.element.style.removeProperty("grid-template-rows");
this.splitter.className = "tk horizontal";
this.splitter.style.cursor = "ew-resize";
} else {
this.element.style.removeProperty("grid-auto-rows");
this.element.style.removeProperty("grid-template-columns");
this.splitter.className = "tk vertical";
this.splitter.style.cursor = "ns-resize";
}
this.element.replaceChildren(... children);
this.priResize();
}
}
export { register };

67
web/toolkit/TextBox.js Normal file
View File

@ -0,0 +1,67 @@
let register = Toolkit => Toolkit.TextBox =
// Check box
class TextBox extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk text-box",
tag : "input",
type : "text"
}, options));
this.element.addEventListener("focusout", e=>this.commit ( ));
this.element.addEventListener("keydown" , e=>this.onKeyDown(e));
this.value = options.value || null;
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
if (e.altKey || e.ctrlKey || e.shiftKey || this.disabled)
return;
if (e.key != "Tab")
e.stopPropagation();
if (e.key == "Enter")
this.commit();
}
///////////////////////////// Public Methods //////////////////////////////
// Contained text
get value() { return this.element.value; }
set value(value) {
value = value === undefined || value === null ? "" : value.toString();
if (value == this.value)
return;
this.element.value = value;
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
this.localizeLabel();
this.localizeTitle();
}
///////////////////////////// Private Methods /////////////////////////////
// Complete editing
commit() {
this.element.dispatchEvent(new Event("action"));
}
}
export { register };

51
web/toolkit/Toolkit.js Normal file
View File

@ -0,0 +1,51 @@
// Namespace container for toolkit classes
class Toolkit {
// Next numeric ID
static nextId = 0;
///////////////////////////// Static Methods //////////////////////////////
// Produce a synthetic Event object
static event(type, properties, bubbles = true) {
return Object.assign(
new Event(type, { bubbles: bubbles }),
properties
);
}
// Finalize an event
static handle(event) {
event.stopPropagation();
event.preventDefault();
}
// Generate a unique DOM element ID
static id() {
return "tk" + this.nextId++;
}
}
// Register component classes
(await import(/**/"./Component.js" )).register(Toolkit);
(await import(/**/"./App.js" )).register(Toolkit);
(await import(/**/"./Button.js" )).register(Toolkit);
(await import(/**/"./Checkbox.js" )).register(Toolkit);
(await import(/**/"./Desktop.js" )).register(Toolkit);
(await import(/**/"./DropDown.js" )).register(Toolkit);
(await import(/**/"./Label.js" )).register(Toolkit);
(await import(/**/"./Menu.js" )).register(Toolkit);
(await import(/**/"./MenuBar.js" )).register(Toolkit);
(await import(/**/"./MenuItem.js" )).register(Toolkit);
(await import(/**/"./ScrollBar.js" )).register(Toolkit);
(await import(/**/"./ScrollPane.js")).register(Toolkit);
(await import(/**/"./Radio.js" )).register(Toolkit);
(await import(/**/"./RadioGroup.js")).register(Toolkit);
(await import(/**/"./SplitPane.js" )).register(Toolkit);
(await import(/**/"./TextBox.js" )).register(Toolkit);
(await import(/**/"./Window.js" )).register(Toolkit);
export { Toolkit };

479
web/toolkit/Window.js Normal file
View File

@ -0,0 +1,479 @@
let register = Toolkit => Toolkit.Window =
class Window extends Toolkit.Component {
//////////////////////////////// Constants ////////////////////////////////
// Resize directions by dragging edge
static RESIZES = {
"nw": { left : true, top : true },
"n" : { top : true },
"ne": { right: true, top : true },
"w" : { left : true },
"e" : { right: true },
"sw": { left : true, bottom: true },
"s" : { bottom: true },
"se": { right: true, bottom: true }
};
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
"aria-modal": "false",
class : "tk window",
id : Toolkit.id(),
role : "dialog",
tabIndex : -1,
visibility : true,
visible : false
}, options, { style: Object.assign({
boxSizing : "border-box",
display : "grid",
gridTemplateRows: "max-content auto",
position : "absolute"
}, options.style || {})} ));
// Configure instance fields
this.lastFocus = null;
this.style = getComputedStyle(this.element);
this.text = null;
// Configure event listeners
this.addEventListener("focusin", e=>this.onFocus (e));
this.addEventListener("keydown", e=>this.onKeyDown(e));
// Working variables
let onBorderDown = e=>this.onBorderDown(e);
let onBorderMove = e=>this.onBorderMove(e);
let onBorderUp = e=>this.onBorderUp (e);
let onDragKey = e=>this.onDragKey (e);
// Resizing borders
for (let edge of [ "nw1", "nw2", "n", "ne1", "ne2",
"w", "e", "sw1", "sw2", "s", "se1", "se2"]) {
let border = document.createElement("div");
border.className = edge;
border.edge = edge.replace(/[12]/g, "");
Object.assign(border.style, {
cursor : border.edge + "-resize",
position: "absolute"
});
border.addEventListener("keydown" , onDragKey );
border.addEventListener("pointerdown", onBorderDown);
border.addEventListener("pointermove", onBorderMove);
border.addEventListener("pointerup" , onBorderUp );
this.element.append(border);
}
// Title bar
this.titleBar = document.createElement("div");
this.titleBar.className = "title";
this.titleBar.id = Toolkit.id();
Object.assign(this.titleBar.style, {
display : "grid",
gridTemplateColumns: "auto max-content",
minWidth : "0",
overflow : "hidden"
});
this.element.append(this.titleBar);
this.titleBar.addEventListener("keydown" , e=>this.onDragKey (e));
this.titleBar.addEventListener("pointerdown", e=>this.onTitleDown(e));
this.titleBar.addEventListener("pointermove", e=>this.onTitleMove(e));
this.titleBar.addEventListener("pointerup" , e=>this.onTitleUp (e));
// Title text
this.title = document.createElement("div");
this.title.className = "text";
this.title.id = Toolkit.id();
this.title.innerText = "\u00a0";
this.titleBar.append(this.title);
this.element.setAttribute("aria-labelledby", this.title.id);
// Close button
this.close = new Toolkit.Button(app, {
class : "close-button",
doNotFocus: true
});
this.close.addEventListener("action",
e=>this.element.dispatchEvent(new Event("close")));
this.close.setLabel("{window.close}", true);
this.close.setTitle("{window.close}", true);
this.titleBar.append(this.close.element);
// Client area
this.client = document.createElement("div");
this.client.className = "client";
Object.assign(this.client.style, {
minHeight: "0",
minWidth : "0",
overflow : "hidden"
});
this.element.append(this.client);
}
///////////////////////////// Event Handlers //////////////////////////////
// Border pointer down
onBorderDown(e) {
// Do not drag
if (e.button != 0 || this.app.drag != null)
return;
// Initiate dragging
this.drag = {
height: this.outerHeight,
left : this.left,
top : this.top,
width : this.outerWidth,
x : e.clientX,
y : e.clientY
};
this.drag.bottom = this.drag.top + this.drag.height;
this.drag.right = this.drag.left + this.drag.width;
// Configure event
this.focus();
this.app.drag = e;
Toolkit.handle(e);
}
// Border pointer move
onBorderMove(e) {
// Not dragging
if (this.app.drag != e.target)
return;
// Working variables
let resize = Toolkit.Window.RESIZES[e.target.edge];
let dx = e.clientX - this.drag.x;
let dy = e.clientY - this.drag.y;
let style = getComputedStyle(this.element);
let minHeight =
this.client .getBoundingClientRect().top -
this.titleBar.getBoundingClientRect().top +
parseFloat(style.borderTop ) +
parseFloat(style.borderBottom)
;
// Output bounds
let height = this.drag.height;
let left = this.drag.left;
let top = this.drag.top;
let width = this.drag.width;
// Dragging left
if (resize.left) {
let bParent = this.parent.element.getBoundingClientRect();
left += dx;
left = Math.min(left, this.drag.right - 32);
left = Math.min(left, bParent.width - 16);
width = this.drag.width + this.drag.left - left;
}
// Dragging top
if (resize.top) {
let bParent = this.parent.element.getBoundingClientRect();
top += dy;
top = Math.max(top, 0);
top = Math.min(top, this.drag.bottom - minHeight);
top = Math.min(top, bParent.height - minHeight);
height = this.drag.height + this.drag.top - top;
}
// Dragging right
if (resize.right) {
width += dx;
width = Math.max(width, 32);
width = Math.max(width, 16 - this.drag.left);
}
// Dragging bottom
if (resize.bottom) {
height += dy;
height = Math.max(height, minHeight);
}
// Apply bounds
this.element.style.height = height + "px";
this.element.style.left = left + "px";
this.element.style.top = top + "px";
this.element.style.width = width + "px";
// Configure event
Toolkit.handle(e);
}
// Border pointer up
onBorderUp(e, id) {
// Not dragging
if (e.button != 0 || this.app.drag != e.target)
return;
// Configure instance fields
this.drag = null;
// Configure event
this.app.drag = null;
Toolkit.handle(e);
}
// Key down while dragging
onDragKey(e) {
if (
this.drag != null && e.key == "Escape" &&
!e.ctrlKey && !e.altKey && !e.shiftKey
) this.cancelDrag();
}
// Focus gained
onFocus(e) {
// The element receiving focus is self, or Close button from external
if (
e.target == this.element ||
e.target == this.close.element && !this.contains(e.relatedTarget)
) {
let elm = this.lastFocus;
if (!elm) {
elm = this.getFocusable();
elm = elm[Math.min(1, elm.length - 1)];
}
elm.focus({ preventScroll: true });
}
// The element receiving focus is not self
else if (e.target != this.close.element)
this.lastFocus = e.target;
// Bring the window to the front among its siblings
if (this.parent instanceof Toolkit.Desktop)
this.parent.bringToFront(this);
}
// Window key press
onKeyDown(e) {
// Take no action
if (e.altKey || e.ctrlKey || e.key != "Tab")
return;
// Move focus to the next element in the sequence
let focuses = this.getFocusable();
let nowIndex = focuses.indexOf(document.activeElement) || 0;
let nextIndex = nowIndex + focuses.length + (e.shiftKey ? -1 : 1);
let target = focuses[nextIndex % focuses.length];
Toolkit.handle(e);
target.focus();
}
// Title bar pointer down
onTitleDown(e) {
// Do not drag
if (e.button != 0 || this.app.drag != null)
return;
// Initiate dragging
this.drag = {
height: this.outerHeight,
left : this.left,
top : this.top,
width : this.outerWidth,
x : e.clientX,
y : e.clientY
};
// Configure event
this.focus();
this.app.drag = e;
Toolkit.handle(e);
}
// Title bar pointer move
onTitleMove(e) {
// Not dragging
if (this.app.drag != e.target)
return;
// Working variables
let bParent = this.parent.element.getBoundingClientRect();
// Move horizontally
let left = this.drag.left + e.clientX - this.drag.x;
left = Math.min(left, bParent.width - 16);
left = Math.max(left, 16 - this.drag.width);
this.element.style.left = left + "px";
// Move vertically
let top = this.drag.top + e.clientY - this.drag.y;
top = Math.min(top, bParent.height - this.minHeight);
top = Math.max(top, 0);
this.element.style.top = top + "px";
// Configure event
Toolkit.handle(e);
}
// Title bar pointer up
onTitleUp(e) {
// Not dragging
if (e.button != 0 || this.app.drag != e.target)
return;
// Configure instance fields
this.drag = null;
// Configure event
this.app.drag = null;
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Bring the window to the front among its siblings
bringToFront() {
if (this.parent != null)
this.parent.bringToFront(this);
}
// Set focus on the component
focus() {
if (!this.contains(document.activeElement))
(this.lastFocus || this.element).focus({ preventScroll: true });
if (this.parent instanceof Toolkit.Desktop)
this.parent.bringToFront(this);
}
// Height of client
get height() { return this.client.getBoundingClientRect().height; }
set height(height) {
this.element.style.height =
this.outerHeight - this.height + Math.max(height, 0) + "px";
}
// Position of window left edge
get left() {
return this.element.getBoundingClientRect().left - (
this.parent == null ? 0 :
this.parent.element.getBoundingClientRect().left
);
}
set left(left) {
if (this.parent != null) {
left = Math.min(left,
this.parent.element.getBoundingClientRect().width - 16);
}
left = Math.max(left, 16 - this.outerWidth);
this.element.style.left = left + "px";
}
// Height of entire window
get outerHeight() { return this.element.getBoundingClientRect().height; }
set outerHeight(height) {
height = Math.max(height, this.minHeight);
this.element.style.height = height + "px";
}
// Width of entire window
get outerWidth() { return this.element.getBoundingClientRect().width; }
set outerWidth(width) {
width = Math.max(width, 32);
this.element.style.width = width + "px";
let left = this.left;
if (left + width < 16)
this.element.style.left = 16 - width + "px";
}
// Specify the window title
setTitle(title, localize) {
this.setString("text", title, localize);
}
// Position of window top edge
get top() {
return this.element.getBoundingClientRect().top - (
this.parent == null ? 0 :
this.parent.element.getBoundingClientRect().top
);
}
set top(top) {
if (this.parent != null) {
top = Math.min(top, -this.minHeight +
this.parent.element.getBoundingClientRect().height);
}
top = Math.max(top, 0);
this.element.style.top = top + "px";
}
// Specify whether the element is visible
get visible() { return super.visible; }
set visible(visible) {
let prevSetting = super.visible;
let prevActual = this.isVisible();
super.visible = visible;
let nowActual = this.isVisible();
if (!nowActual && this.contains(document.activeElement))
document.activeElement.blur();
}
// Width of client
get width() { return this.client.getBoundingClientRect().width; }
set width(width) {
this.outerWidth = this.outerWidth - this.width + Math.max(width, 32);
}
///////////////////////////// Package Methods /////////////////////////////
// Add a child component to the primary client region of this component
append(element) {
this.client.append(element);
}
// Update localization strings
localize() {
this.localizeText(this.title);
if ((this.title.textContent || "") == "")
this.title.innerText = "\u00a0"; // &nbsp;
this.close.localize();
}
///////////////////////////// Private Methods /////////////////////////////
// Cancel a move or resize dragging operaiton
cancelDrag() {
this.app.drag = null;
this.element.style.height = this.drag.height + "px";
this.element.style.left = this.drag.left + "px";
this.element.style.top = this.drag.top + "px";
this.element.style.width = this.drag.width + "px";
this.drag = null;
}
// Minimum height of window
get minHeight() {
return (
this.client .getBoundingClientRect().top -
this.element.getBoundingClientRect().top +
parseFloat(this.style.borderBottomWidth)
);
}
}
export { register };