apple2js/js/gl.ts
Ian Flanigan 04ae0327c2
Add the recommended eslint plugins for TypeScript (#121)
This adds both the recommended TypeScript checks, plus the recommended
TypeScript checks that require type checking.  This latter addition
means that eslint essentially has to compile all of the TypeScript in
the project, causing it to be slower. This isn't much of a problem in
VS Code because there's a lot of caching being done, but it's clearly
slower when run on the commandline.

All of the errors are either fixed or suppressed.  Some errors are
suppressed because fixing them would be too laborious for the little
value gained.

The eslint config is also slightly refactored to separate the strictly
TypeScript checks from the JavaScript checks.
2022-05-31 08:38:40 -07:00

879 lines
26 KiB
TypeScript

import { byte, Color, memory, MemoryPages, rom } from './types';
import { allocMemPages } from './util';
import { screenEmu } from 'apple2shader';
import {
GraphicsState,
HiresPage,
LoresPage,
Region,
VideoModes,
VideoModesState,
bank,
pageNo
} from './videomodes';
// Color constants
const whiteCol: Color = [255, 255, 255];
const blackCol: Color = [0, 0, 0];
const notDirty: Region = {
top: 193,
bottom: -1,
left: 561,
right: -1
};
/****************************************************************************
*
* Text/Lores Graphics
*
***************************************************************************/
export class LoresPageGL implements LoresPage {
// $00-$3F inverse
// $40-$7F flashing
// $80-$FF normal
private _buffer: memory[] = [];
private _refreshing = false;
private _blink = false;
dirty: Region = {...notDirty};
imageData: ImageData;
constructor(
private vm: VideoModes,
private page: pageNo,
private readonly charset: rom,
private readonly e: boolean
) {
this.imageData = this.vm.context.createImageData(560, 192);
this.imageData.data.fill(0xff);
this._buffer[0] = allocMemPages(0x4);
this._buffer[1] = allocMemPages(0x4);
this.vm.setLoresPage(page, this);
}
private _drawPixel(data: Uint8ClampedArray, off: number, color: Color) {
const c0 = color[0], c1 = color[1], c2 = color[2];
data[off + 0] = data[off + 4] = c0;
data[off + 1] = data[off + 5] = c1;
data[off + 2] = data[off + 6] = c2;
}
private _drawHalfPixel(data: Uint8ClampedArray, off: number, color: Color) {
const c0 = color[0], c1 = color[1], c2 = color[2];
data[off + 0] = c0;
data[off + 1] = c1;
data[off + 2] = c2;
}
private _checkInverse(val: byte) {
let inverse = false;
if (this.e) {
if (!this.vm._80colMode && !this.vm.altCharMode) {
inverse = ((val & 0xc0) === 0x40) && this._blink;
}
} else {
inverse = !((val & 0x80) || (val & 0x40) && this._blink);
}
return inverse;
}
bank0(): MemoryPages {
return {
start: () => this._start(),
end: () => this._end(),
read: (page, off) => this._read(page, off, 0),
write: (page, off, val) => this._write(page, off, val, 0),
};
}
bank1(): MemoryPages {
return {
start: () => this._start(),
end: () => this._end(),
read: (page, off) => this._read(page, off, 1),
write: (page, off, val) => this._write(page, off, val, 1),
};
}
// These are used by both bank 0 and 1
private _start() {
return (0x04 * this.page);
}
private _end() { return (0x04 * this.page) + 0x03; }
private _read(page: byte, off: byte, bank: bank) {
const addr = (page << 8) | off, base = addr & 0x3FF;
return this._buffer[bank][base];
}
private _write(page: byte, off: byte, val: byte, bank: bank) {
const addr = (page << 8) | off;
const base = addr & 0x3FF;
let fore, back;
if (this._buffer[bank][base] === val && !this._refreshing) {
return;
}
this._buffer[bank][base] = val;
const col = (base % 0x80) % 0x28;
const adj = off - col;
// 000001cd eabab000 -> 000abcde
const ab = (adj & 0x18);
const cd = (page & 0x03) << 1;
const ee = adj >> 7;
const row = ab | cd | ee;
const data = this.imageData.data;
if ((row < 24) && (col < 40)) {
let y = row << 3;
if (y < this.dirty.top) { this.dirty.top = y; }
y += 8;
if (y > this.dirty.bottom) { this.dirty.bottom = y; }
let x = col * 14;
if (x < this.dirty.left) { this.dirty.left = x; }
x += 14;
if (x > this.dirty.right) { this.dirty.right = x; }
if (this.vm.textMode || this.vm.hiresMode || (this.vm.mixedMode && row > 19)) {
if (this.vm._80colMode) {
const inverse = this._checkInverse(val);
fore = inverse ? blackCol : whiteCol;
back = inverse ? whiteCol : blackCol;
if (!this.vm.altCharMode) {
val = (val >= 0x40 && val < 0x80) ? val - 0x40 : val;
}
let offset = (col * 14 + (bank ? 0 : 1) * 7 + row * 560 * 8) * 4;
for (let jdx = 0; jdx < 8; jdx++) {
let b = this.charset[val * 8 + jdx];
for (let idx = 0; idx < 7; idx++) {
const color = (b & 0x01) ? back : fore;
this._drawHalfPixel(data, offset, color);
b >>= 1;
offset += 4;
}
offset += 553 * 4;
}
} else if (bank === 0) {
val = this._buffer[0][base];
const inverse = this._checkInverse(val);
fore = inverse ? blackCol : whiteCol;
back = inverse ? whiteCol : blackCol;
if (!this.vm.altCharMode) {
val = (val >= 0x40 && val < 0x80) ? val - 0x40 : val;
}
let offset = (col * 14 + row * 560 * 8) * 4;
if (this.e) {
for (let jdx = 0; jdx < 8; jdx++) {
let b = this.charset[val * 8 + jdx];
for (let idx = 0; idx < 7; idx++) {
const color = (b & 0x01) ? back : fore;
this._drawPixel(data, offset, color);
b >>= 1;
offset += 8;
}
offset += 546 * 4;
}
} else {
for (let jdx = 0; jdx < 8; jdx++) {
let b = this.charset[val * 8 + jdx] << 1;
for (let idx = 0; idx < 7; idx++) {
const color = (b & 0x80) ? fore : back;
this._drawPixel(data, offset, color);
b <<= 1;
offset += 8;
}
offset += 546 * 4;
}
}
}
} else {
if (this.vm._80colMode && !this.vm.an3State) {
let offset = (col * 14 + (bank ? 0 : 1) * 7 + row * 560 * 8) * 4;
for (let jdx = 0; jdx < 8; jdx++) {
let b = (jdx < 4) ? (val & 0x0f) : (val >> 4);
b |= (b << 4);
b |= (b << 8);
if (col & 0x1) {
b >>= 2;
}
for (let idx = 0; idx < 7; idx++) {
const color = (b & 0x01) ? whiteCol : blackCol;
this._drawHalfPixel(data, offset, color);
b >>= 1;
offset += 4;
}
offset += 553 * 4;
}
} else if (bank === 0) {
let offset = (col * 14 + row * 560 * 8) * 4;
for (let jdx = 0; jdx < 8; jdx++) {
let b = (jdx < 4) ? (val & 0x0f) : (val >> 4);
b |= (b << 4);
b |= (b << 8);
if (col & 0x1) {
b >>= 2;
}
for (let idx = 0; idx < 14; idx++) {
const color = (b & 0x0001) ? whiteCol : blackCol;
this._drawHalfPixel(data, offset, color);
b >>= 1;
offset += 4;
}
offset += 546 * 4;
}
}
}
}
}
refresh() {
let addr = 0x400 * this.page;
this._refreshing = true;
for (let idx = 0; idx < 0x400; idx++, addr++) {
this._write(addr >> 8, addr & 0xff, this._buffer[0][idx], 0);
if (this.vm._80colMode) {
this._write(addr >> 8, addr & 0xff, this._buffer[1][idx], 1);
}
}
this._refreshing = false;
}
blink() {
let addr = 0x400 * this.page;
this._refreshing = true;
this._blink = !this._blink;
for (let idx = 0; idx < 0x400; idx++, addr++) {
const b = this._buffer[0][idx];
if ((b & 0xC0) === 0x40) {
this._write(addr >> 8, addr & 0xff, this._buffer[0][idx], 0);
}
}
this._refreshing = false;
}
start() {
setInterval(() => this.blink(), 267);
return this._start();
}
end() {
return this._end();
}
read(page: byte, off: byte) {
return this._read(page, off, 0);
}
write(page: byte, off: byte, val: byte) {
return this._write(page, off, val, 0);
}
getState(): GraphicsState {
return {
buffer: [
new Uint8Array(this._buffer[0]),
new Uint8Array(this._buffer[1]),
]
};
}
setState(state: GraphicsState) {
this._buffer[0] = new Uint8Array(state.buffer[0]);
this._buffer[1] = new Uint8Array(state.buffer[1]);
this.refresh();
}
private rowToBase(row: number) {
const ab = (row >> 3) & 3;
const cd = (row >> 1) & 0x3;
const e = row & 1;
return (cd << 8) | (e << 7) | (ab << 5) | (ab << 3);
}
private mapCharCode(charCode: byte) {
charCode &= 0x7F;
if (charCode < 0x20) {
charCode += 0x40;
}
if (!this.e && (charCode >= 0x60)) {
charCode -= 0x40;
}
return charCode;
}
getText() {
let buffer = '', line, charCode;
let row, col, base;
for (row = 0; row < 24; row++) {
base = this.rowToBase(row);
line = '';
if (this.e && this.vm._80colMode) {
for (col = 0; col < 80; col++) {
charCode = this.mapCharCode(this._buffer[1 - col % 2][base + Math.floor(col / 2)]);
line += String.fromCharCode(charCode);
}
} else {
for (col = 0; col < 40; col++) {
charCode = this.mapCharCode(this._buffer[0][base + col]);
line += String.fromCharCode(charCode);
}
}
line = line.trimRight();
buffer += line + '\n';
}
return buffer;
}
}
/****************************************************************************
*
* Hires Graphics
*
***************************************************************************/
const _drawPixel = (data: Uint8ClampedArray, off: number, color: Color) => {
const c0 = color[0], c1 = color[1], c2 = color[2];
data[off + 0] = data[off + 4] = c0;
data[off + 1] = data[off + 5] = c1;
data[off + 2] = data[off + 6] = c2;
};
const _drawHalfPixel = (data: Uint8ClampedArray, off: number, color: Color) => {
const c0 = color[0], c1 = color[1], c2 = color[2];
data[off + 0] = c0;
data[off + 1] = c1;
data[off + 2] = c2;
};
export class HiresPageGL implements HiresPage {
public imageData: ImageData;
dirty: Region = {...notDirty};
private _buffer: memory[] = [];
private _refreshing = false;
constructor(
private vm: VideoModes,
private page: pageNo,
) {
this.imageData = this.vm.context.createImageData(560, 192);
this.imageData.data.fill(0xff);
this._buffer[0] = allocMemPages(0x20);
this._buffer[1] = allocMemPages(0x20);
this.vm.setHiresPage(page, this);
}
bank0(): MemoryPages {
return {
start: () => this._start(),
end: () => this._end(),
read: (page, off) => this._read(page, off, 0),
write: (page, off, val) => this._write(page, off, val, 0),
};
}
bank1(): MemoryPages {
return {
start: () => this._start(),
end: () => this._end(),
read: (page, off) => this._read(page, off, 1),
write: (page, off, val) => this._write(page, off, val, 1),
};
}
private _start() { return (0x20 * this.page); }
private _end() { return (0x020 * this.page) + 0x1f; }
private _read(page: byte, off: byte, bank: bank) {
const addr = (page << 8) | off, base = addr & 0x1FFF;
return this._buffer[bank][base];
}
private _write(page: byte, off: byte, val: byte, bank: bank) {
const addr = (page << 8) | off;
const base = addr & 0x1FFF;
if (this._buffer[bank][base] === val && !this._refreshing) {
return;
}
this._buffer[bank][base] = val;
const col = (base % 0x80) % 0x28;
const adj = off - col;
// 000001cd eabab000 -> 000abcde
const ab = (adj & 0x18);
const cd = (page & 0x03) << 1;
const e = adj >> 7;
const rowa = ab | cd | e,
rowb = base >> 10;
const data = this.imageData.data;
if ((rowa < 24) && (col < 40) && this.vm.hiresMode) {
let y = rowa << 3 | rowb;
if (y < this.dirty.top) { this.dirty.top = y; }
y += 1;
if (y > this.dirty.bottom) { this.dirty.bottom = y; }
let x = col * 14 - 2;
if (x < this.dirty.left) { this.dirty.left = x; }
x += 14;
if (x > this.dirty.right) { this.dirty.right = x; }
const dy = rowa << 3 | rowb;
if (this.vm.doubleHiresMode) {
const dx = col * 14 + (bank ? 0 : 7);
let offset = dx * 4 + dy * 280 * 4 * 2;
let bits = val;
for (let jdx = 0; jdx < 7; jdx++, offset += 4) {
if (bits & 0x01) {
_drawHalfPixel(data, offset, whiteCol);
} else {
_drawHalfPixel(data, offset, blackCol);
}
bits >>= 1;
}
} else if (bank === 0) {
const hbs = val & 0x80;
const lastCol = col === 39;
const cropLastPixel = hbs && lastCol;
const dx = col * 14;
let offset = dx * 4 + dy * 560 * 4;
if (hbs) {
const val0 = this._buffer[bank][base - 1] || 0;
if (val0 & 0x40) {
_drawHalfPixel(data, offset, whiteCol);
} else {
_drawHalfPixel(data, offset, blackCol);
}
offset += 4;
}
let bits = val;
for (let idx = 0; idx < 7; idx++, offset += 8) {
const drawPixel = cropLastPixel && idx === 6
? _drawHalfPixel
: _drawPixel;
if (bits & 0x01) {
drawPixel(data, offset, whiteCol);
} else {
drawPixel(data, offset, blackCol);
}
bits >>= 1;
}
if (!this._refreshing) {
this._refreshing = true;
const after = addr + 1;
this._write(after >> 8, after & 0xff, this._buffer[0][after & 0x1fff], 0);
this._refreshing = false;
}
}
}
}
refresh() {
let addr = 0x2000 * this.page;
this._refreshing = true;
for (let idx = 0; idx < 0x2000; idx++, addr++) {
const page = addr >> 8;
const off = addr & 0xff;
this._write(page, off, this._buffer[0][idx], 0);
if (this.vm._80colMode) {
this._write(page, off, this._buffer[1][idx], 1);
}
}
this._refreshing = false;
}
start() {
return this._start();
}
end() {
return this._end();
}
read(page: byte, off: byte) {
return this._read(page, off, 0);
}
write(page: byte, off: byte, val: byte) {
return this._write(page, off, val, 0);
}
getState(): GraphicsState {
return {
buffer: [
new Uint8Array(this._buffer[0]),
new Uint8Array(this._buffer[1]),
]
};
}
setState(state: GraphicsState) {
this._buffer[0] = new Uint8Array(state.buffer[0]);
this._buffer[1] = new Uint8Array(state.buffer[1]);
this.refresh();
}
}
export class VideoModesGL implements VideoModes {
private _grs: LoresPage[] = [];
private _hgrs: HiresPage[] = [];
private _sv: screenEmu.ScreenView;
private _displayConfig: screenEmu.DisplayConfiguration;
private _scanlines: boolean = false;
private _refreshFlag: boolean = true;
private _canvas: HTMLCanvasElement;
public ready: Promise<void>;
public textMode: boolean;
public mixedMode: boolean;
public hiresMode: boolean;
public pageMode: pageNo;
public _80colMode: boolean;
public altCharMode: boolean;
public an3State: boolean;
public doubleHiresMode: boolean;
public flag = 0;
public monoMode: boolean = false;
public context: CanvasRenderingContext2D;
constructor(
private screen: HTMLCanvasElement,
private e: boolean
) {
this._canvas = document.createElement('canvas');
const context = this._canvas.getContext('2d');
if (!context) {
throw new Error('no 2d context');
}
const { width, height } = screenEmu.C.NTSC_DETAILS.imageSize;
this._canvas.width = width;
this._canvas.height = height;
this.context = context;
this._sv = new screenEmu.ScreenView(this.screen);
this.ready = this.init();
}
async init() {
// There is a typing bug in https://github.com/whscullin/apple2shader/blob/master/index.d.ts
// that declares initOpenGL as returning void when it actually returns Promise<void>.
// eslint-disable-next-line @typescript-eslint/await-thenable
await this._sv.initOpenGL();
this._displayConfig = this.defaultMonitor();
this._sv.displayConfiguration = this._displayConfig;
}
private defaultMonitor(): screenEmu.DisplayConfiguration {
const config = new screenEmu.DisplayConfiguration();
config.displayResolution = new screenEmu.Size(this.screen.width, this.screen.height);
config.displayScanlineLevel = 0.5;
config.videoWhiteOnly = true;
config.videoSaturation = 0.8;
config.videoSize = new screenEmu.Size(1.25, 1.15);
config.videoCenter = new screenEmu.Point(0.01, 0.02);
// config.videoDecoder = 'CANVAS_CXA2025AS';
return config;
}
private monitorII(): screenEmu.DisplayConfiguration {
// Values taken from openemulator/libemulation/res/library/Monitors/Apple Monitor II.xml
const config = new screenEmu.DisplayConfiguration();
config.displayResolution = new screenEmu.Size(this.screen.width, this.screen.height);
config.videoDecoder = 'CANVAS_MONOCHROME';
config.videoBrightness = 0.15;
config.videoContrast = 0.8;
config.videoSaturation = 1.45;
config.videoHue = 0.27;
config.videoCenter = new screenEmu.Point(0.01, 0.02);
config.videoSize = new screenEmu.Size(1.25, 1.15);
config.videoBandwidth = 9000000;
config.displayBarrel = 0.1;
config.displayScanlineLevel = 0.5;
config.displayCenterLighting = 0.5;
config.displayLuminanceGain = 1.5;
return config;
}
private _refresh() {
this.doubleHiresMode = !this.an3State && this.hiresMode && this._80colMode;
this._refreshFlag = true;
if (this._displayConfig) {
this._displayConfig.videoWhiteOnly = this.textMode || this.monoMode;
this._displayConfig.displayScanlineLevel = this._scanlines ? 0.5 : 0;
this._sv.displayConfiguration = this._displayConfig;
}
}
refresh() {
this._refresh();
}
reset() {
this.textMode = true;
this.mixedMode = false;
this.hiresMode = true;
this.pageMode = 1;
this._80colMode = false;
this.altCharMode = false;
this.an3State = true;
this._refresh();
}
setLoresPage(page: pageNo, lores: LoresPage) {
this._grs[page - 1] = lores;
}
setHiresPage(page: pageNo, hires: HiresPage) {
this._hgrs[page - 1] = hires;
}
text(on: boolean) {
const old = this.textMode;
this.textMode = on;
if (old !== on) {
this._refresh();
}
}
_80col(on: boolean) {
if (!this.e) { return; }
const old = this._80colMode;
this._80colMode = on;
if (old !== on) {
this._refresh();
}
}
altChar(on: boolean) {
if (!this.e) { return; }
const old = this.altCharMode;
this.altCharMode = on;
if (old !== on) {
this._refresh();
}
}
hires(on: boolean) {
const old = this.hiresMode;
this.hiresMode = on;
if (old !== on) {
this._refresh();
}
}
an3(on: boolean) {
if (!this.e) { return; }
const old = this.an3State;
this.an3State = on;
if (old !== on) {
this._refresh();
}
}
doubleHires(on: boolean) {
this.an3(!on);
}
mixed(on: boolean) {
const old = this.mixedMode;
this.mixedMode = on;
if (old !== on) {
this._refresh();
}
}
page(pageNo: pageNo) {
const old = this.pageMode;
this.pageMode = pageNo;
if (old !== pageNo) {
this._refresh();
}
}
isText() {
return this.textMode;
}
isMixed() {
return this.mixedMode;
}
isPage2() {
return this.pageMode === 2;
}
isHires() {
return this.hiresMode;
}
isDoubleHires() {
return this.doubleHiresMode;
}
is80Col() {
return this._80colMode;
}
isAltChar() {
return this.altCharMode;
}
updateImage(
mainData: ImageData,
mainDirty: Region,
mixData?: ImageData | null,
mixDirty?: Region | null
) {
let blitted = false;
if (mainDirty.bottom !== -1 || (mixDirty && mixDirty.bottom !== -1)) {
const imageData = this.buildScreen(mainData, mixData);
const imageInfo = new screenEmu.ImageInfo(imageData);
this._sv.image = imageInfo;
blitted = true;
}
this._sv.vsync();
return blitted;
}
buildScreen(mainData: ImageData, mixData?: ImageData | null) {
const details = screenEmu.C.NTSC_DETAILS;
const { width, height } = details.imageSize;
const { x, y } = this._80colMode ? details.topLeft80Col : details.topLeft;
if (mixData) {
this.context.putImageData(mainData, x, y, 0, 0, 560, 160);
this.context.putImageData(mixData, x, y, 0, 160, 560, 32);
} else {
this.context.putImageData(mainData, x, y);
}
return this.context.getImageData(0, 0, width, height);
}
blit(altData?: ImageData) {
let blitted = false;
const hgr = this._hgrs[this.pageMode - 1];
const gr = this._grs[this.pageMode - 1];
if (this._refreshFlag) {
const { width, height } = screenEmu.C.NTSC_DETAILS.imageSize;
this.context.clearRect(0, 0, width, height);
hgr.refresh();
gr.refresh();
this._refreshFlag = false;
}
if (altData) {
blitted = this.updateImage(
altData,
{ top: 0, left: 0, right: 560, bottom: 192 }
);
} else if (this.hiresMode && !this.textMode) {
blitted = this.updateImage(
hgr.imageData,
hgr.dirty,
this.mixedMode ? gr.imageData : null,
this.mixedMode ? gr.dirty : null
);
} else {
blitted = this.updateImage(
gr.imageData, gr.dirty
);
}
hgr.dirty = {...notDirty};
gr.dirty = {...notDirty};
return blitted;
}
getState(): VideoModesState {
return {
grs: [this._grs[0].getState(), this._grs[1].getState()],
hgrs: [this._hgrs[0].getState(), this._hgrs[1].getState()],
textMode: this.textMode,
mixedMode: this.mixedMode,
hiresMode: this.hiresMode,
pageMode: this.pageMode,
_80colMode: this._80colMode,
altCharMode: this.altCharMode,
an3State: this.an3State,
flag: 0
};
}
setState(state: VideoModesState) {
this.textMode = state.textMode;
this.mixedMode = state.mixedMode;
this.hiresMode = state.hiresMode;
this.pageMode = state.pageMode;
this._80colMode = state._80colMode;
this.altCharMode = state.altCharMode;
this.an3State = state.an3State;
this._grs[0].setState(state.grs[0]);
this._grs[1].setState(state.grs[1]);
this._hgrs[0].setState(state.hgrs[0]);
this._hgrs[1].setState(state.hgrs[1]);
this._refresh();
}
mono(on: boolean) {
this.monoMode = on;
this._displayConfig = on ? this.monitorII() : this.defaultMonitor();
this._refresh();
}
scanlines(on: boolean) {
this._scanlines = on;
this._refresh();
}
getText() {
return this._grs[this.pageMode - 1].getText();
}
}