From bb9a4018750b5825bdbeb7c0672554ec9727beee Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Sun, 11 Oct 2026 14:24:31 +0200 Subject: [PATCH] astrocade: improved astrocade-arcade platform, fixed astrocade gfx bugs, machine options (e.g. video rotate) --- CHANGELOG.md | 2 + presets/astrocade/acextra.c | 3 +- src/common/baseplatform.ts | 4 +- src/machine/astrocade.ts | 115 ++++++++++++++++++++++++++++++------ src/platform/astrocade.ts | 7 ++- test/cli/testastrocade.js | 72 ++++++++++++++++++++++ 6 files changed, 181 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b848347d..435dc5df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/presets/astrocade/acextra.c b/presets/astrocade/acextra.c index 5012db85..94364bcd 100644 --- a/presets/astrocade/acextra.c +++ b/presets/astrocade/acextra.c @@ -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; diff --git a/src/common/baseplatform.ts b/src/common/baseplatform.ts index cf722caa..2c7b52f2 100644 --- a/src/common/baseplatform.ts +++ b/src/common/baseplatform.ts @@ -814,6 +814,7 @@ export function hasSerialIO(arg: any): arg is HasSerialIO { export abstract class BaseMachinePlatform 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 extends BaseDebugPl abstract getDefaultExtensions(): string[]; abstract getPresets(): Preset[]; - constructor(mainElement: HTMLElement) { + constructor(mainElement: HTMLElement, options?: any) { super(); this.mainElement = mainElement; + this.options = options || {}; } reset() { diff --git a/src/machine/astrocade.ts b/src/machine/astrocade.ts index a59cf3d6..bae7f5e5 100644 --- a/src/machine/astrocade.ts +++ b/src/machine/astrocade.ts @@ -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 }; } diff --git a/src/platform/astrocade.ts b/src/platform/astrocade.ts index 9f99a99a..f9a23bf9 100644 --- a/src/platform/astrocade.ts +++ b/src/platform/astrocade.ts @@ -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:[ diff --git a/test/cli/testastrocade.js b/test/cli/testastrocade.js index b79848f5..86a2cf86 100644 --- a/test/cli/testastrocade.js +++ b/test/cli/testastrocade.js @@ -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); + }); });