astrocade: improved astrocade-arcade platform, fixed astrocade gfx bugs, machine options (e.g. video rotate)

This commit is contained in:
Steven Hugg committed 2026-10-12 11:33:24 +02:00
1 parent cc1709b63e
commit bb9a401875
6 files changed
+181 -22

No files matched your search

+2
View File
@@ -67,6 +67,8 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md).
- PC Engine Chase example; BASIC uppercase-only dialect fixes; 6502 KIL instructions halt the emulator.
- C64: openroms-compatible cc65 `_cgetc`/`PLOT` overrides.
- Verilog: fixed a non-convergence bug on settle and a shift-state bug on digit keys.
- Astrocade fixes. Astrocade arcade (`astrocade-arcade`) closer to running Gorf.
- Vertical-monitor games can turn the screen with `?options=rotate%3D-90`.
### Build tools
+2 -1
View File
@@ -33,7 +33,8 @@ void render_sprite(const byte* src, byte x, byte y, byte op) {
byte w = *src++; // get width from 1st byte of sprite
byte h = *src++; // get height from 2nd byte of sprite
byte* dest = &vmagic[y][x>>2];// destination address
hw_magic = M_SHIFT(x) | op; // set magic register
// M_ERASE is not a real hardware mode (it's the rotate bit); erase by writing zeros
hw_magic = (op == M_ERASE) ? M_MOVE : M_SHIFT(x) | op; // set magic register
// y clipping off bottom
if (y+h >= VHEIGHT) {
if (y >= VHEIGHT) return;
+3 -1
View File
@@ -814,6 +814,7 @@ export function hasSerialIO(arg: any): arg is HasSerialIO {
export abstract class BaseMachinePlatform<T extends Machine> extends BaseDebugPlatform implements Platform {
machine: T;
mainElement: HTMLElement;
options: any; // URL options (?options=key%3Dvalue), if any
timer: AnimationTimer;
video: RasterVideo;
audio: SampledAudio;
@@ -834,9 +835,10 @@ export abstract class BaseMachinePlatform<T extends Machine> extends BaseDebugPl
abstract getDefaultExtensions(): string[];
abstract getPresets(): Preset[];
constructor(mainElement: HTMLElement) {
constructor(mainElement: HTMLElement, options?: any) {
super();
this.mainElement = mainElement;
this.options = options || {};
}
reset() {
+96 -19
View File
@@ -5,6 +5,8 @@ import { KeyFlags, newAddressDecoder, padBytes, Keys, makeKeycodeMap, newKeyboar
import { TssChannelAdapter, MasterAudio, AY38910_Audio } from "../common/audio";
import { hex, rgb2bgr, lzgmini, stringToByteArray } from "../common/util";
const SC01_CLOCK = 720000;
// http://metopal.com/projects/ballybook/doku.php
// TODO: fix keys, more controllers, vibrato/noise, border color
@@ -187,7 +189,8 @@ const _BallyAstrocade = function(arcade:boolean) {
(((rotdata[0] >> sh) & 3) << 0);
} else {
// fill buffer
rotdata[rotcount & 3] = v;
rotdata[rotcount++ & 3] = v;
return; // the first four writes only fill the buffer
}
rotcount++;
} else {
@@ -214,13 +217,13 @@ const _BallyAstrocade = function(arcade:boolean) {
if ((oldv & 0x30) && (v & 0x30)) icpt |= 0x2;
if ((oldv & 0x0c) && (v & 0x0c)) icpt |= 0x4;
if ((oldv & 0x03) && (v & 0x03)) icpt |= 0x8;
// apply op
// apply op (OR wins if both are set)
if (magicop & 0x10)
v |= oldv;
if (magicop & 0x20)
v ^= oldv; // TODO: what if both?
// upper 4 bits persist, lower are just since last write
intst = (intst & 0xf0) | icpt | (icpt << 4);
else
v ^= oldv;
// lower 4 bits accumulate until INTST is read, upper are just this write
intst = (intst & 0x0f) | icpt | (icpt << 4);
}
// commit write to ram/screen
ramwrite(a, v);
@@ -249,7 +252,7 @@ const _BallyAstrocade = function(arcade:boolean) {
let m_pattern_skip = patboard[3];
let m_pattern_dest = (patdest & 0xff) | (patboard[4] << 8);
let m_pattern_width = patboard[5];
let m_pattern_height = patboard[6] + 1;
let m_pattern_height = patboard[6]; // rows - 1; the loop below runs until it goes negative
let curwidth: number;
let u13ff: number = 0;
@@ -299,7 +302,7 @@ const _BallyAstrocade = function(arcade:boolean) {
if (curwidth === 0 && (m_pattern_mode & 0x08) !== 0) {
busdata = 0;
} else {
busdata = membus.read(m_pattern_source);
busdata = membus.read(busaddr & 0xffff);
}
if ((m_pattern_mode & 0x01) === 0) {
@@ -310,7 +313,7 @@ const _BallyAstrocade = function(arcade:boolean) {
// Write Phase
busaddr = (m_pattern_mode & 0x01) !== 0 ? m_pattern_source : m_pattern_dest;
ramwrite(busaddr, busdata);
membus.write(busaddr & 0xffff, busdata); // through the bus, like MAME's space.write_byte
if ((m_pattern_mode & 0x01) === 0) {
incrementDest();
@@ -347,6 +350,12 @@ const _BallyAstrocade = function(arcade:boolean) {
cpu.retryInterrupts = !(inmod & 0x4);
this.probe && this.probe.logInterrupt(infbk);
cpu.interrupt(infbk);
} else if (arcade && (inmod & 0x12)) {
// arcade boards wire horizontal drive to the lightpen interrupt, so it
// fires every line when enabled (INMOD bit 1 or 4); bit 0 is the mode
cpu.retryInterrupts = !(inmod & 0x1);
this.probe && this.probe.logInterrupt(infbk & 0xf0);
cpu.interrupt(infbk & 0xf0);
}
// refresh this line in frame buffer?
if (sl < sheight && refreshlines > 0) {
@@ -389,7 +398,7 @@ const _BallyAstrocade = function(arcade:boolean) {
[0x4000, 0x7fff, 0x3fff, function(a) { screenwait(); return ram[a]; }], // screen RAM
[0xd000, 0xdfff, 0x0fff, function(a) { return ram[a + 0x4000]; }], // static RAM (own board, no wait states)
[0x0000, 0x3fff, 0x3fff, function(a) { return rom ? rom[a] : 0; }], // ROM
[0x8000, 0xbfff, 0x3fff, function(a) { return rom ? rom[a + 0x4000] : 0; }], // ROM
[0x8000, 0xbfff, 0x3fff, function(a) { return rom ? rom[a + 0x8000] : 0; }], // ROM
]),
write: newAddressDecoder([
[0x4000, 0x7fff, 0x3fff, ramwrite],
@@ -400,6 +409,7 @@ const _BallyAstrocade = function(arcade:boolean) {
}
iobus = {
read: function(addr) {
var hi = (addr >> 8) & 0xff;
addr &= 0x1f;
var rtn;
switch (addr) {
@@ -407,6 +417,17 @@ const _BallyAstrocade = function(arcade:boolean) {
rtn = intst;
intst = 0;
break;
case 0x12:
rtn = inputs[addr];
// Gorf/Wizard of Wor: Votrax request line on bit 7
if (machine.sc01 && machine.sc01.ready) rtn |= 0x80;
break;
case 0x17:
// a read cycle on the sound chip's SO7 latches the high address byte
// (Gorf's IN A,(C) with B = phoneme) into the Votrax
if (machine.sc01) machine.speechOut(hi);
rtn = inputs[addr];
break;
default:
rtn = inputs[addr];
break;
@@ -421,10 +442,10 @@ const _BallyAstrocade = function(arcade:boolean) {
}
addr &= 0xff;
// pattern board
if (addr > 0x78 && addr < 0x80) {
if (addr >= 0x78 && addr < 0x7f) {
patboard[addr & 7] = val;
if (addr == 0x72) { patdest = 0; }
if (addr == 0x74) { patdest = (patdest + patboard[3]) & 0xff; }
if (addr == 0x7a) { patdest = 0; } // mode write clears dest low byte
if (addr == 0x7c) { patdest = (patdest + patboard[3]) & 0xff; } // dest high write adds skip
if (addr == 0x7e) { xfer_patboard(); }
return;
}
@@ -447,8 +468,11 @@ const _BallyAstrocade = function(arcade:boolean) {
refreshall();
break;
case 0xa: // VERBL (vertical blank)
verbl = arcade ? val : val >> 1;
refreshall();
// the arcade board always shows its full 204-line raster (Gorf writes 0 here)
if (!arcade) {
verbl = val >> 1;
refreshall();
}
break;
case 0xb: // OTIR (set palette)
var c = cpu.saveState();
@@ -628,6 +652,11 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl
cpu : Z80;
m; // _BallyAstrocade
arcade : boolean;
sc01 : any; // SC01 | null = null; // Votrax speech (arcade only)
speech : any; // Votrax_Audio | null = null;
sc01Cycles = 0; // fractional master clock cycles carried between scanlines
speechCount = 0; // phonemes strobed, for the debug view
rotate = 0; // display rotation in degrees (arcade; Gorf's monitor is on its side: -90)
psg: AstrocadeAudio;
audioadapter;
@@ -648,7 +677,7 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl
this.sampleRate = 60 * this.numTotalScanlines * oversample;
this.audioadapter = new TssChannelAdapter(this.psg.psg, oversample, this.sampleRate);
this.handler = newKeyboardHandler(this.inputs, ASTROCADE_KEYCODE_MAP);
this.defaultROMSize = arcade ? 0xb000 : 0x2000;
this.defaultROMSize = arcade ? 0xc000 : 0x2000;
this.ram = new Uint8Array(arcade ? 0x5000 : 0x1000);
this.numVisibleScanlines = arcade ? 204 : 102;
this.canvasWidth = arcade ? 320 : 160;
@@ -658,6 +687,19 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl
this.m = new _BallyAstrocade(arcade);
this.m.init(this, this.cpu, this.ram, this.inputs, this.psg);
if (arcade) {
/*
this.speech = new Votrax_Audio(this.psg.master);
const host : SC01Host = {
phonemeMsec: (code) => this.speech.phonemeDuration(code),
speak: (code, msec, scale) => this.speech.speak(code, msec, {
pitch: SC01_PITCH[code >> 6] * scale,
formantScale: scale,
}),
inflectionChanged: () => {},
readyChanged: () => {},
};
this.sc01 = new SC01(host);
*/
this.inputs[0x10] = 0xff; // switches (active low)
this.inputs[0x11] = 0xff; // switches (active low)
this.inputs[0x12] = 0x00;
@@ -697,6 +739,21 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl
startScanline() {
this.audio && this.audioadapter.generate(this.audio);
if (this.sc01) {
this.sc01Cycles += this.sc01.clock / (60 * this.numTotalScanlines);
const n = this.sc01Cycles | 0;
this.sc01Cycles -= n;
this.sc01.step(n);
}
}
// the data a program put on SO7 (MAME votrax_speech_w): bit 7 clear raises the
// inflection, bit 6 picks the 782 kHz clock instead of 756 kHz
speechOut(data:number) {
const sc = this.sc01;
sc.clock = (data & 0x40) ? 782000 : 756000;
this.speechCount++;
sc.load(data & 0x3f, (data & 0x80) ? 0 : 2);
}
drawScanline() {
@@ -733,19 +790,39 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl
reset() {
super.reset();
this.m.reset();
if (this.sc01) {
this.sc01.clock = SC01_CLOCK;
this.sc01.reset();
this.sc01Cycles = 0;
this.speech?.stop();
}
}
loadState(state) {
this.m.loadState(state);
if (this.sc01 && state.sc01) {
this.sc01.loadState(state.sc01);
this.sc01Cycles = state.sc01Cycles || 0;
this.sc01.clock = state.sc01Clock || SC01_CLOCK;
}
}
saveState() {
return this.m.saveState();
var s = this.m.saveState();
if (this.sc01) {
s.sc01 = this.sc01.saveState();
s.sc01Cycles = this.sc01Cycles;
s.sc01Clock = this.sc01.clock;
}
return s;
}
getDebugCategories() {
return ['CPU','Stack','Astro'];
}
getDebugInfo(category, state) {
switch (category) {
case 'Astro': return this.m.toLongString(state);
case 'Astro':
var s = this.m.toLongString(state);
if (this.sc01) s += "\n SC-01: " + this.speechCount + " phonemes, " + (this.sc01.ready ? "ready" : "busy");
return s;
}
}
getRasterCanvasPosition() {
@@ -753,7 +830,7 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl
}
getVideoParams() {
if (this.arcade) {
return { width: 320, height: 204, rotate: 180 };
return { width: 320, height: 204, rotate: this.rotate };
} else {
return { width: 160, height: 102 };
}
+6 -1
View File
@@ -54,7 +54,12 @@ class BallyAstrocadeBIOSPlatform extends BallyAstrocadePlatform implements Platf
class BallyArcadePlatform extends BallyAstrocadePlatform implements Platform {
newMachine() { return new BallyAstrocade(true); }
// ?options=rotate%3D-90 turns the screen for vertical-monitor games like Gorf
newMachine() {
const m = new BallyAstrocade(true);
if (this.options.rotate != null) m.rotate = parseFloat(this.options.rotate) || 0;
return m;
}
getPresets() { return ASTROCADE_ARCADE_PRESETS; }
getMemoryMap = function() { return { main:[
+72
View File
@@ -119,4 +119,76 @@ describe('Bally Astrocade', function () {
assert.equal(8, mach.advanceCPU(), 'arcade=' + arcade); // LD (HL),A = 7 + 1 wait
}
});
// the arcade ROM image is flat: $0000-$3FFF and $8000-$BFFF map straight through
it('should map a flat arcade ROM image', function () {
var mach = new astrocade.BallyAstrocade(true);
var rom = new Uint8Array(0xc000);
rom[0x0123] = 0x55;
rom[0x8123] = 0x77;
rom[0xbfff] = 0x99;
mach.loadROM(rom);
assert.equal(0x55, mach.m.membus.read(0x0123));
assert.equal(0x77, mach.m.membus.read(0x8123));
assert.equal(0x99, mach.m.membus.read(0xbfff));
});
// port $78+n: 0/1 source, 2 mode, 3 skip, 4 dest high, 5 width, 6 height (starts the blit)
it('should run pattern board blits through the memory bus', function () {
var mach = new astrocade.BallyAstrocade(true);
mach.loadROM(new Uint8Array(0xc000));
var out = (port, v) => mach.m.iobus.write(port, v);
mach.write(0xd100, 0xaa); // static RAM, must survive
for (var i = 0; i < 8; i++) mach.write(0x4000 + i, 0xff);
mach.write(0xd12b, 0x00); // source byte
out(0x7a, 0x20); // mode: dest++, source fixed
out(0x78, 0x2b); out(0x79, 0xd1);
out(0x7b, 0x00); // skip
out(0x7c, 0x40); // dest high
out(0x7b, 0x01);
out(0x7d, 0x03); // 4 bytes wide
out(0x7e, 0x00); // 1 row
for (var i = 0; i < 4; i++) assert.equal(0, mach.read(0x4000 + i), 'cleared ' + i);
assert.equal(0xff, mach.read(0x4004));
// height is rows - 1: two rows of two bytes, 'skip' bytes apart
mach.write(0x4101, 0xff); mach.write(0x4102, 0xff); mach.write(0x4181, 0xff); mach.write(0x4200, 0xff);
out(0x7a, 0x20);
out(0x7b, 0x00); out(0x7c, 0x41); // dest $4100
out(0x7b, 0x80); // skip
out(0x7d, 0x01);
out(0x7e, 0x01);
assert.equal(0, mach.read(0x4100));
assert.equal(0, mach.read(0x4101));
assert.equal(0xff, mach.read(0x4102));
assert.equal(0, mach.read(0x4180));
assert.equal(0, mach.read(0x4181));
assert.equal(0xff, mach.read(0x4200), 'no third row');
assert.equal(0xaa, mach.read(0xd100));
});
// arcade horizontal drive is wired to the lightpen interrupt (INMOD bit 1 or 4)
it('should fire the arcade lightpen interrupt every line when enabled', function () {
for (var arcade of [false, true]) {
var mach = new astrocade.BallyAstrocade(arcade);
mach.connectVideo(new Uint32Array(320 * 262));
var n = 0;
var cpu = mach.cpu;
var orig = cpu.interrupt;
cpu.interrupt = function (data) { n++; mach.m.lastvec = data; };
mach.m.iobus.write(0x0d, 0xc4);
mach.m.iobus.write(0x0e, 0x18);
mach.m.iobus.write(0x0f, 0xff); // line interrupt never matches a visible line
for (var sl = 0; sl < 10; sl++) mach.m.drawScanline(sl);
assert.equal(arcade ? 10 : 0, n, 'arcade=' + arcade);
if (arcade) assert.equal(0xc0, mach.m.lastvec);
cpu.interrupt = orig;
}
});
// arcade screens are upright unless a vertical-monitor game asks (?options=rotate%3D-90)
it('should only rotate the arcade screen when asked', function () {
var mach = new astrocade.BallyAstrocade(true);
assert.equal(0, mach.getVideoParams().rotate);
mach.rotate = -90;
assert.equal(-90, mach.getVideoParams().rotate);
});
});