From cca1b9bb140ef0b583dce3e14c11f0bfd8bcc4e0 Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Sat, 5 Sep 2026 15:59:51 +0200 Subject: [PATCH] astrocade: improved wait state accuracy --- src/machine/astrocade.ts | 102 +++++++++++++++++++++++++------- test/cli/testastrocade.js | 121 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 21 deletions(-) create mode 100644 test/cli/testastrocade.js diff --git a/src/machine/astrocade.ts b/src/machine/astrocade.ts index a5ed6bf3..a59cf3d6 100644 --- a/src/machine/astrocade.ts +++ b/src/machine/astrocade.ts @@ -61,6 +61,7 @@ const audioOversample = 2; const _BallyAstrocade = function(arcade:boolean) { + var machine : BallyAstrocade; var cpu : Z80; var ram : Uint8Array; var membus, iobus : Bus; @@ -76,6 +77,29 @@ const _BallyAstrocade = function(arcade:boolean) { const PIXEL_ON = 0xffeeeeee; const PIXEL_OFF = 0xff000000; + /* + Screen RAM is DRAM shared by three requesters in priority order: DRAM refresh + (blanking only, so it never costs the CPU), screen refresh, then Z80/Magic. + CAS# free-runs, so memory timeslots are fixed and one Z80 M clock wide: + + - Every Z80 screen-RAM access takes at least T1,T2,Tw,T3 — "A Z80 access cycle + to this memory always results in at least one wait state" — blanked or not. + That's +1 over the usual 3 T-states. + - During active video, the refresh wins the slot. Consumer mode fetches 1 byte + (4 px) every 4 PX = every 2 M clocks → memory is unavailable half the time + → 50% of accesses eat a second wait state. Commercial/arcade fetches 4 bytes + (16 px) every 8 PX = every 4 M clocks → unavailable ¼ of the time → 25% eat + a second one. + - The fetch only runs for the 40 (consumer) / 20 (arcade) reads per line — 160 + px of the 63.5 µs line, ≈70%. Border and hblank are free, as is ROM and the + arcade's separate scratch RAM board. + */ + const REFRESH_SLOT_MASK = arcade ? 3 : 1; + // the refresh reads 40 (consumer) or 20 (commercial) times per line, covering the + // 160 displayed pixels of the 63.5 us line; the rest is border and hblank + const ACTIVE_LINE_FRACTION = 0.7; + var activecycles = 0; // = ACTIVE_LINE_FRACTION of a scanline, set by init() + // state variables var inputs = new Uint8Array(0x20); var magicop = 0; @@ -95,13 +119,27 @@ const _BallyAstrocade = function(arcade:boolean) { var rotcount = 0; var intst = 0; var waitstates = 0; + var slotphase = 0; var patboard = new Uint8Array(0x08); var patdest = 0; + // is the video refresh reading screen RAM at this point in the frame? + function refreshing() : boolean { + return machine.scanline < verbl && machine.getRasterX() < activecycles; + } + + // charge the Z80 for one screen RAM (DRAM) bus cycle + function screenwait() { + waitstates++; // always at least one wait state + if (refreshing() && !(slotphase++ & REFRESH_SLOT_MASK)) { + waitstates++; // and a second one when we collide with the screen refresh + } + } + function ramwrite(a: number, v: number) { // set RAM ram[a] = v; - waitstates++; + screenwait(); // mark scanline as dirty dirtylines[((a & samask) / swbytes) | 0] = 1; // this was old behavior where we updated instantly @@ -216,6 +254,7 @@ const _BallyAstrocade = function(arcade:boolean) { let curwidth: number; let u13ff: number = 0; let cycles: number = 0; + const wsave = waitstates; u13ff = 0; @@ -296,9 +335,10 @@ const _BallyAstrocade = function(arcade:boolean) { m_pattern_height--; } - // Adjust m_maincpu.icount - // m_maincpu.adjust_icount(-cycles); - // Replace the above line with the actual adjustment of icount. + // the pattern board owns the bus while it runs, so its own cycles (two reads + // and two writes per byte, as MAME counts them) replace the Z80 wait states + // that the transfers above accrued + waitstates = wsave + cycles; } this.drawScanline = (sl:number) => { @@ -320,7 +360,9 @@ const _BallyAstrocade = function(arcade:boolean) { } } - this.init = (machine:BallyAstrocade, c:Z80, r:Uint8Array, inp:Uint8Array, psgg) => { + this.init = (mach:BallyAstrocade, c:Z80, r:Uint8Array, inp:Uint8Array, psgg) => { + machine = mach; + activecycles = mach.cpuCyclesPerLine * ACTIVE_LINE_FRACTION; cpu = c; ram = r; inputs = inp; @@ -333,7 +375,7 @@ const _BallyAstrocade = function(arcade:boolean) { read: newAddressDecoder([ [0x0000, 0x1fff, 0x1fff, function(a) { return bios[a]; }], [0x2000, 0x3fff, 0x1fff, function(a) { return rom ? rom[a] : 0; }], - [0x4000, 0x4fff, 0xfff, function(a) { waitstates++; return ram[a]; }], + [0x4000, 0x4fff, 0xfff, function(a) { screenwait(); return ram[a]; }], ]), write: newAddressDecoder([ [0x4000, 0x4fff, 0xfff, ramwrite], @@ -341,18 +383,18 @@ const _BallyAstrocade = function(arcade:boolean) { ]), } } else { - // arcade game (TODO: wait states 1/4 of the time) + // arcade game membus = { read: newAddressDecoder([ - [0x4000, 0x7fff, 0x3fff, function(a) { return ram[a]; }], // screen RAM - [0xd000, 0xdfff, 0x0fff, function(a) { return ram[a + 0x4000]; }], // static RAM + [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 ]), write: newAddressDecoder([ [0x4000, 0x7fff, 0x3fff, ramwrite], [0x0000, 0x3fff, 0x3fff, magicwrite], - [0xd000, 0xdfff, 0x0fff, function(a, v) { ramwrite(a + 0x4000, v); }], // static RAM + [0xd000, 0xdfff, 0x0fff, function(a, v) { ram[a + 0x4000] = v; }], // static RAM (not displayed, no wait states) ]), } } @@ -458,8 +500,8 @@ const _BallyAstrocade = function(arcade:boolean) { } } } - machine.connectCPUMemoryBus(membus); - machine.connectCPUIOBus(iobus); + mach.connectCPUMemoryBus(membus); + mach.connectCPUIOBus(iobus); this.membus = membus; this.iobus = iobus; // default palette @@ -468,12 +510,21 @@ const _BallyAstrocade = function(arcade:boolean) { } }; - this.resetWaitStates = function(sl) { - var n = sl < verbl ? waitstates : 0; // only wait if video active + // wait states accrued since the last call, charged to the CPU by advanceCPU() + this.resetWaitStates = function() { + var n = waitstates; waitstates = 0; return n; } + // read for the debugger/UI, without stalling the CPU + this.peek = function(a: number) { + var n = waitstates; + var v = membus.read(a); + waitstates = n; + return v; + } + this.loadState = (state) => { cpu.loadState(state.c); ram.set(state.ram); @@ -491,6 +542,7 @@ const _BallyAstrocade = function(arcade:boolean) { rotcount = state.rotcount; rotdata.set(state.rotdata); intst = state.intst; + slotphase = state.slotphase | 0; inputs.set(state.inputs); patboard.set(state.patboard); patdest = state.patdest; @@ -516,6 +568,7 @@ const _BallyAstrocade = function(arcade:boolean) { rotcount: rotcount, rotdata: rotdata.slice(0), intst: intst, + slotphase: slotphase, patboard: patboard.slice(0), patdest: patdest, }; @@ -565,12 +618,12 @@ const _BallyAstrocade = function(arcade:boolean) { export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddleInput { cpuFrequency = 1789000; - numTotalScanlines = 262; + numTotalScanlines : number; numVisibleScanlines : number; cpuCyclesPerLine : number; defaultROMSize : number; canvasWidth : number; - sampleRate = 60 * 262 * audioOversample; + sampleRate : number; ram : Uint8Array; cpu : Z80; m; // _BallyAstrocade @@ -586,7 +639,14 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl this.arcade = arcade; this.cpu = new Z80(); this.psg = new AstrocadeAudio(new MasterAudio()); - this.audioadapter = new TssChannelAdapter(this.psg.psg, audioOversample, this.sampleRate); + // in consumer mode every line of screen RAM is displayed twice, so one emulated + // scanline is two of the 262 NTSC lines (as are INLIN and VERBL, hence the >>1) + this.numTotalScanlines = arcade ? 262 : 131; + // one audio sample per emulated scanline, so oversample twice as much in + // consumer mode to keep the sample rate the same + var oversample = audioOversample * (arcade ? 1 : 2); + 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.ram = new Uint8Array(arcade ? 0x5000 : 0x1000); @@ -606,7 +666,7 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl } read(a:number) : number { - return this.m.membus.read(a); + return this.m.peek(a); } write(a:number, v:number) : void { this.m.membus.write(a,v); @@ -619,7 +679,7 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl this.m.connectVideo(this.backbuffer); } preFrame() { - this.m.resetWaitStates(0); + this.m.resetWaitStates(); } postFrame() { // copy back buffer to front buffer, omitting bottom non-visible pixels @@ -652,8 +712,8 @@ export class BallyAstrocade extends BasicScanlineMachine implements AcceptsPaddl advanceCPU() { var clk = super.advanceCPU(); - // TODO: disable 33% of the time (hblank) - var xtra = this.m.resetWaitStates(this.scanline); + // charge the CPU for screen RAM contention during this instruction + var xtra = this.m.resetWaitStates(); if (xtra) { clk += xtra; this.probe.logClocks(xtra); diff --git a/test/cli/testastrocade.js b/test/cli/testastrocade.js new file mode 100644 index 00000000..3a677126 --- /dev/null +++ b/test/cli/testastrocade.js @@ -0,0 +1,121 @@ + +var assert = require('assert'); +var fs = require('fs'); +var vm = require('vm'); + +// tss audio channels are plain scripts that install themselves as globals +global.window = global; +global.self = global; +function includeInThisContext(path) { + vm.runInThisContext(fs.readFileSync(path), path); +} +includeInThisContext('tss/js/Log.js'); +includeInThisContext('tss/js/tss/PsgDeviceChannel.js'); +includeInThisContext('tss/js/tss/MasterChannel.js'); +includeInThisContext('tss/js/tss/AudioLooper.js'); + +var astrocade = require('gen/machine/astrocade.js'); + +const CPU_FREQ = 1789000; + +// put the raster somewhere specific, then count the wait states one access costs +function waits(mach, scanline, rasterx, fn) { + mach.scanline = scanline; + mach.frameCycles = rasterx; + mach.m.resetWaitStates(); + fn(); + return mach.m.resetWaitStates(); +} + +describe('Bally Astrocade', function () { + + // one emulated scanline is one NTSC line in the arcade and two in the console + // (consumer mode displays each line of screen RAM twice), so both run the Z80 + // at 1.789 MHz over a 262-line frame + it('should run the CPU at 1.789 MHz', function () { + for (var arcade of [false, true]) { + var mach = new astrocade.BallyAstrocade(arcade); + var cyclesPerFrame = mach.numTotalScanlines * mach.cpuCyclesPerLine; + assert.ok(Math.abs(cyclesPerFrame * 60 - CPU_FREQ) < CPU_FREQ * 0.01, + 'CPU runs at ' + (cyclesPerFrame * 60) + ' Hz, arcade=' + arcade); + assert.equal(262, mach.numTotalScanlines * (arcade ? 1 : 2)); + assert.equal(31440, mach.sampleRate); + } + }); + + // "A Z80 access cycle to this memory always results in at least one wait state" + // -- Bally Professional Arcade Video Hardware Description (Tony Miller, 2001) p.9 + it('should wait one cycle per screen RAM access outside of active video', function () { + var mach = new astrocade.BallyAstrocade(false); + var vblank = mach.numVisibleScanlines + 10; + assert.equal(1, waits(mach, vblank, 0, () => mach.write(0x4000, 0x55))); + assert.equal(1, waits(mach, vblank, 0, () => mach.m.membus.read(0x4000))); + assert.equal(1, waits(mach, vblank, 0, () => mach.write(0x0000, 0x55))); // magic write + // ...and during the border/hblank part of a visible line, too + var hblank = mach.cpuCyclesPerLine - 1; + assert.equal(1, waits(mach, 50, hblank, () => mach.write(0x4000, 0x55))); + }); + + it('should not wait for ROM', function () { + var mach = new astrocade.BallyAstrocade(false); + assert.equal(0, waits(mach, 50, 0, () => mach.m.membus.read(0x0000))); // BIOS + assert.equal(0, waits(mach, 50, 0, () => mach.m.membus.read(0x2000))); // cart + }); + + // during active video the screen refresh takes every other memory timeslot in + // consumer mode, so half of the Z80's accesses eat a second wait state + it('should wait 1.5 cycles on average during active video (consumer)', function () { + var mach = new astrocade.BallyAstrocade(false); + var n = waits(mach, 50, 0, () => { + for (var i = 0; i < 8; i++) mach.write(0x4000 + i, 0x55); + }); + assert.equal(12, n); + }); + + // in commercial mode it reads four bytes at a time, so it only takes one + // timeslot in four and only a quarter of the accesses wait twice + it('should wait 1.25 cycles on average during active video (arcade)', function () { + var mach = new astrocade.BallyAstrocade(true); + var n = waits(mach, 50, 0, () => { + for (var i = 0; i < 8; i++) mach.write(0x4000 + i, 0x55); + }); + assert.equal(10, n); + // reads are contended just the same + n = waits(mach, 50, 0, () => { + for (var i = 0; i < 8; i++) mach.m.membus.read(0x4000 + i); + }); + assert.equal(10, n); + }); + + // the arcade's scratch RAM is on its own board, not the screen RAM DRAM bus + it('should not wait for arcade scratch RAM', function () { + var mach = new astrocade.BallyAstrocade(true); + assert.equal(0, waits(mach, 50, 0, () => mach.write(0xd000, 0x55))); + assert.equal(0, waits(mach, 50, 0, () => mach.m.membus.read(0xd000))); + assert.equal(0x55, mach.read(0xd000)); + }); + + // the debugger reads memory constantly; that must not stall the CPU + it('should not wait when peeking for the debugger', function () { + var mach = new astrocade.BallyAstrocade(false); + var n = waits(mach, 50, 0, () => { + for (var i = 0; i < 0x1000; i++) mach.read(0x4000 + i); + }); + assert.equal(0, n); + }); + + // wait states have to reach the CPU's cycle count, whatever the raster is doing + it('should charge wait states to the CPU', function () { + for (var arcade of [false, true]) { + var mach = new astrocade.BallyAstrocade(arcade); + // LD HL,$4000 / LD (HL),A -- the store is 7 T-states on a plain Z80 + var prog = new Uint8Array([0x21, 0x00, 0x40, 0x77, 0x76]); + if (arcade) mach.loadROM(prog); else mach.loadBIOS(prog); + mach.reset(); + mach.scanline = mach.numVisibleScanlines + 10; // vblank: exactly one wait state + mach.frameCycles = 0; + mach.advanceCPU(); // LD HL,nn + assert.equal(8, mach.advanceCPU(), 'arcade=' + arcade); // LD (HL),A = 7 + 1 wait + } + }); +});