diff --git a/docs/ApiAudio.adoc b/docs/ApiAudio.adoc index 755fc19..1693d6a 100644 --- a/docs/ApiAudio.adoc +++ b/docs/ApiAudio.adoc @@ -29,6 +29,16 @@ silent, `jlAudioTickRegister` refuses, and the log says so. The tone and noise voices, and so `jlMusicPlay`, still play where they drive a sound chip of their own: on the Amiga, the ST, DOS, and the X68000. +The mix is the same on every port, within 3 dB: a sound effect at full scale, +and a module channel at full volume playing a full-scale sample, are each as +loud as a tone voice at attenuation 0 (a square wave sampled at full scale +peaks where the voice does), and the noise at attenuation 0 is as loud as a +voice, so modules, effects, voices, and `jlMusicPlay` keep their balance from +one machine to the next. The ports that mix in software put that peak at a +quarter of full scale, so the three voices and the noise together, or a +module's four channels, fit without clipping (the plain ST is the exception, +<>). + === Constants [cols="3,1,4", options="header"] @@ -113,7 +123,11 @@ void jlAudioStopMod(void); ---- Stops the current module and resets the playhead, so the next `jlAudioPlayMod` -starts from the top. +starts from the top. It goes silent at once on every port: the ports that mix +ahead of the sound chip (DOS and the X68000) take it back out of what they +have mixed, leaving the sound effects there playing on, as they start a new +module, or a new sound effect, in what is mixed already rather than after +it. *Parameters:* none. *Returns:* nothing. @@ -148,7 +162,9 @@ new sample replaces it. |`sample` |`const uint8_t *` |Raw signed 8-bit PCM. |`length` |`uint32_t` |Sample count in bytes. |`rateHz` |`uint16_t` |The rate the sample was recorded at; the engine - re-pitches to its own output rate. + re-pitches to its own output rate. Any rate plays at its pitch: one faster + than the Amiga's or the IIgs's chip skips samples there + (<>). |=== *Returns:* nothing. @@ -203,7 +219,7 @@ with no caller-side allocation. |`fill` |`jlAudioStreamFillT` |Refill callback. |`ctx` |`void *` |Opaque pointer handed back to the callback. |`rateHz` |`uint16_t` |Rate your callback produces; the engine resamples with - linear interpolation. + linear interpolation. Any rate plays at its pitch, as for `jlAudioPlaySfx`. |=== *Returns:* nothing. @@ -232,9 +248,10 @@ void jlAudioPlaySfxStreamRaw(uint8_t slot, jlAudioStreamFillT fill, `jlAudioPlaySfxStreamRaw` is `jlAudioPlaySfxStream` except your callback delivers bytes *already in that format*, so the port skips its per-byte conversion of every chunk. On the IIgs that conversion costs about 7 -microseconds a byte, some 50 ms for each 7 KB chunk. Every other port's raw -format is signed PCM, so there this is simply the plain call (DVX still flips -each byte for its sound library). +microseconds a byte, some 50 ms for each 7 KB chunk. DVX's raw format is the +IIgs's too, since its sound library's 8-bit PCM is unsigned, and it skips its +flip of every byte. Every other port's raw format is signed PCM, so there this +is simply the plain call. If your producer cannot make the port's format, use `jlAudioPlaySfxStream`. @@ -279,7 +296,12 @@ What each port can actually do: PTPlayer picks. |X68000 |Ignored. Everything mixes into one ADPCM stream, so panning would move all audio at once. -|DOS, ST, STe |Ignored. Output is mono. +|DOS |All three on an SB16, which plays stereo: the module's channels on Paula's + sides, as on the Amiga, and each effect on its side. Ignored on the Sound + Blasters before it, which play mono. +|ST |Ignored. Output is mono. +|STe build |All three: the DMA chip plays stereo, the module's channels on + Paula's sides, as on the Amiga, and each effect on its side. |DVX |All three, through DVX's stereo mixer (on a stereo card). |SDL3, web, Win32, PSP, Haiku, BeOS |All three: the port mixes in stereo. |Win16 |Ignored. Output is mono. @@ -349,10 +371,10 @@ channels A, B, and C (on the plain ST voice 0 is silent while digital sound plays); the Amiga uses Paula channels 0..2 looping a two-byte square sample; the IIgs uses Ensoniq DOC oscillators playing a resident square on sound effect slots 0..2; the X68000 uses YM2151 channels 0..2, one operator each, -so its tones are sine waves; DVX plays square tones of its sound library, -which puts them on the FM chip when there is a free voice and mixes them -otherwise; the hosted ports mix square waves in software. The OPL2's waves -are not square either. +so its tones are sine waves; DVX and the hosted ports mix square waves in +software (DVX through a stream of its sound library's mixer; on a machine +whose only sound is the PC speaker, DVX's own square tones instead). The +OPL2's waves are not square either. [IMPORTANT] ==== @@ -387,9 +409,10 @@ every port as far as its chip goes. Mapping: the ST uses the YM2149 noise generator mixed onto channel C; the X68000 the YM2151's noise, at the same rates exactly; the Amiga Paula channel 3 looping an LFSR noise sample, whose fastest rate, 28.6 kHz, pitches 0 to 4 all play at; the IIgs a resident noise -sample on slot 3, up to 26 kHz, likewise for pitches 0 to 4; DVX a noise tone -of its sound library; and the hosted ports mix the YM2149's own noise in -software (its 17-bit generator), so it sounds as on the ST. DOS uses the OPL2 +sample on slot 3, up to 26 kHz, likewise for pitches 0 to 4; and DVX and the +hosted ports mix the YM2149's own noise in software (its 17-bit generator), +so it sounds as on the ST (DVX with only the PC speaker: a noise tone of its +sound library). DOS uses the OPL2 rhythm-mode snare, leaving the three tone channels untouched: a tuned drum, not free-running noise, whose pitch spreads over the snare's range rather than following the YM2149's rate. @@ -516,9 +539,8 @@ frame, so the chip only hears the last write and short notes vanish. |=== *Returns:* `true` if installed. `false` when `hz` is 0, when audio is off or -did not start, or when the port cannot take the rate (above 1000 Hz on DVX, or -above the mixer's rate on the hosted ports). Uninstalling always returns -`true`. +did not start, or when the port cannot take the rate (above the mixer's rate +on DVX and the hosted ports). Uninstalling always returns `true`. [WARNING] ==== @@ -529,10 +551,10 @@ allocation, no `printf`, nothing blocking -- and share data with it through ==== On every port the calls average exactly `hz` a second; where they fall -differs. DOS, the Amiga, and the hosted ports place each one at its own time. -The ST and X68000 deliver them from the system's own timer, 200 and 100 times a -second, so the calls due between two of its ticks come together. The IIgs and -DVX deliver them in program time from `jlAudioFrameTick`, so a frame's calls +differs. DOS, the Amiga, DVX, and the hosted ports place each one at its own +time. The ST and X68000 deliver them from the system's own timer, 200 and 100 +times a second, so the calls due between two of its ticks come together. The +IIgs delivers them in program time from `jlAudioFrameTick`, so a frame's calls come together at it. On DOS this reprograms PIT counter 0 and hooks INT 8, keeping the BIOS tick @@ -548,17 +570,15 @@ On the IIgs `fn` runs in program time, at `jlAudioFrameTick`, as many times as the display's beats since the last frame have made due, so the average is exact whatever the program's frame rate. -On DVX a timer of DVX's (at most 1000 Hz) counts the ticks, and `fn` runs in -program time, once per counted tick, at the next `jlAudioFrameTick`: DVX's -sound library, which `jlAudioVoice` and the rest go through there, may not be -called from an interrupt. `fn` may therefore do anything, but what it does -lands on the frame, not between frames. - -On the hosted ports the sound is mixed in program time, a few frames ahead, -and `fn` is called by the mixer between exactly the two samples where its tick -falls: in program time, so it may do anything, yet a note it starts begins at -the right place in the sound. `hz` may be anything up to the mixer's rate, -44100 (22050 on Win16). +On DVX and the hosted ports the sound is mixed in program time, a few frames +ahead (on DVX by its sound library's pump, which DVX also runs while the +desktop has the processor), and `fn` is called by the mixer between exactly +the two samples where its tick falls: in program time, so it may do anything, +yet a note it starts begins at the right place in the sound. `hz` may be +anything up to the mixer's rate: 44100 on the hosted ports (22222, or 22050 +where the card refuses that, on Win16), and on DVX the sound card's mixing +rate. DVX's ticks keep time on a machine with no sound card too: its null +driver runs the mixer as a card would. === jlAudioCriticalEnter / jlAudioCriticalExit diff --git a/docs/ApiCore.adoc b/docs/ApiCore.adoc index 6e32f99..7c6d0ae 100644 --- a/docs/ApiCore.adoc +++ b/docs/ApiCore.adoc @@ -19,14 +19,15 @@ typedef struct { |`codegenBytes` |`uint32_t` |Size of the runtime compiled-sprite cache. `0` selects a per-platform - default: 8 KB on the IIgs, 64 KB on the Amiga and ST, and 32 KB on the - X68000, DOS, DVX, and Win16. The other hosted ports interpret every sprite - and never use the cache, but still allocate it (32 KB by default). The - cache is one block, and `jlInit` fails if it cannot be had -- except on the - IIgs, where it is up to four Memory Manager blocks of at most 64 KB, each - inside one 64 KB bank, so at most 256 KB; a bank that is refused is tried - again at 60 KB, and `jlInit` takes whatever part of the request it was - granted. Size it against `jlSpriteCompiledSize()`. + default: 8 KB on the IIgs, 64 KB on the Amiga, ST, and X68000, and 32 KB on + DOS, DVX, and Win16. The other hosted ports interpret every sprite and have + no cache: they allocate none, whatever is asked. Every port takes as much of + the request as the machine has, and `jlInit` fails only if it can have none + of it; `jlSpriteCodegenBytesTotal()` says what it got. On the IIgs the cache + is up to four Memory Manager blocks of at most 64 KB, each inside one 64 KB + bank, so at most 256 KB (a bank that is refused is tried again at 60 KB); + elsewhere it is one block, halved until the heap has room, down to 4 KB. + Size it against `jlSpriteCompiledSize()`. |`noAudio` |`bool` @@ -60,9 +61,9 @@ sound chip needs no engine: the Amiga, the ST, DOS, and the X68000. |=== *Returns:* `true` on success. On failure returns `false` and `jlLastError()` -describes the reason. Where the port's own start-up fails without saying -why, as the IIgs, Amiga, ST, X68000, and DVX ports' can, the reason is just -`jlpInit failed`. +describes the reason, on every port: for example, the plain ST refuses a +monochrome monitor, and the Amiga says when chip RAM cannot hold the two +screen pages. === jlShutdown @@ -74,8 +75,10 @@ void jlShutdown(void); Shuts the library down: stops audio first, then restores the display mode, releases the library's own resources, and un-hooks any interrupt vectors the port installed. Surfaces and sprites the program created are not released: -destroy them with `jlSurfaceDestroy` and `jlSpriteDestroy` first. Call it on -every exit path; skipping it can leave the machine unusable. +destroy them with `jlSurfaceDestroy` and `jlSpriteDestroy` first. A program +that ends without calling it, by returning from `main` or calling `exit`, has +it called then: the first successful `jlInit` registers it with `atexit`. +Nothing can run it after a crash or an `abort`. *Parameters:* none. *Returns:* nothing. @@ -191,7 +194,11 @@ Blocks until the next display vertical blank, pacing a game loop to the display's native refresh rate. On the machine ports it waits for the display's own vertical blank. The hosted ports, and DVX in a window, have no vertical blank to wait for: there it waits for the next tick of a virtual -frame clock, 60 Hz on the hosted ports and 70 Hz on DVX, and nothing stops a +frame clock, 60 Hz on the hosted ports and 70 Hz on DVX. A hosted window shows +one frame a tick, as a display shows one a refresh (a present in a tick +already shown waits for the next); a desktop that composites its windows, as +every current one does, puts only whole frames on the screen, but on one that +does not (Windows 3.1, a bare X11 desktop) and in a DVX window nothing stops a present from tearing. *Parameters:* none. *Returns:* nothing. @@ -243,9 +250,15 @@ seconds. uint32_t jlMillisElapsed(void); ---- -Monotonic millisecond counter since `jlInit`. It may advance a frame at a -time. Two calls separated by a long render report the true elapsed time, not -the number of vertical blanks that fired. +Monotonic millisecond counter since `jlInit`, read from a hardware timer to +the millisecond on every port: the IIgs from the frame count and where the +beam is, the ST and the X68000 from their system tick and the MFP timer +counting down to the next, the Amiga from the E-clock, and DOS from the PIT. +A machine whose timer is taken (an X68000 whose Timer C a resident program +has set to another rate, an Amiga with no timer.device) falls back to the +frame count and advances a frame at a time. Two calls separated by a long +render report the true elapsed time, not the number of vertical blanks that +fired. *Parameters:* none. diff --git a/docs/ApiDebug.adoc b/docs/ApiDebug.adoc index 8637328..8db23f7 100644 --- a/docs/ApiDebug.adoc +++ b/docs/ApiDebug.adoc @@ -2,13 +2,13 @@ Declared in ``. -A crash-tracing logger that writes `joeylog.txt` in the current directory -(on DVX, in the program's settings folder, -`CONFIG\APPS\JOEYLIB\NAME\JOEYLOG.TXT`). Debugging a program on an emulated -1988 machine is awkward, and this is usually the fastest way to find out what -it did before it stopped doing it. The headless run scripts retrieve the log -automatically. The library itself writes to it only when something fails, such -as sound that does not start. +A crash-tracing logger that writes `joeylog.txt` in the program's folder, +beside `DATA/` and `SAVES/` (<<_files>>: on the ST, the current directory; on +DVX, the program's settings folder, `CONFIG\APPS\JOEYLIB\NAME\JOEYLOG.TXT`). +Debugging a program on an emulated 1988 machine is awkward, and this is usually +the fastest way to find out what it did before it stopped doing it. The +headless run scripts retrieve the log automatically. The library itself writes +to it only when something fails, such as sound that does not start. How the file is written differs: @@ -20,21 +20,19 @@ How the file is written differs: `jlLogFlush`, which replaces the file with the last 8 KB logged. A program that never calls it leaves no log. * *The web* writes the file into the page's memory, where it is lost when the - page closes. -* *The hosted ports* make the program's folder the current directory in - `jlInit`: a line logged before that goes to a log in the directory the - program was started from, and the rest of the run's lines follow it there. + page closes, so every line also goes to the browser's console. [NOTE] ==== The logger's buffer is linked into every program, since the library logs its -own failures: the 8 KB ring on the IIgs, and a 16 KB file buffer on every +own failures: the 8 KB ring on the IIgs, and a 512-byte line buffer on every other port but DVX. A memory-tight program can still leave its own log calls out and observe hardware state through the emulator instead. ==== -On DOS the library also creates `CRASH.LOG` in the current directory at -`jlInit`, and writes the processor's registers there if the program faults. +On DOS the library also opens `CRASH.LOG` beside the program at `jlInit`, and +writes the processor's registers there if the program faults; a run that shuts +down cleanly deletes it, so it is only there after a crash. === jlLog @@ -61,8 +59,8 @@ void jlLogF(const char *fmt, ...); ---- Appends a formatted message, `printf` style, as one line: like `jlLog`, the -library ends the line, so the format needs no trailing `\n`. On the IIgs a -line is cut at 199 characters. +library ends the line, so the format needs no trailing `\n`. A formatted line +is cut at 255 characters, on every port. [cols="1,1,4", options="header"] |=== diff --git a/docs/ApiFile.adoc b/docs/ApiFile.adoc index 91f346e..1733926 100644 --- a/docs/ApiFile.adoc +++ b/docs/ApiFile.adoc @@ -8,24 +8,29 @@ packager: * `DATA/` -- game content: levels, sprites, audio. * `SAVES/` -- writable saves and preferences. -Where the calls look for them: +The calls find them in the program's own folder, wherever the program was +started from, and before `jlInit` as well as after (the log, `joeylog.txt`, +goes there too): -* *The hosted ports* make the program's own folder the current directory in - `jlInit`, so both are found beside the program wherever it was started - from. -* *DOS, the ST, the Amiga, and the X68000* look in the current directory. A - program started from its own folder (as a desktop starts it) finds them; - one started from a command line elsewhere finds no `DATA/` and makes its - `SAVES/` there. -* *The IIgs* looks for `DATA/` under GS/OS prefix 0 and puts `SAVES/` in the - application's folder (prefix 1). When the two differ, data and saves come - from different folders. -* *DVX* is the exception, because its programs share one current directory: - there `DATA/` is beside the program, in its folder under DVX's `APPS`, and - `SAVES/` is in the program's own settings folder, +* *DOS* takes the folder from the path it started the program by, *the Amiga* + uses `PROGDIR:` (Kickstart 2.0 and later; under 1.3, the current + directory), *the X68000* reads it from the program's process block, *the + IIgs* uses GS/OS prefix 1 (the application's folder), and *the hosted ports* + take it from their executable's path. The hosted ports also make it the + current directory in `jlInit`, for the program's own relative paths. +* *The ST* looks in the current directory: TOS tells a program nothing of + where it was started from. A program the desktop starts runs in its own + folder; one started from a command line elsewhere finds no `DATA/` and makes + its `SAVES/` there. +* *DVX* keeps the two apart, because its programs share one current + directory: there `DATA/` is beside the program, in its folder under DVX's + `APPS`, and `SAVES/` is in the program's own settings folder, `CONFIG\APPS\JOEYLIB\NAME`, which DVX keeps apart from the program files so - a reinstall never overwrites it. The calls below find both there by - themselves. + a reinstall never overwrites it. +* *The web* keeps them apart too: `DATA/` is packed into the program, and + `SAVES/` and the log are in a folder named after the page + (`games/hero.html` has `/games/hero/`), its `SAVES/` kept in the browser's + storage for that page alone (<<_web_programs>>). Both are addressed by a *bare name relative to their directory*. The prefix is added for you, so never hard-code `"DATA/"` or `"SAVES/"` yourself. @@ -97,9 +102,12 @@ whole-file blob prefer `jlSaveWrite` and `jlSaveRead`. bool jlSaveWrite(const char *name, const void *buf, uint32_t len); ---- -Writes a whole save file, replacing any existing contents. The write is not -atomic: if it fails part way (a full disk, say) the save is left short. A -`NULL` `buf` with a non-zero `len` returns `false` and leaves the save alone. +Writes a whole save file, replacing any existing contents. The bytes go to a +temporary file first, and only once all of them are written does it take the +save's place, so a write that fails part way (a full disk, say, or the machine +switched off) leaves the old save as it was. A `NULL` `buf` with a non-zero +`len` returns `false` and leaves the save alone. A save written with +`jlSaveOpen` is written in place, as `fopen` writes. [cols="1,1,4", options="header"] |=== diff --git a/docs/ApiInput.adoc b/docs/ApiInput.adoc index ec87b71..e32ddd6 100644 --- a/docs/ApiInput.adoc +++ b/docs/ApiInput.adoc @@ -97,9 +97,9 @@ characters are dropped until you pop some. A chord types nothing on any port: Ctrl+M is not Return and Ctrl+1 is not 1, and Alt chords type nothing where Alt is a command key (DOS, DVX, Windows, the Be ports' Command key, the PSP's on-screen keyboard); the Amiga's Alt and Windows' AltGr type their layout's -characters, and so does the ST's Alt where TOS keeps its keyboard's Alt -layer in its tables (TOS 4.00 and later, and EmuTOS: a German keyboard's -@ [ ] { } \), and otherwise nothing (<>). A held key +characters, and so does the ST's Alt, the characters its national +keyboard's Alt layer gives (a German keyboard's @ [ ] { } \; +<>). A held key repeats its character on every port (the ST at the rate TOS is set to); repeat policy is yours to manage. 7-bit ASCII only -- no Unicode, no input methods. diff --git a/docs/ApiSerial.adoc b/docs/ApiSerial.adoc index aea5cf3..21befae 100644 --- a/docs/ApiSerial.adoc +++ b/docs/ApiSerial.adoc @@ -23,15 +23,15 @@ the ST goes through the BIOS Aux channel; the X68000 transmits through IOCS and receives on its own SCC interrupt; DVX goes through DVX's serial library, which owns the COM ports for every program on the desktop (a port another program has open cannot be opened); Win32 and Win16 go through Windows' own -communications calls, whose driver owns the ports the same way. SDL3, the web, -the PSP, Haiku, and BeOS have no serial port: `jlSerialOpen` fails there. +communications calls, whose driver owns the ports the same way; SDL3 on Linux, +Haiku, and BeOS open the port as a terminal device through termios. The web and +the PSP have no serial port: `jlSerialOpen` fails there. The model is *non-blocking and buffered*. Received bytes accumulate in a background ring -- a hardware interrupt on DOS, the X68000, and the IIgs, the -OS on Amiga, ST, DVX, and Windows -- and reads never block. Call `jlSerialPoll` -once per frame. (The X68000 takes its interrupt only when the port is opened in -supervisor mode, which `jlInit` puts every program in; opened without it, the -port is drained from RSDRV.SYS's own 64-byte buffer by the serial calls.) +OS on Amiga, ST, DVX, Windows, Linux, Haiku, and BeOS -- and reads never +block. Call `jlSerialPoll` +once per frame. On the IIgs a Super Serial Card only interrupts with its interrupt switch (SW2-6) on; with it off the card still works, drained by `jlSerialPoll`, but @@ -70,9 +70,10 @@ at 9600 baud, so: At 19200 baud each lasts half as long, and so on. -A byte that arrives with a framing, parity, or overrun error is dropped on the -X68000 and delivered as it came on the IIgs and DOS; on the other ports the -system's driver decides. +A byte that arrives with a framing, parity, or overrun error is delivered as +it came on the IIgs, DOS, the X68000, Linux, Haiku, and BeOS (the port does +not ask the driver to check parity); on the other ports the system's driver +decides. JoeyLib's own work is safe while a port is open, with one exception on the X68000 (below). On the IIgs, where the @@ -106,6 +107,14 @@ These can still lose bytes, and are the program's to schedule around: that relies on the protocol to recover. The built-in modem and printer ports stay loss-free at 9600 through full-screen presents, clears, joystick reads, and sound effects. +* *The IIgs's built-in ports above 9600 baud.* At 19200 the present's ~1.7 ms + stretches outlast the chip's 3 bytes, and a stream received through + full-screen presents loses about one byte in 70. From 38400 up a steady + stream keeps the 65816 in its interrupt handler until it ends, even with + nothing drawn: the program gets no time until then, and the 512-byte receive + buffer keeps the first 511 bytes. Those rates suit bursts that fit the + buffer, a protocol that paces its blocks (XMODEM's acknowledgements do), and + sending, which they do at full speed. * *Per-scanline palettes on the X68000.* The port reloads its one hardware palette as the beam scans, and a run of palette changes on consecutive lines holds serial receive off for the whole run, about 32 microseconds a line. @@ -209,8 +218,9 @@ A field left 0 takes a default: `baud` 9600, `dataBits` 8, and `stopBits` 1. |Port |Rates |IIgs modem and printer ports -|115200 divided by a whole number, up to 57600; a rate between two of those - runs at the one above it, and anything faster at 57600. +|115200 divided by a whole number: 115200 itself straight from the ports' + crystal, the rest from the SCC's baud rate generator. A rate between two of + those runs at the one above it, and anything faster at 115200. |IIgs Super Serial Card |The 6551's fifteen rates, 50 to 19200; any other runs at the fastest one @@ -240,12 +250,21 @@ A field left 0 takes a default: `baud` 9600, `dataBits` 8, and `stopBits` 1. |Win16 |The rate as asked up to 57600; above that, see below. + +|SDL3 (Linux), Haiku, BeOS +|The nearest of 300, 600, 1200, 2400, 4800, 9600, 19200, 38400, 57600, and + 115200, handed to the system's driver. |=== The IIgs is the machine with three device choices; elsewhere `JL_SERIAL_MODEM` and `JL_SERIAL_PRINTER` both mean the default port. `JL_SERIAL_DEFAULT` selects the IIgs modem port; COM1 on DOS, DVX, Win32, and Win16; the ST Modem port; -Amiga unit 0; or the X68000's RS-232C port. +Amiga unit 0; the X68000's RS-232C port; on Linux `/dev/ttyS0`; or on Haiku and +BeOS the first entry of `/dev/ports`. On Linux, Haiku, and BeOS the +`JOEYLIB_SERIAL` environment variable names another device for the default +(and the modem and printer) port: a USB adapter, `JOEYLIB_SERIAL=/dev/ttyUSB0`. +Haiku's and BeOS's drivers take 7 or 8 data bits only, so 5 or 6 fail the +open there. On Win16 a `baud` above 57600 runs at 115200 through KPCOMM.DRV, the communications driver JoeyLib's Windows runs with in place of Windows 3.1's @@ -262,8 +281,16 @@ the usual tolerance of a UART at the far end. `unit` disambiguates `JL_SERIAL_SLOT` and is ignored otherwise: an IIgs Super Serial Card slot 1..7, a DOS or DVX COM number 1..4, a Win16 COM number -1..9, a Win32 COM number 1..255, or an Amiga `serial.device` unit. The ST and -X68000 have one port, and open it for every device, `JL_SERIAL_SLOT` included. +1..9, a Win32 COM number 1..255, an Amiga `serial.device` unit, or on Linux +`/dev/ttyS` and on Haiku and BeOS the unit'th entry of `/dev/ports` +in name order. On the ST family it is a port in TOS's Bconmap order: 1 the +Modem port, and on a Mega STe, TT, or Falcon 2 and up its other ports (a +Mega STe's SCC channel B, Modem 2, is 2 and its LAN port 3; a TT adds its +second MFP's port as 3 and has the LAN port at 4). The open maps TOS's AUX +device to that port and the close maps it back; a plain ST has unit 1 only. +The other devices open AUX wherever TOS has it, the Modem port unless the +user mapped it elsewhere. The X68000 has one port, and opens it for every +device, `JL_SERIAL_SLOT` included. === jlSerialOpen @@ -300,9 +327,11 @@ open when the program exits is closed then: the first successful `jlSerialOpen` registers an `atexit` handler, because on DOS, the X68000, and the IIgs the port's receive interrupt vector points into the program until it is closed. Bytes `jlSerialWrite` accepted are still sent, so a program can -write a farewell and close straight away. DOS, the ST, and the X68000 put back -the speed and framing the port had before the open; the IIgs (whose serial -chip's settings cannot be read back) and DVX leave it as JoeyLib set it. +write a farewell and close straight away. DOS, the ST, the X68000, Linux, +Haiku, and BeOS put back the speed and framing the port had before the open +(Linux, Haiku, and BeOS with HUPCL off, so the system's own last close keeps +DTR up); the IIgs (whose serial chip's settings cannot be read back) and DVX +leave it as JoeyLib set it. Closing does *not* hang up: `jlSerialClose` leaves DTR exactly as it is, so a modem call or a BBS caller survives the program handing the line back: a @@ -310,9 +339,11 @@ door returning to its BBS, or a terminal program exiting while the call stays up. RTS is not part of that promise; on the Amiga, `serial.device` puts back whatever RTS was before the open. -Windows cannot make the promise. On Win32 and Win16 the close gives the port -back to Windows' driver, and neither has a call that keeps DTR up past it. A -Windows program that must not hang up the line keeps the port open. +Win32 cannot make the promise: the close gives the port back to Windows' +driver, which has no call that keeps DTR up past it, so a Win32 program that +must not hang up the line keeps the port open. Win16 keeps it through +KPCOMM.DRV, the communications driver JoeyLib's Windows runs with; with +Windows 3.1's own COMM.DRV it drops too. To hang up, drop DTR first: call `jlSerialSetDtr(false)`, wait longer than the modem needs to notice (a Hayes modem's S25 setting, 50 ms by default), @@ -385,10 +416,10 @@ uint16_t jlSerialWrite(const uint8_t *buf, uint16_t len); ---- Sends bytes without waiting long. Most ports feed the bytes to the line as it -takes them and give up after a bounded wait when it stalls (a peer holding -transmit off, say); the Amiga queues them in two 1 KB buffers it sends from in -the background, and Win16 in Windows' driver's queue, so neither waits at -all. +takes them, and give up once it has taken none for 100 ms (a peer holding +transmit off, say). The Amiga queues them in two 1 KB buffers it sends from in +the background, DVX in its serial library's 2 KB ring, and Win16 in Windows' +driver's queue, so those never wait at all. [cols="1,1,4", options="header"] |=== @@ -465,6 +496,11 @@ Two ports tie DTR to more than the line: whenever it runs, so the next `jlSerialOpen` -- or another program setting the line -- raises it again. +On a Mega STe's, TT's, or Falcon's ports other than the Modem port (a +`JL_SERIAL_SLOT` open, or AUX mapped there) DTR stays as TOS set it: TOS has +no call for those ports' lines, and JoeyLib does not reach past its driver to +the SCC. + Closing after a hang-up keeps DTR down, but on two ports it rises briefly on the way: on the Amiga for under a millisecond (`serial.device` puts back the lines as they were at its open) and on the X68000 for about 0.2 ms (close puts @@ -487,7 +523,8 @@ pause actually needs. Under `JL_SERIAL_FLOW_RTSCTS` on DOS, the X68000, and a IIgs Super Serial Card, RTS is up only while both the port's handshake and this call want it; elsewhere the driver may change it again (see Flow Control). The IIgs modem and printer ports have no RTS line on their -connector, so nothing reaches the peer there. +connector, so nothing reaches the peer there, and a Mega STe's, TT's, or +Falcon's ports other than the Modem port keep RTS as TOS sets it (as DTR). [cols="1,1,4", options="header"] |=== diff --git a/docs/ApiSprite.adoc b/docs/ApiSprite.adoc index 8603fb1..b5a3d92 100644 --- a/docs/ApiSprite.adoc +++ b/docs/ApiSprite.adoc @@ -70,7 +70,7 @@ static uint16_t gBacking[jlSpriteBackupLength(uint16_t, 4, 4)]; ==== Give the backing array *at least 2-byte alignment*. On the Amiga, ST, and X68000 an odd buffer address sends the save and restore calls down a much -slower path, and on the Amiga and ST it makes the trusted variants crash; the +slower path, and it makes the trusted variants crash; the other ports do not mind. Declaring it `uint16_t`, as above, guarantees the alignment. ==== @@ -361,8 +361,8 @@ following hold: 16-pixel-aligned x and width on the Amiga, ST, and X68000, even on the other ports, and a height equal to the sprite's pixel height (on the IIgs, to the rows the compiled save keeps, those holding an opaque pixel); -* on the Amiga and ST, `+backup->bytes+` is at least 2-byte aligned, for the - trusted save and save-and-draw as well as the restore; +* on the Amiga, ST, and X68000, `+backup->bytes+` is at least 2-byte aligned, + for the trusted save and save-and-draw as well as the restore; * the recorded region is fully on the destination surface. Use the plain calls anywhere those invariants are not statically guaranteed. @@ -424,9 +424,8 @@ Arena introspection, for judging whether to compact or to raise *Parameters:* none. *Returns:* bytes in use, and total arena size. Free space is the difference, -though it may be fragmented across holes until `jlSpriteCompact` runs. On the -IIgs the total is what `jlInit` was granted, which can be less than -`codegenBytes` asked for, and it is split into blocks of at most 64 KB that -compacting never merges: one sprite's code must fit in one block. On the -ports that interpret every sprite the total is the arena they allocated -anyway, and the bytes in use stay 0. +though it may be fragmented across holes until `jlSpriteCompact` runs. The +total is what `jlInit` was granted, which can be less than `codegenBytes` +asked for on a machine short of memory. On the IIgs it is split into blocks +of at most 64 KB that compacting never merges: one sprite's code must fit in +one block. The ports that interpret every sprite have no arena: both are 0. diff --git a/docs/ApiSurface.adoc b/docs/ApiSurface.adoc index 3dd3a89..112591e 100644 --- a/docs/ApiSurface.adoc +++ b/docs/ApiSurface.adoc @@ -40,8 +40,9 @@ jlSurfaceT *jlSurfaceCreate(void); ---- Allocates a new offscreen surface. Each one costs about 32 KB. On the Amiga -that comes from chip RAM, because the blitter works on the planes there, so on -a 512 KB machine, beside the display's own pages, only a few fit. +that comes from chip RAM, where the blitter can work on its planes; once chip +RAM is full, a surface comes from Fast RAM instead, and its fills and copies +run on the CPU, more slowly, so make the surfaces drawn on most first. *Parameters:* none. @@ -228,6 +229,14 @@ Drawing primitives mark per-row dirty ranges as a side effect, so you only need to call this at the end of a frame and everything you drew appears. Rows nothing touched since the last present are skipped. +The Amiga, the ST, DOS, and the X68000 draw into a second screen page and flip +to it at the next vertical blank, so the picture never tears. A present that comes before +that blank, right after another one, waits for it: the page it would draw into +is still on the screen. Present once a frame, after `jlWaitVBL`, and it never +waits. On DOS, a present that changes colours also waits: VGA loads them in +the blank that shows the new page, and the `jlWaitVBL` after it returns at +once, so the loop keeps its pace. + *Parameters:* none. *Returns:* nothing. [NOTE] diff --git a/docs/Portability.adoc b/docs/Portability.adoc index f097a7d..a64b238 100644 --- a/docs/Portability.adoc +++ b/docs/Portability.adoc @@ -103,7 +103,7 @@ depending on the machine, and nothing warns you. *Align your backup buffers.* Give any `jlSpriteBackupT` backing array 2-byte alignment, by declaring it `uint16_t` or with an alignment attribute. On the Amiga, ST, and X68000 an odd buffer address sends save and restore down a much -slower path, and on the Amiga and ST it crashes the trusted variants. +slower path, and it crashes the trusted variants. [source,c] ---- @@ -257,8 +257,8 @@ The API is uniform; these are the places where what you get differs. |Stereo pan |all three |all three (centred on a free channel each side) -|ignored (mono) -|ignored (mono) +|ignored (mono); all three in the STe build +|all three on an SB16; ignored (mono) before it |ignored |all three |all three @@ -313,7 +313,7 @@ The API is uniform; these are the places where what you get differs. |COM1..COM4 |RS-232C (needs RSDRV.SYS) |COM1..COM4 (DVX's library) -|Win32: COM1 and up; SDL3, web, PSP, Haiku, BeOS: none +|Win32: COM1 and up; SDL3 (Linux), Haiku, BeOS: terminal devices; web, PSP: none |COM1..COM9 |=== @@ -366,27 +366,29 @@ and 56 under MAME, whose X68000 display runs slightly fast (56.07 Hz). timer to the millisecond. Per-scanline palettes cost CPU time on the ST and the X68000, whose displays -have one 16-colour palette, and a stall on the Amiga when the bands move. The -ST takes a timer interrupt at every change of palette, every frame, and copies -the next band's 16 colours there. The X68000 reloads its palette as the beam -scans: an interrupt stores the next band's colours in the horizontal blanking -before the band's first line. A screen with one palette costs nothing on -either and arms no interrupt. On the X68000 each change of palette between one +have one 16-colour palette. Both reload it as the beam scans, and store each +band's 16 colours in the horizontal blanking before the band's first line, so +the line shows them from its first pixel. The ST takes a timer interrupt a +line above every change of palette, every frame, and waits in it for that +line's display to end: about one line of CPU time (64 microseconds) a change. +A screen with one palette costs nothing on either and arms no interrupt. On +the X68000 each change of palette between one line and the next costs about one line of CPU time (32 microseconds); a band more than two lines from the one before costs about two. A screen that changes palette on every line therefore spends a third of each frame in that interrupt, during which serial and mouse interrupts wait (see the serial -chapter for how long a run can be before received bytes are lost). On the -Amiga the copper changes the palettes at no CPU cost, but an SCB change that -moves where a band starts or ends rebuilds the copper list, which holds up -that `jlStagePresent` for about three frames on an A500; a change of colours, -or of which palette an unmoved band uses, costs nothing. On the ST a -present's colours and pixels show from the same frame: it hands the -interrupts their new colours with the page it flips to. On the X68000, a -screen of two or more bands takes palette and SCB changes at the start of the -next frame rather than mid-frame, so a fade can lag its pixels by one frame -but never tears; a screen of one palette has its colours written at the -present. +chapter for how long a run can be before received bytes are lost). The ST +does the same for palettes one line apart, and spends those lines in the +interrupt. On the Amiga the copper changes the palettes at no CPU cost: a +present that changes palettes or the SCB writes a list of the bands' copper +instructions, a few hundred bytes. On the Amiga, the ST, DOS, and the X68000 +a present's colours and pixels show from the same frame: the Amiga's VBL +switches the copper to the new list with the page it flips to, the ST and +the X68000 hand their interrupts the new colours with the page they flip to, +and DOS loads changed colours in the vertical blank that shows the new page. +A present that changes colours right after another, before the vertical +blank that shows the first, waits for it on the Amiga and DOS (and on DOS +the `jlWaitVBL` after it returns at once). === Where the Ports Still Differ @@ -422,21 +424,11 @@ names the ports, says how they differ, and gives the reason. it among all its programs, so Win32 and a DVX window show each colour as the nearest one the display has, and Win16 shows them exactly only while the 236 entries left are enough (<<_windows_programs>>). -* *Amiga and X68000: colours a frame off their pixels.* A present's new - colours and new pixels show from the same frame on the other ports (the IIgs - and DOS change both at once, and can tear). The Amiga's pixels change at the - next frame, when the copper loads the new bitplane pointers, but the bands - below the beam take their new colours in the frame the present runs: the - copper list is graphics.library's, one per screen, and JoeyLib patches its - colours in place. The X68000 draws straight into the displayed text plane, - which has no second page, while a screen of two or more bands takes its new - colours at the start of the next frame, so there the colours come a frame - after the pixels. * *ST and X68000: per-scanline palettes cost time.* Both displays have one 16-colour palette at a time, so the CPU loads each band's colours as the beam reaches it: the ST in a timer interrupt at every change of palette, every - frame, and the X68000 in a raster interrupt. A screen with one palette costs - nothing (<<_per_platform_behaviour_summary>>). + frame (about a line of CPU time each), and the X68000 in a raster interrupt. + A screen with one palette costs nothing (<<_per_platform_behaviour_summary>>). [[portsDifferInput]] ==== Input @@ -454,11 +446,14 @@ names the ports, says how they differ, and gives the reason. (Delete is the backspace key), and one Shift as far as JoeyLib can tell: `JL_KEY_LSHIFT` and `JL_KEY_RSHIFT` both read either. Option is `JL_KEY_LALT`. -* *ST keyboard: the Alt layer on TOS before 4.00.* A national keyboard types - some ASCII with Alt (a German one's @ [ ] { } \). TOS 4.00 and later, and - EmuTOS, keep that layer in the keyboard tables JoeyLib reads, so Alt types - it; older TOS keeps it in the keyboard code JoeyLib replaces, so there Alt - with a key types nothing, as on the ports where Alt is a command key. +* *ST keyboard: no Alt layer on two countries' older TOS.* A national keyboard + types some ASCII with Alt (a German one's @ [ ] { } \). TOS 4.00 and later, + and EmuTOS, keep that layer in the keyboard tables JoeyLib reads; older TOS + keeps it in the keyboard code JoeyLib replaces, so JoeyLib carries its own + copies of those layers, by the ROM's country: Germany, France, Spain, + Italy, Sweden, Finland, Norway, and German Switzerland. On the French Swiss + and Danish TOS before 4.00, whose layers it does not know, Alt with a key + types nothing. * *X68000 keyboard: one Shift.* Both Shift keys send the same code, so `JL_KEY_LSHIFT` and `JL_KEY_RSHIFT` both read either. OPT.1 and OPT.2 are `JL_KEY_LALT`. @@ -491,7 +486,13 @@ names the ports, says how they differ, and gives the reason. and its noise is mixed into channel C, voice 2's. Author noise hits and voice-2 notes apart; `songbake` warns when they overlap. * *ST: volume steps.* The YM2149 has 16 volume levels about 3.5 dB apart, so a - 2 dB attenuation step is matched to within 1.6 dB. + 2 dB attenuation step is matched to within 1.4 dB on the plain ST and 1.9 dB + on the STe, whose voices start a level lower to balance its louder digital + sound. +* *Plain ST: a loud module clips.* The plain ST plays all its digital sound + through one sound chip channel, so the mix gives a module channel half of + it: a module whose three or four channels peak at the same moment clips. + Everywhere else all four fit. * *Amiga: four channels for everything.* Paula has four sound channels, two on each side, shared by the module, the effects, the three tone voices, and the noise. At most four sounds play at once, an effect can take a channel a @@ -506,18 +507,47 @@ names the ports, says how they differ, and gives the reason. voices or `jlMusicPlay` sound. * *IIgs: mono.* A stock IIgs mixes its stereo channels into one speaker, so panning is heard only with a stereo card. +* *IIgs: tone pitch in steps.* The DOC sets a voice's pitch in steps of + 1.6 Hz for notes up to 346 Hz and 6.4 Hz above, so a note can be up to + half a step off: about 12 cents at 110 Hz, 13 at 440 Hz, and 6 at 880 Hz. * *Plain ST and X68000: no panning.* Each has one output for digital sound: the ST its channel A, the X68000 its one ADPCM channel, so `jlAudioSetPan` - is ignored. + is ignored. The STe build plays stereo through the DMA chip: the module's + channels on Paula's sides, as on the Amiga, and each sound effect where + `jlAudioSetPan` puts it. +* *DOS: stereo on an SB16 only.* An SB16 plays the module's channels on + Paula's sides and each sound effect where `jlAudioSetPan` puts it; the + Sound Blasters before it play mono (an SB Pro's stereo needs the DSP's + high-speed mode, which takes no commands while it runs). Mono cards also + play 22050 Hz as 22222 Hz, the rate their time constant gives, which + JoeyLib mixes for, so pitch is right on both. +* *DOS: the mix needs a mixer.* The OPL2's loudest voice is about 5 dB under + the quarter of the Sound Blaster's digital full scale where JoeyLib mixes a + sound effect or a module channel (each keeps 6 of its 8 bits, so the four + channels fit), so on an SB Pro or SB16 JoeyLib sets the + card's mixer, FM at full and digital sound about 4 dB under it, and + `jlShutdown` puts both back. The cards before the SB Pro have no mixer, and + there sound effects and modules play about 5 dB over the voices. * *DOS: the noise is a drum.* The OPL2 has no free-running noise generator; its rhythm-mode snare is the nearest, a tuned drum whose pitch cannot follow the YM2149's noise rate. +* *X68000: digital sound at 7812 Hz.* Modules and effects are mixed at 7812 Hz + and encoded to the MSM6258's 4-bit ADPCM, so nothing above 3.9 kHz is + heard. The chip can play 15625 Hz, but the mix and the encode run on the + 10 MHz 68000 for every sample: twice the rate would take twice the + ~13% of the machine they take now. * *DOS and X68000: tones are not square.* The OPL2 and the YM2151 make their waves from sines and have no square wave, so their tone voices (DOS's when it has an OPL2) are not the square waves the other ports play. * *Amiga and IIgs: the brightest noise.* Paula plays a sample no faster than 28.6 kHz and the IIgs's DOC no faster than 26 kHz, so noise pitches 0 to 4 sound alike there. +* *Amiga and IIgs: fast effects lose their top.* For the same reason, an effect + or stream faster than 28.6 kHz plays every second sample on the Amiga (every + third above 57.2 kHz), and one faster than the DOC's 26 kHz output steps over + samples on the IIgs, both at its own pitch and length: 44.1 kHz plays as + about 22 kHz, so what it holds above 11 kHz is gone, where the other ports + keep it up to their own output's limit. * *Where tick callbacks fall.* The calls average exactly `hz` a second on every port. The ST and X68000 deliver them from the system's own timer, at 200 and 100 Hz, so the calls due between two of its interrupts come together. DVX @@ -528,8 +558,15 @@ names the ports, says how they differ, and gives the reason. the SFX overlay run too long for an interrupt, so their sound buffers are refilled at `jlAudioFrameTick`: a program that stops calling it for longer than the buffer lasts stops or repeats its sound. +* *Win16: a module's end is reported early.* `jlAudioIsPlayingMod` turns false + up to about 0.15 s before the module's last notes are heard. Windows 3.1's + Sound Blaster driver counts a buffer as played as soon as it takes it into + its own DMA buffer, and reports the same through `waveOutGetPosition`, so + nothing tells what has been heard. * *Win16 with a Sound Blaster 2.0: 8-bit sound.* Its driver, SNDBLST2.DRV, takes - only 8-bit samples, so the quietest attenuation steps are coarser there. + only 8-bit samples, so the quietest attenuation steps are coarser there, and + sound effects and modules, mixed at a quarter of full scale like every + software mix, keep 6 of their 8 bits. [[portsDifferSerial]] ==== Serial @@ -538,7 +575,11 @@ names the ports, says how they differ, and gives the reason. IIgs Super Serial Card at 19200 (the 6551's), the X68000 at 38400 (RSDRV.SYS's table; really 39062), and Win16 with Windows' own COMM.DRV at 57600. * *One port.* The base ST has one RS-232 port, and so has the X68000, whose - second serial channel is the mouse's. + second serial channel is the mouse's. A Mega STe, TT, or Falcon has more, + which `JL_SERIAL_SLOT` opens through TOS's Bconmap; their DTR and RTS + stay as TOS sets them. +* *Haiku and BeOS: 7 or 8 data bits.* Their termios cannot ask for 5 or 6, + so an open that does fails. * *IIgs modem and printer ports: no RTS.* Their mini-DIN-8 connector's one handshake output is DTR, so under RTS/CTS nothing can tell the peer to pause: receive needs XON/XOFF or a protocol that paces itself. The printer @@ -559,8 +600,13 @@ names the ports, says how they differ, and gives the reason. * *Name rules.* ProDOS names on the IIgs are at most 15 characters and start with a letter; DOS, the ST, and Win16 use 8.3 names; and Linux, Haiku, BeOS, - and the web tell upper and lower case apart. Keep to the portable rule - (<<_files>>). + and the web tell upper and lower case apart. The save calls refuse a name + outside the portable rule on every port (<<_files>>); a `DATA/` name is + passed through, so keep those to it too. +* *ST: the current directory.* Every other port finds `DATA/`, `SAVES/`, and + the log in the program's own folder however it was started; TOS tells a + program nothing of where it was started from, so the ST uses the current + directory, which is the program's folder when the desktop starts it. * *Pre-compiled sprites per processor.* A `.spc` holds machine code, so it is baked per port: DVX and Win16 run DOS's x86 code, the STe the ST's, and the ports that interpret sprites use only its pixels. @@ -583,6 +629,11 @@ names the ports, says how they differ, and gives the reason. is one Memory Manager block inside one bank, 32 KB to 48 KB, and a `jlAlloc` block is a Memory Manager handle, so `jlFree` and `free` must never be given each other's blocks. +* *ST: a `` of five functions.* The ST's C library has no maths + library, so JoeyLib brings `ceil`, `floor`, `log`, `pow`, and `trunc` (and + their `float` forms), good to a few hundred parts per million, and nothing + else: a program that calls `sin` or `sqrt` does not link there. Every + other port has its C library's whole ``. [[portsDifferTiming]] ==== Timing @@ -646,9 +697,11 @@ Nothing in the API differs between them: |Digital sound (`.MOD`, SFX, streams) |PWM through the YM2149's channel A volume, driven by a 12288 Hz timer - interrupt; tone voice 0 is silent while it plays -|The 8-bit DMA sound chip, mono at 12517 Hz, with no interrupt; all three tone - voices keep playing + interrupt, each sample the nearest of the YM's sixteen logarithmic levels + (so about three bits near full scale); tone voice 0 is silent while it + plays, and for a fifth of a second after +|The 8-bit DMA sound chip, stereo at 12517 Hz, with no interrupt; all three + tone voices keep playing |Fills and copies |The CPU @@ -687,10 +740,14 @@ MS-DOS port's -- the same chunky stage and the same x86 code, so UBER's hashes are DOS's -- and the rest behaves like this: * *A window, or the whole screen.* The window's content is the 320x200 stage - at the largest whole scale, up to 3x, that fits the desktop: 1x at - 640x480, 2x at 800x600, 3x at 1024x768. The stage reaches the desktop - through one colour table per palette, so SCB palettes are exact on 15-, - 16-, and 32-bit desktops. On a 256-colour desktop every colour is the + at the largest whole scale, up to 3x, that fits the desktop, in 4:3 as a + CRT shows it and as the SDL3 window does (each stage row a fifth taller: + 1x is 320x240), or with Game > Square Pixels, square pixels (16:10, as + `JOEYLIB_ASPECT=square` gives the SDL3 window). In 4:3 that is 1x at + 640x480 and 2x at 800x600 and 1024x768; with square pixels 1x, 2x, and + 3x. The choice is kept with the full-screen one. The stage reaches the + desktop through one colour table per palette, so SCB palettes are exact on + 15-, 16-, and 32-bit desktops. On a 256-colour desktop every colour is the nearest one in DVX's palette, which all programs share: gradients band. * *Full screen.* Game > Full Screen, or Alt+Enter, puts the program on the whole screen in DVX's full-screen mode, 320x200 in 256 colours (VGA mode @@ -716,7 +773,8 @@ hashes are DOS's -- and the rest behaves like this: screen, every key goes to the program, F1, F10, and Alt included, except Alt+Tab (switch programs), Ctrl+Esc (Task Manager) and Alt+F4 (close the window), and Alt+Enter, which the port keeps for full screen. The window's - Game menu has Full Screen, Help (the program's help file) and Exit. Typed + Game menu has Full Screen, Square Pixels, Help (the program's help file), + and Exit. Typed characters come through DVX's keyboard layout. * *Closing ends the program.* Alt+F4, the close box, and Game > Exit end the program at its next `jlWaitVBL`, `jlInputPoll`, `jlStagePresent`, or @@ -733,10 +791,13 @@ hashes are DOS's -- and the rest behaves like this: folder, `CONFIG\APPS\JOEYLIB\NAME`, which DVX keeps apart from the program files so a reinstall never overwrites them. * *Sound* goes through DVX's sound and music libraries, mixed with every - other program's: `.MOD` music, SFX in stereo, and the tone voices on the FM - chip when there is one. A `jlAudioTickRegister` callback runs in program - time, at the next `jlAudioFrameTick`, because DVX's sound library may not - be called from an interrupt. + other program's: `.MOD` music, SFX in stereo, and the tone voices and the + noise as the hosted ports make them, square waves and the YM2149's noise + mixed in software into a stream of DVX's mixer, so their levels and + balance are the same on every card (a machine whose only sound is the PC + speaker plays DVX's own tones instead). A `jlAudioTickRegister` callback + runs in program time from that mixing, between exactly the samples where + its tick falls, as on the hosted ports. * *Joysticks* are DVX's, calibrated in its Control Panel: real analog axes. Two sticks on one game port (a Y cable, or a four-axis stick in DVX's default two-stick mode) are joysticks 0 and 1. An axis that was never @@ -808,7 +869,11 @@ this: exactly the samples it falls between, so its notes start in the right place in the sound. Every sound needs audio started (the default), the tone voices included. -* *No serial port.* +* *Serial* through termios on Linux: `/dev/ttyS0`, or the device the + `JOEYLIB_SERIAL` environment variable names (a USB adapter: + `JOEYLIB_SERIAL=/dev/ttyUSB0`), and `/dev/ttyS` for a slot. The + user running the program needs the device's permissions (on most + distributions, the `dialout` group). `make sdl3` builds every example as a native program, each in `build/sdl3/bin/` (a program that ships in a folder, in a folder of its own @@ -835,12 +900,18 @@ are DOS's here too. Everything in <<_sdl3_programs>> holds, except: `jlInputPoll` alone, as `jlWaitForAnyKey` is). * *Sound starts with the first click or key press*, as browsers require; the page says so. -* *Saves persist.* `SAVES/` is kept in the browser's IndexedDB, read back - when the program starts and written back as each save changes, so a save - outlives the page however it ends. The store is the site's, not the page's: - every JoeyLib program served from the same site shares one `SAVES/`, so give - each game's saves names of their own. `jlDiskFree` there is the browser's - own estimate of what the site may still store. +* *Saves persist, page by page.* `SAVES/` is kept in the browser's + IndexedDB, read back the first time the program asks for it and written + back as each save changes, so a save outlives the page however it ends. + Each page keeps its own, as each program keeps its own `SAVES/` folder on + a machine: `games/hero.html` and `games/maze.html` on one site never see + each other's saves, and moving or renaming a page leaves its saves behind + under the old address. `jlDiskFree` there is the browser's own estimate of + what the whole site may still store. +* *No serial port.* A page reaches one only through Web Serial, which only + Chromium-based browsers have and which opens a port only when the person + picks it from the browser's own chooser, opened by a click: nothing a + program calls can do that for it. * *No full screen by default and no closing.* Alt+Enter asks the browser for the whole screen; the program ends when the page is closed. @@ -862,6 +933,8 @@ is the same chunky one, so UBER's hashes are DOS's. Everything in and `joeylog.txt` are there. It runs on a PSP with custom firmware (it is not signed) and in PPSSPP. The ELF it is packed from stays in `build/psp/elf/`, for `psp-addr2line`. +* *No serial port.* The PSP's one UART, on the remote connector, is + reached only from kernel mode, which a homebrew program does not run in. * *The screen.* The PSP's 480x272 screen shows the stage at 1:1 in its middle, black around it. The scale and aspect settings do not apply (a PSP has no environment), and there is no full screen to switch to. @@ -995,8 +1068,10 @@ The 2x window has nearly five times the IIgs's pixels to fill (640x480 for its 320x200): a 386 keeps up with that for sprites, but not for a whole new screen every frame, so a game that redraws everything every frame wants `JOEYLIB_SCALE=1` there. -* *Sound* is 16-bit mono at 22.05 kHz through waveOut, what a Sound Blaster - and a 386 handle well, or 8-bit on a card whose driver has no 16-bit (a +* *Sound* is 16-bit mono at 22.222 kHz through waveOut, what a Sound Blaster + and a 386 handle well (a Sound Blaster plays 22.05 kHz as 22.222, 0.8% + sharp, so JoeyLib asks for the rate it plays exactly; a card that refuses + it gets 22.05 kHz), or 8-bit on a card whose driver has no 16-bit (a Sound Blaster 2.0, whose SNDBLST2.DRV is 8-bit only, which makes the quiet attenuation steps coarser); stereo pan is ignored. * *Joysticks* are the first two MMSYSTEM sticks, through `joyGetPos`, with @@ -1078,7 +1153,11 @@ Their stage is the same chunky one, so UBER's hashes are DOS's. Everything in across from its resources (as `mimeset -F` does), so Tracker shows it by its icon once it has run; that is the one time a program writes to its own file. -* *No serial port.* +* *Serial* through termios: the first entry of `/dev/ports` (Haiku's + `pc_serial0`, BeOS's `serial1`), or the device the `JOEYLIB_SERIAL` + environment variable names, and the unit'th entry in name order for a + slot; 7 or 8 data bits only. The gates run it on Haiku; BeOS R5 shares the + code but not the test, since the gates' BeOS VM has no serial ports. * *BeOS R5's loader* refuses any program with a dynamic tag it does not know, so `make beos` links without combined relocations (`-z nocombreloc`). Build for R5 with `make beos`, not `make haiku32`. diff --git a/docs/ProgramLayout.adoc b/docs/ProgramLayout.adoc index 8a29a7d..6f6d498 100644 --- a/docs/ProgramLayout.adoc +++ b/docs/ProgramLayout.adoc @@ -26,19 +26,20 @@ native sprite blitter code into. Getting it wrong is the most common cause of sprites drawing slowly, so it is worth understanding. * `0` selects a per-platform default: 8 KB on the IIgs, 64 KB on the - Amiga and ST, and 32 KB on the X68000, DOS, DVX, and Win16. The other - hosted ports interpret every sprite and never use it, though they still - allocate it. + Amiga, ST, and X68000, and 32 KB on DOS, DVX, and Win16. The other hosted + ports interpret every sprite and allocate no arena at all. * The 68000 and x86 emitters produce code roughly four to eight times less dense than the 65816 one, so the same sprite needs far more arena space on those ports. A 32x32 sprite can need 8 to 16 KB there. * Measure, do not guess: after compiling a sprite, `jlSpriteCompiledSize()` reports its exact cost on the current platform. -* On the IIgs a compiled sprite cannot cross a 64 KB bank boundary, so the - arena is up to four Memory Manager blocks of at most 64 KB, each inside one - bank: 256 KB at most. A bank that is refused is tried again at 60 KB, and - `jlInit` takes whatever part of the request it was granted. Elsewhere the - arena is one block, and `jlInit` fails if it cannot be had. +* Every port takes as much of the request as the machine has, and `jlInit` + fails only if it can have none of it; `jlSpriteCodegenBytesTotal()` says + what it got. On the IIgs a compiled sprite cannot cross a 64 KB bank + boundary, so the arena is up to four Memory Manager blocks of at most + 64 KB, each inside one bank: 256 KB at most (a bank that is refused is + tried again at 60 KB). Elsewhere it is one block, halved until the heap + has room, down to 4 KB. If the arena is too small, sprites still draw: they fall back to an interpreter that is *two to six times slower*, depending on the machine. @@ -357,8 +358,8 @@ jlSpriteSaveAndDraw(stage, sprite, x, y, &backup); Size the buffer with `jlSpriteBackupLength`, and declare it as `uint16_t` (or otherwise 2-byte aligned): on the Amiga, ST, and X68000 an odd address sends -save and restore down a much slower path, and on the Amiga and ST it crashes -the trusted variants. +save and restore down a much slower path, and it crashes the trusted +variants. ==== The Clean-Buffer Pattern diff --git a/examples/audpar/audpar.c b/examples/audpar/audpar.c index 2943023..f93c07a 100644 --- a/examples/audpar/audpar.c +++ b/examples/audpar/audpar.c @@ -8,8 +8,9 @@ // port played and tools/gradeAudioConformance.py can measure it against the // API's contract: tone and voice pitch, 2 dB attenuation steps, every voice, // the noise generator's rate and steps, sound effects at their rates and -// pans, a module that does not loop ending (and jlAudioIsPlayingMod saying -// so), and tick callbacks at their rates. +// pans, streamed ones (signed, and in the port's raw format), a module that +// does not loop ending (and jlAudioIsPlayingMod saying so), one that loops +// stopping when it is told, and tick callbacks at their rates. // // The log, one line per event, "AUDPAR ", ms counted // from the first sync tone's start: @@ -18,9 +19,17 @@ // tone jlAudioTone // voice jlAudioVoice // noise jlAudioNoise -// sfx a one-shot sine sounding at +// sfx +// a one-shot sine sounding at , +// long (44100 Hz: above the IIgs's +// and the Amiga's chips, which decimate) +// stream +// a sine streamed (raw 1: through +// jlAudioPlaySfxStreamRaw) // mod start / mod end jlAudioPlayMod with loop false, and the // first jlAudioIsPlayingMod false after it +// mod loop / mod stop jlAudioPlayMod with loop true, and the +// jlAudioStopMod after it // tick jlAudioTickRegister's result, the calls // that came, and the time they had // silence everything stopped @@ -31,6 +40,7 @@ #include #include #include +#include #include #include @@ -57,10 +67,17 @@ #define VOICE_HZ 660u #define TICK_MS 2000u #define MOD_WAIT_MS 12000u +#define MOD_STOP_MS 1500u // a looping module plays this long, then stops #define SFX_SLOT 0u #define SFX_SAMPLES 4000u #define SFX_PERIOD 8u // samples a cycle: the tone is rate / 8 +// Faster than the IIgs's DOC and the Amiga's Paula play, so they decimate: +// the tone rate / 16, as the 22050 Hz step's, as long as that step. +#define SFX_FAST_RATE 44100u +#define SFX_FAST_PERIOD 16u +#define SFX_FAST_SAMPLES 8000u #define SFX_AMPLITUDE 100 +#define RAW_BIAS 0x80 // JL_SFX_RAW_UNSIGNED8_NOZERO's zero #define MS_PER_SECOND 1000u #define EVENTS_MAX 128u @@ -70,7 +87,12 @@ typedef enum { STEP_VOICE, STEP_NOISE, STEP_SFX, + STEP_SFX_FAST, + STEP_STREAM, + STEP_STREAM_RAW, + STEP_STREAM_FAST, STEP_MOD, + STEP_MOD_STOP, STEP_TICK, STEP_SILENCE } StepKindE; @@ -83,6 +105,19 @@ typedef struct { uint16_t ms; // how long it sounds (sfx: from its length) } StepT; +// A sine a sound step plays: count samples, period of them a cycle. +typedef struct { + const int8_t *samples; + uint32_t count; + uint16_t period; +} SineT; + +// A stream step's sine and its progress through it. +typedef struct { + const SineT *sine; + uint32_t pos; +} StreamT; + // What the log will say of one step, kept in memory until the timeline is // over: a log line can take a disk write (a fifth of a second on the ST), // and written while it plays it would stall the program's sound. @@ -95,7 +130,7 @@ typedef struct { uint32_t count; // mod: its length, then playing; tick: calls uint32_t span; // tick: the time the calls had bool ok; // tick: registered; mod end: still playing - bool modEnd; + bool modEnd; // mod: its end; mod stop: the stop } EventT; static EventT *eventAdd(uint32_t ms, const StepT *step); @@ -105,6 +140,8 @@ static bool pump(void); static void runStep(const StepT *step, const uint8_t *mod, uint32_t modLength); static uint32_t since(void); static void silenceAll(void); +static const SineT *stepSine(StepKindE kind); +static uint32_t streamFill(void *ctx, int8_t *dst, uint32_t count); static void tickCount(void); static bool waitMs(uint16_t ms); @@ -147,10 +184,17 @@ static const StepT kTimeline[] = { { STEP_SFX, JL_AUDIO_PAN_CENTER, 8000u, 0u, 0u }, { STEP_SILENCE, 0u, 0u, 0u, GAP_MS }, { STEP_SFX, JL_AUDIO_PAN_CENTER, 16000u, 0u, 0u }, { STEP_SILENCE, 0u, 0u, 0u, GAP_MS }, { STEP_SFX, JL_AUDIO_PAN_CENTER, 22050u, 0u, 0u }, { STEP_SILENCE, 0u, 0u, 0u, GAP_MS }, + { STEP_SFX_FAST, JL_AUDIO_PAN_CENTER, SFX_FAST_RATE, 0u, 0u }, { STEP_SILENCE, 0u, 0u, 0u, GAP_MS }, { STEP_SFX, JL_AUDIO_PAN_LEFT, 8000u, 0u, 0u }, { STEP_SILENCE, 0u, 0u, 0u, GAP_MS }, { STEP_SFX, JL_AUDIO_PAN_RIGHT, 8000u, 0u, 0u }, { STEP_SILENCE, 0u, 0u, 0u, PAUSE_MS }, + // The same sound streamed, streamed raw, and streamed fast. + { STEP_STREAM, JL_AUDIO_PAN_CENTER, 11025u, 0u, 0u }, { STEP_SILENCE, 0u, 0u, 0u, GAP_MS }, + { STEP_STREAM_RAW, JL_AUDIO_PAN_CENTER, 12000u, 0u, 0u }, { STEP_SILENCE, 0u, 0u, 0u, GAP_MS }, + { STEP_STREAM_FAST, JL_AUDIO_PAN_CENTER, SFX_FAST_RATE, 0u, 0u }, { STEP_SILENCE, 0u, 0u, 0u, PAUSE_MS }, // A module that does not loop, to its end. { STEP_MOD, 0u, 0u, 0u, MOD_WAIT_MS }, { STEP_SILENCE, 0u, 0u, 0u, PAUSE_MS }, + // A module that loops, stopped while it plays. + { STEP_MOD_STOP, 0u, 0u, 0u, MOD_STOP_MS }, { STEP_SILENCE, 0u, 0u, 0u, PAUSE_MS }, // Tick callbacks, slow and at a frame's rate. { STEP_TICK, 0u, 10u, 0u, TICK_MS }, { STEP_TICK, 0u, 60u, 0u, TICK_MS }, @@ -165,6 +209,14 @@ static const int8_t kSineCycle[SFX_PERIOD] = { 0, 71, SFX_AMPLITUDE, 71, 0, -71, static EventT gEvents[EVENTS_MAX]; static uint16_t gEventCount = 0u; static int8_t gSine[SFX_SAMPLES]; +static int8_t gSineRaw[SFX_SAMPLES]; // gSine in the port's raw stream format +// The sines the steps play (stepSine); the fast one's samples are allocated +// at the start, as a static buffer that size would crowd the IIgs's bank. +static SineT gSineSlow = { gSine, SFX_SAMPLES, SFX_PERIOD }; +static SineT gSineSlowRaw = { gSineRaw, SFX_SAMPLES, SFX_PERIOD }; +static SineT gSineFast = { NULL, SFX_FAST_SAMPLES, SFX_FAST_PERIOD }; +static StreamT gStream; +static uint8_t gRawFormat = JL_SFX_RAW_SIGNED8; static uint32_t gStartMs = 0u; static volatile uint32_t gTickCalls = 0u; @@ -193,6 +245,7 @@ static EventT *eventAdd(uint32_t ms, const StepT *step) { // The log, once the timeline is over (the format in the header). static void eventsLog(void) { const EventT *event; + const SineT *sine; uint16_t i; for (i = 0u; i < gEventCount; i++) { @@ -211,7 +264,15 @@ static void eventsLog(void) { jlLogF("AUDPAR %lu noise %u %u", (unsigned long)event->ms, (unsigned)event->value, (unsigned)event->level); break; case STEP_SFX: - jlLogF("AUDPAR %lu sfx %u %u %u %u", (unsigned long)event->ms, (unsigned)SFX_SLOT, (unsigned)event->value, (unsigned)(event->value / SFX_PERIOD), (unsigned)event->channel); + case STEP_SFX_FAST: + sine = stepSine(event->kind); + jlLogF("AUDPAR %lu sfx %u %u %u %u %lu", (unsigned long)event->ms, (unsigned)SFX_SLOT, (unsigned)event->value, (unsigned)(event->value / sine->period), (unsigned)event->channel, (unsigned long)sine->count); + break; + case STEP_STREAM: + case STEP_STREAM_RAW: + case STEP_STREAM_FAST: + sine = stepSine(event->kind); + jlLogF("AUDPAR %lu stream %u %u %u %u %u %lu", (unsigned long)event->ms, (unsigned)SFX_SLOT, (unsigned)event->value, (unsigned)(event->value / sine->period), (unsigned)event->channel, (unsigned)(event->kind == STEP_STREAM_RAW), (unsigned long)sine->count); break; case STEP_MOD: if (event->modEnd) { @@ -220,6 +281,9 @@ static void eventsLog(void) { jlLogF("AUDPAR %lu mod start %lu", (unsigned long)event->ms, (unsigned long)event->count); } break; + case STEP_MOD_STOP: + jlLogF("AUDPAR %lu mod %s", (unsigned long)event->ms, event->modEnd ? "stop" : "loop"); + break; case STEP_TICK: jlLogF("AUDPAR %lu tick %u %u %lu %lu", (unsigned long)event->ms, (unsigned)event->value, (unsigned)event->ok, (unsigned long)event->count, (unsigned long)event->span); break; @@ -268,8 +332,9 @@ static bool pump(void) { // Each step's time is taken as it starts. static void runStep(const StepT *step, const uint8_t *mod, uint32_t modLength) { - uint32_t start; - EventT *event; + const SineT *sine; + uint32_t start; + EventT *event; start = since(); event = eventAdd(start, step); @@ -288,9 +353,24 @@ static void runStep(const StepT *step, const uint8_t *mod, uint32_t modLength) { (void)waitMs(step->ms); break; case STEP_SFX: + case STEP_SFX_FAST: + sine = stepSine(step->kind); jlAudioSetPan(SFX_SLOT, (jlAudioPanE)step->channel); - jlAudioPlaySfx(SFX_SLOT, (const uint8_t *)gSine, SFX_SAMPLES, step->value); - (void)waitMs((uint16_t)((uint32_t)SFX_SAMPLES * MS_PER_SECOND / step->value)); + jlAudioPlaySfx(SFX_SLOT, (const uint8_t *)sine->samples, sine->count, step->value); + (void)waitMs((uint16_t)(sine->count * MS_PER_SECOND / step->value)); + break; + case STEP_STREAM: + case STEP_STREAM_RAW: + case STEP_STREAM_FAST: + jlAudioSetPan(SFX_SLOT, (jlAudioPanE)step->channel); + gStream.sine = stepSine(step->kind); + gStream.pos = 0u; + if (step->kind == STEP_STREAM_RAW) { + jlAudioPlaySfxStreamRaw(SFX_SLOT, streamFill, &gStream, step->value); + } else { + jlAudioPlaySfxStream(SFX_SLOT, streamFill, &gStream, step->value); + } + (void)waitMs((uint16_t)(gStream.sine->count * MS_PER_SECOND / step->value)); break; case STEP_MOD: if (mod != NULL) { @@ -311,6 +391,17 @@ static void runStep(const StepT *step, const uint8_t *mod, uint32_t modLength) { } jlAudioStopMod(); break; + case STEP_MOD_STOP: + if (mod != NULL) { + jlAudioPlayMod(mod, modLength, true); + } + (void)waitMs(step->ms); + event = eventAdd(since(), step); + if (event != NULL) { + event->modEnd = true; + } + jlAudioStopMod(); + break; case STEP_TICK: gTickCalls = 0u; if (event != NULL) { @@ -347,6 +438,32 @@ static void silenceAll(void) { } +// The sine a sound step plays: the fast steps' (SFX_FAST_RATE), the raw +// stream's in the port's raw format, the rest the one-shots' signed bytes. +static const SineT *stepSine(StepKindE kind) { + if (kind == STEP_SFX_FAST || kind == STEP_STREAM_FAST) { + return &gSineFast; + } + return kind == STEP_STREAM_RAW ? &gSineSlowRaw : &gSineSlow; +} + + +// A stream step's fill: its sine, as stepSine chose it; 0 once it has all +// gone, which ends the stream. A copy, so the time the fill takes is the +// port's, not this program's. +static uint32_t streamFill(void *ctx, int8_t *dst, uint32_t count) { + StreamT *stream; + + stream = (StreamT *)ctx; + if (count > stream->sine->count - stream->pos) { + count = stream->sine->count - stream->pos; + } + memcpy(dst, stream->sine->samples + stream->pos, count); + stream->pos += count; + return count; +} + + static void tickCount(void) { gTickCalls++; } @@ -368,6 +485,7 @@ static bool waitMs(uint16_t ms) { int main(void) { jlConfigT config; uint8_t *mod; + int8_t *fast; uint32_t modLength; uint16_t i; @@ -377,9 +495,22 @@ int main(void) { fprintf(stderr, "jlInit failed: %s\n", jlLastError()); return 1; } + gRawFormat = jlAudioSfxRawFormat(); for (i = 0u; i < SFX_SAMPLES; i++) { - gSine[i] = kSineCycle[i % SFX_PERIOD]; + gSine[i] = kSineCycle[i % SFX_PERIOD]; + gSineRaw[i] = gRawFormat == JL_SFX_RAW_UNSIGNED8_NOZERO ? (int8_t)(uint8_t)(gSine[i] + RAW_BIAS) : gSine[i]; } + // The fast sine: each cycle step of the slow one held for two samples. + fast = (int8_t *)malloc(SFX_FAST_SAMPLES); + if (fast == NULL) { + fprintf(stderr, "AUDPAR: no memory for the fast sine\n"); + jlShutdown(); + return 1; + } + for (i = 0u; i < SFX_FAST_SAMPLES; i++) { + fast[i] = kSineCycle[(i / (SFX_FAST_PERIOD / SFX_PERIOD)) % SFX_PERIOD]; + } + gSineFast.samples = fast; loadMod(&mod, &modLength); jlSurfaceClear(jlStageGet(), 0u); jlStagePresent(); @@ -396,6 +527,7 @@ int main(void) { jlLogF("AUDPAR %lu done", (unsigned long)since()); jlLogFlush(); free(mod); + free(fast); jlShutdown(); return 0; } diff --git a/examples/sertest/sertest.c b/examples/sertest/sertest.c index 6b2a28e..1ea323c 100644 --- a/examples/sertest/sertest.c +++ b/examples/sertest/sertest.c @@ -4,8 +4,9 @@ // // Minimal headless serial self-test -- NO jlInit (no video/graphics), so it // runs even where the graphics HAL can't (e.g. AROS on the Amiga). Opens the -// DEFAULT port at 9600 8N1, sends a banner (TX heartbeat), then echoes every -// received byte forever. A host serial peer verifies banner (TX) + echo (RX). +// DEFAULT port (or SERTEST_DEVICE) at 9600 8N1, sends a banner (TX +// heartbeat), then echoes every received byte forever. A host serial peer +// verifies banner (TX) + echo (RX). // // A peer that sees the modem lines (DOSBox's nullmodem reports them in the // byte stream as they change) can also send DTR_CHECK: the program then @@ -18,6 +19,15 @@ #define DTR_CHECK 0x04u // ASCII EOT: run the DTR check (not echoed) #define DTR_ACK 0x06u // ASCII ACK: the peer has the whole marker line +// The port, overridable at build time (-DSERTEST_DEVICE=JL_SERIAL_SLOT +// -DSERTEST_UNIT=2u) so a gate can open one other than the default. +#ifndef SERTEST_DEVICE +#define SERTEST_DEVICE JL_SERIAL_DEFAULT +#endif +#ifndef SERTEST_UNIT +#define SERTEST_UNIT 0u +#endif + static void dtrCheck(const jlSerialConfigT *cfg); static void sendMark(const char *text); @@ -28,7 +38,7 @@ static void dtrCheck(const jlSerialConfigT *cfg) { jlSerialSetDtr(true); sendMark("JLDTR-ON\r\n"); jlSerialClose(); - if (!jlSerialOpen(JL_SERIAL_DEFAULT, cfg)) { + if (!jlSerialOpen(SERTEST_DEVICE, cfg)) { return; } sendMark("JLDTR-REOPENED\r\n"); @@ -74,8 +84,8 @@ int main(void) { cfg.stopBits = 1u; cfg.parity = JL_SERIAL_PARITY_NONE; cfg.flow = JL_SERIAL_FLOW_NONE; - cfg.unit = 0u; - if (!jlSerialOpen(JL_SERIAL_DEFAULT, &cfg)) { + cfg.unit = SERTEST_UNIT; + if (!jlSerialOpen(SERTEST_DEVICE, &cfg)) { return 1; } jlSerialWrite((const uint8_t *)banner, (uint16_t)(sizeof(banner) - 1u)); diff --git a/include/joey/audio.h b/include/joey/audio.h index ecd3e8a..af9c0ea 100644 --- a/include/joey/audio.h +++ b/include/joey/audio.h @@ -12,10 +12,12 @@ // 65816 replayer linked into the binary. // Amiga -- raw .MOD, played by Frank Wille's PTPlayer. // DOS -- raw .MOD, mixed by JoeyLib's module player (PTPlayer's -// replay in C) into SB DMA (22 kHz mono 8-bit). +// replay in C) into SB DMA (22 kHz 8-bit, stereo on an +// SB16). // Atari ST -- raw .MOD, mixed by the module player at 12288 Hz and // played through the YM2149's channel A volume (PWM); the -// STe build plays it through DMA sound at 12517 Hz. +// STe build plays it in stereo through DMA sound at +// 12517 Hz. // X68000 -- raw .MOD, mixed by the module player at 7812 Hz into the // MSM6258 ADPCM channel. // DVX -- raw .MOD through DVX's music library. @@ -25,6 +27,18 @@ // the engine choice. jlInit starts audio (jlConfigT.noAudio leaves it // off) and jlShutdown stops it. Sound that does not start is non-fatal: // jlInit still succeeds and audio calls become no-ops. +// +// The mix is the same on every port, within 3 dB: a sound effect at full +// scale, and a module channel at full volume playing a full-scale sample, +// are each as loud as a tone voice at attenuation 0 (a square wave sampled +// at full scale peaks where the voice does), and the noise at attenuation 0 +// is as loud as a voice. The ports that mix in software put that peak at a +// quarter of full scale, so the three voices and the noise together, or a +// module's four channels, fit; the plain ST puts it at half of its one +// sound chip channel, so a module whose three or four channels peak at once +// clips there. DOS keeps the mix on the Sound Blaster's mixer, which the +// cards before the SB Pro do not have: there effects and modules play about +// 5 dB over the voices. #ifndef JOEYLIB_AUDIO_H #define JOEYLIB_AUDIO_H @@ -59,8 +73,9 @@ // ahead of time. void jlAudioPlayMod(const uint8_t *data, uint32_t length, bool loop); -// Stop the current module (if any). The playhead is reset so the next -// jlAudioPlayMod starts from the top. +// Stop the current module (if any), silent at once on every port (DOS and +// the X68000 take it back out of what they have mixed ahead of the chip). +// The playhead is reset so the next jlAudioPlayMod starts from the top. void jlAudioStopMod(void); // True if a module is currently producing output (false during silence @@ -71,7 +86,9 @@ bool jlAudioIsPlayingMod(void); // Trigger a one-shot digital SFX on the given slot (0..JL_AUDIO_SFX_SLOTS-1). // sample points at raw signed 8-bit PCM. rateHz is the playback rate // the sample was recorded at; the engine pitches as needed for its -// own output rate. If the slot is currently playing, the new sample +// own output rate, any rate at its pitch (the IIgs above 26 kHz and the +// Amiga above 28.6 kHz skip samples, so a fast one loses its top there). If +// the slot is currently playing, the new sample // replaces it. Most ports read the sample in place while it plays, so it // must stay valid and unchanged until it has finished or the slot is // stopped; the IIgs keeps a copy per slot keyed by address, length, and @@ -111,10 +128,11 @@ void jlAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint // Raw-format streaming: jlAudioPlaySfxStream, except `fill` delivers // bytes already in the port's raw stream format (jlAudioSfxRawFormat), // so the port skips its per-byte conversion of every chunk. On the IIgs -// that conversion costs ~7 us a byte, ~50 ms per 7 KB chunk. Every other -// port's raw format is signed PCM, so there Raw is the plain call (DVX -// still flips each byte for its sound library). A producer that cannot -// make the port's format uses jlAudioPlaySfxStream. +// that conversion costs ~7 us a byte, ~50 ms per 7 KB chunk. DVX takes the +// IIgs's format too (its sound library's 8-bit PCM is unsigned) and skips +// its flip. Every other port's raw format is signed PCM, so there Raw is +// the plain call. A producer that cannot make the port's format uses +// jlAudioPlaySfxStream. #define JL_SFX_RAW_SIGNED8 0u // int8_t PCM, the jlAudioPlaySfxStream contract #define JL_SFX_RAW_UNSIGNED8_NOZERO 1u // uint8_t PCM biased 0x80; 0x00 is forbidden (DOC stop byte) uint8_t jlAudioSfxRawFormat(void); @@ -145,7 +163,10 @@ typedef enum { // have one, and otherwise on the one channel PTPlayer picks. // X68000 ignored. Everything is mixed into ONE MSM6258 ADPCM stream, so // its pan bits would move all audio at once, not one slot. -// DOS, ST ignored -- their output is mono (the STe build's too). +// DOS all three on an SB16, the module's channels on Paula's sides; +// ignored on the Sound Blasters before it, which play mono. +// ST ignored -- its output is mono. The STe build plays all three +// (its DMA chip plays stereo), the module's as on DOS. // DVX all three, through DVX's stereo mixer. // hosted all three (the mix is stereo), except Win16, which is mono. // Passing an out-of-range pan is ignored. @@ -182,9 +203,9 @@ void jlAudioTone(uint16_t freqHz); // correct). The Amiga uses a timer.device E-clock request and a // software interrupt; the ST and X68000 ride the system's Timer C // interrupt (200 and 100 Hz), so calls due between its ticks come -// together. The IIgs and DVX call fn in program time from -// jlAudioFrameTick, and the hosted ports from the mixer, between the -// samples where each tick falls. +// together. The IIgs calls fn in program time from jlAudioFrameTick, +// and DVX and the hosted ports from the mixer, between the samples where +// each tick falls. typedef void (*jlAudioTickFnT)(void); bool jlAudioTickRegister(jlAudioTickFnT fn, uint16_t hz); @@ -213,7 +234,8 @@ void jlAudioCriticalExit(void); // IIgs -- Ensoniq DOC oscillators playing a resident square on SFX // slots 0/1/2. // X68000 -- YM2151 channels 0/1/2, one sine operator each. -// DVX -- square tones of DVX's sound library. +// DVX -- square waves mixed in software into a stream of DVX's +// mixer (DVX's own square tones with only the PC speaker). // hosted -- square waves mixed in software. // // A note sounds until the next call for that voice. The IIgs plays a @@ -244,7 +266,9 @@ void jlAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten); // (channels 0-2 are the tone voices). // IIgs -- a resident LFSR noise sample on SFX slot 3. // X68000 -- the YM2151's noise generator, at the rate exactly. -// DVX -- a noise tone of DVX's sound library. +// DVX -- the YM2149's 17-bit generator, mixed in software (a +// noise tone of DVX's sound library with only the PC +// speaker). // hosted -- the YM2149's 17-bit generator, mixed in software. // // The same contract as jlAudioVoice: it sounds until the next call. diff --git a/include/joey/core.h b/include/joey/core.h index 4853d7b..9263df6 100644 --- a/include/joey/core.h +++ b/include/joey/core.h @@ -12,16 +12,17 @@ typedef struct { // Runtime compiled-sprite cache size. 0 selects a per-platform - // default: 8 KB on the IIgs, 64 KB on the Amiga and ST, and 32 KB - // everywhere else (the 68k/x86 emitters are ~4-8x less code-dense - // than the 65816's, so a 32x32 sprite can need 8-16 KB there). The - // ports that interpret every sprite still allocate it. Size against - // jlSpriteCompiledSize() when tuning. Elsewhere the cache is one - // block, and jlInit fails if it cannot be had. On the IIgs it is up - // to four Memory Manager blocks of at most 64 KB, each inside one - // bank (a compiled sprite cannot cross a bank), so at most 256 KB; - // a bank that is refused is retried at 60 KB, and jlInit takes - // whatever part of the request it was granted. + // default: 8 KB on the IIgs, 64 KB on the Amiga, ST, and X68000, and + // 32 KB on DOS, DVX, and Win16 (the 68k/x86 emitters are ~4-8x less + // code-dense than the 65816's, so a 32x32 sprite can need 8-16 KB + // there). The ports that interpret every sprite have no cache and + // allocate none. Size against jlSpriteCompiledSize() when tuning. + // Every port takes as much of the request as the machine has, and + // jlInit fails only if it can have none (jlSpriteCodegenBytesTotal + // says what it got): on the IIgs up to four Memory Manager blocks of + // at most 64 KB, each inside one bank (a compiled sprite cannot cross + // a bank), so at most 256 KB; elsewhere one block, halved until the + // heap has room, down to 4 KB. uint32_t codegenBytes; // True: leave audio off. Every audio call is then silent, as on a // machine without sound, and the program pays nothing for audio: @@ -34,13 +35,13 @@ typedef struct { // Initialize the library, audio too (unless config->noAudio). Returns true // on success; sound that does not start is not a failure (modules and // effects are then silent, and the log says so). On failure, jlLastError() -// returns a human-readable description; where the port's own start-up -// fails without saying why, that is just "jlpInit failed". +// returns a human-readable description of why, on every port. bool jlInit(const jlConfigT *config); // Shut down the library, audio first, releasing the library's own // resources. Surfaces and sprites the program created are not freed: -// destroy them first. +// destroy them first. A program that returns from main or calls exit +// without it has it run then (jlInit registers it with atexit). void jlShutdown(void); // Returns the most recent error message, or NULL if none. @@ -98,8 +99,9 @@ uint16_t jlFrameCount(void); // by to convert iters-per-N-frames to ops/sec. uint16_t jlFrameHz(void); -// Monotonic wall-clock millisecond counter since jlInit. It may -// advance a frame at a time. Call it at least once every 15 minutes, or +// Monotonic wall-clock millisecond counter since jlInit, to the +// millisecond (a frame at a time only where the machine's timer is +// taken: docs/ApiCore.adoc). Call it at least once every 15 minutes, or // the time between two calls can be lost. Two consecutive calls // separated by a long render report the true elapsed time, not the // number of VBLs that fired. diff --git a/include/joey/file.h b/include/joey/file.h index 19c5217..40ab75a 100644 --- a/include/joey/file.h +++ b/include/joey/file.h @@ -10,11 +10,13 @@ // DATA/ -- game content (levels, sprites, audio). jlDataOpen. // SAVES/ -- writable save / preference files. jlSave*. // Both are addressed by a bare name relative to their directory; the prefix is -// forced here so callers never hard-code "DATA/" or "SAVES/". The hosted -// ports make the program's folder the current directory in jlInit; DOS, the -// ST, the Amiga, and the X68000 use the current directory as they find it; -// the IIgs finds DATA/ under GS/OS prefix 0 and SAVES/ in the application's -// folder (prefix 1); DVX roots both at run time (docs/ApiFile.adoc). +// forced here so callers never hard-code "DATA/" or "SAVES/". Both are found +// in the program's own folder wherever it was started from, before jlInit as +// after: DOS from its start path, the Amiga as PROGDIR:, the X68000 from its +// process block, the IIgs as GS/OS prefix 1, the hosted ports from their +// executable's path. The ST uses the current directory (TOS tells a program +// nothing of its folder); DVX keeps SAVES/ in the program's settings folder +// (docs/ApiFile.adoc). #ifndef JOEYLIB_FILE_H #define JOEYLIB_FILE_H @@ -54,9 +56,10 @@ FILE *jlDataOpen(const char *name, const char *mode); FILE *jlSaveOpen(const char *name, const char *mode); // Write `len` bytes from `buf` to save file `name`, replacing any existing -// contents. Returns true only if all `len` bytes were written. Not atomic: a -// write that fails part way leaves the save short. buf NULL with len > 0 -// returns false before the save is touched. +// contents. Returns true only if all `len` bytes were written. The bytes go to +// a temporary file that takes the save's place only once it is whole, so a +// write that fails part way leaves the old save as it was. buf NULL with +// len > 0 returns false before the save is touched. bool jlSaveWrite(const char *name, const void *buf, uint32_t len); // Read up to `max` bytes from save file `name` into `buf`. Returns the number diff --git a/include/joey/platform.h b/include/joey/platform.h index 2d10e67..e687ced 100644 --- a/include/joey/platform.h +++ b/include/joey/platform.h @@ -199,6 +199,7 @@ #define JL_HAS_STAGE_ALLOC_PIXELS // pinned $01:2000 display memory, function #define JL_HAS_STAGE_FREE_PIXELS // no-op (pinned), function #define JL_HAS_FRAME_HZ // runtime 50/60 from battery RAM, function + #define JL_HAS_MILLIS_ELAPSED // heartbeat frames plus the beam's line // (frameCount: port.h #defines jlpFrameCount straight to iigsGetTickWord, // the VBL heartbeat tick read -- no function override to register here.) #define JL_HAS_WAIT_VBL // the VBL heartbeat count advancing, function @@ -245,6 +246,9 @@ #define JL_HAS_SAVE_DIR_ENSURE #define JL_HAS_SAVE_DELETE #define JL_HAS_DISK_FREE + // DATA/, SAVES/ and joeylog.txt in the application's folder, GS/OS prefix + // 1 (fileRoot.h). + #define JL_HAS_FILE_ROOTS // millisElapsed: generic frameCount*1000/frameHz (no override) #elif defined(JOEYLIB_PLATFORM_AMIGA) #define JL_HAS_SURFACE_CLEAR // planar clear, function @@ -314,10 +318,17 @@ #define JL_HAS_SERIAL_SET_DTR // Application-driven RTS for a caller doing its own receive-side flow control (jlSerialSetRts). #define JL_HAS_SERIAL_SET_RTS - // save files + disk space (CreateDir/DeleteFile/Info via dos.library) + // the millisecond clock: timer.device's E-clock (ReadEClock) + #define JL_HAS_MILLIS_ELAPSED + // save files + disk space (CreateDir/DeleteFile/Rename/Info via + // dos.library: libnix has no rename) #define JL_HAS_SAVE_DIR_ENSURE #define JL_HAS_SAVE_DELETE + #define JL_HAS_SAVE_RENAME #define JL_HAS_DISK_FREE + // DATA/, SAVES/ and joeylog.txt in the program's folder, PROGDIR: + // (fileRoot.h). + #define JL_HAS_FILE_ROOTS // compiled code written: clear the 68020+'s caches (exec CacheClearE) #define JL_HAS_CODE_WRITTEN // millisElapsed: generic frameCount*1000/frameHz (no override) @@ -350,10 +361,10 @@ #define JL_HAS_SURFACE_ALLOC_PIXELS // st: NULL (planar storage), function #define JL_HAS_SURFACE_ALLOC_PORT_DATA // st StPlanarT + interleaved buffer, function #define JL_HAS_SURFACE_FREE_PORT_DATA // st free buffer, function - #define JL_HAS_FRAME_HZ // the machine's 50 / 60 (71 mono), function + #define JL_HAS_FRAME_HZ // the machine's 50 / 60, function #define JL_HAS_FRAME_COUNT // VBL-ISR counter, function #define JL_HAS_WAIT_VBL // frame-counter wait, function - #define JL_HAS_MILLIS_ELAPSED // Supexec 200 Hz tick, function + #define JL_HAS_MILLIS_ELAPSED // 200 Hz tick + Timer C's live count, function // audio: every real port implements the full engine #define JL_HAS_AUDIO_INIT #define JL_HAS_AUDIO_SHUTDOWN @@ -362,6 +373,9 @@ #define JL_HAS_AUDIO_IS_PLAYING_MOD #define JL_HAS_AUDIO_PLAY_SFX #define JL_HAS_AUDIO_PLAY_SFX_STREAM + #if defined(JOEYLIB_ATARI_STE) + #define JL_HAS_AUDIO_SET_PAN // the STe's DMA chip plays stereo + #endif #define JL_HAS_AUDIO_STOP_SFX #define JL_HAS_AUDIO_VOICE #define JL_HAS_AUDIO_NOISE @@ -370,6 +384,7 @@ #define JL_HAS_AUDIO_CRITICAL_EXIT #define JL_HAS_AUDIO_FRAME_TICK #define JL_HAS_MOD_MIX_RUN // the module player's 8-bit mix, src/m68k/modMix.s + #define JL_HAS_SFX_MIX_RUN // the SFX overlay's run, src/m68k/sfxMix.s // lifecycle / present / input (every real port implements) #define JL_HAS_INIT #define JL_HAS_SHUTDOWN @@ -417,6 +432,7 @@ #define JL_HAS_AUDIO_IS_PLAYING_MOD #define JL_HAS_AUDIO_PLAY_SFX #define JL_HAS_AUDIO_PLAY_SFX_STREAM + #define JL_HAS_AUDIO_SET_PAN // the side, on an SB16 (stereo) #define JL_HAS_AUDIO_STOP_SFX #define JL_HAS_AUDIO_VOICE #define JL_HAS_AUDIO_NOISE @@ -444,10 +460,14 @@ #define JL_HAS_SERIAL_SET_DTR // Application-driven RTS for a caller doing its own receive-side flow control (jlSerialSetRts). #define JL_HAS_SERIAL_SET_RTS - // save files + disk space (POSIX mkdir/remove + int21h/36h disk-free) + // save files + disk space (POSIX mkdir/remove/statvfs: statvfs takes + // FAT32's free space past 2 GB) #define JL_HAS_SAVE_DIR_ENSURE #define JL_HAS_SAVE_DELETE #define JL_HAS_DISK_FREE + // DATA/, SAVES/ and joeylog.txt in the program's folder, from the path + // DOS started it by (fileRoot.h). + #define JL_HAS_FILE_ROOTS #elif defined(JOEYLIB_PLATFORM_X68000) // Platform services in src/x68000/hal.c, the serial add-on in // src/x68000/serial.c, and planar graphics adapted from the Amiga @@ -497,6 +517,9 @@ #define JL_HAS_DISK_FREE #define JL_HAS_SAVE_DELETE #define JL_HAS_SAVE_DIR_ENSURE + // DATA/, SAVES/ and joeylog.txt in the program's folder, from its process + // block (fileRoot.h). + #define JL_HAS_FILE_ROOTS // Serial add-on: RSDRV.SYS transmit and line setup, receive on the // port's own SCC interrupt (src/x68000/x68kSccRxIsr.s). #define JL_HAS_SERIAL_OPEN @@ -521,6 +544,7 @@ #define JL_HAS_AUDIO_CRITICAL_EXIT #define JL_HAS_AUDIO_FRAME_TICK #define JL_HAS_MOD_MIX_RUN // the module player's 8-bit mix, src/m68k/modMix.s + #define JL_HAS_SFX_MIX_RUN // the SFX overlay's run, src/m68k/sfxMix.s #define JL_HAS_CODE_WRITTEN // the X68030's caches, after codegen // SAMPLE side (src/x68000/audioPcm.c): the module player + the shared 5-slot SFX // overlay, encoded to MSM6258 ADPCM and played via _iocs_adpcmlot. Separate @@ -565,6 +589,7 @@ #define JL_HAS_AUDIO_IS_PLAYING_MOD #define JL_HAS_AUDIO_PLAY_SFX #define JL_HAS_AUDIO_PLAY_SFX_STREAM + #define JL_HAS_AUDIO_PLAY_SFX_STREAM_RAW // unsigned bytes as they come (port.h) #define JL_HAS_AUDIO_SET_PAN #define JL_HAS_AUDIO_STOP_SFX #define JL_HAS_AUDIO_VOICE @@ -603,6 +628,9 @@ #define JL_HAS_SAVE_DIR_ENSURE #define JL_HAS_SAVE_DELETE #define JL_HAS_DISK_FREE + // DATA/, SAVES/ and joeylog.txt in the program's folder, from the + // executable's path (hostBackendProgramFolder, fileRoot.h). + #define JL_HAS_FILE_ROOTS // audio: a software mixer (MOD through the module player, the SFX slots, three // square-wave voices and YM-style noise) run on the program's thread, so // the tick callback and the stream fills run there too and the critical @@ -633,6 +661,19 @@ #define JL_HAS_SERIAL_FLUSH #define JL_HAS_SERIAL_SET_DTR #define JL_HAS_SERIAL_SET_RTS + #elif defined(JOEYLIB_HOSTED_POSIX_SERIAL) + // serial add-on (optional; a terminal device through termios, + // src/hosted/posix/posixSerial.c: SDL3 on Linux, Haiku, and BeOS; the + // poll moves what the driver holds into the add-on's ring) + #define JL_HAS_SERIAL_OPEN + #define JL_HAS_SERIAL_CLOSE + #define JL_HAS_SERIAL_POLL + #define JL_HAS_SERIAL_READ + #define JL_HAS_SERIAL_WRITE + #define JL_HAS_SERIAL_AVAILABLE + #define JL_HAS_SERIAL_FLUSH + #define JL_HAS_SERIAL_SET_DTR + #define JL_HAS_SERIAL_SET_RTS #endif #elif defined(JOEYLIB_PLATFORM_BLANK) // Copy-to-start template. It overrides ONLY the platform SERVICES (TODO diff --git a/include/joey/serial.h b/include/joey/serial.h index 533e5f3..0375d83 100644 --- a/include/joey/serial.h +++ b/include/joey/serial.h @@ -22,11 +22,14 @@ // opened in supervisor mode, as jlInit leaves every program). // DVX -- a COM port through DVX's serial library. // Win32/16 -- a COM port through Windows' communications calls. -// SDL3, the web, the PSP, Haiku, and BeOS have no serial port. +// SDL3 (Linux), Haiku, BeOS -- a terminal device through termios +// (JOEYLIB_SERIAL in the environment names the default one). +// The web and the PSP have no serial port. // // Model: non-blocking + buffered. Received bytes accumulate in a background // ring (a hardware IRQ on DOS, the X68000 and the IIgs, the OS on Amiga/ST, -// DVX, and Windows); jlSerialRead / jlSerialAvailable never block. Call +// DVX, Windows, Linux, Haiku, and BeOS); jlSerialRead / jlSerialAvailable +// never block. Call // jlSerialPoll() once per game frame. Transmit never waits long: a bounded // poll on most ports, a queue on the Amiga and Win16. // @@ -59,11 +62,15 @@ // JL_SERIAL_SLOT -- a card/port selected by jlSerialConfigT.unit: IIgs // Super Serial Card slot (1..7); DOS or DVX COM number // (1..4); Win16 COM (1..9); Win32 COM (1..255); Amiga -// serial.device unit. The ST and X68000 have one port -// and open it for every device. +// serial.device unit; Linux /dev/ttyS; Haiku +// and BeOS the unit'th /dev/ports entry; ST family +// TOS Bconmap port (1 the Modem port; 2.. a Mega STe's, +// TT's, or Falcon's others). The X68000 has one port +// and opens it for every device. // JL_SERIAL_DEFAULT -- the platform's natural RS-232 port (IIgs modem, DOS, // DVX, and Windows COM1, ST Modem port, Amiga unit 0, -// X68000 RS-232C). +// X68000 RS-232C, Linux /dev/ttyS0, Haiku and BeOS +// the first /dev/ports entry). typedef enum { JL_SERIAL_DEFAULT = 0, JL_SERIAL_MODEM, @@ -93,7 +100,7 @@ typedef struct { uint8_t stopBits; // 1 or 2 jlSerialParityE parity; jlSerialFlowE flow; - uint8_t unit; // JL_SERIAL_SLOT: IIgs slot 1..7 / DOS, DVX COM 1..4 / Win16 COM 1..9 / Win32 COM 1..255 / Amiga unit + uint8_t unit; // JL_SERIAL_SLOT: IIgs slot 1..7 / DOS, DVX COM 1..4 / Win16 COM 1..9 / Win32 COM 1..255 / Amiga unit / ST Bconmap port 1.. } jlSerialConfigT; // Open + configure the port. Only one port is open at a time; a second diff --git a/include/joey/sprite.h b/include/joey/sprite.h index 3957fd3..36e79bf 100644 --- a/include/joey/sprite.h +++ b/include/joey/sprite.h @@ -157,7 +157,7 @@ void jlSpriteDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y); // UP): one extra 16-px group, (ceil(w/16) + 1) * 8 bytes per row. Give // the buffer 2-byte alignment (a uint16_t array has it): on the Amiga, // ST, and X68000 an odd address sends save and restore down a much -// slower path, and on the Amiga and ST it crashes the trusted variants. +// slower path, and it crashes the trusted variants. #define jlSpriteBackupLength(_type, _wT, _hT) \ ((uint16_t)((((((((uint16_t)(_wT) * 8u) + 15u) >> 4) + 1u) * 8u * ((uint16_t)(_hT) * 8u)) + sizeof(_type) - 1u) / sizeof(_type))) @@ -200,8 +200,8 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup); // even x/width on chunky, backup->height == the rows the compiled // save covers -- the sprite's pixel height, or on the IIgs the rows // holding an opaque pixel), -// * on the Amiga and ST, backup->bytes is at least 2-byte aligned (the -// trusted save and save-and-draw need that too), +// * on the Amiga, ST, and X68000, backup->bytes is at least 2-byte +// aligned (the trusted save and save-and-draw need that too), // * the recorded region is fully on the destination surface. // It is the expert inner-loop entry for a game that pre-compiles its // sprites (jlSpriteCompile) and already clamps positions on-screen -- @@ -259,10 +259,11 @@ void jlSpriteCompact(void); // Arena introspection. Used to gauge whether jlSpriteCompact is // worth running, or whether the codegenBytes budget needs to grow. // Free space is (Total - Used), but it may be fragmented across -// holes until jlSpriteCompact runs. On the IIgs Total is what jlInit was -// granted, in blocks of at most 64 KB that compacting never merges (one -// sprite's code must fit in one). The interpreting ports still allocate -// the arena: Total reports it and Used stays 0. +// holes until jlSpriteCompact runs. Total is what jlInit was granted, +// which a machine short of memory can make less than codegenBytes; on the +// IIgs in blocks of at most 64 KB that compacting never merges (one +// sprite's code must fit in one). The interpreting ports have no arena: +// both are 0. uint32_t jlSpriteCodegenBytesUsed(void); uint32_t jlSpriteCodegenBytesTotal(void); diff --git a/install.sh b/install.sh index d6172b0..9203e50 100755 --- a/install.sh +++ b/install.sh @@ -311,7 +311,7 @@ portGroups() { # wantedGroups -- the port groups the user chose: those holding an installed # component that belongs to that group alone. Shared members (headless, -# libxmp, mame, cadius) do not count, or installing one port would make +# mame, cadius) do not count, or installing one port would make # every port that shares them look half-installed. wantedGroups() { local id diff --git a/installer/components/libxmp.sh b/installer/components/libxmp.sh index 68d8a06..6c3acc2 100644 --- a/installer/components/libxmp.sh +++ b/installer/components/libxmp.sh @@ -6,10 +6,12 @@ # libxmp-lite 4.7.0: the MOD decoder the DOS, Atari ST, X68000, SDL3, PSP, # web, Win16, Win32, Haiku and BeOS audio HALs compiled in until 2026-10-14, # when JoeyLib's own module player (src/core/modPlayer.c) replaced it. No -# build uses it now; it stays a reference to compare that player against. +# build uses it now; it stays a reference to compare that player against, +# installed only when asked for by name (install libxmp), so no toolchain +# fetches it. -compDescription="audio: libxmp-lite 4.7.0 source (no build links it; a reference for the module player)" -compGroups="toolchain:dos toolchain:atarist toolchain:x68000 toolchain:sdl3 toolchain:psp toolchain:web toolchain:win16 toolchain:win32 toolchain:haiku toolchain:haiku32 toolchain:beos" +compDescription="audio: libxmp-lite 4.7.0 source (optional; no build links it; a reference for the module player)" +compGroups="optional" compRequires="host" compHostDeps="tar" compUrl="https://github.com/libxmp/libxmp/releases/download/libxmp-4.7.0/libxmp-lite-4.7.0.tar.gz" diff --git a/installer/patches/mame/0001-apple2gs-scc-clock.patch b/installer/patches/mame/0001-apple2gs-scc-clock.patch deleted file mode 100644 index 1dd6d3f..0000000 --- a/installer/patches/mame/0001-apple2gs-scc-clock.patch +++ /dev/null @@ -1,18 +0,0 @@ ---- a/src/mame/apple/apple2gs.cpp -+++ b/src/mame/apple/apple2gs.cpp -@@ -3839,8 +3839,13 @@ - ADDRESS_MAP_BANK(config, A2GS_C300_TAG).set_map(&apple2gs_state::c300bank_map).set_options(ENDIANNESS_LITTLE, 8, 32, 0x100); - - /* serial */ -- SCC85C30(config, m_scc, A2GS_14M / 2); -- m_scc->configure_channels(3'686'400, 3'686'400, 3'686'400, 3'686'400); -+ // RetroNet patch: a real IIgs feeds the SCC BRG a 3.6864 MHz PCLK (the classic 115200 -+ // time-constant base); A2GS_14M/2 = 7.159 MHz made every firmware/native BRG rate ~1.94x -+ // fast. Zeroed rxc/txc: the m_rxc>0 path in z80scc update_serial() otherwise FORCES the -+ // receive rate to rxc/16 = 230400 regardless of the programmed BRG, which no rs232 peer -+ // can match. -+ SCC85C30(config, m_scc, 3'686'400); -+ m_scc->configure_channels(0, 0, 0, 0); - m_scc->out_int_callback().set(FUNC(apple2gs_state::scc_irq_w)); - m_scc->out_txda_callback().set("printer", FUNC(rs232_port_device::write_txd)); - m_scc->out_txdb_callback().set("modem", FUNC(rs232_port_device::write_txd)); diff --git a/installer/patches/mame/README.md b/installer/patches/mame/README.md index 9905157..770c9a3 100644 --- a/installer/patches/mame/README.md +++ b/installer/patches/mame/README.md @@ -6,11 +6,16 @@ drivers. The gates depend on every one of them. | Patch | What it fixes | Needed by | |---|---|---| -| 0001-apple2gs-scc-clock | The IIgs SCC clock, so serial RX works at rates other than 230400 baud | `verify-iigs-serial.sh` | | 0002-z80scc-rx-fifo | Makes the Z8530 receive FIFO depth selectable at run time (see below). By default it is 512 bytes, so a POLLED receiver (no RX interrupt) survives the emulator's deaf windows; `JOEY_Z80SCC_RX_FIFO=3` restores the real chip's depth. Originally written for RetroNet. | IIgs serial gates (512); `verify-iigs-serial-loss.sh` (3) | | 0003-x68k-crtc-timing | The X68000 CRTC: the graphic high-speed clear's timing (the main defect), and the text raster copy firing on both hsync edges instead of only the front porch | X68000 gates and timing | | 0004-x68k-waitstates-rs232-mouse | Three X68000 changes that edit the same files: VRAM wait states (MAME modelled none), the RS-232C port wired to a slot so `-rs232c null_modem` works, and a backport of upstream commit ab6b6fc9e797 (Patrick Mackinlay, "x68k: add serial mouse"), which provides the `:mouse_port:x68k:X/Y/BTN` inputs | `verify-x68000-serial.sh`, `verify-x68000-mouse.sh`, X68000 timing | +There is no 0001 any more. It gave the IIgs SCC a 3.6864 MHz PCLK and no +RTxC clock, to suit a HAL that ran the baud rate generator from PCLK. Stock +MAME already clocks the chip as a IIgs does (PCLK 7.16 MHz from the Mega II, +the 3.6864 MHz crystal on RTxC), and the HAL now runs the generator from +RTxC, as cc65's IIgs driver does, and takes 115200 straight from the crystal. + ## Receive FIFO depth (0002) Real Z8530 silicon holds 3 received bytes (an ESCC, 8), so a real client diff --git a/make/be.mk b/make/be.mk index 18a52ca..c903681 100644 --- a/make/be.mk +++ b/make/be.mk @@ -18,7 +18,7 @@ # BE_CXX_RUNTIME (its C++ runtime library), then includes this file. HOSTED_BACKEND := be -HOSTED_DEFINES := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_BE +HOSTED_DEFINES := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_BE -DJOEYLIB_HOSTED_POSIX_SERIAL HOSTED_SDK_CFLAGS := # The Interface and Application Kits (libbe), the Media Kit's BSoundPlayer # (libmedia), the Device Kit's BJoystick (libdevice); the backend's C++ is diff --git a/make/examples.mk b/make/examples.mk index 6d69ca6..38dadb3 100644 --- a/make/examples.mk +++ b/make/examples.mk @@ -32,6 +32,10 @@ # PALPROBE Amiga: the palette gate's program: how fast palette and SCB # changes reach the screen through the copper, and a held screen # of bands to check that they reach it right. +# SERSLOT ST: SERTEST opening JL_SERIAL_SLOT unit 2 -- a Mega STe's SCC +# channel B through Bconmap -- for verify-atarist-serial-slot.sh. +# FASTRAM Amiga: the Fast RAM gate's program: a surface made once chip +# RAM has run out draws and copies as a chip one does. # WBLAUNCH Amiga: the icon gate's program: a program's icon read and # drawn by icon.library, and the program started from it the way # Workbench starts a tool. @@ -40,7 +44,7 @@ # and tick rates, which every port must play alike. PROGRAMS := HELLO PATTERN DRAW KEYS SERIAL SERTEST SERMOUSE SAVE TILEBANK SCB JOY SPRITE AUDIO UBER ADV ADV2 -TEST_PROGRAMS := SCCPROBE SERLOSS SERLOSSS DIRTYMRK UBERPROB AUDIOPRB AUDPAR PALPROBE WBLAUNCH +TEST_PROGRAMS := SCCPROBE SERLOSS SERLOSSS SERSLOT DIRTYMRK UBERPROB AUDIOPRB AUDPAR PALPROBE FASTRAM WBLAUNCH # DVX's help and resource texts call these JoeyLib examples. DVX_KIND := JoeyLib example @@ -129,6 +133,13 @@ DIRTYMRK_BINDIR := $(PROGRAM_BUILD)/test PALPROBE_SRCS := $(EXAMPLES)/palprobe/palprobe.c PALPROBE_PORTS := amiga PALPROBE_BINDIR := $(PROGRAM_BUILD)/test +SERSLOT_SRCS := $(EXAMPLES)/sertest/sertest.c +SERSLOT_PORTS := atarist +SERSLOT_BINDIR := $(PROGRAM_BUILD)/test +SERSLOT_CFLAGS := -DSERTEST_DEVICE=JL_SERIAL_SLOT -DSERTEST_UNIT=2u +FASTRAM_SRCS := $(REPO_DIR)/tests/amiga/fastRam.c +FASTRAM_PORTS := amiga +FASTRAM_BINDIR := $(PROGRAM_BUILD)/test WBLAUNCH_SRCS := $(EXAMPLES)/wblaunch/wblaunch.c WBLAUNCH_PORTS := amiga WBLAUNCH_BINDIR := $(PROGRAM_BUILD)/test diff --git a/make/hosted.mk b/make/hosted.mk index a007c84..20845ef 100644 --- a/make/hosted.mk +++ b/make/hosted.mk @@ -56,6 +56,9 @@ endif # system it runs on, built with the backend's flags; the backend includes # its headers. HOSTED_C_SRCS := $(wildcard $(HOSTED_DIR)/*.c) +# -DJOEYLIB_HOSTED_POSIX_SERIAL in HOSTED_DEFINES (make/sdl3.mk, make/be.mk): +# the serial add-on through termios, src/hosted/posix. +POSIX_C_SRCS := $(if $(filter -DJOEYLIB_HOSTED_POSIX_SERIAL,$(HOSTED_DEFINES)),$(wildcard $(HOSTED_DIR)/posix/*.c)) BACKEND_C_SRCS := $(wildcard $(BACKEND_DIR)/*.c) BACKEND_CXX_SRCS := $(wildcard $(BACKEND_DIR)/*.cpp) EXTRA_C_SRCS := $(if $(HOSTED_BACKEND_EXTRA),$(wildcard $(HOSTED_BACKEND_EXTRA)/*.c)) @@ -67,6 +70,7 @@ LIB_OBJS := \ $(patsubst $(SRC_DIR)/generic/%.c,$(BUILD)/obj/generic/%.o,$(GENERIC_C_SRCS)) \ $(HOSTED_SPRITE_OBJS) \ $(patsubst $(HOSTED_DIR)/%.c,$(BUILD)/obj/hosted/%.o,$(HOSTED_C_SRCS)) \ + $(patsubst $(HOSTED_DIR)/posix/%.c,$(BUILD)/obj/posix/%.o,$(POSIX_C_SRCS)) \ $(patsubst $(BACKEND_DIR)/%.c,$(BUILD)/obj/backend/%.o,$(BACKEND_C_SRCS)) \ $(patsubst $(BACKEND_DIR)/%.cpp,$(BUILD)/obj/backend/%.o,$(BACKEND_CXX_SRCS)) \ $(patsubst $(HOSTED_BACKEND_EXTRA)/%.c,$(BUILD)/obj/backendExtra/%.o,$(EXTRA_C_SRCS)) @@ -93,6 +97,10 @@ $(BUILD)/obj/hosted/%.o: $(HOSTED_DIR)/%.c @mkdir -p $(dir $@) $(HOSTED_CC) $(CFLAGS) -c $< -o $@ +$(BUILD)/obj/posix/%.o: $(HOSTED_DIR)/posix/%.c + @mkdir -p $(dir $@) + $(HOSTED_CC) $(CFLAGS) -c $< -o $@ + # Only the backend sees its library's headers. $(BUILD)/obj/backend/%.o: $(BACKEND_DIR)/%.c @mkdir -p $(dir $@) diff --git a/make/sdl3.mk b/make/sdl3.mk index 9b6373e..1402cc2 100644 --- a/make/sdl3.mk +++ b/make/sdl3.mk @@ -24,7 +24,7 @@ SDL3_PKG_CONFIG := PKG_CONFIG_PATH=$(SDL3_DIR)/lib/pkgconfig pkg-config HOSTED_CC := $(HOST_CC) HOSTED_AR := ar HOSTED_BACKEND := sdl3 -HOSTED_DEFINES := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_SDL3 +HOSTED_DEFINES := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_SDL3 -DJOEYLIB_HOSTED_POSIX_SERIAL HOSTED_SDK_CFLAGS := $(shell $(SDL3_PKG_CONFIG) --cflags sdl3) -DHOSTED_WINDOW_ICON HOSTED_LINK := $(shell $(SDL3_PKG_CONFIG) --static --libs sdl3) PROGRAM_EXT := diff --git a/make/support/atarist/include-shim/math.h b/make/support/atarist/include-shim/math.h index 101643a..33f14c6 100644 --- a/make/support/atarist/include-shim/math.h +++ b/make/support/atarist/include-shim/math.h @@ -3,10 +3,11 @@ // SPDX-License-Identifier: MIT // // Minimal math.h shim for the m68k-atari-mint toolchain (mintlib -// doesn't ship or libm). Declares the few functions libxmp-lite -// once reached for; the implementations live in math_shim.c next to this -// header, built into the ST's libjoey.a. Accuracy is a few hundred parts -// per million, not for general-purpose use. +// doesn't ship or libm), so a program that includes +// builds on the ST: ceil, floor, log, pow and trunc (and their float +// forms), nothing else (docs/Portability.adoc). The implementations live in +// math_shim.c next to this header, built into the ST's libjoey.a. Accuracy +// is a few hundred parts per million, not for general-purpose use. #ifndef JOEY_ATARIST_MATH_SHIM_H #define JOEY_ATARIST_MATH_SHIM_H diff --git a/make/support/atarist/include-shim/math_shim.c b/make/support/atarist/include-shim/math_shim.c index 73429a9..33fd694 100644 --- a/make/support/atarist/include-shim/math_shim.c +++ b/make/support/atarist/include-shim/math_shim.c @@ -3,7 +3,8 @@ // SPDX-License-Identifier: MIT // // Tiny libm replacement on the m68k-atari-mint toolchain, built into the -// ST's libjoey.a. Only the functions libxmp-lite once called are implemented. +// ST's libjoey.a, for programs that include (math.h here says +// which functions). // Accuracy targets: a few hundred parts per million, plenty for // note-frequency math. diff --git a/make/support/web/shell.html b/make/support/web/shell.html index 2f84b85..21bfb3b 100644 --- a/make/support/web/shell.html +++ b/make/support/web/shell.html @@ -13,9 +13,10 @@ sets the window as the native SDL3 port's environment does: NAME.html?scale=1&aspect=square&fullscreen=1 become JOEYLIB_SCALE, JOEYLIB_ASPECT and JOEYLIB_FULLSCREEN (the web gates open the stage at one - canvas pixel per stage pixel that way). The program's joeylog.txt (the - in-memory file system's /joeylog.txt) is mirrored into the hidden #joeylog - element once a second, for a look without a debugger. + canvas pixel per stage pixel that way). The program's joeylog.txt (in its + write folder in the in-memory file system, Module.joeyWriteFolder, once the + program has one) is mirrored into the hidden #joeylog element once a + second, for a look without a debugger. --> @@ -56,11 +57,11 @@ }); setInterval(function () { var log; - if (!Module.FS) { + if (!Module.FS || !Module.joeyWriteFolder) { return; } try { - log = Module.FS.readFile("/joeylog.txt", { encoding: "utf8" }); + log = Module.FS.readFile(Module.joeyWriteFolder + "joeylog.txt", { encoding: "utf8" }); } catch (e) { return; } diff --git a/make/x68000.mk b/make/x68000.mk index 2ebb00b..6fb5246 100644 --- a/make/x68000.mk +++ b/make/x68000.mk @@ -58,6 +58,7 @@ LIB_OBJS := \ $(BUILD)/obj/68k/planarPlot.o \ $(BUILD)/obj/68k/stageBands.o \ $(BUILD)/obj/68k/modMix.o \ + $(BUILD)/obj/68k/sfxMix.o \ $(BUILD)/obj/68k/planarSpriteWalk.o \ $(BUILD)/obj/68k/spriteEmitPlanar68k.o \ $(BUILD)/obj/68k/planarSprite.o \ diff --git a/scripts/bench-dvx.sh b/scripts/bench-dvx.sh index 72b0213..6290879 100755 --- a/scripts/bench-dvx.sh +++ b/scripts/bench-dvx.sh @@ -22,6 +22,9 @@ # 320x200x8, VGA mode 13h here), and fails unless DVX.LOG says the mode was # set: the same hashes, the full-screen present's timings. # +# --square opens the window with square pixels (Game > Square Pixels, staged +# the same way) instead of 4:3. +# # --probe runs UBER's probe build instead (make dvx builds it: its UBER_PROBE # rows, the display parity row among them, at one frame per timed op), its # log copied to build/dvx/probe/joeylog.txt; scripts/verify-dvx-display.sh @@ -30,7 +33,7 @@ # # scripts/bench-dvx.sh # default 600 s timeout # scripts/bench-dvx.sh --full-screen # on the whole screen -# scripts/bench-dvx.sh --probe # the probe build (with --full-screen too) +# scripts/bench-dvx.sh --probe # the probe build (with --full-screen or --square too) # TIMEOUT=900 scripts/bench-dvx.sh # override set -euo pipefail @@ -42,6 +45,7 @@ joeyNeed DOSBOX dosbox joeyNeed DVX_ROOT dvx fullScreen=0 +square=0 program=uber appDir=$(dvxAppDir uber) out=$DVX_BIN/$DVX_LOG_NAME @@ -50,13 +54,16 @@ for arg in "$@"; do --full-screen) fullScreen=1 ;; + --square) + square=1 + ;; --probe) program=uberprob appDir=$DVX_PROBE_DIR/apps/$DVX_VENDOR/$program out=$DVX_PROBE_DIR/$DVX_LOG_NAME ;; *) - echo "usage: scripts/bench-dvx.sh [--full-screen] [--probe]" >&2 + echo "usage: scripts/bench-dvx.sh [--full-screen] [--square] [--probe]" >&2 exit 2 ;; esac @@ -84,8 +91,11 @@ cleanup() { trap cleanup EXIT dvxStage "$work/c" "$appDir" -if [ "$fullScreen" = 1 ]; then - dvxFullScreen "$work/c" "$program" +switches=() +[ "$fullScreen" = 1 ] && switches+=("$DVX_SETTINGS_FULL_SCREEN") +[ "$square" = 1 ] && switches+=("$DVX_SETTINGS_SQUARE") +if [ ${#switches[@]} -gt 0 ]; then + dvxSettings "$work/c" "$program" "${switches[@]}" fi dvxDosboxConf "$work/dosbox.conf" "$work/c" mkdir -p "$work/xdg" diff --git a/scripts/bench-x68000.sh b/scripts/bench-x68000.sh index 672bdbc..ad6a310 100755 --- a/scripts/bench-x68000.sh +++ b/scripts/bench-x68000.sh @@ -97,12 +97,19 @@ local EXIT_SETTLE = 900 -- keypress -> fclose -> back at the Human68k prompt -- 16-bit word per pixel, 512 words per row. -- The 320x200 stage is centred at origin (96, 156) either way. local presentPlane = "$PRESENT_PLANE" +-- The text plane holds two pages, and the port flips between them with the +-- text Y scroll (CRTC R11, which reads back): rows are counted from the page +-- on screen. +local function shownRow() + return mem:read_u16(0xE80016) & 0x3FF +end + local function stagePixel(px, py) if presentPlane == "gfx" then return mem:read_u8(0xC00000 + ((156 + py) * 512 + 96 + px) * 2 + 1) end local x = 96 + px - local byteOff = (156 + py) * 128 + (x >> 3) + local byteOff = (156 + shownRow() + py) * 128 + (x >> 3) local bit = 0x80 >> (x & 7) local v = 0 for plane = 0, 3 do diff --git a/scripts/check-save.sh b/scripts/check-save.sh index 5222b69..a0dd1e6 100755 --- a/scripts/check-save.sh +++ b/scripts/check-save.sh @@ -19,7 +19,7 @@ work=$(mktemp -d -t joeylib-save.XXXXXX) trap 'rm -rf "$work"' EXIT "$cc" -DJOEYLIB_PLATFORM_BLANK \ - -DJL_HAS_SAVE_DIR_ENSURE -DJL_HAS_SAVE_DELETE -DJL_HAS_DISK_FREE \ + -DJL_HAS_SAVE_DIR_ENSURE -DJL_HAS_SAVE_DELETE -DJL_HAS_SAVE_RENAME -DJL_HAS_DISK_FREE \ -I"$repo/include" -I"$repo/include/joey" -I"$repo/src/core" -Wall -Wextra \ "$repo/src/core/save.c" "$repo/tests/host/saveHost.c" \ -o "$work/saveHost" diff --git a/scripts/check-serial.sh b/scripts/check-serial.sh index a28ecf4..a9f5538 100755 --- a/scripts/check-serial.sh +++ b/scripts/check-serial.sh @@ -23,6 +23,7 @@ cd "$repo" -DJL_HAS_SERIAL_OPEN -DJL_HAS_SERIAL_CLOSE -DJL_HAS_SERIAL_POLL \ -DJL_HAS_SERIAL_READ -DJL_HAS_SERIAL_WRITE -DJL_HAS_SERIAL_AVAILABLE \ -DJL_HAS_SERIAL_FLUSH -DJL_HAS_SERIAL_SET_RTS -DJL_HAS_SERIAL_SET_DTR \ + -DJL_HAS_MILLIS_ELAPSED \ -Iinclude -Iinclude/joey -Isrc/core -Wall -Wextra \ src/core/serial.c tests/host/serialHost.c \ -o "$work/serialHost" diff --git a/scripts/lib/dosboxJoy.sh b/scripts/lib/dosboxJoy.sh index d240739..457521e 100644 --- a/scripts/lib/dosboxJoy.sh +++ b/scripts/lib/dosboxJoy.sh @@ -13,6 +13,9 @@ # Ctrl reaches the program. The mapper replaces every DOSBox binding: only # these keys and Escape reach the machine. Source after joyGate.sh. # +# DOSBOX_JOY_SETTINGS [joystick] settings every run needs +# ("name=value"); DOSBOX_JOY_SET_ARGS has them +# as DOSBox --set arguments # dosboxJoyMapper FILE write the mapper file # dosboxJoyTap NAME press and let go of a key of the table # (escape) @@ -29,6 +32,22 @@ DOSBOX_JOY_WINDOW="" DOSBOX_JOY_SHOTS="" DOSBOX_JOY_TAP_SECONDS=0.3 +# DOSBox Staging 0.83's 4axis sticks wrap buttons at two when no host +# joystick is open: CStickBindGroup sets the wrap to its two buttons, and +# C4AxisBindGroup raises the count to four but copies it to the wrap only +# with buttonwrap on, so stick B's buttons would press stick A's. buttonwrap +# on wraps at four. A host pad DOSBox opened set the wrap to its own button +# count and hid this, and would add its own axes and buttons, so every host +# pad is hidden from DOSBox's SDL (an id no device has is the only one let +# through) and the runs are the same whatever is plugged in. +DOSBOX_JOY_SETTINGS=("buttonwrap=on") +DOSBOX_JOY_SET_ARGS=() +for setting in "${DOSBOX_JOY_SETTINGS[@]}"; do + DOSBOX_JOY_SET_ARGS+=(--set "$setting") +done +unset setting +export SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0000/0x0000 + # Control or name, its host key (xdotool's name), the key's SDL scancode and # the DOSBox mapper event it drives. DOSBOX_JOY_KEYS=( diff --git a/scripts/lib/dvxGate.sh b/scripts/lib/dvxGate.sh index 1d8589d..d858a2d 100644 --- a/scripts/lib/dvxGate.sh +++ b/scripts/lib/dvxGate.sh @@ -22,10 +22,11 @@ DVX_PROBE_DIR="${DVX_PROBE_DIR:-$JOEY_REPO/build/dvx/probe}" DVX_VENDOR="${DVX_VENDOR:-joeylib}" DVX_LOG_NAME=joeylog.txt # The port's own settings file in a program's settings folder, and its -# full-screen switch (src/dvx/hal.c). +# full-screen and square-pixels switches (src/dvx/hal.c). DVX_SETTINGS_FILE=JOEYLIB.INI DVX_SETTINGS_SECTION=display DVX_SETTINGS_FULL_SCREEN=fullScreen +DVX_SETTINGS_SQUARE=squarePixels # DVX.LOG's line when a program's full-screen mode is set (the port asks for # the stage's size at 8 bits a pixel). DVX_FULL_SCREEN_LOG="Video: full screen 320x200 at 8 bpp" @@ -90,18 +91,30 @@ dvxStage() { } -# dvxFullScreen DIR PROGRAM -- have PROGRAM start on the whole screen in the -# DVX staged in DIR: the port's own setting (JOEYLIB.INI, [display] -# fullScreen, in the program's settings folder), as Game > Full Screen leaves -# it. DOS names are case-blind, so an existing folder of any case is used. -dvxFullScreen() { +# dvxSettings DIR PROGRAM SWITCH... -- have PROGRAM start with the port's +# own display switches on (DVX_SETTINGS_FULL_SCREEN, DVX_SETTINGS_SQUARE) in +# the DVX staged in DIR: its JOEYLIB.INI, [display], in the program's +# settings folder, as the Game menu leaves it. DOS names are case-blind, so +# an existing folder of any case is used. +dvxSettings() { local conf + local key conf="$(find "$1/DVX" -ipath "*/config/apps/$DVX_VENDOR/$2" -type d -print -quit 2>/dev/null)" if [ -z "$conf" ]; then conf="$1/DVX/config/apps/$DVX_VENDOR/$2" mkdir -p "$conf" fi - printf '[%s]\r\n%s = true\r\n' "$DVX_SETTINGS_SECTION" "$DVX_SETTINGS_FULL_SCREEN" > "$conf/$DVX_SETTINGS_FILE" + printf '[%s]\r\n' "$DVX_SETTINGS_SECTION" > "$conf/$DVX_SETTINGS_FILE" + for key in "${@:3}"; do + printf '%s = true\r\n' "$key" >> "$conf/$DVX_SETTINGS_FILE" + done +} + + +# dvxFullScreen DIR PROGRAM -- have PROGRAM start on the whole screen +# (dvxSettings). +dvxFullScreen() { + dvxSettings "$1" "$2" "$DVX_SETTINGS_FULL_SCREEN" } diff --git a/scripts/lib/serialLoopPeer.py b/scripts/lib/serialLoopPeer.py index f72d3a1..24c54d3 100755 --- a/scripts/lib/serialLoopPeer.py +++ b/scripts/lib/serialLoopPeer.py @@ -18,6 +18,13 @@ works too). WALL is the wall-clock budget in seconds. Prints one LOOP PASS / LOOP FAIL line; exit 0 on pass, 1 on fail. +--close-hold FILE (pty) then has SERTEST close the port: its DTR check, run +for the closes alone (a pty has no modem lines), every marker line +acknowledged, after which the port stays closed. Prints one CLOSE PASS / +CLOSE FAIL line more, and keeps the pty pair open until FILE exists or WALL +runs out, so the gate can read the guest end's settings after the close +(verify-sdl3-serial.sh). + --dtr-check (TCP, with DOSBox's nullmodem in transparent:0 mode, which puts the guest's modem lines in the byte stream as they change: 0xFF 0xFF is a data 0xFF, 0xFF the lines, bit 0 RTS, bit 1 DTR) then runs SERTEST's @@ -132,6 +139,42 @@ def openPty(pathFile): return master +def ackMarks(send, got, base, marks): + # Acknowledge each of SERTEST's marker lines (after got[base:]) as it + # completes, recording where it is in marks. + while len(marks) < len(MARKS): + name, mark = MARKS[len(marks)] + at = got.find(mark, base) + if at < 0: + return + marks[name] = at + send(DTR_ACK) + + +def closeHold(send, receive, wall, start, got, holdFile): + # After the loopback: SERTEST's DTR check for its closes alone, then the + # pty held open until holdFile exists. + base = len(got) + send(DTR_CHECK) + marks = {} + while len(marks) < len(MARKS) and time.time() - start < wall: + data = receive() + if data is None: + break + got += data + ackMarks(send, got, base, marks) + if len(marks) == len(MARKS): + print("CLOSE PASS: every marker acknowledged; SERTEST has closed the port") + status = 0 + else: + print("CLOSE FAIL: no %r from SERTEST; stream=%r" % (MARKS[len(marks)][1], got[base:base + SHOWN_BYTES])) + status = 1 + sys.stdout.flush() + while not os.path.exists(holdFile) and time.time() - start < wall: + receive() + return status + + def dtrCheck(send, receive, wall, start, lines, got): # After the loopback: the stream so far is got; lines has decoded it. base = len(got) @@ -145,15 +188,8 @@ def dtrCheck(send, receive, wall, start, lines, got): if data is None: break got += data - # Acknowledge each marker line as it completes; the last starts the - # watch. - while len(marks) < len(MARKS): - name, mark = MARKS[len(marks)] - at = got.find(mark, base) - if at < 0: - break - marks[name] = at - send(DTR_ACK) + # The last marker starts the watch. + ackMarks(send, got, base, marks) if hangupAt is None and len(marks) == len(MARKS): hangupAt = time.time() if len(marks) < len(MARKS): @@ -206,7 +242,12 @@ def loop(send, receive, wall, start, banner): def main(argv): withDtrCheck = "--dtr-check" in argv argv = [arg for arg in argv if arg != "--dtr-check"] - if len(argv) < 4 or argv[1] not in ("tcp-server", "tcp-client", "pty") or (withDtrCheck and argv[1] == "pty"): + holdFile = None + if "--close-hold" in argv and argv.index("--close-hold") + 1 < len(argv): + at = argv.index("--close-hold") + holdFile = argv[at + 1] + argv = argv[:at] + argv[at + 2:] + if len(argv) < 4 or argv[1] not in ("tcp-server", "tcp-client", "pty") or (withDtrCheck and argv[1] == "pty") or (holdFile is not None and argv[1] != "pty"): sys.stderr.write(__doc__) return 2 mode = argv[1] @@ -226,7 +267,11 @@ def main(argv): except OSError: return b"" - return loop(lambda data: os.write(master, data), receive, wall, start, banner)[0] + send = lambda data: os.write(master, data) + status, got = loop(send, receive, wall, start, banner) + if status != 0 or holdFile is None: + return status + return closeHold(send, receive, wall, start, got, holdFile) port = int(argv[2]) conn = acceptOne(port, wall) if mode == "tcp-server" else connectOne(port, wall, start) diff --git a/scripts/lib/webDriver.mjs b/scripts/lib/webDriver.mjs index d21097f..1433ad0 100644 --- a/scripts/lib/webDriver.mjs +++ b/scripts/lib/webDriver.mjs @@ -19,6 +19,8 @@ // " ") for MS milliseconds // reload load the page again (what a program // kept in IndexedDB comes back) +// open:PAGE load PAGE, a page beside --url's +// (same site, same browser storage) // shot:FILE a PNG of the canvas alone, now // pad:press:CONTROLS, pad:release:CONTROLS // hold or let go of the --gamepads @@ -34,13 +36,20 @@ // button and button 1 the east // --log FILE at the end, the program's joeylog.txt // --file PATH:OUT at the end, the page's file PATH (its in-memory file -// system) into OUT; given once per file +// system; a PATH without a leading / is in the +// program's write folder, Module.joeyWriteFolder) into +// OUT; given once per file // --audio FILE record what the program plays, as a 16-bit stereo WAV: // SDL3 plays through a ScriptProcessorNode, so a script // put in the page before the program's own wraps the // node's onaudioprocess and keeps a copy of every buffer // it fills; the WAV, and the --step script, start with // the first one +// --sound play through the system's sound output (PULSE_SINK +// in the environment picks the device) rather than +// muted: a muted page's audio runs on a fake clock of +// Chromium's, which can drift off real time, so a +// recording that must keep time plays to a sink // Console lines and uncaught exceptions go to stderr as they happen. Exits 0 // when it ran to the end (or reached --until), 1 when --until never came, the // page threw, or Chromium could not be driven. @@ -62,8 +71,9 @@ const MS_PER_SECOND = 1000; const DEBUG_PORT_MIN = 30000; const DEBUG_PORT_SPAN = 20000; -// The program's log, read from the in-memory file system ("" until there is one). -const READ_LOG = "(function () { try { return Module.FS.readFile('/joeylog.txt', { encoding: 'utf8' }); } catch (e) { return ''; } })()"; +// The program's log, read from its write folder in the in-memory file system +// ("" until there is one). +const READ_LOG = "(function () { try { return Module.FS.readFile(Module.joeyWriteFolder + 'joeylog.txt', { encoding: 'utf8' }); } catch (e) { return ''; } })()"; // The recorder --audio puts in the page: every buffer SDL's ScriptProcessorNode // fills, kept as 16-bit stereo samples. const AUDIO_RECORDER = `(function () { @@ -167,6 +177,7 @@ function parseArgs(argv) { case "--log": opts.log = value; i++; break; case "--audio": opts.audio = value; i++; break; case "--file": opts.files.push(value); i++; break; + case "--sound": opts.sound = true; break; default: throw new Error("unknown option " + name); } @@ -228,14 +239,16 @@ function connect(wsUrl, onEvent) { } -// One --step. -async function runStep(cdp, step) { +// One --step; url is --url's, beside which open: finds its page. +async function runStep(cdp, step, url) { const at = step.indexOf(":"); const verb = step.slice(0, at); const rest = step.slice(at + 1); if (step === "reload") { await cdp.send("Page.reload"); + } else if (verb === "open") { + await cdp.send("Page.navigate", { url: new URL(rest, url).href }); } else if (verb === "wait") { await sleep(Number(rest) * MS_PER_SECOND); } else if (verb === "type") { @@ -324,7 +337,8 @@ async function main() { const profile = mkdtempSync(join(tmpdir(), "joeylib-web-")); const debugPort = DEBUG_PORT_MIN + Math.floor(Math.random() * DEBUG_PORT_SPAN); const chrome = spawn(opts.chrome, [ - "--headless=new", "--no-sandbox", "--no-first-run", "--mute-audio", + "--headless=new", "--no-sandbox", "--no-first-run", + ...(opts.sound ? [] : ["--mute-audio"]), "--autoplay-policy=no-user-gesture-required", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--window-size=1024,800", @@ -387,7 +401,7 @@ async function main() { await sleep(POLL_MS); } for (const step of opts.steps) { - await runStep(cdp, step); + await runStep(cdp, step, opts.url); } } if (opts.audio !== undefined) { @@ -404,7 +418,7 @@ async function main() { for (const file of opts.files) { const at = file.lastIndexOf(":"); const path = file.slice(0, at); - const data = await evaluate(cdp, "(function () { try { var b = Module.FS.readFile(" + JSON.stringify(path) + "); var t = ''; var i; for (i = 0; i < b.length; i++) { t += String.fromCharCode(b[i]); } return btoa(t); } catch (e) { return null; } })()"); + const data = await evaluate(cdp, "(function () { try { var p = " + JSON.stringify(path) + "; var b = Module.FS.readFile(p.charAt(0) === '/' ? p : Module.joeyWriteFolder + p); var t = ''; var i; for (i = 0; i < b.length; i++) { t += String.fromCharCode(b[i]); } return btoa(t); } catch (e) { return null; } })()"); if (data === null) { throw new Error("the page has no file " + path); } diff --git a/scripts/lib/webGate.sh b/scripts/lib/webGate.sh index 132877e..4d69a08 100644 --- a/scripts/lib/webGate.sh +++ b/scripts/lib/webGate.sh @@ -10,11 +10,19 @@ # # webNeed exit 2 unless Chromium and Node (emsdk's) are here # webServe DIR serve DIR; sets WEB_PORT and WEB_SERVER_PID +# webSoundSink a PulseAudio null sink for Chromium to play to, so +# a gate that times the page's sound has a real clock +# pacing it (muted, Chromium's audio runs on a fake +# one that drifts off real time); false without a +# sound server # webDrive PAGE ARGS... run webDriver.mjs on http://127.0.0.1:WEB_PORT/PAGE -# webStop stop the server +# (playing to the sink, when there is one) +# webStop stop the server, and remove the sink WEB_PORT="" WEB_SERVER_PID="" +WEB_SINK_NAME="" +WEB_SINK_MODULE="" WEB_PORT_MIN=20000 WEB_PORT_SPAN=20000 @@ -49,14 +57,32 @@ webServe() { } +webSoundSink() { + # shellcheck source=scripts/pulseRecordLib.sh + source "$JOEY_REPO/scripts/pulseRecordLib.sh" + pulseEnsureServer || return 1 + WEB_SINK_NAME="joeyWeb$$" + WEB_SINK_MODULE=$(pulseCreateSink "$WEB_SINK_NAME") || WEB_SINK_MODULE="" + [ -n "$WEB_SINK_MODULE" ] +} + + webDrive() { local page="$1" shift - "$WEB_NODE" "$JOEY_REPO/scripts/lib/webDriver.mjs" --chrome "$WEB_CHROME" --url "http://127.0.0.1:$WEB_PORT/$page" "$@" + if [ -n "$WEB_SINK_MODULE" ]; then + PULSE_SINK="$WEB_SINK_NAME" "$WEB_NODE" "$JOEY_REPO/scripts/lib/webDriver.mjs" --chrome "$WEB_CHROME" --url "http://127.0.0.1:$WEB_PORT/$page" --sound "$@" + else + "$WEB_NODE" "$JOEY_REPO/scripts/lib/webDriver.mjs" --chrome "$WEB_CHROME" --url "http://127.0.0.1:$WEB_PORT/$page" "$@" + fi } webStop() { joeyStop "$WEB_SERVER_PID" WEB_SERVER_PID="" + if [ -n "$WEB_SINK_MODULE" ]; then + pulseUnloadSink "$WEB_SINK_MODULE" + WEB_SINK_MODULE="" + fi } diff --git a/scripts/verify-all.sh b/scripts/verify-all.sh index 8665ef3..2df416b 100755 --- a/scripts/verify-all.sh +++ b/scripts/verify-all.sh @@ -46,8 +46,10 @@ # atarist and ste: the palette -- every shifter colour the ST # build writes is the 3-bit truncation, the STe build's (on an # STe and a Mega STe) the exact 4-bit $0RGB -# amiga: the palette -- palette changes reach the screen at copper -# patch speed, and right, after a palette and an SCB change +# amiga: the palette -- palette and SCB changes present one a +# frame (no copper rebuild), and right, after a palette and an +# SCB change; and a surface in Fast RAM (chip RAM full) draws and +# copies as a chip one # tilebank each port: examples/tilebank loads the port's own .tbk bake # of the host fixture both ways, pixel-exact and with the host # gate's surface hash, and refuses every other target's bake (the @@ -120,13 +122,18 @@ # host scripts/check-serial.sh (no emulator) # iigs SCC modem port -> MAME null_modem # x68000 SCC channel A -> MAME null_modem -# atarist Modem port -> Hatari pty +# atarist Modem port -> Hatari pty; JL_SERIAL_SLOT unit 2 -> a +# Mega STe's SCC channel B (Bconmap), sending only # amiga serial.device -> FS-UAE TCP # dos COM1 -> DOSBox nullmodem # dvx COM1 through DVX's rs232 library -> DOSBox nullmodem # win32 COM1 through the Win32 comms calls -> Wine -> a pty # win16 COM1 through Windows 3.11's OpenComm -> KPCOMM.DRV # -> DOSBox nullmodem +# sdl3 termios -> a pty; the close puts back the line's +# speed and leaves HUPCL off +# haiku, haiku32 termios -> COM2 in the VM -> QEMU TCP (BeOS +# R5's VM has no serial ports) # mouse x68000: deltas, clamps and buttons through the IOCS mouse # (and with serial streaming both ways on the same SCC) # save iigs: a SAVES/ blob written and read back through GS/OS @@ -248,6 +255,7 @@ visual ste palette scripts/verify-atarist-palette.sh ste megaste visual amiga palette scripts/verify-amiga-palette.sh visual amiga a1200pal JOEY_AMIGA_MODEL=A1200 scripts/verify-amiga-palette.sh visual amiga a4000pal JOEY_AMIGA_MODEL=A4000 scripts/verify-amiga-palette.sh +visual amiga fastram scripts/verify-amiga-fastram.sh tilebank iigs tilebank make -s -f make/iigs.mk iigs-verify-tilebank || exit 1 tilebank x68000 tilebank scripts/verify-x68000-tilebank.sh tilebank atarist tilebank scripts/verify-atarist-tilebank.sh @@ -315,11 +323,15 @@ serial host serial scripts/check-serial.sh serial iigs serial scripts/verify-iigs-serial.sh serial x68000 serial scripts/verify-x68000-serial.sh serial atarist serial scripts/verify-atarist-serial.sh +serial atarist slot scripts/verify-atarist-serial-slot.sh serial amiga serial scripts/verify-amiga-serial.sh serial dos serial scripts/verify-dos-serial.sh serial dvx serial scripts/verify-dvx-serial.sh serial win32 serial scripts/verify-win32-serial.sh serial win16 serial scripts/verify-win16-serial.sh +serial sdl3 serial scripts/verify-sdl3-serial.sh +serial haiku serial scripts/verify-be-serial.sh haiku +serial haiku32 serial scripts/verify-be-serial.sh haiku32 mouse x68000 mouse scripts/verify-x68000-mouse.sh mouse x68000 sermouse scripts/verify-x68000-serial-mouse.sh save iigs save make -s -f make/iigs.mk iigs-verify-save || exit 1 diff --git a/scripts/verify-amiga-fastram.sh b/scripts/verify-amiga-fastram.sh new file mode 100755 index 0000000..41619c6 --- /dev/null +++ b/scripts/verify-amiga-fastram.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# verify-amiga-fastram.sh - a surface in Fast RAM draws and copies as one in +# chip RAM does. +# +# The Amiga puts a surface's planes in chip RAM, where the blitter that does +# its fills and copies can reach them; once chip RAM has run out, a surface +# other than the stage goes in Fast RAM, and its fills and copies run on the +# CPU (src/amiga/hal.c, jlpSurfaceAllocPortData). FASTRAM (tests/amiga/ +# fastRam.c; make -f make/amiga.mk build/amiga/test/Fastram) runs under +# FS-UAE on an A500 with 2 MB of Fast RAM: it fills chip RAM with ballast, +# makes a surface, which must land in Fast RAM, and checks that the same +# drawing and copies on it and on a chip surface hash the same. +# +# Usage: scripts/verify-amiga-fastram.sh +# Env: TIMEOUT seconds to wait for the program (default 120) +# Exit: 0 PASS, 1 FAIL, 2 SKIP (FS-UAE or the Kickstart not installed) +set -uo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +source "$repo/scripts/lib/joeyEnv.sh" +joeyNeed FSUAE fsuae +source "$repo/scripts/lib/amigaGate.sh" +amigaMachine +timeout_s=${TIMEOUT:-120} +probe=$repo/build/amiga/test/Fastram +label=verify-amiga-fastram + +make -s -f "$repo/make/amiga.mk" "$probe" || { + echo "$label: FAIL - building Fastram failed" >&2 + exit 1 +} + +work=$(mktemp -d -t joeylib-amiga-fastram.XXXXXX) +mkdir -p "$work/s" +cp "$probe" "$work/" +printf 'stack 32768\n:Fastram\n' > "$work/s/startup-sequence" +display=:$((RANDOM % 400 + 1300)) +Xvfb "$display" -screen 0 800x600x24 -nolisten tcp > /dev/null 2>&1 & +xvfb_pid=$! +fs_pid= +cleanup() { + [[ -n $fs_pid ]] && joeyStop "$fs_pid" + kill "$xvfb_pid" 2>/dev/null || true + wait 2>/dev/null || true + rm -rf "$work" +} +trap cleanup EXIT +sleep 0.5 + +DISPLAY=$display "$FSUAE" "${AMIGA_MACHINE_ARGS[@]}" --fast_memory=2048 \ + --hard_drive_0="$work" --hard_drive_0_label=JOEYLIB \ + --automatic_input_grab=0 \ + --fullscreen=0 --fade_out_duration=0 > "$work/fsuae.log" 2>&1 & +fs_pid=$! + +log=$work/joeylog.txt +for ((waited = 0; waited < timeout_s; waited++)); do + if grep -q "FASTRAM: done" "$log" 2>/dev/null; then + break + fi + sleep 1 +done +if ! grep -q "FASTRAM: done" "$log" 2>/dev/null; then + echo "$label: FAIL - the program did not finish within ${timeout_s}s" >&2 + exit 1 +fi +grep "FASTRAM:" "$log" +if ! grep -q "FASTRAM: fast surface chip=0" "$log"; then + echo "$label: FAIL - the surface made with chip RAM full did not land outside chip RAM" + exit 1 +fi +if ! grep -q "FASTRAM: PASS" "$log"; then + echo "$label: FAIL - $(grep 'FASTRAM: ' "$log" | grep -v 'done\|surface chip' | head -3 | tr '\n' ';')" + exit 1 +fi +echo "$label: PASS (a surface in Fast RAM, chip RAM full, draws and copies as a chip one, onto the stage too)" diff --git a/scripts/verify-amiga-palette.sh b/scripts/verify-amiga-palette.sh index a66f016..e3a5ba6 100755 --- a/scripts/verify-amiga-palette.sh +++ b/scripts/verify-amiga-palette.sh @@ -6,19 +6,23 @@ # verify-amiga-palette.sh - the Amiga's palette and SCB changes through the # copper: fast, and right on the screen. # -# The Amiga shows each SCB band's palette with a user copper list, built and -# merged by graphics.library (MrgCop, LoadView, then a WaitTOF) -- about three -# frames on an A500. A change that leaves every band where it was (a palette -# changing, or a band given another palette) patches the colour MOVEs of the -# installed list in place instead (src/amiga/hal.c, patchBandColors). The -# stage hashes cannot see any of this: they hash the surface, not the copper. +# The Amiga shows each SCB band's palette from one of two band lists of its +# own, which the screen's user copper list (merged by graphics.library once) +# jumps to: a present that changes palettes or the SCB writes the list not +# shown, and the VBL server switches to it with the planes (src/amiga/hal.c, +# bandListUpdate). Before, a band that moved rebuilt and merged the whole +# copper list (MrgCop, LoadView, then a WaitTOF) -- about three frames on an +# A500 -- and a palette change patched the shown list, a frame before its +# pixels. The stage hashes cannot see any of this: they hash the surface, +# not the copper. # # PALPROBE (examples/palprobe; make -f make/amiga.mk build/amiga/test/Palprobe) # runs under FS-UAE on an A500. It logs how many palette changes, and SCB -# changes, it presented a second; the palette rate must be at least -# MIN_PALETTE_RATE (one rebuild a change gave 16). Then it holds a screen of -# four bands after one palette change and one same-layout SCB change, and -# the screenshot's bands must be, top to bottom, red, green, yellow, red. +# changes (bands moving), it presented a second, back to back: each waits +# for the VBL that shows the last, so both rates must be at least MIN_RATE, +# nearly the frame rate (a rebuild a change gave 16). Then it holds a screen +# of four bands after one palette change and one same-layout SCB change, +# and the screenshot's bands must be, top to bottom, red, green, yellow, red. # # Usage: scripts/verify-amiga-palette.sh # Env: TIMEOUT seconds to wait for the probe (default 120) @@ -33,9 +37,9 @@ amigaMachine timeout_s=${TIMEOUT:-120} probe=$repo/build/amiga/test/Palprobe label=verify-amiga-palette -# Palette changes presented a second: 16 when every change rebuilt the -# copper list, ~990 patching it in place (A500, 2026-10-06). -MIN_PALETTE_RATE=500 +# Changes presented a second, back to back: one a frame is 50 on the PAL +# A500 (16 when every change rebuilt the copper list). +MIN_RATE=45 make -s -f "$repo/make/amiga.mk" "$probe" || { echo "$label: FAIL - building Palprobe failed" >&2 @@ -81,20 +85,20 @@ sleep 2 DISPLAY=$display import -window root "$work/screen.png" grep "PALPROBE:" "$log" -python3 - "$work/screen.png" "$log" "$MIN_PALETTE_RATE" "$label" <<'PY' +python3 - "$work/screen.png" "$log" "$MIN_RATE" "$label" <<'PY' import re import sys from PIL import Image png, log, minRate, label = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4] -rate = None +rates = {} for line in open(log, encoding="ascii", errors="replace"): - m = re.search(r"PALPROBE: palette\+present: \d+ in \d+ ms = (\d+)/s", line) + m = re.search(r"PALPROBE: (palette|scb)\+present: \d+ in \d+ ms = (\d+)/s", line) if m: - rate = int(m.group(1)) -if rate is None: - print(f"{label}: FAIL - no palette rate in the log") + rates[m.group(1)] = int(m.group(2)) +if sorted(rates) != ["palette", "scb"]: + print(f"{label}: FAIL - the log does not have both rates ({rates})") sys.exit(1) # The bands down the middle column, top to bottom, black (the border) @@ -112,12 +116,13 @@ for y in range(h): bands.append(name) want = ["red", "green", "yellow", "red"] fails = [] -if rate < minRate: - fails.append(f"palette changes presented {rate}/s, want at least {minRate}") +for kind in ("palette", "scb"): + if rates[kind] < minRate: + fails.append(f"{kind} changes presented {rates[kind]}/s, want at least {minRate}") if bands != want: fails.append(f"bands {bands}, want {want}") if fails: print(f"{label}: FAIL - " + "; ".join(fails)) sys.exit(1) -print(f"{label}: PASS (palette changes {rate}/s; bands {', '.join(want)} after a palette change and a same-layout SCB change)") +print(f"{label}: PASS (palette changes {rates['palette']}/s, SCB changes {rates['scb']}/s; bands {', '.join(want)} after a palette change and a same-layout SCB change)") PY diff --git a/scripts/verify-amiga-tilebank.sh b/scripts/verify-amiga-tilebank.sh index 41a6a19..c34d012 100755 --- a/scripts/verify-amiga-tilebank.sh +++ b/scripts/verify-amiga-tilebank.sh @@ -10,9 +10,12 @@ # build refuses a bank baked for any other target. # # Boots an A500 (real Kickstart) in FS-UAE under Xvfb from a scratch directory -# hard drive whose startup-sequence runs TileBank (examples/tilebank), with -# the DATA/tilebank make/amiga.mk staged from the host gate's fixture beside -# it. The program loads its own bake through jlTileBankLoadToSurface and +# hard drive whose startup-sequence runs GAME/TileBank (examples/tilebank) +# from the drive's root, with the DATA/tilebank make/amiga.mk staged from the +# host gate's fixture beside it in GAME -- so it must find its DATA/ through +# PROGDIR:, not the current directory, and log there too +# (src/core/fileRoot.h). The program loads its own bake through +# jlTileBankLoadToSurface and # through jlTileBankLoad + jlTilePaste, checks every pixel of both against the # PNG, hashes both, and tries every other target's bake; its TILEBANK line in # joeylog.txt must say PASS with both hashes equal to the host gate's @@ -51,10 +54,10 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$work/hd/s" "$work/hd/DATA" -cp "$binDir/TileBank" "$work/hd/" -cp -r "$dataDir" "$work/hd/DATA/tilebank" -echo ":TileBank" > "$work/hd/s/startup-sequence" +mkdir -p "$work/hd/s" "$work/hd/GAME/DATA" +cp "$binDir/TileBank" "$work/hd/GAME/" +cp -r "$dataDir" "$work/hd/GAME/DATA/tilebank" +echo ":GAME/TileBank" > "$work/hd/s/startup-sequence" display=":$((RANDOM % 400 + 1300))" Xvfb "$display" -screen 0 800x600x24 -nolisten tcp > /dev/null 2>&1 & @@ -73,7 +76,7 @@ DISPLAY="$display" timeout -s KILL "$wall" "$FSUAE" \ guard=$! fsuae=$(joeyChild "$guard") -log=$(tileBankAwaitLine "$work/hd" "$lineSeconds" "$guard") +log=$(tileBankAwaitLine "$work/hd/GAME" "$lineSeconds" "$guard") joeyStop "$fsuae" "$guard" 2> /dev/null fsuae="" guard="" diff --git a/scripts/verify-atarist-audio.sh b/scripts/verify-atarist-audio.sh index 1ece96f..55de8ad 100755 --- a/scripts/verify-atarist-audio.sh +++ b/scripts/verify-atarist-audio.sh @@ -53,12 +53,13 @@ # being started and every sample it plays, so the STe checks are: # # 1. The tracker is alive (as above). -# 2. The DMA chip ran, as the HAL programs it: mono at 12517 Hz, play +# 2. The DMA chip ran, as the HAL programs it: stereo at 12517 Hz, play # + repeat. # 3. It played the SFX: each run pulls a stretch of non-silent samples -# from the mix buffer, near full scale. A chip looping over silence -# (a mix that never reached the buffer, or unsigned samples left -# unconverted, which play as a DC step and not a wave) fails here. +# from the mix buffer, near half scale (the mix's unit for effects). +# A chip looping over silence (a mix that never reached the buffer, +# or unsigned samples left unconverted, which play as a DC step and +# not a wave) fails here. # 4. No PWM pump: reg 8 is never written at the pump's rate. The YM is # not a DAC on this machine. # 5. Channel A is the tracker's while the chip plays: voice 0's period @@ -215,13 +216,13 @@ REG_MIXER = 7 TONE_B_REGS = (2, 3) # channel B period == the tracker's own voice VOICE_A_REGS = (0, 1, 8) # channel A period + volume == the tracker's voice 0 -# STe DMA sound: "DMA snd mode write: 0x81 mode=mono freq=12517 ... pc=22eec" +# STe DMA sound: "DMA snd mode write: 0x01 mode=stereo freq=12517 ... pc=22eec" DMA_WRITE = re.compile(r'^DMA snd (?:mode|control) write: 0x([0-9a-f]+) .*pc=([0-9a-f]+)') -DMA_MODE_MONO_12K = 0x81 # bit 7 mono, rate 1 = 12517 Hz +DMA_MODE_STEREO_12K = 0x01 # bit 7 clear stereo, rate 1 = 12517 Hz DMA_CTRL_PLAY_LOOP = 0x03 # play + repeat DMA_UNCONVERTED_SILENCE = -128 # $80, the mixer's unsigned silence, read as signed SFX_SAMPLES_MIN = 100 # test.sfx is ~390 non-silent samples at 12517 Hz -SFX_PEAK_MIN = 32 # it peaks near full scale (122 measured) +SFX_PEAK_MIN = 32 # it peaks near half scale, the mix's unit (61 measured) UNSIGNED_PCT_MAX = 5.0 @@ -362,9 +363,9 @@ def gradeDma(summary): if not runs: fails.append("the DMA sound chip never started: jlAudioPlaySfx did not reach it, so " "every digital sound on the STe is silent") - if not modes or any(m != DMA_MODE_MONO_12K for m in modes): - fails.append(f"DMA mode writes {[hex(m) for m in modes]}, want {hex(DMA_MODE_MONO_12K)} " - f"(mono, 12517 Hz) -- the mix is made for that rate") + if not modes or any(m != DMA_MODE_STEREO_12K for m in modes): + fails.append(f"DMA mode writes {[hex(m) for m in modes]}, want {hex(DMA_MODE_STEREO_12K)} " + f"(stereo, 12517 Hz) -- the mix is made for that rate and layout") if DMA_CTRL_PLAY_LOOP not in controls: fails.append(f"DMA control writes {[hex(c) for c in controls]} never set play + repeat " f"({hex(DMA_CTRL_PLAY_LOOP)}), so the chip cannot loop over the mix buffer") diff --git a/scripts/verify-atarist-input.sh b/scripts/verify-atarist-input.sh index a9d422d..f75e324 100755 --- a/scripts/verify-atarist-input.sh +++ b/scripts/verify-atarist-input.sh @@ -27,7 +27,11 @@ # # Then KEYS again on German EmuTOS, whose Alt layer types @ [ ] on Alt with # the keys U-umlaut, O-umlaut, and A-umlaut, and \ { } with Shift too; Alt -# with a letter types nothing, and a plain key goes on typing. +# with a letter types nothing, and a plain key goes on typing. And once more +# with the OS header made to say TOS 1.04 instead of EmuTOS (Hatari's +# debugger writes it before the boot): then the layer must come from +# src/atarist/stAltLayer.c's German table, found by the header's country, +# as on a German ST's own TOS, which keeps its layer out of Keytbl. # # Usage: scripts/verify-atarist-input.sh # Requires: hatari, the EmuTOS images (US and German), @@ -64,24 +68,28 @@ send() { } -# hatariBoot TOS -- KEYS in Hatari on that TOS image; sets hatari_pid and -# rowAddr, KEYS's verification row in ST RAM, once its A5A5 sentinel is up. +# hatariBoot TOS [HATARI-ARG...] -- KEYS in Hatari on that TOS image; sets +# hatari_pid and rowAddr, KEYS's verification row in ST RAM, once its A5A5 +# sentinel is up. # The video base is read from the shifter regs each attempt (planar # sentinel = plane words 5000 A000 5000 A000). hatariBoot() { + local tos="$1" local i local base local addr local row + shift joeyStop "${hatari_pid:-}" rm -f "$work/cmd.fifo" (cd "$work" && exec env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy "$HATARI" \ - --tos "$1" \ + --tos "$tos" \ --harddrive "$bin_dir" \ --gemdos-drive C \ --auto 'C:\KEYS.PRG' \ --cmd-fifo "$work/cmd.fifo" \ --fast-forward on \ + "$@" \ > "$work/hatari.log" 2>&1) & hatari_pid=$! for i in $(seq 1 30); do @@ -217,16 +225,36 @@ if [ -n "$KEYS_FAILS" ]; then exit 1 fi -# German EmuTOS's Alt layer. +# altLayerType -- the Alt layer's keys, as deTarget says. +altLayerType() { + send "hatari-event keydown 56" + for c in 26 39 40; do send "hatari-event keypress $c"; done + send "hatari-event keydown 42" + for c in 26 39 40; do send "hatari-event keypress $c"; done + send "hatari-event keyup 42" + send "hatari-event keypress 30" + send "hatari-event keyup 56" + send "hatari-event keypress 2" +} + + +# German EmuTOS's Alt layer, from its Keytbl tables. hatariBoot "$EMUTOS_DE_IMG" -send "hatari-event keydown 56" -for c in 26 39 40; do send "hatari-event keypress $c"; done -send "hatari-event keydown 42" -for c in 26 39 40; do send "hatari-event keypress $c"; done -send "hatari-event keyup 42" -send "hatari-event keypress 30" -send "hatari-event keyup 56" -send "hatari-event keypress 2" +altLayerType rowCheck "$deTarget" || exit 1 -echo "$label: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar; $KEYS_STEPS named-key steps: the keypad's arrows and Enter, Up held with its second key; German EmuTOS's Alt layer typed \"$deTarget\")" +# The same as TOS 1.04 would show it: "XXXX" over the header's "ETOS" +# (offset 0x2C) and version 0x0104 (offset 2), in the 512 KB ROM at +# 0xE00000. +printf 'w $e0002c $58 $58 $58 $58\nw $e00002 $01 $04\n' > "$work/tos104.dbg" +hatariBoot "$EMUTOS_DE_IMG" --parse "$work/tos104.dbg" +send 'hatari-debug m $e0002c 4' +sleep 1 +if ! grep -qi "^00E0002C: 58 58 58 58" "$work/hatari.log"; then + echo "$label: FAIL - the OS header still says EmuTOS: $(grep -i '^00E0002C:' "$work/hatari.log" | tail -1)" >&2 + exit 1 +fi +altLayerType +rowCheck "$deTarget" || exit 1 + +echo "$label: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar; $KEYS_STEPS named-key steps: the keypad's arrows and Enter, Up held with its second key; German EmuTOS's Alt layer typed \"$deTarget\", from its tables and as TOS 1.04 from the built-in German one)" diff --git a/scripts/verify-atarist-serial-slot.sh b/scripts/verify-atarist-serial-slot.sh new file mode 100755 index 0000000..5922144 --- /dev/null +++ b/scripts/verify-atarist-serial-slot.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# verify-atarist-serial-slot.sh - JL_SERIAL_SLOT opens a Mega STe's other +# serial ports through Bconmap. +# +# A Mega STe, TT, or Falcon has serial ports besides the Modem port (its +# SCC's two, the TT its second MFP's), and TOS 2 and EmuTOS map the AUX +# device onto any of them with XBIOS Bconmap. JL_SERIAL_SLOT unit n maps +# AUX to Bconmap device 5 + n while the port is open (src/atarist/serial.c); +# unit 2 is SCC channel B (Modem 2). SERSLOT (examples/sertest built with +# -DSERTEST_DEVICE=JL_SERIAL_SLOT -DSERTEST_UNIT=2u) opens it and sends its +# banner. Hatari writes what SCC channel B sends to a file (--scc-b-out; it +# has no SCC input, so this is the sending side only) and what the Modem +# port sends to another (--rs232-out, which Hatari opens only along with an +# input, here an empty file): +# +# 1. Mega STe: the banner comes out of SCC channel B, and nothing out of +# the Modem port. +# 2. A plain ST, whose one port is the Modem port: unit 2 does not open, +# so nothing comes out at all. +# +# Usage: scripts/verify-atarist-serial-slot.sh +# Requires: hatari, EmuTOS (install.sh install emulator:atarist); SERSLOT.PRG +# (built here: make -f make/atarist.mk build/atarist/test/SERSLOT.PRG) +set -uo pipefail +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +source "$repo/scripts/lib/joeyEnv.sh" +joeyNeed HATARI hatari +joeyNeed EMUTOS_IMG emutos +label=verify-atarist-serial-slot +prog=$repo/build/atarist/test/SERSLOT.PRG +banner="JLSERTEST-READY" +wall=90 # hard cap on Hatari, whatever happens to this script +runVbls=900 # EmuTOS boot + SERSLOT open and banner: ~600 frames + +make -s -f "$repo/make/atarist.mk" "$prog" || { + echo "$label: FAIL - building SERSLOT failed" >&2 + exit 1 +} +work=$(mktemp -d -t joeylib-st-serslot.XXXXXX) +trap 'rm -rf "$work"' EXIT + +# run MACHINE -- SERSLOT on MACHINE; SCC channel B's output in +# $work/MACHINE.scc, the Modem port's in $work/MACHINE.mfp. +run() { + mkdir -p "$work/$1" + cp "$prog" "$work/$1/" + : > "$work/$1.scc" + : > "$work/$1.mfp" + : > "$work/empty.in" + SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy timeout -s KILL "$wall" "$HATARI" \ + --machine "$1" --tos "$EMUTOS_IMG" --harddrive "$work/$1" --gemdos-drive C \ + --drive-a off --drive-b off --sound off --fast-forward on --run-vbls "$runVbls" \ + --scc-b-out "$work/$1.scc" --rs232-in "$work/empty.in" --rs232-out "$work/$1.mfp" \ + --auto 'C:\SERSLOT.PRG' > "$work/$1.log" 2>&1 +} + +fails="" +run megaste +grep -q "$banner" "$work/megaste.scc" || fails="${fails}Mega STe: no banner out of SCC channel B; " +[ -s "$work/megaste.mfp" ] && fails="${fails}Mega STe: the Modem port sent $(wc -c < "$work/megaste.mfp") bytes; " +run st +[ -s "$work/st.scc" ] || [ -s "$work/st.mfp" ] && fails="${fails}plain ST: unit 2 opened something; " +if [ -n "$fails" ]; then + echo "$label: FAIL - ${fails%; }" + exit 1 +fi +echo "$label: PASS (Mega STe: JL_SERIAL_SLOT unit 2 sends on SCC channel B, not the Modem port; plain ST: no unit 2)" diff --git a/scripts/verify-audio-conformance.sh b/scripts/verify-audio-conformance.sh index 33d45d4..3da06c3 100755 --- a/scripts/verify-audio-conformance.sh +++ b/scripts/verify-audio-conformance.sh @@ -32,6 +32,10 @@ # hangs, AUDPAR with it # # PORT_STEREO lists the ports with stereo output, whose pans are graded. +# DOS is stereo on DOSBox's own Sound Blaster, an SB16; AUDPAR_DOS_SBTYPE +# runs it on another (sbpro2, sb2: the mono cards), graded in mono. Not +# sbpro1: DOSBox makes its two OPL2s the halves of one OPL3, whose rhythm +# mode is in the first half only, so its noise plays on the left alone. # Win16 mixes in mono; beos is listed for the Be backend it shares with # Haiku, whose stereo is graded there. The IIgs is graded in mono, as a # stock IIgs plays (its output sums every DOC channel): its stereo is a @@ -45,11 +49,14 @@ # take a channel each side and are graded). # A port whose chip cannot land every 2 dB step gets a level tolerance of # its own: the YM2149's sixteen levels are about 3.5 dB apart, so -# the nearest one can be 1.75 dB off. +# the nearest one can be 1.75 dB off, and on the STe, whose voices start at +# level 14 (the mix's level for them), 1.95: level 14 to 13 is 3.9 dB. # # Usage: scripts/verify-audio-conformance.sh PORT # Env: AUDPAR_KEEP set to keep the recording and the log in # build//audpar/ +# AUDPAR_DOS_SBTYPE DOSBox's sbtype for the DOS run (default its +# own, sb16) # Exit: 0 PASS, 1 FAIL, 2 cannot run set -uo pipefail @@ -58,10 +65,15 @@ source "$repo/scripts/lib/joeyEnv.sh" port=${1:-} label="verify-audio-conformance ($port)" grader=$repo/tools/gradeAudioConformance.py -PORT_STEREO="amiga dvx sdl3 web win32 psp haiku haiku32 beos" +PORT_STEREO="amiga dos dvx ste sdl3 web win32 psp haiku haiku32 beos" +DOS_SBTYPE=${AUDPAR_DOS_SBTYPE:-} +if [ -n "$DOS_SBTYPE" ] && [ "$DOS_SBTYPE" != sb16 ]; then + PORT_STEREO=${PORT_STEREO/ dos / } + label="verify-audio-conformance ($port, $DOS_SBTYPE)" +fi RUN_SECONDS=120 X68K_RUN_SECONDS=110 # emulated: Human68k's boot and AUDPAR's run -IIGS_RUN_FRAMES=7200 # emulated, 60 a second: GS/OS, the Finder and AUDPAR +IIGS_RUN_FRAMES=7800 # emulated, 60 a second: GS/OS, the Finder and AUDPAR HAIKU_BOOT_SECONDS=120 # a Haiku VM booting until its job runner starts RECORD_RATE=44100 QEMU_WAV_HEADER=44 # bytes: QEMU's wav audio driver's header, its sizes left 0 @@ -89,6 +101,10 @@ cleanup() { if [ -n "$sinkModule" ]; then pulseUnloadSink "$sinkModule" fi + # The web run's own sink (webSoundSink), should it have failed early. + if [ -n "${WEB_SINK_MODULE:-}" ]; then + pulseUnloadSink "$WEB_SINK_MODULE" + fi if [ -n "${AUDPAR_KEEP:-}" ] && [ -n "$port" ]; then mkdir -p "$repo/build/$port/audpar" cp "$work"/out.wav "$work"/log.txt "$repo/build/$port/audpar/" 2>/dev/null @@ -192,7 +208,7 @@ runDos() { xvfbStart "$display" pulseStart XDG_CONFIG_HOME="$work/xdg" DISPLAY="$display" PULSE_SINK="$sinkName" SDL_AUDIODRIVER=pulse \ - "$DOSBOX" "${JOEY_DOSBOX_ISOLATE[@]}" --conf "$repo/scripts/dosbox-386sx16.conf" \ + "$DOSBOX" "${JOEY_DOSBOX_ISOLATE[@]}" --conf "$repo/scripts/dosbox-386sx16.conf" ${DOS_SBTYPE:+--set "sbtype=$DOS_SBTYPE"} \ -c "MOUNT C $dir" -c "C:" -c "AUDPAR.EXE" -c "EXIT" > "$work/dosbox.log" 2>&1 & dosPid=$! pids+=("$dosPid") @@ -437,6 +453,11 @@ runWeb() { make -s -C "$repo" -f make/web.mk program-AUDPAR > "$work/build.log" 2>&1 || fail "the build failed (make -f make/web.mk program-AUDPAR)" webServe "$repo/build/web/probe/AUDPAR" pids+=("$WEB_SERVER_PID") + # Chromium plays to a null sink of its own, whose clock paces the page's + # sound: muted, its audio runs on a fake clock that drifts off real time + # (the timeline stretched by up to 15%). The page's own buffers are what + # is recorded. + webSoundSink || cannot "no PulseAudio/PipeWire null sink for Chromium to play to" webDrive AUDPAR.html --seconds "$RUN_SECONDS" --until " done" --audio "$work/out.wav" --log "$work/log.txt" > "$work/web.log" 2>&1 || fail "AUDPAR did not finish in $RUN_SECONDS s ($(tail -1 "$work/web.log"))" webStop } @@ -527,7 +548,8 @@ case " $PORT_STEREO " in *" $port "*) flags+=(--stereo) ;; esac case "$port" in - atarist|ste) flags+=(--level-tolerance 1.75) ;; + atarist) flags+=(--level-tolerance 1.75) ;; + ste) flags+=(--level-tolerance 1.95) ;; amiga) flags+=(--skip "tone centred" "voice 0 centred" "voice 1 centred" "voice 2 centred") ;; esac python3 "$grader" "$port" "$work/log.txt" "$work/out.wav" "${flags[@]}" > "$work/grade.txt" diff --git a/scripts/verify-be-serial.sh b/scripts/verify-be-serial.sh new file mode 100755 index 0000000..b47a7df --- /dev/null +++ b/scripts/verify-be-serial.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# verify-be-serial.sh - both-directions RS-232 gate for the Be API ports. +# +# Runs SERTEST (examples/sertest: banner, then echo) in the port's VM +# (scripts/lib/beGate.sh) with the machine's second serial port joined to +# the host peer (scripts/lib/serialLoopPeer.py) over TCP: the peer listens, +# QEMU's COM2 connects to it, and JOEYLIB_SERIAL names COM2's device to the +# program (Haiku's /dev/ports/pc_serial1; COM1 is the VM's console). The +# peer waits for the banner, sends a probe and expects it echoed, so the +# chain is JoeyLib's POSIX serial HAL (src/hosted/posix) -> the system's +# termios -> its serial driver -> QEMU -> the host. QEMU's TCP serial carries no modem lines: DTR and the close's +# settings are gated on SDL3 (verify-sdl3-serial.sh). +# +# BeOS R5 SKIPs: its serial driver finds the ports through the ISA plug and +# play bus, which the VM goes without (installer/lib/beos.sh: it crashes R5 +# in the fail-safe video mode), so the machine has none. +# +# Usage: scripts/verify-be-serial.sh [haiku|haiku32|beos] +# Requires: the port's VM (component haikuVm, haikuVm32 or beosVm), python3; +# build/PORT/bin/SERTEST (make PORT). +set -uo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +source "$repo/scripts/lib/joeyEnv.sh" +source "$repo/scripts/lib/beGate.sh" + +port=${1:-haiku} +label="verify-be-serial ($port)" +if [ "$port" = beos ]; then + echo "$label: SKIP (the BeOS R5 VM has no serial ports: R5 finds them through the ISA plug and play bus, which the VM goes without)" + exit 2 +fi +beNeed "$port" +program=$(joeyExampleProgram sertest "$port") +[ -x "$program" ] || { echo "$label: build SERTEST first (make $port)" >&2; exit 2; } +bootSeconds=150 +runSeconds=60 # SERTEST echoes until the job ends it +peerPortMin=40000 +peerPortSpan=20000 +device=/dev/ports/pc_serial1 + +work=$(mktemp -d -t joeylib-be-serial.XXXXXX) +peerPid="" +cleanup() { + beStop + joeyStop "$peerPid" + rm -rf "$work" +} +trap cleanup EXIT + +mkdir -p "$work/job/SERTEST" +cp "$program" "$work/job/SERTEST/SERTEST" +{ + printf 'ls /dev/ports > ports.txt\n' + printf '(\n' + beJobScript SERTEST SERTEST "" "$runSeconds" JOEYLIB_SERIAL="$device" + printf ')\n' +} > "$work/job/job.sh" +beJobDisk "$work/job.img" "$work/job" + +peerPort=$((peerPortMin + RANDOM % peerPortSpan)) +python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-server "$peerPort" "$((bootSeconds + runSeconds))" > "$work/peer.log" 2>&1 & +peerPid=$! +sleep 1 +beStart "$work" "$work/job.img" -serial "tcp:127.0.0.1:$peerPort" +if ! beWaitJob "$work" "$bootSeconds"; then + echo "$label: FAIL - the VM did not start the job within ${bootSeconds} s" >&2 + exit 1 +fi +wait "$peerPid" +peerStatus=$? +peerPid="" +beWaitExit "$work" "$((runSeconds + 60))" || true +beJobGet "$work/job.img" ports.txt "$work/ports.txt" || true +cat "$work/peer.log" +if [ "$peerStatus" -eq 0 ]; then + echo "$label: PASS (banner out, probe echoed through $device)" +else + echo "$label: FAIL - the probe did not come back through $device; the machine's ports: $(tr '\n' ' ' < "$work/ports.txt" 2> /dev/null)" >&2 + exit 1 +fi diff --git a/scripts/verify-dos-joy.sh b/scripts/verify-dos-joy.sh index 2580854..655d2b8 100755 --- a/scripts/verify-dos-joy.sh +++ b/scripts/verify-dos-joy.sh @@ -58,7 +58,7 @@ boot() { DOSBOX_JOY_SHOTS="$dir/shots" XDG_CONFIG_HOME="$dir/xdg" DISPLAY="$DOSBOX_JOY_DISPLAY" SDL_AUDIODRIVER=dummy \ timeout -s KILL "$wall" "$DOSBOX" "${JOEY_DOSBOX_ISOLATE[@]}" \ - --conf "$repo/scripts/dosbox-386sx16.conf" --set "joysticktype=$2" \ + --conf "$repo/scripts/dosbox-386sx16.conf" --set "joysticktype=$2" "${DOSBOX_JOY_SET_ARGS[@]}" \ --set "mapperfile=$dir/mapper.txt" --set "capture_dir=$dir/shots" \ -c "MOUNT C \"$(dirname "$program")\"" -c "C:" -c "$(basename "$program")" -c "EXIT" \ > "$dir/dosbox.log" 2>&1 & diff --git a/scripts/verify-dos-tilebank.sh b/scripts/verify-dos-tilebank.sh index 033ab3a..c159282 100755 --- a/scripts/verify-dos-tilebank.sh +++ b/scripts/verify-dos-tilebank.sh @@ -10,7 +10,9 @@ # # Runs TILEBANK.EXE (examples/tilebank) in DOSBox Staging on the 386SX-16 # reference config, from a scratch C: holding it and the DATA/tilebank -# make/dos.mk staged from the host gate's fixture. The program loads its own +# make/dos.mk staged from the host gate's fixture, both in C:\GAME, started +# from C:\ -- so it must find its DATA/ beside itself, not in the current +# directory, and log there too (src/core/fileRoot.h). The program loads its own # bake through jlTileBankLoadToSurface and through jlTileBankLoad + # jlTilePaste, checks every pixel of both against the PNG, hashes both, and # tries every other target's bake; its TILEBANK line in JOEYLOG.TXT must say @@ -49,9 +51,9 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$work/c/DATA" "$work/xdg" -cp "$binDir/TILEBANK.EXE" "$work/c/" -cp -r "$dataDir" "$work/c/DATA/tilebank" +mkdir -p "$work/c/GAME/DATA" "$work/xdg" +cp "$binDir/TILEBANK.EXE" "$work/c/GAME/" +cp -r "$dataDir" "$work/c/GAME/DATA/tilebank" display=":$((RANDOM % 400 + 1300))" Xvfb "$display" -screen 0 800x600x24 -nolisten tcp > /dev/null 2>&1 & @@ -63,11 +65,11 @@ sleep 1 XDG_CONFIG_HOME="$work/xdg" DISPLAY="$display" SDL_AUDIODRIVER=dummy \ timeout -s KILL "$wall" "$DOSBOX" "${JOEY_DOSBOX_ISOLATE[@]}" \ --conf "$conf" \ - -c "MOUNT C \"$work/c\"" -c "C:" -c "TILEBANK.EXE" > "$work/dosbox.log" 2>&1 & + -c "MOUNT C \"$work/c\"" -c "C:" -c "GAME\\TILEBANK.EXE" > "$work/dosbox.log" 2>&1 & guard=$! dosbox=$(joeyChild "$guard") -log=$(tileBankAwaitLine "$work/c" "$lineSeconds" "$guard") +log=$(tileBankAwaitLine "$work/c/GAME" "$lineSeconds" "$guard") joeyStop "$dosbox" "$guard" 2> /dev/null dosbox="" guard="" diff --git a/scripts/verify-dvx-display.sh b/scripts/verify-dvx-display.sh index d42a138..490eb13 100755 --- a/scripts/verify-dvx-display.sh +++ b/scripts/verify-dvx-display.sh @@ -14,20 +14,24 @@ # and asks the port after every present how many pixels differ from the # stage (dvxPortProbeDisplayMismatch in src/dvx/hal.c): the window's content # buffer in the desktop's own pixel format, every screen pixel of a stage -# pixel's scale x scale block, or the full-screen backbuffer and its -# palette; each pixel derived from the stage's palette entries, not through -# the present's tables. Each run is scripts/bench-dvx.sh --probe on one -# desktop (DVX.INI's video mode), about 8 s. Every count must be 0, every -# pixel must have been compared (64000 x scale^2, plus the 256 palette -# entries on the full screen), and DVX.LOG must show the program on the -# desktop asked for, at the scale asked for. +# pixel's block (scale across; down, the rows the window's shape gives it: +# 4:3 stretches every fifth row one more, square pixels take scale each), +# or the full-screen backbuffer and its palette; each pixel derived from the +# stage's palette entries, not through the present's tables. Each run is +# scripts/bench-dvx.sh --probe on one desktop (DVX.INI's video mode), about +# 8 s, in 4:3 or (":square") with square pixels. Every count must be 0, every +# pixel of the window must have been compared (320 x 240 x scale^2 in 4:3, +# 320 x 200 x scale^2 square, plus the 256 palette entries on the full +# screen), and DVX.LOG must show the program on the desktop asked for, at +# the scale and in the shape asked for. # # The default desktops cover each pixel size the present writes (1, 2 and -# 4 bytes) at each window scale (1x, 2x, 3x), the 8-bit desktop's nearest +# 4 bytes) at each window scale (1x, 2x, 3x: 3x is square pixels only, as 4:3 +# at 3x does not fit 1024x768), in both shapes, the 8-bit desktop's nearest # colours and both 16-bit packings (5-5-5 and 5-6-5), then the full screen # (320x200 at 8 bits, its palette the stage's 16). # -# Usage: scripts/verify-dvx-display.sh [WxHxBPP:SCALE | full]... +# Usage: scripts/verify-dvx-display.sh [WxHxBPP:SCALE[:square] | full]... # Requires: DOSBox Staging, Xvfb, the dvx component; the probe build # (make dvx builds it, into build/dvx/probe). set -uo pipefail @@ -46,10 +50,11 @@ paletteEntries=256 probeLine="UBER-PROBE: dvxDisplayMismatch" fullSpec=full benchSeconds=120 # one probe run, boot to done line -defaults=(640x480x8:1 800x600x8:2 1024x768x8:3 - 640x480x16:1 800x600x15:2 1024x768x16:3 - 640x480x32:1 800x600x32:2 1024x768x32:3 +defaults=(640x480x8:1 800x600x8:2 1024x768x8:3:square + 640x480x16:1:square 800x600x15:2 1024x768x16:3:square + 640x480x32:1 800x600x32:2:square 1024x768x32:2 "$fullSpec") +shape43Height=240 specs=("$@") [ ${#specs[@]} -gt 0 ] || specs=("${defaults[@]}") @@ -78,18 +83,28 @@ for spec in "${specs[@]}"; do h=$DVX_VIDEO_H bpp=$DVX_VIDEO_BPP scale=1 + shape=4:3 + rows=$((shape43Height * scale)) want=$((stagePixels + paletteEntries)) benchArgs+=(--full-screen) else - if ! [[ "$spec" =~ ^([0-9]+)x([0-9]+)x([0-9]+):([0-9]+)$ ]]; then - echo "verify-dvx-display: bad desktop \"$spec\" (WxHxBPP:SCALE or $fullSpec)" >&2 + if ! [[ "$spec" =~ ^([0-9]+)x([0-9]+)x([0-9]+):([0-9]+)(:square)?$ ]]; then + echo "verify-dvx-display: bad desktop \"$spec\" (WxHxBPP:SCALE[:square] or $fullSpec)" >&2 exit 2 fi w=${BASH_REMATCH[1]} h=${BASH_REMATCH[2]} bpp=${BASH_REMATCH[3]} scale=${BASH_REMATCH[4]} - want=$((stagePixels * scale * scale)) + if [ -n "${BASH_REMATCH[5]}" ]; then + shape=square + rows=$((stageHeight * scale)) + benchArgs+=(--square) + else + shape=4:3 + rows=$((shape43Height * scale)) + fi + want=$((stageWidth * scale * rows)) fi DVX_VIDEO_W=$w DVX_VIDEO_H=$h DVX_VIDEO_BPP=$bpp TIMEOUT=$benchSeconds \ "$repo/scripts/bench-dvx.sh" "${benchArgs[@]}" > /dev/null 2>&1 @@ -101,8 +116,8 @@ for spec in "${specs[@]}"; do fi counts=${row#*"$probeLine" } echo "verify-dvx-display: $spec: $counts" - if ! grep -qF "JoeyLib: Uber Probe: $((stageWidth * scale))x$((stageHeight * scale)) at ${scale}x on a ${w}x${h} ${bpp}-bit desktop" "$dvxLog"; then - fail "$spec" "DVX.LOG does not show the window at ${scale}x on a ${w}x${h} ${bpp}-bit desktop: $(grep -F 'JoeyLib: Uber Probe:' "$dvxLog")" + if ! grep -qF "JoeyLib: Uber Probe: $((stageWidth * scale))x$rows at ${scale}x, $shape, on a ${w}x${h} ${bpp}-bit desktop" "$dvxLog"; then + fail "$spec" "DVX.LOG does not show the window at ${scale}x, $shape, on a ${w}x${h} ${bpp}-bit desktop: $(grep -F 'JoeyLib: Uber Probe:' "$dvxLog")" continue fi if [ "$spec" = "$fullSpec" ] && ! grep -qF "$DVX_FULL_SCREEN_LOG" "$dvxLog"; then diff --git a/scripts/verify-dvx-input.sh b/scripts/verify-dvx-input.sh index a502473..d564b49 100755 --- a/scripts/verify-dvx-input.sh +++ b/scripts/verify-dvx-input.sh @@ -22,10 +22,11 @@ # as hex to its joeylog.txt (in its settings folder), which must equal # the typed ASCII. # Each desktop given (default 640x480 at 16 bits, where the window is 1x) is -# a run of its own; DVX.LOG must show KEYS's window on that desktop at the -# scale given. +# a run of its own, the window 4:3 or (":square") with square pixels; +# DVX.LOG must show KEYS's window on that desktop at the scale and in the +# shape given. # -# Usage: scripts/verify-dvx-input.sh [WxHxBPP:SCALE]... +# Usage: scripts/verify-dvx-input.sh [WxHxBPP:SCALE[:square]]... # e.g. scripts/verify-dvx-input.sh 800x600x8:2 1024x768x32:3 # Requires: DOSBox Staging, Xvfb, xdotool, python3 + PIL, the dvx component; # the keys program (make dvx). @@ -41,6 +42,7 @@ program=keys target="192.168.1.10:6510" stageWidth=320 stageHeight=200 +shape43Height=240 # a 4:3 window's rows a scale for the stage's 200 wall=180 # hard cap on DOSBox, whatever happens to this script readySeconds=60 # DVX boot until its shell reports ready settleSeconds=3 # then KEYS opening its window and taking the focus @@ -98,13 +100,15 @@ run() { local h local bpp local scale + local shape + local rows local ready="" local waited local log local png - if ! [[ "$spec" =~ ^([0-9]+)x([0-9]+)x([0-9]+):([0-9]+)$ ]]; then - echo "verify-dvx-input: bad desktop \"$spec\" (WxHxBPP:SCALE)" >&2 + if ! [[ "$spec" =~ ^([0-9]+)x([0-9]+)x([0-9]+):([0-9]+)(:square)?$ ]]; then + echo "verify-dvx-input: bad desktop \"$spec\" (WxHxBPP:SCALE[:square])" >&2 exit 2 fi w=${BASH_REMATCH[1]} @@ -112,6 +116,14 @@ run() { bpp=${BASH_REMATCH[3]} scale=${BASH_REMATCH[4]} DVX_VIDEO_W=$w DVX_VIDEO_H=$h DVX_VIDEO_BPP=$bpp dvxStage "$dir/c" "$(dvxAppDir "$program")" + if [ -n "${BASH_REMATCH[5]}" ]; then + shape=square + rows=$((stageHeight * scale)) + dvxSettings "$dir/c" "$program" "$DVX_SETTINGS_SQUARE" + else + shape=4:3 + rows=$((shape43Height * scale)) + fi dvxDosboxConf "$dir/dosbox.conf" "$dir/c" printf '\n[capture]\ncapture_dir = %s\ndefault_image_capture_formats = raw\n' "$dir/shots" >> "$dir/dosbox.conf" mkdir -p "$dir/xdg" "$dir/shots" @@ -165,8 +177,8 @@ run() { dosbox="" guard="" - if ! dvxDvxLog "$dir/c" 400 | grep -qF "JoeyLib: Keys: $((stageWidth * scale))x$((stageHeight * scale)) at ${scale}x on a ${w}x${h} ${bpp}-bit desktop"; then - fail "$spec" "DVX.LOG does not show KEYS at ${scale}x on a ${w}x${h} ${bpp}-bit desktop: $(dvxDvxLog "$dir/c" 400 | grep -F 'JoeyLib: Keys:')" + if ! dvxDvxLog "$dir/c" 400 | grep -qF "JoeyLib: Keys: $((stageWidth * scale))x$rows at ${scale}x, $shape, on a ${w}x${h} ${bpp}-bit desktop"; then + fail "$spec" "DVX.LOG does not show KEYS at ${scale}x, $shape, on a ${w}x${h} ${bpp}-bit desktop: $(dvxDvxLog "$dir/c" 400 | grep -F 'JoeyLib: Keys:')" fi png=$(ls "$dir"/shots/*.png 2> /dev/null | head -1) if [ -z "$png" ]; then diff --git a/scripts/verify-dvx-joy.sh b/scripts/verify-dvx-joy.sh index 5f2e5e3..2879f95 100755 --- a/scripts/verify-dvx-joy.sh +++ b/scripts/verify-dvx-joy.sh @@ -22,7 +22,9 @@ # # JOY (examples/joy) draws, for each stick, a dot (in the middle at rest, # grey; green when moved), two button squares and a patch that says whether -# the port reports a stick; scripts/lib/joyScreen.py reads the WHOLE state of +# the port reports a stick. It runs with square pixels (dvxSettings): the +# 4:3 window repeats some rows, and the dots are read pixel for pixel. +# scripts/lib/joyScreen.py reads the WHOLE state of # BOTH sticks off each screenshot (scripts/lib/joyGate.sh's steps: each # direction of each stick alone, two diagonals each, each button alone and # both together, with a release after every press so a latched line shows @@ -90,6 +92,7 @@ boot() { local dir="$JOY_WORK/$1" local waited dvxStage "$dir/c" "$(dvxAppDir "$program")" + dvxSettings "$dir/c" "$program" "$DVX_SETTINGS_SQUARE" dvxDosboxConf "$dir/dosbox.conf" "$dir/c" mkdir -p "$dir/xdg" "$dir/shots" dosboxJoyMapper "$dir/mapper.txt" @@ -97,6 +100,7 @@ boot() { DOSBOX_JOY_SHOTS="$dir/shots" printf '\n[capture]\ncapture_dir = %s\n' "$dir/shots" >> "$dir/dosbox.conf" printf '\n[joystick]\njoysticktype = %s\n' "$2" >> "$dir/dosbox.conf" + printf '%s\n' "${DOSBOX_JOY_SETTINGS[@]}" >> "$dir/dosbox.conf" printf '\n[sdl]\nmapperfile = %s\n' "$dir/mapper.txt" >> "$dir/dosbox.conf" XDG_CONFIG_HOME="$dir/xdg" DISPLAY="$DOSBOX_JOY_DISPLAY" SDL_AUDIODRIVER=dummy \ timeout -s KILL "$wall" "$DOSBOX" "${JOEY_DOSBOX_ISOLATE[@]}" \ diff --git a/scripts/verify-iigs-serial-loss.sh b/scripts/verify-iigs-serial-loss.sh index 2382e5f..971b19b 100755 --- a/scripts/verify-iigs-serial-loss.sh +++ b/scripts/verify-iigs-serial-loss.sh @@ -167,6 +167,9 @@ case "${SERLOSS_BAUD:-9600}" in 4800) baudCode=6 ;; 9600) baudCode=7 ;; 19200) baudCode=9 ;; + 38400) baudCode=11 ;; + 57600) baudCode=12 ;; + 115200) baudCode=13 ;; *) echo "verify-iigs-serial-loss: unsupported SERLOSS_BAUD" >&2; exit 2 ;; esac diff --git a/scripts/verify-iigs-serial.sh b/scripts/verify-iigs-serial.sh index ad86f6b..d71a621 100755 --- a/scripts/verify-iigs-serial.sh +++ b/scripts/verify-iigs-serial.sh @@ -21,9 +21,6 @@ # A1 missing -> the probe never ran / printer path is broken # # MAME CONNECTS OUT to the bitbanger socket, so the peer here listens first. -# Needs the PATCHED apple2gs (installer/patches/mame/0001-apple2gs-scc-clock.patch): -# stock 0.264 feeds the SCC BRG the wrong clock and pins RX at 230400, so no -# baud ever matches the null_modem. # # scripts/verify-iigs-serial.sh set -uo pipefail diff --git a/scripts/verify-sdl3-serial.sh b/scripts/verify-sdl3-serial.sh new file mode 100755 index 0000000..b7a53b3 --- /dev/null +++ b/scripts/verify-sdl3-serial.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# verify-sdl3-serial.sh - both-directions RS-232 gate for the SDL3 port, and +# the close's promise to put the line back. +# +# Runs SERTEST (examples/sertest: banner, then echo) with JOEYLIB_SERIAL +# naming the guest end of a pseudo-terminal pair the host peer +# (scripts/lib/serialLoopPeer.py) makes: the peer waits for the banner, sends +# a probe and expects it echoed, so the chain is JoeyLib's POSIX serial HAL +# (src/hosted/posix) -> termios -> the pty -> the host. Before the run the +# guest end is set to 4800 baud with HUPCL on; SERTEST opens it at 9600. The +# peer then has SERTEST close the port (its DTR check, which closes it twice +# and leaves it closed; a pseudo-terminal has no modem lines, so DTR itself +# is not judged here), and once SERTEST no longer holds the pty the line +# must read 4800 again, with HUPCL off: the close puts back the speed the +# port had and leaves DTR as it was. +# +# Usage: scripts/verify-sdl3-serial.sh +# Requires: python3, stty; build/sdl3/bin/SERTEST (make sdl3). +set -uo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +source "$repo/scripts/lib/joeyEnv.sh" + +label=verify-sdl3-serial +program=$(joeyExampleProgram sertest sdl3) +[ -x "$program" ] || { echo "$label: build SERTEST first (make sdl3)" >&2; exit 2; } +wall=40 +ptyWaitTries=40 +closeWaitTries=40 +speedBefore=4800 + +work=$(mktemp -d -t joeylib-sdl3-serial.XXXXXX) +peerPid="" +programPid="" +cleanup() { + touch "$work/release" + joeyStop "$programPid" "$peerPid" + rm -rf "$work" +} +trap cleanup EXIT + +python3 "$repo/scripts/lib/serialLoopPeer.py" pty "$work/pty.path" "$wall" --close-hold "$work/release" > "$work/peer.log" 2>&1 & +peerPid=$! +for ((tries = 0; tries < ptyWaitTries; tries++)); do + [ -s "$work/pty.path" ] && break + sleep 0.25 +done +[ -s "$work/pty.path" ] || { echo "$label: FAIL - the peer made no pty" >&2; exit 1; } +pty=$(cat "$work/pty.path") +stty -F "$pty" "$speedBefore" hupcl + +JOEYLIB_SERIAL="$pty" "$program" > "$work/program.log" 2>&1 & +programPid=$! +for ((tries = 0; tries < wall * 4; tries++)); do + grep -q "^CLOSE \|^LOOP FAIL" "$work/peer.log" && break + joeyAlive "$peerPid" || break + sleep 0.25 +done +# The close is done when SERTEST no longer has the pty open. +for ((tries = 0; tries < closeWaitTries; tries++)); do + stillOpen=no + for fd in /proc/"$programPid"/fd/*; do + [ "$(readlink "$fd" 2> /dev/null)" = "$pty" ] && stillOpen=yes + done + [ "$stillOpen" = no ] && break + sleep 0.25 +done +settings=$(stty -F "$pty" -a 2>&1) +touch "$work/release" +wait "$peerPid" +peerPid="" +cat "$work/peer.log" + +if ! grep -q "^LOOP PASS" "$work/peer.log"; then + echo "$label: FAIL - the probe was not echoed" >&2 + exit 1 +fi +if ! grep -q "^CLOSE PASS" "$work/peer.log" || [ "$stillOpen" = yes ]; then + echo "$label: FAIL - SERTEST did not close the port" >&2 + exit 1 +fi +speedAfter=$(printf '%s\n' "$settings" | sed -n 's/.*speed \([0-9]*\) baud.*/\1/p') +if [ "$speedAfter" != "$speedBefore" ]; then + echo "$label: FAIL - after the close the line runs at ${speedAfter:-?} baud, not the $speedBefore it had" >&2 + exit 1 +fi +if ! printf '%s\n' "$settings" | grep -qw -- "-hupcl"; then + echo "$label: FAIL - the close left HUPCL on, so the last close would drop DTR" >&2 + exit 1 +fi +echo "$label: PASS (banner out, probe echoed through the pty; the close put back $speedBefore baud and left HUPCL off)" diff --git a/scripts/verify-web-audio.sh b/scripts/verify-web-audio.sh index 390233c..123028b 100755 --- a/scripts/verify-web-audio.sh +++ b/scripts/verify-web-audio.sh @@ -41,6 +41,9 @@ cleanup() { trap cleanup EXIT webServe "$dir" +# A null sink's clock paces the page's sound (webSoundSink), so the phases +# fall in real time. +webSoundSink || { echo "$label: no PulseAudio/PipeWire null sink for Chromium to play to" >&2; exit 2; } # The same timeline as verify-sdl3-audio.sh (seconds since the first buffer). if ! webDrive "AUDIO.html" --seconds 120 --audio "$work/out.wav" \ --step wait:5 --step hold:p:400 \ diff --git a/scripts/verify-web-save.sh b/scripts/verify-web-save.sh index 9c24334..037f10e 100755 --- a/scripts/verify-web-save.sh +++ b/scripts/verify-web-save.sh @@ -3,16 +3,21 @@ # See LICENSE.md for license information. # SPDX-License-Identifier: MIT # -# verify-web-save.sh - Save gate for the web port: a save outlives the page. +# verify-web-save.sh - Save gate for the web port: a save outlives the page, +# and each page keeps its own. # # A web program's SAVES/ is kept in the browser's IndexedDB (the SDL3 # backend mounts IDBFS there with autoPersist, so each file is written back -# as it is closed, and again when the page is left). SAVE (examples/save) -# counts its launches in SAVES/runs.dat, so its page is opened in headless -# Chromium, given time to write, opened again (reloaded), and given time -# again: the count it then holds must be 2 -- the first launch's 1 came -# back from IndexedDB and the second launch added one. Each wait covers a -# cold browser loading the program (4 s once fell short). +# as it is closed, and again when the page is left), in a folder named after +# the page: SAVE.html's is /SAVE/SAVES, a store of its own on a site every +# page shares. SAVE (examples/save) counts its launches in SAVES/runs.dat. +# Its folder is served with a second copy of its page, OTHER.html, and in +# one headless Chromium OTHER.html is opened first (its own count, 1), then +# SAVE.html, given time to write, opened again (reloaded), and given time +# again: the count SAVE.html then holds must be 2 -- the first launch's 1 +# came back from IndexedDB and the second launch added one; a store shared +# with OTHER.html would say 3. Each wait covers a cold browser loading the +# program (4 s once fell short). # # Usage: scripts/verify-web-save.sh # Requires: Chromium, emsdk's node; build/web/bin/SAVE (make web). @@ -33,12 +38,14 @@ cleanup() { } trap cleanup EXIT -webServe "$dir" -webDrive "SAVE.html" --step wait:8 --step reload --step wait:8 --file "/SAVES/runs.dat:$work/runs.dat" +cp -r "$dir" "$work/site" +cp "$work/site/SAVE.html" "$work/site/OTHER.html" +webServe "$work/site" +webDrive "OTHER.html" --step wait:8 --step open:SAVE.html --step wait:8 --step reload --step wait:8 --file "/SAVE/SAVES/runs.dat:$work/runs.dat" count=$(python3 -c "import struct, sys; print(struct.unpack('&2 + echo "verify-web-save: FAIL - SAVE.html's second launch counted $count, not 2 (3: its store is shared with OTHER.html)" >&2 exit 1 fi diff --git a/scripts/verify-win16-joy.sh b/scripts/verify-win16-joy.sh index 88acc67..ae6daff 100755 --- a/scripts/verify-win16-joy.sh +++ b/scripts/verify-win16-joy.sh @@ -75,7 +75,7 @@ boot() { dosboxJoyMapper "$dir/mapper.txt" DOSBOX_JOY_SHOTS="$dir/shots" WIN16_DOSBOX_ARGS=(--set "capture_dir=$dir/shots" --set "default_image_capture_formats=raw" - --set "joysticktype=$2" --set "mapperfile=$dir/mapper.txt" + --set "joysticktype=$2" "${DOSBOX_JOY_SET_ARGS[@]}" --set "mapperfile=$dir/mapper.txt" --set "cpu_cycles=$fixedCycles" --set "cpu_cycles_protected=$fixedCycles") win16Start "$dir" JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square DOSBOX_JOY_DISPLAY="$WIN16_DISPLAY" diff --git a/scripts/verify-win16-serial.sh b/scripts/verify-win16-serial.sh index 2542d5e..721710e 100755 --- a/scripts/verify-win16-serial.sh +++ b/scripts/verify-win16-serial.sh @@ -13,6 +13,13 @@ # KPCOMM.DRV (the communications driver JoeyLib's Windows runs with in place # of COMM.DRV) and Windows' virtual COM device -> DOSBox's UART -> host. # +# Then the DTR check (--dtr-check), as verify-dvx-serial.sh does it: the +# nullmodem runs non-transparent, so DOSBox reports the guest's modem lines to +# the peer in the byte stream, and SERTEST drops and raises DTR, closes and +# reopens the port, then hangs up. jlSerialSetDtr must reach the line, and +# jlSerialClose must leave DTR as it is, up or down: KPCOMM.DRV keeps it at +# CloseComm when the HAL asks (KPESC_KEEP_DTR_ON_CLOSE). +# # Usage: scripts/verify-win16-serial.sh # Env: WIN16_SERIAL_PORT TCP port the peer listens on (default 6804) # Requires: Windows on DOSBox (install.sh install emulator:win16), python3; @@ -29,8 +36,8 @@ win16Need program=$(joeyExampleProgram sertest win16) [ -f "$program" ] || { echo "$label: build SERTEST.EXE first (make win16)" >&2; exit 2; } port="${WIN16_SERIAL_PORT:-6804}" -# The peer's budget: Windows' boot, then the banner and the echo. -wall=60 +# The peer's budget: Windows' boot, the banner and the echo, then the DTR check. +wall=95 work=$(mktemp -d -t joeylib-win16-serial.XXXXXX) peerPid="" @@ -41,22 +48,22 @@ cleanup() { } trap cleanup EXIT -python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-server "$port" "$wall" > "$work/peer.log" 2>&1 & +python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-server "$port" "$wall" --dtr-check > "$work/peer.log" 2>&1 & peerPid=$! sleep 1 mkdir -p "$work/program" cp "$program" "$work/program/SERTEST.EXE" win16Stage "$work" "$work/program" SERTEST.EXE -# transparent:1 -- data bytes only: DOSBox's in-band escapes for the control -# lines would reach the plain TCP peer as data. -WIN16_DOSBOX_ARGS=(--set "serial1=nullmodem server:127.0.0.1 port:$port transparent:1") +# transparent:0 -- DOSBox sends the guest's modem lines in-band, which the +# peer's --dtr-check reads. +WIN16_DOSBOX_ARGS=(--set "serial1=nullmodem server:127.0.0.1 port:$port transparent:0") win16Start "$work" wait "$peerPid" rc=$? peerPid="" cat "$work/peer.log" if [ "$rc" -eq 0 ]; then - echo "$label: PASS (banner out, probe echoed through COM1)" + echo "$label: PASS (banner out, probe echoed through COM1; DTR follows jlSerialSetDtr and survives jlSerialClose)" else echo "$label: FAIL" >&2 fi diff --git a/scripts/verify-x68000-audio.sh b/scripts/verify-x68000-audio.sh index 64b7c00..05d0aea 100755 --- a/scripts/verify-x68000-audio.sh +++ b/scripts/verify-x68000-audio.sh @@ -9,18 +9,20 @@ # watches the two sound devices through memory taps, so the checks are on # what the hardware was actually told, not on a log line: # -# 1. PPI port C ($E9A005) low nibble. That nibble IS the MSM6258's -# sample rate (bits 3,2) and channel gating (bits 1,0): the gate -# wants rate index 2, divider 512, 7812 Hz -- the rate the mixer -# renders at -- and pan 0, both channels. IOCS's own mode byte does -# not set these correctly (measured: mode 0x05 leaves 0x01, which is -# 3906 Hz out of the right speaker only), so audioPcm.c writes the -# register itself and this checks that it stuck. +# 1. The MSM6258's rate and channels, as IOCS sets them from the mode +# audioPcm.c asks for: PPI port C ($E9A005) bits 3,2 pick the divider +# (1024, 768, 512) and bits 1,0 gate the channels (0 both), and the +# YM2151's CT1 pin (register $1B bit 7) the clock (0 8 MHz, the chip's +# reset state; 1 4 MHz). The gate wants 7812 Hz, the rate the mixer +# renders at, on both channels. (A mode byte where IOCS wants a word +# -- this port's old 0x05 -- is 3906 Hz out of one side.) # 2. Every YM2151 noise write ($E90000 register 0x0F). The low five -# bits must be a value from kOpmNoiseFreq in src/x68000/audio.c, -# which is read out of the source here rather than duplicated: a -# pitch that escapes the table is the bug this gate exists for -# (a plain `31 - pitch` ran the generator an octave bright). +# bits must be OPM_NOISE_PERIODS - pitch for a pitch of 1 to +# NOISE_PITCH_MAX (0 plays as 1), both read out of +# src/x68000/audio.c rather than duplicated: that is the YM2149's +# 125000 / pitch Hz on the X68000's 4 MHz OPM, and a code outside it +# is the bug this gate exists for (a plain `31 - pitch` ran the +# generator an octave bright). # 3. The ADPCM byte feed rate WHILE A SOUND IS PLAYING. Two samples per # byte at 7812 Hz is 3906 bytes/s; half that means the divider is # wrong again and every sample the port plays is an octave low. It @@ -30,7 +32,7 @@ # # The default subject is JoeyLib's audio probe, examples/audio built to # press its own keys: it sweeps the noise generator through all 32 pitches, -# which reaches every distinct kOpmNoiseFreq value, plays its tracker song's +# which reaches every noise code, plays its tracker song's # noise drums, and keeps a phrase streaming through the ADPCM. # # Usage: scripts/verify-x68000-audio.sh [program] [seconds] @@ -57,16 +59,20 @@ source=$repo/src/x68000/audio.c [[ -f $bin ]] || { echo "verify-x68000-audio: $bin not built" >&2; exit 2; } [[ -f $X68K_SYSTEM_DISK ]] || { echo "verify-x68000-audio: missing $X68K_SYSTEM_DISK" >&2; exit 2; } -# The noise table, straight out of the port so the two cannot drift apart. +# The noise codes, from the port's own constants so the two cannot drift +# apart: OPM_NOISE_PERIODS - pitch, pitch 1 .. NOISE_PITCH_MAX. table=$(python3 - "$source" <<'PY' import re, sys src = open(sys.argv[1]).read() -m = re.search(r'kOpmNoiseFreq\[32\]\s*=\s*\{(.*?)\}', src, re.S) -if not m: - sys.exit("kOpmNoiseFreq not found") -print(",".join(v.strip() for v in m.group(1).split(",") if v.strip())) +def const(name): + m = re.search(r'#define\s+' + name + r'\s+(\d+)u?', src) + if not m: + sys.exit(name + " not found") + return int(m.group(1)) +periods = const("OPM_NOISE_PERIODS") +print(",".join(str(periods - p) for p in range(1, const("NOISE_PITCH_MAX") + 1))) PY -) || { echo "verify-x68000-audio: cannot read kOpmNoiseFreq from $source" >&2; exit 2; } +) || { echo "verify-x68000-audio: cannot read the noise constants from $source" >&2; exit 2; } work=$(mktemp -d -t joey-x68audio.XXXXXX) trap 'rm -rf "$work"' EXIT @@ -101,6 +107,8 @@ opmTap = mem:install_write_tap(0xE90000, 0xE90003, "opm", function(offset, data, sel = v elseif sel == 0x0F then out:write(string.format("NOISE %02X\\n", v)) + elseif sel == 0x1B then + out:write(string.format("CT %02X\\n", v)) end return data end) @@ -157,7 +165,7 @@ python3 - "$work/audio.txt" "$table" "$PROG" <<'PY' import sys path, table, prog = sys.argv[1], sys.argv[2], sys.argv[3] allowed = {int(v) for v in table.split(",")} -noise, portc, rate, done = [], [], None, False +noise, portc, ct, rate, done = [], [], [], None, False for line in open(path): p = line.split() if not p: @@ -166,6 +174,8 @@ for line in open(path): noise.append(int(p[1], 16)) elif p[0] == "PORTC": portc.append(int(p[1], 16)) + elif p[0] == "CT": + ct.append(int(p[1], 16)) elif p[0] == "PCMRATE": rate = float(p[1]) elif p[0] == "DONE": @@ -175,24 +185,27 @@ fails = [] if not done: fails.append("the run never reached its time limit") -# 1. The ADPCM rate/pan nibble. The last value written wins; the ROM's own -# boot value (0x0B) does not count as ours. +# 1. The ADPCM rate and channels: the last values written win; the ROM's +# own boot value of port C (0x0B) does not count as the port's. ours = [v for v in portc if v != 0x0B] if not ours: fails.append("PPI port C was never programmed, so the ADPCM never played") else: got = ours[-1] & 0x0F - if got != 0x08: - fails.append(f"port C low nibble {got:#04x}, want 0x08 " - f"(rate index {(got >> 2) & 3} pan {got & 3}; want rate 2 = 7812 Hz, pan 0 = both)") + clock = 4000000 if ct and ct[-1] & 0x80 else 8000000 + divider = (1024, 768, 512, 512)[(got >> 2) & 3] + hz = clock // divider + if hz != 7812 or got & 3: + fails.append(f"port C low nibble {got:#04x} with a {clock // 1000000} MHz clock: " + f"{hz} Hz, channels {got & 3}; want 7812 Hz on both (0)") -# 2. Every enabled noise write must name a table entry. +# 2. Every enabled noise write must be one of the port's codes. on = {v & 0x1F for v in noise if v & 0x80} if not on: fails.append("no noise writes seen at all") stray = sorted(on - allowed) if stray: - fails.append(f"noise codes outside kOpmNoiseFreq: {stray}") + fails.append(f"noise codes no pitch makes: {stray}") # 3. Byte feed rate: two samples per byte at 7812 Hz. if rate is None: @@ -204,8 +217,8 @@ elif not (3500.0 <= rate <= 4300.0): portcText = "/".join(f"{v:02X}" for v in ours[-2:]) if ours else "never set" rateText = f"{rate:.0f} bytes/s in-burst" if rate is not None else "silent" print(f"verify-x68000-audio: {prog} noise writes {len(noise)} " - f"({len(on)} distinct codes, {len(allowed - on)} table entries unused), " - f"port C {portcText}, ADPCM {rateText}") + f"({len(on)} distinct codes, {len(allowed - on)} of the port's unused), " + f"port C {portcText}, CT writes {len(ct)}, ADPCM {rateText}") if fails: for f in fails: print(f"verify-x68000-audio: FAIL ({f})", file=sys.stderr) diff --git a/scripts/verify-x68000-golden.sh b/scripts/verify-x68000-golden.sh index 1fe7ed2..39dd2d4 100755 --- a/scripts/verify-x68000-golden.sh +++ b/scripts/verify-x68000-golden.sh @@ -91,12 +91,19 @@ local EXIT_SETTLE = 900 -- keypress -> fclose -> back at the Human68k prompt -- 16-bit word per pixel, 512 words per row. -- The 320x200 stage is centred at origin (96, 156) either way. local presentPlane = "$PRESENT_PLANE" +-- The text plane holds two pages, and the port flips between them with the +-- text Y scroll (CRTC R11, which reads back): rows are counted from the page +-- on screen. +local function shownRow() + return mem:read_u16(0xE80016) & 0x3FF +end + local function stagePixel(px, py) if presentPlane == "gfx" then return mem:read_u8(0xC00000 + ((156 + py) * 512 + 96 + px) * 2 + 1) end local x = 96 + px - local byteOff = (156 + py) * 128 + (x >> 3) + local byteOff = (156 + shownRow() + py) * 128 + (x >> 3) local bit = 0x80 >> (x & 7) local v = 0 for plane = 0, 3 do diff --git a/scripts/verify-x68000-input.sh b/scripts/verify-x68000-input.sh index 3cff828..3f21c69 100755 --- a/scripts/verify-x68000-input.sh +++ b/scripts/verify-x68000-input.sh @@ -83,9 +83,16 @@ local fields = {} -- apart at 0xE00000, 128 bytes per row, leftmost pixel in bit 7, the stage -- centred at (96, 156). (Before 2026-09-24 it drew the graphics plane at -- 0xC00000, which this gate used to read.) +-- The text plane holds two pages, and the port flips between them with the +-- text Y scroll (CRTC R11, which reads back): rows are counted from the page +-- on screen. +local function shownRow() + return mem:read_u16(0xE80016) & 0x3FF +end + local function textPix(px, y) local sx = 96 + px - local addr = 0xE00000 + (156 + y) * 128 + (sx >> 3) + local addr = 0xE00000 + (156 + shownRow() + y) * 128 + (sx >> 3) local bit = 7 - (sx & 7) local v = 0 for p = 0, 3 do diff --git a/scripts/verify-x68000-joy.sh b/scripts/verify-x68000-joy.sh index ef37242..9408a13 100755 --- a/scripts/verify-x68000-joy.sh +++ b/scripts/verify-x68000-joy.sh @@ -125,9 +125,16 @@ for stick = 1, 2 do end -- Stage pixel (x, y) -> its colour index, one bit from each text plane. +-- The text plane holds two pages, and the port flips between them with the +-- text Y scroll (CRTC R11, which reads back): rows are counted from the page +-- on screen. +local function shownRow() + return mem:read_u16(0xE80016) & 0x3FF +end + local function stagePixel(x, y) local sx = ORIGIN_X + x - local addr = TVRAM + (ORIGIN_Y + y) * TVRAM_STRIDE + (sx >> 3) + local addr = TVRAM + (ORIGIN_Y + shownRow() + y) * TVRAM_STRIDE + (sx >> 3) local bit = 7 - (sx & 7) local v = 0 for p = 0, PLANES - 1 do diff --git a/scripts/verify-x68000-mouse.sh b/scripts/verify-x68000-mouse.sh index 2615ca1..daa2e08 100755 --- a/scripts/verify-x68000-mouse.sh +++ b/scripts/verify-x68000-mouse.sh @@ -65,9 +65,16 @@ local fails = {} -- apart at 0xE00000, 128 bytes per row, leftmost pixel in bit 7, the stage -- centred at (96, 156). (Before 2026-09-24 it drew the graphics plane at -- 0xC00000, which this gate used to read.) +-- The text plane holds two pages, and the port flips between them with the +-- text Y scroll (CRTC R11, which reads back): rows are counted from the page +-- on screen. +local function shownRow() + return mem:read_u16(0xE80016) & 0x3FF +end + local function textPix(px, y) local sx = 96 + px - local addr = 0xE00000 + (156 + y) * 128 + (sx >> 3) + local addr = 0xE00000 + (156 + shownRow() + y) * 128 + (sx >> 3) local bit = 7 - (sx & 7) local v = 0 for p = 0, 3 do diff --git a/scripts/verify-x68000-scb.sh b/scripts/verify-x68000-scb.sh index ccf3be0..0acc99b 100755 --- a/scripts/verify-x68000-scb.sh +++ b/scripts/verify-x68000-scb.sh @@ -193,9 +193,16 @@ local function out(s) io.flush() end +-- The text plane holds two pages, and the port flips between them with the +-- text Y scroll (CRTC R11, which reads back): rows are counted from the page +-- on screen. +local function shownRow() + return mem:read_u16(0xE80016) & 0x3FF +end + local function stagePixel(px, py) local x = ORIGIN_X + px - local byteOff = (ORIGIN_Y + py) * TVRAM_STRIDE + (x >> 3) + local byteOff = (ORIGIN_Y + shownRow() + py) * TVRAM_STRIDE + (x >> 3) local bit = 0x80 >> (x & 7) local v = 0 for plane = 0, 3 do diff --git a/scripts/verify-x68000-serial-mouse.sh b/scripts/verify-x68000-serial-mouse.sh index 804bf82..dce159e 100755 --- a/scripts/verify-x68000-serial-mouse.sh +++ b/scripts/verify-x68000-serial-mouse.sh @@ -228,9 +228,16 @@ local wr9 = emu.item(scc.items["0/m_wr9"]) local wr2 = emu.item(sccA.items["0/m_wr2"]) local iackFixed = 0 +-- The text plane holds two pages, and the port flips between them with the +-- text Y scroll (CRTC R11, which reads back): rows are counted from the page +-- on screen. +local function shownRow() + return mem:read_u16(0xE80016) & 0x3FF +end + local function textPix(px, y) local sx = ORIGIN_X + px - local addr = TEXT_BASE + (ORIGIN_Y + y) * ROW_BYTES + (sx >> 3) + local addr = TEXT_BASE + (ORIGIN_Y + shownRow() + y) * ROW_BYTES + (sx >> 3) local bit = 7 - (sx & 7) local v = 0 for p = 0, PLANES - 1 do diff --git a/scripts/verify-x68000-tilebank.sh b/scripts/verify-x68000-tilebank.sh index d477150..efcd44f 100755 --- a/scripts/verify-x68000-tilebank.sh +++ b/scripts/verify-x68000-tilebank.sh @@ -55,11 +55,13 @@ work=$(mktemp -d -t joeylib-x68-tilebank.XXXXXX) trap 'rm -rf "$work"' EXIT # The boot disk carries only Human68k and AUTOEXEC; the program, its DATA and -# its log live on the data disk in B:, which AUTOEXEC makes current. -x68kBootDisk "$work/boot.xdf" 'B:' 'B:\TILEBANK.X' || exit 1 +# its log live in GAME on the data disk in B:, and AUTOEXEC starts it from +# B:'s root -- so it must find its DATA/ from its process block, not the +# current directory, and log there too (src/core/fileRoot.h). +x68kBootDisk "$work/boot.xdf" 'B:' 'B:\GAME\TILEBANK.X' || exit 1 x68kDataDisk "$work/data.xdf" || exit 1 -x68kXdf add "$work/data.xdf" "$binDir/TILEBANK.X" TILEBANK.X > /dev/null || exit 1 -x68kAddTree "$work/data.xdf" "$dataDir" DATA/TILEBANK || exit 1 +x68kXdf add "$work/data.xdf" "$binDir/TILEBANK.X" GAME/TILEBANK.X > /dev/null || exit 1 +x68kAddTree "$work/data.xdf" "$dataDir" GAME/DATA/TILEBANK || exit 1 cat > "$work/tilebank.lua" <> 3) + local byteOff = (156 + shownRow() + py) * 128 + (x >> 3) local bit = 0x80 >> (x & 7) local v = 0 for plane = 0, 3 do @@ -163,7 +172,7 @@ lampOk=1 if ! grep -q 'lamp=PASS' <<< "$out"; then lampOk=0 fi -if ! x68kXdf extract "$work/data.xdf" joeylog.txt "$work/joeylog.txt" > /dev/null 2>&1; then +if ! x68kXdf extract "$work/data.xdf" GAME/joeylog.txt "$work/joeylog.txt" > /dev/null 2>&1; then echo "verify-x68000-tilebank: FAIL - no joeylog.txt on the data disk (the program never logged, or never exited)" >&2 exit 1 fi diff --git a/src/amiga/amigaPlanar.h b/src/amiga/amigaPlanar.h index cf6ea21..342fc90 100644 --- a/src/amiga/amigaPlanar.h +++ b/src/amiga/amigaPlanar.h @@ -28,7 +28,9 @@ // jlSurfaceT.portData points to this on Amiga: the 4 plane base // pointers + stride. Every surface (including the stage, which gets // its OWN shadow planes, NOT aliased to the displayed gPlanes) owns -// AllocMem(MEMF_CHIP) planes so the blitter can reach them; present +// AllocMem'd planes, in chip RAM where the blitter can reach them; a +// surface other than the stage made when chip RAM has run out gets them +// anywhere (chip false), and its fills and copies run on the CPU. present // copies shadow -> display planes atomically. // // Direct fields rather than a union because the consumer is asm / @@ -39,6 +41,7 @@ typedef struct { uint16_t bytesPerPlane; // = AMIGA_PLANE_SIZE (8000) bool ownsPlanes; // true = AllocMem'd (free at destroy); // false = aliased (don't free) + bool chip; // the planes are chip RAM: the blitter reaches them } AmigaPlanarT; diff --git a/src/amiga/audio.c b/src/amiga/audio.c index 7c2590c..c23e87f 100644 --- a/src/amiga/audio.c +++ b/src/amiga/audio.c @@ -51,6 +51,12 @@ extern UBYTE mt_E8Trigger; #define PAULA_CLOCK_PAL 3546895UL #define PAULA_PERIOD_MIN 124 // Paula HW limit #define PAULA_PERIOD_MAX 65535 +// The fastest rate Paula plays: an effect faster than this plays decimated +// (audioDecimate), at its pitch. +#define PAULA_MAX_RATE ((uint16_t)(PAULA_CLOCK_PAL / PAULA_PERIOD_MIN)) +// A one-shot's head, in Chip RAM before it starts: the rest is copied while +// Paula plays it (jlpAudioPlaySfx). +#define SFX_HEAD_BYTES 512u #define PAULA_CHANNELS 4 // hardware audio channel count #define SFX_VOLUME_MAX 64 @@ -116,7 +122,7 @@ extern UBYTE mt_E8Trigger; typedef struct { UBYTE *samples; // Chip-RAM copy of one SFX sample - UWORD lengthWords; // sample length in 16-bit words + ULONG sampleBytes; // its allocation // ---- streaming (jlpAudioPlaySfxStream) ---- UBYTE *chunk; // Chip-RAM chunk buffer, SFX_STREAM_CHUNK BYTE cha; // Paula channel, CHANNEL_PICK = centred @@ -126,6 +132,8 @@ typedef struct { void *ctx; uint16_t rateHz; uint16_t dueFrame; // the VBL count the next chunk goes out at + uint8_t decimate; // every decimate-th sample plays (audioDecimate), at rateHz + uint8_t phase; // audioDecimate's phase between chunks bool active; } SfxSlotT; @@ -299,7 +307,7 @@ bool jlpAudioInit(void) { gModuleLength = 0; for (i = 0; i < JL_AUDIO_SFX_SLOTS; i++) { gSfxSlots[i].samples = NULL; - gSfxSlots[i].lengthWords = 0; + gSfxSlots[i].sampleBytes = 0; // Centred, not 0: zero is Paula channel 0, which would silently pin // every slot hard left. gSfxSlots[i].cha = CHANNEL_PICK; @@ -351,9 +359,9 @@ void jlpAudioShutdown(void) { } for (i = 0; i < JL_AUDIO_SFX_SLOTS; i++) { if (gSfxSlots[i].samples != NULL) { - FreeMem(gSfxSlots[i].samples, (ULONG)gSfxSlots[i].lengthWords * 2UL); + FreeMem(gSfxSlots[i].samples, gSfxSlots[i].sampleBytes); gSfxSlots[i].samples = NULL; - gSfxSlots[i].lengthWords = 0; + gSfxSlots[i].sampleBytes = 0; } gSfxSlots[i].active = false; if (gSfxSlots[i].chunk != NULL) { @@ -466,11 +474,18 @@ static bool streamChunk(uint8_t slot) { uint32_t got; // The fill contract is SIGNED 8-bit, which is exactly what Paula - // wants, so the callback writes straight into Chip RAM. - got = s->fill(s->ctx, (int8_t *)s->chunk, (uint32_t)SFX_STREAM_CHUNK); - if (got < 2u || got > (uint32_t)SFX_STREAM_CHUNK) { - return false; - } + // wants, so the callback writes straight into Chip RAM. A stream faster + // than Paula keeps every decimate-th sample, packed to the front, and + // asks again if a short fill left too few. + do { + got = s->fill(s->ctx, (int8_t *)s->chunk, (uint32_t)SFX_STREAM_CHUNK); + if (got == 0u || got > (uint32_t)SFX_STREAM_CHUNK) { + return false; + } + if (s->decimate > 1u) { + got = audioDecimate(s->chunk, s->chunk, got, s->decimate, &s->phase); + } + } while (got < 2u); got &= ~1u; // Paula counts 16-bit words sfx.sfx_ptr = s->chunk; @@ -577,11 +592,18 @@ void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint1 SfxSlotT *s; UBYTE *chip; UWORD words; + uint32_t kept; + uint32_t head; + uint8_t factor; + uint8_t phase; if (!gInstalled || slot >= JL_AUDIO_SFX_SLOTS) { return; } - if (length < 2 || length > 0x1FFFEUL) { + // Faster than Paula plays: every factor-th sample, at its pitch. + factor = audioDecimationFactor(rateHz, PAULA_MAX_RATE); + kept = (length + factor - 1u) / factor; + if (kept < 2 || kept > 0x1FFFEUL) { return; // Paula sample length is 16 bits of words = 128KB max } @@ -590,27 +612,35 @@ void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint1 // about to be freed: stop it first. slotSilence(slot); if (s->samples != NULL) { - FreeMem(s->samples, (ULONG)s->lengthWords * 2UL); + FreeMem(s->samples, s->sampleBytes); s->samples = NULL; - s->lengthWords = 0; + s->sampleBytes = 0; } - chip = (UBYTE *)AllocMem((ULONG)length, MEMF_CHIP); + chip = (UBYTE *)AllocMem(kept, MEMF_CHIP); if (chip == NULL) { return; } - memcpy(chip, sample, length); - words = (UWORD)(length >> 1); + // The head goes into Chip RAM first and the sound starts on it; the rest + // follows while Paula plays, a copy far faster than Paula reads, so a + // long or fast sample starts as soon as a short one. + head = kept < SFX_HEAD_BYTES ? kept : SFX_HEAD_BYTES; + phase = 0u; + (void)audioDecimate(chip, sample, head * factor, factor, &phase); + words = (UWORD)(kept >> 1); s->samples = chip; - s->lengthWords = words; + s->sampleBytes = kept; sfx.sfx_ptr = chip; sfx.sfx_len = words; - sfx.sfx_per = periodForRate(rateHz); + sfx.sfx_per = periodForRate((uint16_t)(rateHz / factor)); sfx.sfx_vol = SFX_VOLUME_MAX; sfx.sfx_pri = SLOT_PRIORITY(slot); sfxPlay(slot, &sfx); + if (kept > head) { + (void)audioDecimate(chip + head, sample + head * factor, length - head * factor, factor, &phase); + } } @@ -637,10 +667,13 @@ void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uin return; } } - s->fill = fill; - s->ctx = ctx; - s->rateHz = rateHz; - s->active = true; + // Faster than Paula plays: every decimate-th sample, at its pitch. + s->decimate = audioDecimationFactor(rateHz, PAULA_MAX_RATE); + s->phase = 0u; + s->fill = fill; + s->ctx = ctx; + s->rateHz = (uint16_t)(rateHz / s->decimate); + s->active = true; if (!streamChunk(slot)) { s->active = false; } diff --git a/src/amiga/hal.c b/src/amiga/hal.c index 0370a7d..49854d7 100644 --- a/src/amiga/hal.c +++ b/src/amiga/hal.c @@ -10,13 +10,13 @@ // * Partial-rect present covers only the dirty scanlines. // // M2.5 scope (per-scanline palette / SCB emulation): -// * Build a user copper list that WAITs for each display scanline -// and MOVEs the 16 color registers with that line's palette. -// * Install it via ViewPort.UCopIns + MakeScreen + RethinkDisplay. -// * Rebuild only when SCB or palette state differs from the last -// presented frame (cached in gCachedScb / gCachedPalette). On -// clean frames (typical game loop, where only pixel bytes change) -// we skip AllocMem + MrgCop + LoadView + WaitTOF entirely. +// * Two band lists of our own in chip RAM WAIT for each SCB band's +// first line and MOVE its palette into the 16 colour registers. The +// screen's user copper list, merged once, jumps to the one shown. +// * A present that changes palettes or the SCB writes the other list, +// and the VBL server switches to it with the planes, so colours and +// pixels change on the same frame and a band that moves costs a list +// write, not a MrgCop (see the band lists below). // // Deferred: // * Blitter-assisted c2p for speed on A500. @@ -47,9 +47,14 @@ #include #include +#include + #include #include #include +// timer.device's library calls (ReadEClock) through this file's own base. +#define TIMER_BASE_NAME gClockTimerBase +#include #include "port.h" #include "spriteInternal.h" @@ -60,6 +65,8 @@ // Frame-counter VBL server lives at end of file; forward-declare so // jlpInit / jlpShutdown can install / remove it without C inferring // implicit non-static linkage at the call sites. +static void flipRequest(uint8_t bits); +static void flipWait(uint8_t bits); static void frameHzMeasure(void); static void installVblServer(void); static void removeVblServer(void); @@ -82,15 +89,19 @@ static void amigaBlitterMaskedFillOne(uint8_t *dst, uint16_t widt static void amigaPlanarSetPixel(AmigaPlanarT *pd, int16_t x, int16_t y, uint8_t color); static void amigaPresentFlushRun(AmigaPlanarT *pd, UBYTE *destPlanes[AMIGA_BITPLANES], int16_t runFirst, int16_t runLast, uint16_t byteStart, uint16_t byteLen, bool *blitOwned); static void amigaPresentRectInner(AmigaPlanarT *pd, UBYTE *destPlanes[AMIGA_BITPLANES], int16_t firstRow, int16_t lastRow, uint16_t byteStart, uint16_t bytesPerRow, bool *blitOwned); +static uint8_t bandListUpdate(const jlSurfaceT *src); +static bool bandListWrite(const jlSurfaceT *src, uint8_t idx); +static void bandListsFree(void); static uint16_t bandsFromScb(const jlSurfaceT *src, uint8_t *lines, uint8_t *palettes); -static void buildCopperList(const jlSurfaceT *src); -static void installCopperList(void); -static void patchBandColors(const jlSurfaceT *src, uint16_t palettes); +static void copperInstall(const jlSurfaceT *src); +static bool copperJumpAt(const volatile uint16_t *cop); +static bool copperListLive(void); +static void copperScan(void); static void patchBplCopperPokes(UBYTE *planes[AMIGA_BITPLANES]); -static void scanBandColorPokes(void); -static void scanBplCopperPokes(void); -static void uploadFirstBandPalette(const jlSurfaceT *src); -static void updateCopperIfNeeded(const jlSurfaceT *src); +static void patchTopColors(const uint16_t *colors); +static void screenPlanesFree(void); +static bool surfacePlanesAlloc(AmigaPlanarT *pd, ULONG memType); +static void trampolinePoint(const uint16_t *list); // ----- Module state ----- @@ -103,40 +114,90 @@ static struct BitMap *gBitMap = NULL; // copy, so the beam never catches the writes. static UBYTE *gPlanes [AMIGA_BITPLANES]; static UBYTE *gPlanesB[AMIGA_BITPLANES]; -static uint8_t gFrontIdx = 0; -static struct UCopList *gNewUCL = NULL; // built but not yet installed +static uint8_t gFrontIdx = 0; +// The flips a present asked for (flipRequest) that no VBL has made yet: +// FLIP_PLANES, the page it drew; FLIP_LIST, the band list it wrote. The VBL +// server makes them together. Until then the display still shows the page +// and list the next present would write (flipWait). +#define FLIP_PLANES 0x01u +#define FLIP_LIST 0x02u +static volatile uint8_t gFlipPending = 0u; -// Pointers into the merged hardware copper list at the data words of -// each BPLnPTH/PTL move instruction. scanBplCopperPokes finds them -// after every installCopperList; patchBplCopperPokes writes new plane -// addresses into them per flip. NULL until the first copper list is -// installed (i.e. before the first jlpPresent). -static volatile uint16_t *gBplPokes[AMIGA_BITPLANES][2]; // [plane][0=PTH,1=PTL] +// timer.device, open from jlpInit for the millisecond clock's ReadEClock. +static struct Device *gClockTimerBase = NULL; +static struct timerequest gClockRequest; -// The SCB bands of the installed user copper list, top to bottom: the line -// each one starts on and the palette it loads (buildCopperList records them -// for the list it builds, installCopperList adopts them), and, found in the -// merged hardware list by scanBandColorPokes, the data word of its COLOR00 -// MOVE -- the 16 colour MOVEs follow it COPPER_INSTR_WORDS apart. A palette -// change, or an SCB change that leaves every band where it was and only -// gives some another palette, patches those words in place -// (patchBandColors) instead of rebuilding and merging the whole copper -// list, which costs about three frames on an A500 (MrgCop, LoadView and a -// WaitTOF). gBandPokesValid is false until the pokes of the installed list -// are all found; then the full rebuild is the fallback. +// ----- The band lists ----- +// +// Each SCB band's palette comes from a band list of our own in chip RAM, +// one of two. The screen's user copper list, merged by graphics.library +// once (copperInstall), WAITs for the screen's first line and jumps (MOVEs +// to COP2LC and COPJMP2; graphics.library's copinit sets COP2LC again every +// frame before it uses it) to gTrampoline, which jumps to the list shown. A +// band list holds what the merged list runs at the jump after it (its tail's +// head), band 0's colours, a WAIT and the colours of each later band, and +// then the rest of the merged list's tail -- the end of the screen's display +// and whatever is below it -- so the copper never has to jump back into a +// list Intuition may have rebuilt. A present that changes palettes or the +// SCB writes the list not shown and asks the VBL server to switch the +// trampoline to it with the planes (FLIP_LIST). The server also loads band +// 0's colours into the merged list's own colour load above the screen, and +// the planes into its bitplane pointer MOVEs, while that list is still the +// one copperScan read; when Intuition rebuilds it (a screen moved, or +// another brought to the front), the next present reads the new one. #define COPPER_INSTR_WORDS 2 +#define COPPER_INSTR_BYTES 4u #define COPPER_WAIT_BIT 0x0001u #define COPPER_REG_MASK 0x01FEu +#define COPPER_WAIT_ALL 0xFFFEu // a WAIT's second word: every position bit compared +#define COPPER_END 0xFFFFu // WAIT $FFFF,$FFFE: never comes, so the list ends +#define COPPER_NOOP 0x01FEu // a MOVE here writes nothing +#define COPPER_VP_SHIFT 8 +#define COPPER_LAST_VP 0xFFu // a WAIT compares 8 bits of the line: later lines wrap +#define COPPER_BAND_HPOS 0x00u // a band's WAIT: the start of its first line #define COPPER_COLOR00 0x0180u #define COPPER_COLOR_STRIDE 2u -static uint8_t gBandLine [JL_SURFACE_HEIGHT]; -static uint8_t gBandPalette [JL_SURFACE_HEIGHT]; -static uint8_t gNewBandLine [JL_SURFACE_HEIGHT]; -static uint8_t gNewBandPalette[JL_SURFACE_HEIGHT]; -static uint16_t gBandCount = 0; -static uint16_t gNewBandCount = 0; -static volatile uint16_t *gBandColorWords[JL_SURFACE_HEIGHT]; -static bool gBandPokesValid = false; +#define COPPER_BPL1PTH 0x00E0u +#define COPPER_BPL_STRIDE 4u // BPLnPTH, BPLnPTL +#define COPPER_BPL_LOW 0x0002u // BPLnPTL's offset from BPLnPTH +#define COPPER_COP2LCH 0x0084u +#define COPPER_COP2LCL 0x0086u +#define COPPER_COPJMP2 0x008Au +#define COPPER_JUMP_INSTRS 3u // MOVE COP2LCH, MOVE COP2LCL, MOVE COPJMP2 +#define COPPER_JUMP_STROBE 2u // the MOVE COPJMP2 of a jump +#define COPPER_BAND_INSTRS (JL_SURFACE_COLORS_PER_PALETTE + 1u) +#define BEAM_WRAP_LINE 256u +#define BAND_LISTS 2u +#define BAND_LIST_SPARE 64u // instructions a list grows by beyond what it needs +#define UCL_INSTRS 5u // the user copper list: WAIT, a jump, CEND +// gTrampoline, in words: the jump (its data words TRAMP_HI and TRAMP_LO, +// its COPJMP2 MOVE at TRAMP_JMP), then a list of one instruction, the end +// (TRAMP_STUB), that it leads to until band list 0 exists. +#define TRAMPOLINE_INSTRS 4u +#define TRAMP_HI 1u +#define TRAMP_LO 3u +#define TRAMP_JMP 4u +#define TRAMP_STUB 6u +// The data words of the merged list's BPLnPTH/PTL MOVEs, before the jump. +static volatile uint16_t *gBplPokes[AMIGA_BITPLANES][2]; // [plane][0=PTH,1=PTL] +static uint16_t *gBandList[BAND_LISTS]; +static uint16_t gBandListInstrs[BAND_LISTS]; // each one's room +static uint8_t gListFront = 0u; // shown, or at the next VBL (FLIP_LIST) +static uint16_t gListBand0[BAND_LISTS][JL_SURFACE_COLORS_PER_PALETTE]; +static uint16_t *gTrampoline = NULL; +// What copperScan found in the merged list: the list, the instruction +// index of the jump, the data word of the screen's own COLOR00 load (16 +// follow), the beam line of the screen's first line, and the tail. +static bool gCopperReady = false; +static volatile uint16_t *gCopStart = NULL; +static uint16_t gCopJump = 0u; +static volatile uint16_t *gTopColorWords = NULL; +static uint16_t gTopLine = 0u; +static const volatile uint16_t *gTailHead = NULL; +static uint16_t gTailHeadInstrs = 0u; +static const volatile uint16_t *gTailRest = NULL; +static uint16_t gTailRestInstrs = 0u; +static uint16_t gTailLine = 0u; // the beam line of its first WAIT // Snapshot of the previous frame's dirty bands. With N=2 buffers, // the back buffer holds content from 2 frames ago, so per-present we @@ -192,18 +253,9 @@ typedef char amigaPresentScanAbiRec[(sizeof(AmigaPresentRunT) == 8) ? 1 : -1]; // presents still run. static bool gPrevAnyDirty = true; -// Cached SCB + palettes from the last present. jlpPresent* only needs -// to rebuild/install the copper list when SCB assignments or palette -// RGB values differ from what is already on screen; pure pixel updates -// (which dominate a typical game loop and every frame of the keys -// demo after the initial paint) leave both alone. MrgCop + LoadView + -// WaitTOF is hundreds of milliseconds on a 7 MHz 68000, so skipping -// them on clean frames is a major win. -/* Change detection is the core gStageScbDirty / gStagePaletteDirty - * flags (surfaceInternal.h): the mutation APIs set them and - * updateCopperIfNeeded clears them after rebuilding, replacing a - * 712-byte cached-mirror long-compare per present (NATIVE-PERF - * Phase 2 R9; the IIgs consumed the flags first). */ +// Change detection is the core gStageScbDirty / gStagePaletteDirty flags +// (surfaceInternal.h): the mutation APIs set them, and jlpPresent clears +// them once it has written the band list they ask for (bandListUpdate). // ----- Internal helpers (alphabetical) ----- @@ -358,6 +410,130 @@ static void amigaBlitterMaskedFillOne(uint8_t *dst, uint16_t widthWords, uint16_ } +// The band lists for this present (the copper is installed on the first): +// read the merged list again when Intuition has rebuilt it, and write the +// list not shown when the stage's palettes or SCB changed, or the merged +// list did (the new one needs its tail). Returns the flips to ask the VBL +// for. While the screen's jump is not in the list the display runs (the +// screen behind another), the change waits, flags set, for a present +// that finds it. +static uint8_t bandListUpdate(const jlSurfaceT *src) { + uint8_t flip; + uint8_t back; + + if (gTrampoline == NULL) { + copperInstall(src); + if (gCopperReady) { + gStageScbDirty = false; + gStagePaletteDirty = false; + gStagePaletteDirtyMask = 0; + } + return 0u; + } + flip = 0u; + if (!copperListLive()) { + copperScan(); + if (!gCopperReady) { + return 0u; + } + flip = FLIP_PLANES; + } else if (!gStageScbDirty && !gStagePaletteDirty) { + return 0u; + } + flipWait(FLIP_LIST); + back = (uint8_t)(gListFront ^ 1u); + if (!bandListWrite(src, back)) { + return flip; + } + gListFront = back; + gStageScbDirty = false; + gStagePaletteDirty = false; + gStagePaletteDirtyMask = 0; + return (uint8_t)(flip | FLIP_LIST); +} + + +// Write the stage's bands into band list idx, growing it when it is too +// small (false when chip RAM runs out): the head of the merged list's tail +// (MOVEs it runs right after the jump), band 0's colours, a WAIT and the +// colours of each later band, then the rest of the tail. A band the beam +// meets only at or past the tail's first WAIT, or past line 255, is left +// out: the copper would never reach it before the tail. +static bool bandListWrite(const jlSurfaceT *src, uint8_t idx) { + uint8_t lines[JL_SURFACE_HEIGHT]; + uint8_t palettes[JL_SURFACE_HEIGHT]; + uint16_t *w; + uint16_t bands; + uint16_t need; + uint16_t band; + uint16_t col; + uint16_t beam; + uint16_t lastLine; + uint16_t i; + + bands = bandsFromScb(src, lines, palettes); + need = (uint16_t)(gTailHeadInstrs + bands * COPPER_BAND_INSTRS + gTailRestInstrs); + if (gBandListInstrs[idx] < need) { + if (gBandList[idx] != NULL) { + FreeMem(gBandList[idx], (ULONG)gBandListInstrs[idx] * COPPER_INSTR_BYTES); + gBandList[idx] = NULL; + gBandListInstrs[idx] = 0u; + } + gBandList[idx] = (uint16_t *)AllocMem((ULONG)(need + BAND_LIST_SPARE) * COPPER_INSTR_BYTES, (ULONG)MEMF_CHIP); + if (gBandList[idx] == NULL) { + return false; + } + gBandListInstrs[idx] = (uint16_t)(need + BAND_LIST_SPARE); + } + w = gBandList[idx]; + for (i = 0; i < gTailHeadInstrs * COPPER_INSTR_WORDS; i++) { + *w++ = gTailHead[i]; + } + lastLine = gTailLine < BEAM_WRAP_LINE ? gTailLine : BEAM_WRAP_LINE; + for (band = 0; band < bands; band++) { + if (band != 0u) { + beam = (uint16_t)(gTopLine + lines[band]); + if (beam >= lastLine) { + break; + } + *w++ = (uint16_t)((beam << COPPER_VP_SHIFT) | COPPER_BAND_HPOS | COPPER_WAIT_BIT); + *w++ = COPPER_WAIT_ALL; + } + for (col = 0; col < JL_SURFACE_COLORS_PER_PALETTE; col++) { + *w++ = (uint16_t)(COPPER_COLOR00 + col * COPPER_COLOR_STRIDE); + *w++ = src->palette[palettes[band]][col]; + } + } + for (col = 0; col < JL_SURFACE_COLORS_PER_PALETTE; col++) { + gListBand0[idx][col] = src->palette[palettes[0]][col]; + } + for (i = 0; i < gTailRestInstrs * COPPER_INSTR_WORDS; i++) { + *w++ = gTailRest[i]; + } + return true; +} + + +// Free the band lists and the trampoline (jlpShutdown, once the display no +// longer runs them). +static void bandListsFree(void) { + uint16_t i; + + for (i = 0; i < BAND_LISTS; i++) { + if (gBandList[i] != NULL) { + FreeMem(gBandList[i], (ULONG)gBandListInstrs[i] * COPPER_INSTR_BYTES); + gBandList[i] = NULL; + } + gBandListInstrs[i] = 0u; + } + if (gTrampoline != NULL) { + FreeMem(gTrampoline, (ULONG)TRAMPOLINE_INSTRS * COPPER_INSTR_BYTES); + gTrampoline = NULL; + } + gCopperReady = false; +} + + // The SCB as bands: lines[b] is where band b starts and palettes[b] the // palette it loads (an out-of-range SCB entry counts as palette 0); a band // ends where the next starts. Returns the number of bands. @@ -383,307 +559,275 @@ static uint16_t bandsFromScb(const jlSurfaceT *src, uint8_t *lines, uint8_t *pal } -// Build a user copper list for per-scanline palette (SCB emulation). -// One WAIT + 16 MOVEs per band (bandsFromScb) + one CEND. The list is -// stored in gNewUCL until installCopperList swaps it onto the screen. -// DyOffset tells us where display line 0 sits in hardware coordinates -// so the WAITs line up with the real visible region regardless of -// PAL/NTSC or any overscan the user may have configured. -static void buildCopperList(const jlSurfaceT *src) { +// Give the screen its user copper list -- WAIT for its first line, jump to +// gTrampoline -- and merge it once (MakeScreen, MrgCop, LoadView, then a +// WaitTOF). The trampoline leads to a one-instruction list, the end, until +// band list 0 is written, from the merged list, before LoadView shows it; +// if it cannot be, the jump becomes a no-op and the screen shows its own +// colours only. +static void copperInstall(const jlSurfaceT *src) { struct UCopList *ucl; - UWORD band; - UWORD col; - UBYTE palIdx; - UWORD vpos; - UWORD topBorder; + struct View *view; + uint32_t tramp; - ucl = (struct UCopList *)AllocMem(sizeof(struct UCopList), MEMF_PUBLIC | MEMF_CLEAR); - if (ucl == NULL) { - gNewUCL = NULL; - return; - } - - // Worst-case reservation is one band-change per scanline (16 MOVEs - // + 1 WAIT per change), plus the terminal wait. For realistic SCB - // tables the actual count is far smaller, but CINIT only takes a - // single number so we size for the cap. - CINIT(ucl, (JL_SURFACE_HEIGHT * 17) + 1); - - // Hardware scanline where display line 0 lives. 0x2C is the - // standard top border for a PAL screen at TopEdge=0; we hardcode - // rather than reading ViewPort.DyOffset because DyOffset is a - // signed +/- adjustment around the standard value, not the - // absolute hardware line. - // User-copper vpos values are DISPLAY-RELATIVE -- graphics.lib - // MrgCop adds the active View's DyOffset to each WAIT before - // emitting, so a vp=0 user WAIT lands at beam line DyOffset, - // which is where Intuition places display line 0. Emitting at - // vpos=line keeps merged vpos under 256 even for the last band - // (175 + 44 = 219 < 256), avoiding MrgCop's destructive wrap- - // handling path that would otherwise disable bitplane DMA at - // the viewport end. - topBorder = 0; - gNewBandCount = bandsFromScb(src, gNewBandLine, gNewBandPalette); - - for (band = 0; band < gNewBandCount; band++) { - palIdx = gNewBandPalette[band]; - vpos = (UWORD)(gNewBandLine[band] + topBorder); - CWAIT(ucl, vpos, 0); - for (col = 0; col < JL_SURFACE_COLORS_PER_PALETTE; col++) { - CMOVE(ucl, custom.color[col], src->palette[palIdx][col]); + gTrampoline = (uint16_t *)AllocMem((ULONG)TRAMPOLINE_INSTRS * COPPER_INSTR_BYTES, (ULONG)(MEMF_CHIP | MEMF_CLEAR)); + ucl = (struct UCopList *)AllocMem((ULONG)sizeof(struct UCopList), (ULONG)(MEMF_PUBLIC | MEMF_CLEAR)); + if (gTrampoline == NULL || ucl == NULL) { + if (ucl != NULL) { + FreeMem(ucl, (ULONG)sizeof(struct UCopList)); } - } - CEND(ucl); - - gNewUCL = ucl; -} - - -// Swap the freshly built user copper list onto the screen's ViewPort -// and force a full graphics-library recomputation of the hardware -// copper list. MakeScreen regenerates the viewport copper to include -// our UCopIns; MrgCop merges every viewport's copper into one hardware -// list; LoadView swaps the live copper pointers. Calling the graphics -// primitives directly (rather than only Intuition's RethinkDisplay / -// RemakeDisplay) was observed here to be the step that actually makes -// the user copper list visible -- Intuition's wrappers sometimes -// skipped the merge. -static void installCopperList(void) { - struct View *view; - - if (gNewUCL == NULL || gScreen == NULL) { + bandListsFree(); return; } + gTrampoline[TRAMP_HI - 1u] = COPPER_COP2LCH; + gTrampoline[TRAMP_LO - 1u] = COPPER_COP2LCL; + gTrampoline[TRAMP_JMP] = COPPER_COPJMP2; + gTrampoline[TRAMP_STUB] = COPPER_END; + gTrampoline[TRAMP_STUB + 1u] = COPPER_WAIT_ALL; + trampolinePoint(&gTrampoline[TRAMP_STUB]); + tramp = (uint32_t)gTrampoline; + CINIT(ucl, UCL_INSTRS); + CWAIT(ucl, 0, 0); + CMOVE(ucl, custom.cop2lc, (UWORD)(tramp >> 16)); + CMOVE(ucl, ((UWORD *)&custom.cop2lc)[1], (UWORD)tramp); + CMOVE(ucl, custom.copjmp2, 0); + CEND(ucl); Forbid(); if (gScreen->ViewPort.UCopIns != NULL) { FreeVPortCopLists(&gScreen->ViewPort); } - gScreen->ViewPort.UCopIns = gNewUCL; - gNewUCL = NULL; + gScreen->ViewPort.UCopIns = ucl; Permit(); - memcpy(gBandLine, gNewBandLine, gNewBandCount); - memcpy(gBandPalette, gNewBandPalette, gNewBandCount); - gBandCount = gNewBandCount; - MakeScreen(gScreen); - view = ViewAddress(); Forbid(); MrgCop(view); + copperScan(); + if (gCopperReady) { + if (bandListWrite(src, 0u)) { + gListFront = 0u; + trampolinePoint(gBandList[0]); + patchTopColors(gListBand0[0]); + } else { + gCopStart[(gCopJump + COPPER_JUMP_STROBE) * COPPER_INSTR_WORDS] = COPPER_NOOP; + gCopperReady = false; + } + } LoadView(view); Permit(); WaitTOF(); - - // MrgCop rebuilt the hardware copper list from scratch, including - // fresh BPL pointer pokes derived from gBitMap->Planes[]. Re-cache - // pointers to those pokes so the per-frame flip in jlpPresent can - // patch them in place without another MrgCop pass; and the bands' - // colour MOVEs, for palette changes. - scanBplCopperPokes(); - scanBandColorPokes(); } -// Write the colours of the changed palettes (bit N of palettes = palette N) -// into the colour MOVEs of every band that loads one. The copper reads each -// word whole, so a band the beam is in the middle of shows its new colours -// from the next frame; the bands below it change this frame. -static void patchBandColors(const jlSurfaceT *src, uint16_t palettes) { - volatile uint16_t *words; - uint16_t band; - uint16_t col; - uint8_t palIdx; +// The merged list's jump to gTrampoline starts at cop. +static bool copperJumpAt(const volatile uint16_t *cop) { + uint32_t tramp; - for (band = 0; band < gBandCount; band++) { - palIdx = gBandPalette[band]; - if ((palettes & (1u << palIdx)) == 0) { - continue; - } - words = gBandColorWords[band]; - for (col = 0; col < JL_SURFACE_COLORS_PER_PALETTE; col++) { - words[col * COPPER_INSTR_WORDS] = src->palette[palIdx][col]; - } - } + tramp = (uint32_t)gTrampoline; + return (cop[0] & COPPER_REG_MASK) == COPPER_COP2LCH && cop[1] == (uint16_t)(tramp >> 16) && (cop[COPPER_INSTR_WORDS] & COPPER_REG_MASK) == COPPER_COP2LCL && cop[COPPER_INSTR_WORDS + 1] == (uint16_t)tramp && (cop[COPPER_JUMP_STROBE * COPPER_INSTR_WORDS] & COPPER_REG_MASK) == COPPER_COPJMP2; } -// Find each band's 16 colour MOVEs in the merged hardware copper list: a run -// of MOVEs to COLOR00..COLOR15 in order, not followed by COLOR16 (Intuition's -// own colour load for the screen can be such a run too, or a longer one, but -// it comes first: the bands are the LAST gBandCount runs). Leaves -// gBandPokesValid false unless every band's run is found. -static void scanBandColorPokes(void) { +// The merged list copperScan read is still the one the display runs, with +// the jump where it was: Intuition rebuilding the display (a screen moved, +// or another brought to the front) makes a new list or rewrites this one. +static bool copperListLive(void) { + struct View *view; + + view = GfxBase->ActiView; + return gCopperReady && view != NULL && view->LOFCprList != NULL && (volatile uint16_t *)view->LOFCprList->start == gCopStart && copperJumpAt(&gCopStart[gCopJump * COPPER_INSTR_WORDS]); +} + + +// Read the merged list the display runs: before the jump, the screen's own +// colour load (the first COLOR00..COLOR15 run) and its bitplane pointer +// MOVEs; the jump and the line its WAIT waits for; after it, the tail to +// the list's end, split at its first WAIT. gCopperReady says all were +// found. A jump with no end after it is made a no-op, so the copper never +// takes it into a band list that cannot end. +static void copperScan(void) { struct View *view; volatile uint16_t *cop; - volatile uint16_t *runs[JL_SURFACE_HEIGHT]; - uint16_t runCount; - uint16_t maxPairs; + uint16_t count; uint16_t i; - uint16_t col; - uint16_t band; - - gBandPokesValid = false; - view = ViewAddress(); - if (view == NULL || view->LOFCprList == NULL || view->LOFCprList->start == NULL || gBandCount == 0) { - return; - } - cop = (volatile uint16_t *)view->LOFCprList->start; - maxPairs = (uint16_t)view->LOFCprList->MaxCount; - runCount = 0; - i = 0; - while (i + JL_SURFACE_COLORS_PER_PALETTE <= maxPairs) { - for (col = 0; col < JL_SURFACE_COLORS_PER_PALETTE; col++) { - if ((cop[(i + col) * COPPER_INSTR_WORDS] & (COPPER_REG_MASK | COPPER_WAIT_BIT)) != COPPER_COLOR00 + col * COPPER_COLOR_STRIDE) { - break; - } - } - if (col < JL_SURFACE_COLORS_PER_PALETTE) { - i++; - continue; - } - // A 16-colour run; one that goes on into COLOR16 is not a band's. - if (i + JL_SURFACE_COLORS_PER_PALETTE < maxPairs && (cop[(i + JL_SURFACE_COLORS_PER_PALETTE) * COPPER_INSTR_WORDS] & (COPPER_REG_MASK | COPPER_WAIT_BIT)) == COPPER_COLOR00 + JL_SURFACE_COLORS_PER_PALETTE * COPPER_COLOR_STRIDE) { - i += JL_SURFACE_COLORS_PER_PALETTE; - continue; - } - if (runCount == JL_SURFACE_HEIGHT) { - return; - } - runs[runCount++] = &cop[i * COPPER_INSTR_WORDS + 1]; - i += JL_SURFACE_COLORS_PER_PALETTE; - } - if (runCount < gBandCount) { - return; - } - for (band = 0; band < gBandCount; band++) { - gBandColorWords[band] = runs[runCount - gBandCount + band]; - } - gBandPokesValid = true; -} - - -// Walk the merged hardware copper list and remember where the -// BPL1PTH/PTL through BPL4PTH/PTL move instructions are. Each cached -// pointer addresses the DATA word of one MOVE -- patchBplCopperPokes -// can rewrite those 8 words to switch which CHIP buffer the display -// fetches from, with no graphics.library involvement. -// -// Copper MOVE encoding: word 0 = register offset (low bit clear), -// word 1 = data. WAIT/SKIP have word-0 low bit set, so they are -// skipped here. BPL pointer registers live at $E0..$F6 in increments -// of 2 ($E0 = BPL1PTH, $E2 = BPL1PTL, ..., $F4 = BPL4PTH, $F6 = -// BPL4PTL); planes beyond 4 ($F8+) are ignored. -static void scanBplCopperPokes(void) { - struct View *view; - volatile uint16_t *cop; - uint16_t inst; + uint16_t first; + uint16_t word; uint16_t reg; - uint16_t plane; - uint16_t hilo; - uint16_t i; - uint16_t maxPairs; + uint16_t vp; + uint16_t line; + uint16_t wrap; + uint16_t col; + bool found; + gCopperReady = false; + gCopStart = NULL; + gTopColorWords = NULL; for (i = 0; i < AMIGA_BITPLANES; i++) { gBplPokes[i][0] = NULL; gBplPokes[i][1] = NULL; } - - view = ViewAddress(); - if (view == NULL || view->LOFCprList == NULL || view->LOFCprList->start == NULL) { + view = GfxBase->ActiView; + if (gTrampoline == NULL || view == NULL || view->LOFCprList == NULL || view->LOFCprList->start == NULL) { return; } - cop = (volatile uint16_t *)view->LOFCprList->start; - // MaxCount is in copper "instructions" (longs); each instruction - // is a pair of words. Cap defensively so a malformed list can't - // run us past the end of the buffer. - maxPairs = (uint16_t)view->LOFCprList->MaxCount; - - for (i = 0; i < maxPairs; i++) { - inst = cop[i * 2]; - // Bit 0 set marks WAIT/SKIP (the kind is disambiguated by - // bit 0 of the second word). Either way, skip. - if (inst & 0x0001u) { + cop = (volatile uint16_t *)view->LOFCprList->start; + count = (uint16_t)view->LOFCprList->MaxCount; + line = 0u; + wrap = 0u; + found = false; + for (i = 0; i + COPPER_JUMP_INSTRS <= count; i++) { + word = cop[i * COPPER_INSTR_WORDS]; + if ((word & COPPER_WAIT_BIT) != 0u) { + if (word == COPPER_END && cop[i * COPPER_INSTR_WORDS + 1] == COPPER_WAIT_ALL) { + return; + } + vp = (uint16_t)(word >> COPPER_VP_SHIFT); + line = (uint16_t)(wrap + vp); + if (vp == COPPER_LAST_VP) { + wrap = BEAM_WRAP_LINE; + } continue; } - reg = (uint16_t)(inst & 0x01FEu); - if (reg < 0x00E0u || reg > 0x00F6u) { - continue; + reg = (uint16_t)(word & COPPER_REG_MASK); + if (reg == COPPER_COP2LCH && copperJumpAt(&cop[i * COPPER_INSTR_WORDS])) { + found = true; + break; } - plane = (uint16_t)((reg - 0x00E0u) >> 2); - hilo = (uint16_t)((reg & 0x0002u) >> 1); - if (plane >= AMIGA_BITPLANES) { - continue; + if (reg == COPPER_COLOR00 && gTopColorWords == NULL && i + JL_SURFACE_COLORS_PER_PALETTE <= count) { + for (col = 1; col < JL_SURFACE_COLORS_PER_PALETTE; col++) { + if ((cop[(i + col) * COPPER_INSTR_WORDS] & (COPPER_REG_MASK | COPPER_WAIT_BIT)) != COPPER_COLOR00 + col * COPPER_COLOR_STRIDE) { + break; + } + } + if (col == JL_SURFACE_COLORS_PER_PALETTE) { + gTopColorWords = &cop[i * COPPER_INSTR_WORDS + 1]; + } + } + if (reg >= COPPER_BPL1PTH && reg < COPPER_BPL1PTH + AMIGA_BITPLANES * COPPER_BPL_STRIDE) { + gBplPokes[(reg - COPPER_BPL1PTH) / COPPER_BPL_STRIDE][(reg & COPPER_BPL_LOW) != 0u ? 1 : 0] = &cop[i * COPPER_INSTR_WORDS + 1]; } - gBplPokes[plane][hilo] = &cop[i * 2 + 1]; } + if (!found) { + return; + } + gCopJump = i; + gTopLine = line; + i = (uint16_t)(i + COPPER_JUMP_INSTRS); + gTailHead = &cop[i * COPPER_INSTR_WORDS]; + first = i; + while (i < count && (cop[i * COPPER_INSTR_WORDS] & COPPER_WAIT_BIT) == 0u) { + i++; + } + gTailHeadInstrs = (uint16_t)(i - first); + gTailRest = &cop[i * COPPER_INSTR_WORDS]; + first = i; + for (; i < count; i++) { + if (cop[i * COPPER_INSTR_WORDS] == COPPER_END && cop[i * COPPER_INSTR_WORDS + 1] == COPPER_WAIT_ALL) { + break; + } + } + if (i == count) { + cop[(gCopJump + COPPER_JUMP_STROBE) * COPPER_INSTR_WORDS] = COPPER_NOOP; + return; + } + gTailRestInstrs = (uint16_t)(i - first + 1u); + word = cop[first * COPPER_INSTR_WORDS]; + gTailLine = word == COPPER_END ? BEAM_WRAP_LINE : (uint16_t)(wrap + (word >> COPPER_VP_SHIFT)); + gCopStart = cop; + gCopperReady = true; } -// Rewrite the cached BPLnPTH/PTL data words to point at a fresh set -// of CHIP plane buffers. Takes effect at the next vblank when the -// copper next runs through these pokes. Disable() guards against an -// interrupt landing between word writes; the copper itself is past -// the BPL section by the time jlpPresent runs (the app calls -// jlWaitVBL before jlStagePresent, so we're already in the visible -// scan region by here), so we can't actually tear a poke in flight. +// Point the merged list's bitplane pointer MOVEs at planes; a MOVE whose +// register is not the one copperScan found there is left alone. static void patchBplCopperPokes(UBYTE *planes[AMIGA_BITPLANES]) { uint16_t i; + uint16_t reg; uint32_t addr; - if (gBplPokes[0][0] == NULL) { - return; // first present before any installCopperList yet - } - Disable(); for (i = 0; i < AMIGA_BITPLANES; i++) { if (gBplPokes[i][0] == NULL || gBplPokes[i][1] == NULL) { continue; } + reg = (uint16_t)(COPPER_BPL1PTH + i * COPPER_BPL_STRIDE); + if ((gBplPokes[i][0][-1] & COPPER_REG_MASK) != reg || (gBplPokes[i][1][-1] & COPPER_REG_MASK) != (reg | COPPER_BPL_LOW)) { + continue; + } addr = (uint32_t)planes[i]; - *gBplPokes[i][0] = (uint16_t)(addr >> 16); // PTH - *gBplPokes[i][1] = (uint16_t)(addr & 0xFFFFu); // PTL + *gBplPokes[i][0] = (uint16_t)(addr >> 16); + *gBplPokes[i][1] = (uint16_t)addr; } - Enable(); } -// Rebuild and install the user copper list only if the palette/SCB -// state visible to the display differs from what the surface carries -// now. On clean frames we skip the AllocMem + MrgCop + LoadView + -// WaitTOF chain entirely. `src` is always gStage (jlpPresent's -// contract), so the core dirty flags are the change signal; clear -// them only after rebuilding. -static void updateCopperIfNeeded(const jlSurfaceT *src) { - uint16_t palettes; - uint16_t band; +// Load colors into the merged list's own colour load above the screen, so +// the lines before the band list's first MOVEs land show band 0's colours. +static void patchTopColors(const uint16_t *colors) { + uint16_t col; - if (!gStageScbDirty && !gStagePaletteDirty) { + if (gTopColorWords == NULL) { return; } - palettes = gStagePaletteDirty ? gStagePaletteDirtyMask : 0u; - // An SCB change that leaves every band where it was only gives some - // bands another palette: patch those bands' colours too. - if (gStageScbDirty && gBandPokesValid && bandsFromScb(src, gNewBandLine, gNewBandPalette) == gBandCount && memcmp(gNewBandLine, gBandLine, gBandCount) == 0) { - for (band = 0; band < gBandCount; band++) { - if (gNewBandPalette[band] != gBandPalette[band]) { - gBandPalette[band] = gNewBandPalette[band]; - palettes |= (uint16_t)(1u << gBandPalette[band]); - } + for (col = 0; col < JL_SURFACE_COLORS_PER_PALETTE; col++) { + if ((gTopColorWords[col * COPPER_INSTR_WORDS - 1] & COPPER_REG_MASK) != COPPER_COLOR00 + col * COPPER_COLOR_STRIDE) { + return; } - gStageScbDirty = false; + gTopColorWords[col * COPPER_INSTR_WORDS] = colors[col]; } - // Only colours changed, on bands whose MOVEs are known: patch them. - if (!gStageScbDirty && gBandPokesValid) { - patchBandColors(src, palettes); - gStagePaletteDirty = false; - gStagePaletteDirtyMask = 0; - return; +} + + +// Free the screen's bitmap and both page sets, whatever of them was +// allocated (jlpInit's failures and jlpShutdown: the screen does not free +// what SA_BitMap gave it). +static void screenPlanesFree(void) { + uint16_t i; + + for (i = 0; i < AMIGA_BITPLANES; i++) { + if (gPlanes[i] != NULL) { + FreeMem(gPlanes[i], (ULONG)AMIGA_PLANE_SIZE); + gPlanes[i] = NULL; + } + if (gPlanesB[i] != NULL) { + FreeMem(gPlanesB[i], (ULONG)AMIGA_PLANE_SIZE); + gPlanesB[i] = NULL; + } } - uploadFirstBandPalette(src); - buildCopperList(src); - installCopperList(); - gStageScbDirty = false; - gStagePaletteDirty = false; - gStagePaletteDirtyMask = 0; + if (gBitMap != NULL) { + FreeMem(gBitMap, (ULONG)sizeof(struct BitMap)); + gBitMap = NULL; + } +} + + +// Allocate pd's four planes, cleared, in memType memory; false (and none +// kept) when one does not fit. AMIGA_PLANE_ALLOC, not AMIGA_PLANE_SIZE: one +// byte of slack past the end. A span in src/m68k/planarFill.s whose two ends +// land on the same byte writes the FOLLOWING byte back unchanged rather than +// branching to its own code path, which on the last row would otherwise be +// one byte past the buffer. +static bool surfacePlanesAlloc(AmigaPlanarT *pd, ULONG memType) { + uint16_t i; + + for (i = 0; i < AMIGA_BITPLANES; i++) { + pd->planes[i] = (uint8_t *)AllocMem((ULONG)AMIGA_PLANE_ALLOC, memType | MEMF_CLEAR); + if (pd->planes[i] == NULL) { + while (i > 0u) { + i--; + FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_ALLOC); + pd->planes[i] = NULL; + } + return false; + } + } + return true; +} + + +// Point the trampoline's jump at list. +static void trampolinePoint(const uint16_t *list) { + gTrampoline[TRAMP_HI] = (uint16_t)((uint32_t)list >> 16); + gTrampoline[TRAMP_LO] = (uint16_t)(uint32_t)list; } @@ -695,8 +839,8 @@ static void updateCopperIfNeeded(const jlSurfaceT *src) { // MUST be equal: the union copy guarantees the freshly flipped page // carries every row of the current shadow. Inequality == stale rows // reaching the display -- the failure class the golden stage hashes -// cannot see. Falls back to gFrontIdx before the first copper -// install. Always compiled (the probe build links the stock library; +// cannot see. Reads gFrontIdx's planes while the VBL has not yet made +// the flip a present asked for, and before the first copper install. Always compiled (the probe build links the stock library; // gPresentCopiedBytes precedent). void amigaProbePresentPageSums(const jlSurfaceT *src, uint32_t *frontSum, uint32_t *shadowSum) { AmigaPlanarT *pd; @@ -712,7 +856,7 @@ void amigaProbePresentPageSums(const jlSurfaceT *src, uint32_t *frontSum, uint32 fSum = 5381u; sSum = 5381u; for (i = 0; i < AMIGA_BITPLANES; i++) { - if (gBplPokes[i][0] != NULL && gBplPokes[i][1] != NULL) { + if ((gFlipPending & FLIP_PLANES) == 0u && gBplPokes[i][0] != NULL && gBplPokes[i][1] != NULL) { addr = ((uint32_t)*gBplPokes[i][0] << 16) | (uint32_t)*gBplPokes[i][1]; fp = (const uint8_t *)addr; } else { @@ -729,33 +873,10 @@ void amigaProbePresentPageSums(const jlSurfaceT *src, uint32_t *frontSum, uint32 } -// Load the first band's palette into the screen's ColorMap so the -// Intuition-generated frame-start copper writes those values on each -// frame. This acts as a safety net: even if our user copper list does -// not fire (or fires late) for the very first band, the top of the -// display still shows the correct colors because Intuition's own -// COLORxx loads happen before any user copper instruction. -static void uploadFirstBandPalette(const jlSurfaceT *src) { - UWORD aPalette[JL_SURFACE_COLORS_PER_PALETTE]; - UWORD i; - UBYTE palIdx; - - palIdx = src->scb[0]; - if (palIdx >= JL_SURFACE_PALETTE_COUNT) { - palIdx = 0; - } - for (i = 0; i < JL_SURFACE_COLORS_PER_PALETTE; i++) { - aPalette[i] = (UWORD)src->palette[palIdx][i]; - } - LoadRGB4(&gScreen->ViewPort, aPalette, JL_SURFACE_COLORS_PER_PALETTE); -} - - // ----- HAL API (alphabetical) ----- bool jlpInit(const jlConfigT *config) { uint16_t i; - uint16_t j; (void)config; @@ -776,6 +897,7 @@ bool jlpInit(const jlConfigT *config) { // defaults. gBitMap = (struct BitMap *)AllocMem((ULONG)sizeof(struct BitMap), (ULONG)(MEMF_PUBLIC | MEMF_CLEAR)); if (gBitMap == NULL) { + coreSetError("out of memory for the screen's bitmap"); return false; } InitBitMap(gBitMap, (LONG)AMIGA_BITPLANES, (LONG)JL_SURFACE_WIDTH, (LONG)JL_SURFACE_HEIGHT); @@ -787,22 +909,8 @@ bool jlpInit(const jlConfigT *config) { gPlanes[i] = (UBYTE *)AllocMem((ULONG)AMIGA_PLANE_SIZE, (ULONG)(MEMF_CHIP | MEMF_CLEAR)); gPlanesB[i] = (UBYTE *)AllocMem((ULONG)AMIGA_PLANE_SIZE, (ULONG)(MEMF_CHIP | MEMF_CLEAR)); if (gPlanes[i] == NULL || gPlanesB[i] == NULL) { - if (gPlanes[i] != NULL) { - FreeMem(gPlanes[i], (ULONG)AMIGA_PLANE_SIZE); - gPlanes[i] = NULL; - } - if (gPlanesB[i] != NULL) { - FreeMem(gPlanesB[i], (ULONG)AMIGA_PLANE_SIZE); - gPlanesB[i] = NULL; - } - for (j = 0; j < i; j++) { - FreeMem(gPlanes[j], (ULONG)AMIGA_PLANE_SIZE); - FreeMem(gPlanesB[j], (ULONG)AMIGA_PLANE_SIZE); - gPlanes[j] = NULL; - gPlanesB[j] = NULL; - } - FreeMem(gBitMap, (ULONG)sizeof(struct BitMap)); - gBitMap = NULL; + screenPlanesFree(); + coreSetError("not enough chip RAM for the two screen pages"); return false; } // Set A is the initial front buffer; BPL DMA fetches from it @@ -826,28 +934,23 @@ bool jlpInit(const jlConfigT *config) { gScreen = OpenScreenTags(NULL, (ULONG)SA_Width, (ULONG)JL_SURFACE_WIDTH, (ULONG)SA_Height, (ULONG)JL_SURFACE_HEIGHT, (ULONG)SA_Depth, (ULONG)AMIGA_BITPLANES, (ULONG)SA_DisplayID, (ULONG)(PAL_MONITOR_ID | LORES_KEY), (ULONG)SA_DetailPen, (ULONG)0, (ULONG)SA_BlockPen, (ULONG)1, (ULONG)SA_Title, (ULONG)"JoeyLib", (ULONG)SA_Type, (ULONG)CUSTOMSCREEN, (ULONG)SA_Quiet, (ULONG)TRUE, (ULONG)SA_BitMap, (ULONG)gBitMap, TAG_DONE); if (gScreen == NULL) { - for (i = 0; i < AMIGA_BITPLANES; i++) { - if (gPlanes[i] != NULL) { - FreeMem(gPlanes[i], (ULONG)AMIGA_PLANE_SIZE); - gPlanes[i] = NULL; - } - if (gPlanesB[i] != NULL) { - FreeMem(gPlanesB[i], (ULONG)AMIGA_PLANE_SIZE); - gPlanesB[i] = NULL; - } - } - FreeMem(gBitMap, (ULONG)sizeof(struct BitMap)); - gBitMap = NULL; + screenPlanesFree(); + coreSetError("Intuition could not open the 320x200 PAL screen"); return false; } // Force COLOR00 to black so the overscan/border region around the // 320x200 display is black until the app's palette load takes over // on the first jlStagePresent. Apps that paint a non-black bg need - // do nothing -- their palette[0] writes the same COLOR00 once the - // first LoadRGB4 fires from uploadScbAndPalette. + // do nothing -- the first present's band list loads their palette[0] + // into COLOR00, above the screen too (patchTopColors). SetRGB4(&gScreen->ViewPort, 0, 0, 0, 0); installVblServer(); frameHzMeasure(); + // The millisecond clock's E-clock (jlpMillisElapsed); without it the + // clock counts frames. + if (OpenDevice((CONST_STRPTR)TIMERNAME, UNIT_ECLOCK, (struct IORequest *)&gClockRequest, 0) == 0) { + gClockTimerBase = gClockRequest.tr_node.io_Device; + } return true; } @@ -988,14 +1091,16 @@ void jlpPresent(const jlSurfaceT *src) { bool anyCurDirty; UBYTE **backPlanes; uint8_t backIdx; + uint8_t flip; if (src == NULL || gScreen == NULL) { return; } - updateCopperIfNeeded(src); + flip = bandListUpdate(src); pd = (AmigaPlanarT *)src->portData; if (pd == NULL) { + flipRequest(flip); return; } @@ -1040,6 +1145,7 @@ void jlpPresent(const jlSurfaceT *src) { } gPresentScannedRows += (uint32_t)(anyDirty ? (y + 1) : JL_SURFACE_HEIGHT); if (!anyDirty) { + flipRequest(flip); return; } } @@ -1050,10 +1156,9 @@ void jlpPresent(const jlSurfaceT *src) { // * Union(prev,current) dirty rows: the back buffer holds // content from 2 frames ago, so anything dirty in either of // the last two frames may differ from the current shadow. - // * After the copy, patch the copper list's BPLnPT pokes to - // point at the freshly-drawn set. The copper picks up the - // new pokes at the next vblank and BPL DMA fetches from the - // new buffer on the following frame. + // * After the copy, ask the VBL server to point the copper list's + // BPLnPT pokes at the freshly-drawn set (flipRequest), with the + // band list written above: both show from that VBL's frame. backIdx = (uint8_t)(gFrontIdx ^ 1u); backPlanes = (backIdx == 0u) ? gPlanes : gPlanesB; @@ -1091,6 +1196,7 @@ void jlpPresent(const jlSurfaceT *src) { // blitOwned: the first run that takes the blitter path OwnBlitter's; // every later blitter run reuses that ownership; the DisownBlitter is // paid once after the loop. All-CPU presents never own it. + flipWait(FLIP_PLANES); { bool blitOwned = false; for (r = 0; r < numRuns; r++) { @@ -1102,9 +1208,8 @@ void jlpPresent(const jlSurfaceT *src) { } if (anyUnionDirty) { - // Patch copper-list BPL pokes to the freshly drawn buffer. - // Takes effect at the next vblank. Keep gBitMap->Planes in - // sync so a later updateCopperIfNeeded rebuild bakes the + // Flip to the freshly drawn buffer at the next vblank. Keep + // gBitMap->Planes in sync so a list Intuition rebuilds bakes the // same pointers. Only flip when there was actually something // to draw -- flipping to a stale buffer on an idle frame // would oscillate the display between two stale views. @@ -1116,9 +1221,10 @@ void jlpPresent(const jlSurfaceT *src) { gBitMap->Planes[i] = backPlanes[i]; } } - patchBplCopperPokes(backPlanes); gFrontIdx = backIdx; + flip |= FLIP_PLANES; } + flipRequest(flip); // Snapshot (gPrevStageMinWord/MaxWord <- cur) and gPrevAnyDirty are // now folded into the scan loop above, eliminating the second @@ -1156,6 +1262,7 @@ static bool gVblInstalled = false; // for PAL, but an NTSC machine whose chips cannot do PAL scans at 60 Hz all // the same. A PAL beam counts 312 lines a frame and an NTSC one 262, so the // highest line the beam reaches in two frames says which. +#define MS_PER_SECOND 1000u #define BEAM_PAL_LINES 300u #define BEAM_FRAMES 2u #define FRAME_HZ_PAL 50u @@ -1165,6 +1272,27 @@ static bool gVblInstalled = false; static uint16_t gFrameHz = FRAME_HZ_PAL; +// Ask the VBL server for the flips in bits (FLIP_PLANES, FLIP_LIST); it +// makes them all in the same VBL. +static void flipRequest(uint8_t bits) { + if (bits == 0u) { + return; + } + Disable(); + gFlipPending = (uint8_t)(gFlipPending | bits); + Enable(); +} + + +// A present right after another, before the VBL that makes its flips, would +// write the page or band list the display still shows: wait for that VBL +// first. A program that waits for the VBL between presents never waits here. +static void flipWait(uint8_t bits) { + while ((gFlipPending & bits) != 0u) { + } +} + + static void frameHzMeasure(void) { uint16_t line; uint16_t last; @@ -1195,8 +1323,32 @@ static void frameHzMeasure(void) { // continues walking the INTB_VERTB chain, letting other VBL servers // further down the priority list still fire. Returning non-zero would // claim the interrupt and terminate the chain. +// +// It also makes the flips a present asked for (flipRequest): the band list +// through the trampoline, and, in the merged list while it is still the +// one copperScan read, the bitplane pointers and the screen's own colour +// load (band 0's colours for the lines above the band list's). The copper +// reaches the screen about 44 lines later, so they show from this frame. static ULONG vblServer(void) { + uint8_t pending; + gFrameCount = (uint16_t)(gFrameCount + 1u); + pending = gFlipPending; + if (pending == 0u) { + return 0; + } + if ((pending & FLIP_LIST) != 0u) { + trampolinePoint(gBandList[gListFront]); + } + if (copperListLive()) { + if ((pending & FLIP_PLANES) != 0u) { + patchBplCopperPokes(gFrontIdx == 0u ? gPlanes : gPlanesB); + } + if ((pending & FLIP_LIST) != 0u) { + patchTopColors(gListBand0[gListFront]); + } + } + gFlipPending = 0u; return 0; } @@ -1243,17 +1395,27 @@ uint16_t jlpFrameHz(void) { } -// Wall-clock ms: frame-counter approximation, same fallback as the -// IIgs. A timer.device-based ReadEClock impl would give microsecond -// resolution and decouple from VBL load, but adds a 7-line library -// open / close dance for what AGI doesn't notice yet (audio is -// stubbed on Amiga -- see jlpAudioVoice). Revisit when 3-voice -// audio actually lands on Amiga. +// Milliseconds on timer.device's E-clock (709 kHz on a PAL machine, 716 kHz +// on an NTSC one), which runs whatever the display and the interrupts do; +// the frame count before jlInit and after jlShutdown, as the generic clock +// keeps it. The core counts from jlInit (it subtracts jlInit's reading). +uint32_t jlpMillisElapsed(void) { + struct EClockVal now; + uint32_t hz; + + if (gClockTimerBase == NULL) { + return jlpGenericMillisElapsed(); + } + hz = ReadEClock(&now); + return (uint32_t)((((uint64_t)now.ev_hi << 32) | now.ev_lo) * MS_PER_SECOND / hz); +} void jlpShutdown(void) { - uint16_t i; - + if (gClockTimerBase != NULL) { + CloseDevice((struct IORequest *)&gClockRequest); + gClockTimerBase = NULL; + } // Tear down the VBL server before closing the screen so the // interrupt chain is clean if anything else is watching. removeVblServer(); @@ -1267,31 +1429,17 @@ void jlpShutdown(void) { Permit(); CloseScreen(gScreen); gScreen = NULL; + // Intuition loads a display without the screen, and its jump to + // the band lists, at the next VBL: past that, nothing runs them. + WaitTOF(); + WaitTOF(); } + bandListsFree(); // We allocated the BitMap and its planes manually (see jlpInit) // so SA_BitMap could pin Intuition to non-interleaved layout. // CloseScreen with an SA_BitMap'd screen does NOT free our // BitMap or planes -- we own them and must clean up here. - if (gBitMap != NULL) { - for (i = 0; i < AMIGA_BITPLANES; i++) { - if (gPlanes[i] != NULL) { - FreeMem(gPlanes[i], (ULONG)AMIGA_PLANE_SIZE); - gPlanes[i] = NULL; - } - if (gPlanesB[i] != NULL) { - FreeMem(gPlanesB[i], (ULONG)AMIGA_PLANE_SIZE); - gPlanesB[i] = NULL; - } - } - FreeMem(gBitMap, (ULONG)sizeof(struct BitMap)); - gBitMap = NULL; - } - // gNewUCL is never live at shutdown: buildCopperList and - // installCopperList run back-to-back inside updateCopperIfNeeded, - // so the built list is always consumed (gNewUCL == NULL) before we - // get here. A bare FreeMem of the UCopList struct would also leak - // the attached CopList chain that CINIT/CWAIT/CMOVE allocate, so we - // deliberately do NOT attempt to free it here. + screenPlanesFree(); } @@ -1300,6 +1448,7 @@ void jlpShutdown(void) { // same bytes in the same places. src/m68k/planarFill.s does the area fills, // src/m68k/planarPlot.s the pixel walkers. extern void surface68kFillCircle(uint8_t *const *planes, uint16_t cx, uint16_t cy, uint16_t r, uint16_t color); +extern void surface68kFillRect(uint8_t *const *planes, uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color); // src/m68k/planarPlot.s, shared with the X68000: Bresenham walkers with 16-way // colour dispatch, so every plane write is one memory read-modify-write with // the set-or-clear choice already made. Both are bit-identical to draw.c's own @@ -1326,6 +1475,10 @@ void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) { } colorIndex = (uint8_t)(doubled & 0x0Fu); pd = (AmigaPlanarT *)s->portData; + if (!pd->chip) { + surface68kFillRect(pd->planes, 0u, 0u, JL_SURFACE_WIDTH, JL_SURFACE_HEIGHT, colorIndex); + return; + } // Blitter DEST-only block fill of every whole plane to $00 (clear) // or $FF (set). Each plane is 200 rows x 20 words, contiguous, so @@ -1378,6 +1531,10 @@ void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint jlpGenericFillRect(s, x, y, w, h, colorIndex); return; } + if (!pd->chip) { + surface68kFillRect(pd->planes, (uint16_t)x, (uint16_t)y, (uint16_t)w, (uint16_t)h, colorIndex); + return; + } // Word-granular rect geometry. wordFirst/wordLast index the 16-pixel // plane words the rect touches; fwm/lwm are the blitter first/last- @@ -1656,12 +1813,21 @@ void jlpSurfaceCopyPlanes(jlSurfaceT *dst, const jlSurfaceT *src) { if (dstPd == NULL || srcPd == NULL) { return; } + // A surface outside chip RAM is out of the blitter's reach: exec's + // long copy (planes are AllocMem'd, so long-aligned, and a long + // multiple in size). + if (!dstPd->chip || !srcPd->chip) { + uint16_t i; + + for (i = 0; i < AMIGA_BITPLANES; i++) { + CopyMemQuick(srcPd->planes[i], dstPd->planes[i], (ULONG)AMIGA_PLANE_SIZE); + } + return; + } // Straight blitter A->D copy of every whole plane (200 rows x 20 - // words, contiguous). Both source and dest planes are CHIP RAM now - // (jlpSurfaceAllocPortData) so the blitter can reach both. The - // helper owns the blitter, copies all four planes, and WaitBlit's - // before DisownBlitter so the dest planes are complete before any - // CPU read. + // words, contiguous). The helper owns the blitter, copies all four + // planes, and WaitBlit's before DisownBlitter so the dest planes are + // complete before any CPU read. amigaBlitterCopyPlanes(dstPd->planes, srcPd->planes, 0u, AMIGA_BLT_WORDS_PER_ROW, JL_SURFACE_HEIGHT, 0u); } @@ -1671,14 +1837,14 @@ void jlpSurfaceCopyPlanes(jlSurfaceT *dst, const jlSurfaceT *src) { // and 16-px-snapped the rect, so x and w are multiples of 16: the // plane byte offset (x >> 3) is even (68000 word-safe) and each row // is a whole number of words, so no BLTAFWM/BLTALWM edge masking is -// needed. Both surfaces' planes are CHIP RAM (jlpSurfaceAllocPortData), -// so the copy runs as one modulo-driven A->D blit per plane -// (mod = 40 - rowBytes), replacing 4*h short memcpys whose call -// overhead dominated (a 16x16 region paid 64 memcpy calls to move -// 128 bytes -- ~8-10x the blitter path). Rects below -// AMIGA_COPYRECT_BLIT_MIN_WORDS per-plane words stay on a CPU -// word-store walk instead: the blitter's fixed setup cost loses -// there, and the snap's word alignment makes the walk 68000-safe. +// needed. When both surfaces' planes are chip RAM the copy runs as one +// modulo-driven A->D blit per plane (mod = 40 - rowBytes), replacing +// 4*h short memcpys whose call overhead dominated (a 16x16 region paid +// 64 memcpy calls to move 128 bytes -- ~8-10x the blitter path). Rects +// below AMIGA_COPYRECT_BLIT_MIN_WORDS per-plane words, and any rect of a +// surface outside chip RAM, stay on a CPU word-store walk instead: the +// blitter's fixed setup cost loses there, and the snap's word alignment +// makes the walk 68000-safe. void jlpSurfaceCopyRect(jlSurfaceT *dst, const jlSurfaceT *src, uint16_t x, uint16_t y, uint16_t w, uint16_t h) { AmigaPlanarT *dstPd; AmigaPlanarT *srcPd; @@ -1700,7 +1866,7 @@ void jlpSurfaceCopyRect(jlSurfaceT *dst, const jlSurfaceT *src, uint16_t x, uint off = (uint16_t)(y * AMIGA_BYTES_PER_ROW + (x >> 3)); rowBytes = (uint16_t)(w >> 3); widthWords = (uint16_t)(rowBytes >> 1); - if ((uint16_t)(h * widthWords) >= AMIGA_COPYRECT_BLIT_MIN_WORDS) { + if (dstPd->chip && srcPd->chip && (uint16_t)(h * widthWords) >= AMIGA_COPYRECT_BLIT_MIN_WORDS) { amigaBlitterCopyPlanes(dstPd->planes, srcPd->planes, off, widthWords, h, (uint16_t)(AMIGA_BYTES_PER_ROW - rowBytes)); return; } @@ -2174,11 +2340,9 @@ uint8_t *jlpSurfaceAllocPixels(void) { // s->portData. void *jlpSurfaceAllocPortData(jlSurfaceT *s, bool isStage) { AmigaPlanarT *pd; - uint16_t i; (void)s; - (void)isStage; - pd = (AmigaPlanarT *)AllocMem((ULONG)sizeof(AmigaPlanarT), (ULONG)(MEMF_CHIP | MEMF_CLEAR)); + pd = (AmigaPlanarT *)AllocMem((ULONG)sizeof(AmigaPlanarT), (ULONG)(MEMF_PUBLIC | MEMF_CLEAR)); if (pd == NULL) { return NULL; } @@ -2191,31 +2355,20 @@ void *jlpSurfaceAllocPortData(jlSurfaceT *s, bool isStage) { * is gPlanes[], CHIP, updated only by jlpPresent). Non-stage uses * these as the surface's only planes. * - * MEMF_CHIP because the bulk plane ops (clear, full-row fill, - * surface copy) now run on the BLITTER, which can only reach chip - * RAM. This is a deliberate performance tradeoff: chip RMW is - * slower than fast RAM for per-pixel CPU draws (line / circle / - * single pixel), but the blitter wins big on the full-plane - * clear/fill/copy paths and runs in parallel with the CPU. Net is - * workload-dependent; see the blitter helpers above. The planes - * must be MEMF_CHIP unconditionally now -- a fast-RAM fallback - * would put the blitter helpers out of reach and corrupt output. */ - for (i = 0; i < AMIGA_BITPLANES; i++) { - // AMIGA_PLANE_ALLOC, not AMIGA_PLANE_SIZE: one byte of slack past the - // end. A span in src/m68k/planarFill.s whose two ends land on the same - // byte writes the FOLLOWING byte back unchanged rather than branching - // to its own code path, which on the last row would otherwise be one - // byte past the buffer. - pd->planes[i] = (uint8_t *)AllocMem((ULONG)AMIGA_PLANE_ALLOC, (ULONG)(MEMF_CHIP | MEMF_CLEAR)); - if (pd->planes[i] == NULL) { - // Roll back any planes already allocated. - while (i > 0u) { - i--; - FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_ALLOC); - } - FreeMem(pd, (ULONG)sizeof(AmigaPlanarT)); - return NULL; - } + * Chip RAM first, because the bulk plane ops (clear, full-row fill, + * surface copy) run on the BLITTER, which can only reach chip RAM. + * This is a deliberate performance tradeoff: chip RMW is slower + * than fast RAM for per-pixel CPU draws (line / circle / single + * pixel), but the blitter wins big on the full-plane + * clear/fill/copy paths and runs in parallel with the CPU. A + * surface other than the stage (whose shadow the present blits + * from) goes anywhere once chip RAM runs out, and pd->chip sends + * its fills and copies to the CPU, as on a machine without a + * blitter, rather than failing while fast RAM is free. */ + pd->chip = surfacePlanesAlloc(pd, MEMF_CHIP); + if (!pd->chip && (isStage || !surfacePlanesAlloc(pd, MEMF_PUBLIC))) { + FreeMem(pd, (ULONG)sizeof(AmigaPlanarT)); + return NULL; } pd->ownsPlanes = true; return pd; diff --git a/src/amiga/save.c b/src/amiga/save.c index 2dbe9f3..5a26786 100644 --- a/src/amiga/save.c +++ b/src/amiga/save.c @@ -2,25 +2,32 @@ // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // -// Amiga save-file + disk-space HAL. +// Amiga save-file + disk-space HAL, and the file roots. // -// SAVES/ is a real directory in the current directory (the program's own, -// as Workbench starts it), made with dos.library CreateDir; delete is -// DeleteFile; free space is Info() on a lock to the current directory, so it -// measures the volume SAVES/ is written to however the program was started. -// All ROM-backed, so no libc filesystem dependency. +// The program's folder (fileRoot.h) is PROGDIR:, the assign dos.library +// gives every program for the folder it was loaded from (Kickstart 2.0 and +// later; on 1.3, and for a program started without a folder, the current +// directory). SAVES/ is a real directory there, +// made with dos.library CreateDir; delete is DeleteFile, rename Rename +// (libnix has no rename); free space is Info() on a lock to that folder, +// so it measures the volume SAVES/ is written to however the program was +// started. All ROM-backed, so no libc filesystem dependency. #include #include +#include #include +#include #include "joey/file.h" +#include "fileRoot.h" #include "port.h" -#define AMIGA_MAX_U32 0xFFFFFFFFu -#define CURRENT_DIR "" // Lock's name for the current directory +#define AMIGA_MAX_U32 0xFFFFFFFFu +#define PROGDIR_VERSION 36u // dos.library that knows PROGDIR: +#define PROGDIR_ROOT "PROGDIR:" // Info() takes the block as a BPTR, so it must be longword aligned: a stack @@ -34,7 +41,7 @@ uint32_t jlpDiskFree(void) { uint32_t bytesPerBlock; bool ok; - lock = Lock((CONST_STRPTR)CURRENT_DIR, SHARED_LOCK); + lock = Lock((CONST_STRPTR)jlpWriteRoot(), SHARED_LOCK); if (lock == 0) { return 0u; } @@ -54,6 +61,18 @@ uint32_t jlpDiskFree(void) { } +// PROGDIR: where dos.library has it and the program has a folder, else "" +// (the current directory). A process started without one -- CreateProc, as +// Workbench 1.3 and some launchers start a tool -- has no PROGDIR:, and DOS +// would ask for a volume of that name and wait. +const char *jlpReadRoot(void) { + if (((struct Library *)DOSBase)->lib_Version < PROGDIR_VERSION) { + return ""; + } + return ((struct Process *)FindTask(NULL))->pr_HomeDir != 0 ? PROGDIR_ROOT : ""; +} + + bool jlpSaveDelete(const char *path) { return DeleteFile((CONST_STRPTR)path) != 0; } @@ -75,3 +94,13 @@ bool jlpSaveDirEnsure(const char *dir) { } return false; } + + +bool jlpSaveRename(const char *from, const char *to) { + return Rename((CONST_STRPTR)from, (CONST_STRPTR)to) != 0; +} + + +const char *jlpWriteRoot(void) { + return jlpReadRoot(); +} diff --git a/src/atarist/audio.c b/src/atarist/audio.c index 50d4b3a..65fd48f 100644 --- a/src/atarist/audio.c +++ b/src/atarist/audio.c @@ -9,23 +9,27 @@ // * Timer A is NOT started at init: the ISR costs ~40% of the CPU at // 12.3 kHz, so the pump is demand-driven -- installed on the first // PlayMod/PlaySfx (MFP prescaler 200, data 1) and uninstalled once -// the mix buffer has drained back to pure silence with nothing -// playing. +// the mix buffer has held pure silence, with nothing playing, for +// PWM_IDLE_MS, after a ramp down to level 0 (and it starts with one +// up to silence), so neither end clicks. // * jlpAudioPlayMod starts the module player at the same 12288 Hz mono // unsigned-8-bit output that the ISR consumes one byte at a time. -// * Each Timer A tick the ISR pops one byte off the buffer, writes -// its high 4 bits to YM register 8 (channel A volume), advances -// the play pointer, raises a refill flag at half/end. The refill -// itself happens in jlpAudioFrameTick on the main thread: the mix is -// too long for an interrupt. +// * Each Timer A tick the ISR pops one byte off the buffer, writes its +// YM level (gYmLevel: the YM's sixteen levels are logarithmic, so each +// sample plays as the level nearest it on a straight line) to YM +// register 8 (channel A volume), and advances the play pointer; the +// frame tick sees which half it has left. The refill itself happens in +// jlpAudioFrameTick on the main thread: the mix is too long for an +// interrupt. // -// Quality budget: 4-bit unsigned PWM at 12.3 kHz on the YM is grim by -// modern standards but period-correct for stock 520ST/1040ST without -// the STe DMA chip. +// Quality budget: sixteen logarithmic levels at 12.3 kHz on the YM is +// grim by modern standards but period-correct for stock 520ST/1040ST +// without the STe DMA chip. // -// The STe build (make ste, JOEYLIB_ATARI_STE) plays the same mix buffer -// through the STe's 8-bit DMA sound chip instead, mono at 12517 Hz, -// looping over the whole buffer with no interrupt at all. +// The STe build (make ste, JOEYLIB_ATARI_STE) plays the mix buffer, in +// left and right byte pairs, through the STe's 8-bit DMA sound chip +// instead, stereo at 12517 Hz, looping over the whole buffer with no +// interrupt at all. // jlpAudioFrameTick reads the chip's frame counter to see which half it // has finished, converts each refilled half to the chip's signed // samples, and channel A stays the PSG's: the chiptune keeps all three @@ -76,23 +80,32 @@ #define YM_MIX_NOISE_A 0x08u #define YM_MIX_NOISE_C 0x20u -#define MIX_BUFFER 2048 -#define MIX_HALF (MIX_BUFFER / 2) -// Bytes ahead of the play position where a new sound's start-now overlay -// begins (sfxStartNow), so the write always lands before the read. On the -// plain ST the PWM interrupt takes about half the CPU, and that mix runs -// slower than the interrupt plays (~10.5K samples a second against 12288, -// measured under Hatari): it starts ~42 ms ahead, twice what the play -// position gains on it over the longest overlay. The STe's DMA chip takes -// no CPU: ~5 ms is enough. +// The buffer is two halves of MIX_HALF_FRAMES frames: bytes on the plain +// ST, left and right byte pairs on the STe, whose DMA chip plays stereo. #ifdef JOEYLIB_ATARI_STE -#define SFX_START_MARGIN 64u +#define MIX_CHANNELS 2u #else -#define SFX_START_MARGIN 512u +#define MIX_CHANNELS 1u #endif +#define MIX_HALF_FRAMES 1024u +#define MIX_HALF (MIX_HALF_FRAMES * MIX_CHANNELS) +#define MIX_BUFFER (MIX_HALF * 2u) +// Frames ahead of the play position where a new sound's start-now overlay +// begins (sfxStartNow), so the write always lands before the read: ~5 ms. +// The overlay's mix (src/m68k/sfxMix.s) outruns the play position once +// started, at about twice the rate the plain ST's PWM interrupt plays even +// with the interrupt's share of the CPU gone, so the margin only covers the +// start. +#define SFX_START_MARGIN 64u // The module player and the SFX overlay mix unsigned samples centred on $80. #define MIX_SILENCE_UNSIGNED MOD_PLAYER_SILENCE_U8 +// The 8-bit mix's unit (joey/audio.h's mix): a module channel at full volume +// and a full-scale sound effect each swing half a byte (modPlayerSetU8Shift, +// audioSfxSetShift). On the STe a side's two channels fill it; the plain +// ST's mono sums four, which clip when three or four peak together. The +// voices come down to the same level (kYmVolume). +#define MIX_UNIT_SHIFT 1u #ifdef JOEYLIB_ATARI_STE // STe build: the output stage is the DMA chip, which plays signed @@ -120,7 +133,7 @@ #define STE_DMA_MODE ((volatile uint8_t *)0xFFFF8921L) #define STE_DMA_STOP 0x00u #define STE_DMA_PLAY_LOOP 0x03u // bit 0 play, bit 1 repeat -#define STE_DMA_MONO_12K 0x81u // bit 7 mono, rate 1 = 12517 Hz +#define STE_DMA_STEREO_12K 0x01u // bit 7 clear: stereo; rate 1 = 12517 Hz #else // ST build: the output stage is PWM through YM channel A's volume, one // sample per MFP Timer A interrupt. @@ -141,6 +154,27 @@ #define MIX_RATE 12288 // Hz, matches MFP 2.4576 MHz / 200 #define MIX_SILENCE MIX_SILENCE_UNSIGNED +// The YM's channel-A volume is the DAC, and its sixteen levels are +// logarithmic, about 3 dB apart: each sample is played as the level nearest +// its value on a straight line from level 0 to level 15 (gYmLevel), so the +// waveform keeps its shape and silence sits near the middle, level 13. The +// levels' amplitudes, as the YM2149 puts them out (ST-Sound's measured +// table). +#define YM_LEVELS 16u +#define SAMPLE_VALUES 256u +#define YM_SAMPLE_MAX 255u +static const uint16_t kYmAmplitude[YM_LEVELS] = { + 62u, 161u, 265u, 377u, 580u, 774u, 1155u, 1575u, 2260u, 3088u, 4570u, 6233u, 9330u, 12481u, 18663u, 25000u +}; +// So the step from channel A's level 0 to silence's level is no click, the +// pump starts with a ramp up to silence and, once everything has played, +// ends with one down to 0 before it stops (the drain): PWM_RAMP_SAMPLES, +// ~10 ms, from PWM_DRAIN_MARGIN samples ahead of the ISR. +#define PWM_RAMP_SAMPLES 128u +#define PWM_DRAIN_MARGIN 64u +// The drain waits for this long of silence, so sounds close together keep +// the level at silence between them rather than ramp down and up again. +#define PWM_IDLE_MS 200u #endif // jlAudioTickRegister rides TOS's 200 Hz Timer C (both builds: the plain @@ -152,6 +186,7 @@ // the interrupt, as the contract allows (joey/audio.h). #define VEC_MFP_TC (0x114 / 4) #define ST_TIMER_C_HZ 200u +#define SETEXC_READ (-1L) // Setexc's vector argument that only reads // ----- Module state ----- @@ -170,6 +205,16 @@ static uint8_t gPlayHalf = 0u; // The PWM ISR's play pointer and the buffer's end (audio_isr.s). volatile uint8_t *gMixPos = NULL; volatile uint8_t *gMixEnd = NULL; +// Each sample's YM level (kYmAmplitude), built at jlpAudioInit: the ISR +// plays gYmLevel[sample], from beside its own code (audio_isr.s). +extern uint8_t gYmLevel[SAMPLE_VALUES]; +// The drain (PWM_RAMP_SAMPLES): the ramp down is written, from +// PWM_DRAIN_MARGIN past gDrainFrom, where the ISR was; and the silence +// before it (PWM_IDLE_MS), since gIdleSinceMs. +static bool gDraining = false; +static uint16_t gDrainFrom = 0u; +static bool gIdle = false; +static uint32_t gIdleSinceMs = 0u; extern void mfpTimerAIsr(void); @@ -211,6 +256,9 @@ static uint8_t gModTailHalf = 0; // SFX overlay shared with the DOS HAL (see src/core/audioSfxMix.c). // Mixed in over the module's output during jlpAudioFrameTick. static AudioSfxSlotT gSfxSlots[JL_AUDIO_SFX_SLOTS]; +#ifdef JOEYLIB_ATARI_STE +static uint8_t gSfxPan[JL_AUDIO_SFX_SLOTS]; // jlAudioSetPan's +#endif // Single shadow of YM mixer reg 7's low 6 bits (tone+noise disables). // Both the PWM (installTimerA) and PSG-tone (applyYmVoice) paths @@ -238,11 +286,20 @@ static long applyYmVoice(void); static bool anySfxActive(void); static uint16_t playOffset(void); static void pollRefill(void); +static bool pumpStart(void); +static void pumpStop(void); static void refillHalf(uint8_t half); static void sfxOverlayAt(uint16_t at, uint16_t len, uint8_t slot); -static void sfxStartNow(uint8_t slot); +static void sfxStartNow(uint8_t slot, bool fresh); static void silenceMixBuffer(void); -static void tickUnhook(void); +static void tickHookUpdate(void); +#ifndef JOEYLIB_ATARI_STE +static void drainCancel(void); +static bool drainDone(void); +static void drainStart(void); +static void rampWrite(uint16_t at, uint8_t from, uint8_t to); +static void ymLevelsBuild(void); +#endif #ifdef JOEYLIB_ATARI_STE static uint16_t dmaPlayOffset(void); static void dmaSetFrame(volatile uint8_t *hi, volatile uint8_t *mid, volatile uint8_t *lo, uint32_t addr); @@ -285,11 +342,14 @@ static long installTimerA(void) { // Park the buffer in known-silent state (unsigned 8-bit middle // = 128) before opening the IRQ gate. silenceMixBuffer(); + rampWrite(0u, 0u, MIX_SILENCE); gMixPos = (volatile uint8_t *)gMixBuf; gMixEnd = (volatile uint8_t *)(gMixBuf + MIX_BUFFER); gNeedRefill[0] = 0; gNeedRefill[1] = 0; gPlayHalf = 0u; + gDraining = false; + gIdle = false; // YM2149 setup for PWM-via-volume on channel A: // reg 7 (mixer): set bits 0 (tone A off) and 3 (noise A off); @@ -413,7 +473,7 @@ static long installDma(void) { gPlayHalf = 0u; dmaSetFrame(STE_DMA_START_HI, STE_DMA_START_MID, STE_DMA_START_LO, (uint32_t)gMixBuf); dmaSetFrame(STE_DMA_END_HI, STE_DMA_END_MID, STE_DMA_END_LO, (uint32_t)(gMixBuf + MIX_BUFFER)); - *STE_DMA_MODE = STE_DMA_MONO_12K; + *STE_DMA_MODE = STE_DMA_STEREO_12K; *STE_DMA_CTRL = STE_DMA_PLAY_LOOP; return 0; } @@ -466,14 +526,18 @@ static void refillHalf(uint8_t half) { dst = gMixBuf + (uint16_t)half * MIX_HALF; silentFill = !modPlayerActive() && !anySfxActive(); if (modPlayerActive()) { - if (!modPlayerRenderU8((uint8_t *)dst, MIX_HALF)) { + if (!modPlayerRenderU8((uint8_t *)dst, MIX_HALF_FRAMES, MIX_CHANNELS)) { gModTail = true; gModTailHalf = half; } } else { memset((void *)dst, MIX_SILENCE_UNSIGNED, MIX_HALF); } +#ifdef JOEYLIB_ATARI_STE + audioSfxOverlayMixStereo(dst, MIX_HALF_FRAMES, gSfxSlots, JL_AUDIO_SFX_SLOTS, gSfxPan, AUDIO_SFX_DST_UNSIGNED); +#else audioSfxOverlayMix(dst, MIX_HALF, gSfxSlots, JL_AUDIO_SFX_SLOTS); +#endif #ifdef JOEYLIB_ATARI_STE dmaSignBytes(dst, MIX_HALF); #endif @@ -482,15 +546,15 @@ static void refillHalf(uint8_t half) { } -// Mix a slot over bytes of the buffer that are mixed already, in the -// output's own format: the STe's are signed, so flipped for the mix and back. +// Mix a slot over len bytes of the buffer from at that are mixed already, +// in the output's own format: the STe's are signed stereo pairs, the slot +// on its pan's side. The chip may reach them while this runs, so they are +// mixed as they stand, in order, never flipped to unsigned and back. static void sfxOverlayAt(uint16_t at, uint16_t len, uint8_t slot) { #ifdef JOEYLIB_ATARI_STE - dmaSignBytes(gMixBuf + at, len); -#endif + audioSfxOverlayMixStereo(gMixBuf + at, (uint16_t)(len / MIX_CHANNELS), &gSfxSlots[slot], 1u, &gSfxPan[slot], AUDIO_SFX_DST_SIGNED); +#else audioSfxOverlayMix(gMixBuf + at, len, &gSfxSlots[slot], 1u); -#ifdef JOEYLIB_ATARI_STE - dmaSignBytes(gMixBuf + at, len); #endif } @@ -499,32 +563,43 @@ static void sfxOverlayAt(uint16_t at, uint16_t len, uint8_t slot) { // the play position are mixed already, so on its own a sound waited for // the next refill: up to two halves, ~80-170 ms. So its first samples go on // top of what is mixed ahead -- the rest of the half playing and, if it has -// been refilled already, the half after it -- and the refills carry on from -// where that leaves the slot. -static void sfxStartNow(uint8_t slot) { +// been refilled already, the half after it -- from SFX_START_MARGIN past the +// play position, which is in the next half when the play position is that +// near its half's end; and the refills carry on from where that leaves the +// slot. +static void sfxStartNow(uint8_t slot, bool fresh) { uint16_t pos; uint16_t start; uint16_t halfEnd; + uint16_t aheadEnd; uint8_t half; - bool nextReady; - pos = playOffset(); + // A pump just started plays from the buffer's start; the STe's frame + // counter may still say where the chip stopped last time until its new + // frame begins. + pos = fresh ? 0u : playOffset(); if (pos >= MIX_BUFFER) { return; } half = (uint8_t)(pos >= MIX_HALF); // The play position may have moved on since the frame tick last looked, // leaving a half behind that waits for its refill: that refill places - // the sound there. - nextReady = half == gPlayHalf; - start = (uint16_t)(pos + SFX_START_MARGIN); - halfEnd = (uint16_t)((half + 1u) * MIX_HALF); + // the sound there. Offsets here run on past the buffer's end into the + // other half, unwrapped. + halfEnd = (uint16_t)((half + 1u) * MIX_HALF); + aheadEnd = (uint16_t)(halfEnd + (half == gPlayHalf ? MIX_HALF : 0u)); + // A whole frame, so the sides keep their places. + start = (uint16_t)((pos / MIX_CHANNELS + SFX_START_MARGIN) * MIX_CHANNELS); + if (start >= aheadEnd) { + return; + } if (start < halfEnd) { sfxOverlayAt(start, (uint16_t)(halfEnd - start), slot); gHalfSilent[half] = false; + start = halfEnd; } - if (nextReady) { - sfxOverlayAt((uint16_t)((half ^ 1u) * MIX_HALF), MIX_HALF, slot); + if (start < aheadEnd) { + sfxOverlayAt((uint16_t)((half ^ 1u) * MIX_HALF + (start - halfEnd)), (uint16_t)(aheadEnd - start), slot); gHalfSilent[half ^ 1u] = false; } } @@ -541,10 +616,96 @@ static bool anySfxActive(void) { } -static void pumpStart(void) { - if (gPumpRunning) { +#ifndef JOEYLIB_ATARI_STE +// A sound starts while the drain is under way: the ramp down and the zeros +// after it, ahead of the ISR, become a ramp back up to silence and silence, +// the baseline the start-now overlay mixes onto. +static void drainCancel(void) { + uint16_t at; + uint16_t i; + + if (!gDraining) { return; } + gDraining = false; + at = (uint16_t)((playOffset() + PWM_DRAIN_MARGIN) % MIX_BUFFER); + for (i = 0u; i < MIX_BUFFER - 2u * PWM_DRAIN_MARGIN; i++) { + gMixBuf[(at + i) % MIX_BUFFER] = MIX_SILENCE; + } + rampWrite(at, 0u, MIX_SILENCE); + gHalfSilent[0] = false; + gHalfSilent[1] = false; +} + + +// The ISR has played the ramp down and is into the zeros after it: as far +// from where it was as the ramp's end, and not back round to it. +static bool drainDone(void) { + return (uint16_t)((playOffset() + MIX_BUFFER - gDrainFrom) % MIX_BUFFER) >= PWM_DRAIN_MARGIN + PWM_RAMP_SAMPLES; +} + + +// Everything has played: from just ahead of the ISR, a ramp down to level +// 0, then zeros round the buffer to just behind it; the pump stops once +// the ISR is past the ramp (drainDone). +static void drainStart(void) { + uint16_t at; + uint16_t i; + + gDrainFrom = playOffset(); + at = (uint16_t)((gDrainFrom + PWM_DRAIN_MARGIN) % MIX_BUFFER); + for (i = 0u; i < MIX_BUFFER - 2u * PWM_DRAIN_MARGIN; i++) { + gMixBuf[(at + i) % MIX_BUFFER] = 0u; + } + rampWrite(at, MIX_SILENCE, 0u); + gDraining = true; +} + + +// PWM_RAMP_SAMPLES of a straight line from sample value from to to, into +// the buffer from at on (round its end). +static void rampWrite(uint16_t at, uint8_t from, uint8_t to) { + uint16_t i; + + for (i = 0u; i < PWM_RAMP_SAMPLES; i++) { + gMixBuf[(at + i) % MIX_BUFFER] = (uint8_t)((int16_t)from + ((int16_t)to - (int16_t)from) * (int16_t)i / (int16_t)PWM_RAMP_SAMPLES); + } +} + + +// gYmLevel: for each sample value, the level whose amplitude is nearest +// the value's place on the line from level 0's amplitude to level 15's. +static void ymLevelsBuild(void) { + uint32_t target; + uint32_t gap; + uint32_t best; + uint16_t s; + uint8_t v; + + for (s = 0u; s < SAMPLE_VALUES; s++) { + target = kYmAmplitude[0] + ((uint32_t)(kYmAmplitude[YM_LEVELS - 1u] - kYmAmplitude[0]) * s) / YM_SAMPLE_MAX; + best = UINT32_MAX; + gYmLevel[s] = 0u; + for (v = 0u; v < YM_LEVELS; v++) { + gap = kYmAmplitude[v] > target ? kYmAmplitude[v] - target : target - kYmAmplitude[v]; + if (gap < best) { + best = gap; + gYmLevel[s] = v; + } + } + } +} +#endif + + +// True when the pump was idle: it now plays from the buffer's start. +static bool pumpStart(void) { + if (gPumpRunning) { +#ifndef JOEYLIB_ATARI_STE + drainCancel(); +#endif + return false; + } #ifdef JOEYLIB_ATARI_STE Supexec(installDma); #else @@ -553,6 +714,8 @@ static void pumpStart(void) { gPumpRunning = true; gHalfSilent[0] = false; gHalfSilent[1] = false; + tickHookUpdate(); + return true; } @@ -571,6 +734,7 @@ static void pumpStop(void) { #else Supexec(uninstallTimerA); #endif + tickHookUpdate(); } @@ -585,19 +749,37 @@ bool jlpAudioInit(void) { if (!steMachineCheck()) { return false; } +#else + ymLevelsBuild(); #endif +#ifdef JOEYLIB_ATARI_STE + memset(gSfxPan, JL_AUDIO_PAN_CENTER, sizeof(gSfxPan)); +#endif + modPlayerSetU8Shift(MIX_UNIT_SHIFT); + audioSfxSetShift(MIX_UNIT_SHIFT); gReady = true; return true; } +#ifdef JOEYLIB_ATARI_STE +// A slot's side, for the sound it plays and the next (the DMA chip plays +// stereo; the plain ST's one DAC cannot pan). +void jlpAudioSetPan(uint8_t slot, uint8_t pan) { + if (slot < JL_AUDIO_SFX_SLOTS && pan < JL_AUDIO_PAN_COUNT) { + gSfxPan[slot] = pan; + } +} +#endif + + void jlpAudioShutdown(void) { // Pump first, then the PSG: stopping the pump hands channel A back and // replays voice 0, so the all-off has to come after it or a replayed note // would outlive the program. (The all-off still runs independent of // gReady: the tone/noise voice layer is usable without jlpAudioInit, and // the YM latches -- a live voice keeps sounding after exit.) - tickUnhook(); + (void)jlpAudioTickRegister(NULL, 0u); if (gReady) { pumpStop(); } @@ -625,7 +807,7 @@ void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { if (!modPlayerStart(data, length, MIX_RATE, loop)) { return; } - pumpStart(); + (void)pumpStart(); } @@ -634,8 +816,7 @@ void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint1 return; } audioSfxSlotArm(&gSfxSlots[slot], sample, length, rateHz, MIX_RATE); - pumpStart(); - sfxStartNow(slot); + sfxStartNow(slot, pumpStart()); } @@ -644,8 +825,7 @@ void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uin return; } audioSfxSlotArmStream(&gSfxSlots[slot], fill, ctx, rateHz, MIX_RATE); - pumpStart(); - sfxStartNow(slot); + sfxStartNow(slot, pumpStart()); } @@ -661,8 +841,12 @@ void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uin // Volume mapping: attenuation is 2 dB a step on every port (joey/audio.h), // and the YM's sixteen levels are about 3.5 dB apart (measured: 0, 3.5, // 7.4, 10.4, 13.8, 16.5, 19.9, 22.7, 26.0, 28.8, 32.3, 35.3, 39.1, 42.8 and -// 49.4 dB below level 15), so each attenuation takes the nearest level: -// within 1.6 dB of the asked one, the closest the chip can come. YM also +// 49.4 dB below level 15), so each attenuation takes the level nearest 2 dB +// a step below attenuation 0's. That top level is where the mix puts a +// voice, as loud as a full-scale effect at MIX_UNIT_SHIFT: the plain ST's +// level 13 (its PWM plays the digital sound through a channel like these), +// within 1.4 dB a step; the STe's level 14 (its DMA sound is louder than a +// YM channel), within 1.9 dB, half the 3.9 dB from level 14 to 13. YM also // accepts bit 4 = envelope-controlled volume; we never set it. // // YM regs are supervisor-only on the ST, so this is a Supexec block. @@ -673,9 +857,15 @@ void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uin #define YM_TONE_HZ 125000u // 2 MHz / 16: Hz = this / period #define YM_PERIOD_MAX 0x0FFFu +#ifdef JOEYLIB_ATARI_STE static const uint8_t kYmVolume[15] = { - 15u, 14u, 14u, 13u, 13u, 12u, 12u, 11u, 10u, 10u, 9u, 8u, 8u, 7u, 6u + 14u, 13u, 13u, 12u, 12u, 11u, 10u, 10u, 9u, 8u, 8u, 7u, 6u, 6u, 5u }; +#else +static const uint8_t kYmVolume[15] = { + 13u, 12u, 12u, 11u, 10u, 10u, 9u, 8u, 8u, 7u, 7u, 6u, 5u, 5u, 4u +}; +#endif static long applyYmVoice(void) { @@ -776,29 +966,45 @@ static long ymPsgAllOff(void) { // The tick rides TOS's 200 Hz Timer C (stTickIsr chains to TOS's handler), -// counting the rate against it: see the Timer C notes at the top. +// counting the rate against it: see the Timer C notes at the top. The hook +// may stay in place for the pump (tickHookUpdate), so the tick is switched +// off while its fields change -- at rate 0 stTickRun's carry never comes +// due -- and its rate goes in last. Each store is one instruction, so this +// needs no interrupts held off, and runs before jlInit's supervisor mode. bool jlpAudioTickRegister(jlAudioTickFnT fn, uint16_t hz) { - tickUnhook(); - if (fn == NULL) { - return true; + gStTickHz = 0u; + gStTickCarry = 0u; + gStTickFn = fn; + if (fn != NULL) { + gStTickHz = hz; } - gStTickFn = fn; - gStTickHz = hz; - gStTickCarry = 0u; - gStTickOldVec = (void (*)(void))Setexc(VEC_MFP_TC, (long)stTickIsr); - gStTickHooked = true; + tickHookUpdate(); return true; } -// TOS's Timer C vector back, if it was taken. -static void tickUnhook(void) { - if (!gStTickHooked) { +// The Timer C hook in place while something needs it: the program's tick, +// or the plain ST's PWM pump, whose Timer A the hook lets in during TOS's +// handler (tickIsr.s). TOS's vector is read before the hook goes in, so an +// interrupt between the two never chains to a vector not yet saved. +static void tickHookUpdate(void) { + bool want; + +#ifdef JOEYLIB_ATARI_STE + want = gStTickFn != NULL; +#else + want = gStTickFn != NULL || gPumpRunning; +#endif + if (want == gStTickHooked) { return; } - (void)Setexc(VEC_MFP_TC, (long)gStTickOldVec); - gStTickHooked = false; - gStTickFn = NULL; + if (want) { + gStTickOldVec = (void (*)(void))Setexc(VEC_MFP_TC, SETEXC_READ); + (void)Setexc(VEC_MFP_TC, (long)stTickIsr); + } else { + (void)Setexc(VEC_MFP_TC, (long)gStTickOldVec); + } + gStTickHooked = want; } @@ -904,6 +1110,15 @@ void jlpAudioFrameTick(void) { if (!gReady || !gPumpRunning) { return; } +#ifndef JOEYLIB_ATARI_STE + // The drain owns the buffer until the ISR is past its ramp. + if (gDraining) { + if (drainDone()) { + pumpStop(); + } + return; + } +#endif pollRefill(); // An ended song's last half, flagged for refill, has played. if (gModTail && gNeedRefill[gModTailHalf]) { @@ -916,8 +1131,21 @@ void jlpAudioFrameTick(void) { refillHalf(1u); } // Drain-to-silence stop: both halves hold pure silence and nothing - // is playing, so the ISR would stream silence forever -- park it. + // is playing, so the ISR would stream silence forever -- park it (on + // the plain ST once PWM_IDLE_MS of it have gone by, after a ramp down + // to level 0: drainStart). if (!modPlayerActive() && gHalfSilent[0] && gHalfSilent[1] && !anySfxActive()) { +#ifdef JOEYLIB_ATARI_STE pumpStop(); +#else + if (!gIdle) { + gIdle = true; + gIdleSinceMs = jlpMillisElapsed(); + } else if (jlpMillisElapsed() - gIdleSinceMs >= PWM_IDLE_MS) { + drainStart(); + } + } else { + gIdle = false; +#endif } } diff --git a/src/atarist/audio_isr.s b/src/atarist/audio_isr.s index cca1c43..c5f9532 100644 --- a/src/atarist/audio_isr.s +++ b/src/atarist/audio_isr.s @@ -7,21 +7,23 @@ | | Each interrupt: | 1. Reads one byte from the mix buffer (unsigned 8-bit). -| 2. Writes its high 4 bits to YM register 8 (channel A volume) so -| the analog filter on the YM output emits a 4-bit-resolution -| sample for that tick. +| 2. Writes its YM level (_gYmLevel[byte], the level nearest the +| sample's value: the YM's levels are logarithmic) to YM register 8 +| (channel A volume), so the analog filter on the YM output emits +| that sample for that tick. | 3. Advances the play pointer, wrapping at the end of the buffer. | 4. Acks the MFP Timer-A bit in ISRA and RTEs. | The frame tick reads the play pointer to see which half has played, as | the STe build reads its DMA chip's, so the ISR raises no refill flags. | At 12.3 kHz every cycle here is ~1.2% of the 68000: the chip registers -| are reached by short addresses, and the high nibble needs no mask (the -| YM takes the byte's low 4 bits). +| are reached by short addresses. | | Communication with the C HAL is via three globals defined in audio.c: | _gMixBuf -- start of the mix buffer (uint8_t array) | _gMixPos -- volatile pointer to the next sample to play | _gMixEnd -- end of buffer (used to detect wrap) +| and _gYmLevel, each sample value's YM level (256 bytes), defined here +| and filled by audio.c. | | GAS-syntax (binutils m68k); assembled by m68k-atari-mint-as via the | gcc driver. @@ -34,13 +36,13 @@ .equ YM_VOL_A, 8 .equ MFP_ISRA, 0xFFFFFA0F .equ ISRA_ACK, 0xDF | clear Timer A's in-service bit - .equ NIBBLE, 4 _mfpTimerAIsr: movem.l %d0/%a0,-(%sp) movea.l _gMixPos,%a0 + moveq #0,%d0 move.b (%a0)+,%d0 - lsr.b #NIBBLE,%d0 + move.b (_gYmLevel,%pc,%d0.w),%d0 move.b #YM_VOL_A,(YM_SELECT).w move.b %d0,(YM_DATA).w cmpa.l _gMixEnd,%a0 @@ -51,3 +53,11 @@ _mfpTimerAIsr: move.b #ISRA_ACK,(MFP_ISRA).w movem.l (%sp)+,%d0/%a0 rte + +| Each sample value's YM level, which audio.c fills at jlpAudioInit. Here, +| right after the code that reads it, so the read is PC-relative: a table +| anywhere else would cost an address register (saved and restored) and +| its load every sample, and on the plain ST the mix already runs about as +| fast as the interrupt plays it. TOS does not protect a program's text. + .globl _gYmLevel +_gYmLevel: .space 256 diff --git a/src/atarist/hal.c b/src/atarist/hal.c index 184dd48..30b598b 100644 --- a/src/atarist/hal.c +++ b/src/atarist/hal.c @@ -14,15 +14,17 @@ // for a new palette region. For pattern.c's 8 uniform bands this // is 8 entries; in the worst case it is 200 (one per scanline). // * VBL ISR pre-loads the first band's palette, then programs -// MFP Timer B (event-count mode, TBDR = HBL delta to first -// transition) to fire at the END of the last scanline before -// the next band starts. -// * Timer B ISR writes the current band's palette, advances the -// transition index, and (stop/reload TBDR/restart) reprograms -// Timer B to fire at the next transition. With 8 transitions per -// frame the ISR runs 8 times instead of 313 -- well under the -// ~147-HBL-fires-per-frame cap Hatari's MFP emulation imposes on -// event-count mode, and ~0.2% CPU overhead vs ~60% for per-HBL. +// MFP Timer B (event-count mode) to fire at the end of the display +// of the line TWO above the next band (fireDelta). +// * Timer B's handler (rasterIsr.s) loads the band's palette into +// registers, waits for the video address counter to reach the end +// of the next line's display, and stores all 16 colours in the +// horizontal blank before the band, so its first line shows them +// from its first pixel; rasterBandNext then aims it at the next +// band. With 8 transitions per frame it runs 8 times instead of 313 +// -- well under the ~147-HBL-fires-per-frame cap Hatari's MFP +// emulation imposes on event-count mode -- and waits about a line +// each time, ~3% of the CPU. // * A table set (ScreenTablesT) holds each of the 16 palettes already // in shifter format and the band table; jlpPresent requantizes only // the palettes jlPaletteSet changed (gStagePaletteDirtyMask), and the @@ -137,12 +139,22 @@ typedef char stPresentSlamGeometryFits[(JL_SURFACE_HEIGHT == 200 && ST_BYTES_PER // to zero). Both bytes are odd-addressed byte registers; byte access // to an odd address is legal on 68000 (only word/long faults). #define ST_VID_BASE_HI ((volatile uint8_t *)0xFFFF8201L) // bits 23..16 +#define ST_VID_COUNT_MID ((volatile uint8_t *)0xFFFF8207L) // video address counter, bits 15..8 +#define ST_VID_COUNT_LOW ((volatile uint8_t *)0xFFFF8209L) // bits 7..0 #define ST_VID_BASE_MID ((volatile uint8_t *)0xFFFF8203L) // bits 15..8 // MFP hardware addresses. #define ST_MFP_TBCR ((volatile uint8_t *)0xFFFFFA1BL) // Timer B control #define ST_MFP_TBDR ((volatile uint8_t *)0xFFFFFA21L) // Timer B data #define ST_MFP_ISRA ((volatile uint8_t *)0xFFFFFA0FL) // In-service A +#define ST_MFP_IPRB ((volatile uint8_t *)0xFFFFFA0DL) // Interrupt pending B +#define ST_MFP_TCDR ((volatile uint8_t *)0xFFFFFA23L) // Timer C data: the live count +#define ST_MFP_TIMER_C 0x20u // Timer C's bit in IPRB +// TOS's 200 Hz system clock: MFP Timer C at 2.4576 MHz / 64, reloaded with +// 192, its ticks counted at $4BA (_hz_200). +#define ST_HZ200 ((volatile uint32_t *)0x4BAUL) +#define ST_TICK_RELOAD 192u +#define ST_TICK_MS 5u #define MFP_TBCR_STOP 0x00 #define MFP_TBCR_EVENT 0x08 #define MFP_TB_CLEAR 0xFE // clear bit 0 of ISRA (Timer B) @@ -280,16 +292,24 @@ static inline __attribute__((always_inline)) uint16_t stFloodStopBits(const uint // about 38 ms of 8 MHz 68000 per jlPaletteSet, which is what made Space // Taxi's palette-cycled warp drop frames on the ST); and the band table, // one entry per palette change down the screen: at display line -// bandStart[i], load palette bandPalIdx[i]. Built from the jlSurfaceT's -// palettes and SCB by jlpPresent. +// bandStart[i], load palette bandPalIdx[i]. Timer B fires for band i +// fireDelta[i] display lines after it fired for band i - 1 (for band 1, +// after the frame starts): at the end of the line two above it, or as +// soon after the last fire as it can (buildTransitions). Built from the +// jlSurfaceT's palettes and SCB by jlpPresent. #define MAX_BANDS JL_SURFACE_HEIGHT +// Timer B fires this many lines above a band: its handler then has a whole +// line to load the colours before the blank they go in (rasterIsr.s). +#define RASTER_LEAD_LINES 2 typedef struct { uint16_t palettes [JL_SURFACE_PALETTE_COUNT][JL_SURFACE_COLORS_PER_PALETTE]; uint16_t bandStart [MAX_BANDS]; uint8_t bandPalIdx[MAX_BANDS]; + uint8_t fireDelta [MAX_BANDS]; uint16_t bandCount; } ScreenTablesT; +static void flipWait(void); static void presentShow(const uint8_t *page); static uint16_t quantizeColorToSt(uint16_t orgb); static void quantizeDirtyPalettes(ScreenTablesT *tables, const jlSurfaceT *src); @@ -298,13 +318,15 @@ static ScreenTablesT *tablesToWrite(void); extern void stLeaveSuper(void *savedSsp); static long readFrameHz(void); -static long readHz200(void); +static long readClockMs(void); static long writePrevPaletteRegs(void); -static __attribute__((interrupt_handler)) void timerBIsr(void); static __attribute__((interrupt_handler)) void vblIsr(void); static void buildTransitions(ScreenTablesT *tables, const jlSurfaceT *src); +static void rasterAim(const ScreenTablesT *tables, uint16_t band); +void rasterBandNext(void); static void refreshPaletteStateIfNeeded(const jlSurfaceT *src); +extern void stRasterIsr(void); // ----- Module state ----- @@ -378,15 +400,27 @@ static volatile uint8_t gTablesShown = 0; static volatile uint8_t gTablesPending = TABLES_NONE; static uint8_t gTablesWriting = TABLES_NONE; -// Index of the band the Timer B ISR is currently scheduling TO. At -// VBL this is 0 (band 0 palette pre-loaded, Timer B scheduled to -// fire when it's time to transition to band 1). Each ISR fire writes -// the palette for gCurrentBand and advances to the next. +// Index of the band whose palette is on the shifter. At VBL this is 0 +// (band 0 palette pre-loaded, Timer B scheduled for band 1); each Timer B +// fire loads the next band's and advances it (rasterBandNext). static volatile uint16_t gCurrentBand = 0; +// What the Timer B handler (rasterIsr.s) loads next: the band's 16 colours, +// and the video address counter's low 16 bits when the display of the line +// above the band ends (rasterAim), from gRasterBase, the counter at the +// frame's start (the shown page's address). +const uint16_t *gStRasterColors = gTables[0].palettes[0]; +uint16_t gStRasterTarget = 0u; +static uint16_t gRasterBase = 0u; + // The VBL count (jlpFrameCount). static volatile int16_t gFrameCount = 0; +// A flip handed over (presentShow) that no VBL has shown yet: until then +// the shifter still shows the page before it, the one the next present +// would write (flipWait). The VBL ISR clears it. +static volatile uint8_t gFlipPending = 0u; + // Saved exception vectors for restore on shutdown. static void (*gOldVblVec)(void) = NULL; static void (*gOldTimerBVec)(void) = NULL; @@ -407,6 +441,9 @@ static void (*gOldTimerBVec)(void) = NULL; // bandPalIdx[i] is the palette index that band uses. static void buildTransitions(ScreenTablesT *tables, const jlSurfaceT *src) { uint16_t line; + uint16_t band; + int16_t fire; + int16_t lastFire; uint8_t idx; uint8_t prev; @@ -427,6 +464,28 @@ static void buildTransitions(ScreenTablesT *tables, const jlSurfaceT *src) { prev = idx; } } + // Timer B fires for band i at the end of display line bandStart[i] - 2 + // (RASTER_LEAD_LINES), the frame's line -1 being its start, or a line + // after the last fire when that is later: a band one line below the + // last has its colours by the line after it starts. + lastFire = -1; + for (band = 1; band < tables->bandCount; band++) { + fire = (int16_t)(tables->bandStart[band] - RASTER_LEAD_LINES); + if (fire <= lastFire) { + fire = (int16_t)(lastFire + 1); + } + tables->fireDelta[band] = (uint8_t)(fire - lastFire); + lastFire = fire; + } +} + + +// A present right after another, before the VBL that shows its flip, would +// write the page the shifter still shows: wait for that VBL first. A +// program that waits for the VBL between presents never waits here. +static void flipWait(void) { + while (gFlipPending != 0u) { + } } @@ -442,6 +501,7 @@ static void presentShow(const uint8_t *page) { sr = m68kInterruptsOff(); if (page != NULL) { setShifterVideoBase(page); + gFlipPending = 1u; } if (gTablesWriting != TABLES_NONE) { gTablesPending = gTablesWriting; @@ -521,93 +581,57 @@ static ScreenTablesT *tablesToWrite(void) { } -// Timer B interrupt handler. Fires once at each band transition; -// writes the band's palette to the shifter and lets Timer B's -// auto-reload keep counting for the next fire. We deliberately do -// NOT stop/reload/restart the timer here: that sequence would cost -// 1-2 HBL edges each fire, and those losses compound across 7+ -// transitions into a visible "last band short" drift. +// Aim the Timer B handler at band: its colours, and the counter's value when +// the display of the line above it ends. +static void rasterAim(const ScreenTablesT *tables, uint16_t band) { + gStRasterColors = tables->palettes[tables->bandPalIdx[band]]; + gStRasterTarget = (uint16_t)(gRasterBase + tables->bandStart[band] * ST_BYTES_PER_ROW); +} + + +// Timer B's work after its handler (rasterIsr.s) has loaded the next band's +// colours: advance to it, aim at the band after it, and end the interrupt. +// Timer B's auto-reload keeps counting for the next fire. We deliberately +// do NOT stop/reload/restart the timer here: that sequence would cost 1-2 +// HBL edges each fire, and those losses compound across 7+ transitions into +// a visible "last band short" drift. // -// MFP event-count auto-reload latches the CURRENT TBDR at each -// underflow, so a value written here does not take effect on the -// NEXT fire (that interval was already reloaded at the underflow -// that triggered this ISR) but on the fire AFTER next. To time -// variable-height bands correctly we therefore program one fire -// ahead: in the ISR that transitions to `band` we load TBDR with -// the band+1 -> band+2 delta (the interval that the band+1 -// underflow will reload and use to time the band+2 transition). -// Uniform bands (like pattern.c) have all deltas equal, so the -// one-ahead value is identical and they stay perfectly aligned. -static void timerBIsr(void) { +// MFP event-count auto-reload latches the CURRENT TBDR at each underflow, +// so a value written here does not take effect on the NEXT fire (that +// interval was already reloaded at the underflow that triggered this +// interrupt) but on the fire AFTER next: the band + 2 delta. +void rasterBandNext(void) { const ScreenTablesT *tables; uint16_t band; - const uint16_t *src; - volatile uint16_t *dst; - uint16_t nextDelta; tables = &gTables[gTablesShown]; - band = gCurrentBand + 1; + band = (uint16_t)(gCurrentBand + 1u); gCurrentBand = band; - - if (band < tables->bandCount) { - // buildTransitions already folded out-of-range SCB values to 0. - src = tables->palettes[tables->bandPalIdx[band]]; - dst = ST_PALETTE_REGS; - dst[ 0] = src[ 0]; - dst[ 1] = src[ 1]; - dst[ 2] = src[ 2]; - dst[ 3] = src[ 3]; - dst[ 4] = src[ 4]; - dst[ 5] = src[ 5]; - dst[ 6] = src[ 6]; - dst[ 7] = src[ 7]; - dst[ 8] = src[ 8]; - dst[ 9] = src[ 9]; - dst[10] = src[10]; - dst[11] = src[11]; - dst[12] = src[12]; - dst[13] = src[13]; - dst[14] = src[14]; - dst[15] = src[15]; - - if (band + 1 < tables->bandCount) { - // A transition to band+1 is still pending; keep Timer B - // running. The band+1 underflow has already had its reload - // value latched (set one fire earlier), so program TBDR - // now with the band+1 -> band+2 delta, which the band+1 - // underflow will reload and use to time band+2. Don't - // stop the timer. - if (band + 2 < tables->bandCount) { - nextDelta = tables->bandStart[band + 2] - tables->bandStart[band + 1]; - if (nextDelta == 0 || nextDelta > 255) { - nextDelta = 1; - } - *ST_MFP_TBDR = (uint8_t)nextDelta; - } - *ST_MFP_ISRA = MFP_TB_CLEAR; - return; + if (band + 1u < tables->bandCount) { + rasterAim(tables, (uint16_t)(band + 1u)); + if (band + 2u < tables->bandCount) { + *ST_MFP_TBDR = tables->fireDelta[band + 2u]; } + } else { + // No further transitions this frame. + *ST_MFP_TBCR = MFP_TBCR_STOP; } - - // No further transitions this frame; stopping Timer B here only - // affects the (never-used) next fire, not timing of any band - // we've already scheduled. - *ST_MFP_TBCR = MFP_TBCR_STOP; *ST_MFP_ISRA = MFP_TB_CLEAR; } // Vertical blank handler. Pre-loads band 0's palette so the first -// visible scanline is correct, then programs Timer B to fire at -// the HBL delta to the next band transition (if any). +// visible scanline is correct, then programs Timer B to fire for the next +// band transition (if any). The video address counter holds the shown +// page's address until the display starts: the band targets' base. static void vblIsr(void) { const ScreenTablesT *tables; - uint16_t delta; const uint16_t *src; volatile uint16_t *dst; gFrameCount = gFrameCount + 1; gCurrentBand = 0; + gFlipPending = 0u; // The table set the last present handed over shows from this frame, as // does the page it flipped to (presentShow). if (gTablesPending != TABLES_NONE) { @@ -642,26 +666,20 @@ static void vblIsr(void) { // Program Timer B for the next band transition. if (tables->bandCount > 1) { - delta = tables->bandStart[1] - tables->bandStart[0]; - if (delta == 0 || delta > 255) { - delta = 1; - } + gRasterBase = (uint16_t)((*ST_VID_COUNT_MID << 8) | *ST_VID_COUNT_LOW); + rasterAim(tables, 1u); *ST_MFP_TBCR = MFP_TBCR_STOP; - *ST_MFP_TBDR = (uint8_t)delta; + *ST_MFP_TBDR = tables->fireDelta[1]; *ST_MFP_ISRA = MFP_TB_CLEAR; *ST_MFP_TBCR = MFP_TBCR_EVENT; - // The restart above loads the counter from TBDR (= the band - // 0 -> 1 delta), so the first fire is correctly timed. Now - // pre-program TBDR with the band 1 -> 2 delta so the band-1 - // underflow auto-reloads with the right interval (one fire - // ahead, matching timerBIsr). Writing TBDR while counting - // only changes the next reload, not the in-flight count. + // The restart above loads the counter from TBDR (band 1's + // delta), so the first fire is correctly timed. Now pre-program + // TBDR with band 2's so the band-1 underflow auto-reloads with + // the right interval (one fire ahead, matching rasterBandNext). + // Writing TBDR while counting only changes the next reload, not + // the in-flight count. if (tables->bandCount > 2) { - delta = tables->bandStart[2] - tables->bandStart[1]; - if (delta == 0 || delta > 255) { - delta = 1; - } - *ST_MFP_TBDR = (uint8_t)delta; + *ST_MFP_TBDR = tables->fireDelta[2]; } } else { *ST_MFP_TBCR = MFP_TBCR_STOP; @@ -713,6 +731,14 @@ bool jlpInit(const jlConfigT *config) { } #endif + // A monochrome monitor (SM124) takes only the high resolution, which + // Getrez reports as the shifter's own mode number: the 16-colour + // 320x200 mode cannot be shown there. + if (Getrez() == ST_RES_MONO) { + coreSetError("a monochrome monitor cannot show the 16-colour 320x200 mode"); + return false; + } + // Enter supervisor mode for the program's lifetime. The shifter // video-base, palette, and MFP registers live in the supervisor-only // $FF8000..$FFFFFF I/O region; jlpPresent pokes the video-base bytes @@ -781,10 +807,11 @@ bool jlpInit(const jlConfigT *config) { gOldTimerBVec = (void (*)(void))Setexc(VEC_MFP_TB, -1L); (void)Setexc(VEC_VBL, (long)vblIsr); - // Program MFP Timer B: event-count (HBL) mode, initial TBDR=1 - // (a placeholder -- VBL ISR reprograms it for the first real - // transition per frame), vector=timerBIsr, then enable in IMRA. - Xbtimer(1, MFP_TBCR_EVENT, 1, timerBIsr); + // Install MFP Timer B's vector (stRasterIsr) stopped, TBDR=1 a + // placeholder, then enable it in IMRA: the VBL ISR starts it in + // event-count (HBL) mode each frame with bands, once it has aimed the + // handler at the first (rasterAim). + Xbtimer(1, MFP_TBCR_STOP, 1, stRasterIsr); Jenabint(INT_TIMER_B); return true; @@ -873,6 +900,7 @@ void jlpPresent(const jlSurfaceT *src) { backIdx = (uint8_t)(gFrontIdx ^ 1u); backPage = gScreenPage[backIdx]; + flipWait(); slam = present68kStSlam(pd->base, backPage); copied = (uint16_t)slam; @@ -948,6 +976,32 @@ uint16_t jlpFrameCount(void) { } +// Milliseconds on TOS's 200 Hz clock: the ticks counted at $4BA, plus how +// far Timer C's live count has run down since its last reload, so the clock +// steps every 26 us, not every 5 ms. Read again if a tick is counted between +// the reads; a reload whose interrupt is still pending (IPRB) belongs to +// this reading only if the count read is the reloaded one, near the top. +// The MFP and $4BA need supervisor mode: the program holds it for its whole +// lifetime (jlpInit's Super(0L)), so the hot path reads them directly; the +// Supexec trap is only the pre-init / post-shutdown fallback. The core +// counts from jlInit (it subtracts the reading jlInit took). +static long readClockMs(void) { + uint32_t ticks; + uint8_t count; + bool pending; + + do { + ticks = *ST_HZ200; + count = *ST_MFP_TCDR; + pending = (*ST_MFP_IPRB & ST_MFP_TIMER_C) != 0u; + } while (ticks != *ST_HZ200); + if (pending && count > ST_TICK_RELOAD / 2u) { + ticks++; + } + return (long)(ticks * ST_TICK_MS + (uint32_t)(ST_TICK_RELOAD - count) * ST_TICK_MS / ST_TICK_RELOAD); +} + + uint16_t jlpFrameHz(void) { if (gSuperEntered) { return (uint16_t)readFrameHz(); @@ -973,37 +1027,11 @@ static long readFrameHz(void) { } -// TOS maintains a 200 Hz long counter at $4BA, incremented by the -// Timer C VBL chain. The program holds supervisor mode for its whole -// lifetime (jlpInit Super(0L) -- same authority the $FFFF82xx reads in -// jlpFrameHz rely on), so the hot path reads $4BA directly; the -// Supexec trap (~100+ cyc GEMDOS round trip per call) is only the -// pre-init / post-shutdown fallback (rank 17). Resolution is 5 ms, -// plenty for sound scheduling at 60 Hz AGI ticks. -static volatile uint32_t gMillisBase200 = 0; -static volatile bool gMillisBaseSet = false; - -static long readHz200(void) { - return (long)*((volatile uint32_t *)0x4BAUL); -} - uint32_t jlpMillisElapsed(void) { - uint32_t ticks; if (gSuperEntered) { - ticks = (uint32_t)readHz200(); - } else { - ticks = (uint32_t)Supexec(readHz200); + return (uint32_t)readClockMs(); } - // 0 is a legal counter value (power-on tick 0 and every ~248-day - // wrap), so use an explicit one-shot flag instead of a value-0 - // sentinel to capture the base. - if (!gMillisBaseSet) { - gMillisBase200 = ticks; - gMillisBaseSet = true; - return 0u; - } - // 200 Hz counter -> ms = ticks * 5. Unsigned subtraction is wrap-safe. - return (uint32_t)((ticks - gMillisBase200) * 5u); + return (uint32_t)Supexec(readClockMs); } diff --git a/src/atarist/input.c b/src/atarist/input.c index bd6f4d7..3c6cf75 100644 --- a/src/atarist/input.c +++ b/src/atarist/input.c @@ -32,9 +32,9 @@ // (caps-lock toggles on scan 0x3A); Ctrl chords are commands, not // text, and produce no character, and so are Alt ones but for the // characters a national keyboard's Alt layer types (a German one's -// @ [ ] { } \), where the OS keeps that layer in its Keytbl tables: -// TOS from 4.00, and EmuTOS. Older TOS keeps it inside the keyboard -// code JoeyLib takes over. The IKBD sends one make per press +// @ [ ] { } \): from the OS's Keytbl tables on TOS 4.00 and later and +// on EmuTOS, and from stAltLayer.c's copies of the layers older TOS +// keeps inside the keyboard code JoeyLib takes over. The IKBD sends one make per press // (TOS's software auto-repeat is gone with ikbdsys), so the poll repeats // a held key's character itself, at TOS's Kbrate settings. @@ -44,9 +44,9 @@ #include #include -#include "m68kIrq.h" #include "port.h" #include "inputInternal.h" +#include "stAltLayer.h" #include "joey/surface.h" #ifdef JOEYLIB_ATARI_STE @@ -86,6 +86,8 @@ #define TOS_ALT_LAYER_VERSION 0x0400u #define EMUTOS_MAGIC 0x45544F53uL #define EMUTOS_DUAL_KEYBOARD 0x0001u +// os_conf: bit 0 PAL, the rest the ROM's country. +#define TOS_CONF_COUNTRY_SHIFT 1u // Private ISR->poll ring for translated characters. Power of two so // the wrap is a mask; holds ISR_CHAR_QUEUE_SIZE - 1 pending bytes. @@ -154,9 +156,9 @@ typedef struct { // ----- Prototypes ----- static uint8_t altChar(const uint8_t *pairs, uint8_t code); +static void altLayerFind(const KeyTabT *keyTab); static void charRepeat(void); static long ikbdHandler(void); -static bool keyTabHasAlt(const KeyTabT *keyTab); #ifdef JOEYLIB_ATARI_STE static void padToStick(const StePadT *pad, jlJoystickE js); #endif @@ -248,9 +250,14 @@ static bool gHooked = false; // uint8_t (not bool) so element size matches gKeyState's. See // src/core/inputInternal.h for the full rationale. static volatile uint8_t gIsrState[JL_KEY_COUNT]; -// Keys pressed since the last poll, so a tap between two polls still reads -// down for one (inputLatch). -static volatile uint8_t gIsrTapped[JL_KEY_COUNT]; +// Each key's presses, counted by the ISR (which alone writes them), and the +// count the last poll saw: a key whose count moved was pressed since, so a +// tap between two polls still reads down for one (inputLatch's meaning). +// The poll writes nothing the ISR does, so it needs no interrupts held off: +// held off across the keys, the plain ST's 12288 Hz PWM pump lost five +// samples a poll, and digital sound played 1.4% flat. +static volatile uint8_t gIsrTaps[JL_KEY_COUNT]; +static uint8_t gTapsSeen[JL_KEY_COUNT]; // Mouse delta accumulator. Each ACIA mouse packet adds dx/dy here; the // poll routine clamps the running absolute position into the surface @@ -274,7 +281,7 @@ static const uint8_t *gKeyTabUnshift = NULL; static const uint8_t *gKeyTabShift = NULL; static const uint8_t *gKeyTabCaps = NULL; // Its Alt layer: (scan code, character) pairs ended by a 0, or NULL where -// the OS keeps none in the tables (keyTabHasAlt). +// the keyboard has none (altLayerFind). static const uint8_t *gKeyTabAltUnshift = NULL; static const uint8_t *gKeyTabAltShift = NULL; static const uint8_t *gKeyTabAltCaps = NULL; @@ -321,6 +328,32 @@ static uint8_t altChar(const uint8_t *pairs, uint8_t code) { } +// The Alt layer: Keytbl's own pair lists where its struct goes on with them +// (TOS_ALT_LAYER_VERSION), else the ones TOS before 4.00 keeps inside its +// keyboard code, by the ROM's country (stAltLayer.c). Read in supervisor +// mode (jlpInit's), as the OS header is. +static void altLayerFind(const KeyTabT *keyTab) { + const OSHEADER *os; + bool inTables; + + os = ((const OSHEADER *)*_sysbase)->os_beg; + if ((uint32_t)os->p_rsv2 == EMUTOS_MAGIC) { + // Unless its Alt tables are a second whole layout: then no layer. + inTables = (keyTab->features & EMUTOS_DUAL_KEYBOARD) == 0u; + } else if (os->os_version >= TOS_ALT_LAYER_VERSION) { + inTables = true; + } else { + (void)stAltLayerFind((uint8_t)(os->os_conf >> TOS_CONF_COUNTRY_SHIFT), &gKeyTabAltUnshift, &gKeyTabAltShift, &gKeyTabAltCaps); + return; + } + if (inTables) { + gKeyTabAltUnshift = keyTab->altUnshift; + gKeyTabAltShift = keyTab->altShift; + gKeyTabAltCaps = keyTab->altCaps; + } +} + + // The held key's character again, once TOS's delay has passed since it went // down and then at TOS's rate (the ISR names the key, gIsrRepeatCode). static void charRepeat(void) { @@ -445,7 +478,7 @@ static long ikbdHandler(void) { if (key != JL_KEY_NONE) { gIsrState[key] = inputKeyHeld(gIsrState[key], entry, !isBreak); if (!isBreak) { - gIsrTapped[key] = 1u; + gIsrTaps[key]++; } } @@ -486,19 +519,6 @@ static long ikbdHandler(void) { } -// Whether keyTab goes on with the Alt layer (TOS_ALT_LAYER_VERSION). Read in -// supervisor mode (jlpInit's), as the OS header is. -static bool keyTabHasAlt(const KeyTabT *keyTab) { - const OSHEADER *os; - - os = ((const OSHEADER *)*_sysbase)->os_beg; - if ((uint32_t)os->p_rsv2 == EMUTOS_MAGIC) { - return (keyTab->features & EMUTOS_DUAL_KEYBOARD) == 0u; - } - return os->os_version >= TOS_ALT_LAYER_VERSION; -} - - #ifdef JOEYLIB_ATARI_STE // A Jaguar pad as a stick: its directions, and its A and B as the two // buttons. @@ -572,7 +592,8 @@ void jlpInputInit(void) { memset(gKeyState, 0, sizeof(gKeyState)); memset(gKeyPrev, 0, sizeof(gKeyPrev)); memset((void *)gIsrState, 0, sizeof(gIsrState)); - memset((void *)gIsrTapped, 0, sizeof(gIsrTapped)); + memset((void *)gIsrTaps, 0, sizeof(gIsrTaps)); + memset(gTapsSeen, 0, sizeof(gTapsSeen)); memset((void *)gIsrJoyByte, 0, sizeof(gIsrJoyByte)); // Cache the TOS keyboard translation tables (the OS keymap) for @@ -583,11 +604,7 @@ void jlpInputInit(void) { gKeyTabUnshift = keyTab->unshift; gKeyTabShift = keyTab->shift; gKeyTabCaps = keyTab->caps; - if (keyTabHasAlt(keyTab)) { - gKeyTabAltUnshift = keyTab->altUnshift; - gKeyTabAltShift = keyTab->altShift; - gKeyTabAltCaps = keyTab->altCaps; - } + altLayerFind(keyTab); } // Seed caps lock from TOS: taking over ikbdsys freezes the OS's // own tracking, so an engaged caps lock at launch would otherwise @@ -638,14 +655,15 @@ void jlpInputPoll(void) { uint8_t btn; uint8_t joy; uint8_t tail; + uint8_t taps; uint16_t i; - uint16_t sr; - // Interrupts held off across the latch, so a press between its read of a - // key's tap and its clearing of it is not lost. - sr = m68kInterruptsOff(); - inputLatch(gKeyState, gIsrState, gIsrTapped, JL_KEY_COUNT); - m68kInterruptsRestore(sr); + // Down when held, or pressed since the last poll (gIsrTaps). + for (i = 0u; i < JL_KEY_COUNT; i++) { + taps = gIsrTaps[i]; + gKeyState[i] = (uint8_t)(gIsrState[i] | (taps != gTapsSeen[i])); + gTapsSeen[i] = taps; + } // Drain ISR-translated characters into the core queue. Single- // consumer side of the SPSC ring: only gIsrCharTail is written diff --git a/src/atarist/rasterIsr.s b/src/atarist/rasterIsr.s new file mode 100644 index 0000000..3c2c25c --- /dev/null +++ b/src/atarist/rasterIsr.s @@ -0,0 +1,68 @@ +| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +| See LICENSE.md for license information. +| SPDX-License-Identifier: MIT +| +| The ST family's Timer B band handler (hal.c has the rest). A band's 16 +| colours are 16 words, and the shifter shows a line's pixels from about +| cycle 56 to 376 of its 512 (508 at 60 Hz), so they must all land in the +| 190 or so cycles between two lines' displays. Taking the interrupt and +| copying 16 words takes longer than that, so Timer B fires a line early, +| at the end of the display of the line two above the band (hal.c, +| buildTransitions). This handler loads the colours (_gStRasterColors) into +| eight registers, waits for the video address counter to reach +| _gStRasterTarget -- its value at the end of the next line's display -- +| and stores all 16 with one movem.l, about 72 cycles. rasterBandNext (C) +| then sets up the next band and ends the interrupt. +| +| It waits with the interrupt mask at 5, so the plain ST's PWM pump (Timer +| A, above Timer B in the MFP) is not held off past a sample, and raises it +| to 6 for the last RASTER_NEAR bytes and the store, so nothing lands in +| between. A counter more than RASTER_FAR bytes short of the target (a +| frame whose start the VBL handler did not see) is not waited for: the +| colours go at once. +| +| GAS-syntax (binutils m68k); assembled by m68k-atari-mint-as via the +| gcc driver. + + .text + .globl _stRasterIsr + + .equ VIDEO_COUNTER_MID, 0xffff8207 | the counter's middle byte; its low byte is 2 on + .equ PALETTE, 0xffff8240 + .equ SR_ABOVE_TIMER_B, 0x2500 | supervisor, interrupt mask 5 + .equ SR_MFP_HELD, 0x2600 | supervisor, interrupt mask 6 + .equ RASTER_NEAR, 32 | bytes of display: about 64 cycles + .equ RASTER_FAR, 480 | bytes of display: three lines + +_stRasterIsr: + move.w #SR_ABOVE_TIMER_B,%sr + movem.l %d0-%d7/%a0-%a2,-(%sp) + move.l _gStRasterColors,%a0 + movem.l (%a0),%d1-%d7/%a2 + move.w _gStRasterTarget,%a0 + lea VIDEO_COUNTER_MID,%a1 + movep.w 0(%a1),%d0 + sub.w %a0,%d0 + cmp.w #-RASTER_FAR,%d0 + blt.s .Lstore + cmp.w #-RASTER_NEAR,%d0 + bge.s .Lnear +.Lfar: + movep.w 0(%a1),%d0 + sub.w %a0,%d0 + cmp.w #-RASTER_NEAR,%d0 + blt.s .Lfar +.Lnear: + move.w #SR_MFP_HELD,%sr + tst.w %d0 + bpl.s .Lstore +.Lclose: + movep.w 0(%a1),%d0 + sub.w %a0,%d0 + bmi.s .Lclose +.Lstore: + movem.l %d1-%d7/%a2,PALETTE + move.w #SR_ABOVE_TIMER_B,%sr + jsr _rasterBandNext + movem.l (%sp)+,%d0-%d7/%a0-%a2 + rte diff --git a/src/atarist/serial.c b/src/atarist/serial.c index 63ace0a..f63ec9d 100644 --- a/src/atarist/serial.c +++ b/src/atarist/serial.c @@ -6,7 +6,11 @@ // // The base ST's one RS-232 line is the Modem port, driven by the 68901 MFP // USART and wrapped by TOS as BIOS logical device 1 (AUX) with a receive- -// buffered IOREC. This HAL rides that: XBIOS Rsconf sets baud/flow/format, +// buffered IOREC. A Mega STe, TT, or Falcon has more (its SCC's two ports, +// the TT its second MFP's), and TOS 2 and EmuTOS map AUX onto any of them +// with XBIOS Bconmap: JL_SERIAL_SLOT unit n maps it to Bconmap device 5 + n +// while the port is open, and the close maps it back; every other device +// opens AUX as it is mapped. This HAL rides AUX: XBIOS Rsconf sets baud/flow/format, // Bconin/Bconout move bytes, Bconstat/Bcostat gate them. All are GEMDOS/BIOS/ // XBIOS traps that switch to supervisor internally, so -- unlike the direct // $FFFFFAxx pokes elsewhere in the ST HAL -- serial needs no supervisor @@ -27,6 +31,13 @@ #define ST_AUX_DEV 1 #define ST_IOREC_RS232 0 // Iorec's own numbering: 0 RS-232, 1 IKBD, 2 MIDI +// Bconmap's devices: the Modem port (the MFP's, whose DTR and RTS are the +// sound chip's port A lines) is the first. Bconmap(BMAP_CHECK) answers 0 +// where Bconmap exists (TOS hands back the opcode where it does not). +#define ST_BCONMAP_MODEM 6 +#define ST_BCONMAP_NONE 0 +#define ST_NO_SUCH_PORT "serial: no such port here (JL_SERIAL_SLOT unit)" + // 68901 MFP USART control register (UCR) bit fields, built by serialUcr: // bit 7 clock divide (1 = /16, standard async) // bits6-5 word length (00=8, 01=7, 10=6, 11=5) -> (8 - dataBits) @@ -58,6 +69,8 @@ #define ST_RX_BUFFER_BYTES 4096 #define ST_RX_LOW_WATER (ST_RX_BUFFER_BYTES / 4) #define ST_RX_HIGH_WATER (ST_RX_BUFFER_BYTES / 4 * 3) +// A write's backstop on a busy transmitter, in Bcostat polls (serialTxStalled): +// the clock bounds it first, once it runs. #define ST_TX_WAIT_POLLS 200000ul @@ -69,6 +82,12 @@ static int16_t gSavedBaudCode = ST_RSCONF_NO_CHANGE; static int16_t gSavedUcr = ST_RSCONF_NO_CHANGE; +// The Bconmap device AUX is mapped to while the port is open (the Modem port +// where TOS has no Bconmap), and the one a SLOT open mapped it away from +// (ST_BCONMAP_NONE: none), for the close to put back. +static int16_t gAuxDevice = ST_BCONMAP_MODEM; +static int16_t gSavedMap = ST_BCONMAP_NONE; + // TOS's receive record, and its own buffer settings while ours is in. static _IOREC *gRxIorec = NULL; static _IOREC gSavedRxIorec; @@ -78,6 +97,7 @@ static char gRxBuffer[ST_RX_BUFFER_BYTES]; // ----- Prototypes ----- +static bool serialAuxMap(jlSerialDeviceE device, uint8_t unit); static int16_t serialBaudCode(uint32_t baud); static int32_t serialRsconf(int16_t baud, int16_t flow, int16_t ucr); static long serialRxBufferInstall(void); @@ -87,6 +107,32 @@ static int16_t serialUcr(const jlSerialConfigT *cfg); // ----- Helpers ----- +// Point AUX at the port the open asked for: a SLOT unit maps it to Bconmap +// device 5 + unit, remembering the device it was on (gSavedMap); any other +// device leaves it where it is. Where TOS has no Bconmap, unit 1, the Modem +// port, is the only one. Sets gAuxDevice; false when there is no such port. +static bool serialAuxMap(jlSerialDeviceE device, uint8_t unit) { + const _BCONMAP *table; + bool mapped; + + mapped = Bconmap(BMAP_CHECK) == 0; + gAuxDevice = mapped ? (int16_t)Bconmap(BMAP_INQUIRE) : ST_BCONMAP_MODEM; + if (device != JL_SERIAL_SLOT) { + return true; + } + if (!mapped) { + return unit == 1u; + } + table = (const _BCONMAP *)Bconmap(BMAP_MAPTAB); + if (unit == 0u || unit > (uint16_t)table->maptabsize) { + return false; + } + gSavedMap = (int16_t)Bconmap((int16_t)(ST_BCONMAP_MODEM + unit - 1u)); + gAuxDevice = (int16_t)(ST_BCONMAP_MODEM + unit - 1u); + return true; +} + + // Rsconf's speed codes are the index here, 0 fastest to 15 slowest. A rate // the MFP does not have plays at the fastest it has below it (the slowest, // 50, for anything under that), as every port rounds: 19200 is its top. @@ -233,6 +279,13 @@ void jlpSerialClose(void) { (void)serialRsconf(gSavedBaudCode, ST_RSCONF_NO_CHANGE, gSavedUcr); gSavedBaudCode = ST_RSCONF_NO_CHANGE; gSavedUcr = ST_RSCONF_NO_CHANGE; + // AUX back on the port it was on before a SLOT open, its settings above + // put back first. + if (gSavedMap != ST_BCONMAP_NONE) { + (void)Bconmap(gSavedMap); + gSavedMap = ST_BCONMAP_NONE; + } + gAuxDevice = ST_BCONMAP_MODEM; } @@ -250,7 +303,10 @@ bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) { int32_t inquired; uint32_t previous; - (void)device; // the base ST has one RS-232 line: the Modem port. + if (!serialAuxMap(device, config->unit)) { + coreSetError(ST_NO_SUCH_PORT); + return false; + } switch (config->flow) { case JL_SERIAL_FLOW_RTSCTS: flow = 2; @@ -277,7 +333,7 @@ bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) { // Raise DTR, as the open does on every port: TOS only sets it at boot, so a line an earlier // jlSerialSetDtr(false) hung up would otherwise stay down. - Offgibit(ST_GI_DTR_ON_MASK); + jlpSerialSetDtr(true); // Snapshot the baud code BEFORE installing ours -- the inquire reports the // rate currently in force. Range-gate the answer: a TOS that did not @@ -323,8 +379,12 @@ uint16_t jlpSerialRead(uint8_t *buf, uint16_t max) { // DTR on demand (jlSerialSetDtr, how a program hangs up). Neither Rsconf nor anything else in EmuTOS // touches port A bit 4 after boot (Hatari psg_write + xbios trace), and close does not either, so a -// dropped DTR stays dropped. +// dropped DTR stays dropped. Those lines are the Modem port's: TOS gives no way to the other ports' +// (an SCC's are its own registers, behind TOS's driver), so there it does nothing. void jlpSerialSetDtr(bool on) { + if (gAuxDevice != ST_BCONMAP_MODEM) { + return; + } if (on) { Offgibit(ST_GI_DTR_ON_MASK); } else { @@ -334,8 +394,11 @@ void jlpSerialSetDtr(bool on) { // RTS on demand (jlSerialSetRts). Under RTS/CTS TOS's receive interrupt drives RTS itself, at the -// buffer's water marks, so it may change it again. +// buffer's water marks, so it may change it again. The Modem port's alone, as DTR. void jlpSerialSetRts(bool on) { + if (gAuxDevice != ST_BCONMAP_MODEM) { + return; + } if (on) { Offgibit(ST_GI_RTS_ON_MASK); } else { @@ -345,17 +408,15 @@ void jlpSerialSetRts(bool on) { uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) { - uint16_t n; + SerialTxWaitT wait; + uint16_t n; for (n = 0u; n < len; n++) { - uint32_t guard; - - // Bounded transmitter-ready spin (Bcostat -1 = ready) so a stalled + // A bounded transmitter-ready wait (Bcostat -1 = ready), so a stalled // link (no CTS) reports a partial write instead of hanging the frame. - guard = 0u; + serialTxWaitStart(&wait, ST_TX_WAIT_POLLS); while (!Bcostat(ST_AUX_DEV)) { - guard++; - if (guard > ST_TX_WAIT_POLLS) { + if (serialTxStalled(&wait)) { return n; } } diff --git a/src/atarist/stAltLayer.c b/src/atarist/stAltLayer.c new file mode 100644 index 0000000..0f922c1 --- /dev/null +++ b/src/atarist/stAltLayer.c @@ -0,0 +1,81 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// The Alt layer of the national keyboards TOS before 4.00 keeps inside its +// keyboard code: the ASCII characters each country's keyboard types with +// Alt (and Alt with Shift), as Atari's ROMs for that country type them and +// EmuTOS's layout tables reproduce them (bios/keyb_*.h). Only ASCII is kept: +// jlInputGetChar delivers nothing else. A country whose TOS layer is not +// known here (French Switzerland, Denmark) types nothing with Alt, as do the +// countries whose keyboards have no such layer (the US, the UK, the +// Netherlands). +// +// Each list is (scan code, character) pairs ended by a 0, the form Keytbl's +// own Alt tables take on TOS 4.00 and later; the scan codes name key +// positions (0x1A is the key right of P, 0x27 and 0x28 the two right of L, +// 0x2B the one left of Return's lower half, 0x60 the one right of left +// Shift, 0x02-0x04 the 1-3 keys, 0x0D the one left of Backspace, 0x29 the +// one left of 1). + +#include + +#include "stAltLayer.h" + +#define TOS_COUNTRY_GERMANY 1u +#define TOS_COUNTRY_FRANCE 2u +#define TOS_COUNTRY_SPAIN 4u +#define TOS_COUNTRY_ITALY 5u +#define TOS_COUNTRY_SWEDEN 6u +#define TOS_COUNTRY_SWISS_GERMAN 8u +#define TOS_COUNTRY_FINLAND 10u +#define TOS_COUNTRY_NORWAY 11u + +typedef struct { + uint8_t country; + const uint8_t *unshift; + const uint8_t *shift; + const uint8_t *caps; +} AltLayerT; + +static const uint8_t kGermanPlain[] = { 0x1A, '@', 0x27, '[', 0x28, ']', 0 }; +static const uint8_t kGermanShift[] = { 0x1A, '\\', 0x27, '{', 0x28, '}', 0 }; +static const uint8_t kFrenchPlain[] = { 0x1A, '[', 0x1B, ']', 0x28, '\\', 0x2B, '@', 0 }; +static const uint8_t kFrenchShift[] = { 0x1A, '{', 0x1B, '}', 0x2B, '~', 0 }; +static const uint8_t kSpanishPlain[] = { 0x1A, '[', 0x1B, ']', 0x2B, '#', 0 }; +static const uint8_t kSpanishShift[] = { 0x1A, '{', 0x1B, '}', 0x2B, '@', 0 }; +static const uint8_t kItalianPlain[] = { 0x1A, '[', 0x1B, ']', 0x60, '`', 0 }; +static const uint8_t kItalianShift[] = { 0x1A, '{', 0x1B, '}', 0x2B, '~', 0x60, '`', 0 }; +static const uint8_t kNordicPlain[] = { 0x1A, '[', 0x1B, ']', 0x28, '`', 0x2B, '^', 0 }; +static const uint8_t kNordicShift[] = { 0x1A, '{', 0x1B, '}', 0x28, '~', 0x2B, '@', 0 }; +static const uint8_t kSwissGermanPlain[] = { 0x02, '|', 0x03, '@', 0x04, '#', 0x0D, '~', 0x1A, '[', 0x1B, ']', 0x28, '{', 0x29, '}', 0x2B, '\\', 0x60, '\\', 0 }; +static const uint8_t kSwissGermanShift[] = { 0x1A, '{', 0x1B, '}', 0 }; +static const uint8_t kNone[] = { 0 }; + +// Caps Lock types what no Shift does, but on the Swiss German keyboard, +// whose Caps Lock leaves the layer out. +static const AltLayerT kAltLayers[] = { + { TOS_COUNTRY_GERMANY, kGermanPlain, kGermanShift, kGermanPlain }, + { TOS_COUNTRY_FRANCE, kFrenchPlain, kFrenchShift, kFrenchPlain }, + { TOS_COUNTRY_SPAIN, kSpanishPlain, kSpanishShift, kSpanishPlain }, + { TOS_COUNTRY_ITALY, kItalianPlain, kItalianShift, kItalianPlain }, + { TOS_COUNTRY_SWEDEN, kNordicPlain, kNordicShift, kNordicPlain }, + { TOS_COUNTRY_SWISS_GERMAN, kSwissGermanPlain, kSwissGermanShift, kNone }, + { TOS_COUNTRY_FINLAND, kNordicPlain, kNordicShift, kNordicPlain }, + { TOS_COUNTRY_NORWAY, kNordicPlain, kNordicShift, kNordicPlain }, +}; + + +bool stAltLayerFind(uint8_t country, const uint8_t **unshift, const uint8_t **shift, const uint8_t **caps) { + size_t i; + + for (i = 0; i < sizeof(kAltLayers) / sizeof(kAltLayers[0]); i++) { + if (kAltLayers[i].country == country) { + *unshift = kAltLayers[i].unshift; + *shift = kAltLayers[i].shift; + *caps = kAltLayers[i].caps; + return true; + } + } + return false; +} diff --git a/src/atarist/stAltLayer.h b/src/atarist/stAltLayer.h new file mode 100644 index 0000000..e10c545 --- /dev/null +++ b/src/atarist/stAltLayer.h @@ -0,0 +1,21 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// The Alt layer of the national keyboards TOS before 4.00 keeps inside its +// keyboard code, where Keytbl cannot reach it (src/atarist/input.c reads the +// layer from Keytbl's tables on TOS 4.00 and later and on EmuTOS). + +#ifndef JOEYLIB_ST_ALT_LAYER_H +#define JOEYLIB_ST_ALT_LAYER_H + +#include +#include + +// The Alt layer of the TOS country `country` (the OS header's os_conf >> 1): +// three lists of (scan code, character) pairs ended by a 0, for no Shift or +// Caps Lock, with Shift, and with Caps Lock. False for a country with none +// (or none known), and the lists are then left alone. +bool stAltLayerFind(uint8_t country, const uint8_t **unshift, const uint8_t **shift, const uint8_t **caps); + +#endif diff --git a/src/atarist/tickIsr.s b/src/atarist/tickIsr.s index 597f436..518475c 100644 --- a/src/atarist/tickIsr.s +++ b/src/atarist/tickIsr.s @@ -12,13 +12,24 @@ | address (_gStTickOldVec) and returning into it with the exception frame | untouched. The C call may use d0-d1/a0-a1, so they are kept here. | +| It first lowers the interrupt mask from the 6 the MFP's interrupt raised it +| to, to 5. The MFP itself holds back Timer C and every channel below it +| until TOS's handler ends the interrupt (TOS runs it in software +| end-of-interrupt mode), so what this lets in is the channels above it: the +| plain ST's PWM pump's Timer A above all, which TOS's 50 Hz work in that +| handler (etv_timer, every fourth tick) held off past a sample, and the +| program's tick with it. The handler's rte puts back the interrupted mask. +| | GAS-syntax (binutils m68k); assembled by m68k-atari-mint-as via the | gcc driver. .text .globl _stTickIsr + .equ SR_ABOVE_TIMER_C, 0x2500 | supervisor, interrupt mask 5 + _stTickIsr: + move.w #SR_ABOVE_TIMER_C,%sr movem.l %d0-%d1/%a0-%a1,-(%sp) jsr _stTickRun movem.l (%sp)+,%d0-%d1/%a0-%a1 diff --git a/src/core/assetLoad.c b/src/core/assetLoad.c index 77765ab..b140f96 100644 --- a/src/core/assetLoad.c +++ b/src/core/assetLoad.c @@ -59,9 +59,9 @@ extern void stSpriteDrawShiftWalker(void); #define ASSET_PALETTE_OFFSET 12 #define ASSET_PALETTE_ENTRIES 16 -// All game data is opened relative to this directory (see joey/file.h). +// All game data is opened relative to this directory (see joey/file.h), +// under the port's read root (fileRoot.h). #define DATA_DIR_PREFIX "DATA/" -#define DATA_PATH_MAX 256 // Tile-bank payload reads stream the whole run in big freads instead of // one 32-byte fread per tile: jlTileT is exactly JL_TILE_BYTES with no @@ -208,37 +208,15 @@ static void readPaletteFromHeader(const uint8_t *header, uint16_t *outPalette) { // here is the single place the DATA/ convention is enforced -- every asset // loader and example routes file opens through this, so callers never // hard-code "DATA/" and the runtime tree layout stays consistent across -// ports. Returns NULL if the joined path overflows DATA_PATH_MAX or the -// file cannot be opened. A port with run-time roots (fileRoot.h) puts -// DATA/ under its read root. +// ports. Returns NULL if the joined path overflows FILE_PATH_MAX or the +// file cannot be opened. FILE *jlDataOpen(const char *name, const char *mode) { - char path[DATA_PATH_MAX]; -#if defined(JL_HAS_FILE_ROOTS) + char path[FILE_PATH_MAX]; if (name == NULL || mode == NULL || !fileRootJoin(path, sizeof(path), jlpReadRoot(), DATA_DIR_PREFIX, name)) { return NULL; } return fopen(path, mode); -#else - size_t prefixLen = sizeof(DATA_DIR_PREFIX) - 1u; // literal length, no NUL - size_t nameLen; - - // A manual prefix concat instead of snprintf: the format is fixed - // ("DATA/" + name), and snprintf drags the whole C formatted-output - // engine (~20 KB of code plus a soft-float dependency) into every - // binary that opens a data file, which the IIgs bank-0 budget cannot - // spare. strlen/memcpy do the same join with none of that. - if (name == NULL || mode == NULL) { - return NULL; - } - nameLen = strlen(name); - if (prefixLen + nameLen + 1u > sizeof(path)) { - return NULL; - } - memcpy(path, DATA_DIR_PREFIX, prefixLen); - memcpy(path + prefixLen, name, nameLen + 1u); // copy the NUL too - return fopen(path, mode); -#endif } diff --git a/src/core/audio.c b/src/core/audio.c index 1cf5313..9aba2e3 100644 --- a/src/core/audio.c +++ b/src/core/audio.c @@ -11,6 +11,7 @@ // layer's included. #include +#include #include "joey/audio.h" #include "joey/debug.h" @@ -29,6 +30,38 @@ static bool gAudioOff = false; // the program wants no audio static uint8_t gCriticalDepth = 0u; // jlAudioCriticalEnter calls not yet exited +// ----- Port services (port.h) ----- + +// Every factor-th of count samples at src, from *phase on, to dst, which may +// be src itself (each sample lands at or before where it was). *phase +// becomes where the next block's first sample is. Returns how many it wrote. +// Factor 1 is a copy (the phase stays 0), at memmove's speed. +uint32_t audioDecimate(uint8_t *dst, const uint8_t *src, uint32_t count, uint8_t factor, uint8_t *phase) { + uint32_t i; + uint32_t n; + + if (factor == 1u) { + if (dst != src) { + memmove(dst, src, count); + } + return count; + } + n = 0u; + for (i = *phase; i < count; i += factor) { + dst[n] = src[i]; + n++; + } + *phase = (uint8_t)(i - count); + return n; +} + + +// The smallest whole factor that brings rateHz to maxHz or under. +uint8_t audioDecimationFactor(uint16_t rateHz, uint16_t maxHz) { + return (uint8_t)(((uint32_t)rateHz + maxHz - 1u) / maxHz); +} + + // ----- Lifecycle (jlInit, jlShutdown) ----- void audioInit(bool wanted) { diff --git a/src/core/audioSfxMix.c b/src/core/audioSfxMix.c index 607b359..c73055b 100644 --- a/src/core/audioSfxMix.c +++ b/src/core/audioSfxMix.c @@ -9,6 +9,20 @@ #include "audioSfxMixInternal.h" +// The unsigned 8-bit output's top, and a stereo frame's samples (left, then +// right). +#define AUDIO_SFX_SAMPLE_MAX 255 // an int, as the clamp's arithmetic is +#define AUDIO_SFX_STEREO 2u + +// A slot's position: a 16.16 number, the sample index above the point. +#define FRAC_BITS 16 +#define FRAC_MASK 0xFFFFu + +// A run's index stays under this: the 68000 run (src/m68k/sfxMix.s) reads +// the samples at it as a signed word, and with a step's whole samples on top +// it never carries into the fraction above it. +#define RUN_END_MAX 0x7F00u + // interp()/mix arithmetic (delta * fracHalf up to ~8.4M, and the // >> 15 on a possibly-negative value) requires a >= 32-bit int with // arithmetic right shift. DOS (DJGPP) and ST (m68k gcc) both satisfy @@ -19,110 +33,81 @@ #error "audioSfxMix requires >= 32-bit int (DOS/ST only); IIgs must use NTPstreamsound" #endif +// Where a run's samples land: a byte each (mono), one side of each stereo +// pair (dst at that side's byte), or both sides of each pair. +typedef enum { + RUN_MONO = 0, + RUN_SIDE = 1, + RUN_BOTH = 2 +} RunLayoutE; + +// One slot's run into the output: count samples, each the one at src's +// 16.16 position pos (src its sample 0) interpolated toward the next by the +// fraction, shifted down shift bits, added into dst as layout says and +// clamped, the position stepping by step. The caller sets count so that no +// sample's index reaches the last sample it may read, so the run tests +// nothing else. The 68000 ports have it in assembly (src/m68k/sfxMix.s, +// JL_HAS_SFX_MIX_RUN), which reads these fields as longs in this order. +typedef struct { + const int8_t *src; + volatile uint8_t *dst; + uint32_t pos; + uint32_t step; + uint32_t count; + uint32_t format; // AUDIO_SFX_DST_* + uint32_t layout; // RunLayoutE + uint32_t shift; // gShift +} SfxRunT; + +// How far under full scale a sound effect is mixed (audioSfxSetShift). +static uint8_t gShift = 0u; + // Prototypes (file-local, alphabetical). -static int interp(int8_t s0, int8_t s1, uint16_t frac); +#if defined(JL_HAS_SFX_MIX_RUN) +void audioSfxMixRun(SfxRunT *run); +#define sfxRun audioSfxMixRun +#else +static int interp(int8_t s0, int8_t s1, uint16_t frac); +static void sfxRun(SfxRunT *run); +#endif +static void slotMix(AudioSfxSlotT *slot, volatile uint8_t *dst, uint16_t frames, RunLayoutE layout, uint8_t format); static bool streamRefill(AudioSfxSlotT *slot); void audioSfxOverlayMix(volatile uint8_t *dst, uint16_t len, AudioSfxSlotT *slots, uint8_t numSlots) { - AudioSfxSlotT *slot; - const uint8_t *smp; - const int8_t *streamBuf; - uint32_t idx; - uint32_t stepIdx; - uint32_t length; - uint16_t frac; - uint16_t stepFrac; - uint16_t streamLen; - uint16_t i; - uint8_t s; - int sample; - int mixed; - int8_t s0; - int8_t s1; + uint8_t s; for (s = 0; s < numSlots; s++) { - slot = &slots[s]; - if (!slot->active) { + if (slots[s].active) { + slotMix(&slots[s], dst, len, RUN_MONO, AUDIO_SFX_DST_UNSIGNED); + } + } +} + + +void audioSfxOverlayMixStereo(volatile uint8_t *dst, uint16_t frames, AudioSfxSlotT *slots, uint8_t numSlots, const uint8_t *pans, uint8_t format) { + uint8_t s; + + for (s = 0; s < numSlots; s++) { + if (!slots[s].active) { continue; } - // Split the 16.16 step once per slot: the inner loops advance - // a uint32_t sample index + uint16_t fraction with a carry - // compare instead of 64-bit fixed-point math per sample. - stepIdx = slot->stepFp >> 16; - stepFrac = (uint16_t)(slot->stepFp & 0xFFFFu); - idx = slot->posIdx; - frac = slot->posFrac; - // The streaming and fixed-buffer cases are two separate inner - // loops so the loop-invariant isStream test runs once per slot - // and the slot fields stay cached in locals across the - // volatile dst stores (which would otherwise force per-sample - // re-reads); the short mix body is intentionally duplicated - // between them. - if (slot->fill != NULL) { - // Streaming: refill the prefetch buffer when drained. - streamBuf = slot->streamBuf; - streamLen = slot->streamLen; - for (i = 0; i < len; i++) { - if (idx >= streamLen) { - if (!streamRefill(slot)) { - slot->active = false; - break; - } - // streamRefill rewound the slot position; re-read - // the fields it updated. - idx = slot->posIdx; - frac = slot->posFrac; - streamLen = slot->streamLen; - } - s0 = streamBuf[idx]; - s1 = (idx + 1u < streamLen) ? streamBuf[idx + 1u] : s0; - sample = interp(s0, s1, frac); - mixed = (int)dst[i] - 128 + sample; - if (mixed > 127) { - mixed = 127; - } else if (mixed < -128) { - mixed = -128; - } - dst[i] = (uint8_t)(mixed + 128); - frac = (uint16_t)(frac + stepFrac); - if (frac < stepFrac) { - idx++; - } - idx += stepIdx; - } + if (pans[s] == JL_AUDIO_PAN_LEFT) { + slotMix(&slots[s], dst, frames, RUN_SIDE, format); + } else if (pans[s] == JL_AUDIO_PAN_RIGHT) { + slotMix(&slots[s], dst + 1, frames, RUN_SIDE, format); } else { - // Fixed buffer. - smp = slot->sample; - length = slot->length; - for (i = 0; i < len; i++) { - if (idx >= length) { - slot->active = false; - break; - } - s0 = (int8_t)smp[idx]; - s1 = (idx + 1u < length) ? (int8_t)smp[idx + 1u] : s0; - sample = interp(s0, s1, frac); - mixed = (int)dst[i] - 128 + sample; - if (mixed > 127) { - mixed = 127; - } else if (mixed < -128) { - mixed = -128; - } - dst[i] = (uint8_t)(mixed + 128); - frac = (uint16_t)(frac + stepFrac); - if (frac < stepFrac) { - idx++; - } - idx += stepIdx; - } + slotMix(&slots[s], dst, frames, RUN_BOTH, format); } - slot->posIdx = idx; - slot->posFrac = frac; } } +void audioSfxSetShift(uint8_t shift) { + gShift = shift; +} + + void audioSfxSlotArm(AudioSfxSlotT *slot, const uint8_t *sample, uint32_t length, uint16_t rateHz, uint32_t outputRate) { if (rateHz == 0 || outputRate == 0) { return; @@ -160,19 +145,15 @@ void audioSfxSlotArmStream(AudioSfxSlotT *slot, jlAudioStreamFillT fill, void *c } +#if !defined(JL_HAS_SFX_MIX_RUN) // Linear interpolation between two adjacent signed 8-bit samples. // `frac` is the 16-bit fractional part of the read position; 0 = // exactly at s0, 0xFFFF = nearly s1. Result is a signed int in -// [-128, +127]. -// -// Formulated as a 16x16->32 signed multiply with explicit int16_t -// locals so m68k gcc matches its mulhisi3 pattern and emits a single -// muls.w instead of a __mulsi3 library call (~250-350 cycles per -// sample on the 68000). Dropping frac's low bit means the result can -// differ from the exact delta * frac >> 16 by at most 1 LSB of 8-bit -// audio -- an accepted, inaudible deviation. The >> 15 of a possibly- -// negative int32_t relies on arithmetic shift, which holds on every -// target this file builds for (DOS/ST). +// [-128, +127]. frac's low bit is dropped, as the 68000 run's muls.w +// needs (src/m68k/sfxMix.s), so the two give the same samples: at worst +// 1 LSB of 8-bit audio from the exact delta * frac >> 16, inaudible. The +// >> 15 of a possibly-negative int32_t relies on arithmetic shift, which +// holds on every target this file builds for. static int interp(int8_t s0, int8_t s1, uint16_t frac) { int16_t delta; int16_t fracHalf; @@ -180,70 +161,169 @@ static int interp(int8_t s0, int8_t s1, uint16_t frac) { delta = (int16_t)(s1 - s0); fracHalf = (int16_t)(uint16_t)(frac >> 1); -#if defined(__m68k__) - // One muls.w: gcc for the 68000 calls __mulsi3 for this 16 x 16 bit - // product, which on the ST and the X68000 cost several times the rest of - // the mix. - product = delta; - __asm__ ("muls.w %1,%0" : "+d" (product) : "dm" (fracHalf) : "cc"); -#else - product = (int32_t)delta * fracHalf; -#endif + product = (int32_t)delta * fracHalf; return (int)s0 + (int)(product >> 15); } +// SfxRunT's run, in C. The run's fields are cached in locals: dst's stores +// are bytes, which may alias anything, so the compiler would re-read them. +static void sfxRun(SfxRunT *run) { + volatile uint8_t *dst; + const int8_t *src; + uint32_t pos; + uint32_t step; + uint32_t count; + uint32_t idx; + uint8_t stride; + uint8_t format; + uint8_t shift; + bool both; + int sample; + int value; + + src = run->src; + dst = run->dst; + pos = run->pos; + step = run->step; + format = (uint8_t)run->format; + shift = (uint8_t)run->shift; + both = run->layout == RUN_BOTH; + stride = run->layout == RUN_MONO ? 1u : AUDIO_SFX_STEREO; + for (count = run->count; count > 0u; count--) { + idx = pos >> FRAC_BITS; + sample = interp(src[idx], src[idx + 1u], (uint16_t)(pos & FRAC_MASK)) >> shift; + value = (int)(dst[0] ^ format) + sample; + dst[0] = (uint8_t)((value < 0 ? 0 : (value > AUDIO_SFX_SAMPLE_MAX ? AUDIO_SFX_SAMPLE_MAX : value)) ^ format); + if (both) { + value = (int)(dst[1] ^ format) + sample; + dst[1] = (uint8_t)((value < 0 ? 0 : (value > AUDIO_SFX_SAMPLE_MAX ? AUDIO_SFX_SAMPLE_MAX : value)) ^ format); + } + dst += stride; + pos += step; + } + run->dst = dst; + run->pos = pos; + run->count = 0u; +} +#endif + + +// One slot mixed over frames samples at dst (layout and format as SfxRunT's), +// in runs: each as far as the last sample whose next one it can read, then +// that last sample on its own, held (there is nothing after it to +// interpolate toward), then -- a stream's -- the next chunk. A slot whose +// position passes its end goes inactive. +static void slotMix(AudioSfxSlotT *slot, volatile uint8_t *dst, uint16_t frames, RunLayoutE layout, uint8_t format) { + SfxRunT run; + const int8_t *base; + int8_t held[2]; + uint32_t length; + uint32_t idx; + uint32_t end; + uint32_t left; + uint32_t want; + + run.dst = dst; + run.pos = slot->posFrac; + run.step = slot->stepFp; + run.format = format; + run.layout = (uint32_t)layout; + run.shift = gShift; + idx = slot->posIdx; + want = frames; + while (want > 0u) { + if (slot->fill != NULL) { + base = slot->streamBuf; + length = slot->streamLen; + } else { + base = (const int8_t *)slot->sample; + length = slot->length; + } + if (idx >= length) { + slot->posIdx = idx; + slot->posFrac = (uint16_t)run.pos; + if (slot->fill == NULL || !streamRefill(slot)) { + slot->active = false; + return; + } + // streamRefill moved the position into the new chunk. + idx = slot->posIdx; + run.pos = slot->posFrac; + continue; + } + if (idx + 1u < length) { + run.src = base + idx; + end = length - 1u - idx; + if (end > RUN_END_MAX) { + end = RUN_END_MAX; + } + } else { + held[0] = base[idx]; + held[1] = held[0]; + run.src = held; + end = 1u; + } + // The samples before the index reaches end. + left = ((end << FRAC_BITS) - run.pos + run.step - 1u) / run.step; + run.count = want < left ? want : left; + want -= run.count; + sfxRun(&run); + idx += run.pos >> FRAC_BITS; + run.pos &= FRAC_MASK; + } + slot->posIdx = idx; + slot->posFrac = (uint16_t)run.pos; +} + + // Refill a streaming slot's prefetch buffer. Preserves the very last // sample of the previous chunk at streamBuf[0] so linear interp can // step across the chunk boundary without a discontinuity click. The // new chunk fills streamBuf[1..]. On a real refill (a previous chunk -// existed) the position resets to 0 so the first read interpolates the -// carried old-last sample at streamBuf[0] into the new chunk's first -// sample at streamBuf[1], bridging the boundary. On the very first -// prime there is no real previous sample, so the position resets to -// "after byte 0" and the first read starts at the new chunk's first -// sample (index 1). +// existed) the position carries on where it ran out: as far past the +// old last sample, now streamBuf[0], as it had got, fraction and all, +// so the first read interpolates across the boundary and no time is +// lost or played twice. A step that carries past a chunk this short +// takes the next one too. On the very first prime there is no real +// previous sample, so the position starts "after byte 0" and the first +// read is the new chunk's first sample (index 1). // // Returns true if more samples are available, false if the stream // has ended (caller should mark the slot inactive). static bool streamRefill(AudioSfxSlotT *slot) { int8_t lastSample; uint32_t got; - bool haveCarry; + uint16_t oldLen; - if (slot->streamEof) { - return false; - } - haveCarry = (slot->streamLen > 0u); - lastSample = haveCarry ? slot->streamBuf[slot->streamLen - 1u] : (int8_t)0; - // Clamp the callback's reported count to the request size so a - // non-conforming fill() that returns more than asked cannot make - // the mixer read past streamBuf. (Memory safety still requires - // fill() to honor count -- an over-returning callback has already - // over-written streamBuf+1; this caps only the subsequent reads.) - got = slot->fill(slot->fillCtx, slot->streamBuf + 1, AUDIO_SFX_STREAM_BUF - 1u); - if (got > (uint32_t)(AUDIO_SFX_STREAM_BUF - 1u)) { - got = AUDIO_SFX_STREAM_BUF - 1u; - } - if (got == 0u) { - slot->streamEof = true; - slot->streamLen = 0u; - return false; - } - slot->streamBuf[0] = lastSample; - slot->streamLen = (uint16_t)(got + 1u); - if (haveCarry) { - // Bridge the chunk boundary: start at index 0 so the first - // read interpolates streamBuf[0] (old last sample) -> [1] - // (new first sample), replaying one sample with no audible - // discontinuity click. - slot->posIdx = 0u; - slot->posFrac = 0u; - } else { - // First prime: streamBuf[0] is a synthetic zero, not a real - // sample. Skip it so it isn't played as a leading sample. - slot->posIdx = 1u; - slot->posFrac = 0u; - } + do { + if (slot->streamEof) { + return false; + } + oldLen = slot->streamLen; + lastSample = oldLen > 0u ? slot->streamBuf[oldLen - 1u] : (int8_t)0; + // Clamp the callback's reported count to the request size so a + // non-conforming fill() that returns more than asked cannot make + // the mixer read past streamBuf. (Memory safety still requires + // fill() to honor count -- an over-returning callback has already + // over-written streamBuf+1; this caps only the subsequent reads.) + got = slot->fill(slot->fillCtx, slot->streamBuf + 1, AUDIO_SFX_STREAM_BUF - 1u); + if (got > (uint32_t)(AUDIO_SFX_STREAM_BUF - 1u)) { + got = AUDIO_SFX_STREAM_BUF - 1u; + } + if (got == 0u) { + slot->streamEof = true; + slot->streamLen = 0u; + return false; + } + slot->streamBuf[0] = lastSample; + slot->streamLen = (uint16_t)(got + 1u); + if (oldLen > 0u) { + slot->posIdx = slot->posIdx - (oldLen - 1u); + } else { + slot->posIdx = 1u; + slot->posFrac = 0u; + } + } while (slot->posIdx >= slot->streamLen); return true; } diff --git a/src/core/audioSfxMixInternal.h b/src/core/audioSfxMixInternal.h index a42f0fa..59fe3dd 100644 --- a/src/core/audioSfxMixInternal.h +++ b/src/core/audioSfxMixInternal.h @@ -57,6 +57,12 @@ typedef struct { bool streamEof; // last fill returned 0 } AudioSfxSlotT; +// Sound effects mixed shift bits under full scale from now on: a port whose +// 8-bit mix holds a module's four channels at a fraction of a byte each +// (modPlayerSetU8Shift) mixes its effects at the same level, as loud as a +// module channel (joey/audio.h's mix). 0 until a port sets it. +void audioSfxSetShift(uint8_t shift); + // Arm a slot for one-shot playback from a fixed PCM buffer. // `outputRate` is the HAL's mixer output rate; the helper computes // stepFp from rateHz and outputRate. Sets `active = true`. No-op @@ -82,4 +88,15 @@ void audioSfxSlotArmStream(AudioSfxSlotT *slot, jlAudioStreamFillT fill, void *c // memory but the function accepts both via the wider qualifier. void audioSfxOverlayMix(volatile uint8_t *dst, uint16_t len, AudioSfxSlotT *slots, uint8_t numSlots); +// The stereo form, for a port that mixes interleaved left and right 8-bit +// pairs (DOS on an SB16, the STe): each slot is added into the side its +// pans[] entry reaches (both, for JL_AUDIO_PAN_CENTER), each side clamped +// as audioSfxOverlayMix clamps. pans[i] is slots[i]'s. dst holds unsigned +// samples (AUDIO_SFX_DST_UNSIGNED) or signed ones (AUDIO_SFX_DST_SIGNED), +// each mixed in its own format and in order, a sample at a time, so a +// buffer that is playing can take a sound ahead of the play position. +#define AUDIO_SFX_DST_UNSIGNED 0x00u +#define AUDIO_SFX_DST_SIGNED 0x80u +void audioSfxOverlayMixStereo(volatile uint8_t *dst, uint16_t frames, AudioSfxSlotT *slots, uint8_t numSlots, const uint8_t *pans, uint8_t format); + #endif diff --git a/src/core/codegenArena.c b/src/core/codegenArena.c index 88ec29c..70825a6 100644 --- a/src/core/codegenArena.c +++ b/src/core/codegenArena.c @@ -308,7 +308,7 @@ bool codegenArenaInit(uint32_t totalBytes) { while (want > 0u && gBankCount < CODEGEN_ARENA_BANKS_MAX) { uint32_t bytes = (want > CODEGEN_ARENA_IIGS_BANK) ? CODEGEN_ARENA_IIGS_BANK : want; if (!bankAlloc(gBankCount, bytes)) { - if (bytes <= 60ul * 1024ul || !bankAlloc(gBankCount, 60ul * 1024ul)) { + if (bytes <= CODEGEN_ARENA_IIGS_RETRY || !bankAlloc(gBankCount, CODEGEN_ARENA_IIGS_RETRY)) { break; } } @@ -317,11 +317,17 @@ bool codegenArenaInit(uint32_t totalBytes) { want = (want > gBankSize[gBankCount - 1u]) ? want - gBankSize[gBankCount - 1u] : 0u; } #else + // One block: the request, or as much of it as the heap gives, halving + // down to CODEGEN_ARENA_MIN_BYTES, as the IIgs takes what it is granted. want = totalBytes; - if (bankAlloc(0u, want)) { - gTotalBytes = want; - gBankCount = 1u; + while (!bankAlloc(0u, want)) { + if (want <= CODEGEN_ARENA_MIN_BYTES) { + return false; + } + want = want / 2u; } + gTotalBytes = want; + gBankCount = 1u; #endif return gBankCount != 0u; } diff --git a/src/core/codegenArenaInternal.h b/src/core/codegenArenaInternal.h index dc3cce4..02d5e8f 100644 --- a/src/core/codegenArenaInternal.h +++ b/src/core/codegenArenaInternal.h @@ -44,16 +44,20 @@ typedef struct ArenaSlotT { } ArenaSlotT; // Banks: at most this many, and the IIgs's bank is one 64 KB block (the -// Memory Manager refuses a no-cross block any larger). +// Memory Manager refuses a no-cross block any larger); a whole free bank +// refused, it asks for any page-aligned 60 KB run inside one. Elsewhere one +// block, halved until the heap gives it, down to CODEGEN_ARENA_MIN_BYTES. #define CODEGEN_ARENA_BANKS_MAX 4u #define CODEGEN_ARENA_BANK_SHIFT 24u #define CODEGEN_ARENA_BANK_MASK 0x00FFFFFFul #define CODEGEN_ARENA_IIGS_BANK 65536ul +#define CODEGEN_ARENA_IIGS_RETRY (60ul * 1024ul) +#define CODEGEN_ARENA_MIN_BYTES 4096ul -// Lifecycle. Returns false if totalBytes is 0 or no bank at all could be -// allocated; a request the machine cannot fully satisfy yields as many -// banks as it can (codegenArenaBytesTotal tells). Idempotent: calling -// Init twice without an intervening Shutdown is a no-op. +// Lifecycle. Returns false if totalBytes is 0 or no memory at all could be +// had; a request the machine cannot fully satisfy yields what it can +// (codegenArenaBytesTotal tells), on every port. Idempotent: calling Init +// twice without an intervening Shutdown is a no-op. bool codegenArenaInit(uint32_t totalBytes); void codegenArenaShutdown(void); diff --git a/src/core/debug.c b/src/core/debug.c index 26e58b0..3bc33b1 100644 --- a/src/core/debug.c +++ b/src/core/debug.c @@ -26,6 +26,10 @@ #include #include +#if defined(__EMSCRIPTEN__) +#include +#endif + #include "joey/platform.h" #include "joey/debug.h" #include "fileRoot.h" @@ -33,6 +37,10 @@ static const char *kLogPath = "joeylog.txt"; +// One logged line, its newline left out: jlLogF formats into this much on +// every port, so a longer line is cut at 255 characters everywhere. +#define LOG_LINE_BYTES 256u + #ifdef JOEY_LOG_SERIAL_TEE // Supplied by the port. Declared ahead of the platform split because BOTH log // implementations below tee through it. @@ -98,7 +106,7 @@ void jlLog(const char *msg) { void jlLogF(const char *fmt, ...) { - char line[200]; + char line[LOG_LINE_BYTES]; va_list args; if (fmt == NULL) { @@ -121,6 +129,7 @@ void jlLogF(const char *fmt, ...) { // below). void jlLogFlush(void) { FILE *fp; + char path[FILE_PATH_MAX]; uint16_t head; uint16_t start; uint16_t count; @@ -131,7 +140,10 @@ void jlLogFlush(void) { if (head == 0u && gJoeyLogRingFull == 0u) { return; } - fp = fopen(kLogPath, "w"); + if (!fileRootJoin(path, sizeof(path), jlpWriteRoot(), kLogPath, "")) { + return; + } + fp = fopen(path, "w"); if (fp == NULL) { return; } @@ -168,31 +180,21 @@ void jlLogReset(void) { #else // not JOEYLIB_PLATFORM_IIGS static FILE *gLogFp = NULL; -#if defined(JL_HAS_FILE_ROOTS) -// The log FILE belongs to the process hosting the program and can outlive -// it (a program ended by force never reaches jlLogClose), so it keeps the -// host's own buffer rather than one inside the program. (DVX does not even -// offer its programs setvbuf.) jlLog/jlLogF still flush once per line. -#define LOG_PATH_MAX 256 -#else -/* 16 KB full buffer so a single log line's many small vfprintf writes - * coalesce into one host write; jlLog/jlLogF then fflush once per - * line. Default stdio buffers are only ~512 bytes, which would split a - * long line across multiple writes. */ -#define JOEY_LOG_BUF_BYTES 16384 -static char gLogBuf[JOEY_LOG_BUF_BYTES]; +#if !defined(JOEYLIB_PLATFORM_DVX) +// A buffer that holds a whole line and its newline, so each line reaches +// the file in one write (logLine flushes per line). DVX's log FILE belongs +// to the desktop's process and can outlive the program (one ended by force +// never reaches jlLogClose), so it keeps the host's own buffer -- DVX does +// not even offer its programs setvbuf. +static char gLogBuf[LOG_LINE_BYTES * 2u]; #endif // ----- Prototypes ----- static void logClose(void); static FILE *logFile(void); -#if defined(JL_HAS_FILE_ROOTS) +static void logLine(const char *line); static FILE *logOpen(const char *mode); -#else -// joeylog.txt is relative to the current directory. -#define logOpen(_mode) fopen(kLogPath, (_mode)) -#endif static void logClose(void) { @@ -207,7 +209,7 @@ static void logClose(void) { static FILE *logFile(void) { if (gLogFp == NULL) { gLogFp = logOpen("a"); -#if !defined(JL_HAS_FILE_ROOTS) +#if !defined(JOEYLIB_PLATFORM_DVX) if (gLogFp != NULL) { (void)setvbuf(gLogFp, gLogBuf, _IOFBF, sizeof(gLogBuf)); } @@ -217,35 +219,44 @@ static FILE *logFile(void) { } -#if defined(JL_HAS_FILE_ROOTS) +// One line out: to the serial tee (when built in), the file, and on the +// web the browser's console as well (the page's file is lost with it). +static void logLine(const char *line) { + FILE *fp; + +#ifdef JOEY_LOG_SERIAL_TEE + jlpLogTee(line); + jlpLogTee("\n"); +#endif +#if defined(__EMSCRIPTEN__) + emscripten_log(EM_LOG_CONSOLE, "%s", line); +#endif + fp = logFile(); + if (fp == NULL) { + return; + } + fputs(line, fp); + fputc('\n', fp); + fflush(fp); +} + + // joeylog.txt under the port's write root (fileRoot.h). static FILE *logOpen(const char *mode) { - char path[LOG_PATH_MAX]; + char path[FILE_PATH_MAX]; if (!fileRootJoin(path, sizeof(path), jlpWriteRoot(), kLogPath, "")) { return NULL; } return fopen(path, mode); } -#endif void jlLog(const char *msg) { - FILE *fp; if (msg == NULL) { return; } -#ifdef JOEY_LOG_SERIAL_TEE - jlpLogTee(msg); - jlpLogTee("\n"); -#endif - fp = logFile(); - if (fp == NULL) { - return; - } - fputs(msg, fp); - fputc('\n', fp); - fflush(fp); + logLine(msg); } @@ -257,42 +268,16 @@ void jlLogClose(void) { void jlLogF(const char *fmt, ...) { - FILE *fp; + char line[LOG_LINE_BYTES]; va_list args; + if (fmt == NULL) { return; } - fp = logFile(); -#ifdef JOEY_LOG_SERIAL_TEE - // Format once into a buffer so the same bytes reach the file and the wire. - // Only under the tee: default builds keep the straight vfprintf, which has - // no line-length ceiling. - { - char line[200]; - - va_start(args, fmt); - (void)vsnprintf(line, sizeof(line), fmt, args); - va_end(args); - jlpLogTee(line); - jlpLogTee("\n"); - if (fp == NULL) { - return; - } - fputs(line, fp); - fputc('\n', fp); - fflush(fp); - return; - } -#else - if (fp == NULL) { - return; - } va_start(args, fmt); - vfprintf(fp, fmt, args); + (void)vsnprintf(line, sizeof(line), fmt, args); va_end(args); - fputc('\n', fp); - fflush(fp); -#endif + logLine(line); } @@ -304,14 +289,14 @@ void jlLogFlush(void) { void jlLogReset(void) { + FILE *fp; + logClose(); - /* Truncate by opening for write then closing; subsequent - * jlLog* will reopen for append. */ - { - FILE *fp = logOpen("w"); - if (fp != NULL) { - fclose(fp); - } + // Truncate by opening for write then closing; the next jlLog opens it + // for append again. + fp = logOpen("w"); + if (fp != NULL) { + fclose(fp); } } diff --git a/src/core/fileRoot.h b/src/core/fileRoot.h index ec1d8f2..95c13a7 100644 --- a/src/core/fileRoot.h +++ b/src/core/fileRoot.h @@ -2,29 +2,31 @@ // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // -// Run-time file roots (JL_HAS_FILE_ROOTS). -// -// On the machine ports the core opens DATA/, SAVES/ and joeylog.txt by those -// relative names (literals in assetLoad.c, save.c and debug.c), so they -// resolve against the current directory: the program's own folder when its -// desktop starts it (the IIgs roots SAVES/ at its application prefix -// instead, JL_SAVE_DIR in port.h). A port -// whose programs share one current directory with other programs and end -// without ending the process (DVX: each program is a module loaded into the -// desktop) defines JL_HAS_FILE_ROOTS in platform.h and supplies the folders -// they live in. Without the flag this header declares nothing and the core -// keeps its literals, so no other port's code changes. +// File roots: the folders the core finds DATA/ in (the read root) and puts +// SAVES/ and joeylog.txt in (the write root). A port that knows the +// program's own folder (JL_HAS_FILE_ROOTS) supplies it, so a program finds +// its files wherever it was started from, before jlInit as after: DOS from +// the path DOS started it by, the Amiga as PROGDIR:, the X68000 from its +// process block, the IIgs as GS/OS prefix 1, the hosted ports from their +// executable's path. DVX keeps SAVES/ and the log apart, in the program's +// settings folder. Without the flag both roots are "": the current +// directory (the ST, whose GEMDOS tells a program nothing of where it +// was started from). #ifndef JOEYLIB_FILE_ROOT_H #define JOEYLIB_FILE_ROOT_H -#include "joey/platform.h" - -#if defined(JL_HAS_FILE_ROOTS) - #include #include -#include +#include +#include "joey/platform.h" + +// The longest path the core builds: a root, DATA/ or SAVES/, and a name. +#define FILE_PATH_MAX 256 + +static inline bool fileRootJoin(char *out, size_t cap, const char *root, const char *rel, const char *name); + +#if defined(JL_HAS_FILE_ROOTS) // The roots are folder prefixes: "" or a path ending in a separator, valid // from before the program's main runs until it ends. // jlpReadRoot holds DATA/, the program's own read-only files @@ -32,21 +34,34 @@ // the first time it is asked for it const char *jlpReadRoot(void); const char *jlpWriteRoot(void); - -// Close joeylog.txt (debug.c). The port calls it when its program ends: -// the file handle belongs to the host process, which lives on. A later -// jlLog opens the file again, appending. +// Close joeylog.txt (debug.c). A port whose program ends without its process +// ending calls it then (DVX: the file handle belongs to the desktop's +// process, which lives on). A later jlLog opens the file again, appending. void jlLogClose(void); +#else +#define jlpReadRoot() "" +#define jlpWriteRoot() "" +#endif -// out = root + rel + name. False when that does not fit in cap bytes. +// out = root + rel + name. False when that does not fit in cap bytes. A +// plain copy: snprintf would pull the formatted-output engine into every +// program that opens a file, which the IIgs's bank 0 cannot spare. static inline bool fileRootJoin(char *out, size_t cap, const char *root, const char *rel, const char *name) { - int written; + size_t rootLen; + size_t relLen; + size_t nameLen; - written = snprintf(out, cap, "%s%s%s", root, rel, name); - return written >= 0 && (size_t)written < cap; + rootLen = strlen(root); + relLen = strlen(rel); + nameLen = strlen(name); + if (rootLen + relLen + nameLen + 1u > cap) { + return false; + } + memcpy(out, root, rootLen); + memcpy(out + rootLen, rel, relLen); + memcpy(out + rootLen + relLen, name, nameLen + 1u); + return true; } #endif - -#endif diff --git a/src/core/init.c b/src/core/init.c index 89a3836..c95c9c3 100644 --- a/src/core/init.c +++ b/src/core/init.c @@ -39,10 +39,16 @@ // sprite rather than failing when it still runs out. These ports // have real heaps. // +// The X68000 runs the Amiga's planar 68000 emitters, so it takes the +// same 64 KB. +// // DOS: two chunky shifts, ~1-6 KB per sprite; 32 KB is generous. +// +// The ports that interpret every sprite (JL_SPRITE_INTERPRETED) have no +// arena at all. #if defined(JOEYLIB_PLATFORM_IIGS) #define DEFAULT_CODEGEN_BYTES (8UL * 1024UL) -#elif defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) +#elif defined(SPRITE_68K) #define DEFAULT_CODEGEN_BYTES (64UL * 1024UL) #else #define DEFAULT_CODEGEN_BYTES (32UL * 1024UL) @@ -59,9 +65,10 @@ static void clearError(void); // ----- Module state ----- static jlConfigT gConfig; -static bool gInitialized = false; -static const char *gLastError = NULL; -static uint32_t gMillisBase = 0u; // the port's millisecond count at jlInit +static bool gInitialized = false; +static const char *gLastError = NULL; +static uint32_t gMillisBase = 0u; // the port's millisecond count at jlInit +static bool gShutdownAtExitOn = false; // jlShutdown registered to run at exit // ----- Internal helpers (alphabetical) ----- @@ -141,12 +148,14 @@ bool jlInit(const jlConfigT *config) { return false; } +#if !defined(JL_SPRITE_INTERPRETED) if (!codegenArenaInit(gConfig.codegenBytes != 0 ? gConfig.codegenBytes : DEFAULT_CODEGEN_BYTES)) { - coreSetError("failed to allocate codegen arena"); + coreSetError("no memory for any of the compiled-sprite cache"); stageFree(); jlpShutdown(); return false; } +#endif // Empty the typed-character queue and centre the mouse before the // port arms its input: a shutdown/re-init cycle must not hand the app @@ -158,6 +167,14 @@ bool jlInit(const jlConfigT *config) { // audio calls are then silent. audioInit(!gConfig.noAudio); + // A program that ends without jlShutdown (exit, or main returning) must + // still leave the machine as jlInit found it: interrupts unhooked, the + // display and the sound put back. jlShutdown runs at exit, once + // registered; a program that shut down itself makes it a no-op. + if (!gShutdownAtExitOn) { + gShutdownAtExitOn = atexit(jlShutdown) == 0; + } + gInitialized = true; return true; } diff --git a/src/core/modPlayer.c b/src/core/modPlayer.c index fe2304b..c27993d 100644 --- a/src/core/modPlayer.c +++ b/src/core/modPlayer.c @@ -94,13 +94,18 @@ #define SAMPLE_VALUES 256u #define VOLUME_SHIFT 6u // a sample times a volume of 64 is the sample #define MIX_CHUNK 256u // output samples mixed at a time -// 8-bit output: each channel's sample times its volume, over 64, and a -// quarter of that, added into the output: a channel at full volume swings a -// quarter of the range, as on the Amiga (two channels a side) and as -// libxmp-lite mixed these ports' modules before. A quarter of each is -32 to -// 31, so the four together, -128 to 124 on the silence byte, never wrap. -#define U8_SHIFT 2u +// 8-bit output: each channel's sample times its volume, over 64, shifted down +// gU8Shift bits (modPlayerSetU8Shift), added into the output. At +// U8_SHIFT_QUARTER, the default, a channel at full volume swings a quarter of +// the range, -32 to 31, so the four together, -128 to 124 on the silence +// byte, never wrap. At U8_SHIFT_HALF a channel swings half: a side's two +// still fit a byte, and a mono render sums its two sides through gU8Clamp, +// so four loud channels clip rather than wrap. +#define U8_SHIFT_QUARTER 2u +#define U8_SHIFT_HALF 1u #define U8_BIAS 128 +#define U8_MAX 255 // an int, as the clamp's arithmetic is +#define U8_SUMS (2u * SAMPLE_VALUES - 1u) // two bytes added: 0 .. 510 // A run's end, in samples from its start: within a 68000's signed word index, // with room for a step past it. #define RUN_END_MAX 0x7F00u @@ -254,6 +259,7 @@ static void updateFunk(ChannelT *c); static void vibrato(ChannelT *c, PaulaT *a, uint8_t param); static void vibratoNc(ChannelT *c, PaulaT *a, uint8_t amp, uint8_t spd); static void voiceMixS16(PaulaT *a, int32_t *acc, uint32_t count); +static void voiceMixSidesU8(uint32_t count); static void voiceMixU8(PaulaT *a, uint8_t *dst, uint32_t count); static void voiceSkip(PaulaT *a, uint32_t count); static uint32_t voiceStep(PaulaT *a); @@ -357,10 +363,16 @@ static const uint8_t kArpeggio[ROW_TICKS_MAX] = { // ----- Module state ----- static PlayerT gPlay; -static int8_t gVolTab[VOLUME_MAX + 1u][SAMPLE_VALUES]; // a sample (as its byte) times a volume, over 256 +static int8_t gVolTab[VOLUME_MAX + 1u][SAMPLE_VALUES]; // a sample (as its byte) times a volume, over 64 << gU8Shift static bool gVolTabBuilt = false; +static uint8_t gU8Shift = U8_SHIFT_QUARTER; +// The sum of two 8-bit sides (each centred on U8_BIAS), clamped to a byte. +static uint8_t gU8Clamp[U8_SUMS]; static int32_t gMixLeft[MIX_CHUNK]; static int32_t gMixRight[MIX_CHUNK]; +// The 8-bit stereo sides, before they are interleaved. +static uint8_t gU8Left[MIX_CHUNK]; +static uint8_t gU8Right[MIX_CHUNK]; // mt_arpeggio: the note, then the note plus x semitones, then plus y, as @@ -695,26 +707,48 @@ bool modPlayerRenderS16(int16_t *dst, uint32_t frames, uint8_t channels) { } -bool modPlayerRenderU8(uint8_t *dst, uint32_t count) { +bool modPlayerRenderU8(uint8_t *dst, uint32_t frames, uint8_t channels) { uint32_t run; + uint32_t f; uint8_t i; - while (count > 0u) { - if (!renderAdvance(&run, count)) { - memset(dst, MOD_PLAYER_SILENCE_U8, count); + while (frames > 0u) { + if (!renderAdvance(&run, frames)) { + memset(dst, MOD_PLAYER_SILENCE_U8, frames * channels); return false; } - memset(dst, MOD_PLAYER_SILENCE_U8, run); - for (i = 0u; i < MOD_CHANNELS; i++) { - voiceMixU8(&gPlay.paula[i], dst, run); + if (channels == S16_STEREO) { + voiceMixSidesU8(run); + for (f = 0u; f < run; f++) { + *dst++ = gU8Left[f]; + *dst++ = gU8Right[f]; + } + } else if (gU8Shift < U8_SHIFT_QUARTER) { + // Four channels at more than a quarter can pass a byte: each + // side alone, then the two added through the clamp. + voiceMixSidesU8(run); + for (f = 0u; f < run; f++) { + *dst++ = gU8Clamp[(uint16_t)gU8Left[f] + gU8Right[f]]; + } + } else { + memset(dst, MOD_PLAYER_SILENCE_U8, run); + for (i = 0u; i < MOD_CHANNELS; i++) { + voiceMixU8(&gPlay.paula[i], dst, run); + } + dst += run; } - dst += run; - count -= run; + frames -= run; } return true; } +void modPlayerSetU8Shift(uint8_t shift) { + gU8Shift = shift < U8_SHIFT_HALF ? U8_SHIFT_HALF : (shift > U8_SHIFT_QUARTER ? U8_SHIFT_QUARTER : shift); + gVolTabBuilt = false; +} + + bool modPlayerStart(const uint8_t *data, uint32_t length, uint32_t rate, bool loop) { const uint8_t *info; uint32_t samplesOffset; @@ -1394,6 +1428,18 @@ static void voiceMixS16(PaulaT *a, int32_t *acc, uint32_t count) { } +// The two sides into gU8Left and gU8Right: channels 0 and 3 left, 1 and 2 +// right, as Paula plays them. +static void voiceMixSidesU8(uint32_t count) { + memset(gU8Left, MOD_PLAYER_SILENCE_U8, count); + memset(gU8Right, MOD_PLAYER_SILENCE_U8, count); + voiceMixU8(&gPlay.paula[0], gU8Left, count); + voiceMixU8(&gPlay.paula[1], gU8Right, count); + voiceMixU8(&gPlay.paula[2], gU8Right, count); + voiceMixU8(&gPlay.paula[3], gU8Left, count); +} + + // One channel added into the 8-bit output: runs of its sample through the // volume's table, each as far as its block's end, on to the next block there. static void voiceMixU8(PaulaT *a, uint8_t *dst, uint32_t count) { @@ -1479,22 +1525,28 @@ static bool voiceWrap(PaulaT *a, uint32_t *idx) { } -// gVolTab[v][s] is the signed sample s (as its byte) times volume v, over 256: -// a quarter of the sample at volume 64. Built by sums, so the 68000 needs no -// multiplies for it. +// gVolTab[v][s] is the signed sample s (as its byte) times volume v, over 64 +// << gU8Shift: the sample shifted down gU8Shift bits at volume 64. Built by +// sums, so the 68000 needs no multiplies for it. And gU8Clamp, the sides' +// sum. static void volTabBuild(void) { uint16_t volume; uint16_t s; int16_t product; + int16_t sum; for (volume = 0u; volume <= VOLUME_MAX; volume++) { // From sample -128 up: product steps by volume. product = (int16_t)(-(int16_t)U8_BIAS * (int16_t)volume); for (s = 0u; s < SAMPLE_VALUES; s++) { - gVolTab[volume][(uint8_t)(s + U8_BIAS)] = (int8_t)(product >> (VOLUME_SHIFT + U8_SHIFT)); + gVolTab[volume][(uint8_t)(s + U8_BIAS)] = (int8_t)(product >> (VOLUME_SHIFT + gU8Shift)); product = (int16_t)(product + (int16_t)volume); } } + for (s = 0u; s < U8_SUMS; s++) { + sum = (int16_t)((int16_t)s - U8_BIAS); + gU8Clamp[s] = (uint8_t)(sum < 0 ? 0 : (sum > U8_MAX ? U8_MAX : sum)); + } gVolTabBuilt = true; } diff --git a/src/core/modPlayer.h b/src/core/modPlayer.h index dab329f..a7ae6f1 100644 --- a/src/core/modPlayer.h +++ b/src/core/modPlayer.h @@ -20,16 +20,26 @@ // a song that does not loop, its end. bool modPlayerActive(void); -// Mono unsigned 8-bit, centred on MOD_PLAYER_SILENCE_U8: the DOS, ST and -// X68000 ports' format. Returns false once the song has ended (or nothing -// plays): the rest of dst is silence and the player has stopped. -bool modPlayerRenderU8(uint8_t *dst, uint32_t count); +// Unsigned 8-bit, centred on MOD_PLAYER_SILENCE_U8: the DOS, ST and X68000 +// ports' format, mono when channels is 1, and interleaved when it is 2 +// (Paula's left and right channels; each channel at the level +// modPlayerSetU8Shift gives it, in mono or stereo alike). Returns +// false once the song has ended (or nothing plays): the rest of dst is +// silence and the player has stopped. +bool modPlayerRenderU8(uint8_t *dst, uint32_t frames, uint8_t channels); // Signed 16-bit frames, interleaved when channels is 2 (Paula's left and // right channels, as the Amiga plays them), mono when it is 1: the hosted // ports' format. Returns false as modPlayerRenderU8 does. bool modPlayerRenderS16(int16_t *dst, uint32_t frames, uint8_t channels); +// The 8-bit renders' level: a channel at full volume playing a full-scale +// sample swings full scale shifted down shift bits, 1 or 2 (2 until set: +// four channels fill a byte and never wrap). At 1 a side's two channels +// still fit, and a mono render adds its two sides through a clamp, so four +// loud channels clip. A port sets its own before modPlayerStart. +void modPlayerSetU8Shift(uint8_t shift); + // Plays the module data[0..length) at rate output samples a second. The // player keeps its own copy, so data may go once this returns. loop false // ends the song where it comes back to a row it has played (its end, or a diff --git a/src/core/port.h b/src/core/port.h index 1d7db74..63563a5 100644 --- a/src/core/port.h +++ b/src/core/port.h @@ -40,16 +40,15 @@ #ifdef JOEYLIB_PLATFORM_IIGS -// Root the save tree at the APPLICATION directory via the GS/OS "1/" prefix -// designator (the Loader sets prefix 1 to the app's directory at launch). -// Unrooted paths resolve against prefix 0, which a Finder launch leaves -// pointing away from (or nowhere near) the app's volume - measured: every -// plain "SAVES/x" open failed under MAME GS/OS 6 while the same file opened -// fine through "1/SAVES/x". libc's fopen passes the string verbatim to -// GS/OS Open, which accepts prefix-designator syntax. -#define JL_SAVE_DIR "1/SAVES" -#define JL_SAVE_DIR_PREFIX "1/SAVES/" -#define JL_SAVE_PREFIX_NUM 1u // the GS/OS prefix those name: jlpDiskFree's volume +// The file roots (fileRoot.h) are the APPLICATION directory, through the +// GS/OS "1/" prefix designator (the Loader sets prefix 1 to the app's +// directory at launch). Unrooted paths resolve against prefix 0, which a +// Finder launch leaves pointing away from (or nowhere near) the app's +// volume - measured: every plain "SAVES/x" open failed under MAME GS/OS 6 +// while the same file opened fine through "1/SAVES/x". libc's fopen passes +// the string verbatim to GS/OS Open, which accepts prefix-designator syntax. +#define IIGS_FILE_ROOT "1/" +#define JL_SAVE_PREFIX_NUM 1u // the GS/OS prefix that names: jlpDiskFree's volume // ===================================================================== // IIgs direct-dispatch macros. @@ -823,6 +822,14 @@ static inline uint32_t audioNoiseHz(uint8_t pitch) { return AUDIO_NOISE_CLOCK_HZ / (pitch == 0u ? 1u : (uint32_t)pitch); } +// An effect faster than the port's chip plays (the Amiga's Paula, which +// reads every byte it is given) plays every audioDecimationFactor-th sample +// at rateHz over the factor, so at its own pitch (src/core/audio.c); +// audioDecimate picks them, a stream's chunks one after another with its +// phase carried between them. (The IIgs's DOC steps over samples itself.) +uint32_t audioDecimate(uint8_t *dst, const uint8_t *src, uint32_t count, uint8_t factor, uint8_t *phase); +uint8_t audioDecimationFactor(uint16_t rateHz, uint16_t maxHz); + bool jlpGenericAudioInit(void); #if !defined(jlpAudioInit) #if defined(JL_HAS_AUDIO_INIT) @@ -889,6 +896,11 @@ void jlpGenericAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *c #define jlpAudioPlaySfxStreamRaw(_a, _b, _c, _d) jlpAudioPlaySfxStream((_a), (_b), (_c), (_d)) #endif #endif +// DVX's sound library takes 8-bit PCM unsigned, so the IIgs's raw format +// (unsigned, 0x00 never used) is DVX's own bytes too: no flip. +#if defined(JOEYLIB_PLATFORM_DVX) + #define jlpAudioSfxRawFormat() 1u +#endif #if !defined(jlpAudioSfxRawFormat) #define jlpAudioSfxRawFormat() 0u #endif @@ -1096,6 +1108,18 @@ void jlpGenericSerialSetRts(bool on); #define jlpSerialSetRts(_o) jlpGenericSerialSetRts((_o)) #endif #endif +// A port that feeds its transmitter a byte at a time, polling until it takes +// each, bounds the wait with these (src/core/serial.c): serialTxStalled turns +// true once the transmitter has taken nothing for SERIAL_TX_STALL_MS (a peer +// holding transmit off, a line held), or after pollLimit polls, which is +// what stops a wait on a clock that does not run yet (before jlInit). +typedef struct { + uint32_t startMs; + uint32_t polls; + uint32_t pollLimit; +} SerialTxWaitT; +bool serialTxStalled(SerialTxWaitT *wait); +void serialTxWaitStart(SerialTxWaitT *wait, uint32_t pollLimit); // --- Save files + disk space (platform-only; blank-port stub generic + JL_HAS override) --- @@ -1125,6 +1149,14 @@ uint32_t jlpGenericDiskFree(void); #define jlpDiskFree() jlpGenericDiskFree() #endif #endif +bool jlpGenericSaveRename(const char *from, const char *to); +#if !defined(jlpSaveRename) + #if defined(JL_HAS_SAVE_RENAME) + bool jlpSaveRename(const char *from, const char *to); + #else + #define jlpSaveRename(_f, _t) jlpGenericSaveRename((_f), (_t)) + #endif +#endif // --- Compiled code written (the codegen arena) --- diff --git a/src/core/psgSynth.c b/src/core/psgSynth.c new file mode 100644 index 0000000..20df2d2 --- /dev/null +++ b/src/core/psgSynth.c @@ -0,0 +1,179 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// The software sound chip (psgSynth.h). Phase accumulators are 32-bit +// fractions of a cycle, the top bit a square's half; the noise's is 32.32, +// whole LFSR clocks above the point. Integer throughout, so it builds where +// there is no maths library. + +#include + +#include "port.h" +#include "psgSynth.h" + +// ----- Constants ----- + +// Attenuation 15 (and up) is silence (joey/audio.h). +#define ATTEN_SILENT 15u +#define ATTEN_STEPS 16u +#define PHASE_SHIFT 32 +#define PHASE_HIGH_HALF 0x80000000u + +// The YM2149 noise generator: a 17-bit LFSR fed by bit 0 XOR bit 3, clocked +// at AUDIO_NOISE_CLOCK_HZ / period, where period is the pitch (0 counts as 1). +#define NOISE_LFSR_SEED 1u +#define NOISE_LFSR_TOP_SHIFT 16 +#define NOISE_LFSR_TAP_SHIFT 3 + +// ----- Types ----- + +typedef struct { + uint32_t phase; + uint32_t step; // phase per frame; 0 = silent + int16_t amp; +} SquareT; + +// ----- Prototypes ----- + +static int16_t attenAmp(uint8_t atten); +static void mixEveryChannel(int32_t *frame, uint8_t channels, int16_t level); + +// ----- Module state ----- + +// PSG_SYNTH_PEAK 2 dB less a step: round(PSG_SYNTH_PEAK * 10^(-step / 10)). +static const int16_t gAttenAmp[ATTEN_STEPS] = { + 8191, 6506, 5168, 4105, 3261, 2590, 2057, 1634, 1298, 1031, 819, 651, 517, 411, 326, 0 +}; + +typedef char psgSynthPeakFits[(4 * PSG_SYNTH_PEAK <= INT16_MAX) ? 1 : -1]; +typedef char psgSynthTableTop[(PSG_SYNTH_PEAK == 8191) ? 1 : -1]; + +static uint32_t gRate = 0u; +static SquareT gVoices[JL_AUDIO_VOICES]; +static uint64_t gNoisePhase = 0u; +static uint64_t gNoiseStep = 0u; // LFSR clocks per frame, 32.32 +static uint32_t gNoiseLfsr = NOISE_LFSR_SEED; +static int16_t gNoiseAmp = 0; +static jlAudioTickFnT gTickFn = NULL; +static uint32_t gTickHz = 0u; +static uint32_t gTickAcc = 0u; // gTickHz a frame; a tick each time it passes gRate + +// ----- Internal helpers (alphabetical) ----- + +static int16_t attenAmp(uint8_t atten) { + return gAttenAmp[atten < ATTEN_SILENT ? atten : ATTEN_SILENT]; +} + + +static void mixEveryChannel(int32_t *frame, uint8_t channels, int16_t level) { + uint8_t c; + + for (c = 0; c < channels; c++) { + frame[c] += level; + } +} + +// ----- Synth API (alphabetical) ----- + +void psgSynthInit(uint32_t rate) { + gRate = rate; + memset(gVoices, 0, sizeof(gVoices)); + gNoisePhase = 0u; + gNoiseStep = 0u; + gNoiseLfsr = NOISE_LFSR_SEED; + gNoiseAmp = 0; + gTickFn = NULL; + gTickHz = 0u; + gTickAcc = 0u; +} + + +void psgSynthMix(int32_t *mix, uint32_t frames, uint8_t channels) { + SquareT *sq; + uint32_t i; + uint32_t clocks; + uint8_t v; + int16_t level; + + for (v = 0; v < JL_AUDIO_VOICES; v++) { + sq = &gVoices[v]; + if (sq->step == 0u || sq->amp == 0) { + continue; + } + for (i = 0; i < frames; i++) { + sq->phase += sq->step; + level = (sq->phase & PHASE_HIGH_HALF) != 0u ? sq->amp : (int16_t)-sq->amp; + mixEveryChannel(&mix[i * channels], channels, level); + } + } + if (gNoiseAmp == 0) { + return; + } + for (i = 0; i < frames; i++) { + gNoisePhase += gNoiseStep; + for (clocks = (uint32_t)(gNoisePhase >> PHASE_SHIFT); clocks > 0u; clocks--) { + gNoiseLfsr = (gNoiseLfsr >> 1) | (((gNoiseLfsr ^ (gNoiseLfsr >> NOISE_LFSR_TAP_SHIFT)) & 1u) << NOISE_LFSR_TOP_SHIFT); + } + gNoisePhase &= (uint64_t)UINT32_MAX; + level = (gNoiseLfsr & 1u) != 0u ? gNoiseAmp : (int16_t)-gNoiseAmp; + mixEveryChannel(&mix[i * channels], channels, level); + } +} + + +void psgSynthNoise(uint8_t pitch, uint8_t atten) { + uint32_t period; + + if (gRate == 0u) { + return; + } + period = pitch == 0u ? 1u : pitch; + gNoiseStep = ((uint64_t)AUDIO_NOISE_CLOCK_HZ << PHASE_SHIFT) / period / gRate; + gNoiseAmp = attenAmp(atten); +} + + +bool psgSynthTickRegister(jlAudioTickFnT fn, uint16_t hz) { + if (fn != NULL && (hz == 0u || hz > gRate)) { + return false; + } + gTickFn = fn; + gTickHz = hz; + gTickAcc = 0u; + return true; +} + + +uint32_t psgSynthTickRun(uint32_t frames) { + uint32_t run; + + if (gTickFn == NULL) { + return frames; + } + // A callback may unregister itself, or register another. + while (gTickFn != NULL && gTickAcc >= gRate) { + gTickAcc -= gRate; + gTickFn(); + } + if (gTickFn == NULL) { + return frames; + } + // Frames until the accumulator reaches the next tick. + run = (gRate - gTickAcc + gTickHz - 1u) / gTickHz; + if (run > frames) { + run = frames; + } + gTickAcc += run * gTickHz; + return run; +} + + +void psgSynthVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { + if (voice >= JL_AUDIO_VOICES || gRate == 0u) { + return; + } + // hz cycles a second in 32-bit fractions of a cycle per frame. + gVoices[voice].step = (uint32_t)(((uint64_t)freqHz << PHASE_SHIFT) / gRate); + gVoices[voice].amp = attenAmp(atten); +} diff --git a/src/core/psgSynth.h b/src/core/psgSynth.h new file mode 100644 index 0000000..898be03 --- /dev/null +++ b/src/core/psgSynth.h @@ -0,0 +1,48 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// The software sound chip: the three tone voices as square waves and the +// noise voice as the YM2149's 17-bit LFSR clocked at AUDIO_NOISE_CLOCK_HZ / +// pitch (the ST's chip, the reference for the noise curve), each attenuated +// 2 dB a step (joey/audio.h), mixed at one rate -- the sound of the hosted +// ports (src/hosted/audio.c) and of DVX (src/dvx/audio.c). And the tick +// placement those mixers share: a jlAudioTickRegister callback runs between +// exactly the samples it falls between, however the mixing is batched, so +// notes start where they should in the sound. +// +// One synth a program: module state, set up by psgSynthInit at the mix +// rate. Every call runs where the mixer runs (the program's thread, or +// DVX's sound pump), never in an interrupt. + +#ifndef JOEYLIB_PSG_SYNTH_H +#define JOEYLIB_PSG_SYNTH_H + +#include +#include + +#include "joey/audio.h" + +// The loudest a voice (or the noise) gets in the mix: a quarter of a 16-bit +// sample, so four at full volume stay inside one. A mixer puts a full-scale +// sound effect's peak here too (joey/audio.h's mix). +#define PSG_SYNTH_PEAK 8191 + +// Silence, every voice and the noise, and no tick, at rate frames a second. +void psgSynthInit(uint32_t rate); +// Add frames of the voices and the noise to mix (channels interleaved +// samples a frame, each voice in every channel). +void psgSynthMix(int32_t *mix, uint32_t frames, uint8_t channels); +// jlAudioNoise and jlAudioVoice (voice 0 .. JL_AUDIO_VOICES - 1). +void psgSynthNoise(uint8_t pitch, uint8_t atten); +void psgSynthVoice(uint8_t voice, uint16_t freqHz, uint8_t atten); +// jlAudioTickRegister: fn hz times a second of the mix (NULL: none). False +// for a rate of 0 or above the mix rate. +bool psgSynthTickRegister(jlAudioTickFnT fn, uint16_t hz); +// The mixer is about to mix up to frames frames: every tick due before the +// first of them runs now, and the frames to mix before the next tick come +// back (frames when there is none in them). The mixer mixes exactly that +// many, then asks again. +uint32_t psgSynthTickRun(uint32_t frames); + +#endif diff --git a/src/core/save.c b/src/core/save.c index 56243c2..65e5a7b 100644 --- a/src/core/save.c +++ b/src/core/save.c @@ -5,11 +5,14 @@ // Writable save files (SAVES/) and a disk-space query. // // Mirrors jlDataOpen's forced-prefix approach (see assetLoad.c) but rooted at -// the writable SAVES/ tree instead of read-only DATA/, and it creates SAVES/ -// on demand before a write open. Blob read/write/exists are plain stdio over -// jlSaveOpen; only the two operations stdio cannot do portably -- create the -// directory and delete a file -- plus the disk-free query are per-port HAL -// hooks (jlpSaveDirEnsure / jlpSaveDelete / jlpDiskFree). +// the writable SAVES/ tree, under the port's write root (fileRoot.h), instead +// of read-only DATA/, and it creates SAVES/ on demand before a write open. +// Blob read/exists are plain stdio over jlSaveOpen; jlSaveWrite writes a +// temporary file and renames it over the save, so a write that fails or is +// cut short leaves the old save whole. The operations stdio cannot do +// portably -- create the directory, delete and rename a file -- plus the +// disk-free query are per-port HAL hooks (jlpSaveDirEnsure, jlpSaveDelete, +// jlpSaveRename, jlpDiskFree). #include #include @@ -19,21 +22,16 @@ #include "port.h" -// A port that must root the save tree elsewhere overrides these in port.h -// (IIgs: GS/OS prefix-designator "1/" = the application directory - unrooted -// paths resolve against prefix 0, which a Finder launch does not point at the -// app's volume, so plain "SAVES/x" opens fail there). -#ifndef JL_SAVE_DIR -#define JL_SAVE_DIR "SAVES" -#define JL_SAVE_DIR_PREFIX "SAVES/" -#endif -#define SAVE_DIR JL_SAVE_DIR -#define SAVE_DIR_PREFIX JL_SAVE_DIR_PREFIX -#define SAVE_PATH_MAX 256 +#define SAVE_DIR "SAVES" +#define SAVE_DIR_PREFIX "SAVES/" // A save name every port's filesystem takes as it is: DOS's 8.3, which also // fits ProDOS's 15 characters starting with a letter. #define SAVE_NAME_BASE_MAX 8u #define SAVE_NAME_EXT_MAX 3u +// jlSaveWrite's temporary file: its 10-letter name is outside the rule, so +// no save can be called it, but every filesystem takes it (DOS keeps the +// first 8 letters of it, every time). +#define SAVE_TEMP_NAME "JOEYLIBTMP" // ----- Helpers ----- @@ -41,33 +39,18 @@ static bool buildSavePath(char *out, size_t cap, const char *name); static bool charIsAlnum(char c); static bool charIsLetter(char c); -#if defined(JL_HAS_FILE_ROOTS) static void ensureSaveDir(void); -#else -// SAVES/ is relative to the current directory: ensure it by its literal name. -#define ensureSaveDir() ((void)jlpSaveDirEnsure(SAVE_DIR)) -#endif static bool saveNameValid(const char *name); -// Join SAVE_DIR_PREFIX + name into `out` (under the write root on a port -// with run-time roots, fileRoot.h). Returns false for a name outside the -// portable rule (saveNameValid), on every port alike, or if it would -// overflow. +// Join SAVE_DIR_PREFIX + name under the write root into `out`. Returns +// false for a name outside the portable rule (saveNameValid), on every +// port alike, or if it would overflow. static bool buildSavePath(char *out, size_t cap, const char *name) { -#if !defined(JL_HAS_FILE_ROOTS) - int written; - -#endif if (!saveNameValid(name)) { return false; } -#if defined(JL_HAS_FILE_ROOTS) return fileRootJoin(out, cap, jlpWriteRoot(), SAVE_DIR_PREFIX, name); -#else - written = snprintf(out, cap, SAVE_DIR_PREFIX "%s", name); - return written >= 0 && (size_t)written < cap; -#endif } @@ -81,16 +64,15 @@ static bool charIsLetter(char c) { } -#if defined(JL_HAS_FILE_ROOTS) -// Create SAVES/ under the write root. Advisory, like the literal form above. +// Create SAVES/ under the write root. Advisory: if the folder cannot be +// made, the open that follows fails and its caller sees that. static void ensureSaveDir(void) { - char dir[SAVE_PATH_MAX]; + char dir[FILE_PATH_MAX]; if (fileRootJoin(dir, sizeof(dir), jlpWriteRoot(), SAVE_DIR, "")) { (void)jlpSaveDirEnsure(dir); } } -#endif // Up to SAVE_NAME_BASE_MAX letters and digits starting with a letter, then @@ -129,12 +111,9 @@ uint32_t jlDiskFree(void) { bool jlSaveDelete(const char *name) { - char path[SAVE_PATH_MAX]; + char path[FILE_PATH_MAX]; - if (name == NULL) { - return false; - } - if (!buildSavePath(path, sizeof(path), name)) { + if (name == NULL || !buildSavePath(path, sizeof(path), name)) { return false; } return jlpSaveDelete(path); @@ -142,7 +121,7 @@ bool jlSaveDelete(const char *name) { bool jlSaveExists(const char *name) { - FILE *fp; + FILE *fp; if (name == NULL) { return false; @@ -157,27 +136,21 @@ bool jlSaveExists(const char *name) { FILE *jlSaveOpen(const char *name, const char *mode) { - char path[SAVE_PATH_MAX]; + char path[FILE_PATH_MAX]; - if (name == NULL || mode == NULL) { + if (name == NULL || mode == NULL || !buildSavePath(path, sizeof(path), name)) { return NULL; } - // Create SAVES/ before a write / append open. The result is advisory: if - // the directory cannot be made, fopen below simply fails and the caller - // sees NULL. if (strchr(mode, 'w') != NULL || strchr(mode, 'a') != NULL) { ensureSaveDir(); } - if (!buildSavePath(path, sizeof(path), name)) { - return NULL; - } return fopen(path, mode); } uint32_t jlSaveRead(const char *name, void *buf, uint32_t max) { - FILE *fp; - size_t got; + FILE *fp; + size_t got; if (name == NULL || buf == NULL || max == 0u) { return 0u; @@ -192,16 +165,26 @@ uint32_t jlSaveRead(const char *name, void *buf, uint32_t max) { } +// The new bytes go to SAVE_TEMP_NAME first; only once they are all written +// and closed does the old save go and the temporary file take its name. A +// write that fails, or a machine switched off part way, leaves the old save +// as it was. The old save is deleted before the rename because not every +// system's rename replaces a file that exists. bool jlSaveWrite(const char *name, const void *buf, uint32_t len) { - FILE *fp; - size_t written; + char path[FILE_PATH_MAX]; + char temp[FILE_PATH_MAX]; + FILE *fp; + size_t written; + bool ok; - // Refused before the save is opened: opening it for writing empties it, - // and a call with nothing to write must not cost the old one. if (name == NULL || (buf == NULL && len > 0u)) { return false; } - fp = jlSaveOpen(name, "wb"); + if (!buildSavePath(path, sizeof(path), name) || !fileRootJoin(temp, sizeof(temp), jlpWriteRoot(), SAVE_DIR_PREFIX, SAVE_TEMP_NAME)) { + return false; + } + ensureSaveDir(); + fp = fopen(temp, "wb"); if (fp == NULL) { return false; } @@ -209,6 +192,11 @@ bool jlSaveWrite(const char *name, const void *buf, uint32_t len) { if (buf != NULL && len > 0u) { written = fwrite(buf, 1u, (size_t)len, fp); } - fclose(fp); - return written == (size_t)len; + ok = fclose(fp) == 0 && written == (size_t)len; + if (!ok) { + (void)jlpSaveDelete(temp); + return false; + } + (void)jlpSaveDelete(path); + return jlpSaveRename(temp, path); } diff --git a/src/core/serial.c b/src/core/serial.c index 4b1d739..1a9771b 100644 --- a/src/core/serial.c +++ b/src/core/serial.c @@ -33,6 +33,12 @@ #define SERIAL_STOP_BITS_DEF 1u #define SERIAL_BAUD_DEF 9600u +// A transmit wait gives up after this long (serialTxStalled). The clock is +// read every SERIAL_TX_CLOCK_POLLS polls, from the first such read on, so a +// byte the transmitter takes at once costs no clock read at all. +#define SERIAL_TX_STALL_MS 100u +#define SERIAL_TX_CLOCK_POLLS 64u + // ----- Module state ----- @@ -167,3 +173,30 @@ bool jlSerialWriteByte(uint8_t b) { static void serialAtExit(void) { jlSerialClose(); } + + +// One more poll of a busy transmitter (port.h): true once the wait is over. +bool serialTxStalled(SerialTxWaitT *wait) { + uint32_t now; + + wait->polls++; + if (wait->polls >= wait->pollLimit) { + return true; + } + if (wait->polls % SERIAL_TX_CLOCK_POLLS != 0u) { + return false; + } + now = jlpMillisElapsed(); + if (wait->polls == SERIAL_TX_CLOCK_POLLS) { + wait->startMs = now; + return false; + } + return now - wait->startMs >= SERIAL_TX_STALL_MS; +} + + +// A transmit wait begins (port.h): pollLimit is the port's backstop. +void serialTxWaitStart(SerialTxWaitT *wait, uint32_t pollLimit) { + wait->polls = 0u; + wait->pollLimit = pollLimit; +} diff --git a/src/core/sprite.c b/src/core/sprite.c index 010fa8c..1beb36d 100644 --- a/src/core/sprite.c +++ b/src/core/sprite.c @@ -49,7 +49,7 @@ * the sprite -- the backup captured the SPRITE instead of the background, so * the paired restore repainted it and left residue. Draw-only rows hid this * because a double draw is idempotent. */ -#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_X68000) +#if defined(SPRITE_68K) #define COMPILED_SPRITE_WRITES_PLANES 1 #else #define COMPILED_SPRITE_WRITES_PLANES 0 @@ -785,7 +785,7 @@ void jlSpriteDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) { if (!COMPILED_SPRITE_WRITES_PLANES) { jlpSpriteDrawPlanes(s, sp, x, y); } -#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JL_CPU_X86) +#if defined(SPRITE_FAST_PATHS) // P7-1 lever b: inline the dominant h==16 stage mark to drop // the spriteMarkDirty call boundary (see the restore arm in // jlSpriteRestoreUnder). Other heights / non-stage draws keep @@ -813,7 +813,7 @@ void jlSpriteDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) { // fully-validated jlSpriteDraw for uncompiled sprites and on the ports // without compiled sprites. void jlSpriteDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) { -#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JL_CPU_X86) +#if defined(SPRITE_FAST_PATHS) if (sp->slot != NULL) { uint8_t shift; uint16_t drawOffset; @@ -910,7 +910,7 @@ void jlSpriteSaveAndDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlS jlpSpriteSavePlanes(s, backup->x, backup->y, backup->width, backup->height, backup->bytes); jlpSpriteDrawPlanes(s, sp, x, y); } -#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JL_CPU_X86) +#if defined(SPRITE_FAST_PATHS) // P7-1 lever b: inline the h==16 stage mark (see jlSpriteDraw). if (s == gStage && heightPx == 16) { SPRITE_MARK_STAGE_ROWS16(x, widthPx, y); @@ -944,7 +944,7 @@ void jlSpriteSaveAndDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlS // sprite entries). #if !defined(JOEYLIB_PLATFORM_IIGS) void jlSpriteSaveAndDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) { -#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JL_CPU_X86) +#if defined(SPRITE_FAST_PATHS) ArenaSlotT *slot; slot = sp->slot; @@ -1041,7 +1041,7 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) { if (s == gStage && iigsSpriteRestoreStage(backup) == 0u) { return; } -#elif defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JL_CPU_X86) +#elif defined(SPRITE_FAST_PATHS) // Fused stage restore (P7-1 lever b): the compiled copy body // stays INLINED (spriteCompiledRestoreUnder is always-inline, // sharing the backup-field loads with the validation above), @@ -1118,7 +1118,7 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) { // The IIgs body is asm (joeyDraw.s, beside the stage sprite entries). #if !defined(JOEYLIB_PLATFORM_IIGS) void jlSpriteRestoreUnderTrusted(jlSurfaceT *s, const jlSpriteBackupT *backup) { -#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JL_CPU_X86) +#if defined(SPRITE_FAST_PATHS) jlSpriteT *sp; int16_t bx; int16_t by; @@ -1243,7 +1243,7 @@ void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, // metadata so they can size their buffer for the next // SaveUnder call. RestoreUnder will refuse to operate on a // backup with bytes==NULL. -#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_X68000) +#if defined(SPRITE_68K) // Planar metadata widening: a real (buffered) unclipped save on // these ports goes through the compiled 16-px-region path, so a // probe-then-allocate caller must be told the REGION size, not @@ -1285,15 +1285,14 @@ void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, // TRUSTED save-under (P7-1). Skips the NULL / on-surface / backup gates // jlSpriteSaveUnder runs, going straight to the compiled save. Same // trust contract as jlSpriteRestoreUnderTrusted (see joey/sprite.h). -// Optimized on Amiga/ST (the compiled save is a read, so there is no -// fused entry -- just skip validation, and set backup->sprite, which -// their compiled save does not and jlSpriteRestoreUnder* reads) and on -// the IIgs (the stage save entry, which fills all the backup's -// metadata); falls back to the fully-validated jlSpriteSaveUnder for -// uncompiled sprites, other destinations on the IIgs, and on DOS / -// generic. +// Optimized on the 68000 ports and x86 (the compiled save is a read, so +// there is no fused entry -- just skip validation, and set +// backup->sprite, which jlSpriteRestoreUnder* reads) and on the IIgs (the +// stage save entry, which fills all the backup's metadata); falls back to +// the fully-validated jlSpriteSaveUnder for uncompiled sprites, other +// destinations on the IIgs, and on the interpreting ports. void jlSpriteSaveUnderTrusted(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) { -#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) +#if defined(SPRITE_FAST_PATHS) ArenaSlotT *slot; slot = sp->slot; diff --git a/src/core/spriteInternal.h b/src/core/spriteInternal.h index 132801c..7046d74 100644 --- a/src/core/spriteInternal.h +++ b/src/core/spriteInternal.h @@ -12,6 +12,17 @@ #include "codegenArenaInternal.h" #include "joey/sprite.h" +// SPRITE_68K: the 68000 ports, which share the planar sprite paths (the ST +// with an emitter of its own); their compiled routines write the planes +// directly. SPRITE_FAST_PATHS: the ports whose compiled draw, save and +// restore take the fused fast paths -- the 68000 ports and x86. +#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_X68000) +#define SPRITE_68K +#endif +#if defined(SPRITE_68K) || defined(JL_CPU_X86) +#define SPRITE_FAST_PATHS +#endif + #define SPRITE_OP_DRAW 0 #define SPRITE_OP_SAVE 1 #define SPRITE_OP_RESTORE 2 @@ -83,7 +94,7 @@ // backup->bytes, so an odd caller buffer must route BOTH ops to the // byte-safe interpreted paths (pointer parity is stable between save // and restore, so provenance never splits). -#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_X68000) +#if defined(SPRITE_68K) #define SPRITE_GROUPS0(_wPx) ((uint16_t)(((_wPx) + 15u) >> 4)) // NOTE (P7-1, 2026-07-11): an algebraically-reduced form of these two // macros (save class = (x&15)+((widthPx+15)&15) >= 16; restore class = diff --git a/src/dos/audio.c b/src/dos/audio.c index 6034a99..c84f552 100644 --- a/src/dos/audio.c +++ b/src/dos/audio.c @@ -2,23 +2,29 @@ // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // -// DOS audio HAL: Sound Blaster Pro / SB16 8-bit mono playback driven -// by 8237 auto-init DMA, fed by the shared module player -// (src/core/modPlayer.c) and SFX overlay (src/core/audioSfxMix.c). +// DOS audio HAL: Sound Blaster 8-bit playback driven by 8237 auto-init +// DMA, fed by the shared module player (src/core/modPlayer.c) and SFX +// overlay (src/core/audioSfxMix.c): stereo on an SB16 (DSP 4.xx), at +// 22050 Hz exactly, the module's channels on Paula's sides and each sound +// effect where jlAudioSetPan put it; mono on the cards before it (an SB +// Pro's stereo needs the DSP's high-speed mode, which stops answering +// commands), at the 22222 Hz their time constant gives 22050. // // Pipeline: // * jlpAudioInit detects the card via DSP reset + version, allocates -// a 4 KB conventional-memory DMA buffer (aligned to not cross a -// 64 KB boundary), installs an IRQ handler on the SB's IRQ line, -// and arms auto-init DMA at 22050 Hz mono 8-bit. The DMA buffer is -// split into two halves; the SB IRQ fires at the end of each half +// a conventional-memory DMA buffer (aligned to not cross a 64 KB +// boundary), installs an IRQ handler on the SB's IRQ line, and arms +// auto-init DMA. The DMA buffer is split into two halves of +// MIX_HALF_FRAMES frames; the SB IRQ fires at the end of each half // and flags it, and jlpAudioFrameTick refills it on the main thread. -// * jlpAudioPlayMod starts the module player; the refills render it. +// * jlpAudioPlayMod starts the module player, mixed into what is ahead +// of the DSP at once; the refills render the rest. // * jlpAudioPlaySfx arms an overlay slot, mixed over the module in the -// same refills. +// same way. // -// Defaults match DOSBox-Staging's emulated SB Pro: base $220, IRQ 7, -// 8-bit DMA channel 1. BLASTER environment variable overrides each. +// Defaults match DOSBox-Staging's emulated Sound Blaster (an SB16): base +// $220, IRQ 7, 8-bit DMA channel 1. BLASTER environment variable overrides +// each. // // Because DJGPP runs in protected mode with paging, the IRQ handler // code and any data it touches (the refill flags, the SB's port and IRQ) @@ -61,10 +67,43 @@ #define DSP_OUT_8_AUTO 0x1C #define DSP_HALT_DMA8 0xD0 #define DSP_EXIT_AUTO 0xDA +// The SB16's (DSP 4.xx): the output rate in Hz, then 8-bit auto-init output +// through the FIFO, its mode byte (stereo, unsigned) and its block length. +#define DSP_OUT_RATE 0x41 +#define DSP_OUT_8_AUTO_FIFO 0xC6 +#define DSP_MODE_STEREO_U8 0x20 +#define DSP_MAJOR_SB16 4 #define DSP_RESET_RETRIES 1024 #define DSP_READY_BYTE 0xAA +// ----- The SB Pro's and SB16's mixer ----- +// +// The mix joey/audio.h asks for: a full-scale sample, and a module channel at +// full volume, as loud as a voice at attenuation 0. The mix puts both at a +// quarter of the DSP's full scale (MIX_UNIT_SHIFT), so the module's four +// channels fit a byte, and the OPL2's loudest voice is about 5 dB under +// that, so the mixer plays the DSP's output (its "voice" input) 4 dB under +// the FM, which it sets to full; jlShutdown puts both back. A card before +// the SB Pro has no mixer, and plays effects and modules that much over. +#define SB_MIXER_INDEX_OFFSET 0x04 +#define SB_MIXER_DATA_OFFSET 0x05 +#define DSP_MAJOR_SBPRO 3 +// The SB Pro's: a nibble a side (left high), three bits of it, ~4 dB a step. +#define MIXPRO_VOICE 0x04 +#define MIXPRO_FM 0x26 +#define MIXPRO_FULL 0xFF +#define MIXPRO_VOICE_MIX 0xDD // one step down: about 4 dB +// The SB16's: a register a side, five bits (7-3), 2 dB a step. +#define MIX16_VOICE_L 0x32 +#define MIX16_VOICE_R 0x33 +#define MIX16_FM_L 0x34 +#define MIX16_FM_R 0x35 +#define MIX16_FULL 0xF8 +#define MIX16_VOICE_MIX 0xE8 // 29 of 31: 4 dB down +#define MIXER_SETS_MAX 4u +#define MIXER_SET_COUNT(_t) ((uint8_t)(sizeof(_t) / sizeof((_t)[0]))) + // ----- 8237 DMA controller (the 8-bit channel BLASTER's D names) ----- #define DMA_MASK 0x0A @@ -96,10 +135,20 @@ static const uint8_t kDmaPagePort[DMA8_CHANNELS] = { 0x87, 0x83, 0x81, 0x82 }; // the same time constant (see gEffRate, set in jlpAudioInit) is the // single source of truth handed to the module player and the SFX resampler. #define MIX_RATE 22050 -#define MIX_DMA_BUFFER 4096 // total; two 2048-byte halves -#define MIX_HALF (MIX_DMA_BUFFER / 2) +// The buffer is two halves of MIX_HALF_FRAMES frames each: bytes in mono, +// left and right byte pairs in stereo (an SB16 plays stereo; the cards +// before it play mono). +#define MIX_HALF_FRAMES 2048u +#define MIX_CHANNELS_MAX 2u +#define MIX_HALF_MAX (MIX_HALF_FRAMES * MIX_CHANNELS_MAX) +#define MIX_DMA_BUFFER_MAX (MIX_HALF_MAX * 2u) #define MIX_SILENCE MOD_PLAYER_SILENCE_U8 -// Bytes ahead of the DSP where a new sound's start-now overlay begins +// The 8-bit mix's unit: a module channel at full volume and a full-scale +// sound effect each swing a quarter of a byte, so the module's four channels +// fit (modPlayerSetU8Shift, audioSfxSetShift); the card's mixer makes up +// the level (above). +#define MIX_UNIT_SHIFT 2u +// Frames ahead of the DSP where a new sound's start-now overlay begins // (sfxStartNow): about 3 ms, so the write always lands before the read. #define SFX_START_MARGIN 64u #define SB_DSP_CLOCK_HZ 1000000u // 8-bit DSP time-constant base @@ -120,6 +169,9 @@ static bool gDspPresent = false; // the module player and the SFX resampler, so they match what the DAC // actually clocks (~22222 Hz for a 22050 Hz target) instead of drifting. static uint16_t gEffRate = MIX_RATE; +// Mono or stereo (DSP 4.xx), and so a half's bytes. +static uint8_t gChannels = 1u; +static uint16_t gHalfBytes = MIX_HALF_FRAMES; // Conventional-memory DMA buffer. static int gDmaSelector = 0; @@ -137,9 +189,14 @@ static bool gSbIrqWasMasked = false; // the line's mask before t static bool gModTail = false; static uint8_t gModTailHalf = 0; +// What the module put in each half (silence where none played): a stopped +// module is taken back out of what is mixed ahead of the DSP (modRemove). +static uint8_t gModHalf[2][MIX_HALF_MAX]; + // SFX overlay: the one-shot sample mixer shared with the ST and X68000 // (src/core/audioSfxMix.c), run in refillHalf over the module's output. static AudioSfxSlotT gSfxSlots[JL_AUDIO_SFX_SLOTS]; +static uint8_t gSfxPan[JL_AUDIO_SFX_SLOTS]; // jlAudioSetPan's, stereo only // ISR-side state. volatile so the main loop sees IRQ updates promptly. // gNextHalf flips between 0 and 1 each IRQ; gNeedRefill[i] is raised @@ -151,6 +208,46 @@ static volatile bool gNeedRefill[2] = { false, false }; // Whether interrupts were on at the outermost jlAudioCriticalEnter. static bool gCriticalIrqWasOn = false; +// What is mixed ahead of the DSP, from SFX_START_MARGIN frames past where +// it plays: from start to halfEnd, the end of the half it plays (both byte +// offsets in the buffer; none when start is past it), and when nextMixed +// (the other half's interrupt has come and its refill is done) the other +// half from otherFrom (bytes into it: past 0 when the margin reaches into +// it) on. +typedef struct { + uint16_t start; + uint16_t halfEnd; + uint16_t otherFrom; + uint8_t half; + bool nextMixed; +} AheadT; + +// A mixer register the mix sets, and its value. +typedef struct { + uint8_t reg; + uint8_t value; +} MixerSetT; + +static const MixerSetT kMixerPro[] = { + { MIXPRO_VOICE, MIXPRO_VOICE_MIX }, + { MIXPRO_FM, MIXPRO_FULL } +}; +static const MixerSetT kMixer16[] = { + { MIX16_VOICE_L, MIX16_VOICE_MIX }, + { MIX16_VOICE_R, MIX16_VOICE_MIX }, + { MIX16_FM_L, MIX16_FULL }, + { MIX16_FM_R, MIX16_FULL } +}; + +typedef char mixer16Fits[(MIXER_SET_COUNT(kMixer16) <= MIXER_SETS_MAX) ? 1 : -1]; +typedef char mixerProFits[(MIXER_SET_COUNT(kMixerPro) <= MIXER_SETS_MAX) ? 1 : -1]; + +// The card's mixer settings the mix made (none before the SB Pro), and what +// each register held before: put back at shutdown. +static const MixerSetT *gMixerSets = NULL; +static uint8_t gMixerSetCount = 0u; +static uint8_t gMixerSaved[MIXER_SETS_MAX]; + // ----- Prototypes ----- static uint8_t adlibAttenTl(uint8_t atten); @@ -162,6 +259,7 @@ static bool adlibReady(void); static void adlibShutdown(void); static void adlibSilence(void); static void adlibWrite(uint8_t reg, uint8_t val); +static bool aheadOfDsp(AheadT *ahead); static bool allocDmaBuffer(void); static uint16_t dmaPlayOffset(void); static int dspRead(void); @@ -170,10 +268,19 @@ static uint8_t dspTimeConst(uint16_t rate); static void dspWrite(uint8_t value); static void freeDmaBuffer(void); static void haltDspDma(void); +static void mixerBalance(void); +static uint8_t mixerRead(uint8_t reg); +static void mixerRestore(void); +static void mixerWrite(uint8_t reg, uint8_t value); +static void modAdd(uint8_t half, uint16_t from); +static void modRemove(uint8_t half, uint16_t from); +static void modRemoveAhead(void); +static void modStartNow(void); static void parseBlasterEnv(void); static void programDmaController(void); static void programDspAutoInit(void); static void refillHalf(uint8_t halfIdx); +static void sfxOverlay(uint8_t *dst, uint16_t frames, uint8_t first, uint8_t count); static void sfxStartNow(uint8_t slot); static void sbIsr(void); static void speakerSet(uint16_t freqHz); @@ -303,6 +410,25 @@ static bool dspReset(void) { // 64 KB physical-page boundary -- the 8237 cannot DMA across that // boundary on 8-bit channels. We over-allocate to 2x size and use the // lower-aligned half so we always have room. +// Where the DSP plays and what is mixed ahead of it (AheadT); false when it +// is past the buffer (between the end and the auto-init restart). +static bool aheadOfDsp(AheadT *ahead) { + uint16_t pos; + + pos = dmaPlayOffset(); + ahead->half = (uint8_t)(pos / gHalfBytes); + if (ahead->half > 1u) { + return false; + } + // A whole frame, so the sides keep their places. + ahead->start = (uint16_t)((pos / gChannels + SFX_START_MARGIN) * gChannels); + ahead->halfEnd = (uint16_t)((ahead->half + 1u) * gHalfBytes); + ahead->otherFrom = ahead->start > ahead->halfEnd ? (uint16_t)(ahead->start - ahead->halfEnd) : 0u; + ahead->nextMixed = ahead->half == gNextHalf && !gNeedRefill[ahead->half ^ 1u] && ahead->otherFrom < gHalfBytes; + return true; +} + + static bool allocDmaBuffer(void) { int sel; int seg; @@ -310,7 +436,7 @@ static bool allocDmaBuffer(void) { uint32_t aligned; int paragraphs; - paragraphs = ((MIX_DMA_BUFFER * 2) + 15) >> 4; + paragraphs = ((MIX_DMA_BUFFER_MAX * 2) + 15) >> 4; seg = __dpmi_allocate_dos_memory(paragraphs, &sel); if (seg < 0) { return false; @@ -318,8 +444,8 @@ static bool allocDmaBuffer(void) { linear = (uint32_t)seg << 4; // Round linear up to a starting offset that gives us - // MIX_DMA_BUFFER without crossing the next 64 KB boundary. - if (((linear & 0xFFFF) + MIX_DMA_BUFFER) > 0x10000) { + // MIX_DMA_BUFFER_MAX without crossing the next 64 KB boundary. + if (((linear & 0xFFFF) + MIX_DMA_BUFFER_MAX) > 0x10000) { aligned = (linear + 0xFFFFu) & ~0xFFFFu; } else { aligned = linear; @@ -328,7 +454,7 @@ static bool allocDmaBuffer(void) { gDmaSelector = sel; gDmaLinear = aligned; gDmaBuf = (uint8_t *)(__djgpp_conventional_base + aligned); - memset(gDmaBuf, MIX_SILENCE, MIX_DMA_BUFFER); + memset(gDmaBuf, MIX_SILENCE, MIX_DMA_BUFFER_MAX); return true; } @@ -350,7 +476,7 @@ static void programDmaController(void) { page = (uint8_t)((gDmaLinear >> 16) & 0xFF); offset = (uint16_t)(gDmaLinear & 0xFFFF); - count = (uint16_t)(MIX_DMA_BUFFER - 1); + count = (uint16_t)(gHalfBytes * 2u - 1u); outportb(DMA_MASK, (uint8_t)(DMA_MASK_DISABLE | gSbDma8)); outportb(DMA_FLIPFLOP, 0); @@ -381,18 +507,28 @@ static uint8_t dspTimeConst(uint16_t rate) { } +// An interrupt at the end of each half. The SB16 takes the rate in Hz and +// plays stereo; the cards before it take a time constant and play mono. static void programDspAutoInit(void) { uint16_t blockMinusOne; + blockMinusOne = (uint16_t)(gHalfBytes - 1u); dspWrite(DSP_SPEAKER_ON); + if (gChannels == MIX_CHANNELS_MAX) { + dspWrite(DSP_OUT_RATE); + dspWrite((uint8_t)((MIX_RATE >> 8) & 0xFF)); + dspWrite((uint8_t)(MIX_RATE & 0xFF)); + dspWrite(DSP_OUT_8_AUTO_FIFO); + dspWrite(DSP_MODE_STEREO_U8); + dspWrite((uint8_t)(blockMinusOne & 0xFF)); + dspWrite((uint8_t)((blockMinusOne >> 8) & 0xFF)); + return; + } dspWrite(DSP_TIME_CONST); dspWrite(dspTimeConst(MIX_RATE)); - - blockMinusOne = (uint16_t)(MIX_HALF - 1); dspWrite(DSP_BLOCK_SIZE); dspWrite((uint8_t)(blockMinusOne & 0xFF)); dspWrite((uint8_t)((blockMinusOne >> 8) & 0xFF)); - dspWrite(DSP_OUT_8_AUTO); } @@ -404,6 +540,127 @@ static void haltDspDma(void) { } +// The card's mixer to the mix (the mixer constants above), what it held +// noted first. Nothing on a card before the SB Pro. +static void mixerBalance(void) { + uint8_t i; + + if (gDspMajor >= DSP_MAJOR_SB16) { + gMixerSets = kMixer16; + gMixerSetCount = MIXER_SET_COUNT(kMixer16); + } else if (gDspMajor >= DSP_MAJOR_SBPRO) { + gMixerSets = kMixerPro; + gMixerSetCount = MIXER_SET_COUNT(kMixerPro); + } else { + gMixerSetCount = 0u; + } + for (i = 0; i < gMixerSetCount; i++) { + gMixerSaved[i] = mixerRead(gMixerSets[i].reg); + mixerWrite(gMixerSets[i].reg, gMixerSets[i].value); + } +} + + +static uint8_t mixerRead(uint8_t reg) { + outportb(gSbBase + SB_MIXER_INDEX_OFFSET, reg); + return inportb(gSbBase + SB_MIXER_DATA_OFFSET); +} + + +// The mixer as mixerBalance found it. +static void mixerRestore(void) { + uint8_t i; + + for (i = 0; i < gMixerSetCount; i++) { + mixerWrite(gMixerSets[i].reg, gMixerSaved[i]); + } + gMixerSetCount = 0u; +} + + +static void mixerWrite(uint8_t reg, uint8_t value) { + outportb(gSbBase + SB_MIXER_INDEX_OFFSET, reg); + outportb(gSbBase + SB_MIXER_DATA_OFFSET, value); +} + + +// Mix the module into half from byte from on, over what is there (the +// effects): what it renders is kept in gModHalf, as a refill keeps it. A +// song that ends in it sounds until the DSP has played it. +static void modAdd(uint8_t half, uint16_t from) { + uint8_t *dst; + uint8_t *mod; + uint16_t i; + int value; + + dst = gDmaBuf + (uint32_t)half * gHalfBytes; + mod = gModHalf[half]; + if (!modPlayerRenderU8(mod + from, (uint32_t)(gHalfBytes - from) / gChannels, gChannels)) { + gModTail = true; + gModTailHalf = half; + } + for (i = from; i < gHalfBytes; i++) { + value = (int)dst[i] + (int)mod[i] - MIX_SILENCE; + dst[i] = (uint8_t)(value < 0 ? 0 : (value > UINT8_MAX ? UINT8_MAX : value)); + } +} + + +// Take the module back out of half from byte from on: each byte less its +// module part (gModHalf) and back on the silence line, so the effects mixed +// over it go on alone. A byte the effects pushed to a limit comes out a +// little off, as clipping left it. +static void modRemove(uint8_t half, uint16_t from) { + uint8_t *dst; + uint8_t *mod; + uint16_t i; + int value; + + dst = gDmaBuf + (uint32_t)half * gHalfBytes; + mod = gModHalf[half]; + for (i = from; i < gHalfBytes; i++) { + value = (int)dst[i] - (int)mod[i] + MIX_SILENCE; + dst[i] = (uint8_t)(value < 0 ? 0 : (value > UINT8_MAX ? UINT8_MAX : value)); + mod[i] = MIX_SILENCE; + } +} + + +// A stopped (or replaced) module out of everything mixed ahead of the DSP, +// so it stops now rather than up to two halves (~185 ms) later; modStartNow +// is the other way round. +static void modRemoveAhead(void) { + AheadT ahead; + + if (!aheadOfDsp(&ahead)) { + return; + } + if (ahead.start < ahead.halfEnd) { + modRemove(ahead.half, (uint16_t)(ahead.start - (uint16_t)ahead.half * gHalfBytes)); + } + if (ahead.nextMixed) { + modRemove((uint8_t)(ahead.half ^ 1u), ahead.otherFrom); + } +} + + +// A module just started, mixed into everything mixed ahead of the DSP, as +// sfxStartNow does a sound, so it starts now rather than at the next refill. +static void modStartNow(void) { + AheadT ahead; + + if (!aheadOfDsp(&ahead)) { + return; + } + if (ahead.start < ahead.halfEnd) { + modAdd(ahead.half, (uint16_t)(ahead.start - (uint16_t)ahead.half * gHalfBytes)); + } + if (ahead.nextMixed && modPlayerActive()) { + modAdd((uint8_t)(ahead.half ^ 1u), ahead.otherFrom); + } +} + + // Refill one half of the DMA buffer from the module player. // Called from jlpAudioFrameTick on the main thread (NOT in IRQ // context); the IRQ only flags which halves need refilling. @@ -413,16 +670,17 @@ static void haltDspDma(void) { static void refillHalf(uint8_t halfIdx) { uint8_t *dst; - dst = gDmaBuf + ((uint32_t)halfIdx * MIX_HALF); + dst = gDmaBuf + ((uint32_t)halfIdx * gHalfBytes); if (modPlayerActive()) { - if (!modPlayerRenderU8(dst, MIX_HALF)) { + if (!modPlayerRenderU8(gModHalf[halfIdx], MIX_HALF_FRAMES, gChannels)) { gModTail = true; gModTailHalf = halfIdx; } } else { - memset(dst, MIX_SILENCE, MIX_HALF); + memset(gModHalf[halfIdx], MIX_SILENCE, gHalfBytes); } - audioSfxOverlayMix(dst, MIX_HALF, gSfxSlots, JL_AUDIO_SFX_SLOTS); + memcpy(dst, gModHalf[halfIdx], gHalfBytes); + sfxOverlay(dst, MIX_HALF_FRAMES, 0u, JL_AUDIO_SFX_SLOTS); } @@ -445,6 +703,17 @@ static void sbIsr(void) { } +// Slots first .. first + count - 1 mixed over frames frames at dst: in +// stereo each on its pan's side (audioSfxOverlayMixStereo). +static void sfxOverlay(uint8_t *dst, uint16_t frames, uint8_t first, uint8_t count) { + if (gChannels == 1u) { + audioSfxOverlayMix(dst, frames, &gSfxSlots[first], count); + return; + } + audioSfxOverlayMixStereo(dst, frames, &gSfxSlots[first], count, &gSfxPan[first], AUDIO_SFX_DST_UNSIGNED); +} + + // Start a just-armed slot now. A refill mixes it, but the halves ahead of // the DSP are already mixed, so on its own a sound waited for the next // refill: up to two halves, ~90-190 ms. So its first samples go on top of @@ -452,25 +721,16 @@ static void sbIsr(void) { // has been refilled already, the half after it -- and the refills carry on // from where that leaves the slot. static void sfxStartNow(uint8_t slot) { - uint16_t pos; - uint16_t start; - uint16_t halfEnd; - uint8_t half; + AheadT ahead; - pos = dmaPlayOffset(); - half = (uint8_t)(pos / MIX_HALF); - if (half > 1u) { + if (!aheadOfDsp(&ahead)) { return; } - start = (uint16_t)(pos + SFX_START_MARGIN); - halfEnd = (uint16_t)((half + 1u) * MIX_HALF); - if (start < halfEnd) { - audioSfxOverlayMix(gDmaBuf + start, (uint16_t)(halfEnd - start), &gSfxSlots[slot], 1u); + if (ahead.start < ahead.halfEnd) { + sfxOverlay(gDmaBuf + ahead.start, (uint16_t)((ahead.halfEnd - ahead.start) / gChannels), slot, 1u); } - // The half after it is ahead of the DSP only if the interrupt for this - // one has come (gNextHalf) and its refill is done. - if (half == gNextHalf && !gNeedRefill[half ^ 1u]) { - audioSfxOverlayMix(gDmaBuf + (uint32_t)(half ^ 1u) * MIX_HALF, MIX_HALF, &gSfxSlots[slot], 1u); + if (ahead.nextMixed) { + sfxOverlay(gDmaBuf + (uint32_t)(ahead.half ^ 1u) * gHalfBytes + ahead.otherFrom, (uint16_t)((gHalfBytes - ahead.otherFrom) / gChannels), slot, 1u); } } @@ -498,10 +758,16 @@ bool jlpAudioInit(void) { gDspMajor = (uint8_t)major; gDspMinor = (uint8_t)minor; - // Lock in the effective playback rate from the same time constant - // the SB DAC will receive, so the module player and the SFX resampler - // agree with the hardware instead of using the nominal MIX_RATE. - gEffRate = (uint16_t)(SB_DSP_CLOCK_HZ / (256u - dspTimeConst(MIX_RATE))); + // An SB16 plays stereo at MIX_RATE itself. Before it, lock in the + // effective playback rate from the same time constant the SB DAC will + // receive, so the module player and the SFX resampler agree with the + // hardware instead of using the nominal MIX_RATE. + gChannels = gDspMajor >= DSP_MAJOR_SB16 ? MIX_CHANNELS_MAX : 1u; + gHalfBytes = (uint16_t)(MIX_HALF_FRAMES * gChannels); + gEffRate = gChannels == MIX_CHANNELS_MAX ? (uint16_t)MIX_RATE : (uint16_t)(SB_DSP_CLOCK_HZ / (256u - dspTimeConst(MIX_RATE))); + memset(gSfxPan, JL_AUDIO_PAN_CENTER, sizeof(gSfxPan)); + modPlayerSetU8Shift(MIX_UNIT_SHIFT); + audioSfxSetShift(MIX_UNIT_SHIFT); if (!allocDmaBuffer()) { return false; @@ -535,12 +801,22 @@ bool jlpAudioInit(void) { programDspAutoInit(); gSbIrqWasMasked = picIrqMasked(gSbIrq); picUnmaskIrq(gSbIrq); + mixerBalance(); gDspPresent = true; return true; } +// A slot's side, for the sound it plays and the next: stereo (an SB16) +// only; a mono card plays every sound in the middle. +void jlpAudioSetPan(uint8_t slot, uint8_t pan) { + if (slot < JL_AUDIO_SFX_SLOTS && pan < JL_AUDIO_PAN_COUNT) { + gSfxPan[slot] = pan; + } +} + + void jlpAudioShutdown(void) { int vector; @@ -566,6 +842,7 @@ void jlpAudioShutdown(void) { gModTail = false; freeDmaBuffer(); (void)dspReset(); + mixerRestore(); gDspPresent = false; } @@ -579,8 +856,14 @@ void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { if (!gDspPresent) { return; } + // A song still playing goes now, not over the new one's start. + if (modPlayerActive()) { + modRemoveAhead(); + } gModTail = false; - (void)modPlayerStart(data, length, gEffRate, loop); + if (modPlayerStart(data, length, gEffRate, loop)) { + modStartNow(); + } } @@ -709,6 +992,9 @@ static const uint8_t kAdlibSlotMap[ADLIB_VOICE_COUNT * 2] = { #define ADLIB_BD_KEY_SD 0x08u // reg 0xBD bit 3: snare key #define ADLIB_NOISE_MAX_HZ 6200u // pitch 0 tops out the bright end #define ADLIB_NOISE_MIN_HZ 194u // pitch 31, the deepest the snare goes +// The snare sounds 4-5 dB louder than a tone voice at the same total level: +// this many 0.75 dB steps down, it is as loud as one. +#define ADLIB_NOISE_TL_TRIM 7u // The standard AdLib probe: timer 1 started at its fastest must have both // status flags set within 80 us, and none before. A PC with no OPL2 reads @@ -747,9 +1033,12 @@ static void adlibWrite(uint8_t reg, uint8_t val) { // Attenuation is 2 dB a step on every port (joey/audio.h); the OPL2's total -// level is 0.75 dB a step, so a step is 8/3 of it, rounded. +// level is 0.75 dB a step, so a step is 8/3 of it, rounded up from a third: +// the chip's deepest levels sound a little louder than their total level +// (measured under DOSBox: TL 37 is 27.0 dB down, not 27.75), and this keeps +// every step within half a dB of 2. static uint8_t adlibAttenTl(uint8_t atten) { - return (uint8_t)(((uint16_t)atten * 8u + 1u) / 3u); + return (uint8_t)(((uint16_t)atten * 8u + 2u) / 3u); } @@ -1099,19 +1388,17 @@ void jlpAudioNoise(uint8_t pitch, uint8_t atten) { return; } - adlibWrite((uint8_t)(0x40u + ADLIB_SLOT_SD), adlibAttenTl(atten)); + adlibWrite((uint8_t)(0x40u + ADLIB_SLOT_SD), (uint8_t)(adlibAttenTl(atten) + ADLIB_NOISE_TL_TRIM)); // pitch 0..31 spread evenly from ADLIB_NOISE_MAX_HZ down to // ADLIB_NOISE_MIN_HZ. (Core clamps pitch to <= 31 before the HAL // sees it.) // - // Evenly, because the shared curve every other port follows -- - // rate = 26000 - pitch * 750, from src/iigs/audio_full.c -- is - // linear in pitch, so a linear spread here puts a given pitch at the - // same place in the range as it sits on those machines. The absolute - // rates cannot be matched: the OPL2's snare is a tuned drum voice - // rather than a sampled noise source, and its band tops out near - // 6 kHz where the curve asks for 26 kHz. + // The rate every other port plays (AUDIO_NOISE_CLOCK_HZ / pitch) cannot + // be matched: the OPL2's snare is a tuned drum voice rather than a + // noise source, and its band tops out near 6 kHz where that rate runs + // to 125 kHz. An even spread keeps every pitch distinct and in order, + // bright to dark. // // It replaces MAX / (pitch + 1), a hyperbola that spent nearly all // its resolution on the first few pitches and squashed everything @@ -1174,6 +1461,7 @@ void jlpAudioStopMod(void) { return; } modPlayerStop(); + modRemoveAhead(); gModTail = false; } diff --git a/src/dos/hal.c b/src/dos/hal.c index 2adf9b9..a19b76e 100644 --- a/src/dos/hal.c +++ b/src/dos/hal.c @@ -2,11 +2,12 @@ // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // -// DOS HAL: VGA mode 13h, 6-bit DAC, nibble-expand present. +// DOS HAL: VGA Mode Y (unchained 320x200x256), 6-bit DAC, two pages, +// nibble-expand present. // // JoeyLib surfaces are 4bpp chunky with a 200-entry SCB table selecting -// one of 16 palettes per scanline. Mode 13h is 8bpp; the three pairs of -// operations map cleanly: +// one of 16 palettes per scanline. The display is 8bpp; the three pairs +// of operations map cleanly: // * All 16 palettes x 16 colors = 256 entries fill the VGA DAC once. // * Pixel byte = (scb[y] << 4) | (source nibble). // * No per-scanline palette programming needed at display time. @@ -29,37 +30,115 @@ #include #include "dosTimer.h" +#include "fileRoot.h" #include "port.h" #include "surfaceInternal.h" -#include "x86Expand.h" // ----- Constants ----- #define VGA_LINEAR_ADDR 0xA0000u -#define VGA_STRIDE 320u #define DAC_INDEX_PORT 0x3C8 #define DAC_DATA_PORT 0x3C9 #define VGA_INPUT_STAT_1 0x3DA #define VGA_VRETRACE_BIT 0x08 +// Mode Y: mode 13h with chain 4 off, so each of the four planes is a whole +// 64 KB of its own and a pixel at x lives in plane x % 4, byte x / 4 of its +// row. That leaves room for two pages, and the present draws into the one not +// shown and flips the CRTC's start address to it, so no frame shows half of +// two. The start address latches as the vertical retrace begins. +#define VGA_PLANES 4u +#define VGA_PLANE_ROW_BYTES 80u // 320 pixels / 4 planes +#define VGA_PAGE_BYTES 16000u // a 320x200 page in one plane +#define VGA_WINDOW_BYTES 65536u // the A000 window: one plane's whole +#define VGA_SEQ_INDEX 0x3C4 +#define VGA_SEQ_MAP_MASK 0x02u // the planes a write lands in +#define VGA_SEQ_MEMORY_MODE 0x04u +#define VGA_SEQ_UNCHAINED 0x06u // chain 4 and odd/even off, all 256 KB +#define VGA_ALL_PLANES 0x0Fu +#define VGA_GC_INDEX 0x3CE +#define VGA_GC_READ_MAP 0x04u // the plane a read comes from +#define VGA_CRTC_INDEX 0x3D4 +#define VGA_CRTC_START_HI 0x0Cu +#define VGA_CRTC_START_LO 0x0Du +#define VGA_CRTC_UNDERLINE 0x14u +#define VGA_CRTC_DWORD_OFF 0x00u // underline register: doubleword addressing off +#define VGA_CRTC_MODE 0x17u +#define VGA_CRTC_BYTE_MODE 0xE3u // mode control: byte addressing +// A frame on the PIT's clock: mode 13h refreshes at 70.086 Hz. +#define VGA_FRAME_PIT_CLOCKS ((uint64_t)PIT_CLOCK_HZ * 1000u / 70086u) +// A stage byte holds two 4-bit pixels: 256 values. +#define STAGE_BYTE_VALUES 256u +#define PIXEL_BITS 4u +#define PIXEL_MASK 0x0Fu +#define PLANE_LUT_LO 0u +#define PLANE_LUT_HI 1u +#define PLANE_LUT_TABLES 2u + // ----- Prototypes ----- static void crashHandler(int sig); void dosPitProgram(uint8_t ctrl, uint16_t divisor); +static void expandGroupPlanes(const uint32_t *lut, const uint8_t *srcBytes, uint8_t *plane0, uint16_t g); +static void expandRowPlanes(const jlSurfaceT *src, int16_t y, int16_t x, uint16_t w, uint8_t *plane0); +static void flipWait(void); static void installCrashLog(void); static uint64_t pitClocksNow(void); static void pitClockStart(void); +static void planeLutBuild(void); +static bool presentIdle(uint8_t page); +static uint16_t presentRows(const jlSurfaceT *src, uint8_t page); +static void retraceWait(void); +static void startAddressSet(uint16_t addr); static void uploadPalette(const jlSurfaceT *src); -static void uploadPaletteIfNeeded(const jlSurfaceT *src); // ----- Module state ----- static uint8_t *gVgaMem = NULL; -// The SCB each VGA row was last expanded with (jlpPresent); mode 13h -// starts cleared, every row with palette 0. -static uint8_t gPresentedScb[JL_SURFACE_HEIGHT]; +// The SCB each row of each page was last expanded with (presentRows); the +// pages start cleared, every row with palette 0. A page whose rows may not +// have the stage's SCB (gStageScbDirty since it was drawn) is stale, and its +// next present compares them. +static uint8_t gPresentedScb[2][JL_SURFACE_HEIGHT]; +static bool gScbStale[2] = { true, true }; +// The page on screen (or about to be: gFlipPending), and the flip: a start +// address written at gFlipClocks that a retrace may not have latched yet. +static uint8_t gFrontPage = 0u; +static bool gFlipPending = false; +static uint64_t gFlipClocks = 0u; +// A present that waited for a retrace to load its colours, at +// gBankedClocks: the next jlpWaitVBL within a frame of it has had its +// retrace, so a loop that waits and then presents keeps the refresh rate. +static bool gVblBanked = false; +static uint64_t gBankedClocks = 0u; +// The back page holds the frame before last, so a present writes the +// union of this frame's dirty bands and the last one's (presentRows): the +// last frame's bands, and whether it marked anything (all, at first, so the +// first two presents fill both pages). +static uint8_t gPrevMinWord[JL_SURFACE_HEIGHT]; +static uint8_t gPrevMaxWord[JL_SURFACE_HEIGHT]; +static bool gPrevAnyDirty = true; +// The page being drawn, a buffer per plane in RAM: a row's pixels go to all +// four in one pass over the stage (expandRowPlanes), and each plane's rows +// then go to the screen in one run with its map mask set once. +static uint8_t gPlaneBuf[VGA_PLANES][VGA_PAGE_BYTES]; +// Mode Y's expansion tables (planeLutBuild): for palette p and stage byte s, +// gPlaneLut[p][PLANE_LUT_LO][s] holds s's left pixel (its high nibble) in +// byte 0 and its right pixel in byte 2, each (p << 4) | nibble, and +// [PLANE_LUT_HI] the same a byte up, so one entry of each or'd together is +// two bytes of two planes. A palette's two tables sit together, one pointer +// reaching both. +static uint32_t gPlaneLut[JL_SURFACE_PALETTE_COUNT][PLANE_LUT_TABLES][STAGE_BYTE_VALUES]; +// The rows presentRows chose: each one's row and its pixels (multiples of 4). +static int16_t gRowY[JL_SURFACE_HEIGHT]; +static int16_t gRowX[JL_SURFACE_HEIGHT]; +static uint16_t gRowW[JL_SURFACE_HEIGHT]; static bool gNearEnabled = false; +// CRASH.LOG beside the program (the write root, fileRoot.h), open from +// jlpInit so a fault has a file to write to; a run that shuts down cleanly +// deletes it, so it is only left after a crash. static FILE *gCrashLog = NULL; +static char gCrashLogPath[FILE_PATH_MAX]; // The millisecond clock (pitClocksNow, jlpMillisElapsed). static uint64_t gPitStart = 0u; // PIT clocks at jlpInit @@ -75,7 +154,7 @@ static uint32_t gMillisLastIsr = 0u; // audio ISR fires at the last read // VGA DAC programming is ~768 outportb calls; skipped when the core // gStagePaletteDirty flag (surfaceInternal.h) says the stage palette -// is unchanged -- see uploadPaletteIfNeeded. +// is unchanged -- see jlpPresent. // ----- Internal helpers (alphabetical) ----- @@ -157,8 +236,79 @@ void dosPitProgram(uint8_t ctrl, uint16_t divisor) { outportb(PIT_TIMER0_PORT, (uint8_t)((divisor >> 8) & 0xFFu)); } +// A flip written less than a frame ago may not have latched: the page it +// left could still be on screen, so wait for the retrace that latches it +// before drawing there. A frame or more since, a retrace has come. +// One 4-pixel group (stage bytes srcBytes[0..1]) into byte g of the four +// planes: plane p is p * VGA_PAGE_BYTES past plane0. +static void expandGroupPlanes(const uint32_t *lut, const uint8_t *srcBytes, uint8_t *plane0, uint16_t g) { + uint32_t even; + uint32_t odd; -// Open CRASH.LOG in cwd (DOSBox's mounted dir on the host) and install + even = lut[srcBytes[0]]; + odd = lut[srcBytes[1]]; + plane0[g] = (uint8_t)even; + plane0[VGA_PAGE_BYTES + g] = (uint8_t)(even >> 16); + plane0[2u * VGA_PAGE_BYTES + g] = (uint8_t)odd; + plane0[3u * VGA_PAGE_BYTES + g] = (uint8_t)(odd >> 16); +} + + +// Stage row y, pixels x..x+w-1 (multiples of 4), into the plane buffers +// whose first plane's row starts at plane0. Pixel 4g + p is plane p's byte +// g: the high nibble of stage byte 2g + p / 2 for an even plane, its low +// nibble for an odd one. Four groups make a dword a plane: two table entries +// or'd give the even bytes' left and right pixels for two groups, and the +// low and high halves of two such dwords are planes 0 and 1 (the odd bytes, +// planes 2 and 3). Groups at either end of the run go a byte a plane. +static void expandRowPlanes(const jlSurfaceT *src, int16_t y, int16_t x, uint16_t w, uint8_t *plane0) { + const uint32_t *lut; + const uint8_t *srcBytes; + const uint8_t *srcQuadEnd; + uint8_t *dst; + uint32_t lo; + uint32_t hi; + uint16_t g; + uint16_t end; + + lut = gPlaneLut[src->scb[y] & PIXEL_MASK][PLANE_LUT_LO]; + srcBytes = &src->pixels[y * JL_SURFACE_BYTES_PER_ROW + (x >> 1)]; + g = (uint16_t)((uint16_t)x >> 2); + end = (uint16_t)((uint16_t)(x + w) >> 2); + for (; g < end && (g & 3u) != 0u; g++) { + expandGroupPlanes(lut, srcBytes, plane0, g); + srcBytes += 2; + } + dst = &plane0[g]; + srcQuadEnd = srcBytes + ((uint16_t)(end - g) & ~3u) * 2u; + g = (uint16_t)(g + ((uint16_t)(end - g) & ~3u)); + for (; srcBytes != srcQuadEnd; srcBytes += 8) { + lo = lut[srcBytes[0]] | lut[PLANE_LUT_HI * STAGE_BYTE_VALUES + srcBytes[2]]; + hi = lut[srcBytes[4]] | lut[PLANE_LUT_HI * STAGE_BYTE_VALUES + srcBytes[6]]; + *(uint32_t *)&dst[0] = (lo & 0x0000FFFFul) | (hi << 16); + *(uint32_t *)&dst[VGA_PAGE_BYTES] = (lo >> 16) | (hi & 0xFFFF0000ul); + lo = lut[srcBytes[1]] | lut[PLANE_LUT_HI * STAGE_BYTE_VALUES + srcBytes[3]]; + hi = lut[srcBytes[5]] | lut[PLANE_LUT_HI * STAGE_BYTE_VALUES + srcBytes[7]]; + *(uint32_t *)&dst[2u * VGA_PAGE_BYTES] = (lo & 0x0000FFFFul) | (hi << 16); + *(uint32_t *)&dst[3u * VGA_PAGE_BYTES] = (lo >> 16) | (hi & 0xFFFF0000ul); + dst += 4; + } + for (; g < end; g++) { + expandGroupPlanes(lut, srcBytes, plane0, g); + srcBytes += 2; + } +} + + +static void flipWait(void) { + if (gFlipPending && pitClocksNow() - gFlipClocks < VGA_FRAME_PIT_CLOCKS) { + retraceWait(); + } + gFlipPending = false; +} + + +// Open CRASH.LOG beside the program (gCrashLogPath) and install // signal handlers that route fault reports there. This runs before the // VGA mode change so its file handle is established while the screen // is still text-mode, but the writes themselves only happen on a @@ -168,7 +318,10 @@ static void installCrashLog(void) { if (gCrashLog != NULL) { return; } - gCrashLog = fopen("CRASH.LOG", "w"); + if (!fileRootJoin(gCrashLogPath, sizeof(gCrashLogPath), jlpWriteRoot(), "CRASH.LOG", "")) { + return; + } + gCrashLog = fopen(gCrashLogPath, "w"); if (gCrashLog == NULL) { return; } @@ -241,12 +394,108 @@ static void pitClockStart(void) { } -static void uploadPaletteIfNeeded(const jlSurfaceT *src) { - if (!gStagePaletteDirty) { - return; +// Nothing to present: no band marked this frame or the last, the back page +// has every row's SCB, and the colours are as they were. +static void planeLutBuild(void) { + uint16_t palette; + uint16_t byte; + uint32_t base; + uint32_t entry; + + for (palette = 0u; palette < JL_SURFACE_PALETTE_COUNT; palette++) { + base = (uint32_t)palette << PIXEL_BITS; + for (byte = 0u; byte < STAGE_BYTE_VALUES; byte++) { + entry = (base | (uint32_t)(byte >> PIXEL_BITS)) | ((base | (uint32_t)(byte & PIXEL_MASK)) << 16); + gPlaneLut[palette][PLANE_LUT_LO][byte] = entry; + gPlaneLut[palette][PLANE_LUT_HI][byte] = entry << 8; + } + } +} + + +static bool presentIdle(uint8_t page) { + uint8_t g; + + if (gPrevAnyDirty || gStagePaletteDirty || gScbStale[page]) { + return false; + } + for (g = 0u; g < (uint8_t)(JL_SURFACE_HEIGHT >> STAGE_DIRTY_GROUP4_SHIFT); g++) { + if (!STAGE_DIRTY_GROUP4_CLEAN(g)) { + return false; + } + } + return true; +} + + +// The rows a present writes into page: the union of this frame's dirty bands +// and the last frame's, and the whole of a row whose SCB the page does not +// have (a row's palette is in every byte of it). Each row's pixels go to +// gRowY / gRowX / gRowW; returns how many. This frame's bands are kept for +// the next present. +static uint16_t presentRows(const jlSurfaceT *src, uint8_t page) { + uint16_t rows; + int16_t y; + uint8_t minWord; + uint8_t maxWord; + bool anyCur; + + rows = 0u; + anyCur = false; + for (y = 0; y < JL_SURFACE_HEIGHT; y++) { + anyCur = anyCur || !STAGE_DIRTY_ROW_CLEAN(y); + minWord = gStageMinWord[y] < gPrevMinWord[y] ? gStageMinWord[y] : gPrevMinWord[y]; + maxWord = gStageMaxWord[y] > gPrevMaxWord[y] ? gStageMaxWord[y] : gPrevMaxWord[y]; + gPrevMinWord[y] = gStageMinWord[y]; + gPrevMaxWord[y] = gStageMaxWord[y]; + if (gScbStale[page] && src->scb[y] != gPresentedScb[page][y]) { + gPresentedScb[page][y] = src->scb[y]; + minWord = 0u; + maxWord = STAGE_DIRTY_FULL_MAX; + } + if (minWord > maxWord) { + continue; + } + // Defense-in-depth: a band past the row's last word would spill + // into the next row. + if (maxWord > STAGE_DIRTY_FULL_MAX) { + maxWord = STAGE_DIRTY_FULL_MAX; + } + gRowY[rows] = y; + gRowX[rows] = (int16_t)((uint16_t)minWord << 2); + gRowW[rows] = (uint16_t)(((uint16_t)maxWord - minWord + 1u) << 2); + rows++; + } + gPrevAnyDirty = anyCur; + gScbStale[page] = false; + return rows; +} + + +// Wait for the vertical retrace to begin: first out of any retrace already +// under way, then into the next, so back-to-back calls each take a frame. +static void retraceWait(void) { + while (inportb(VGA_INPUT_STAT_1) & VGA_VRETRACE_BIT) { + /* spin while still in current retrace */; + } + while (!(inportb(VGA_INPUT_STAT_1) & VGA_VRETRACE_BIT)) { + /* spin until next retrace begins */; + } +} + + +// Point the CRTC at a page: it reads the address as the next retrace begins. +// Interrupts are held off across the two halves, so the window in which a +// retrace could take one without the other is the hardware's alone. +static void startAddressSet(uint16_t addr) { + int wasEnabled; + + wasEnabled = disable(); + outportw(VGA_CRTC_INDEX, (uint16_t)((addr & 0xFF00u) | VGA_CRTC_START_HI)); + outportw(VGA_CRTC_INDEX, (uint16_t)(((addr & 0x00FFu) << 8) | VGA_CRTC_START_LO)); + if (wasEnabled) { + enable(); } - uploadPalette(src); - gStagePaletteDirty = false; } @@ -283,77 +532,82 @@ bool jlpInit(const jlConfigT *config) { __dpmi_int(0x10, ®s); gVgaMem = (uint8_t *)(__djgpp_conventional_base + VGA_LINEAR_ADDR); + planeLutBuild(); + + // Mode Y (see the constants): chain 4 off, the CRTC addressing bytes, + // every plane of both pages cleared, page 0 shown. + outportw(VGA_SEQ_INDEX, (uint16_t)((VGA_SEQ_UNCHAINED << 8) | VGA_SEQ_MEMORY_MODE)); + outportw(VGA_CRTC_INDEX, (uint16_t)((VGA_CRTC_DWORD_OFF << 8) | VGA_CRTC_UNDERLINE)); + outportw(VGA_CRTC_INDEX, (uint16_t)((VGA_CRTC_BYTE_MODE << 8) | VGA_CRTC_MODE)); + outportw(VGA_SEQ_INDEX, (uint16_t)((VGA_ALL_PLANES << 8) | VGA_SEQ_MAP_MASK)); + memset(gVgaMem, 0, VGA_WINDOW_BYTES); + startAddressSet(0u); memset(gPresentedScb, 0, sizeof(gPresentedScb)); + memset(gPrevMinWord, 0, sizeof(gPrevMinWord)); + memset(gPrevMaxWord, STAGE_DIRTY_FULL_MAX, sizeof(gPrevMaxWord)); + gPrevAnyDirty = true; + gScbStale[0] = true; + gScbStale[1] = true; + gFrontPage = 0u; + gFlipPending = false; + gVblBanked = false; return true; } +// PAGE FLIP (see the Mode Y constants): the rows that changed go into the +// page not shown, a plane at a time, and the CRTC flips to it as the next +// retrace begins. A palette change waits for that retrace and loads the DAC +// in its blank, so the new colours show from the frame the new pixels do. void jlpPresent(const jlSurfaceT *src) { - int16_t y; - uint8_t minWord; - uint8_t maxWord; - int16_t pixelX; - uint16_t pixelW; + uint8_t *page; + uint32_t offset; + uint16_t rows; + uint16_t r; + uint8_t back; + uint8_t plane; if (src == NULL || gVgaMem == NULL) { return; } - uploadPaletteIfNeeded(src); - - // A row's palette is baked into every byte of it, so a row whose SCB - // changed is expanded whole, whatever of it is dirty. + flipWait(); + back = (uint8_t)(gFrontPage ^ 1u); if (gStageScbDirty) { - for (y = 0; y < JL_SURFACE_HEIGHT; y++) { - if (src->scb[y] != gPresentedScb[y]) { - x86ExpandRow(src, y, 0, JL_SURFACE_WIDTH, &gVgaMem[y * VGA_STRIDE]); - gPresentedScb[y] = src->scb[y]; - gPresentCopiedBytes += JL_SURFACE_WIDTH; - } - } + gScbStale[0] = true; + gScbStale[1] = true; gStageScbDirty = false; } - - // Walk per-row dirty bands: each chunky word holds 4 mode-13h - // bytes, so pixelX = minWord*4 and pixelW = (maxWord-minWord+1)*4 - // gives the byte range x86ExpandRow needs. - // Group test, 4 rows per dword (perf-hunt-2 rank 5, the landed - // ST/Amiga idle-scan trick; STAGE_DIRTY_GROUP4_CLEAN). A failing - // group keeps the per-row CLEAN test, so a clean row INSIDE a dirty - // group is skipped exactly as before -- written bytes and counters are - // byte-identical to the row loop. - { - uint8_t g; - - for (g = 0u; g < (uint8_t)(JL_SURFACE_HEIGHT >> STAGE_DIRTY_GROUP4_SHIFT); g++) { - int16_t yEnd; - - if (STAGE_DIRTY_GROUP4_CLEAN(g)) { - continue; - } - yEnd = (int16_t)(((uint16_t)g << STAGE_DIRTY_GROUP4_SHIFT) + STAGE_DIRTY_GROUP4_ROWS); - for (y = (int16_t)((uint16_t)g << STAGE_DIRTY_GROUP4_SHIFT); y < yEnd; y++) { - // Canonical clean-row test (finding #47): min > max is the - // CLEAN sentinel; see STAGE_DIRTY_ROW_CLEAN. - if (STAGE_DIRTY_ROW_CLEAN(y)) { - continue; - } - minWord = gStageMinWord[y]; - maxWord = gStageMaxWord[y]; - // Defense-in-depth: an upstream clipping bug that wrote - // maxWord >= SURFACE_WORDS_PER_ROW would push the expand - // copy past the 320-byte VGA row into the next scanline. - // Clamp to the in-contract maximum word index. - if (maxWord >= SURFACE_WORDS_PER_ROW) { - maxWord = (uint8_t)(SURFACE_WORDS_PER_ROW - 1); - } - pixelX = (int16_t)((uint16_t)minWord << 2); - pixelW = (uint16_t)(((uint16_t)maxWord - minWord + 1u) << 2); - x86ExpandRow(src, y, pixelX, pixelW, &gVgaMem[y * VGA_STRIDE]); - gPresentCopiedBytes += pixelW; + gPresentScannedRows += JL_SURFACE_HEIGHT; + if (presentIdle(back)) { + return; + } + rows = presentRows(src, back); + if (rows != 0u) { + for (r = 0u; r < rows; r++) { + expandRowPlanes(src, gRowY[r], gRowX[r], gRowW[r], &gPlaneBuf[0][(uint32_t)gRowY[r] * VGA_PLANE_ROW_BYTES]); + gPresentCopiedBytes += gRowW[r]; + } + page = &gVgaMem[(uint32_t)back * VGA_PAGE_BYTES]; + for (plane = 0u; plane < VGA_PLANES; plane++) { + outportw(VGA_SEQ_INDEX, (uint16_t)(((1u << plane) << 8) | VGA_SEQ_MAP_MASK)); + for (r = 0u; r < rows; r++) { + offset = (uint32_t)gRowY[r] * VGA_PLANE_ROW_BYTES + ((uint16_t)gRowX[r] >> 2); + memcpy(&page[offset], &gPlaneBuf[plane][offset], (size_t)(gRowW[r] >> 2)); } } + startAddressSet((uint16_t)(back * VGA_PAGE_BYTES)); + gFrontPage = back; + gFlipPending = true; + gFlipClocks = pitClocksNow(); + } + if (gStagePaletteDirty) { + retraceWait(); + uploadPalette(src); + gStagePaletteDirty = false; + gFlipPending = false; + gVblBanked = true; + gBankedClocks = pitClocksNow(); } - gPresentScannedRows += JL_SURFACE_HEIGHT; } @@ -370,21 +624,25 @@ uint32_t dosProbeVgaMismatch(const jlSurfaceT *src) { uint32_t bad = 0; uint16_t x; uint16_t y; + uint8_t plane; if (src == NULL || gVgaMem == NULL) { return 0xFFFFFFFFul; } - for (y = 0; y < JL_SURFACE_HEIGHT; y++) { - const uint8_t *rowPx = &src->pixels[(uint32_t)y * JL_SURFACE_BYTES_PER_ROW]; - const uint8_t *vga = &gVgaMem[(uint32_t)y * VGA_STRIDE]; - uint8_t palBase = (uint8_t)(src->scb[y] << 4); + for (plane = 0u; plane < VGA_PLANES; plane++) { + outportw(VGA_GC_INDEX, (uint16_t)((plane << 8) | VGA_GC_READ_MAP)); + for (y = 0; y < JL_SURFACE_HEIGHT; y++) { + const uint8_t *rowPx = &src->pixels[(uint32_t)y * JL_SURFACE_BYTES_PER_ROW]; + const uint8_t *vga = &gVgaMem[(uint32_t)gFrontPage * VGA_PAGE_BYTES + (uint32_t)y * VGA_PLANE_ROW_BYTES]; + uint8_t palBase = (uint8_t)(src->scb[y] << 4); - for (x = 0; x < JL_SURFACE_WIDTH; x++) { - uint8_t packed = rowPx[x >> 1]; - uint8_t nib = ((x & 1u) != 0u) ? (uint8_t)(packed & 0x0Fu) : (uint8_t)(packed >> 4); + for (x = plane; x < JL_SURFACE_WIDTH; x += VGA_PLANES) { + uint8_t packed = rowPx[x >> 1]; + uint8_t nib = ((x & 1u) != 0u) ? (uint8_t)(packed & 0x0Fu) : (uint8_t)(packed >> 4); - if ((uint8_t)(palBase | nib) != vga[x]) { - bad++; + if ((uint8_t)(palBase | nib) != vga[x >> 2]) { + bad++; + } } } } @@ -399,12 +657,15 @@ uint32_t dosProbeVgaMismatch(const jlSurfaceT *src) { // first wait while we're already in retrace, then wait until we // re-enter retrace. void jlpWaitVBL(void) { - while (inportb(VGA_INPUT_STAT_1) & VGA_VRETRACE_BIT) { - /* spin while still in current retrace */; - } - while (!(inportb(VGA_INPUT_STAT_1) & VGA_VRETRACE_BIT)) { - /* spin until next retrace begins */; + // A present that just waited for a retrace to load its colours has + // given this wait its retrace. + if (gVblBanked && pitClocksNow() - gBankedClocks < VGA_FRAME_PIT_CLOCKS) { + gVblBanked = false; + return; } + gVblBanked = false; + retraceWait(); + gFlipPending = false; // whatever flip was written has latched } @@ -496,13 +757,13 @@ void jlpShutdown(void) { if (gCrashLog != NULL) { fclose(gCrashLog); gCrashLog = NULL; + (void)remove(gCrashLogPath); } } -// DOS / VGA mode 13h is chunky-native (8bpp linear). portData is -// unused; the chunky `pixels` buffer feeds the present-time -// nearest-neighbor copy to VGA RAM. +// DOS keeps the stage chunky: portData is unused, and the present +// expands the chunky `pixels` buffer into VGA's planes. // surfaceCopyPlanes (and the sprite *Planes hooks): elided to ((void)0) by diff --git a/src/dos/save.c b/src/dos/save.c index 444d2ec..b721243 100644 --- a/src/dos/save.c +++ b/src/dos/save.c @@ -2,17 +2,24 @@ // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // -// DOS save-file + disk-space HAL. +// DOS save-file + disk-space HAL, and the file roots. // -// SAVES/ is a real subdirectory next to the .EXE, made with POSIX mkdir; -// delete is stdio remove(); free space comes from DOS INT 21h AH=36h (Get -// Disk Free Space) on the default drive. +// The program's folder (fileRoot.h) is the path DOS started it by, which +// DJGPP keeps whole (drive, folders, and name) in __dos_argv0, cut after its +// last separator. SAVES/ is a real subdirectory there, made with POSIX +// mkdir; delete is stdio remove(); free space comes from statvfs on that +// folder, which asks DOS for FAT32's extended free space (INT 21h AX=7303h) +// before falling back to AH=36h, so a drive past 2 GB reads right. -#include +#include +#include #include +#include #include +#include #include "joey/file.h" +#include "fileRoot.h" #include "port.h" @@ -21,23 +28,46 @@ #define DOS_MAX_U32 0xFFFFFFFFu -uint32_t jlpDiskFree(void) { - __dpmi_regs r; - uint32_t bytesPerClus; +// The program's folder, found the first time it is asked for: "" (the +// current directory) until then, and if the path does not fit. +static char gRoot[FILE_PATH_MAX]; +static bool gRootFound = false; - r.h.ah = 0x36u; // Get Disk Free Space - r.h.dl = 0x00u; // 0 = default drive - __dpmi_int(0x21, &r); - if (r.x.ax == 0xFFFFu) { - return 0u; // invalid drive + +uint32_t jlpDiskFree(void) { + struct statvfs fs; + const char *root; + uint64_t bytes; + + root = jlpWriteRoot(); + if (statvfs(root[0] != '\0' ? root : ".", &fs) != 0) { + return 0u; } - // AX = sectors/cluster, CX = bytes/sector, BX = free clusters. Saturate - // in 32-bit rather than pull a 64-bit multiply helper onto the target. - bytesPerClus = (uint32_t)r.x.ax * (uint32_t)r.x.cx; // <= 65535*65535 < 2^32 - if (bytesPerClus != 0u && (uint32_t)r.x.bx > DOS_MAX_U32 / bytesPerClus) { - return DOS_MAX_U32; + bytes = (uint64_t)fs.f_bavail * (uint64_t)fs.f_frsize; + return bytes > DOS_MAX_U32 ? DOS_MAX_U32 : (uint32_t)bytes; +} + + +const char *jlpReadRoot(void) { + const char *end; + const char *p; + size_t length; + + if (!gRootFound) { + gRootFound = true; + end = NULL; + for (p = __dos_argv0; p != NULL && *p != '\0'; p++) { + if (*p == '\\' || *p == '/' || *p == ':') { + end = p + 1; + } + } + length = end == NULL ? 0u : (size_t)(end - __dos_argv0); + if (length < sizeof(gRoot)) { + memcpy(gRoot, __dos_argv0, length); + gRoot[length] = '\0'; + } } - return bytesPerClus * (uint32_t)r.x.bx; + return gRoot; } @@ -55,3 +85,8 @@ bool jlpSaveDirEnsure(const char *dir) { // mkdir failed -- success only if it failed because a directory is there. return stat(dir, &st) == 0 && S_ISDIR(st.st_mode); } + + +const char *jlpWriteRoot(void) { + return jlpReadRoot(); +} diff --git a/src/dos/serial.c b/src/dos/serial.c index f2dc4e7..acbb897 100644 --- a/src/dos/serial.c +++ b/src/dos/serial.c @@ -78,7 +78,8 @@ #define XOFF_CHAR 0x13u #define TX_CTL_NONE 0x00u // no XOFF / XON waiting to go out -// A write gives up on a byte after this many polls of a busy or held transmitter. +// A write's backstop on a busy or held transmitter, in polls (serialTxStalled): +// the clock bounds it first, once it runs. #define TX_WAIT_POLLS 200000ul // UART base clock 1.8432 MHz -> divisor = 115200 / baud. @@ -472,11 +473,11 @@ void jlpSerialSetRts(bool on) { uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) { - uint16_t n; + SerialTxWaitT wait; + uint16_t n; for (n = 0u; n < len; n++) { - uint32_t guard = 0u; - + serialTxWaitStart(&wait, TX_WAIT_POLLS); // Bounded wait for the holding register and the peer. Interrupts are off from the check to // the write, so the ISR's XOFF / XON cannot land in the register in between. for (;;) { @@ -487,8 +488,7 @@ uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) { break; } enable(); - guard++; - if (guard > TX_WAIT_POLLS) { + if (serialTxStalled(&wait)) { return n; } } diff --git a/src/dvx/audio.c b/src/dvx/audio.c index f50d842..1f85cc9 100644 --- a/src/dvx/audio.c +++ b/src/dvx/audio.c @@ -7,27 +7,35 @@ // program on the desktop and mix them all together. // // MOD music.lib (libxmp-lite inside DVX: no second copy here), -// loaded from memory; music.lib plays it through audio.lib. +// loaded from memory; music.lib plays it through audio.lib. A +// song that ends on its own plays on from its stream for up to +// MUSIC_TAIL_MS after music.lib says so, so jlAudioIsPlayingMod +// says true until then. // SFX five slots, each at most one sound: a one-shot is an // audio.lib sample made from the program's signed 8-bit PCM // (the top bit flipped: DVX's 8-bit samples are unsigned); a // stream is an audio.lib pull stream whose fill calls the -// program's, flipping the same bit. JoeyLib's end of stream +// program's, flipping the same bit, or none for a raw stream +// (jlAudioPlaySfxStreamRaw: the IIgs's unsigned format, DVX's +// own bytes). JoeyLib's end of stream // (a fill returning 0) closes the stream once what it queued // has played. Pan sticks to the slot, as on every port. -// Voices, audio.lib tones that play until changed: the three -// noise jlAudioVoice voices (jlAudioTone is voice 0) squares, -// jlAudioNoise a noise tone. DVX puts squares on the FM chip -// when there is one and mixes them otherwise, and keeps noise in -// the mixer. +// Voices, the software sound chip the hosted ports use (psgSynth.c: +// noise squares, the YM2149's LFSR, 2 dB a step), mixed into an +// audio.lib pull stream of its own at the device's rate: exact +// levels and the same balance on every card. A device that plays +// no sampled sound (the PC speaker's tones-only driver) gets +// audio.lib tones instead, which it plays on its own tone +// generator. // -// None of this may run inside an interrupt (audio.lib's rule), so -// jlAudioTickRegister's callback runs in program time: a DVX timer counts -// the ticks at the asked rate, and jlAudioFrameTick delivers the ones that -// came due, in order. A callback can therefore do anything; what DVX loses -// against DOS is the sub-frame placement of what it does. jlAudioCritical* -// stay the generic no-ops: nothing the callback touches can be interrupted -// by it. +// None of this may run inside an interrupt (audio.lib's rule), and the +// stream's fill does not: audio.lib calls it from its pump in program time. +// So jlAudioTickRegister's callback runs there, between exactly the samples +// it falls between (psgSynthTickRun), as on the hosted ports: notes start +// where they should in the sound. Every driver runs the mixer in real time, +// the null one included, so the ticks keep time on a machine with no sound +// too. A callback can do anything; jlAudioCritical* stay the generic no-ops: +// nothing the callback touches can be interrupted by it. // // jlAudioFrameTick is also where DVX's music and streams are fed // (musicPump, which pumps audio.lib too) when the program keeps the @@ -39,10 +47,12 @@ #include #include +#include #include "port.h" #include "joey/audio.h" #include "dvxPort.h" +#include "psgSynth.h" // ----- Constants ----- @@ -52,12 +62,32 @@ #define PCM8_SIGN_FLIP 0x80u #define SFX_BITS 8 #define SFX_CHANNELS 1 -#define SFX_VOLUME AUDIO_VOLUME_MAX +// The mix joey/audio.h asks for: a full-scale effect, and a module channel at +// full volume, peak where a voice at attenuation 0 does, a quarter of full +// scale (PSG_SYNTH_PEAK, in the synth's stream at full volume). libxmp-lite +// in music.lib plays a channel at full volume 3.4 dB over a quarter +// (measured with AUDPAR's module), so the module plays at two thirds. +#define MIX_DIVISOR 4 +#define SFX_VOLUME (AUDIO_VOLUME_MAX / MIX_DIVISOR) +#define MUSIC_VOLUME (MUSIC_VOLUME_MAX * 2 / 3) // A stream's own buffer: DVX refills it every desktop frame or so. #define STREAM_BUFFER_MS 100 +// music.lib says a song has ended once it has rendered its last frame, and +// its stream (MODULE_STREAM_MS in music.lib) may still hold this much of +// it to play: a song that ended on its own is playing for this long after. +#define MUSIC_TAIL_MS 400u -// Tone voices at half scale: the three voices and the noise together stay -// clear of the mixer's clipping at full volume. +// The synth's stream: signed 16-bit mono at the device's own rate (or +// TONE_RATE_DEFAULT where it says none), a short buffer so a voice change +// is heard soon, mixed TONE_CHUNK_FRAMES at a time. +#define TONE_BITS 16 +#define TONE_CHANNELS 1 +#define TONE_RATE_DEFAULT 22050u +#define TONE_BUFFER_MS 60 +#define TONE_CHUNK_FRAMES 512u +// audio.lib tones, on a device with no sampled sound: at half scale, so the +// three voices and the noise together stay clear of the mixer's clipping at +// full volume. #define TONE_VOLUME (AUDIO_VOLUME_MAX / 2) #define TONE_UNTIL_STOPPED 0 // Attenuation 15 (and up) is silence (joey/audio.h). @@ -75,6 +105,7 @@ typedef struct { void *fillCtx; int32_t voice; int32_t pan; + bool raw; // the stream's bytes are unsigned already bool streamEnded; } SfxSlotT; @@ -88,14 +119,15 @@ typedef struct { // ----- Prototypes ----- static AudioCtxT *audioContext(void); +static void musicEnded(MusicT *music, void *userData); static void sfxFormat(uint16_t rateHz, AudioFormatT *fmt, int32_t *pitch); static void sfxStart(uint8_t slot, int32_t voice, uint16_t rateHz); static void sfxStop(uint8_t slot); +static void sfxStreamOpen(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz, bool raw); static void streamEndReap(void); static uint32_t streamFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData); -static void tickDeliver(void); -static void tickIsr(void *userData); -static void tickStop(void); +static uint32_t synthFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData); +static void synthOpen(void); static void toneSet(ToneChannelT *channel, uint32_t hz, int32_t volume); static int32_t voiceVolume(uint8_t atten); @@ -103,6 +135,8 @@ static int32_t voiceVolume(uint8_t atten); static AudioCtxT *gCtx = NULL; static MusicT *gMusic = NULL; +static bool gMusicTail = false; // the song ended on its own (musicEnded)... +static uint32_t gMusicEndMs = 0u; // ... at this jlpMillisElapsed static SfxSlotT gSlots[JL_AUDIO_SFX_SLOTS]; // DVX pan for each JoeyLib pan. @@ -129,11 +163,11 @@ static const uint16_t gAttenVolume[ATTEN_SILENT] = { static uint8_t *gPcm = NULL; static uint32_t gPcmSize = 0; -// jlAudioTickRegister: the callback, its DVX timer and the ticks the timer -// has counted that are not delivered yet. -static jlAudioTickFnT gTickFn = NULL; -static int32_t gTickTimer = DVX_PORT_NO_TIMER; -static volatile uint32_t gTicksDue = 0; +// The synth's stream (NULL: none, so no ticks), whether the voices play +// through it (the device plays sampled sound), and one chunk's mix. +static AudioStreamT *gSynth = NULL; +static bool gSynthVoices = false; +static int32_t gSynthMix[TONE_CHUNK_FRAMES]; // ----- Internal helpers (alphabetical) ----- @@ -147,6 +181,15 @@ static AudioCtxT *audioContext(void) { } +// music.lib's end call: the song has rendered its last frame (MUSIC_TAIL_MS). +static void musicEnded(MusicT *music, void *userData) { + (void)music; + (void)userData; + gMusicTail = true; + gMusicEndMs = jlpMillisElapsed(); +} + + // A one-shot's PCM format, and the pitch that makes a rate audio.lib cannot // take play right: it is created at the nearest rate it takes and played // slower or faster. @@ -161,8 +204,8 @@ static void sfxFormat(uint16_t rateHz, AudioFormatT *fmt, int32_t *pitch) { } -// A slot's new voice: its pan, and its pitch for a rate audio.lib cannot -// take directly. +// A slot's new voice: its volume (a stream's starts at full), its pan, and +// its pitch for a rate audio.lib cannot take directly. static void sfxStart(uint8_t slot, int32_t voice, uint16_t rateHz) { AudioFormatT fmt; int32_t pitch; @@ -171,6 +214,7 @@ static void sfxStart(uint8_t slot, int32_t voice, uint16_t rateHz) { return; } gSlots[slot].voice = voice; + (void)audioVoiceSetVolume(gCtx, voice, SFX_VOLUME); sfxFormat(rateHz, &fmt, &pitch); if (pitch != AUDIO_PITCH_NORMAL) { (void)audioVoiceSetPitch(gCtx, voice, pitch); @@ -191,10 +235,34 @@ static void sfxStop(uint8_t slot) { s->sample = NULL; s->fill = NULL; s->voice = NO_VOICE; + s->raw = false; s->streamEnded = false; } +// A slot's stream through the program's fill, its bytes signed (flipped +// as they come) or raw. +static void sfxStreamOpen(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz, bool raw) { + AudioFormatT fmt; + int32_t pitch; + SfxSlotT *s; + + sfxStop(slot); + s = &gSlots[slot]; + s->fill = fill; + s->fillCtx = ctx; + s->raw = raw; + sfxFormat(rateHz, &fmt, &pitch); + if (audioStreamOpen(gCtx, &fmt, STREAM_BUFFER_MS, streamFill, s, &s->stream) != AUDIO_OK) { + s->stream = NULL; + s->fill = NULL; + s->raw = false; + return; + } + sfxStart(slot, audioStreamVoice(s->stream), rateHz); +} + + // Close the streams whose program fill has ended, once what they queued // has played. static void streamEndReap(void) { @@ -209,8 +277,9 @@ static void streamEndReap(void) { // audio.lib asks a slot's stream for PCM: the program's fill writes signed -// bytes in place, which then become unsigned. Its 0 is the end of the -// stream; audio.lib's 0 only means "nothing yet", so the slot remembers. +// bytes in place, which then become unsigned (a raw stream's are already). +// Its 0 is the end of the stream; audio.lib's 0 only means "nothing yet", +// so the slot remembers. static uint32_t streamFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData) { SfxSlotT *s; uint8_t *pcm; @@ -228,6 +297,9 @@ static uint32_t streamFill(AudioStreamT *stream, void *buf, uint32_t frames, voi return 0; } got = got < frames ? got : frames; + if (s->raw) { + return got; + } pcm = (uint8_t *)buf; for (i = 0; i < got; i++) { pcm[i] ^= PCM8_SIGN_FLIP; @@ -236,40 +308,54 @@ static uint32_t streamFill(AudioStreamT *stream, void *buf, uint32_t frames, voi } -// Run the tick callback once for every tick the timer counted since last -// time. -static void tickDeliver(void) { - uint32_t state; - uint32_t ticks; +// audio.lib asks the synth's stream for PCM: the voices and the noise (when +// they play here), with the tick callback run between the samples it falls +// between. Four voices at the synth's peak fit a 16-bit sample. +static uint32_t synthFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData) { + int16_t *out; + uint32_t done; + uint32_t run; + uint32_t i; - if (gTickFn == NULL) { + (void)stream; + (void)userData; + out = (int16_t *)buf; + done = 0; + while (done < frames) { + run = psgSynthTickRun(frames - done < TONE_CHUNK_FRAMES ? frames - done : TONE_CHUNK_FRAMES); + memset(gSynthMix, 0, run * sizeof(gSynthMix[0])); + if (gSynthVoices) { + psgSynthMix(gSynthMix, run, TONE_CHANNELS); + } + for (i = 0; i < run; i++) { + out[done + i] = (int16_t)gSynthMix[i]; + } + done += run; + } + return frames; +} + + +// The synth's stream at the device's rate, on its own voice at full volume +// in the centre. +static void synthOpen(void) { + AudioInfoT info; + AudioFormatT fmt; + int32_t voice; + + audioGetInfo(&info); + fmt.rate = info.rate >= AUDIO_RATE_MIN && info.rate <= AUDIO_RATE_MAX ? info.rate : TONE_RATE_DEFAULT; + fmt.bits = TONE_BITS; + fmt.channels = TONE_CHANNELS; + psgSynthInit(fmt.rate); + if (audioStreamOpen(gCtx, &fmt, TONE_BUFFER_MS, synthFill, NULL, &gSynth) != AUDIO_OK) { + gSynth = NULL; return; } - state = platformIrqSave(); - ticks = gTicksDue; - gTicksDue = 0; - platformIrqRestore(state); - while (ticks > 0 && gTickFn != NULL) { - ticks--; - gTickFn(); - } -} - - -// The DVX timer callback, inside the timer interrupt: count, nothing more. -static void tickIsr(void *userData) { - (void)userData; - gTicksDue++; -} - - -static void tickStop(void) { - if (gTickTimer != DVX_PORT_NO_TIMER) { - dvxPortTimerStop(gTickTimer, tickIsr, (const void *)&gTicksDue, sizeof(gTicksDue)); - gTickTimer = DVX_PORT_NO_TIMER; - } - gTickFn = NULL; - gTicksDue = 0; + gSynthVoices = info.hasDac; + voice = audioStreamVoice(gSynth); + (void)audioVoiceSetVolume(gCtx, voice, AUDIO_VOLUME_MAX); + (void)audioVoiceSetPan(gCtx, voice, DVX_PAN_CENTER); } @@ -313,7 +399,6 @@ static int32_t voiceVolume(uint8_t atten) { // ----- HAL API (alphabetical) ----- void jlpAudioFrameTick(void) { - tickDeliver(); streamEndReap(); // Feeds the music and the streams; pumps audio.lib too. musicPump(); @@ -326,18 +411,32 @@ bool jlpAudioInit(void) { for (slot = 0; slot < JL_AUDIO_SFX_SLOTS; slot++) { gSlots[slot].pan = DVX_PAN_CENTER; } - return audioContext() != NULL; + if (audioContext() == NULL) { + return false; + } + synthOpen(); + return true; } bool jlpAudioIsPlayingMod(void) { - return gMusic != NULL && musicIsPlaying(gMusic); + if (gMusic == NULL) { + return false; + } + if (gMusicTail) { + return jlpMillisElapsed() - gMusicEndMs < MUSIC_TAIL_MS; + } + return musicIsPlaying(gMusic); } void jlpAudioNoise(uint8_t pitch, uint8_t atten) { uint32_t hz; + if (gSynthVoices) { + psgSynthNoise(pitch, atten); + return; + } // Every port's curve (audioNoiseHz), as far as an audio.lib tone goes. hz = audioNoiseHz(pitch); if (hz > AUDIO_TONE_MAX_HZ) { @@ -353,6 +452,8 @@ void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { gMusic = NULL; return; } + musicSetEndFn(gMusic, musicEnded, NULL); + (void)musicSetVolume(gMusic, MUSIC_VOLUME); (void)musicPlay(gMusic, loop); } @@ -385,21 +486,12 @@ void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint1 void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz) { - AudioFormatT fmt; - int32_t pitch; - SfxSlotT *s; + sfxStreamOpen(slot, fill, ctx, rateHz, false); +} - sfxStop(slot); - s = &gSlots[slot]; - s->fill = fill; - s->fillCtx = ctx; - sfxFormat(rateHz, &fmt, &pitch); - if (audioStreamOpen(gCtx, &fmt, STREAM_BUFFER_MS, streamFill, s, &s->stream) != AUDIO_OK) { - s->stream = NULL; - s->fill = NULL; - return; - } - sfxStart(slot, audioStreamVoice(s->stream), rateHz); + +void jlpAudioPlaySfxStreamRaw(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz) { + sfxStreamOpen(slot, fill, ctx, rateHz, true); } @@ -414,7 +506,9 @@ void jlpAudioSetPan(uint8_t slot, uint8_t pan) { void jlpAudioShutdown(void) { uint8_t i; - tickStop(); + audioStreamClose(gSynth); + gSynth = NULL; + gSynthVoices = false; jlpAudioStopMod(); for (i = 0; i < JL_AUDIO_SFX_SLOTS; i++) { sfxStop(i); @@ -433,7 +527,8 @@ void jlpAudioShutdown(void) { void jlpAudioStopMod(void) { musicFree(gMusic); - gMusic = NULL; + gMusic = NULL; + gMusicTail = false; } @@ -442,21 +537,19 @@ void jlpAudioStopSfx(uint8_t slot) { } +// The ticks come from the synth's stream: without it, none. bool jlpAudioTickRegister(jlAudioTickFnT fn, uint16_t hz) { - tickStop(); - if (fn == NULL) { - return true; - } - gTickFn = fn; - gTickTimer = dvxPortTimerStart(hz, tickIsr, (const void *)&gTicksDue, sizeof(gTicksDue)); - if (gTickTimer == DVX_PORT_NO_TIMER) { - gTickFn = NULL; + if (fn != NULL && gSynth == NULL) { return false; } - return true; + return psgSynthTickRegister(fn, hz); } void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { + if (gSynthVoices) { + psgSynthVoice(voice, freqHz, atten); + return; + } toneSet(&gVoices[voice], freqHz, voiceVolume(atten)); } diff --git a/src/dvx/hal.c b/src/dvx/hal.c index 2fed034..16d6e9f 100644 --- a/src/dvx/hal.c +++ b/src/dvx/hal.c @@ -8,9 +8,11 @@ // jlpInit opens the program's window: a game input window (every key but // Alt+Tab, Ctrl+Esc and Alt+F4 comes to the program) whose content area is // the 320x200 stage at the largest whole scale up to DVX_SCALE_MAX that fits -// the desktop, under a Game menu (Full Screen, Help, Exit). Closing the -// window by any of those routes ends the program at its next frame -// (dvxPortEndIfClosed). +// the desktop, in 4:3 (each stage row a fifth taller, as a CRT shows it, and +// as the hosted ports' windows are) or with square pixels, the user's +// choice, under a Game menu (Full Screen, Square Pixels, Help, Exit). +// Closing the window by any of those routes ends the program at its next +// frame (dvxPortEndIfClosed). // // The window's content buffer is in the desktop's own pixel format (8, 15, // 16 or 32 bits), so the present expands the stage's changed rows through @@ -100,11 +102,23 @@ #define CMD_GAME_FULL_SCREEN 100 #define CMD_GAME_HELP 101 #define CMD_GAME_EXIT 102 +#define CMD_GAME_SQUARE 103 // The port's own settings, a file in the program's settings folder. #define SETTINGS_FILE "JOEYLIB.INI" #define SETTINGS_SECTION "display" #define SETTINGS_FULL_SCREEN "fullScreen" +#define SETTINGS_SQUARE "squarePixels" + +// The window's shape: 4:3, as a CRT showed 320x200 and as the hosted ports' +// windows are, the stage's rows a fifth taller than its pixels are wide +// (SHAPE_43_ROWS window rows a scale for its 200); or with Game > Square +// Pixels, square pixels (16:10). The whole screen is the machine's own 4:3. +#define SHAPE_43_ROWS 240 +// The window's frame around its content: the borders and the title and +// menu bars. +#define WINDOW_FRAME_W (2 * CHROME_TOTAL_SIDE) +#define WINDOW_FRAME_H (CHROME_TOTAL_TOP + CHROME_MENU_HEIGHT + CHROME_TOTAL_BOTTOM) // ----- Types ----- @@ -121,7 +135,8 @@ typedef struct { uint8_t *pixels; // the stage's top-left pixel (NULL: nowhere to draw) int32_t pitch; // bytes per row int32_t bytesPerPixel; - int32_t scale; // pixels per stage pixel, each way + int32_t scale; // pixels per stage pixel across + int32_t height; // pixel rows the stage's JL_SURFACE_HEIGHT take (targetRow) int32_t x; // the stage's top-left pixel in DVX's coordinates int32_t y; // (the content area's, or the screen's) int32_t rows; // the stage rows and columns that fit @@ -148,19 +163,24 @@ static uint32_t probePixelRead(const uint8_t *p, int32_t bytesPerPixel); static uint32_t probeStagePixel(const DisplayT *d, LutKindE kind, const jlSurfaceT *src, int32_t x, int32_t y); static void redrawAll(void); static uint8_t rgb12Channel(uint16_t rgb, uint8_t shift); -static bool settingFullScreenLoad(void); -static void settingFullScreenSave(bool on); +static bool settingLoad(const char *key); +static void settingSave(const char *key, bool on); +static int32_t shapeRows(int32_t scale); +static void shapeSet(bool square); static const DisplayT *targetDisplay(void); static bool targetGet(PresentTargetT *t); +static int32_t targetRow(const PresentTargetT *t, int32_t y); static void targetShow(const PresentTargetT *t, int32_t x, int32_t y, int32_t w, int32_t h); static bool windowCreate(AppContextT *ac); +static int32_t windowScale(const AppContextT *ac); static void yieldMark(uint64_t frame); // ----- Module state ----- WindowT *gDvxPortWindow = NULL; -static int32_t gScale = 1; // window pixels per stage pixel, each way +static int32_t gScale = 1; // window pixels per stage pixel across +static bool gSquare = false; // square pixels, not 4:3 (shapeRows) static GameFramePacerT gPacer; static bool gClockStarted = false; static bool gCloseRequested = false; @@ -206,7 +226,8 @@ static uint64_t clockMicros(void) { // Stage row y, pixels x0..x1-1 (both even), into the target at its scale: -// each pixel repeated across, the row repeated down. On the whole screen at +// each pixel repeated across, the row repeated down over the target's rows +// for it (targetRow). On the whole screen at // 8 bits and 1x the pixels are the DOS port's mode 13h bytes, so its // expander does them (x0 and x1 are then whole chunky words, as it needs). static void expandRow(const PresentTargetT *t, const jlSurfaceT *src, int32_t y, int32_t x0, int32_t x1) { @@ -214,16 +235,18 @@ static void expandRow(const PresentTargetT *t, const jlSurfaceT *src, int32_t y, const uint8_t *in; uint8_t *row; int32_t pairs; + int32_t top; int32_t i; int32_t s; - if (gLutKind == LUT_INDEXED && t->scale == 1) { + if (gLutKind == LUT_INDEXED && t->scale == 1 && t->height == JL_SURFACE_HEIGHT) { x86ExpandRow(src, (int16_t)y, (int16_t)x0, (uint16_t)(x1 - x0), t->pixels + y * t->pitch); return; } lut = gLut[src->scb[y]]; in = &src->pixels[y * JL_SURFACE_BYTES_PER_ROW + x0 / STAGE_PIXELS_PER_BYTE]; - row = t->pixels + y * t->scale * t->pitch + x0 * t->scale * t->bytesPerPixel; + top = targetRow(t, y); + row = t->pixels + top * t->pitch + x0 * t->scale * t->bytesPerPixel; pairs = (x1 - x0) / STAGE_PIXELS_PER_BYTE; switch (t->bytesPerPixel) { @@ -282,8 +305,8 @@ static void expandRow(const PresentTargetT *t, const jlSurfaceT *src, int32_t y, } } - for (s = 1; s < t->scale; s++) { - memcpy(row + s * t->pitch, row, (size_t)((x1 - x0) * t->scale * t->bytesPerPixel)); + for (s = top + 1; s < targetRow(t, y + 1); s++) { + memcpy(row + (s - top) * t->pitch, row, (size_t)((x1 - x0) * t->scale * t->bytesPerPixel)); } } @@ -429,6 +452,11 @@ static void onMenu(WindowT *win, int32_t menuId) { dvxPortFullScreenToggle(); break; + case CMD_GAME_SQUARE: + shapeSet(!gSquare); + settingSave(SETTINGS_SQUARE, gSquare); + break; + case CMD_GAME_HELP: shellOpenHelp(gDvxPortProgram->shellCtx, gDvxPortProgram->helpFile); break; @@ -586,7 +614,9 @@ static uint8_t rgb12Channel(uint16_t rgb, uint8_t shift) { // The saved choice: whether the program was on the whole screen when the // user last switched. -static bool settingFullScreenLoad(void) { +// A display setting the user chose (key: SETTINGS_FULL_SCREEN or +// SETTINGS_SQUARE), false when never chosen. +static bool settingLoad(const char *key) { char path[DVX_MAX_PATH]; PrefsHandleT *prefs; bool on; @@ -596,13 +626,13 @@ static bool settingFullScreenLoad(void) { if (prefs == NULL) { return false; } - on = prefsGetBool(prefs, SETTINGS_SECTION, SETTINGS_FULL_SCREEN, false); + on = prefsGetBool(prefs, SETTINGS_SECTION, key, false); prefsClose(prefs); return on; } -static void settingFullScreenSave(bool on) { +static void settingSave(const char *key, bool on) { char path[DVX_MAX_PATH]; PrefsHandleT *prefs; @@ -614,7 +644,7 @@ static void settingFullScreenSave(bool on) { if (prefs == NULL) { return; } - prefsSetBool(prefs, SETTINGS_SECTION, SETTINGS_FULL_SCREEN, on); + prefsSetBool(prefs, SETTINGS_SECTION, key, on); if (!prefsSave(prefs)) { dvxLog("JoeyLib: %s: could not save %s", appDescriptor.name, path); } @@ -623,6 +653,31 @@ static void settingFullScreenSave(bool on) { // The display the stage goes to now. +// The window rows the whole stage takes at scale in the window's shape. +static int32_t shapeRows(int32_t scale) { + return (gSquare ? (int32_t)JL_SURFACE_HEIGHT : SHAPE_43_ROWS) * scale; +} + + +// The window in a shape: the largest scale that fits it, the window resized +// to the stage at that scale and redrawn whole, and the menu's check mark. +static void shapeSet(bool square) { + AppContextT *ac; + WindowT *win; + + gSquare = square; + win = gDvxPortWindow; + if (win == NULL) { + return; + } + ac = gDvxPortProgram->shellCtx; + gScale = windowScale(ac); + dvxResizeWindow(ac, win, win->w + JL_SURFACE_WIDTH * gScale - win->contentW, win->h + shapeRows(gScale) - win->contentH); + wmMenuItemSetChecked(win->menuBar, CMD_GAME_SQUARE, gSquare); + redrawAll(); +} + + static const DisplayT *targetDisplay(void) { AppContextT *ac; @@ -650,8 +705,9 @@ static bool targetGet(PresentTargetT *t) { scaleW = d->width / JL_SURFACE_WIDTH; scaleH = d->height / JL_SURFACE_HEIGHT; t->scale = scaleW < scaleH ? scaleW : scaleH; + t->height = JL_SURFACE_HEIGHT * t->scale; t->x = (d->width - JL_SURFACE_WIDTH * t->scale) / 2; - t->y = (d->height - JL_SURFACE_HEIGHT * t->scale) / 2; + t->y = (d->height - t->height) / 2; t->pitch = d->pitch; t->pixels = d->backBuf + t->y * t->pitch + t->x * t->bytesPerPixel; t->rows = JL_SURFACE_HEIGHT; @@ -659,13 +715,14 @@ static bool targetGet(PresentTargetT *t) { return true; } t->scale = gScale; + t->height = shapeRows(gScale); t->x = 0; t->y = 0; t->pitch = gDvxPortWindow->contentPitch; t->pixels = gDvxPortWindow->contentBuf; // The content area is the scaled stage unless DVX sized the window // otherwise. - t->rows = gDvxPortWindow->contentH / t->scale; + t->rows = gDvxPortWindow->contentH * JL_SURFACE_HEIGHT / t->height; t->cols = gDvxPortWindow->contentW / t->scale; t->rows = t->rows < JL_SURFACE_HEIGHT ? t->rows : JL_SURFACE_HEIGHT; t->cols = (t->cols < JL_SURFACE_WIDTH ? t->cols : JL_SURFACE_WIDTH) & ~(STAGE_PIXELS_PER_BYTE - 1); @@ -675,13 +732,20 @@ static bool targetGet(PresentTargetT *t) { // Stage rectangle (x, y, w, h) has changed in the target: flush it to the // screen on the whole screen, or have DVX composite it from the window. +// The first target row stage row y fills: rows stretch to the target's +// height, so in 4:3 every fifth stage row takes one row more. +static int32_t targetRow(const PresentTargetT *t, int32_t y) { + return y * t->height / (int32_t)JL_SURFACE_HEIGHT; +} + + static void targetShow(const PresentTargetT *t, int32_t x, int32_t y, int32_t w, int32_t h) { RectT r; r.x = t->x + x * t->scale; - r.y = t->y + y * t->scale; + r.y = t->y + targetRow(t, y); r.w = w * t->scale; - r.h = h * t->scale; + r.h = targetRow(t, y + h) - targetRow(t, y); if (t->full) { dvxExclusiveFlush(gDvxPortProgram->shellCtx, &r); } else { @@ -691,28 +755,21 @@ static void targetShow(const PresentTargetT *t, int32_t x, int32_t y, int32_t w, // The window: its content area the stage at the largest whole scale that -// fits the desktop (up to DVX_SCALE_MAX), not resizable, under a Game menu. +// fits the desktop (up to DVX_SCALE_MAX) in the shape the user last chose, +// not resizable, under a Game menu. static bool windowCreate(AppContextT *ac) { - int32_t frameW; - int32_t frameH; - int32_t scaleW; - int32_t scaleH; int32_t wantW; int32_t wantH; WindowT *win; MenuBarT *bar; MenuT *game; - frameW = 2 * CHROME_TOTAL_SIDE; - frameH = CHROME_TOTAL_TOP + CHROME_MENU_HEIGHT + CHROME_TOTAL_BOTTOM; - scaleW = (ac->display.width - frameW) / JL_SURFACE_WIDTH; - scaleH = (ac->display.height - frameH) / JL_SURFACE_HEIGHT; - gScale = scaleW < scaleH ? scaleW : scaleH; - gScale = gScale < 1 ? 1 : (gScale > DVX_SCALE_MAX ? DVX_SCALE_MAX : gScale); - wantW = JL_SURFACE_WIDTH * gScale; - wantH = JL_SURFACE_HEIGHT * gScale; + gSquare = settingLoad(SETTINGS_SQUARE); + gScale = windowScale(ac); + wantW = JL_SURFACE_WIDTH * gScale; + wantH = shapeRows(gScale); - win = dvxCreateWindowCentered(ac, appDescriptor.name, wantW + frameW, wantH + frameH, false); + win = dvxCreateWindowCentered(ac, appDescriptor.name, wantW + WINDOW_FRAME_W, wantH + WINDOW_FRAME_H, false); if (win == NULL) { return false; } @@ -727,6 +784,7 @@ static bool windowCreate(AppContextT *ac) { game = bar != NULL ? wmAddMenu(bar, "&Game") : NULL; if (game != NULL) { wmAddMenuCheckItem(game, "&Full Screen\tAlt+Enter", CMD_GAME_FULL_SCREEN, false); + wmAddMenuCheckItem(game, "&Square Pixels", CMD_GAME_SQUARE, gSquare); wmAddMenuItem(game, "&Help", CMD_GAME_HELP); wmAddMenuSeparator(game); wmAddMenuItem(game, "E&xit", CMD_GAME_EXIT); @@ -738,11 +796,25 @@ static bool windowCreate(AppContextT *ac) { dvxResizeWindow(ac, win, win->w + wantW - win->contentW, win->h + wantH - win->contentH); } gDvxPortWindow = win; - dvxLog("JoeyLib: %s: %" PRId32 "x%" PRId32 " at %" PRId32 "x on a %" PRId32 "x%" PRId32 " %" PRId32 "-bit desktop", appDescriptor.name, win->contentW, win->contentH, gScale, ac->display.width, ac->display.height, ac->display.format.bitsPerPixel); + dvxLog("JoeyLib: %s: %" PRId32 "x%" PRId32 " at %" PRId32 "x, %s, on a %" PRId32 "x%" PRId32 " %" PRId32 "-bit desktop", appDescriptor.name, win->contentW, win->contentH, gScale, gSquare ? "square" : "4:3", ac->display.width, ac->display.height, ac->display.format.bitsPerPixel); return true; } +// The largest whole scale, up to DVX_SCALE_MAX, at which the stage in the +// window's shape fits the desktop with the window's frame; 1 when none does. +static int32_t windowScale(const AppContextT *ac) { + int32_t scaleW; + int32_t scaleH; + int32_t scale; + + scaleW = (ac->display.width - WINDOW_FRAME_W) / (int32_t)JL_SURFACE_WIDTH; + scaleH = (ac->display.height - WINDOW_FRAME_H) / shapeRows(1); + scale = scaleW < scaleH ? scaleW : scaleH; + return scale < 1 ? 1 : (scale > DVX_SCALE_MAX ? DVX_SCALE_MAX : scale); +} + + // The program has yielded in frame `frame`: the next yield is due when frame // + 1 starts (rounded up to the microsecond, so the frame number there is // already frame + 1), or when the frame timer next ticks. @@ -764,7 +836,7 @@ void dvxPortEndIfClosed(void) { // The user's switch (Game > Full Screen, Alt+Enter), kept for the next run. void dvxPortFullScreenToggle(void) { fullSet(!gFullWanted); - settingFullScreenSave(gFullWanted); + settingSave(SETTINGS_FULL_SCREEN, gFullWanted); } @@ -805,8 +877,8 @@ uint32_t dvxPortProbeDisplayMismatch(const jlSurfaceT *src, uint32_t *compared) for (y = 0; y < t.rows; y++) { for (x = 0; x < t.cols; x++) { want = probeStagePixel(d, kind, src, x, y); - for (sy = 0; sy < t.scale; sy++) { - row = t.pixels + (y * t.scale + sy) * t.pitch + x * t.scale * t.bytesPerPixel; + for (sy = targetRow(&t, y); sy < targetRow(&t, y + 1); sy++) { + row = t.pixels + sy * t.pitch + x * t.scale * t.bytesPerPixel; for (sx = 0; sx < t.scale; sx++) { if (probePixelRead(row + sx * t.bytesPerPixel, t.bytesPerPixel) != want) { bad++; @@ -862,7 +934,7 @@ void dvxPortStagePoint(int32_t *x, int32_t *y) { if (targetGet(&t)) { *x = (*x - t.x) / t.scale; - *y = (*y - t.y) / t.scale; + *y = (*y - t.y) * JL_SURFACE_HEIGHT / t.height; } *x = *x < 0 ? 0 : (*x >= JL_SURFACE_WIDTH ? JL_SURFACE_WIDTH - 1 : *x); *y = *y < 0 ? 0 : (*y >= JL_SURFACE_HEIGHT ? JL_SURFACE_HEIGHT - 1 : *y); @@ -887,11 +959,12 @@ bool jlpInit(const jlConfigT *config) { gLutBits = 0; (void)clockMicros(); if (!windowCreate(gDvxPortProgram->shellCtx)) { + coreSetError("DVX could not open the program's window"); return false; } // Paces dvxPortService's yields; without it the service reads the clock. gFrameTimer = dvxPortTimerStart(DVX_FRAME_HZ, frameTickIsr, (const void *)&gYieldDue, sizeof(gYieldDue)); - if (settingFullScreenLoad()) { + if (settingLoad(SETTINGS_FULL_SCREEN)) { fullSet(true); } return true; diff --git a/src/dvx/serial.c b/src/dvx/serial.c index 37c2970..53e2d7a 100644 --- a/src/dvx/serial.c +++ b/src/dvx/serial.c @@ -14,6 +14,11 @@ // jlSerialSetDtr(false) stays down (scripts/verify-dvx-serial.sh checks // both at the far end). // +// Writes go into the library's transmit ring, which its interrupt sends from +// (rs232WriteBuf), so they never wait; a full ring takes what fits. rs232Close +// drops what the ring and the UART still hold, so jlSerialClose first lets +// them go out: for as long as they take at the line rate, as the Amiga does. +// // jlSerialPoll is also a point where the program comes back to the port // every frame, so it yields to the desktop like the input poll does: a // program that only talks serial would otherwise freeze DVX. @@ -32,14 +37,25 @@ #define NO_PORT (-1) #define COM_FIRST 1u // jlSerialConfigT.unit numbers COM ports from 1 +// A frame on the line: the start bit, then the data, parity, and stop bits. +#define START_BITS 1u +#define US_PER_MS 1000u +#define US_PER_SECOND 1000000u +// How long past the written bytes' line time a close waits for them, before +// it drops what a peer holding flow control still keeps back. +#define CLOSE_MARGIN_MS 100u + // ----- Prototypes ----- static int32_t comForDevice(jlSerialDeviceE device, const jlSerialConfigT *config); static int32_t rs232Bps(uint32_t baud); +static void txDrain(void); // ----- Module state ----- -static int32_t gCom = NO_PORT; +static int32_t gCom = NO_PORT; +static uint32_t gByteUs = 0u; // one byte's time on the line at the open's rate +static uint32_t gTxIdleMs = 0u; // when every byte written should be out // The rates DVX's rs232 library takes (the divisors of its 1.8432 MHz UART // clock in dvxgui's rs232.c), fastest first; it refuses any other. A rate @@ -87,6 +103,23 @@ static int32_t rs232Bps(uint32_t baud) { } +// Let what the program wrote go out before the port does: until it should be +// out at the line rate and the ring is empty, or CLOSE_MARGIN_MS past that. +// The desktop gets the time meanwhile (tsYield: not dvxPortService, which +// may end the program, and this may run in the exit handler). +static void txDrain(void) { + uint32_t deadline; + + deadline = gTxIdleMs + CLOSE_MARGIN_MS; + while ((int32_t)(deadline - jlpMillisElapsed()) > 0) { + if (rs232GetTxBuffered(gCom) <= 0 && (int32_t)(gTxIdleMs - jlpMillisElapsed()) <= 0) { + return; + } + tsYield(); + } +} + + // ----- HAL API (alphabetical) ----- uint16_t jlpSerialAvailable(void) { @@ -104,6 +137,7 @@ void jlpSerialClose(void) { if (gCom == NO_PORT) { return; } + txDrain(); (void)rs232Close(gCom); gCom = NO_PORT; } @@ -117,7 +151,9 @@ void jlpSerialFlush(void) { bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) { - int32_t com; + int32_t com; + int32_t bps; + uint32_t frameBits; if (gCom != NO_PORT) { return false; @@ -126,10 +162,14 @@ bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) { if (com == NO_PORT) { return false; } - if (rs232Open(com, rs232Bps(config->baud), config->dataBits, gParity[config->parity], config->stopBits, gHandshake[config->flow]) != RS232_SUCCESS) { + bps = rs232Bps(config->baud); + if (rs232Open(com, bps, config->dataBits, gParity[config->parity], config->stopBits, gHandshake[config->flow]) != RS232_SUCCESS) { return false; } - gCom = com; + frameBits = START_BITS + config->dataBits + config->stopBits + (config->parity != JL_SERIAL_PARITY_NONE ? 1u : 0u); + gByteUs = (frameBits * US_PER_SECOND + (uint32_t)bps - 1u) / (uint32_t)bps; + gTxIdleMs = jlpMillisElapsed(); + gCom = com; return true; } @@ -164,14 +204,23 @@ void jlpSerialSetRts(bool on) { } -// rs232Write waits on the UART itself and gives up while flow control -// holds the line, returning what went: the bounded poll JoeyLib asks for. +// Into the transmit ring (see the top): what fits, at once. The line time of +// what went in moves on when everything written should be out (gTxIdleMs). uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) { - int32_t n; + int32_t n; + uint32_t now; if (gCom == NO_PORT) { return 0u; } - n = rs232Write(gCom, (const char *)buf, len); - return n > 0 ? (uint16_t)n : 0u; + n = rs232WriteBuf(gCom, (const char *)buf, len); + if (n <= 0) { + return 0u; + } + now = jlpMillisElapsed(); + if ((int32_t)(gTxIdleMs - now) < 0) { + gTxIdleMs = now; + } + gTxIdleMs += ((uint32_t)n * gByteUs + US_PER_MS - 1u) / US_PER_MS; + return (uint16_t)n; } diff --git a/src/generic/genericSave.c b/src/generic/genericSave.c index fdac641..68ce036 100644 --- a/src/generic/genericSave.c +++ b/src/generic/genericSave.c @@ -7,8 +7,12 @@ // SAVES/ directory cannot be made, nothing can be deleted, and free space is // unknown -- so jlSaveWrite/Open fail cleanly and jlDiskFree returns 0. Every // real port overrides via JL_HAS_SAVE_DIR_ENSURE / JL_HAS_SAVE_DELETE / -// JL_HAS_DISK_FREE. Always compiled in via the src/generic/*.c wildcard; -// dead-stripped where overridden. +// JL_HAS_DISK_FREE. Renaming a file is standard C, so the generic rename is +// the C library's, and only a port whose library lacks it (the Amiga's +// libnix) overrides it (JL_HAS_SAVE_RENAME). Always compiled in via the +// src/generic/*.c wildcard; dead-stripped where overridden. + +#include #include "joey/file.h" #include "port.h" @@ -29,3 +33,8 @@ bool jlpGenericSaveDirEnsure(const char *dir) { (void)dir; return false; } + + +bool jlpGenericSaveRename(const char *from, const char *to) { + return rename(from, to) == 0; +} diff --git a/src/hosted/audio.c b/src/hosted/audio.c index 58cd751..d85a73c 100644 --- a/src/hosted/audio.c +++ b/src/hosted/audio.c @@ -6,26 +6,26 @@ // // Everything the machine ports' sound chips do is done here in software and // mixed into the backend's format (hosted.h: signed 16-bit, stereo or mono, -// at HOSTED_AUDIO_RATE): +// at the rate the backend opened, gRate): // * MOD music through the module player (src/core/modPlayer.c, the DOS, ST // and X68000 ports' player too), // * the SFX slots through the shared overlay mixer (audioSfxMix.c), each // placed left, centre or right (jlAudioSetPan; a mono mix has no sides), -// * the three tone voices as square waves and the noise voice as the -// YM2149's 17-bit LFSR clocked at 125 kHz / pitch (the ST's chip, the -// reference for the noise pitch curve), each attenuated 2 dB a step, -// * jlAudioTone as voice 0 at full volume (one-voice tone generator on a -// machine with several oscillators, as joey/audio.h says). +// * the three tone voices and the noise voice through the software sound +// chip DVX uses too (psgSynth.c: squares, the YM2149's LFSR, 2 dB a +// step), jlAudioTone as voice 0 at full volume (one-voice tone +// generator on a machine with several oscillators, as joey/audio.h +// says). // The mix is made on the program's thread, as DOS makes its own: the // library's frequent calls (present, the frame wait, the input poll, the // frame tick) keep the backend's queue AUDIO_QUEUE_FRAMES deep // (hostedAudioService). So the stream fill callbacks run on the program's // thread as they do on DOS and the IIgs, and a tick callback // (jlAudioTickRegister) is called between the very samples it falls -// between: notes start where they should in the sound, however the mixing is -// batched. The critical sections stay the generic no-ops. +// between (psgSynthTickRun): notes start where they should in the sound, +// however the mixing is batched. The critical sections stay the generic +// no-ops. -#include #include #include @@ -34,70 +34,44 @@ #include "hosted.h" #include "joey/audio.h" #include "modPlayer.h" +#include "psgSynth.h" // ----- Constants ----- // The queue the service keeps: four virtual frames of sound, so a program // may miss a few frames of library calls before it runs dry. #define AUDIO_QUEUE_VIRTUAL_FRAMES 4u -#define AUDIO_QUEUE_FRAMES (HOSTED_AUDIO_RATE * AUDIO_QUEUE_VIRTUAL_FRAMES / HOSTED_FRAME_HZ) +#define AUDIO_QUEUE_FRAMES (gRate * AUDIO_QUEUE_VIRTUAL_FRAMES / HOSTED_FRAME_HZ) // Frames mixed at a time. #define AUDIO_CHUNK_FRAMES 1024u -// Square-wave voices: the loudest a voice gets, and 2 dB less a step of -// attenuation down to ATTEN_SILENT. -#define VOICE_PEAK 4096.0 -#define ATTEN_DB_PER_STEP 2.0 -#define DB_PER_DECADE 20.0 -#define ATTEN_SILENT 15u -#define ATTEN_STEPS 16u - -// Phase accumulators are 32-bit fractions of a cycle; the top bit is the -// square's half. -#define PHASE_ONE 4294967296.0 -#define PHASE_HIGH_HALF 0x80000000u -#define PHASE_SHIFT 32 - -// The YM2149 noise generator: a 17-bit LFSR fed by bit 0 XOR bit 3, clocked -// at the ST's 2 MHz master clock / 16 / period, where period is the pitch -// (0 counts as 1). -#define NOISE_LFSR_SEED 1u -#define NOISE_LFSR_TOP_SHIFT 16 -#define NOISE_LFSR_TAP_SHIFT 3 - -// An SFX sample is signed 8-bit; it is mixed at full 16-bit scale, as the DOS -// mixer mixes it over the MOD. +// The mix joey/audio.h asks for: an SFX sample (signed 8-bit) at full scale, +// and a module channel at full volume, peak where a voice at attenuation 0 +// does, at PSG_SYNTH_PEAK, a quarter of a 16-bit sample. The module renders +// in stereo (two channels a side, each 128 times its sample) and is mixed +// at half, its two sides added for a mono mix, so a channel lands at an +// effect's level either way. #define SFX_BIAS 128 -#define SFX_SHIFT 8 +#define SFX_SCALE ((PSG_SYNTH_PEAK + 1) / SFX_BIAS) +#define MOD_SIDES 2u +#define MOD_DIVISOR 2 #define SAMPLE_MAX 32767 #define SAMPLE_MIN (-32768) -// ----- Types ----- - -typedef struct { - uint32_t phase; - uint32_t step; // phase per output frame; 0 = silent - int16_t amp; -} SquareT; - // ----- Prototypes ----- static int16_t clampSample(int32_t value); -static void mixEveryChannel(int32_t *frame, int16_t level); static void mixFrames(int16_t *out, uint32_t frames); static void mixModInto(uint32_t frames); -static void mixNoiseInto(uint32_t frames); static void mixSfxInto(uint32_t frames); -static void mixSquareInto(SquareT *sq, uint32_t frames); static bool panReaches(uint8_t pan, uint8_t channel); -static uint32_t phaseStep(uint32_t hz); -static void squareSet(SquareT *sq, uint16_t freqHz, uint8_t atten); // ----- Module state ----- static bool gReady = false; +static uint32_t gRate = 0u; // the backend's rate (hostBackendAudioOpen), 0 until open // A song is playing until the backend has played its last frame, not just // until the last one is mixed: frames handed to the backend, a song that // ended in the chunk being mixed, and the count at the end of that chunk. @@ -109,20 +83,9 @@ static uint32_t gModEndFrame = 0u; static AudioSfxSlotT gSfxSlots[JL_AUDIO_SFX_SLOTS]; static uint8_t gSfxPan [JL_AUDIO_SFX_SLOTS]; -static int16_t gAttenAmp[ATTEN_STEPS]; -static SquareT gVoices [JL_AUDIO_VOICES]; -static uint64_t gNoisePhase = 0; -static uint64_t gNoiseStep = 0; // 32.32 LFSR clocks per output frame -static uint32_t gNoiseLfsr = NOISE_LFSR_SEED; -static int16_t gNoiseAmp = 0; - -static jlAudioTickFnT gTickFn = NULL; -static uint32_t gTickHz = 0; -static uint32_t gTickAcc = 0; // counts HOSTED_AUDIO_RATE per tick, gTickHz a frame - // One chunk's mix, left and right interleaved, and its pieces. static int32_t gMix [AUDIO_CHUNK_FRAMES * HOSTED_AUDIO_CHANNELS]; -static int16_t gModBuf[AUDIO_CHUNK_FRAMES * HOSTED_AUDIO_CHANNELS]; +static int16_t gModBuf[AUDIO_CHUNK_FRAMES * MOD_SIDES]; static int16_t gOut [AUDIO_CHUNK_FRAMES * HOSTED_AUDIO_CHANNELS]; static uint8_t gSfxBuf[AUDIO_CHUNK_FRAMES]; @@ -139,16 +102,6 @@ static int16_t clampSample(int32_t value) { } -// A centred voice: level into each channel of one frame of the mix. -static void mixEveryChannel(int32_t *frame, int16_t level) { - uint8_t c; - - for (c = 0; c < HOSTED_AUDIO_CHANNELS; c++) { - frame[c] += level; - } -} - - // Mix frames of everything into out, calling the tick callback between the // samples it falls between. static void mixFrames(int16_t *out, uint32_t frames) { @@ -158,26 +111,10 @@ static void mixFrames(int16_t *out, uint32_t frames) { done = 0; while (done < frames) { - run = frames - done; - if (gTickFn != NULL) { - if (gTickAcc >= HOSTED_AUDIO_RATE) { - gTickAcc -= HOSTED_AUDIO_RATE; - gTickFn(); - continue; - } - // Frames until the accumulator reaches the next tick. - run = (HOSTED_AUDIO_RATE - gTickAcc + gTickHz - 1u) / gTickHz; - if (run > frames - done) { - run = frames - done; - } - gTickAcc += run * gTickHz; - } + run = psgSynthTickRun(frames - done); memset(gMix, 0, run * HOSTED_AUDIO_CHANNELS * sizeof(gMix[0])); mixModInto(run); - for (i = 0; i < JL_AUDIO_VOICES; i++) { - mixSquareInto(&gVoices[i], run); - } - mixNoiseInto(run); + psgSynthMix(gMix, run, (uint8_t)HOSTED_AUDIO_CHANNELS); mixSfxInto(run); for (i = 0; i < run * HOSTED_AUDIO_CHANNELS; i++) { out[done * HOSTED_AUDIO_CHANNELS + i] = clampSample(gMix[i]); @@ -189,14 +126,23 @@ static void mixFrames(int16_t *out, uint32_t frames) { static void mixModInto(uint32_t frames) { uint32_t i; + int32_t left; + int32_t right; bool ended; if (!modPlayerActive()) { return; } - ended = !modPlayerRenderS16(gModBuf, frames, (uint8_t)HOSTED_AUDIO_CHANNELS); - for (i = 0; i < frames * HOSTED_AUDIO_CHANNELS; i++) { - gMix[i] += gModBuf[i]; + ended = !modPlayerRenderS16(gModBuf, frames, (uint8_t)MOD_SIDES); + for (i = 0; i < frames; i++) { + left = gModBuf[i * MOD_SIDES]; + right = gModBuf[i * MOD_SIDES + 1u]; + if (HOSTED_AUDIO_CHANNELS == 1u) { + gMix[i] += (left + right) / MOD_DIVISOR; + } else { + gMix[i * HOSTED_AUDIO_CHANNELS] += left / MOD_DIVISOR; + gMix[i * HOSTED_AUDIO_CHANNELS + 1u] += right / MOD_DIVISOR; + } } // A song that has ended (not looping) has stopped the player; it sounds // on until this chunk has played (hostedAudioService). @@ -206,26 +152,6 @@ static void mixModInto(uint32_t frames) { } -static void mixNoiseInto(uint32_t frames) { - uint32_t i; - uint32_t clocks; - int16_t level; - - if (gNoiseAmp == 0) { - return; - } - for (i = 0; i < frames; i++) { - gNoisePhase += gNoiseStep; - for (clocks = (uint32_t)(gNoisePhase >> PHASE_SHIFT); clocks > 0; clocks--) { - gNoiseLfsr = (gNoiseLfsr >> 1) | (((gNoiseLfsr ^ (gNoiseLfsr >> NOISE_LFSR_TAP_SHIFT)) & 1u) << NOISE_LFSR_TOP_SHIFT); - } - gNoisePhase &= (uint64_t)UINT32_MAX; - level = (gNoiseLfsr & 1u) != 0 ? gNoiseAmp : (int16_t)-gNoiseAmp; - mixEveryChannel(&gMix[i * HOSTED_AUDIO_CHANNELS], level); - } -} - - // Each slot alone through the shared overlay mixer (its unsigned 8-bit // output, centred at SFX_BIAS, is that slot's signal), then into the // channels its pan reaches. @@ -246,7 +172,7 @@ static void mixSfxInto(uint32_t frames) { reaches[c] = panReaches(gSfxPan[s], c); } for (i = 0; i < frames; i++) { - sample = ((int32_t)gSfxBuf[i] - SFX_BIAS) << SFX_SHIFT; + sample = ((int32_t)gSfxBuf[i] - SFX_BIAS) * SFX_SCALE; for (c = 0; c < HOSTED_AUDIO_CHANNELS; c++) { if (reaches[c]) { gMix[i * HOSTED_AUDIO_CHANNELS + c] += sample; @@ -257,21 +183,6 @@ static void mixSfxInto(uint32_t frames) { } -static void mixSquareInto(SquareT *sq, uint32_t frames) { - uint32_t i; - int16_t level; - - if (sq->step == 0 || sq->amp == 0) { - return; - } - for (i = 0; i < frames; i++) { - sq->phase += sq->step; - level = (sq->phase & PHASE_HIGH_HALF) != 0 ? sq->amp : (int16_t)-sq->amp; - mixEveryChannel(&gMix[i * HOSTED_AUDIO_CHANNELS], level); - } -} - - // Left is the first channel, right the last, centre all of them; a mono mix // has one channel, which every pan reaches. static bool panReaches(uint8_t pan, uint8_t channel) { @@ -279,20 +190,6 @@ static bool panReaches(uint8_t pan, uint8_t channel) { } -// The phase step of a hz oscillator: hz cycles a second in 32-bit fractions -// of a cycle per output frame. -static uint32_t phaseStep(uint32_t hz) { - return (uint32_t)((double)hz * PHASE_ONE / (double)HOSTED_AUDIO_RATE); -} - - -static void squareSet(SquareT *sq, uint16_t freqHz, uint8_t atten) { - sq->step = phaseStep(freqHz); - sq->amp = gAttenAmp[atten < ATTEN_SILENT ? atten : ATTEN_SILENT]; -} - - - // ----- Hosted core API (alphabetical) ----- void hostedAudioService(void) { @@ -328,27 +225,17 @@ void jlpAudioFrameTick(void) { bool jlpAudioInit(void) { - uint8_t i; - if (gReady) { return true; } - if (!hostBackendAudioOpen()) { + gRate = hostBackendAudioOpen(); + if (gRate == 0u) { return false; } - // Rounded with C89's floor (BeOS R5 has no lrint); the amplitudes are - // positive and never halfway, so it rounds as lrint would. - for (i = 0; i < ATTEN_SILENT; i++) { - gAttenAmp[i] = (int16_t)floor(VOICE_PEAK * pow(10.0, -(double)i * ATTEN_DB_PER_STEP / DB_PER_DECADE) + 0.5); - } - gAttenAmp[ATTEN_SILENT] = 0; + psgSynthInit(gRate); memset(gSfxSlots, 0, sizeof(gSfxSlots)); memset(gSfxPan, JL_AUDIO_PAN_CENTER, sizeof(gSfxPan)); - memset(gVoices, 0, sizeof(gVoices)); - gNoiseStep = 0; - gNoiseAmp = 0; - gNoiseLfsr = NOISE_LFSR_SEED; - gReady = true; + gReady = true; return true; } @@ -367,11 +254,7 @@ bool jlpAudioIsPlayingMod(void) { void jlpAudioNoise(uint8_t pitch, uint8_t atten) { - uint32_t period; - - period = pitch == 0 ? 1u : pitch; - gNoiseStep = ((uint64_t)AUDIO_NOISE_CLOCK_HZ << PHASE_SHIFT) / period / HOSTED_AUDIO_RATE; - gNoiseAmp = gAttenAmp[atten < ATTEN_SILENT ? atten : ATTEN_SILENT]; + psgSynthNoise(pitch, atten); } @@ -381,7 +264,7 @@ void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { } gModEnding = false; gModTail = false; - (void)modPlayerStart(data, length, HOSTED_AUDIO_RATE, loop); + (void)modPlayerStart(data, length, gRate, loop); } @@ -389,7 +272,7 @@ void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint1 if (!gReady || slot >= JL_AUDIO_SFX_SLOTS) { return; } - audioSfxSlotArm(&gSfxSlots[slot], sample, length, rateHz, HOSTED_AUDIO_RATE); + audioSfxSlotArm(&gSfxSlots[slot], sample, length, rateHz, gRate); } @@ -397,7 +280,7 @@ void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uin if (!gReady || slot >= JL_AUDIO_SFX_SLOTS) { return; } - audioSfxSlotArmStream(&gSfxSlots[slot], fill, ctx, rateHz, HOSTED_AUDIO_RATE); + audioSfxSlotArmStream(&gSfxSlots[slot], fill, ctx, rateHz, gRate); } @@ -414,7 +297,7 @@ void jlpAudioShutdown(void) { return; } modPlayerStop(); - gTickFn = NULL; + (void)psgSynthTickRegister(NULL, 0u); hostBackendAudioClose(); gReady = false; } @@ -439,19 +322,10 @@ void jlpAudioStopSfx(uint8_t slot) { bool jlpAudioTickRegister(jlAudioTickFnT fn, uint16_t hz) { - if (fn != NULL && (hz == 0 || hz > HOSTED_AUDIO_RATE)) { - return false; - } - gTickFn = fn; - gTickHz = hz; - gTickAcc = 0; - return true; + return psgSynthTickRegister(fn, hz); } void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { - if (voice >= JL_AUDIO_VOICES) { - return; - } - squareSet(&gVoices[voice], freqHz, atten); + psgSynthVoice(voice, freqHz, atten); } diff --git a/src/hosted/be/beBackend.cpp b/src/hosted/be/beBackend.cpp index 61f001a..5ff6135 100644 --- a/src/hosted/be/beBackend.cpp +++ b/src/hosted/be/beBackend.cpp @@ -1145,7 +1145,7 @@ void hostBackendAudioClose(void) { } -bool hostBackendAudioOpen(void) { +uint32_t hostBackendAudioOpen(void) { media_raw_audio_format format; #if defined(__HAIKU__) @@ -1153,7 +1153,7 @@ bool hostBackendAudioOpen(void) { #endif if (gPlayer != NULL) { - return true; + return HOSTED_AUDIO_RATE; } #if defined(__HAIKU__) for (waited = 0; waited < AUDIO_MIXER_WAIT_US && !audioMixerReady(); waited += AUDIO_MIXER_POLL_US) { @@ -1170,10 +1170,10 @@ bool hostBackendAudioOpen(void) { if (gPlayer->InitCheck() != B_OK || gPlayer->Start() != B_OK) { delete gPlayer; gPlayer = NULL; - return false; + return 0u; } gPlayer->SetHasData(true); - return true; + return HOSTED_AUDIO_RATE; } @@ -1306,6 +1306,23 @@ const char *hostBackendOpen(void) { } +// The executable's folder, with its trailing separator, found once. +const char *hostBackendProgramFolder(void) { + static char root[B_PATH_NAME_LENGTH]; + size_t length; + + if (root[0] == '\0' && programFind()) { + length = strlen(gProgramFolder); + if (length + 2u <= sizeof(root)) { + memcpy(root, gProgramFolder, length); + root[length] = '/'; + root[length + 1u] = '\0'; + } + } + return root; +} + + void hostBackendPump(void) { EventT events[EVENT_QUEUE_SIZE]; uint32_t count; @@ -1392,7 +1409,7 @@ uint32_t jlpDiskFree(void) { fs_info info; uint64_t bytes; - if (fs_stat_dev(dev_for_path("."), &info) != 0) { + if (fs_stat_dev(dev_for_path(hostBackendProgramFolder()), &info) != 0) { return 0u; } bytes = (uint64_t)info.free_blocks * (uint64_t)info.block_size; @@ -1421,9 +1438,8 @@ bool jlpSaveDirEnsure(const char *dir) { // differ from the core's frame it mirrors; plus the window's, as the shows // left it (each drawing only what changed), that differ from the window // after a full repaint, both read back from the screen. The window is left -// fully repainted. A present shows at most once a virtual frame, so the -// probe first lets a frame pass and the pending show happen. Logs the -// window's size. *compared: every pixel compared. +// fully repainted. Logs the window's size. *compared: every pixel +// compared. uint32_t hostBeProbeDisplayMismatch(const jlSurfaceT *src, uint32_t *compared) { const uint8_t *bits; BRect dst; @@ -1438,8 +1454,6 @@ uint32_t hostBeProbeDisplayMismatch(const jlSurfaceT *src, uint32_t *compared) { uint16_t x; uint16_t y; - hostBackendSleepUntil(hostBackendMicros() + US_PER_SECOND / HOSTED_FRAME_HZ + 1u); - hostedShowPending(); *compared = 0; if (gLastFrame == NULL || gWindow == NULL || !gWindow->Lock()) { return 0; diff --git a/src/hosted/hal.c b/src/hosted/hal.c index 2a14b74..093a79e 100644 --- a/src/hosted/hal.c +++ b/src/hosted/hal.c @@ -35,6 +35,7 @@ #include #include "port.h" +#include "fileRoot.h" #include "surfaceInternal.h" #include "hosted.h" @@ -78,6 +79,7 @@ static void changedRects(void); static void expandRow(const jlSurfaceT *src, uint16_t y, uint16_t x0, uint16_t x1); +static void frameBoundaryWait(void); static uint64_t frameNow(void); static uint16_t gcd(uint16_t a, uint16_t b); static void lutBuild(const jlSurfaceT *src, uint16_t palettes); @@ -113,10 +115,11 @@ static uint32_t gLut[JL_SURFACE_PALETTE_COUNT][JL_SURFACE_COLORS_PER_PALETTE // them (hostBackendPaletteBases; PALETTES_NONE before the first present). static uint16_t gUsedPalettes = PALETTES_NONE; #endif +// The virtual frame the window last showed (FRAME_NONE: none yet). +#define FRAME_NONE UINT64_MAX static const char *gError = NULL; static bool gOpen = false; -static bool gShowPending = false; -static uint64_t gShownFrame = 0; +static uint64_t gShownFrame = FRAME_NONE; static uint64_t gStartUs = 0; // ----- Internal helpers (alphabetical) ----- @@ -191,6 +194,16 @@ static void expandRow(const jlSurfaceT *src, uint16_t y, uint16_t x0, uint16_t x } +// Sleep to the clock's next frame boundary. +static void frameBoundaryWait(void) { + uint64_t next; + + next = (frameNow() + 1u) * US_PER_SECOND / HOSTED_FRAME_HZ; + // One microsecond past the rounded-down boundary is inside the new frame. + hostBackendSleepUntil(gStartUs + next + 1u); +} + + // The virtual frame the clock is in. static uint64_t frameNow(void) { return (hostBackendMicros() - gStartUs) * HOSTED_FRAME_HZ / US_PER_SECOND; @@ -262,8 +275,7 @@ static void show(void) { #else hostBackendShow(gFrame, gRects, gRectCount); #endif - gShowPending = false; - gShownFrame = frameNow(); + gShownFrame = frameNow(); } // ----- Hosted core API (alphabetical) ----- @@ -431,11 +443,6 @@ void hostedRectScaled(const HostedRectT *rect, uint16_t outW, uint16_t outH, Hos } -void hostedShowPending(void) { - if (gShowPending && frameNow() != gShownFrame) { - show(); - } -} // ----- HAL API (alphabetical) ----- @@ -459,10 +466,9 @@ bool jlpInit(const jlConfigT *config) { gUsedPalettes = PALETTES_NONE; gShownStage = NULL; #endif - gOpen = true; - gShowPending = false; - gStartUs = hostBackendMicros(); - gShownFrame = 0; + gOpen = true; + gStartUs = hostBackendMicros(); + gShownFrame = FRAME_NONE; return true; } @@ -540,15 +546,26 @@ void jlpPresent(const jlSurfaceT *src) { gPresentCopiedBytes += (uint32_t)(x1 - x0) / STAGE_PIXELS_PER_BYTE; } gPresentScannedRows += JL_SURFACE_HEIGHT; - gShowPending = true; #if defined(HOSTED_FRAME_INDEXED) gShownStage = src; #endif - hostedShowPending(); + // The window shows one frame a tick of the clock, as a display shows one + // a refresh: a present in a tick already shown waits for the next, as a + // second flip in a frame does on the Amiga and the ST. Either way the + // picture is on the screen when the present returns. + if (frameNow() == gShownFrame) { + frameBoundaryWait(); + } + show(); hostedAudioService(); } +const char *jlpReadRoot(void) { + return hostBackendProgramFolder(); +} + + // The audio device goes with the backend; jlShutdown has stopped the mixer // before this. void jlpShutdown(void) { @@ -557,15 +574,14 @@ void jlpShutdown(void) { } -// Sleep to the clock's next frame boundary, then show what the frame -// before it presented. +// Sleep to the clock's next frame boundary. void jlpWaitVBL(void) { - uint64_t next; - - next = (frameNow() + 1u) * US_PER_SECOND / HOSTED_FRAME_HZ; - // One microsecond past the rounded-down boundary is inside the new frame. - hostBackendSleepUntil(gStartUs + next + 1u); + frameBoundaryWait(); hostBackendPump(); - hostedShowPending(); hostedAudioService(); } + + +const char *jlpWriteRoot(void) { + return hostBackendWriteFolder(); +} diff --git a/src/hosted/hosted.h b/src/hosted/hosted.h index 2ffcd61..14c1b81 100644 --- a/src/hosted/hosted.h +++ b/src/hosted/hosted.h @@ -42,9 +42,10 @@ typedef uint32_t HostedPixelT; // The audio the core mixes: signed 16-bit frames of HOSTED_AUDIO_CHANNELS -// samples (left, right; or one, mono) at HOSTED_AUDIO_RATE. 44.1 kHz stereo -// unless the backend's build asks for less (Windows 3.x: 22.05 kHz mono, what -// its sound drivers and a 386 manage). +// samples (left, right; or one, mono) at the rate hostBackendAudioOpen +// opened, HOSTED_AUDIO_RATE as a rule. 44.1 kHz stereo unless the backend's +// build asks for less (Windows 3.x: 22.05 kHz mono, what its sound drivers +// and a 386 manage, or 22.222 kHz where the card plays that exactly). #if !defined(HOSTED_AUDIO_RATE) #define HOSTED_AUDIO_RATE 44100u #endif @@ -64,8 +65,8 @@ typedef struct { // Open the window and get ready to show frames. Before it returns, the // current directory is the folder the program's executable is in, as on -// every machine port (DATA/, SAVES/ and joeylog.txt are opened by those -// relative names). NULL on success, else why the window could not be opened. +// every machine port that has one. NULL on success, else why the window +// could not be opened. const char *hostBackendOpen(void); // Close the window and the audio device; safe when either is not open. void hostBackendClose(void); @@ -107,11 +108,23 @@ bool hostBackendMousePresent(void); void hostBackendPump(void); // Microseconds since some fixed moment before the program started. uint64_t hostBackendMicros(void); +// The program's own folder, with its trailing separator ("" when the system +// cannot tell): the file roots (fileRoot.h), so asked before jlInit too. +const char *hostBackendProgramFolder(void); +#if defined(__EMSCRIPTEN__) +// The write root in a browser, apart from the read root: the page's own +// folder, its SAVES/ kept in IndexedDB (the SDL3 backend). Asked before +// jlInit too. +const char *hostBackendWriteFolder(void); +#else +#define hostBackendWriteFolder() hostBackendProgramFolder() +#endif // Sleep until hostBackendMicros reaches micros; returns at once when it has. void hostBackendSleepUntil(uint64_t micros); -// Open the audio output for the core's frames (HOSTED_AUDIO_RATE above). -// False when there is no audio device. -bool hostBackendAudioOpen(void); +// Open the audio output for the core's frames: the rate it plays them at +// (HOSTED_AUDIO_RATE above, or one the device plays more exactly), 0 when +// there is no audio device. The core mixes at that rate. +uint32_t hostBackendAudioOpen(void); void hostBackendAudioClose(void); // Frames queued and not yet played. uint32_t hostBackendAudioQueued(void); @@ -142,8 +155,6 @@ void hostedJoyButtonEvent(jlJoystickE js, jlJoyButtonE button, bool down); // Keep the audio queue full; the core calls it from every frequent library // call (present, the frame wait, the input poll and the frame tick). void hostedAudioService(void); -// Show a converted frame that has not been shown yet; the input poll calls it. -void hostedShowPending(void); // For a display probe: colour c of the stage's palette p, worked out from // the stage's own entry as the frame's colours are, 0x00RRGGBB. uint32_t hostedProbeStageRgb(const jlSurfaceT *src, uint8_t palette, uint8_t color); diff --git a/src/hosted/input.c b/src/hosted/input.c index d5f1835..72841e9 100644 --- a/src/hosted/input.c +++ b/src/hosted/input.c @@ -121,7 +121,6 @@ void jlpInputPoll(void) { gJoyAxisX[js] = inputDeadZone(gJoyRawX[js], gJoyDeadZone[js]); gJoyAxisY[js] = inputDeadZone(gJoyRawY[js], gJoyDeadZone[js]); } - hostedShowPending(); hostedAudioService(); } diff --git a/src/hosted/posix/posixSerial.c b/src/hosted/posix/posixSerial.c new file mode 100644 index 0000000..5a0b31c --- /dev/null +++ b/src/hosted/posix/posixSerial.c @@ -0,0 +1,424 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// POSIX serial HAL, for the hosted ports whose systems reach a serial port as +// a terminal device through termios: SDL3 on Linux, Haiku, and BeOS R5. The +// device is opened non-blocking in raw mode. The poll (and every read) moves +// what the driver has buffered into a ring here, since BeOS has no FIONREAD +// for jlSerialAvailable to ask how much that is, and a write waits a bounded +// time for the driver to take its bytes, as on Win32 (on BeOS R5 the +// driver's own time: see jlpSerialOpen). BeOS R5 is unverified: the gates' +// VM has no serial ports (verify-be-serial.sh). +// +// Which device: DEFAULT, MODEM and PRINTER are the one JOEYLIB_SERIAL names +// in the environment when it is set (a USB adapter's /dev/ttyUSB0, say), +// else the first port; SLOT is port , from 1. On Linux the ports are +// /dev/ttyS0, /dev/ttyS1, and on; on Haiku and BeOS the entries of +// /dev/ports in name order (BeOS's serial1, serial2, ...; Haiku's +// pc_serial0, ...). +// +// The open raises DTR, and RTS unless RTS/CTS handshakes (the driver then +// owns it). Closing waits until the driver has had time to send what the +// writes gave it, then puts back the speed and framing the port had, with +// HUPCL cleared, so the close leaves DTR as it was. Haiku's and BeOS's +// termios name 7 and 8 data bits only (CS5 and CS6 are CS7 there): 5 or 6 +// fail the open. Parity is not checked, so a byte that arrives with an error +// is delivered as it came. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "joey/serial.h" +#include "hosted.h" +#include "port.h" +#include "surfaceInternal.h" + +// ----- Constants ----- + +#if defined(__HAIKU__) || defined(__BEOS__) +#define PORTS_FOLDER "/dev/ports" +#define PORTS_MAX 32u +#else +#define PORT_PREFIX "/dev/ttyS" +#endif +#define PORT_ENV "JOEYLIB_SERIAL" +#define PORT_NAME_BYTES 128 +#define PORT_FIRST 1u // jlSerialConfigT.unit numbers ports from 1 +#define RX_RING_BYTES 4096u +#define ERROR_TEXT_BYTES 192 +// A write gives up after this long on top of what its bytes take at the line +// rate, so a flow-controlled peer cannot stall the program; the close waits +// as long past the last byte's time before it puts the old settings back. +#define WRITE_TIMEOUT_US 100000u +#define WRITE_RETRY_US 1000u +#define US_PER_SECOND 1000000u +#define START_BITS 1u +// Where termios cannot tell 5 or 6 data bits from 7 (Haiku, BeOS), only 7 +// and 8 are honest. +#if CS5 == CS7 +#define DATA_BITS_MIN 7u +#else +#define DATA_BITS_MIN 5u +#endif +// The modem control lines as setLine names them: Haiku's and BeOS's ioctls, +// or Linux's line mask bits. +#if defined(TCSETDTR) +#define LINE_DTR TCSETDTR +#define LINE_RTS TCSETRTS +#else +#define LINE_DTR TIOCM_DTR +#define LINE_RTS TIOCM_RTS +#endif + +typedef struct { + uint32_t baud; + speed_t code; +} BaudRateT; + +// ----- Prototypes ----- + +static bool portPath(jlSerialDeviceE device, const jlSerialConfigT *config, char *out, size_t cap); +#if defined(PORTS_FOLDER) +static int portNameCompare(const void *a, const void *b); +#endif +static speed_t rateCode(uint32_t baud, uint32_t *actual); +static void rxFill(void); +static void setLine(uint32_t bit, bool on); + +// ----- Module state ----- + +static int gFd = -1; +static struct termios gSaved; // the line as the open found it +static uint8_t gRx[RX_RING_BYTES]; // received, not yet read: gRxLen from the front +static uint16_t gRxLen; +static uint32_t gByteUs; // one byte's time on the line at the open's rate +static uint64_t gTxIdleAt; // when the driver should have sent every byte written +static char gErrorText[ERROR_TEXT_BYTES]; + +static const BaudRateT gRates[] = { + { 300u, B300 }, + { 600u, B600 }, + { 1200u, B1200 }, + { 2400u, B2400 }, + { 4800u, B4800 }, + { 9600u, B9600 }, + { 19200u, B19200 }, + { 38400u, B38400 }, + { 57600u, B57600 }, + { 115200u, B115200 } +}; + +static const tcflag_t gDataBits[] = { CS5, CS6, CS7, CS8 }; + +// ----- Internal helpers (alphabetical) ----- + +#if defined(PORTS_FOLDER) +static int portNameCompare(const void *a, const void *b) { + return strcmp((const char *)a, (const char *)b); +} +#endif + + +// The device a jlSerialDeviceE names (see the header comment) into out. +// False when there is no such port. +static bool portPath(jlSerialDeviceE device, const jlSerialConfigT *config, char *out, size_t cap) { + const char *named; + uint8_t which; +#if defined(PORTS_FOLDER) + static char names[PORTS_MAX][PORT_NAME_BYTES]; + DIR *dir; + struct dirent *entry; + uint16_t count; +#endif + + named = getenv(PORT_ENV); + if (device != JL_SERIAL_SLOT && named != NULL && named[0] != '\0') { + return (size_t)snprintf(out, cap, "%s", named) < cap; + } + which = 0u; + if (device == JL_SERIAL_SLOT) { + if (config->unit < PORT_FIRST) { + return false; + } + which = (uint8_t)(config->unit - PORT_FIRST); + } +#if defined(PORTS_FOLDER) + dir = opendir(PORTS_FOLDER); + if (dir == NULL) { + return false; + } + count = 0u; + while (count < PORTS_MAX && (entry = readdir(dir)) != NULL) { + if (entry->d_name[0] != '.' && strlen(entry->d_name) < PORT_NAME_BYTES) { + strcpy(names[count], entry->d_name); + count++; + } + } + closedir(dir); + if (which >= count) { + return false; + } + qsort(names, count, PORT_NAME_BYTES, portNameCompare); + return (size_t)snprintf(out, cap, "%s/%s", PORTS_FOLDER, names[which]) < cap; +#else + return (size_t)snprintf(out, cap, "%s%u", PORT_PREFIX, (unsigned)which) < cap; +#endif +} + + +// The termios code of the rate nearest baud, and that rate in *actual. +static speed_t rateCode(uint32_t baud, uint32_t *actual) { + size_t best; + uint32_t gap; + uint32_t bestGap; + size_t i; + + best = 0u; + bestGap = UINT32_MAX; + for (i = 0; i < sizeof(gRates) / sizeof(gRates[0]); i++) { + gap = gRates[i].baud > baud ? gRates[i].baud - baud : baud - gRates[i].baud; + if (gap < bestGap) { + bestGap = gap; + best = i; + } + } + *actual = gRates[best].baud; + return gRates[best].code; +} + + +// Move what the driver holds into the ring, as much as fits. Where the +// system says how much is waiting (FIONREAD), only that is read: Haiku's +// serial driver blocks a read that finds nothing, whatever O_NONBLOCK and +// VMIN say. +static void rxFill(void) { + size_t want; + ssize_t got; +#if defined(FIONREAD) + int waiting; +#endif + + while (gRxLen < RX_RING_BYTES) { + want = RX_RING_BYTES - gRxLen; +#if defined(FIONREAD) + if (ioctl(gFd, FIONREAD, &waiting) == 0) { + if (waiting <= 0) { + return; + } + if ((size_t)waiting < want) { + want = (size_t)waiting; + } + } +#endif + got = read(gFd, gRx + gRxLen, want); + if (got <= 0) { + return; + } + gRxLen = (uint16_t)(gRxLen + (uint16_t)got); + } +} + + +// Raise or drop a modem control line: Haiku's and BeOS's TCSETDTR and +// TCSETRTS take a bool, Linux's TIOCMBIS and TIOCMBIC a line mask. A device +// with no such lines (a pseudo-terminal) refuses, which is no error. +static void setLine(uint32_t bit, bool on) { +#if defined(TCSETDTR) + (void)ioctl(gFd, bit, &on); +#else + int mask; + + mask = (int)bit; + (void)ioctl(gFd, on ? TIOCMBIS : TIOCMBIC, &mask); +#endif +} + +// ----- HAL API (alphabetical) ----- + +uint16_t jlpSerialAvailable(void) { + if (gFd < 0) { + return 0u; + } + rxFill(); + return gRxLen; +} + + +void jlpSerialClose(void) { + uint64_t now; + + if (gFd < 0) { + return; + } + now = hostBackendMicros(); + if (gTxIdleAt > now) { + hostBackendSleepUntil(gTxIdleAt + WRITE_TIMEOUT_US); + } + gSaved.c_cflag &= ~(tcflag_t)HUPCL; + (void)tcsetattr(gFd, TCSANOW, &gSaved); + close(gFd); + gFd = -1; + gRxLen = 0u; +} + + +void jlpSerialFlush(void) { + if (gFd < 0) { + return; + } + (void)tcflush(gFd, TCIFLUSH); + gRxLen = 0u; +} + + +bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) { + struct termios line; + char path[PORT_NAME_BYTES]; + speed_t code; + uint32_t rate; + uint32_t frameBits; + bool rtsCts; + + if (gFd >= 0 || config->dataBits < DATA_BITS_MIN) { + return false; + } + if (!portPath(device, config, path, sizeof(path))) { + coreSetError("serial: no such port"); + return false; + } + gFd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK); + if (gFd < 0) { + snprintf(gErrorText, sizeof(gErrorText), "serial: cannot open %s: %s", path, strerror(errno)); + coreSetError(gErrorText); + return false; + } + if (tcgetattr(gFd, &gSaved) != 0) { + snprintf(gErrorText, sizeof(gErrorText), "serial: %s is not a serial port", path); + coreSetError(gErrorText); + close(gFd); + gFd = -1; + return false; + } + rtsCts = config->flow == JL_SERIAL_FLOW_RTSCTS; + code = rateCode(config->baud, &rate); + line = gSaved; + line.c_iflag = config->flow == JL_SERIAL_FLOW_XONXOFF ? (tcflag_t)(IXON | IXOFF) : 0u; + line.c_oflag = 0u; + line.c_lflag = 0u; + line.c_cflag = (tcflag_t)(CREAD | CLOCAL | gDataBits[config->dataBits - 5u]); + if (config->stopBits == 2u) { + line.c_cflag |= CSTOPB; + } + if (config->parity != JL_SERIAL_PARITY_NONE) { + line.c_cflag |= PARENB; + } + if (config->parity == JL_SERIAL_PARITY_ODD) { + line.c_cflag |= PARODD; + } + if (rtsCts) { + line.c_cflag |= CRTSCTS; + } + line.c_cc[VMIN] = 0; + line.c_cc[VTIME] = 0; + if (cfsetispeed(&line, code) != 0 || cfsetospeed(&line, code) != 0 || tcsetattr(gFd, TCSANOW, &line) != 0) { + snprintf(gErrorText, sizeof(gErrorText), "serial: %s refused the line settings", path); + coreSetError(gErrorText); + close(gFd); + gFd = -1; + return false; + } +#if !defined(FIONREAD) + // BeOS R5: reads return at once through VMIN and VTIME of 0 with the + // descriptor blocking, as Be's own BSerialPort leaves it, so a write + // blocks until the driver has taken its bytes. + (void)fcntl(gFd, F_SETFL, fcntl(gFd, F_GETFL) & ~O_NONBLOCK); +#endif + (void)tcflush(gFd, TCIFLUSH); + setLine(LINE_DTR, true); + if (!rtsCts) { + setLine(LINE_RTS, true); + } + frameBits = START_BITS + config->dataBits + (config->parity != JL_SERIAL_PARITY_NONE ? 1u : 0u) + config->stopBits; + gByteUs = (frameBits * US_PER_SECOND + rate - 1u) / rate; + gTxIdleAt = 0u; + gRxLen = 0u; + return true; +} + + +void jlpSerialPoll(void) { + if (gFd >= 0) { + rxFill(); + } +} + + +uint16_t jlpSerialRead(uint8_t *buf, uint16_t max) { + uint16_t take; + + if (gFd < 0) { + return 0u; + } + rxFill(); + take = max < gRxLen ? max : gRxLen; + memcpy(buf, gRx, take); + gRxLen = (uint16_t)(gRxLen - take); + memmove(gRx, gRx + take, gRxLen); + return take; +} + + +void jlpSerialSetDtr(bool on) { + if (gFd >= 0) { + setLine(LINE_DTR, on); + } +} + + +// With RTS/CTS the driver owns RTS: the line handshakes. +void jlpSerialSetRts(bool on) { + if (gFd >= 0) { + setLine(LINE_RTS, on); + } +} + + +uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) { + uint64_t now; + uint64_t deadline; + uint16_t sent; + ssize_t put; + + if (gFd < 0) { + return 0u; + } + now = hostBackendMicros(); + deadline = now + (uint64_t)len * gByteUs + WRITE_TIMEOUT_US; + sent = 0u; + while (sent < len) { + put = write(gFd, buf + sent, (size_t)(len - sent)); + if (put > 0) { + sent = (uint16_t)(sent + (uint16_t)put); + continue; + } + if (put < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + break; + } + now = hostBackendMicros(); + if (now >= deadline) { + break; + } + hostBackendSleepUntil(now + WRITE_RETRY_US); + } + now = hostBackendMicros(); + gTxIdleAt = (gTxIdleAt > now ? gTxIdleAt : now) + (uint64_t)sent * gByteUs; + return sent; +} diff --git a/src/hosted/sdl3/sdl3Backend.c b/src/hosted/sdl3/sdl3Backend.c index dcdfc0b..778c4bf 100644 --- a/src/hosted/sdl3/sdl3Backend.c +++ b/src/hosted/sdl3/sdl3Backend.c @@ -33,10 +33,12 @@ // browser its turn and carries on where it was. The program gets its turn // back at least once a virtual frame even when it never waits (a loop on // jlInputPoll alone, as jlWaitForAnyKey is). Its folder is the in-memory file -// system's root, where its DATA is unpacked; SAVES/ there is kept in the -// browser's IndexedDB (IDBFS), read when the window opens and written back -// as each save changes (and as the page goes away), so saves outlive it. -// Free space is the browser's own figure for the site (navigator.storage). +// system's root, where its DATA is unpacked. SAVES/ and joeylog.txt go in a +// folder named after the page (webWriteFolderMount), SAVES/ there kept in +// the browser's IndexedDB (IDBFS), a store of the page's own: read when the +// program first asks for it and written back as each save changes (and as +// the page goes away), so saves outlive it. Free space is the browser's own +// figure for the site (navigator.storage). // // On the PSP (the psp port) the window is the 480x272 screen and the stage // sits in its middle at 1:1. The PSP's own controls are gamepad 0 through @@ -132,6 +134,9 @@ extern const uint8_t gHostedIconRgba[]; #define US_PER_MS 1000u // The browser's turn at least this often: one virtual frame. #define WEB_YIELD_US (1000000u / HOSTED_FRAME_HZ) +// The longest write folder kept whole (webWriteFolderMount), its NUL in: +// half the core's FILE_PATH_MAX, leaving the rest for SAVES/ and a name. +#define WEB_FOLDER_BYTES 128 #endif #if defined(__PSP__) @@ -213,8 +218,8 @@ static bool gSquare = false; static bool gAtExit = false; static char gErrorText[ERROR_TEXT_BYTES]; #if defined(__EMSCRIPTEN__) -static bool gWebSavesMounted = false; -static uint64_t gWebYieldUs = 0; // when the browser last had its turn +static char gWebWriteFolder[WEB_FOLDER_BYTES]; // "" until mounted +static uint64_t gWebYieldUs = 0; // when the browser last had its turn #endif #if defined(__PSP__) @@ -645,13 +650,31 @@ static void textureFill(const uint32_t *pixels) { #if defined(__EMSCRIPTEN__) -// SAVES/ in the root of the in-memory file system, kept in IndexedDB: made -// and filled from it once (the program waits here until it has been read). -// autoPersist writes each change back as it is made, so a save is kept -// however the page ends; leaving the page writes back anything still due. -EM_ASYNC_JS(void, webSavesMount, (void), { - FS.mkdir("/SAVES"); - FS.mount(IDBFS, {autoPersist: true}, "/SAVES"); +// The page's own folder in the in-memory file system, into out (cap bytes): +// its path on the server less the page's extension, as a folder +// ("/games/UBER.html" gives "/games/UBER/", a folder's own page "index"), +// with SAVES/ in it kept in IndexedDB. IndexedDB is the whole site's, shared +// by every page on it; IDBFS names its database after the mount point, so +// each page keeps its own, as each machine port's program keeps SAVES/ in +// its own folder. A path too long for out keeps the page's name alone. The +// program waits here until SAVES/ has been read; autoPersist writes each +// change back as it is made, so a save is kept however the page ends, and +// leaving the page writes back anything still due. The folder is also +// Module.joeyWriteFolder, where the page's shell and the web gates find +// joeylog.txt. A page's path is ASCII (the browser escapes the rest). +EM_ASYNC_JS(void, webWriteFolderMount, (char *out, int cap), { + var path = window.location.pathname; + var at = path.lastIndexOf("/"); + var file = path.slice(at + 1); + var dot = file.lastIndexOf("."); + var page = (dot >= 0 ? file.slice(0, dot) : file) || "index"; + var folder = path.slice(0, at + 1) + page + "/"; + var i; + if (folder.length >= cap) { + folder = "/" + page.slice(0, cap - 3) + "/"; + } + FS.mkdirTree(folder + "SAVES"); + FS.mount(IDBFS, {autoPersist: true}, folder + "SAVES"); await new Promise(function (resolve) { FS.syncfs(true, function () { resolve(); @@ -661,6 +684,11 @@ EM_ASYNC_JS(void, webSavesMount, (void), { FS.syncfs(false, function () { }); }); + Module.joeyWriteFolder = folder; + for (i = 0; i < folder.length; i++) { + HEAPU8[out + i] = folder.charCodeAt(i); + } + HEAPU8[out + folder.length] = 0; }); @@ -726,14 +754,14 @@ void hostBackendAudioClose(void) { } -bool hostBackendAudioOpen(void) { +uint32_t hostBackendAudioOpen(void) { SDL_AudioSpec spec; if (gAudio != NULL) { - return true; + return HOSTED_AUDIO_RATE; } if (!SDL_InitSubSystem(SDL_INIT_AUDIO)) { - return false; + return 0u; } spec.format = SDL_AUDIO_S16; spec.channels = (int)HOSTED_AUDIO_CHANNELS; @@ -741,10 +769,10 @@ bool hostBackendAudioOpen(void) { gAudio = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, NULL, NULL); if (gAudio == NULL) { SDL_QuitSubSystem(SDL_INIT_AUDIO); - return false; + return 0u; } SDL_ResumeAudioStreamDevice(gAudio); - return true; + return HOSTED_AUDIO_RATE; } @@ -825,19 +853,13 @@ const char *hostBackendOpen(void) { if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) { return SDL_GetError(); } - // The program's own folder is the current directory, as on every - // machine port. - base = SDL_GetBasePath(); - if (base == NULL || chdir(base) != 0) { + // The program's own folder is the current directory from here on, as + // the folder its files are found in (hostBackendProgramFolder). + base = hostBackendProgramFolder(); + if (base[0] == '\0' || chdir(base) != 0) { SDL_Quit(); return "cannot make the program's folder the current directory"; } -#if defined(__EMSCRIPTEN__) - if (!gWebSavesMounted) { - webSavesMount(); - gWebSavesMounted = true; - } -#endif aspect = SDL_getenv("JOEYLIB_ASPECT"); gSquare = aspect != NULL && SDL_strcmp(aspect, "square") == 0; scale = optionInt("JOEYLIB_SCALE", WINDOW_SCALE_DEFAULT, WINDOW_SCALE_MAX); @@ -881,6 +903,15 @@ const char *hostBackendOpen(void) { } +// SDL's base path, which ends in its separator. +const char *hostBackendProgramFolder(void) { + const char *base; + + base = SDL_GetBasePath(); + return base != NULL ? base : ""; +} + + void hostBackendPump(void) { SDL_Event e; @@ -1044,6 +1075,18 @@ void hostBackendSleepUntil(uint64_t micros) { } } + +#if defined(__EMSCRIPTEN__) +// The page's folder, mounted the first time it is asked for +// (webWriteFolderMount): the program may log or save before jlInit. +const char *hostBackendWriteFolder(void) { + if (gWebWriteFolder[0] == '\0') { + webWriteFolderMount(gWebWriteFolder, (int)sizeof(gWebWriteFolder)); + } + return gWebWriteFolder; +} +#endif + // ----- HAL API (alphabetical) ----- // Free bytes where the program runs, saturated at what a uint32_t holds. On @@ -1062,8 +1105,8 @@ uint32_t jlpDiskFree(void) { uint64_t bytes; #if defined(__PSP__) - base = SDL_GetBasePath(); - end = base == NULL ? NULL : SDL_strchr(base, PSP_DEVICE_END); + base = hostBackendProgramFolder(); + end = SDL_strchr(base, PSP_DEVICE_END); if (end == NULL) { return 0u; } @@ -1081,7 +1124,7 @@ uint32_t jlpDiskFree(void) { #elif defined(__EMSCRIPTEN__) bytes = (uint64_t)webStorageFree(); #else - if (statvfs(".", &fs) != 0) { + if (statvfs(hostBackendProgramFolder(), &fs) != 0) { return 0u; } bytes = (uint64_t)fs.f_bavail * (uint64_t)fs.f_frsize; diff --git a/src/hosted/win16/win16Backend.c b/src/hosted/win16/win16Backend.c index 7ef0e92..a8d28ee 100644 --- a/src/hosted/win16/win16Backend.c +++ b/src/hosted/win16/win16Backend.c @@ -61,10 +61,12 @@ // keyboard are let go. Closing the window ends the program. // // Time: MMSYSTEM's timeGetTime at its finest resolution (1 ms). Sound: -// 16-bit mono at the core's 22.05 kHz, or 8-bit unsigned where the card has -// no 16-bit (a Sound Blaster 2.0), in AUDIO_BUFFER_FRAMES buffers written as -// each fills, so a Sound Blaster driver sees a few dozen writes a second -// however the core tops its queue up. +// 16-bit mono at 22.222 kHz (a Sound Blaster's exact rate near the core's +// 22.05, AUDIO_RATE_SB) or 22.05 kHz where the card refuses that, or 8-bit +// unsigned where the card has no 16-bit (a Sound Blaster 2.0), in +// AUDIO_BUFFER_FRAMES buffers written as each fills, so a Sound Blaster +// driver sees a few dozen writes a second however the core tops its queue +// up. // // Files: the program's folder is the current directory (drive and folder) // from the moment the window opens; SAVES/ is made and files are deleted @@ -209,6 +211,12 @@ #define AUDIO_FRAME_BYTES_MAX (AUDIO_CHANNELS * sizeof(int16_t)) #define AUDIO_SAMPLE_SHIFT 8 #define AUDIO_SAMPLE_BIAS 0x80 +// The rates tried, best first: a Sound Blaster's DSP plays 1000000 / (256 - +// time constant) Hz, so the core's 22050 plays as 22222 (0.8% sharp) on +// every one before the SB16, and 22222 itself is exact on all of them; a +// card that refuses it gets the core's own rate. +#define AUDIO_RATE_SB 22222u +#define AUDIO_RATE_COUNT 2u // joyGetPos: the two sticks, and their buttons 1 and 2. #define JOY_BUTTON_0_BIT JOY_BUTTON1 @@ -252,7 +260,7 @@ typedef struct { // ----- Prototypes ----- static bool audioBufferFree(AudioBufferT *buffer); -static bool audioFormatOpen(uint16_t bits); +static bool audioFormatOpen(uint16_t bits, uint32_t rate); static void bordersFill(HDC dc, const RECT *dst); static void buttonsRelease(void); static bool charChord(void); @@ -607,6 +615,8 @@ static AudioBufferT gAudio[AUDIO_BUFFERS]; static uint16_t gAudioNext = 0; static uint16_t gAudioFill = 0; static uint16_t gAudioBits = AUDIO_BITS_NARROW; // the sample size the device took +static uint32_t gAudioRate = 0u; // the rate it took +static const uint32_t gAudioRates[AUDIO_RATE_COUNT] = { AUDIO_RATE_SB, HOSTED_AUDIO_RATE }; // Open Watcom's license (the Sybase Open Watcom Public License, section // 2.2(d)) asks a program that carries its code in object form -- every Win16 @@ -635,8 +645,8 @@ static bool audioBufferFree(AudioBufferT *buffer) { } -// Open the wave device for mono at the core's rate in bits-bit samples. -static bool audioFormatOpen(uint16_t bits) { +// Open the wave device for mono at rate in bits-bit samples. +static bool audioFormatOpen(uint16_t bits, uint32_t rate) { PCMWAVEFORMAT format; uint16_t frameBytes; @@ -644,8 +654,8 @@ static bool audioFormatOpen(uint16_t bits) { memset(&format, 0, sizeof(format)); format.wf.wFormatTag = WAVE_FORMAT_PCM; format.wf.nChannels = AUDIO_CHANNELS; - format.wf.nSamplesPerSec = HOSTED_AUDIO_RATE; - format.wf.nAvgBytesPerSec = HOSTED_AUDIO_RATE * frameBytes; + format.wf.nSamplesPerSec = rate; + format.wf.nAvgBytesPerSec = rate * frameBytes; format.wf.nBlockAlign = frameBytes; format.wBitsPerSample = bits; if (waveOutOpen(&gWaveOut, WAVE_MAPPER, (LPWAVEFORMAT)&format, 0, 0, 0) != 0) { @@ -1572,21 +1582,29 @@ void hostBackendAudioClose(void) { // The device, and its buffers: each locked in place for good, as the driver // reads them while the program runs on. -bool hostBackendAudioOpen(void) { +uint32_t hostBackendAudioOpen(void) { + uint32_t rate; + uint16_t r; uint16_t i; if (gWaveOut != NULL) { - return true; + return gAudioRate; } - if (!audioFormatOpen(AUDIO_BITS_WIDE) && !audioFormatOpen(AUDIO_BITS_NARROW)) { - return false; + rate = 0u; + for (r = 0; r < AUDIO_RATE_COUNT && rate == 0u; r++) { + if (audioFormatOpen(AUDIO_BITS_WIDE, gAudioRates[r]) || audioFormatOpen(AUDIO_BITS_NARROW, gAudioRates[r])) { + rate = gAudioRates[r]; + } + } + if (rate == 0u) { + return 0u; } memset(gAudio, 0, sizeof(gAudio)); for (i = 0; i < AUDIO_BUFFERS; i++) { gAudio[i].memory = GlobalAlloc(GMEM_MOVEABLE | GMEM_SHARE | GMEM_ZEROINIT, sizeof(AudioBlockT)); if (gAudio[i].memory == NULL) { hostBackendAudioClose(); - return false; + return 0u; } gAudio[i].block16 = (DWORD)GlobalLock(gAudio[i].memory); gAudio[i].block = (AudioBlockT __far *)MK_FP32((void *)gAudio[i].block16); @@ -1595,7 +1613,8 @@ bool hostBackendAudioOpen(void) { } gAudioNext = 0; gAudioFill = 0; - return true; + gAudioRate = rate; + return rate; } @@ -1909,6 +1928,19 @@ bool hostBackendPaletteBases(uint16_t used) { } +// The executable's folder, with its trailing separator, found once. +const char *hostBackendProgramFolder(void) { + static char folder[PATH_BYTES]; + char *name; + + if (folder[0] == '\0') { + programFolder(folder, &name); + *name = '\0'; + } + return folder; +} + + void hostBackendPump(void) { messagesDispatch(); } @@ -1938,11 +1970,21 @@ void hostBackendSleepUntil(uint64_t micros) { // Free bytes on the current (the program's) drive, saturated at what a // uint32_t holds. +// On the drive of the program's folder (0, the current one, if it has no +// drive letter). Windows 3.x knows no FAT32, so DOS's classic call is all +// there is. uint32_t jlpDiskFree(void) { struct diskfree_t space; + const char *folder; + unsigned drive; uint64_t bytes; - if (_dos_getdiskfree(0, &space) != 0) { + folder = hostBackendProgramFolder(); + drive = 0u; + if (folder[0] != '\0' && folder[1] == ':') { + drive = (unsigned)(toupper((unsigned char)folder[0]) - 'A' + 1); + } + if (_dos_getdiskfree(drive, &space) != 0) { return 0u; } bytes = (uint64_t)space.avail_clusters * space.sectors_per_cluster * space.bytes_per_sector; @@ -1973,9 +2015,7 @@ bool jlpSaveDirEnsure(const char *dir) { // differ from GDI's own stretch of the frame (this backend's scaler checked // against Windows'). Every comparison is of the screen's raw pixels, read // back the same way, so no colour matching comes into it; the window is -// left fully repainted. A present shows at most once a virtual frame, so -// the probe first lets a frame pass and the pending show happen. -// *compared: every pixel compared. +// left fully repainted. *compared: every pixel compared. uint32_t hostWin16ProbeDisplayMismatch(const jlSurfaceT *src, uint32_t *compared) { RECT dst; HDC dc; @@ -1995,8 +2035,6 @@ uint32_t hostWin16ProbeDisplayMismatch(const jlSurfaceT *src, uint32_t *compared uint16_t x; uint16_t y; - hostBackendSleepUntil(hostBackendMicros() + US_PER_SECOND / HOSTED_FRAME_HZ + 1u); - hostedShowPending(); *compared = 0; if (!gPresented) { return 0; diff --git a/src/hosted/win16/win16Serial.c b/src/hosted/win16/win16Serial.c index 10b1504..6c201e9 100644 --- a/src/hosted/win16/win16Serial.c +++ b/src/hosted/win16/win16Serial.c @@ -20,10 +20,10 @@ // Windows runs with); a driver that refuses it -- Windows 3.1's own COMM.DRV // has no 115200 -- gets 57600 instead. // -// The open raises DTR (and RTS, unless RTS/CTS handshakes). One promise of -// joey/serial.h cannot be kept here: CloseComm gives the port back to the -// driver, and Windows has no call that keeps DTR up after it, so a program -// that must not hang up the line keeps the port open. +// The open raises DTR (and RTS, unless RTS/CTS handshakes), and asks the +// driver to leave DTR as it is at CloseComm, as joey/serial.h promises: +// KPCOMM.DRV takes the escape for it; Windows 3.1's own COMM.DRV does not +// know it, and drops DTR at the close. #include @@ -42,6 +42,7 @@ #define DRIVER_QUEUE_BYTES 4096u #define BAUD_RAW_MAX 57600u #define KPCBR_115200 0xFF24u // KPCOMM.DRV's CBR_-style index for 115200 +#define KPESC_KEEP_DTR 0x4B50 // KPCOMM.DRV's escape: CloseComm leaves DTR up #define XON_CHAR 0x11 #define XOFF_CHAR 0x13 #define XON_LIMIT (DRIVER_QUEUE_BYTES / 4u) @@ -185,6 +186,7 @@ bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) { jlpSerialClose(); return false; } + (void)EscapeCommFunction(gPort, KPESC_KEEP_DTR); return true; } diff --git a/src/hosted/win32/win32Backend.c b/src/hosted/win32/win32Backend.c index bec5427..6c984d1 100644 --- a/src/hosted/win32/win32Backend.c +++ b/src/hosted/win32/win32Backend.c @@ -552,11 +552,11 @@ void hostBackendAudioClose(void) { } -bool hostBackendAudioOpen(void) { +uint32_t hostBackendAudioOpen(void) { WAVEFORMATEX format; if (gWaveOut != NULL) { - return true; + return HOSTED_AUDIO_RATE; } memset(&format, 0, sizeof(format)); format.wFormatTag = WAVE_FORMAT_PCM; @@ -567,11 +567,11 @@ bool hostBackendAudioOpen(void) { format.nAvgBytesPerSec = HOSTED_AUDIO_RATE * AUDIO_FRAME_BYTES; if (waveOutOpen(&gWaveOut, WAVE_MAPPER, &format, 0, 0, CALLBACK_NULL) != MMSYSERR_NOERROR) { gWaveOut = NULL; - return false; + return 0u; } memset(gAudio, 0, sizeof(gAudio)); gAudioNext = 0; - return true; + return HOSTED_AUDIO_RATE; } @@ -687,8 +687,7 @@ const char *hostBackendOpen(void) { if (gTitle[0] == '\0') { strcpy(gTitle, WINDOW_TITLE_DEFAULT); } - *name = '\0'; - if (!SetCurrentDirectoryA(folder)) { + if (!SetCurrentDirectoryA(hostBackendProgramFolder())) { return "cannot make the program's folder the current directory"; } instance = GetModuleHandleA(NULL); @@ -746,6 +745,19 @@ const char *hostBackendOpen(void) { } +// The executable's folder, with its trailing separator, found once. +const char *hostBackendProgramFolder(void) { + static char folder[MAX_PATH]; + char *name; + + if (folder[0] == '\0') { + programFolder(folder, &name); + *name = '\0'; + } + return folder; +} + + void hostBackendPump(void) { MSG message; @@ -789,18 +801,16 @@ void hostBackendSleepUntil(uint64_t micros) { // ----- HAL API (alphabetical) ----- // Free bytes on the program's drive, saturated at what a uint32_t holds. +// The Ex call: the old one tops out at 2 GB on large FAT32 and NTFS drives. uint32_t jlpDiskFree(void) { - DWORD sectorsPerCluster; - DWORD bytesPerSector; - DWORD freeClusters; - DWORD totalClusters; - uint64_t bytes; + ULARGE_INTEGER available; + const char *folder; - if (!GetDiskFreeSpaceA(NULL, §orsPerCluster, &bytesPerSector, &freeClusters, &totalClusters)) { + folder = hostBackendProgramFolder(); + if (!GetDiskFreeSpaceExA(folder[0] != '\0' ? folder : NULL, &available, NULL, NULL)) { return 0u; } - bytes = (uint64_t)sectorsPerCluster * bytesPerSector * freeClusters; - return bytes > UINT32_MAX ? UINT32_MAX : (uint32_t)bytes; + return available.QuadPart > UINT32_MAX ? UINT32_MAX : (uint32_t)available.QuadPart; } @@ -826,9 +836,7 @@ bool jlpSaveDirEnsure(const char *dir) { // window after a full repaint; plus the repaint's that differ from GDI // stretching the core's frame itself (the DIB section checked against it). // Every comparison is of the screen's raw pixels, read back the same way; -// the window is left fully repainted. A present shows at most once a -// virtual frame, so the probe first lets a frame pass and the pending show -// happen. *compared: every pixel compared. +// the window is left fully repainted. *compared: every pixel compared. uint32_t hostWin32ProbeDisplayMismatch(const jlSurfaceT *src, uint32_t *compared) { RECT dst; HDC dc; @@ -842,8 +850,6 @@ uint32_t hostWin32ProbeDisplayMismatch(const jlSurfaceT *src, uint32_t *compared uint16_t rowBytes; LONG size; - hostBackendSleepUntil(hostBackendMicros() + US_PER_SECOND / HOSTED_FRAME_HZ + 1u); - hostedShowPending(); *compared = 0; if (gLastFrame == NULL) { return 0; diff --git a/src/iigs/audio_full.c b/src/iigs/audio_full.c index a4b8673..3a7ccf2 100644 --- a/src/iigs/audio_full.c +++ b/src/iigs/audio_full.c @@ -159,7 +159,10 @@ typedef void *Pointer; // reports rate=26320 with oscsenabled=32 on a live NTP-driven IIgs. // #define IIGS_DOC_DIVISOR 26320UL -#define IIGS_MAX_SFX_RATE 26000U // 1 sample byte per DOC tick, the ceiling +// About the DOC's output rate: a sound faster than it plays at its pitch all +// the same, the freq word stepping over sample bytes (docFreqWord), but noise +// faster than it is no brighter (NOISE_RATE_MAX). +#define IIGS_MAX_SFX_RATE 26000U // Accumulator units per SAMPLE BYTE. This is NOT 65536. // @@ -207,22 +210,32 @@ static uint16_t docFreqWord(uint16_t rateHz) { // sample byte is the DOC's wave-end marker and would halt the osc. #define TONE_PERIOD_BYTES 8u // samples per square period (4 hi + 4 lo) #define TONE_SQUARE_BYTES 4080u // 510 whole periods +// A second resident square after it, of longer periods, for the low notes: +// the DOC's freq word steps the playback rate 51.4 bytes a second (26320 / +// 512), which on an 8-sample period is 6.43 Hz -- 85 cents at 65 Hz -- and +// on a 32-sample one 1.6 Hz. A note plays on it while its sustain there +// (the square less NTP's tail, at freqHz * 32 bytes a second) lasts +// TONE_LONG_SUSTAIN_MS, which the hold margin needs: up to TONE_LONG_MAX_HZ. +#define TONE_LONG_PERIOD_BYTES 32u +#define TONE_LONG_BYTES 3840u // 120 whole periods +#define TONE_LONG_SUSTAIN_MS 300u +#define TONE_LONG_MAX_HZ ((uint16_t)(((TONE_LONG_BYTES - SFX_STREAM_TAIL) * 1000UL) / (TONE_LONG_PERIOD_BYTES * TONE_LONG_SUSTAIN_MS))) // 32-bit: int is 16 here #define TONE_LEVEL_HI 0xFFu #define TONE_LEVEL_LO 0x01u // Pitch ceiling: playback rate is toneHz * TONE_PERIOD_BYTES and must stay // at or below one sample byte per DOC tick, so toneHz <= 26320/8 = 3290. #define TONE_MAX_HZ 3290u -// Sustain: one trigger plays len = freqHz * 256 / 64 = freqHz * 4 bytes, -// which at the playback rate of freqHz * 8 is 500 ms of tone (capped by -// TONE_SQUARE_BYTES less NTP's discarded tail above ~890 Hz: -// 3568 / (freqHz * 8) s). The tracker +// Sustain: one trigger plays len = freqHz * period / 2 bytes, which at the +// playback rate of freqHz * period is 500 ms of tone (capped by the +// square's bytes less NTP's discarded tail: above ~890 Hz on the short +// square, 3568 / (freqHz * 8) s). The tracker // re-asserts a held voice every pump, and jlpAudioVoice only restarts // the sample when the running one is within TONE_HOLD_MARGIN_TICKS of // its end -- an identical call inside that window is a no-op and a // pitch change is a live retune. The margin covers the longest gap // between pumps (one rendered frame, p90 ~6 ticks); a restart inside // the margin still seams because len is whole periods. -#define TONE_SUSTAIN_MUL 256u +#define TONE_SUSTAIN_DIV 2u // len = freqHz * period / TONE_SUSTAIN_DIV: half a second #define TONE_HOLD_MARGIN_TICKS 8u #define AGI_ATTEN_OFF 15u // AGI attenuation: 0 = loudest .. 15 = silent @@ -366,6 +379,7 @@ static uint8_t gNoiseVol = 0; // square must be restarted, and the volume it was started at. static uint16_t gToneHoldUntil[JL_AUDIO_VOICES] = { 0 }; static uint8_t gToneVol[JL_AUDIO_VOICES] = { 0 }; +static uint8_t gTonePeriod[JL_AUDIO_VOICES] = { 0 }; // the square sounding: TONE_PERIOD_BYTES or TONE_LONG_PERIOD_BYTES // What each voice and the noise were last told, so jlpAudioFrameTick can // hold them: the DOC plays our samples once (NTPstreamsound has no loop), @@ -419,6 +433,9 @@ static SfxMemoryT gSfxMemory[JL_AUDIO_SFX_SLOTS]; // to 20 to keep the sample area even. #define SFX_STRUCT_BYTES 20 #define SFX_SAMPLE_OFFSET SFX_STRUCT_BYTES +// A tone voice's slot holds both resident squares (toneSquareEnsure). +typedef char iigsToneSquaresFit[(SFX_SAMPLE_OFFSET + TONE_SQUARE_BYTES + TONE_LONG_BYTES <= SFX_SLOT_BYTES) ? 1 : -1]; +typedef char iigsToneLongSplit[(TONE_LONG_MAX_HZ == 346u) ? 1 : -1]; // Self-modifying call stub, executed by ntpCall. Bakes the X/Y/A // register loads AND the JSL target into the buffer, which keeps the @@ -840,8 +857,7 @@ static uint32_t sfxMemoryFill(void *ctx, int8_t *dst, uint32_t count) { static void sfxFillStruct(uint8_t slot, uint32_t slotBase, uint32_t length, uint16_t rateHz) { unsigned char *sfx = (unsigned char *)slotBase; uint32_t sampleAddr = slotBase + SFX_SAMPLE_OFFSET; - uint16_t rate = (rateHz > IIGS_MAX_SFX_RATE) ? IIGS_MAX_SFX_RATE : rateHz; - uint16_t freqWord = docFreqWord(rate); + uint16_t freqWord = docFreqWord(rateHz); sfx[0] = (unsigned char)(sampleAddr & 0xFFu); sfx[1] = (unsigned char)((sampleAddr >> 8) & 0xFFu); @@ -919,10 +935,16 @@ static void toneSquareEnsure(uint8_t slot, uint32_t slotBase) { dst[i + 6] = TONE_LEVEL_LO; dst[i + 7] = TONE_LEVEL_LO; } + // The long square after it: half of each period high, half low. + dst += TONE_SQUARE_BYTES; + for (i = 0; i < TONE_LONG_BYTES; i++) { + dst[i] = (i % TONE_LONG_PERIOD_BYTES) < TONE_LONG_PERIOD_BYTES / 2u ? TONE_LEVEL_HI : TONE_LEVEL_LO; + } - // Constant stream-struct bytes: sample pointer, DOC page, osc - // pair, playing-osc count, channel. Length (4..7), freq word - // (8..9), and volume (13) are per-note, written by jlpAudioVoice. + // Constant stream-struct bytes: sample pointer (the short square's, + // until a note on the long one), DOC page, osc pair, playing-osc + // count, channel. The square, length (4..7), freq word (8..9), and + // volume (13) are per-note, written by jlpAudioVoice. sfx = (unsigned char *)slotBase; sfx[0] = (unsigned char)(sampleAddr & 0xFFu); sfx[1] = (unsigned char)((sampleAddr >> 8) & 0xFFu); @@ -938,6 +960,7 @@ static void toneSquareEnsure(uint8_t slot, uint32_t slotBase) { gSfxSlotLength[slot] = 0; gSfxSlotRateHz[slot] = 0; gToneHoldUntil[slot] = iigsGetTickWord(); // nothing of ours is sounding + gTonePeriod[slot] = TONE_PERIOD_BYTES; } @@ -1258,10 +1281,13 @@ static uint32_t gTickCarry = 0u; void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { unsigned char *sfx; uint32_t slotBase; + uint32_t squareAddr; uint32_t lenWide; uint16_t freqWord; uint16_t len; + uint16_t squareBytes; uint16_t holdTicks; + uint8_t period; uint8_t vol; uint8_t firstOsc; @@ -1289,37 +1315,49 @@ void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { toneSquareEnsure(voice, slotBase); firstOsc = (uint8_t)(SFX_BASE_OSC + voice * SFX_OSCS_PER_SLOT); - // Pitch: play the 8-sample square period at toneHz, so the DOC - // playback rate is freqHz * TONE_PERIOD_BYTES samples/sec. The word - // comes from docFreqWord, which uses the 512-units-per-byte scale the - // oscillator is actually programmed with -- this used to inline the - // 65536 scale and ran every tone 128x sharp, far above hearing. - freqWord = docFreqWord((uint16_t)(freqHz * TONE_PERIOD_BYTES)); - - // Sustain (see TONE_SUSTAIN_MUL), rounded down to whole periods so - // restarts seam. - lenWide = ((uint32_t)freqHz * TONE_SUSTAIN_MUL) >> 6; - len = (uint16_t)(lenWide & ~(uint32_t)(TONE_PERIOD_BYTES - 1u)); - if (len < TONE_PERIOD_BYTES) { - len = TONE_PERIOD_BYTES; + // The square: the long-period one for a low note, so its pitch is + // four times finer (TONE_LONG_MAX_HZ), else the short one. + squareAddr = slotBase + SFX_SAMPLE_OFFSET; + period = TONE_PERIOD_BYTES; + squareBytes = TONE_SQUARE_BYTES; + if (freqHz <= TONE_LONG_MAX_HZ) { + squareAddr += TONE_SQUARE_BYTES; + period = TONE_LONG_PERIOD_BYTES; + squareBytes = TONE_LONG_BYTES; } - if (len > TONE_SQUARE_BYTES - SFX_STREAM_TAIL) { - len = TONE_SQUARE_BYTES - SFX_STREAM_TAIL; + + // Pitch: play the square's period at toneHz, so the DOC playback rate + // is freqHz * period samples/sec. The word comes from docFreqWord, + // which uses the 512-units-per-byte scale the oscillator is actually + // programmed with -- this used to inline the 65536 scale and ran every + // tone 128x sharp, far above hearing. + freqWord = docFreqWord((uint16_t)(freqHz * period)); + + // Sustain (see TONE_SUSTAIN_DIV), rounded down to whole periods so + // restarts seam. + lenWide = ((uint32_t)freqHz * period) / TONE_SUSTAIN_DIV; + len = (uint16_t)(lenWide & ~(uint32_t)(period - 1u)); + if (len < period) { + len = period; + } + if (len > squareBytes - SFX_STREAM_TAIL) { + len = (uint16_t)(squareBytes - SFX_STREAM_TAIL); } vol = kDocVolume[atten]; - // Still sounding at this volume? Retune in place (the same pitch - // is then a no-op) instead of restarting the square every pump. - // Wrapping compare: GetTick is a 16-bit counter. - if (vol == gToneVol[voice] && (int16_t)(iigsGetTickWord() - gToneHoldUntil[voice]) < 0) { + // Still sounding at this volume, on this square? Retune in place (the + // same pitch is then a no-op) instead of restarting the square every + // pump. Wrapping compare: GetTick is a 16-bit counter. + if (vol == gToneVol[voice] && period == gTonePeriod[voice] && (int16_t)(iigsGetTickWord() - gToneHoldUntil[voice]) < 0) { docRetuneOscPair(firstOsc, freqWord); return; } - // Ticks this trigger sounds for: len bytes at freqHz * 8 bytes/s. NTP is - // told len and the tail it discards (SFX_STREAM_TAIL), all of it square. - holdTicks = (uint16_t)(((uint32_t)len * gBeatHz) / ((uint32_t)freqHz * TONE_PERIOD_BYTES)); + // Ticks this trigger sounds for: len bytes at freqHz * period bytes/s. + // NTP is told len and the tail it discards (SFX_STREAM_TAIL), all of it + // square. + holdTicks = (uint16_t)(((uint32_t)len * gBeatHz) / ((uint32_t)freqHz * period)); if (holdTicks > TONE_HOLD_MARGIN_TICKS) { holdTicks = (uint16_t)(holdTicks - TONE_HOLD_MARGIN_TICKS); } else { @@ -1328,6 +1366,9 @@ void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { len = (uint16_t)(len + SFX_STREAM_TAIL); sfx = (unsigned char *)slotBase; + sfx[0] = (unsigned char)(squareAddr & 0xFFu); + sfx[1] = (unsigned char)((squareAddr >> 8) & 0xFFu); + sfx[2] = (unsigned char)((squareAddr >> 16) & 0xFFu); sfx[4] = (unsigned char)(len & 0xFFu); sfx[5] = (unsigned char)((len >> 8) & 0xFFu); sfx[6] = 0; @@ -1337,6 +1378,7 @@ void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { sfxApplyPan(sfx, voice, vol); sfxTrigger(slotBase); gToneVol[voice] = vol; + gTonePeriod[voice] = period; gToneHoldUntil[voice] = (uint16_t)(iigsGetTickWord() + holdTicks); } @@ -1449,7 +1491,7 @@ static void streamStart(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16 } gStream[slot].fill = fill; gStream[slot].ctx = ctx; - gStream[slot].rateHz = (rateHz > IIGS_MAX_SFX_RATE) ? IIGS_MAX_SFX_RATE : rateHz; + gStream[slot].rateHz = rateHz; gStream[slot].raw = raw; gStream[slot].paddedGot = 0u; gStream[slot].active = true; diff --git a/src/iigs/hal.c b/src/iigs/hal.c index 39b14cf..46c0810 100644 --- a/src/iigs/hal.c +++ b/src/iigs/hal.c @@ -32,7 +32,6 @@ #include #include -#include "joey/debug.h" #include "port.h" #include "surfaceInternal.h" @@ -137,6 +136,8 @@ extern void iigsFillCircleInner(uint8_t *pixels, uint16_t cx, uint16_t cy, uint1 #define IIGS_BORDER_REG ((volatile uint8_t *)0x00C034L) #define IIGS_SHADOW_REG ((volatile uint8_t *)0x00C035L) #define IIGS_VBL_STATUS ((volatile uint8_t *)0x00C019L) +#define IIGS_VERTCNT ((volatile uint8_t *)0x00C02EL) +#define IIGS_HORIZCNT ((volatile uint8_t *)0x00C02FL) #define IIGS_SHR_PIXELS ((uint8_t *)0xE12000L) #define IIGS_SHR_SCB ((uint8_t *)0xE19D00L) #define IIGS_SHR_PALETTE ((uint16_t *)0xE19E00L) @@ -184,13 +185,22 @@ extern void iigsFillCircleInner(uint8_t *pixels, uint16_t cx, uint16_t cy, uint1 #define IRQ_CLEAR_BLOCKS_TIGHT 12u // Video beam ticks a frame (VERTCNT/HORIZCNT: 65 positions a line), for -// the tight joystick read's beam stamps (iigsJoyPass). +// the tight joystick read's beam stamps (iigsJoyPass) and the millisecond +// clock's fraction of a frame. The line is VERTCNT * 2 plus HORIZCNT's top +// bit. #define BEAM_TICKS_PER_LINE 65u #define BEAM_LINES_NTSC 262u #define BEAM_LINES_PAL 312u +#define BEAM_HORIZ_V0_SHIFT 7u +#define MS_PER_SECOND 1000u // ----- Module state ----- +// The beam's lines a frame, and the line the heartbeat's tick steps on, +// measured in jlpInit: the millisecond clock's fraction of a frame. +static uint16_t gBeamLines = BEAM_LINES_NTSC; +static uint16_t gVblLine = 0u; + static uint8_t gPreviousNewVideo = 0; static uint8_t gPreviousBorder = 0; static uint8_t gPreviousShadow = 0; @@ -343,8 +353,9 @@ void jlpBigFree(void *p) { } -static void claimStagePixels(void); -static void irqLatencyApply(bool tight); +static uint16_t beamLine(void); +static void claimStagePixels(void); +static void irqLatencyApply(bool tight); bool jlpInit(const jlConfigT *config) { uint16_t tick; @@ -355,7 +366,7 @@ bool jlpInit(const jlConfigT *config) { // VBL, so prove it advances before touching anything else (a task // that never runs would spin jlpWaitVBL forever). if (iigsVblInstall() != 0u) { - jlLog("iigs: SetHeartBeat failed"); + coreSetError("the Miscellaneous Tool Set refused the VBL heartbeat task"); return false; } tick = iigsGetTickWord(); @@ -363,10 +374,12 @@ bool jlpInit(const jlConfigT *config) { spin++; if (spin == 0u) { iigsVblRemove(); - jlLog("iigs: VBL heartbeat task never ran"); + coreSetError("the VBL heartbeat task never ran"); return false; } } + // The tick has just stepped: where the beam is now is where it steps. + gVblLine = beamLine(); claimStagePixels(); gPreviousNewVideo = *IIGS_NEWVIDEO_REG; gPreviousBorder = *IIGS_BORDER_REG; @@ -385,7 +398,8 @@ bool jlpInit(const jlConfigT *config) { iigsInitRowLut(); irqLatencyApply(gIrqTightHolds != 0u); gFrameHz = (iigsReadHzParam() == 1u) ? 50u : 60u; - gIigsBeamFrameTicks = (uint16_t)(((gFrameHz == 50u) ? BEAM_LINES_PAL : BEAM_LINES_NTSC) * BEAM_TICKS_PER_LINE); + gBeamLines = (gFrameHz == 50u) ? BEAM_LINES_PAL : BEAM_LINES_NTSC; + gIigsBeamFrameTicks = (uint16_t)(gBeamLines * BEAM_TICKS_PER_LINE); gModeSet = true; return true; } @@ -520,6 +534,20 @@ extern char __heap_end[]; static void *gBank0ScratchHandle = NULL; +// The beam's line, VERTCNT and HORIZCNT read until VERTCNT holds still +// across the pair. +static uint16_t beamLine(void) { + uint8_t vert; + uint8_t horiz; + + do { + vert = *IIGS_VERTCNT; + horiz = *IIGS_HORIZCNT; + } while (vert != *IIGS_VERTCNT); + return (uint16_t)(((uint16_t)vert << 1) | (horiz >> BEAM_HORIZ_V0_SHIFT)); +} + + static void claimStagePixels(void) { if (gStagePixelsHandle == NULL) { gStagePixelsHandle = NewHandle((unsigned long)JL_SURFACE_PIXELS_SIZE, _ownerid, attrAddr | attrFixed | attrLocked, (void *)IIGS_STAGE_PIXELS); @@ -651,3 +679,29 @@ void jlpWaitVBL(void) { uint16_t jlpFrameHz(void) { return gFrameHz; } + + +// Milliseconds: the frame clock's (the heartbeat's ticks, jlpGenericMillis- +// Elapsed), plus how far through the frame the beam is, from the line the +// tick steps on (gVblLine), so the clock steps every line, not every frame. +// Read again if the tick steps between the reads. Never earlier than the +// last reading: a tick held back while interrupts are masked would make +// the beam's fraction start again before the tick catches up. +uint32_t jlpMillisElapsed(void) { + static uint32_t gLastMs = 0u; + uint16_t tick; + uint16_t since; + uint32_t ms; + + do { + tick = iigsGetTickWord(); + ms = jlpGenericMillisElapsed(); + since = (uint16_t)((beamLine() + gBeamLines - gVblLine) % gBeamLines); + } while (tick != iigsGetTickWord()); + ms += (uint32_t)since * MS_PER_SECOND / ((uint32_t)gBeamLines * gFrameHz); + if (ms < gLastMs) { + ms = gLastMs; + } + gLastMs = ms; + return ms; +} diff --git a/src/iigs/save.c b/src/iigs/save.c index 22e09b8..2220ecf 100644 --- a/src/iigs/save.c +++ b/src/iigs/save.c @@ -12,7 +12,7 @@ // - delete: remove() (maps to GS/OS Destroy). // - directory create: GS/OS Create ($2001) with storageType 13 (directory). // - disk free: the volume that holds the saves (prefix 1, the application's -// folder, which JL_SAVE_DIR names) does not have a name the Volume call +// folder, the file root) does not have a name the Volume call // accepts directly -- Volume ($2008) wants a DEVICE name. So the chain is // GetPrefix(1) -> ":VOL:..." -> GetDevNumber(":VOL") -> devNum -> // DInfo(devNum) -> device name ".XXX" -> Volume(".XXX") -> free blocks. @@ -26,6 +26,7 @@ #include #include "joey/file.h" +#include "fileRoot.h" #include "port.h" @@ -33,14 +34,14 @@ typedef struct { unsigned short length; char text[64]; -} GsInStr; +} GsInStrT; // GS/OS ResultBuf (maxLen + length + text), sized for a prefix / name string. typedef struct { unsigned short maxLen; unsigned short length; char text[80]; -} GsResult; +} GsResultT; #define GSOS_ERR_DUP 0x47u // duplicate pathname (already exists) #define GSOS_DIR_TYPE 0x0Fu // directory file type @@ -49,11 +50,11 @@ typedef struct { #define IIGS_MAX_U32 0xFFFFFFFFu -static void gsStrSet(GsInStr *dst, const char *src, unsigned short cap); +static void gsStrSet(GsInStrT *dst, const char *src, unsigned short cap); // Copy a C string into a GS/OS length-prefixed string (truncating at cap). -static void gsStrSet(GsInStr *dst, const char *src, unsigned short cap) { +static void gsStrSet(GsInStrT *dst, const char *src, unsigned short cap) { unsigned short i; i = 0u; @@ -67,11 +68,11 @@ static void gsStrSet(GsInStr *dst, const char *src, unsigned short cap) { uint32_t jlpDiskFree(void) { // Static (not stack): keeps this leaf HAL off the shallow IIgs soft stack. - static GsResult prefix; - static GsResult devNameBuf; - static GsResult volNameOut; - static GsInStr volArg; - static GsInStr devArg; + static GsResultT prefix; + static GsResultT devNameBuf; + static GsResultT volNameOut; + static GsInStrT volArg; + static GsInStrT devArg; PrefixRecGS pr; DevNumRecGS dn; DInfoRecGS di; @@ -155,13 +156,19 @@ uint32_t jlpDiskFree(void) { } +// DATA/, SAVES/ and joeylog.txt all live in the application's folder. +const char *jlpReadRoot(void) { + return IIGS_FILE_ROOT; +} + + bool jlpSaveDelete(const char *path) { return remove(path) == 0; } bool jlpSaveDirEnsure(const char *dir) { - GsInStr name; + GsInStrT name; CreateRecGS cr; unsigned short err; @@ -179,3 +186,8 @@ bool jlpSaveDirEnsure(const char *dir) { err = gsosCreate(&cr); return err == 0u || err == GSOS_ERR_DUP; // created, or already present } + + +const char *jlpWriteRoot(void) { + return IIGS_FILE_ROOT; +} diff --git a/src/iigs/serial.c b/src/iigs/serial.c index 6012f43..11df764 100644 --- a/src/iigs/serial.c +++ b/src/iigs/serial.c @@ -111,9 +111,24 @@ #define SCC_WR5_TX_EN 0x08u #define SCC_WR5_RTS 0x02u -// SCC baud time constant: BRG output = PCLK(3.6864MHz) / (2*(TC+2)) with the -// /16 clock mode, so baud = 115200 / (TC+2) -> TC = 115200/baud - 2. -#define SCC_TC_NUM 115200L +// The ports' clock: the 3.6864 MHz crystal on the SCC's RTxC pins, run by +// channel A's crystal oscillator (SCC_WR11_XTAL), as cc65's IIgs driver +// (libsrc/apple2/ser/a2.gs.s) runs it; PCLK comes from the Mega II and is no +// baud base. 115200 is the crystal at the x32 clock mode, +// straight; every other rate the baud rate generator off the crystal at x16: +// baud = 3686400 / (2 * 16 * (TC + 2)) = 115200 / (TC + 2), 57600 at TC 0. +#define SCC_CRYSTAL_BAUD 115200L +#define SCC_WR4_X16 0x40u +#define SCC_WR4_X32 0x80u +#define SCC_WR11_BRG 0x50u // Rx and Tx clocks the BRG's; TRxC an input +#define SCC_WR11_RTXC 0x00u // Rx and Tx clocks straight from RTxC +#define SCC_WR11_XTAL 0x80u // RTxC and SYNC run the crystal (channel A) +#define SCC_WR14_BRG_RTXC 0x01u // the BRG on, its source RTxC +#define SCC_WR14_OFF 0x00u + +// A write's backstop on a busy transmitter, in sends (serialTxStalled): the +// clock bounds it first, once it runs. +#define IIGS_TX_POLL_LIMIT 65536ul // ----- 6551 (Super Serial Card) ----- @@ -227,10 +242,10 @@ extern uint16_t iigsSerialService(void); static uint8_t aciaBaudCode(uint32_t baud); static void sccWrite(volatile uint8_t *ctrl, uint8_t reg, uint8_t val); static bool serialInitAcia(volatile uint8_t *base, const jlSerialConfigT *cfg); -static void serialInitScc(volatile uint8_t *ctrl, uint8_t reset, const jlSerialConfigT *cfg); +static void serialInitScc(volatile uint8_t *ctrl, uint8_t reset, uint8_t xtal, const jlSerialConfigT *cfg); static void serialIrqInstall(uint16_t kind, volatile uint8_t *port, uint8_t rr3Bits, uint16_t vector); static void serialIrqRemove(void); -static void serialOpenScc(volatile uint8_t *ctrl, uint8_t reset, uint8_t rr3Bits, const jlSerialConfigT *cfg); +static void serialOpenScc(volatile uint8_t *ctrl, uint8_t reset, uint8_t xtal, uint8_t rr3Bits, const jlSerialConfigT *cfg); static void serialRxRelease(void); static void serialSetLine(uint8_t sccBit, uint8_t aciaMask, uint8_t aciaOn, bool on); @@ -251,15 +266,20 @@ static void sccWrite(volatile uint8_t *ctrl, uint8_t reg, uint8_t val) { } -// Program a channel and start it, with its interrupts still off. -static void serialInitScc(volatile uint8_t *ctrl, uint8_t reset, const jlSerialConfigT *cfg) { +// Program a channel and start it, with its interrupts still off. xtal is +// SCC_WR11_XTAL for channel A, whose oscillator runs the crystal, else 0. +static void serialInitScc(volatile uint8_t *ctrl, uint8_t reset, uint8_t xtal, const jlSerialConfigT *cfg) { uint8_t wr4; uint8_t wr3; uint8_t wr5; int32_t tc; + bool crystal; - // WR4: /16 clock, stop bits, parity. - wr4 = 0x40u; // /16 clock rate + // 115200 (or faster) runs straight from the crystal, the rest from the BRG. + crystal = cfg->baud >= (uint32_t)SCC_CRYSTAL_BAUD; + + // WR4: the clock mode, stop bits, parity. + wr4 = crystal ? SCC_WR4_X32 : SCC_WR4_X16; wr4 |= (cfg->stopBits == 2u) ? 0x0Cu : 0x04u; // 2 or 1 stop bit if (cfg->parity == JL_SERIAL_PARITY_ODD) { wr4 |= 0x01u; // parity enable, odd @@ -287,12 +307,9 @@ static void serialInitScc(volatile uint8_t *ctrl, uint8_t reset, const jlSerialC break; } - // baud = PCLK(3.6864MHz) / (2 * (TC+2) * 16) with the WR4 /16 clock mode -- the real-hardware - // formula, which MAME's z80scc implements faithfully. Stock MAME 0.264 apple2gs feeds the SCC - // the WRONG clock (14.318MHz/2 instead of the 3.6864MHz PCLK) and pins RX at rxc/16 = 230400 via - // configure_channels; that is fixed in the DRIVER (patches/mame-0.264-apple2gs-scc-clock.patch), - // not here. - tc = SCC_TC_NUM / (int32_t)cfg->baud - 2; + // The BRG's time constant (SCC_CRYSTAL_BAUD): a rate between two it can + // make runs at the one above it. Unused straight from the crystal. + tc = crystal ? 0 : SCC_CRYSTAL_BAUD / (int32_t)cfg->baud - 2; if (tc < 0) { tc = 0; } @@ -312,18 +329,19 @@ static void serialInitScc(volatile uint8_t *ctrl, uint8_t reset, const jlSerialC sccWrite(ctrl, 4u, wr4); sccWrite(ctrl, 3u, wr3); // Rx bits, not yet enabled sccWrite(ctrl, 5u, (uint8_t)(wr5 | SCC_WR5_DTR | SCC_WR5_RTS)); // Tx bits + DTR + RTS, not yet enabled - sccWrite(ctrl, 11u, 0x50u); // Rx & Tx clock = baud rate generator + sccWrite(ctrl, 14u, SCC_WR14_OFF); // the BRG off while it is set + sccWrite(ctrl, 11u, (uint8_t)(xtal | (crystal ? SCC_WR11_RTXC : SCC_WR11_BRG))); sccWrite(ctrl, 12u, (uint8_t)(tc & 0xFFu)); // time constant low sccWrite(ctrl, 13u, (uint8_t)((tc >> 8) & 0xFFu)); // time constant high - sccWrite(ctrl, 14u, 0x03u); // BRG enable, source = PCLK + sccWrite(ctrl, 14u, crystal ? SCC_WR14_OFF : SCC_WR14_BRG_RTXC); sccWrite(ctrl, 3u, (uint8_t)(wr3 | 0x01u)); // Rx enable gWr5 = (uint8_t)(wr5 | SCC_WR5_DTR | SCC_WR5_TX_EN | SCC_WR5_RTS); sccWrite(ctrl, 5u, gWr5); // Tx enable + DTR + RTS } -static void serialOpenScc(volatile uint8_t *ctrl, uint8_t reset, uint8_t rr3Bits, const jlSerialConfigT *cfg) { - serialInitScc(ctrl, reset, cfg); +static void serialOpenScc(volatile uint8_t *ctrl, uint8_t reset, uint8_t xtal, uint8_t rr3Bits, const jlSerialConfigT *cfg) { + serialInitScc(ctrl, reset, xtal, cfg); gReg = ctrl; serialIrqInstall(IIGS_SER_SCC, ctrl, rr3Bits, IIGS_VECTOR_SERIAL); sccWrite(ctrl, 1u, SCC_WR1_RX_INT_ALL); // handler in: let the channel interrupt @@ -513,7 +531,7 @@ bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) { switch (device) { case JL_SERIAL_PRINTER: - serialOpenScc(SCC_A_CTRL, SCC_WR9_RESET_A, SCC_RR3_CHAN_A, config); + serialOpenScc(SCC_A_CTRL, SCC_WR9_RESET_A, SCC_WR11_XTAL, SCC_RR3_CHAN_A, config); break; case JL_SERIAL_SLOT: if (config->unit < 1u || config->unit > 7u) { @@ -531,7 +549,7 @@ bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) { case JL_SERIAL_MODEM: case JL_SERIAL_DEFAULT: default: - serialOpenScc(SCC_B_CTRL, SCC_WR9_RESET_B, SCC_RR3_CHAN_B, config); + serialOpenScc(SCC_B_CTRL, SCC_WR9_RESET_B, 0u, SCC_RR3_CHAN_B, config); break; } return true; @@ -579,16 +597,15 @@ void jlpSerialSetRts(bool on) { uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) { - uint16_t n; + SerialTxWaitT wait; + uint16_t n; for (n = 0u; n < len; n++) { - uint16_t guard = 0u; - // iigsSerialSend drains the receiver too: a 6551 status read clears its interrupt request, so // whoever reads it must take the byte. + serialTxWaitStart(&wait, IIGS_TX_POLL_LIMIT); while (iigsSerialSend(buf[n]) == 0u) { - guard++; - if (guard == 0u) { // wrapped 65536 spins -- link stalled or held + if (serialTxStalled(&wait)) { return n; } } diff --git a/src/m68k/sfxMix.s b/src/m68k/sfxMix.s new file mode 100644 index 0000000..0aeefc4 --- /dev/null +++ b/src/m68k/sfxMix.s @@ -0,0 +1,167 @@ +| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +| See LICENSE.md for license information. +| SPDX-License-Identifier: MIT +| +| The SFX overlay's run (src/core/audioSfxMix.c's SfxRunT, whose C sfxRun +| this replaces) for the 68000 ports that mix sound effects in software: the +| Atari ST and STe and the X68000 (JL_HAS_SFX_MIX_RUN). Each sample is the +| slot's, interpolated toward the next by the position's fraction with one +| muls.w, shifted down the run's shift (the port's level for effects, kept +| in a4 for the loop), and added into the output through a clamp table in +| place of the C's compares. The samples come out the same as the C's. +| +| The position is a 16.16 number with its halves swapped, as in modMix.s: the +| fraction in the high word, the index in the low, so one add.l steps both +| and an addx.w takes the fraction's carry into the index. The C keeps the +| index under its RUN_END_MAX (0x7F00), so it addresses the samples as a +| signed word and never carries into the fraction, and it sets the count so +| that no sample reads past the slot's end: the loop tests only the count. +| +| The output holds unsigned 8-bit samples, or signed ones (the STe's DMA chip +| plays signed, and a sound starting now mixes into the buffer it plays): the +| format, 0 or $80, takes each output byte to unsigned on the way in, and the +| signed format's table takes the sum back to signed on the way out. +| +| ABI: cdecl, d2-d7/a2-a6 callee-save. Every entry point carries BOTH symbol +| spellings at the same address: gcc-atari-mint prepends an underscore to C +| names and elf2x68k does not. + + .text + .even + .globl audioSfxMixRun + .globl _audioSfxMixRun + + .equ RUN_SRC, 0 + .equ RUN_DST, 4 + .equ RUN_POS, 8 + .equ RUN_STEP, 12 + .equ RUN_COUNT, 16 + .equ RUN_FORMAT, 20 + .equ RUN_LAYOUT, 24 + .equ RUN_SHIFT, 28 + .equ ARG_RUN, 40 | nine saved registers, then the return address + .equ LAYOUT_SIDE, 1 | RunLayoutE: 0 mono, 1 one side, 2 both sides + .equ PAIR_BYTES, 2 | a stereo frame + .equ SUM_MIN, -128 | an unsigned output byte plus the lowest sample + .equ SUM_BYTES, 512 | the sums the table covers, from SUM_MIN + .equ SAMPLE_MAX, 255 + .equ SIGN_FLIP, 0x80 + +| The sample into d1's word: s0 + (s1 - s0) * fraction >> 16, the fraction +| halved so muls.w sees it positive, the product doubled and its high word +| taken: the C's (delta * (frac >> 1)) >> 15. Then down the run's shift. + .macro SFXSAMPLE + move.b (%a0,%d5.w),%d0 | s0 + move.b 1(%a0,%d5.w),%d1 | s1 + ext.w %d0 + ext.w %d1 + sub.w %d0,%d1 | s1 - s0 + move.l %d5,%d2 + swap %d2 | the fraction + lsr.w #1,%d2 + muls.w %d2,%d1 + add.l %d1,%d1 + swap %d1 + add.w %d0,%d1 | the sample + move.w %a4,%d0 + asr.w %d0,%d1 | at the port's level + .endm + +| The sample added into the output byte at (a2), through the table at a1; +| out is the store's addressing mode, which may move a2 on. + .macro SFXADD out + moveq #0,%d2 + move.b (%a2),%d2 + eor.b %d3,%d2 | unsigned + add.w %d1,%d2 + move.b (%a1,%d2.w),\out + .endm + +| Step the position: the fraction, and its carry into the index. + .macro SFXSTEP + add.l %d4,%d5 + addx.w %d6,%d5 + .endm + +| void audioSfxMixRun(SfxRunT *run): count fits a word (the C's frames are a +| uint16_t), and the run starts at index 0 of its src. +audioSfxMixRun: +_audioSfxMixRun: + movem.l %d2-%d7/%a2-%a4,-(%sp) + movea.l ARG_RUN(%sp),%a3 + move.l RUN_COUNT(%a3),%d7 + beq.w .Lout + movea.l RUN_SRC(%a3),%a0 + movea.l RUN_DST(%a3),%a2 + move.l RUN_POS(%a3),%d5 + swap %d5 | fraction : index + move.l RUN_STEP(%a3),%d4 + swap %d4 | fraction : whole samples + move.l RUN_FORMAT(%a3),%d3 + movea.l RUN_SHIFT(%a3),%a4 + lea (.LclampUnsigned-SUM_MIN,%pc),%a1 + tst.b %d3 + beq.s .Lformat + lea (.LclampSigned-SUM_MIN,%pc),%a1 +.Lformat: + moveq #0,%d6 | addx's other operand + subq.w #1,%d7 | dbf runs count times + move.l RUN_LAYOUT(%a3),%d0 + beq.w .Lmono + subq.l #LAYOUT_SIDE,%d0 + beq.w .Lside +.Lboth: + SFXSAMPLE + SFXADD (%a2)+ + SFXADD (%a2)+ + SFXSTEP + dbf %d7,.Lboth + bra.w .Lstore +.Lside: + SFXSAMPLE + SFXADD (%a2) + addq.l #PAIR_BYTES,%a2 + SFXSTEP + dbf %d7,.Lside + bra.w .Lstore +.Lmono: + SFXSAMPLE + SFXADD (%a2)+ + SFXSTEP + dbf %d7,.Lmono +.Lstore: + swap %d5 + move.l %d5,RUN_POS(%a3) + move.l %a2,RUN_DST(%a3) + clr.l RUN_COUNT(%a3) +.Lout: + movem.l (%sp)+,%d2-%d7/%a2-%a4 + rts + +| Each sum's output byte, from SUM_MIN: the sum clamped to 0..SAMPLE_MAX, +| and for the signed format flipped back to signed. +.LclampUnsigned: + .set sum, SUM_MIN + .rept SUM_BYTES + .if sum < 0 + .byte 0 + .elseif sum > SAMPLE_MAX + .byte SAMPLE_MAX + .else + .byte sum + .endif + .set sum, sum + 1 + .endr +.LclampSigned: + .set sum, SUM_MIN + .rept SUM_BYTES + .if sum < 0 + .byte SIGN_FLIP + .elseif sum > SAMPLE_MAX + .byte SAMPLE_MAX ^ SIGN_FLIP + .else + .byte sum ^ SIGN_FLIP + .endif + .set sum, sum + 1 + .endr + .even diff --git a/src/x68000/audioPcm.c b/src/x68000/audioPcm.c index 19297fb..ed0eb96 100644 --- a/src/x68000/audioPcm.c +++ b/src/x68000/audioPcm.c @@ -23,8 +23,9 @@ // // VERIFIED AUDIBLY 2026-09-23 by capturing -wavwrite out of MAME while Space // Taxi's attract demo spoke; the captured burst cross-correlates with the -// utterances in the game's speech.bin at 1:1. See the port C notes below for -// what that turned up, and scripts/verify-x68000-audio.sh for the gate. +// utterances in the game's speech.bin at 1:1. See the ADPCM_MODE notes +// below for what that turned up, and scripts/verify-x68000-audio.sh for the +// gate. // // The decoded signal used to sit on a wandering DC offset of about 11% of // full scale: the chain start reset the encoder only after encoding the @@ -44,35 +45,19 @@ // path benched at ~17 cycles a sample, ~13% of a 10 MHz 68000 here, before // the module's mix (see WHY THE RATE IS LOW). #define MSM6258_RATE 7812 -// The MSM6258's sample rate and panning do not live in the chip at all -- -// they are four bits of PPI port C, which is why they survive a chain start -// and why IOCS is not the only way to set them. Bits 1,0 gate the channels -// (0 both, 1 right only, 2 left only, 3 silent) and bits 3,2 pick the -// divider on the 4 MHz ADPCM clock: 1024, 768 and 512 for 3906, 5208 and -// 7812 Hz (measured: divider 512 gives the 7812 Hz the mixer renders). The upper nibble drives the joystick outputs, so write it back -// unchanged. -// -// _iocs_adpcmlot takes a mode byte meant to carry the same two fields, but -// it does not pass them through: measured in MAME, mode 0x05 left port C at -// 0x01 and both 0x0C and 0x20 left it at 0x03, which gates the chip off -// entirely. So the chain start asks IOCS for a mode it accepts and then -// states the rate and pan directly, which is exact and needs no IOCS table. -// -// This matters: the shipped 0x05 ran every sample this port played at -// 3906 Hz -- half speed, an octave low against the 7812 Hz the mixer -// renders -- and out of the right speaker only. Measured, not read off a -// datasheet: a 48 kHz capture of the attract demo stepped every 12.29 -// samples instead of 6.14. -#define PPI_PORT_C ((volatile uint8_t *)0xE9A005) +// _iocs_adpcmlot's mode is a word, the rate's code times 256 plus the output +// (0 none, 1 left, 2 right, 3 both). IOCS sets the three things the rate and +// the output are made of: the ADPCM clock (the OPM's CT1 pin: 8 or 4 MHz), +// the divider (PPI port C bits 3,2: 1024, 768 or 512), and the channel gates +// (port C bits 1,0). Code 2, 7812 Hz, is 8 MHz / 1024. (A byte here, as +// this port once passed, is rate code 0, 3906 Hz: half speed, an octave low.) +#define ADPCM_IOCS_RATE_7812 2 +#define ADPCM_IOCS_OUT_BOTH 3 +#define ADPCM_MODE ((ADPCM_IOCS_RATE_7812 << 8) | ADPCM_IOCS_OUT_BOTH) // DMAC channel 3's memory address register, high and low halves: the chip's // current read position in the chain. #define DMAC3_MAR_HI ((volatile uint16_t *)0xE840CC) #define DMAC3_MAR_LO ((volatile uint16_t *)0xE840CE) -#define PPI_ADPCM_BITS 0x0Fu -#define ADPCM_PAN_BOTH 0u -#define ADPCM_RATE_7812 2u -#define ADPCM_PORT_C (uint8_t)((ADPCM_RATE_7812 << 2) | ADPCM_PAN_BOTH) -#define ADPCM_MODE 0x05 // Chain geometry, set by how often the game actually gets here rather than // by a round number. MEASURED on the X68000 across 250 s of Space Taxi: the @@ -86,13 +71,19 @@ // of the chip. That covers every gap but the loading hitches, which no // affordable chain would cover. A sound that starts from idle restarts the // chain and is heard after one block's fill; one that starts while the chip -// is running is mixed into the blocks already ahead of it, from 10 ms ahead -// of the chip (sfxStartNow). +// is running is mixed into the blocks already ahead of it, from 20 ms ahead +// of the chip (chainRemix; a module that starts or stops while the chip +// runs is mixed in, or taken out, the same way). #define PCM_BLOCK 512 #define ADPCM_BLOCK (PCM_BLOCK / 2) #define BLOCK_COUNT 8 #define PCM_SILENCE MOD_PLAYER_SILENCE_U8 // unsigned 8-bit -// How far ahead of the chip a new sound starts (sfxStartNow): 20 ms. The +// The 8-bit mix's unit: a module channel at full volume and a full-scale +// sound effect each swing a quarter of a byte, so the module's four channels +// fit (modPlayerSetU8Shift, audioSfxSetShift); the encoder makes up the +// level (adpcmLevel). +#define MIX_UNIT_SHIFT 2u +// How far ahead of the chip a new sound starts (chainRemix): 20 ms. The // re-encode that mixes it in starts from the encoder's state at the last // checkpoint before the sound (ENC_CHECK_SAMPLES apart), and encodes the // bytes between the two exactly as they were, so the chip may read them as @@ -103,6 +94,14 @@ #define ENC_CHECK_SAMPLES 64u #define ENC_CHECKS (PCM_BLOCK / ENC_CHECK_SAMPLES) +// What chainRemix mixes into the blocks ahead of the chip: a sound just +// started, a module just started, or a module stopped (taken back out). +typedef enum { + REMIX_SFX, + REMIX_MOD_ADD, + REMIX_MOD_REMOVE +} RemixE; + // The encoder's predictor and step index: the chip's decoder state it mirrors. // Both fit a 68000 word: the predictor stays within -2048..2047 and the step // within the table's 16..1552. @@ -115,13 +114,14 @@ typedef struct { static void adpcmEncodeBlock(uint8_t b, uint16_t from); static inline uint8_t adpcmEncodeSample(int16_t pcm, AdpcmStateT *state); +static inline int16_t adpcmLevel(uint8_t pcm); static uint32_t adpcmPlayOffset(void); static uint8_t adpcmPlayingBlock(void); -static void adpcmSetRateAndPan(void); static void blockFill(uint8_t b); +static void chainRemix(RemixE what, uint8_t slot); +static void modMixBlock(uint8_t b, uint16_t from, bool add); static bool pcmIdle(void); -static void renderBlock(uint8_t *dst); -static void sfxStartNow(uint8_t slot); +static void renderBlock(uint8_t b); static void startChain(void); // ----- Module state ----- @@ -141,6 +141,9 @@ static AudioSfxSlotT gSfxSlots[JL_AUDIO_SFX_SLOTS]; // each block, so a new sound can be mixed into the blocks ahead of the chip // and they re-encoded from just before it. static uint8_t gPcm[BLOCK_COUNT][PCM_BLOCK]; +// What the module put in each block (silence where none played): a stopped +// module is taken back out of the blocks ahead of the chip (chainRemix). +static uint8_t gModPcm[BLOCK_COUNT][PCM_BLOCK]; static AdpcmStateT gEncCheck[BLOCK_COUNT][ENC_CHECKS]; static uint8_t gAdpcm[BLOCK_COUNT][ADPCM_BLOCK]; static struct iocs_chain2 gChain[BLOCK_COUNT]; @@ -196,9 +199,8 @@ static void adpcmEncodeBlock(uint8_t b, uint16_t from) { if (i % ENC_CHECK_SAMPLES == 0u) { gEncCheck[b][i / ENC_CHECK_SAMPLES] = state; } - // The mix is unsigned 8-bit; the encoder wants signed 12-bit-ish. - lo = adpcmEncodeSample((int16_t)(((int16_t)pcm[i] - (int16_t)PCM_SILENCE) * 16), &state); - hi = adpcmEncodeSample((int16_t)(((int16_t)pcm[i + 1u] - (int16_t)PCM_SILENCE) * 16), &state); + lo = adpcmEncodeSample(adpcmLevel(pcm[i]), &state); + hi = adpcmEncodeSample(adpcmLevel(pcm[i + 1u]), &state); out[i / 2] = (uint8_t)(lo | (hi << 4)); } gEnc = state; @@ -257,6 +259,16 @@ static inline uint8_t adpcmEncodeSample(int16_t pcm, AdpcmStateT *state) { } +// A mix byte (unsigned 8-bit) as the encoder's level: its signed value times +// the mix's unit, so a full-scale effect or a module channel at full volume +// peaks at 124 of the X68000's 10-bit 511 (all four channels together fill +// it), as loud as an FM voice at attenuation 0 (joey/audio.h's mix; the +// voices cannot come up, held 9 dB down to the OPM noise's loudest). +static inline int16_t adpcmLevel(uint8_t pcm) { + return (int16_t)(((int16_t)pcm - (int16_t)PCM_SILENCE) * (1 << MIX_UNIT_SHIFT)); +} + + // Where in the chain the chip is reading, in bytes. DMAC channel 3 feeds it, // and that channel's memory address register is the read pointer, so the // answer is exact and costs two register reads. A pointer outside the chain @@ -281,15 +293,6 @@ static uint8_t adpcmPlayingBlock(void) { } -// State the ADPCM rate and panning in the hardware register that holds -// them, rather than hoping IOCS reads the same meaning out of a mode byte. -static void adpcmSetRateAndPan(void) { - uint8_t portC = *PPI_PORT_C; - - *PPI_PORT_C = (uint8_t)((portC & (uint8_t)~PPI_ADPCM_BITS) | ADPCM_PORT_C); -} - - // Render and encode chain block b. // A song that ends in it sounds until the chip has played it: the blocks // from the chip's to b, b included, are its tail. @@ -297,7 +300,7 @@ static void blockFill(uint8_t b) { bool wasPlaying; wasPlaying = modPlayerActive(); - renderBlock(gPcm[b]); + renderBlock(b); if (wasPlaying && !modPlayerActive()) { gModTailBlocks = (uint8_t)((b + BLOCK_COUNT - gLastPlaying) % BLOCK_COUNT + 1u); } @@ -321,24 +324,60 @@ static bool pcmIdle(void) { } -// Render one block: module (if playing) then the SFX overlay on top. -static void renderBlock(uint8_t *dst) { +// Render block b: the module (if playing, kept in gModPcm) then the SFX +// overlay on top. +static void renderBlock(uint8_t b) { if (modPlayerActive()) { - (void)modPlayerRenderU8(dst, PCM_BLOCK); + (void)modPlayerRenderU8(gModPcm[b], PCM_BLOCK, 1u); } else { - memset(dst, PCM_SILENCE, PCM_BLOCK); + memset(gModPcm[b], PCM_SILENCE, PCM_BLOCK); } - audioSfxOverlayMix(dst, PCM_BLOCK, gSfxSlots, JL_AUDIO_SFX_SLOTS); + memcpy(gPcm[b], gModPcm[b], PCM_BLOCK); + audioSfxOverlayMix(gPcm[b], PCM_BLOCK, gSfxSlots, JL_AUDIO_SFX_SLOTS); } -// Start a just-armed slot now. A refill mixes it, but the chain ahead of the -// chip is filled already -- up to seven blocks, ~460 ms -- so on its own a -// sound waited that long. So it is mixed in from SFX_START_SAMPLES ahead of -// the chip, to the end of what is filled, and those blocks are encoded again -// in play order from the encoder's state at the first of them; the refills -// carry on from where that leaves the slot and the encoder. -static void sfxStartNow(uint8_t slot) { +// The module into block b from sample from on (add: rendered now and kept +// in gModPcm), or back out of it (each sample less its module part, back on +// the silence line), over what is there. A sample the effects pushed to a +// limit comes out a little off, as clipping left it. A song that ends while +// it is added sounds until the chip has played that block. +static void modMixBlock(uint8_t b, uint16_t from, bool add) { + uint8_t *pcm; + uint8_t *mod; + uint16_t i; + int value; + + pcm = gPcm[b]; + mod = gModPcm[b]; + if (add) { + // A song that ended in an earlier block adds nothing more. + if (!modPlayerActive()) { + return; + } + if (!modPlayerRenderU8(mod + from, (uint16_t)(PCM_BLOCK - from), 1u)) { + gModTailBlocks = (uint8_t)((b + BLOCK_COUNT - gLastPlaying) % BLOCK_COUNT + 1u); + } + } + for (i = from; i < PCM_BLOCK; i++) { + value = add ? (int)pcm[i] + (int)mod[i] - PCM_SILENCE : (int)pcm[i] - (int)mod[i] + PCM_SILENCE; + pcm[i] = (uint8_t)(value < 0 ? 0 : (value > UINT8_MAX ? UINT8_MAX : value)); + if (!add) { + mod[i] = PCM_SILENCE; + } + } +} + + +// Mix something into the chain ahead of the chip now. A refill mixes it, +// but the chain ahead of the chip is filled already -- up to seven blocks, +// ~460 ms -- so on its own it waited that long. So it is mixed in from +// SFX_START_SAMPLES ahead of the chip, to the end of what is filled, and +// those blocks are encoded again in play order from the encoder's state at +// the first of them; the refills carry on from where that leaves the slot, +// the module and the encoder. what says what: slot's sound just armed, a +// module just started, or a module just stopped. +static void chainRemix(RemixE what, uint8_t slot) { uint32_t offset; uint32_t sample; uint16_t start; @@ -365,14 +404,16 @@ static void sfxStartNow(uint8_t slot) { if (ahead >= filled) { return; } - b = (uint8_t)((playing + ahead) % BLOCK_COUNT); - gEnc = gEncCheck[b][start / ENC_CHECK_SAMPLES]; - audioSfxOverlayMix(gPcm[b] + start, (uint16_t)(PCM_BLOCK - start), &gSfxSlots[slot], 1u); - adpcmEncodeBlock(b, (uint16_t)(start - start % ENC_CHECK_SAMPLES)); - for (ahead++; ahead < filled; ahead++) { + gEnc = gEncCheck[(playing + ahead) % BLOCK_COUNT][start / ENC_CHECK_SAMPLES]; + for (; ahead < filled; ahead++) { b = (uint8_t)((playing + ahead) % BLOCK_COUNT); - audioSfxOverlayMix(gPcm[b], PCM_BLOCK, &gSfxSlots[slot], 1u); - adpcmEncodeBlock(b, 0u); + if (what == REMIX_SFX) { + audioSfxOverlayMix(gPcm[b] + start, (uint16_t)(PCM_BLOCK - start), &gSfxSlots[slot], 1u); + } else { + modMixBlock(b, start, what == REMIX_MOD_ADD); + } + adpcmEncodeBlock(b, (uint16_t)(start - start % ENC_CHECK_SAMPLES)); + start = 0u; } } @@ -398,7 +439,6 @@ static void startChain(void) { // so the sound starts one block's fill after the call, not eight. blockFill(0u); _iocs_adpcmlot(&gChain[0], ADPCM_MODE); - adpcmSetRateAndPan(); // AFTER the IOCS call, which sets them wrong for (b = 1u; b < BLOCK_COUNT; b++) { blockFill(b); } @@ -412,6 +452,8 @@ static void startChain(void) { bool jlpAudioInitPcm(void) { memset(gSfxSlots, 0, sizeof(gSfxSlots)); + modPlayerSetU8Shift(MIX_UNIT_SHIFT); + audioSfxSetShift(MIX_UNIT_SHIFT); gPcmReady = true; gX68kPcmReady = 1u; return true; @@ -486,6 +528,8 @@ void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { } if (!gPlaying) { startChain(); + } else { + chainRemix(REMIX_MOD_ADD, 0u); } } @@ -498,7 +542,7 @@ void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint1 if (!gPlaying) { startChain(); } else { - sfxStartNow(slot); + chainRemix(REMIX_SFX, slot); } } @@ -511,7 +555,7 @@ void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uin if (!gPlaying) { startChain(); } else { - sfxStartNow(slot); + chainRemix(REMIX_SFX, slot); } } @@ -530,11 +574,17 @@ void jlpAudioShutdownPcm(void) { void jlpAudioStopMod(void) { + bool wasPlaying; + if (!gPcmReady) { return; } + wasPlaying = modPlayerActive() || gModTailBlocks != 0u; modPlayerStop(); gModTailBlocks = 0u; + if (gPlaying && wasPlaying) { + chainRemix(REMIX_MOD_REMOVE, 0u); + } } diff --git a/src/x68000/hal.c b/src/x68000/hal.c index 6fe54fa..237bbcf 100644 --- a/src/x68000/hal.c +++ b/src/x68000/hal.c @@ -155,6 +155,8 @@ typedef char x68kJoyPortsCoverSticks[(X68K_JOY_PORT_COUNT == JL_JOYSTICK_COUNT) // CRTC register 9: the raster the compare interrupt fires on. It does not // reach the CPU directly -- it drives MFP GPIP6 (channel 14, vector $4E, // exception address $138), which is the vector _CRTCRAS installs into. +// CRTC register 11: the text plane's Y scroll, which the page flip sets. +#define X68K_CRTC_TEXT_Y ((volatile uint16_t *)0xE80016L) #define X68K_MFP_IERA ((volatile uint8_t *)0xE88007L) // reg 3 #define X68K_MFP_IMRA ((volatile uint8_t *)0xE88013L) // reg 9 #define X68K_MFP_GPIP6 0x40u // channel 14 @@ -346,6 +348,29 @@ extern void x68kVdispIsr(void); extern uint32_t x68kTickChainAddr; // the JMP operand inside the ISR static bool gTickHooked = false; // Timer C is Human68k's 100 Hz and chained static bool gVdispHooked = false; // _VDISPST took x68kVdispIsr + +#if X68K_PRESENT_TEXT +// The page flip (x68kVdispIsr.s, or vdispPoll without it): a present hands +// over the text Y scroll of the page it drew, and the palette a one-band +// screen's colours went into (NULL: none), and the next display start shows +// both together. gX68kFlipPending is set last and cleared there. +volatile uint8_t gX68kFlipPending = 0u; +volatile uint16_t gX68kFlipScroll = 0u; +const uint16_t *volatile gX68kFlipPalette = NULL; +static uint8_t gFrontPage = 0u; +// A one-band screen's colours, for the next flip; gPaletteQueued until then. +static uint16_t gFlipPaletteBuf[JL_SURFACE_COLORS_PER_PALETTE]; +static bool gPaletteQueued = false; +// The page drawn into holds the frame before last, so a present copies what +// changed in either frame: the previous frame's dirty bands, and the union +// of both, which the copy walks (aligned for x68kPresentText's long reads). +// Seeded all dirty, so the first two presents fill both pages. +static uint8_t gPrevMinWord[JL_SURFACE_HEIGHT] __attribute__((aligned(2))); +static uint8_t gPrevMaxWord[JL_SURFACE_HEIGHT] __attribute__((aligned(2))); +static uint8_t gUnionMinWord[JL_SURFACE_HEIGHT] __attribute__((aligned(2))); +static uint8_t gUnionMaxWord[JL_SURFACE_HEIGHT] __attribute__((aligned(2))); +static bool gPrevAnyDirty = true; +#endif static uint32_t gMillisBase = 0ul; // tickMillisNow() as jlpInit finished static uint16_t gFrameHz = X68K_FRAME_HZ_NOMINAL; @@ -456,7 +481,11 @@ static void vdispPoll(void); static uint16_t buildScbBands(X68kBandTableT *table, const jlSurfaceT *src); static void buildScbPalettes(X68kBandTableT *table, const jlSurfaceT *src); static void clearTextPlane(void); +static bool dirtyUnion(void); static void disarmRaster(void); +static void flipApply(void); +static void flipRequest(uint8_t page); +static void flipWait(void); static void installScb(const jlSurfaceT *src, uint16_t paletteMask, bool scbChanged); #endif #if !X68K_PRESENT_TEXT @@ -538,6 +567,14 @@ bool jlpInit(const jlConfigT *config) { _iocs_crtmod(X68K_CRTMOD_512_31K); _iocs_g_clr_on(); clearTextPlane(); + // Page 0 on screen, and both pages filled by the first two presents. + *X68K_CRTC_TEXT_Y = 0u; + gFrontPage = 0u; + gX68kFlipPending = 0u; + gPaletteQueued = false; + memset(gPrevMinWord, 0, sizeof(gPrevMinWord)); + memset(gPrevMaxWord, STAGE_DIRTY_FULL_MAX, sizeof(gPrevMaxWord)); + gPrevAnyDirty = true; #else _iocs_crtmod(X68K_CRTMOD_512_31K); _iocs_g_clr_on(); // clear graphics + enable the plane @@ -596,6 +633,7 @@ void jlpShutdown(void) { } #if X68K_PRESENT_TEXT disarmRaster(); + *X68K_CRTC_TEXT_Y = 0u; // the console's page, whichever was shown last #endif if (gPrevCrtMode >= 0) { _iocs_crtmod(gPrevCrtMode); @@ -842,6 +880,11 @@ static void vdispPoll(void) { gLastVdisp = now; if (now != 0u) { gX68kFrames++; +#if X68K_PRESENT_TEXT + if (gX68kFlipPending != 0u) { + flipApply(); + } +#endif } } } @@ -950,9 +993,9 @@ static void disarmRaster(void) { // Hand a new screen's bands to the interrupt. They are built into the table // the interrupt is not walking and queued for the next frame wrap, so the frame -// being scanned finishes with the bands it started with: a fade or an SCB -// change can lag the pixels by up to a frame but never tears one. A -// single-band screen needs no interrupt: its palette is written here, once. +// being scanned finishes with the bands it started with, and the next shows +// them with the page the same present flips to. A single-band screen needs no +// interrupt: its palette goes to that flip (gFlipPaletteBuf). // paletteMask has bit p set for each stage palette changed since the last // call, scbChanged whether the SCB did; both tables note what they now lack, // and the one being built makes up only that. A palette fade converts one @@ -983,8 +1026,9 @@ static void installScb(const jlSurfaceT *src, uint16_t paletteMask, bool scbChan if (table->bandCount == 1u) { disarmRaster(); for (col = 0u; col < JL_SURFACE_COLORS_PER_PALETTE; col++) { - X68K_TEXT_PALETTE[col] = table->bands[0].palette[col]; + gFlipPaletteBuf[col] = table->bands[0].palette[col]; } + gPaletteQueued = true; return; } sr = m68kInterruptsOff(); @@ -1124,8 +1168,8 @@ static void spreadSetPalBase(uint8_t palBase) { // // Dirty rows only, and only the marked band within a row. #if X68K_PRESENT_TEXT -// Wipe the whole visible text plane once at start-up. Human68k leaves its -// desktop there, and we only ever write the centred stage rectangle, so +// Wipe the whole text plane, both pages, once at start-up. Human68k leaves +// its desktop there, and we only ever write the centred stage rectangle, so // without this the surround keeps the desktop. Long stores; one-time cost. static void clearTextPlane(void) { uint16_t plane; @@ -1133,20 +1177,100 @@ static void clearTextPlane(void) { for (plane = 0u; plane < X68K_BITPLANES; plane++) { volatile uint32_t *p = (volatile uint32_t *)(X68K_TVRAM_PLANE0 + ((uint32_t)plane * X68K_TVRAM_PLANE_STEP)); - for (i = 0u; i < (uint16_t)(X68K_DISPLAY_H * (X68K_TVRAM_STRIDE / 4u)); i++) { + for (i = 0u; i < (uint16_t)(X68K_TVRAM_ROWS * (X68K_TVRAM_STRIDE / 4u)); i++) { p[i] = 0ul; } } } +// The union of this frame's dirty bands and the previous frame's, into +// gUnionMinWord / gUnionMaxWord (a clean row's 0xFF / 0x00 lose to any band), +// with this frame's kept for the next. False when both are clean: the page +// not shown already matches the stage, and there is nothing to copy. +static bool dirtyUnion(void) { + uint16_t y; + uint8_t g; + uint8_t curMin; + uint8_t curMax; + bool anyDirty; + + anyDirty = false; + for (g = 0u; g < (uint8_t)(JL_SURFACE_HEIGHT >> STAGE_DIRTY_GROUP4_SHIFT); g++) { + if (!STAGE_DIRTY_GROUP4_CLEAN(g)) { + anyDirty = true; + break; + } + } + if (!anyDirty && !gPrevAnyDirty) { + return false; + } + for (y = 0u; y < JL_SURFACE_HEIGHT; y++) { + curMin = gStageMinWord[y]; + curMax = gStageMaxWord[y]; + gUnionMinWord[y] = curMin < gPrevMinWord[y] ? curMin : gPrevMinWord[y]; + gUnionMaxWord[y] = curMax > gPrevMaxWord[y] ? curMax : gPrevMaxWord[y]; + gPrevMinWord[y] = curMin; + gPrevMaxWord[y] = curMax; + } + gPrevAnyDirty = anyDirty; + return true; +} + + +// Show the page handed over, and the palette with it: vdispPoll's half of +// what x68kVdispIsr.s does at each display start. +static void flipApply(void) { + const uint16_t *palette; + uint16_t col; + + *X68K_CRTC_TEXT_Y = gX68kFlipScroll; + palette = gX68kFlipPalette; + if (palette != NULL) { + for (col = 0u; col < JL_SURFACE_COLORS_PER_PALETTE; col++) { + X68K_TEXT_PALETTE[col] = palette[col]; + } + } + gX68kFlipPending = 0u; +} + + +// Hand a page over to the next display start, with a one-band screen's +// queued colours. The pending flag goes last: the interrupt reads the rest +// once it sees it. +static void flipRequest(uint8_t page) { + gX68kFlipScroll = (uint16_t)(page * X68K_TEXT_PAGE_ROWS); + gX68kFlipPalette = gPaletteQueued ? gFlipPaletteBuf : NULL; + gPaletteQueued = false; + gX68kFlipPending = 1u; +} + + +// A flip handed over and not shown yet: the page it leaves is on screen until +// then, so a present waits for it before drawing there. Bounded, as +// jlpWaitVBL is, so a display that stops does not hang the program. +static void flipWait(void) { + uint32_t guard; + + for (guard = 0ul; gX68kFlipPending != 0u && guard < X68K_VBL_SPIN_LIMIT; guard++) { + vdispPoll(); + } +} + + // Present to the TEXT plane. The stage and the text plane are both four 1bpp // planes, so this is a copy -- no pixel conversion, no table, 0.5 bytes of bus // traffic per pixel. The SCB is honoured by the raster interrupt above rather // than by a per-pixel palette index. The copy, dirty-band walk included, is // x68kPresentText (src/x68000/x68kPresent.s), whose header has the numbers. +// +// PAGE FLIP: the copy goes into the page not shown, which holds the frame +// before last, so it takes the union of this frame's dirty rows and the last +// one's (dirtyUnion), and the next display start shows it (flipRequest), with +// the colours that came with it. The page on screen is never written. void jlpPresent(const jlSurfaceT *src) { X68kPlanarT *pd; + uint8_t back; if (src == NULL) { return; @@ -1156,6 +1280,9 @@ void jlpPresent(const jlSurfaceT *src) { if (pd == NULL) { return; } + // The page about to be drawn may still be on screen behind a flip not yet + // shown, and a queued palette with it. + flipWait(); // gStagePaletteDirty and gStageScbDirty live in core/surface.c; consume // both here the way src/iigs/hal.c does. Either one updates the band // tables, which carry their own copies of the palettes. @@ -1165,7 +1292,14 @@ void jlpPresent(const jlSurfaceT *src) { gStagePaletteDirtyMask = 0u; gStageScbDirty = false; } - x68kPresentText(pd->planes, gStageMinWord, gStageMaxWord, (volatile uint8_t *)(X68K_TVRAM_PLANE0 + x68kTvramOffset(X68K_ORIGIN_X, X68K_ORIGIN_Y))); + if (dirtyUnion()) { + back = (uint8_t)(gFrontPage ^ 1u); + x68kPresentText(pd->planes, gUnionMinWord, gUnionMaxWord, (volatile uint8_t *)(X68K_TVRAM_PLANE0 + x68kTvramOffset(X68K_ORIGIN_X, (uint16_t)(X68K_ORIGIN_Y + back * X68K_TEXT_PAGE_ROWS)))); + gFrontPage = back; + flipRequest(back); + } else if (gPaletteQueued) { + flipRequest(gFrontPage); + } } #else void jlpPresent(const jlSurfaceT *src) { diff --git a/src/x68000/save.c b/src/x68000/save.c index c1f4057..cc0750d 100644 --- a/src/x68000/save.c +++ b/src/x68000/save.c @@ -2,30 +2,52 @@ // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // -// Sharp X68000 save-file + disk-space HAL. +// Sharp X68000 save-file + disk-space HAL, and the file roots. // -// Structurally the Atari ST HAL (src/atarist/save.c): SAVES/ is a real -// subdirectory next to the .X, delete is stdio remove(), and free space comes -// from the OS. Human68k's _dos_dskfre is the Dfree equivalent. +// The program's folder (fileRoot.h) is the drive and path Human68k keeps in +// the program's process block (exe_path), so DATA/ and SAVES/ are found +// however it was started. Otherwise structurally the Atari ST HAL +// (src/atarist/save.c): SAVES/ is a real subdirectory there, delete is stdio +// remove(), and free space comes from the OS -- Human68k's _dos_dskfre, on +// that folder's drive, is the Dfree equivalent. +#include +#include #include +#include #include #include "joey/file.h" +#include "fileRoot.h" #include "port.h" #define X68K_DRIVE_CURRENT 0 // _dos_dskfre drive 0 = current drive #define X68K_MAX_U32 0xFFFFFFFFu +#define X68K_PATH_SEP '\\' + + +// The program's folder, found the first time it is asked for ("" -- the +// current directory -- if it does not fit). +static char gRoot[FILE_PATH_MAX]; +static bool gRootFound = false; uint32_t jlpDiskFree(void) { struct dos_freeinf info; + const char *root; + int drive; uint32_t bytesPerClus; - // Negative return is an error (bad drive, no media). - if (_dos_dskfre(X68K_DRIVE_CURRENT, &info) < 0) { + // The root's drive ("A:" is 1), or the current one; negative is an + // error (bad drive, no media). + root = jlpWriteRoot(); + drive = X68K_DRIVE_CURRENT; + if (root[0] != '\0' && root[1] == ':') { + drive = toupper((unsigned char)root[0]) - 'A' + 1; + } + if (_dos_dskfre(drive, &info) < 0) { return 0u; } // free = free clusters, sec = sectors per cluster, byte = bytes per sector. @@ -40,6 +62,28 @@ uint32_t jlpDiskFree(void) { } +// exe_path is the folder with its trailing separator ("A:\GAMES\"); one is +// added if it ever comes without. +const char *jlpReadRoot(void) { + const char *path; + size_t length; + + if (!gRootFound) { + gRootFound = true; + path = _dos_getpdb()->exe_path; + length = strnlen(path, sizeof(_dos_getpdb()->exe_path)); + if (length + 2u <= sizeof(gRoot)) { + memcpy(gRoot, path, length); + if (length != 0u && gRoot[length - 1u] != X68K_PATH_SEP) { + gRoot[length++] = X68K_PATH_SEP; + } + gRoot[length] = '\0'; + } + } + return gRoot; +} + + bool jlpSaveDelete(const char *path) { return remove(path) == 0; } @@ -55,3 +99,8 @@ bool jlpSaveDirEnsure(const char *dir) { rc = _dos_mkdir(dir); return rc >= 0 || rc == _DOSE_EXISTDIR; } + + +const char *jlpWriteRoot(void) { + return jlpReadRoot(); +} diff --git a/src/x68000/serial.c b/src/x68000/serial.c index 3f66f3c..36250c0 100644 --- a/src/x68000/serial.c +++ b/src/x68000/serial.c @@ -95,8 +95,8 @@ static const uint16_t kX68kRates[X68K_RATE_COUNT] = { 75u, 150u, 300u, 600u, 120 #define X68K_WR5_BITS_SHIFT 5 #define X68K_SCC_WR5 5u -// Bounded transmitter-ready spin, so a stalled link (no CTS) reports a partial -// write instead of hanging the frame. Same guard the ST HAL uses. +// A write's backstop on a busy transmitter, in IOCS polls (serialTxStalled): +// the clock bounds it first, once it runs. #define X68K_TX_SPIN_LIMIT 200000ul // ----- Soft receive ring ----------------------------------------------------- @@ -154,7 +154,6 @@ extern void x68kSccSpIsr(void); static void sccIsrInstall(void); static void sccIsrUninstall(void); static void x68kFlowSend(void); -static bool x68kInSupervisor(void); static bool x68kRsdrvPresent(void); static void x68kRxRelease(void); static uint16_t x68kSerialMode(const jlSerialConfigT *config); @@ -177,16 +176,21 @@ static uint32_t gSccOldRx = 0ul; static uint32_t gSccOldSp = 0ul; +// The vector table, the SCC and the SR are supervisor-only, so the install +// runs in supervisor mode whatever mode the program is in: a program that +// opens the port before jlInit (SERTEST) is still in user mode, and gets the +// interrupt all the same. Once it is in, nothing the mainline does needs the +// privilege: the ring is memory, the line is IOCS's, and x68kWr5Write enters +// supervisor itself. static void sccIsrInstall(void) { uint16_t sr; uint8_t base; + int32_t ssp; - // Only a program already in supervisor mode (every jlInit app) takes the - // interrupt over. One in user mode (SERTEST) stays on the polled pump, - // which needs no privilege and races nothing. - if (gSccIsrInstalled || !x68kInSupervisor()) { + if (gSccIsrInstalled) { return; } + ssp = x68kSuperEnter(); // RR2 read is a two-access pointer sequence on the shared SCC, so no interrupt (the channel B // mouse) may interleave. sr = m68kInterruptsOff(); @@ -195,17 +199,17 @@ static void sccIsrInstall(void) { m68kInterruptsRestore(sr); // With status-affects-vector the base must have bits 3-1 clear; anything else means the layout // is not what we expect, and the polled pump stays in charge. - if (base == 0u || (base & 0x0Eu) != 0u) { - return; + if (base != 0u && (base & 0x0Eu) == 0u) { + gSccRxVec = (uint8_t)(base | 0x0Cu); + sr = m68kInterruptsOff(); + gSccOldRx = *X68K_VECTOR(gSccRxVec); + gSccOldSp = *X68K_VECTOR(gSccRxVec + 2u); + *X68K_VECTOR(gSccRxVec) = (uint32_t)x68kSccRxIsr; + *X68K_VECTOR(gSccRxVec + 2u) = (uint32_t)x68kSccSpIsr; + m68kInterruptsRestore(sr); + gSccIsrInstalled = true; } - gSccRxVec = (uint8_t)(base | 0x0Cu); - sr = m68kInterruptsOff(); - gSccOldRx = *X68K_VECTOR(gSccRxVec); - gSccOldSp = *X68K_VECTOR(gSccRxVec + 2u); - *X68K_VECTOR(gSccRxVec) = (uint32_t)x68kSccRxIsr; - *X68K_VECTOR(gSccRxVec + 2u) = (uint32_t)x68kSccSpIsr; - m68kInterruptsRestore(sr); - gSccIsrInstalled = true; + x68kSuperLeave(ssp); } @@ -245,21 +249,6 @@ static void x68kFlowSend(void) { } -// Installing and removing the RX ISR touches the vector table, the SCC and -// the SR, all supervisor-only. Inside a JoeyLib app jlInit has already -// switched to supervisor, but the add-on must not depend on that: SERTEST -// opens the port without jlInit. _dos_super(0) switches and returns the old -// stack pointer, or a negative value when the CPU already was supervisor; -// hand that value to x68kSuperLeave, which only switches back if this did. -static bool x68kInSupervisor(void) { - int32_t ssp; - - ssp = x68kSuperEnter(); - x68kSuperLeave(ssp); - return ssp < 0; -} - - // Is RSDRV.SYS loaded? See X68K_IOCS_RS232C_EXT for the test. static bool x68kRsdrvPresent(void) { register int32_t ret __asm__("d0"); @@ -348,6 +337,10 @@ static void x68kSerialPump(void) { } +// Supervisor mode for the hardware the add-on touches, whether or not jlInit +// has switched to it: _dos_super(0) switches and returns the old stack +// pointer, or a negative value when the CPU already was supervisor; hand that +// value to x68kSuperLeave, which only switches back if this did. static int32_t x68kSuperEnter(void) { return (int32_t)_dos_super(0); } @@ -530,15 +523,14 @@ void jlpSerialSetRts(bool on) { // masks the other, and the Timer C mouse solicitation (level 6) finishes its // select-then-write before it returns. uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) { - uint16_t n; - uint32_t guard; + SerialTxWaitT wait; + uint16_t n; x68kSerialPump(); for (n = 0u; n < len; n++) { - guard = 0ul; + serialTxWaitStart(&wait, X68K_TX_SPIN_LIMIT); while (gX68kTxPaused != 0u || _iocs_osns232c() == 0) { - guard++; - if (guard > X68K_TX_SPIN_LIMIT) { + if (serialTxStalled(&wait)) { return n; } } diff --git a/src/x68000/x68kPlanar.h b/src/x68000/x68kPlanar.h index 2902efa..7c66c70 100644 --- a/src/x68000/x68kPlanar.h +++ b/src/x68000/x68kPlanar.h @@ -58,6 +58,12 @@ #define X68K_ORIGIN_X ((X68K_DISPLAY_W - JL_SURFACE_WIDTH) / 2u) // 96 px #define X68K_ORIGIN_Y ((X68K_DISPLAY_H - JL_SURFACE_HEIGHT) / 2u) // 156 rows #define X68K_ORIGIN_BYTE (X68K_ORIGIN_X / 8u) // 12 bytes +// The text plane is 1024 rows, two pages of the 512-row screen: the present +// draws into the one not shown and the V-DISP interrupt flips the text Y +// scroll to it (x68kVdispIsr.s), so a frame never shows half of two. +#define X68K_TVRAM_ROWS 1024u +#define X68K_TEXT_PAGE_ROWS X68K_DISPLAY_H +typedef char x68kTextPagesFit[((2u * X68K_TEXT_PAGE_ROWS) <= X68K_TVRAM_ROWS) ? 1 : -1]; typedef char x68kOriginXIsByteAligned[((X68K_ORIGIN_X % 8u) == 0u) ? 1 : -1]; // Text/PCG palette: 16 entries the text plane indexes. NOT $E82000, which is diff --git a/src/x68000/x68kSccRxIsr.s b/src/x68000/x68kSccRxIsr.s index be030c8..3ee34b8 100644 --- a/src/x68000/x68kSccRxIsr.s +++ b/src/x68000/x68kSccRxIsr.s @@ -35,7 +35,7 @@ | interrupt that runs the IOCS mouse solicitation's own select-then-write on | channel B, must not come between them. A byte with an error stops the loop: | the chip holds it at the head of the FIFO for the special-condition handler -| below, which drops it. +| below, which delivers it as it came, as the other ports do. | | Ring protocol (single consumer, single producer): the mainline reads gX68kRxHead, | this ISR writes gX68kRxTail; each byte is stored BEFORE the tail moves, and the @@ -78,14 +78,21 @@ .globl x68kSccRxIsr .globl x68kSccSpIsr -| Channel A "receive character available". -x68kSccRxIsr: +| Both handlers' entry: the registers they use saved, the ring and its tail +| in hand, and the drain's count. + .macro RXENTRY movem.l %d0-%d3/%a0,-(%sp) lea gX68kRxRing,%a0 move.w gX68kRxTail,%d1 moveq #DRAIN_MAX - 1,%d3 + .endm + +| Channel A "receive character available". +x68kSccRxIsr: + RXENTRY rxByte: move.b SCC_A_DATA,%d0 | the received byte +rxGot: cmpi.b #FLOW_XONXOFF,gX68kSerFlow bne.s rxStore cmpi.b #XOFF_CHAR,%d0 @@ -140,13 +147,12 @@ rxAck: movem.l (%sp)+,%d0-%d3/%a0 rte -| Channel A "special receive condition" (overrun / framing error). The byte is -| suspect, so unlatch and discard it, clear the error, and drop the IUS -- the -| frame CRC turns the loss into a clean drop. +| Channel A "special receive condition" (a framing, parity, or overrun error). +| The byte is delivered as it came, as on the other ports: read, the error +| cleared, and on through the receive handler with it, which drains whatever +| follows and drops the IUS. x68kSccSpIsr: - move.l %d0,-(%sp) - move.b SCC_A_DATA,%d0 | unlatch the offending byte + RXENTRY + move.b SCC_A_DATA,%d0 | the byte with the error move.b #WR0_ERROR_RESET,SCC_A_CTRL - move.b #WR0_RESET_IUS,SCC_A_CTRL - move.l (%sp)+,%d0 - rte + bra.w rxGot diff --git a/src/x68000/x68kVdispIsr.s b/src/x68000/x68kVdispIsr.s index 559be5d..e65f2bc 100644 --- a/src/x68000/x68kVdispIsr.s +++ b/src/x68000/x68kVdispIsr.s @@ -2,7 +2,7 @@ | See LICENSE.md for license information. | SPDX-License-Identifier: MIT | -| Sharp X68000 frame counter. +| Sharp X68000 frame counter, and the present's page flip. | | Entered DIRECTLY from 68000 exception vector $134 (MFP channel 13, Timer A), | where IOCS _VDISPST writes it with no wrapper (installVdispIsr in hal.c). @@ -16,12 +16,40 @@ | Timer C (channel 5); x68kTickIsr.s masks it while the IOCS dispatcher runs, | and an edge in that window is counted as the mask lifts. | +| THE FLIP. jlpPresent draws into the text page not shown and hands it over +| (gX68kFlipPending, set last): its text Y scroll, and the colours of a +| one-band screen (gX68kFlipPalette, NULL for none). Written here, at the +| display's start, they apply to the whole frame: the stage starts 156 lines +| down and both pages are blank above and below it, so the write lands well +| before the first stage line whenever the CRTC takes it. Without this handler +| vdispPoll in hal.c does the same on the edge it polls. +| | GAS m68k syntax, ELF target -- symbols carry NO leading underscore. + .equ CRTC_TEXT_Y, 0xE80016 | CRTC R11: the text plane's Y scroll + .equ TEXT_PALETTE, 0xE82200 | the 16 colours the text plane shows + .equ PALETTE_LONGS, 8 | 16 words + .text .even .globl x68kVdispIsr x68kVdispIsr: addq.w #1,gX68kFrames + tst.b gX68kFlipPending + beq.s .Ldone + move.w gX68kFlipScroll,CRTC_TEXT_Y + tst.l gX68kFlipPalette + beq.s .Lflipped + movem.l %d0/%a0-%a1,-(%sp) + movea.l gX68kFlipPalette,%a0 + lea TEXT_PALETTE,%a1 + moveq #PALETTE_LONGS - 1,%d0 +.Lcolour: + move.l (%a0)+,(%a1)+ + dbf %d0,.Lcolour + movem.l (%sp)+,%d0/%a0-%a1 +.Lflipped: + clr.b gX68kFlipPending +.Ldone: rte diff --git a/src/x86/x86Expand.h b/src/x86/x86Expand.h index 7831fb9..d385bd0 100644 --- a/src/x86/x86Expand.h +++ b/src/x86/x86Expand.h @@ -2,11 +2,12 @@ // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // -// x86 stage-row expansion to 8-bit palette indexes, shared by the DOS port -// (VGA mode 13h) and the DVX port (its full screen at 8 bits a pixel). Both -// load all 16 palettes x 16 colours into the screen's 256 entries, so a -// pixel is (SCB << 4) | nibble and no per-scanline palette programming is -// needed at display time. Each including file gets its own table. +// x86 stage-row expansion to 8-bit palette indexes, for the DVX port's full +// screen at 8 bits a pixel (the DOS port's Mode Y screen has a planar one of +// its own in src/dos/hal.c). All 16 palettes x 16 colours fill the screen's +// 256 entries, so a pixel is (SCB << 4) | nibble and no per-scanline palette +// programming is needed at display time. Each including file gets its own +// table. #ifndef JOEY_X86_EXPAND_H #define JOEY_X86_EXPAND_H diff --git a/tests/amiga/fastRam.c b/tests/amiga/fastRam.c new file mode 100644 index 0000000..48c446d --- /dev/null +++ b/tests/amiga/fastRam.c @@ -0,0 +1,114 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// FASTRAM, scripts/verify-amiga-fastram.sh's program: a surface made after +// chip RAM runs out lands in Fast RAM, where the blitter cannot reach it, +// and must draw and copy exactly as a chip one does. It fills chip RAM with +// ballast, makes the surface, frees the ballast, then runs the same fills, +// lines, circles, and copies (both ways, rects and whole surfaces, and onto +// the stage) on it and on a chip surface and compares their hashes. + +#include + +#include +#include + +#include "amigaPlanar.h" + +#define BALLAST_BYTES 4096ul +#define BALLAST_MAX 600u + +static void ops(jlSurfaceT *s, jlSurfaceT *src); +static void same(const char *what, jlSurfaceT *a, jlSurfaceT *b); + +static void *gBallast[BALLAST_MAX]; +static uint16_t gFails; + + +// The same drawing on every surface: fills of every shape the fill paths +// split on (full width, ragged edges, one pixel, the last pixel), a filled +// circle, a diagonal line, and two rect copies from src. +static void ops(jlSurfaceT *s, jlSurfaceT *src) { + jlSurfaceClear(s, 5); + jlFillRect(s, 0, 0, 320, 40, 9); + jlFillRect(s, 3, 50, 17, 30, 12); + jlFillRect(s, 100, 60, 1, 1, 15); + jlFillRect(s, 33, 90, 250, 7, 6); + jlFillRect(s, 319, 199, 1, 1, 3); + jlFillCircle(s, 160, 120, 40, 2); + jlDrawLine(s, 0, 199, 319, 0, 14); + jlSurfaceCopyRect(s, src, 16, 16, 64, 48); + jlSurfaceCopyRect(s, src, 160, 150, 16, 8); +} + + +static void same(const char *what, jlSurfaceT *a, jlSurfaceT *b) { + uint32_t ha; + uint32_t hb; + + ha = jlSurfaceHash(a); + hb = jlSurfaceHash(b); + if (ha != hb) { + gFails++; + jlLogF("FASTRAM: mismatch after %s: chip %08lx fast %08lx", what, (unsigned long)ha, (unsigned long)hb); + } +} + + +int main(void) { + jlConfigT cfg; + jlSurfaceT *source; + jlSurfaceT *chipSurf; + jlSurfaceT *fastSurf; + AmigaPlanarT *pd; + uint16_t n; + uint16_t i; + + cfg.codegenBytes = 0; + cfg.noAudio = true; + if (!jlInit(&cfg)) { + return 1; + } + source = jlSurfaceCreate(); + chipSurf = jlSurfaceCreate(); + for (n = 0; n < BALLAST_MAX; n++) { + gBallast[n] = AllocMem(BALLAST_BYTES, MEMF_CHIP); + if (gBallast[n] == NULL) { + break; + } + } + fastSurf = jlSurfaceCreate(); + for (i = 0; i < n; i++) { + FreeMem(gBallast[i], BALLAST_BYTES); + } + if (source == NULL || chipSurf == NULL || fastSurf == NULL) { + jlLogF("FASTRAM: FAIL: a surface was not made (%p %p %p)", (void *)source, (void *)chipSurf, (void *)fastSurf); + jlLogF("FASTRAM: done"); + jlShutdown(); + return 0; + } + pd = (AmigaPlanarT *)chipSurf->portData; + jlLogF("FASTRAM: chip surface chip=%d, ballast %u x %lu", (int)pd->chip, (unsigned)n, BALLAST_BYTES); + pd = (AmigaPlanarT *)fastSurf->portData; + jlLogF("FASTRAM: fast surface chip=%d memtype %lx", (int)pd->chip, (unsigned long)TypeOfMem(pd->planes[0])); + jlSurfaceClear(source, 0); + jlFillRect(source, 10, 10, 200, 150, 7); + jlFillCircle(source, 100, 100, 30, 11); + ops(chipSurf, source); + ops(fastSurf, source); + same("ops", chipSurf, fastSurf); + jlSurfaceCopy(jlStageGet(), fastSurf); + same("copy fast to stage", jlStageGet(), chipSurf); + jlSurfaceCopyRect(chipSurf, fastSurf, 0, 0, 320, 200); + jlSurfaceCopyRect(fastSurf, chipSurf, 32, 32, 128, 64); + same("rect copies both ways", chipSurf, fastSurf); + jlSurfaceCopy(fastSurf, source); + jlSurfaceCopy(chipSurf, source); + same("whole copies", chipSurf, fastSurf); + jlStagePresent(); + jlLogF("FASTRAM: %s (fails=%u)", gFails == 0 ? "PASS" : "FAIL", (unsigned)gFails); + jlLogF("FASTRAM: done"); + jlShutdown(); + return 0; +} diff --git a/tests/goldens/sfxmix-baseline.txt b/tests/goldens/sfxmix-baseline.txt index c72ca36..e4ce645 100644 --- a/tests/goldens/sfxmix-baseline.txt +++ b/tests/goldens/sfxmix-baseline.txt @@ -23,15 +23,15 @@ raw=13FFFFBF4CB75F8FFF4F87EF1F46494E9976154C525A27FFCCA19FAA57F3C123B900E5FFFFFF name=multiSlot3 slots=3 len=1024 hash=0x91080E39 first=10 7C FF D3 3D 1E FF 3F A5 6B C6 FF 00 C9 F1 5C raw=107CFFD33D1EFF3FA56BC6FF00C9F15C97C2950BD8FFA9DF440021005500FF0061C300FFB6FFC82FFF7D42B18D0013422573BFDD920000D07B344D0000686CED9F007978FF5C2BB66B5DFFE4FFF869FFFFF0D3097A76240A6D6BFF70FFBCF0B900000010FF21DAB34E82045C918E7761470C707C0CFFC736AB009CC65BFFC0FF375F35D2003B00813E9B00CDFFFFFABA00A436FF7732030F26570046A67F6359E02FD2006050FF5A2CF4DEFFFF09004EBC3288FF71FFC200AC2E7B5296965B00A8FF005DD23824003BFF80FF1F67FF00777380B6A0CAF9003ECE3800FAFF0700FF4A009965000CFFEE1588FF5A203F60FF74417855706AFFFFFFEF882201FFD7AE43FF890094EF42FFFF68FF5DAA00AFFF19854D0081671C00D30000AC59257E18431740FF791D00B4C4FF00FF5115FFB0000091E273FFAAFFC490ADFF00CE9D8E96CCF582D3FFEA8991315FC12A0E11ADBFDEB1380C3200FF78153000B07800B200A9825A5A79CC9B859021A02AD2B74F005ACA256A008D6662FF90FFD276B832B4070000BF0062AB003B45647BFCEBDFA3064F978DFF2F82FFB427009BFFACA8FF31AF42CC6E5209A21B2BFFC65DFFA16A9919D5A1FE45FF007D951692A04FFE8030FF56F900B4B29CB4C385FF00FFE86B006053FFFC96FF3B3812025162FF660000476A00C1C7C43031342C00E3C17780FF43B8FF020000AA522A3BD12791E5C542AD640A800300171C000076E0B92DF85216737EF1A09B7A2B97FFADB813C738884FFF914D00A32A7064EDFB58B700001883EF8C00C716BA80C1FF4B1DBBC1FF185F00FFFF73DA9B11448E00D0FFFFF8326166C75700FFA97C18E08020223CFF98CDC4008E009E6789502CFF5967103DFF6076FBFF4A00CE339F9700C48ECAD470C5FFFE3AE536FF4A73BC59AAB18BBABDE9D47E00323BAD34FF4016007D002F000073630B2DE3FF9C7F54DEFF5D2BA2FF792F32969988A7FFFF6A413BF241FFFFAA67FF004FFFFF3E00881176193386009577D1864A127BB2FF0197FE000095250872DCE82B752C00BFF5008B4E146392605F4BA2657FAB6C002C74803B1A419068FD7228F58F6CFF00047F000B358DF87AAD00B60ABBFF002EFFF02E766F2C7E25FC6B1AC700C000A0DB3F32FFFFFAFDD39DFF96B5D4FF5AFF38AF00FFFFD3DA904009007DFFFCC253530053349D7CC4FFADF21E4B00FF71D598A026A5FF7B99E3FFB80069AAB75B182D00BD96C4B5FF1C0000AC25001FFA3F71067D17D6D45DDEFF00FF6CFF886C5386FF972F007200943CCE127100297EFFCCD2FF00394200E3D0B8FF85FDDAF582E7534A29A88719BB77070059756F00DBF68329AAD00089B3E8C0FF2DF064BFA9003494C15B7DD0777200209BB8B864676A004C2F94AD8D51FFB0A68894A7F1CA1864419F89FF527C0100FFC1D3AEA60000D500BD -name=streamRefillMid slots=1 len=1024 hash=0xE26AC28B +name=streamRefillMid slots=1 len=1024 hash=0xCD3590FE first=00 23 64 C1 49 FF 7B 88 95 8A 39 00 00 8B FF 6D -raw=002364C149FF7B88958A3900008BFF6D0BFFDE0C00E344E6DE628D008A000172AD83D7FFE88B861D676CF5DAD996F02A291738E49A4BC371583A00C900001EB7DA5FCC2B95033B27968EB94A8CC1C2FFB3BABE172200684C66C0A3608F477A8C36FF2D008014124F0D1C3C73000092F500163FF3CCE3BCFF4EA36A6C9D30BE1BAC996BDA7593007BFF9DFFA018766F896F2D3E2DC4A6BEB4F68A67FFB135AAC7B24B5A15A95AA3000338FFFFC4D0FF8BDE4800AB55A115FBFE421B7637FEB675004A9F2AD801C5471D00143BB695223579DC411C7800678AB795480712FFFF57CD25B5B0DA6FFFD100C4FF983A007381CAE2A8FFB039CCC3C08A3408D3815EFF642E02BD88DEFF82FF408858FFFFFF0000FFBF3A5B7B52990072A9635E945FF5D2FFAC7BA5CC258E93351841BDC593FF9F935A2846FF13E47C0079E1693B0034805F4D713BFF87FB7DCCFF87B9FF61582F00B7FF1039652ACE00ABADBBF2ABFF8077E7FFE59DAAFEF6905C73DD0046FDFFA44DFFF9CAFC39FAB54F00996DC39A8BB13545010052D552A6BEE6058810EE560000B8226364C0E90026DD55000095FFC61BC3ADB50C0056C4AA628D9FFD295B008FFFEACDFFD4ADFF744E3AA074A8866F021D57090052D48AD12F9C47021FDD05DCFFFFADAD0F4DB429FFD871D37D000689FF82E7FF59FFB49C44444F984AFFAB0C5EFF1D31A4B0FF110BBD2291F600FF8F0002935C9C71A043A40092EB965F1BCD420091611E60A14C5AFFBDBBFF7EBEAF60B5FFB6341D9BC22F51ECC329B69A6CA92600363DD8AD5862BAFFB559FFFF90F9A57700B0A1ACCAFF37D6365C2C172A7073D3004E3959C38EFFDFCC388736348E3656D1427297806BAFAD77EBC5009BDBFFFFC098060032A142FFB794C4CAE9C4AADDFF56FA1F6EBD8A1A88C562007E421F66641800425CCEEE245341113DC087BE97BBC85A2400FBA3FE8B0F71F87540537F55700BCE657EEB001476FF324B77959AFF35F77000B955FF9B60E80D81CE94022EA9635C7858EF5CC1F26600C5FF2C7767DD7BB79E7431E7FFBBA66B8C9FFFFFF1810FCA7794311A17C25E656F7BBFB10004806C00005CACA7CF1000000042A2FFFD26F0D592150060E3D7B06A845AC7FFAAFF7DFF8BE5FFFF0012650000B3FF95FFCDFF7243E04800001C0F677AEC0008C1FF7263754A37EF3D95B94FF7FF2A9955C5B6A0FF9F4A887AD0E5C6657AADDF008E0205D1FC28E4BD4E6B7600014D46224900A9B517C8002979FF98BEB8CDCE307826468259D34FFFB84BD50088C2C4C800001C39284FBE0000732ACC4823F181FF64134AB33D1B41B724A097C47A119E8B69CDF70002326CC2B1A4767FFF4222FF43F5C01DD40093FF72680A6D48009D69C5106248E5D3F7A2B17DA795FF9C7C7816002E0F0C6EFC0000B6CF89B4882C6F005E0000FF5E6F -name=streamEndMid slots=1 len=1024 hash=0xDBBAA972 +raw=002364C149FF7B88958A3900008BFF6D0BFFDE0C00E344E6DE628D008A000172AD83D7FFE88B861D676CF5DAD996F02A291738E49A4BC371583A00C900001EB7DA5FCC2B95033B27968EB94A8CC1C2FFB3BABE172200684C66C0A3608F477A8C36FF2D008014124F0D1C3C73000092F500163FF3CCE3BCFF4EA36A6C9D30BE1BAC996BDA7593007BFF9DFFA018766F896F2D3E2DC4A6BEB4F68A67FFB135AAC7B24B5A15A95AA3000338FFFFC4D0FF8BDE4800AB55A115FBFE421B7637FEB675004A9F2AD801C5471D00143BB695223579DC411C7800678AB795480712FFFF57CD25B5B0DA6FFFD100C4FF983A007381CAE2A8FFB039CCC3C08A3408D3815EFF642E02BD88DEFF82CD19999BFFD59A2425E9B11A247D578900C5C78698A500F1D0C1FFBA6D6008B2CC6D530098FFBFFF9E6D226033CD41875C0089FF6300005A6949A0BD4BFF999AB3FFFE3DD0E1007A2300F7DC001072719100EFFFFFE68FFF2D75FFFFE98785EDFF802942C7646FAEF6CF86FFD277F744A46BB83B4C89D88078ED0000161ED2C400FF9E8000D754CD00003EE421A82C58AA00D6C2000625BFFFC05BC95B53043290D4E30094CB5200BE50ACFFF6E4D6A4E0F700772097C6BA2D5936002655584FBE347740DB6F051CD94AFFFF9E55901090E646FFE75D7C35236C85F8A1FFFF46ACDFCE413713B665C5644D6BB4860A99FFBC273CB3265FFF2E974644310445AA629674BA28F85C3C8537DA563DDF932368BB233AFFFFE1FF2981973164A9FA5E128AF81E079AD243FFBB4832425B960057B4E39E86FFE990FFFF3866D67B0059FFFFC98500CB2C4208937C7D26B8004B9DD5F544CD6C9738BF9966B11B18DC57AE5B4450C3CA59BBB47FFFFFB57D8F712841A7E306DAE4E7E85AC1C0F4E3A6009F2D4FB9A03476A47000BE9F222C0000098A6FC4EA597799000075FFFFEB852A273A358758FFFF4575FF9D47002F52DD35A7003DFF6B6741FF3E63518D78C200BDAA6AF000FF8CB2FF005086C90000668995B764FF7E89E45912FFFF5100007172CAE0E67A95995CE6FFFFC7A688A7752DBF4AB03F2900FF8152504EFFD90000176B30007368787C00001C8082ADFCDD00BBD7F2932634B0A8CFEBAC7ED1F739FF8BF79DFFCE85002F775899E9FFA2CB849500009F13002F941BA9A0DB5C83B8E903214E4C70FF44B13E66FFB049DB48DBEAB7DC7050373AC890C77471A89907D2392BA29200D0D7766973000071C24C9A2B40EE8DD1000820DDB7FFAEC7FF3F784331A30DB966C8E9D0FF40342B509200005F11006FB60018A02BFFC8279A14AE35337FFF5A3A95CE35772B882D1ADA3A70D5DC00001978E8FF8C5BB2FF904AB63BA0CAA1BD0CA9C2101D0096C300DCA9D6574D00BD97FFFFA8363812C5907E9649657E2F0024FF2B2FFF9B216C00009266C85A12DE1414 +name=streamEndMid slots=1 len=1024 hash=0xB712711D first=00 00 15 FF 7A 8F A0 75 2B 30 F7 FF 57 21 36 FF -raw=000015FF7A8FA0752B30F7FF572136FF688C0037BE4C4606FF3B6F9B12FFCB5BA2DF485FC8003F050B4F18011B6444FF7E308DA145FEFFFF048DDAC7FF52B3AD06FF1A004E87FF4825F67077A8025895B2FF00ACB0FF50C1FF00AC7A89330B2DB838E200BBAA63FF4AFF64E958989DC430FF9EF77D007D7346FF6E0015A37C5484FFD258E24E8C0090A8A9BCF9501900DA2D73E16C9999A7DB0EC9F06EB42C7980E53F36988862FFFF09FF00EF4D939930AE0CDA36D37447006DE7C598E4FF00FFFFFF00E57E9A001E9F00ADB37B6A634D86D1FF87436A6CEC00508EBCF449B6A4F21C696C2DECFF9B44D5FD23659100FFFFFA95856800BDB1FBFF49F0FA173579E95DEB95A5F70DFB9AA90A7EB793A375A8A33BBBF6EE28B4E67CA106445DEC057827F4FE8F1C6CD413A017AFE0C4EF9E510338717184B2350E70870DD534A353DBC47E3E3330B07B7D1FAF738A09A22B7E3B2DF310028C61CE8F7E3F9260C16C734B22D4F684690D481BA52FBBE197EED2BC462A99B5FA5F1A6829AB2298146BA4081D9480DD0E3D6B3AEF1A3DBE0ECEF32B5355185C25EA6A3C5131F08BCE83D87851A5BF76FA505ED8A9419D07A9C68C61C803DB291953B802E4882FD884F7794E4482E4FA6B20200711842E156FE44B6BBB1B77C5C67E8562F13CD0132F20FA731FFA648D821B318989970637CE4CE289EAD4C24B02E1D4BBAEA88CC52C6BCE00B231F8C2474198D69902FEB092330A30B4BDD64A83882D1F7401E06A3D586F456C9D9DA4A32ADD1AEC21B5B6F0E47A0B1DB713C6E6149B55BF4B0B73877D58C4650994B065FD0F364F258D7380BEF4F4CCF61D36ACB7ADD43062124493810A34569662D7407ABFBDB712AC063C3438FE0E05D986383A78CD8CF3BC364E220A819CB5CF7FC9F202F839C0FC8183CC0E6DD1BF7414D4F79220BB8095940A30E0C83A2EEA817A4A73E111DA2BEB19213FBAAB565BB5ABE01863CB60EEA07C90195ED41D032AB2FC2D299DE2AB3E25E19B92D25043B0CD77A8E0ECBDDB8EBE3A45C8B6F0AF3DB415D433602BB26833D0F5D6228521C030D46696739432864C3BF9C3CD6BC7EEA5388C1901DE3A4D32C4219B1B233214AAA2FB36D9B992A408B4CD3CAAA469E7909A00A54BF601C174A7FC29E2AAF550F9229126BAD159F937D95E578407F44464055C582C448990B27964FE3DF12B8CBD27D780D856AB8A82F44D6A34530F99F1D65264B76C1878074B3ACB991FDC537A1902121697724EF7603D01379CA2E9EFFF18B423E6B82CF145677405B354A398F3C95E7FB55832088ED8DAB147DBAFF80667E1CBFE385154A1D312357E22F2644468993EA7CD178804CAE965112AD88B047D3D2ED5288378AE6FF281A317250D329DAEA43707D1B259C454C00944C409A71BCBEE5EE04F08CE2B96E49E4C98A973AD3B2704FC2923 -name=mixedFixedStream slots=2 len=1024 hash=0x2B1919FE +raw=000015FF7A8FA0752B30F7FF572136FF688C0037BE4C4606FF3B6F9B12FFCB5BA2DF485FC8003F050B4F18011B6444FF7E308DA145FEFFFF048DDAC7FF52B3AD06FF1A004E87FF4825F67077A8025895B2FF00ACB0FF50C1FF00AC7A89330B2DB838E200BBAA63FF4AFF64E958989DC430FF9EF77D007D7346FF6E0015A37C54FFFFF52292FC002EF841B0FF8B6F0000FF00C5FF8F2055FF4826FFA8D23F00FF6CD500A29A00E1FFB9819011B23DF962467E15FD7689A500827C72C0FF99A850FFFF9D17B1E13D1F7C3600FF44D400AC8191FF80547A975E9513CD7C828FD060D96C8100B35CFFFF8800FFAC00718990FFFFC5F6890098F65DFFD949F0FA173579E95DEB95A5F70DFB9AA90A7EB793A375A8A33BBBF6EE28B4E67CA106445DEC057827F4FE8F1C6CD413A017AFE0C4EF9E510338717184B2350E70870DD534A353DBC47E3E3330B07B7D1FAF738A09A22B7E3B2DF310028C61CE8F7E3F9260C16C734B22D4F684690D481BA52FBBE197EED2BC462A99B5FA5F1A6829AB2298146BA4081D9480DD0E3D6B3AEF1A3DBE0ECEF32B5355185C25EA6A3C5131F08BCE83D87851A5BF76FA505ED8A9419D07A9C68C61C803DB291953B802E4882FD884F7794E4482E4FA6B20200711842E156FE44B6BBB1B77C5C67E8562F13CD0132F20FA731FFA648D821B318989970637CE4CE289EAD4C24B02E1D4BBAEA88CC52C6BCE00B231F8C2474198D69902FEB092330A30B4BDD64A83882D1F7401E06A3D586F456C9D9DA4A32ADD1AEC21B5B6F0E47A0B1DB713C6E6149B55BF4B0B73877D58C4650994B065FD0F364F258D7380BEF4F4CCF61D36ACB7ADD43062124493810A34569662D7407ABFBDB712AC063C3438FE0E05D986383A78CD8CF3BC364E220A819CB5CF7FC9F202F839C0FC8183CC0E6DD1BF7414D4F79220BB8095940A30E0C83A2EEA817A4A73E111DA2BEB19213FBAAB565BB5ABE01863CB60EEA07C90195ED41D032AB2FC2D299DE2AB3E25E19B92D25043B0CD77A8E0ECBDDB8EBE3A45C8B6F0AF3DB415D433602BB26833D0F5D6228521C030D46696739432864C3BF9C3CD6BC7EEA5388C1901DE3A4D32C4219B1B233214AAA2FB36D9B992A408B4CD3CAAA469E7909A00A54BF601C174A7FC29E2AAF550F9229126BAD159F937D95E578407F44464055C582C448990B27964FE3DF12B8CBD27D780D856AB8A82F44D6A34530F99F1D65264B76C1878074B3ACB991FDC537A1902121697724EF7603D01379CA2E9EFFF18B423E6B82CF145677405B354A398F3C95E7FB55832088ED8DAB147DBAFF80667E1CBFE385154A1D312357E22F2644468993EA7CD178804CAE965112AD88B047D3D2ED5288378AE6FF281A317250D329DAEA43707D1B259C454C00944C409A71BCBEE5EE04F08CE2B96E49E4C98A973AD3B2704FC2923 +name=mixedFixedStream slots=2 len=1024 hash=0xE97713E9 first=00 6A 3E 7D AB 6F 00 68 4F A2 1A B8 B5 4F 5A 91 -raw=006A3E7DAB6F00684FA21AB8B54F5A9182FD86004F01D857FF4901F431A04AD18D0099DA69007C9F0027E9C0999D7B83AADAA0596FDA3B530055D81FA852002363AA90A4003BCE04586215481A1C3E000E000078FFFFF677A3BD001659AC4126721BFF3A5D2BA58C6CABFF00800D8D4F2DFF0654C08485750016883400005400092D004277D79CE000B3B2FFFF2DC20340FD9D7536BE29580054912E6B9D49867CFFFF72FF82B5FF8FAC00372E387A7FD74C007D0AAAFF6BA75A22166A2AD189FF3C1500005C00B8327DA8C732B85B4969001573FF00041C5FC6698BAFA5005AEC4657A7284987FFE76A004E8A642D483076005BF1FFFF6C20A86CB553307CA1FB636536732F2DFF3752FFFF417B2863E14E52125DB3699E573F618C7B00990666CDA0111F6FFF28B9B56972BE9D00B9B400C609E200DB4EB5006E2789DCC9C7FF73FF4A41DDC4AEF051ED4A1F1AEBE2FFFF594FFFD5B7FCCC9E00276668175E86CBD699FFB97DFF29A0FFCE943E848EFFCBA3CDAA59CEFFFF8800004CD88BE0866D2047D1EA1D1750BCAC000051687D8D8F3F4530FCFFA1BD82FF6304FFFFFE1CB3BDEAF5FF7AFF4C003F0000AF5100FF0050AD5300ABFF5BC9FD1B4C900ED77DAA1284806FFFB9A8D600DA28FF0099D776F1332122D059D00055B8B6293BAF0055728771C900FF4DC32A0B16FFD00DAA007873FF705538CEDA791DB700363E84AC2F0075C8FF597B784E35ABA0FFCAC59A68CA520000FFFFCB00D000FF96CD630F007B8F9221FF615654C5CC8411178601CFE3721700B0B47E5CAE1D0000A410A1000861C0649C2576DCD0423099D35BCB98FFC1A400D200F9FF76FF8BCCCA0C9D0000002ACE26003C00696E8B5B496F13410000F097997ED00033F9C1D82D994C56C4AAFF0F3000461765C2F1230000001C003AA1FF55FF88B46B36B5ADBC50953CB18D00764E7BB1CD65600AFA07D6450000CAA43AB2C83D64445EAD4485000A8839AB7EB79B2FDE6796AE45D7A44E00766CD94A4A4F3F7D00D286579F80002DFF2F9338779557B67F49C784E8A99B45A38D1735A2F0658CDA7EFFFFC1B6FFC02AC2B1A4FF7719DE3CFF1598FF8FA6001C3564BD312A0000660916E5D162FF10CBAE3670D74D50E2A8EDD900C2ED686D14CDB5FF003E8673003FE007C1926D8F64C095BAC34F5AFFCF00158F140D641BBF7262FE55665EC7FDB34C09B071CC7600FB0E0067FFD5A75A550000D649CF9FCD00861C00DE9CF7EC30FF80CF7DA07B0694CDCEB8EDDCD21CE858E2BFB4F5B97EC3FF8DC4E0D97195D43979FF006D009697645FBAD253F2B90B04437D37E1E4B745C2F6A4D54E28954121B7EBCF5097003BFF3B53D266FF9BCE4C756F9C6F32788592A7009C0095268DC66D0095E73941B3FF4BC1357BC0528D9249082C970F0600E3D9E2269C82FF19F3266599 +raw=006A3E7DAB6F00684FA21AB8B54F5A9182FD86004F01D857FF4901F431A04AD18D0099DA69007C9F0027E9C0999D7B83AADAA0596FDA3B530055D81FA852002363AA90A4003BCE04586215481A1C3E000E000078FFFFF677A3BD001659AC4126721BFF3A5D2BA58C6CABFF00800D8D4F2DFF0654C0848575001688340000304900842C2171C464E200FF99F0C551737047D16D921FFF20000B756A6E48834DC915FFFFB29353FFD5656F10870000C8ACDF5045793E5ACAC2D400003E5128FF9F9F07286300690EEA008FFF8A009E5B75570F7448990080140BFCB700BAC73362B392933C0065F7CF8A6D2D355E8B5137007800BAFFFFC2834C743DB16117A774F8D23E00A23028FF6B5CFFB664983000DA44E71E336A63D8120935FF7132770700E9474DB85AD000E9FF3227B99300B1D96179357100817FE550896F8591B4FFFF87B51D01C8F8C5BB34C57C621EFFBFD8FF7641FFEF70ABE36B003560684EA5BFBDDC22FF9D79FF2166FFFF87503483FFFF10CDFFC53275FF6C095403B072D2C2B04908F19D001D2EE5A07420905D1838E677787BA6B79FACC8D8C01884E0FF5FFF909EE7B9B9C61D402A003882581AD4033AF88700BD822E7DFF50A1951FBEA0698B7AA9A3AF998FE52C7500F5249FF147BF3863449C6F451F5584A73574A30DAB478F699D32E9B1E5900000B56B3FEE2B6E71D09C6556D3FF95007E000038BAEC772190D2D436BE74893EF107D730AC7E35FF8C3048EAB8F54052009220FFA521009CBF7A3EFF0000004FF57F6E46EC00B9FF715239B5487E00B600001AAD0B9B2200A68141AA9633FFFF006FA7928C869AC3FF955DFF2DF8FF4DBB04629E00B45E000028BF47940047417A7D2A26AD12004D00A155FF81FF33459AA1FD2265B54E6F4691004A272A8088C5A4244F3B09380E0A8BFF6ABCB491BC00CDCFC70095009F1835D77593CB9308000AFF00FD8E000054BE1EA2F69E9600C8450054162ECB7EA3F6700E08FA88B4B88CFF6400227631B1697A005A7B0049B16A5EA65B7BC26EC5534351599A6300DB7BD8BF9F6FE2B05DA3AAE303C9E85CAEDCCB89F9C465FFA6D7DFA66DA98CEA6BB7E5D7691B0000824A00370046E60028EAFCA8FF00D8FF0D82904314AD7FFFFF48FFCA5DA5008A94D718009D400098C95CDC8A91BD55E420E4435B00DDED214CB6004C002B736F42FFB46B51C1FF9B58688A54E9B320B9000C30E3FFC263BC002BEF4AFFAA950C6F6B3EB86CFF8D9DFF93A1FEB9C4088DC6A6FF8A9EFF00FF54D2E483C84B47FFFF88FFB7FF6D655E3A1DFF3C8D5EFFB330909DC601DADA720F00BE60FFBE40318CFF8ED939415189196CC38A81E90088DE6265FF72E1B26014A5264356464A929488008E27E768D4B89B327ECB002283F0886A799AFF1735AA5A003467480233F8FFFF009470E141F30000D0 name=fixedLongPos64k slots=1 len=8192 hash=0x5EE54B61 first=2D 98 5A 00 99 1D 3B 6A C7 C4 A0 E5 7F 88 9E FF raw=2D985A00991D3B6AC7C4A0E57F889EFF0088B0789E67EBADFF64D792A520A1FD338D47850057B88949378882892C020000A8D300FF428C75A37935E1CBBD50A91F8800FF1445C5D100E1FC76C36A6E0695553900B65EFFFFB34F6600B8A6FF8678A00F807883255800FF7351CF6CA28C9665B498497800000044A9EDBE53D44862E2973B00EAB67752258308BA49064642FF009C43FF3AEF00EA3A1317D77611295B25C4279AFF7EE66A8E673AFF00FF1E251463002DCC2100D2FE11BF0650F6E25EF030009A28D3A56C90BE1D628079B824008E9A60A10041E38392E31DF9FF7DFFF670D8FFF15061A94C0000AE54B9BA0BFF8878004A3F0487C3282CFF0076D65D9B259E957167CEDD47645155D1004157FF8B8C3DA79184A6A92924168EC6901B007F5373394EDDFF989B1754968EC64505FF80A40C6AFFDFB4112F455DA800FF54C4F1FA8A9CAAB9FFFFEB5B5BFF548CD29EEC1680A074FFE98AA1B3DB74149F0D00BBCB41FFB30022004B97A0987A559D18C2A409D1001CD10093E75C007AFD3C31FF3216105ECBA2FF8315FFE7D3FF846350FF7EC8FF13D70E5188FF6BC4FF9281B76F5D67BE056BFFE4CEB100DF7184746032AC9B476DCB13FF333400B0C88B0E0D19DC5700B6529B6367FF8AAE3A6F95DBA06EDFFE8A641392C39D342E87A1495100FF64C9C679FFCDC7F700EED7A63D00FF00BD7A3971FFDF75C534C747FF39BF609361A47A00552400BF9535489CEDFF502F30E29A9CFF00157642FF9C001A2E0BB0476D8ADD2B38FF03DD2CA400AA67AC7090439937577DE8131BCD224EE16C1EE9651800B42762FDC8000045CCAC4836ED40D5B7A9DD5FA7C481DE539673E48D0070B7610012F5FDAAFC82A300008C7EC1DD4F16C40AEFB6005BEF00702A00919426FF560854FF5755D536004059EB195BEE1506D0EBFFA5C7416D9F616BDAA86FB200ADB5C152A9E2D000809D796FF44E68ACACF140DE19C84A9B00ED459106FFB3A4FF68ED9AC949D5DA5534DC376AFF944D2F38FF749FB85CEC4FD50B3DC7C2B0DC95FFE050DB9BB7F72DB05DFF7817449800F22600FFBFFF00713838DDDB4E91BC007800B7197B3EFFEF1746D213FEA65A9152547880A62B0A769DFFE2FF00648EB1327584BDA8A387732EDEF400005C753C4BFFC5C921690042C56391AA870BD49E6E6D6809A15FA6E33959AE3A7381298FC247E5EE51FE009A888E641E72CFDF1AA4F3A8BEFF0668C782509436402CFF32119500FF9334B050A16BE8D1FF00D90000004FCC7AA14440FFE273D8FF00D9C654A500414F40003ABD21592F20FFB22D93256FB715A04ED43E7EFF40A12F6F23443EC8364C00A5DF2B6F123EDD0000C63F19B3FFCAFF0C1CA787FFB34A0638FFD064F04392450C9DB966A851D145CC8DAFFF2F000B7C688CFF00FF4056FBBFB1F220FF4D68E10EE3DF00AB289C5D6AD431DC435ABA8F46C73237002C4AFFFFD400003DF678FE886DDCBFF4006375B9071E71223BC2D36A82A89117C12AFFB1D4487A00002D61A325FF3C005B078E7E91005300B590F6A58A05CE2A62E75D4DFF4B0CF3803A1353E5B98B1800FC9F7DC7F0E8AC862233CD55B65BF5496C4D007126F85B9FB6D0D5DC72CD514681A122D7225F3F3AE7936AB42E0CA7B30000FF2238FFD51BB90076C6EA34874FA03F929ED89B93AAC6CEC100319829FFD5B8A9D41760CB958A176891902B0B10AF8BE8A3461200666B70001161B1FFE842D98645CB19FF255A1EEAA5659A0084F91C51004172FF6ADBAF0825305100697900440482CB95E8B8B0B164003F1A9B6400C2009AFADB0EA8C9FFC1799C3E5AE70002E0DD22CD04CA24FF9071000000FFFFFF8778FF4EE34D6B718C5800FF353D1BA0A14B6D0B4E347E9F007F49FF14004AF7446A92E5DAD20042FF2900CD0000C09DB5420000978B6F96722D37B4BB1F73C600338DCF8A2E4AA77EAB480012E97CFF5B70474B8195B19AE2552B1E4EA2B4793127355540C10E50A4CBFFD46C7D1BD934A9AE65008366570DEF4DFF63EB98C9D9DD4FC3C051A68C07B600CB5119590228EDCF3DC06D4566A8CA0700A4E1725D4DAB34D28CE4FF9B79008222892B76686E750D181C07006B0D9AE18EB549FFFFD8DE412DCD86C756091F4E005E1F5D7F002D62D7D2FFBF1A13AC95D2A0C9FF74664F1E0014E7B869FF97CD7E299CEB004CB175627F227B1DD6004E4818B0E9A904FFFF00ECB54A8054A56700A79A7D1E7CFFA200CBA500235057D438C178005400D4E11F8DFFB5DAEF2EED371CFF9CE8EEC4FFC3FF9FA390209531A6BA7158B48D1B79A7B0E6FF37FF33619B92716C00FF1AD88D53BBFF559D310000A600BD4A0081AEF56E258A6B00FFB310534462C57956BB19FF4BFFAE08D10000162A90896847B1CA7750D8D400712F80BFA760921845AE0F007985974FE27685DA1F4AB8670AD69655004BFCC19B82113D4397AB008170303AEC15FF6CCDFF002DB1D416B438D422727288B1CDFFBE961242FF2DC5A9C53AEEFC8D9CF5FFEC00A0286E8D00F5351F30EED085D2E4718EE7000014E0C04BCB5247F200D6DFEA609C42895F1CDADB6FCDACB900010094D12DACA6E2FF00457AA6FFD99767557DC8C2641AC4CF9C26BC467871A96C0077FF241800C399FF62A62C885B3BFF062B0A534BEFFF115373125B8E122E259AF513790E3D840E91986CDD000A07D0ECD8FF57BAFFA136CBAFFF805493FF5BEED77F8DF63ECDB776CD7400AD1194AE7CFF15FA0077781C5584B12D03CE5C71A738FE886FE68A61B99BFFDA1700FFA55630B5C0561E174D9DA33442005BDFB83A00FF004702679C2D6D00A90317BE9759891D34B177858D834F04EC337F2C57E47609C6D34868FF88C1CA9564595DB5B64206B65ECDB2AF7A1E0048E2AC607691FFFA8C00D91337CCAA154DD20F7996007CDEFF00BC965A84FF6F40FEDBF476007A007741006FA8AA3C907E63EBB3948F9BCBEEB1F4B9007BC17580329B01751CB4BB587A40FF3FED00FE000269C475FF76A3B150F5000000CD1A437E5A9438A38E89A8B2A5FFA07E0016305049BB15FFA2F6348F75FD5F2D5DCC4B66FFAE41FB00A02B0350A0C4976FB57D6200A0687F3D3143B34B5982C186A766EDFF4354647200FF301CFAD8EE8E0713409B241B80567BA772F3FFD486C2F4E36E1100BC2BF4A4527399FFFF678000582066ED006A6E5EFF7454A49757FFEAFF7A850032A93B993548D2FFBC00E0FF4128FE00DA99F239BD5FFF0BC5B4881FC300546293FF8079D8025CFF0000AB8011643B1D52F8789A319BA2CE48D9C3735E538984C4130000B2FF2949F2214CC50040A95B3ED36DEF6AFF85FF32E3FFF7C3FF1AFC574B5884AA5AC5005E009C9859C7002332D87C00003231AFEEEDDBFF00838E9AFF27687000A8DD5CDE97624C274E5E938357006BABB2BB7485E8613DC00662B498BA5BCC4A58B287CD4317ADFFA599FF3300FF00AB004F8EB3E10058B773FF7CD7970A50BE767FFF237E3D472062FFFF97936E221130AFFF00F5B7AD4BB815ECA4B4FF745500E985B5CB349E9877E69BFF32AF06000500875DA39BE69C3D58CD5FB300FFFF00947552B86630417BB6DB43E1FF8F9B4027BFF888DEFF88D26DD6B4E93207AE613BD8EDBDC61F3BFFFF0E66FF7E4CFF60FFD32A1B51CCA5B7A55FEE4A9E67B40A296A51D170A9FF9C3AB7F67A368CEDE500741B65524678FFE000BDA4FFABD1D6C359139AC45100CAEE2DCE293FC5BB0D3270ACA4BA7EE858199FFF9F6BC4CD73D50004AA9D50FF76AB8B1032002F8A8D3C6B963E01DCDC0D34F7212E18CC7A881DFF4BE07CCE001FDFDD00FF872068A046A1D8D3FF51FF23728F659470BFE24C262A9A2964CBB5A48D383A14FF6EACD446FF595A6C13FFEBEC3975FFACECA1E60058006E00F9E88DFFD7B3A2A1009D00A2AC7E785EF445ED009AC3E90F6B9F00627376D849906050C7051F3CFF5EEF92CBA311FF054DEB2707007722000B5400A367B70E867F59B335221ACB00CE67FF3400FFB9002930641AB37D3100ED3F98A868673DC752F1CD8A0630FFA32A947A150017D943E49EFFD956B2B6003C3A90FFB78EF8CF4CFF6E4F0D66FFE2D5BBC2FA12415643FFAFE54ADECEA93C67FFFF42EECA9C65E2A88C472A1900749F25FFDE00FF1ACA7ED81231A33D77C200750000DA76B237ED346D9EC59666EE2077005AFF64A6C8D34863FFB5510036B1824D243F52AFA877B7226D2FBF4B5982FF8C2C0045003480D17773CF818FE921BB7296FFA18ED2520036FF54D52DF6A9D0C7E4AB3504CB15AF578FB78B7E965EAEDDFF5A9F52BCBFC27A7F26FF394FFFA332A9009D0FF8221A812660DEFFA5AA0AB7D579571296C63A7E636B40648BAC9100B657FF4B650D83409FDAFF80805A57FF50B4D800E78C5E46A200BB4B6DC7B4A67D1CF99F7427409F62FFEA3047143B00FFA11B6500CEA7E700A15F9E84FF303163D4FB1749030059FF7DEC8E25D3BD10FFB4FF001BD38CA8FF00D213004B41D6CA697E51A35339347700E38DC824B1EEB15265C49A8EF5BD0905A20025A940BD33FFDE5EFF7F5D871ED9608ABC00F2B3646D7B00E1FFD69A7D259F000076E3E289F32E00926FE56CFF0157BB97FFF745322A8D9D322C82BD501489BAD645BA877F19001A48CA83BB4B230000FF3E117C72710FB7DA32851D009E7B81B25A25B0E6470F9915CD5BBF7BDE43DE2BF6DDDBFFBC8436AB4E004D40567A09FF00009613FF6597D2797D8F8093C7F19B834F7F0023F915DF7CFF9700A3A9F3031361C102FFE25039AF4BCA028DFF809BA3B9FF2A6F02C24D00176DFF2C3F15743CC1638D0065B1FF2CA12E68AFF4BB000B0050DCED08239FE31F2EB3D2A1403A86000038983E16AD1E00AD531908FFC25CB4B321AC00CC7985C711D7441AD34F91FF582436006B845662F619E000C5EE55AF0026621C79A8EA73C2FFDCE1B1E07A5D2CF6A7CF4239F4B0FF79B29700E2EE3C1840B63FDF234E2BFFA46F9899A6A78DE567C8006F4B227BFFCC66B0C8BCEA353D5D6D00AF8F4BFF75183245166EAC0A8BA7B458C4B0DC95FFC375D9499FA57C9DBBDC6C4FCB6848F280727EDA5E21A8EAE13A984BCFEB6687135CBB45A80D6827B7FF7332C9560082474785005FFFCB15005D8C8FFF19FF03DF5F3F2600ACF40E33AA3CF77BED9C741E995E96AA29774270320022566880F05CFF9FB97600BD5EA2006DFFB47FA4FF7DD1921DFFFFFD0F00532F408A9A49FFC8001B187D4C6629E07D00098D5C64743EDE78680029FF8E6ED538C200A7E23F9194FFF202FFCE8AA03146C75B13FF7207AD9D9D904CFFB7FF39F72A46FB80AD8725FFE4DE4E9A88055241FFB8C56C989EFF008C84FF0A51EF000AE34ECD2A5CA6A5FFFB8D53D7DF3B1578FF64DD00982AA07EBE19FFF0863E425EFF009B83D359BF88B80087851584FAD49ED1B0B9AE42B38A43150013B2A5AEA446C52B16B129594F19BC9F008EAA067BFF025943DFA132609CC24062C60034376072CCBF6F5F6B14D94496D50BA937B9FF784D1BFF7A05006C60E4C480EA0037AB001B7CB33781B8F6EA4C79EE5D37C200932C3CC40FC2779DBFC2CFBAC29FF5A98588B8992900FF1E3F907F723CD900C8245FFF0036A3FF89FF0000AA0600967000DE2051AF88526AFACE0700FFC1285A00BC46001B315F210094B1008B7B008159004100009DC2292A422E388D8FFF4E3FC86D6990FF286B0871FFA639AD02A969564457EE7C7100B2A7314D64008AD293FB8B6E000DDFED409367BD5FE7FFA2B43054ABFA5D8942FF1EBCB6074DD7FD3DC077147E3B00B7014B4D400F619EEAFFFF20C200996BC6AA0060BF0038514F4B18DC53FFB200DCE7373163E99B50FFF8B404759CBEADCE9EBE1105B34DBECB12FEFB0000B4C1A22E1B72C955EA8A9C6E6C00DF6A6EEB57966ABAC634EE00FF2F711268805EC9479CF341974DAE55CCC0709700782EB81278828B6961FFBE44001D00722C12639248A3217DC79BA393DAFFDB8F0E84DA4B3705C73168C55C9D0D633F63CF10009E2F526A25F67880EA000090C453141E5600FC86FFDDD5419BACA19C58A20865B3C6C6AA8D125F835DFF8C5E00FF47003488D7CE0694390030938E3A762DFA3CF700AD108F9F7A64E6F3000A615C5FFFA037192F0D68413BF869C7D9E98A7662300097FF3186FF0000FF1637B4D9F6FFE44F7800C3E7CBFFFF3C60BFBB6E562388002CF16068BF4B933BCFBCCF03962ACCB3D7553B2D64B04C72D901B2907E476CFF4A17985D8E6C7F415082A48DA70519498658559C10771A3397AD0B8F9F9F8DF8BF896B8600FFB7CF00BB1EA493F70059B6EFFFD8B41F8BEBE883A641F5BAFF2130BB450017D7675A087300DFD7E46EA1FF262D8D9DC1AD560033E5FE009DD4D809090057FF6E54C95654FC6DA9DAB05A00FFFFC14F2F0022A7B993776FA2B130C5701207007B5CFF964AB219A6C923B1E05D7F623E000010008C00B6C249FCFFE492B08AF227A932C7EBC786FFDD61A38FAB1E8C631A842EDA64BEFFEA68F72400B88203A300BD778E5D00A83462F000939EEAC2BD43FFD7331D008CFFA5841268E1C400FF89005B2F714BAC00E3570C262D00E03C6FE7A36DB9CD3B64FF4D9504CF62B021476A67D94374AC62C6FF813FFF4CC5FF004700277A7600DD79D0B1FFB1B4D8B9BEB880B7E47A15004D74ECDA3861BED468BC2BFF30A21ED1B5795A7700E4AA00542600001C3E762C7C309F626A44D2A18CE9FFA4E0648877D1966FAA45007B2D7D63EF913D1BFF00B7006CD67AA3850B00301A336F4588E670DF8469755AC3985189F78F03C05C7D074900FF00E0E7C10CFFFFE30024496FCF002C971327620C6CB712833012AD1A74B2C6AF51C32420A703FD2E38F4C072AA988AA70095C8942B3640A21C9795DC8A0744C80909EDDE20BAAE9D2AAF00F25D90FF7588D5FF00D975C794FF2B0023D0A1E9FF66003EFFE2A16F93FF3D8B56C32D136700407A3B77B3E3CDDEC52500495A6700CDE22CFEF9FED6B800004DBA6BD63F99C9BCA07097375DFF6A704CE8FFF741C22576787BFFC0884AC396A46F658EC5E4C8C7B30778EC23B400B8A558FFC39CFFBBFF009881FF2A57FFF3000D7689003D534AA791B6FFA03F738BC545B694BF9F6F601EB0DC7D6DFF3AE921855A239F2669AC008AFF49FF31EF28E255BEA3C1FFA18EC0B18979809791D0F0760C931F33432A0000FF53800C2EE100C4BCACD1E75E22008777930C957EFFA434C11B10812B9722BD8FAEC3B2003CC16967791C00FF7C6C9E68A8329E875E1AC586AD1B54F0FF73FF00009D00FFB72F6EFF9C2B976DB551C7B2485518A0FFFF46715D0098150DBC7AE000E5F3D24C48BD67AF942B77AE6EEE84B65F3AC253BB5F755B9908B5812E6E80BE2AC248FF00718432609900A985372BB6880047821C9EA28EAFC14790FF38B8EFA9FFC245063F359E823CAA77140965ED831DD67C17A9B6FAFF8900FF7EB996FF84BBFF174153FFFF80E9C1FC806B45FB5FD74AFF7B9AF72C8016FF629440B3746F5413ED9DB7E8A6CB597062A634CFC67C289D6FB200F9886CA738E289A6D6EA7FC7FF2AA6DC82C325B5A67C68A8B59370AFFF49AF8FCA81A616F9008FBFFF83FF3E657DC643FF9AF1419A00DC99D9DFFFC0AAE14D4871FFF80400386FFF2A1CD2E19C986C45D48A4B4E235D8EB9C5DCF34CFF829295A6F5077CEE828E43099C53A3F2B6509E59BDB21A024E55FCC74A95BEC10027FFE840FFFF6FB76B276B55B500E9DCA8A66B0FFFFF1C104C43CEBD55AC4CC44C5B6759CDA61A9D97E88DCAB1E44BD05CCF4A947F9132FF091794A3AD1FAF0198D4D275FF00EAFA3C9826735000009EF9CA3891008DA5CB7A16A81E046A3F9C3F026778483783AF7730004F8875002CD32C173E72A2043DDE3BF7FFFF8E0028E5E200956C8245FF09BEF854CDDA2FFFD26F006D15A9FF3F9C69EA31C0B8674458FD6DEDB9F05E25475C25ED1B8400C96E40280BFE007FFA59FF5FC7D0FF4DA38396AFDE85FFA56D0000D47051272503B24DAFE73400A642FF505DFF918D16008719BBDC0F83FF65CD94ABB895FF63C01697FAE4E071003A54FF638600057454D06DFDD1C190B23852AD0000B0BE98949AA3862E37AA1FEE067A54005E4CADB6ABFF28BD009FB399A678FF00ADA50C3B720EDBC1726B5FD9A5D4CB4DAC4F4FA88B7F88EA518F1B7311334205DA0D00ABB931252FDD61B986CD2B16888523C74F6457C8DE7B9720A4B671FFC06A59FF2EF24EB71DB2D549E22A00B8BA9600CA86FF2D1A3D2B7F15228B0014250076B0F92EF15D27FC05BEA0C3F80076399B634349FFE6248CFAADB597552EFFDF28625CFFAE8F00CE7D442A36484CA4002B72B848670F32DB0A863DFF5CFFB3A2FE3888E517F29571009E001001DADD7620403191754E6CC35700FFBA7DCA846B677C205CADE700FF004AD17C69BB4C1500BD9B3F7AFFFF7E1EDDFFCBA71D917B824D2F2B33F3D72FF356E0215A95BDC8F2D1204F925800849E3E795442D691D6FFC5FF919C7732D2FFCC89AE9418ECB1BD6EB339550E5C4DEC48E0FECEFF9036FC0057AB469A7B6A9100004D3C0050A200B9FF64A19D4479AA82C1CB3EBDEF3F2D99830012A9643F3458DA73855905206BB270BE35687344E7FFAD00F600AF385947FF83C9CFB999B7FFDB007AFF00E646ED9DD11023A812440DFF2E7A70FF003F445EACB800FF6372F905CB628CFE9D391157450010A712093EEB0B77FF8DC864B1A45879FFFEFF001F894B2E69FF6600A069B42AF8006700489EB5AAAA78F3FFE5FFFF79696372CCB3837A9EC4B629BD460043AF67D5BEFBFFC4D0483247690008B6B4E05ADB4ACFC046A660DCB6A6AB9D9000FE2B5EAB70373D77B333B4FD4F560014CDD6AA431CBEA7B0520234A8DA084F88AA04639BCAD78C2D5400A055D200000003B4490400A3DB64A7B61CBF00A17C792E009B627AE31F56FF4D9ED3A1D4EC8E65BC6C49D526EB80174A39C191E1FFDE38A1616AB2250043B92F94451B9B7AFFA8364044E6E6D1E4C59B408F69DD4087FF003FDCDBA1FFE3269D65FF783ACBC2B7FFA1D475AD44B758F7FFD0770D40DE92FF11C34054FF2AFFA75E0E86FF80660765BD518E9230734A625BD6E7D4849DC2A2146773C58A1C9E3A7096BE650000B52C7CA17D66FF936213FF8E3861E8DCC7971BFF00FD00951B3CD99C45001EBBAD342E2D72183E133AEB52E9FFFF394CFFB3CBB6E90EFF60A76AFF51975CB55725A0E48DC57051C9B87C7C3E4D842BA788FFA413427484D2EDDBFFFFA7B4FC657478FFE0B47FAC8B11826DC4FDFF1DD954007995F20B35B9FB639681AA680022952EF3B77C6765812AFF8DC598B080009A27DBFE94ED00C2128878B1F265E678E8CBFF098E37FF65000700E1008700FC207B8E84AD373B4759FD20CB050338AA01C4BB0CA06E44FF75D200FF5A663E982D3819966B99C60625D67DDB98941B3DF70C84A3D5B89668D3DFA97330A4A3BAC347AFDF0DFF904684FF45005C5D3A8A2AC9FF00FF07D509CCFE18C27C00FF6D8B81A6AA9DFF98FFBCE58F2FCC507010B3A1E16B5564CAC2A737A369CCFF43B700BF5FB1FF6ADF51A882A099A163709EE81E5EFFFFB67EAC859590DAB3738A705BAEE7BC51FFCB9A75A4003900FFC529E3E885FF2B00FBFF0000661FFF3AC4474FFFFF003AE0FFE9E4B70E7A70F4FF2A44B23E3579DBBB90873EAF8C294DFB50ABAAD8CD3F4BF182E20110CA00C61983994496B4B300A7A29132004C0AFF5BE700FF8E7E18CAE180F0FF86008706850000FF0022F900E26398FFAD81001A867C9A408D00953A448CA0E27FAEFD6FAE8135714EFF033517DBFFFFFE6D00FF79EB49A83085F783A440DBFFE96A00E0FF69616F1B4B59A351A9E83821F593D85FBDA97930E14525AE80C63174E16B003B0690B99643DC4BE1278F73EA0F8E7FFF13FF7F5063BAFF4C006DFF250000D600AFFF7BC46F22CF4300024136FF288A711FC533B5FF4E612ADF8B72FF89078E8B8EFF7BFFBCB64D198F1C12F2E85DFF1AAB6AC2FFA76CB500C3F5543EC27F6F7614CAE33F937F0022045800DC0A96DAADCB0E68D072A18782156B0043B84D07431E77BFFFAB98B6734BB87785AB3570FFF714A4002700DFCFA3635C5C6AB546FF547A3B3B4663573E420D44555021D5638E680055AEE623738CD89B525D46A2C7D90000E2CA890019C6FFD2AA482900CBCC3C78000E360B49355B3495350DCA55A5DA007D4D22CBFF30E02C470000A7FF29FF6D00026EFFBB42C6DF008D605B58C500ECAA473A8CE545DF4C2335CCDD850D006055FF18FF429D3C16FFFF3200AD394CE4A7AE1E9756D4F6FFAC3375941D008451A1ABF69B4BA9568298C093194DFF7948FFD834FFB5E2CCA110598583049F87ECA900BAEB49C012C0B4867C58281F9EFF854610D04C00BE39FFFFDDEECA00BA0AB48DFE00147822FFFFAEB4724C933B1F9116140071405DF312F166A4FFCB09AB141B86DEA19015C2E2EDB746FFD2CCBE8C1E5C00CE75AB009300622971DF29477ED100C66A230078AED1A771718CDFFFC8C3746773A9D73E036DC2D5BB62CDD1615CCC058CE77234407CFF008AFF37317842206CF447FFB39AEB904CCEB38A9959C7143D3E2DA8860B00FF8D73EF633B7B8BD8C83ACD50892047E9C7A73031C4F025EE03F7A8C3504DC0AAD3209F1615A243FF7BD7588EA925FFA6FF3597B0C1A60D30E26CB341E1275730C8FFA694CD46977073AF32C53875EF49464CC022FF80E92AAD0B260300A630502CA1E14166545FBEDEA6F4FFFF4A005F773A7ABA7034319D725DFF7E76614182C9B3967B6000BA0010BDFF99DB2C001AFFFF2F754C8F31E5C1CB4E00075D5DD7462300E37875080A49636BA290F20055922FA4B6C17759CD6E0E0346B2005F7D2604B03CDFED4BA6D2158F31D3D9007BA146C37ED693C5E0309F9A720063985C539E788BD61D100041DFC7CC672EC6329C6B08E1006973A8286540F752FF7F8C8BFFFFB0A8B4015C59EB968D002039642505034EC0423085C3FF82FFE23EEF3382605F8ECE6F704F49FDFF40FF985EFF07BF80B49E10E135A928A853932036D9967E8E8545607EFF617A060045FFFFB5C8FF410757A52C330C634F47AF3BA80C5A8EC151D300685A4D8D7000BCAB30377016867E3F07FF865F9B00A33913BCAC2B7DF01D00008E2273FF0C76276A4BB75E8DF431008466C87E3BBA95D4A5746673756994AD000080FCB531A590BA00008D49B164009D23B6436181991D596CDE8500210046FF5D97114B723BF3FF4CC15EE53B8D52FB7FCE5A1E50D6F7FF0096C17360003791FF31D376431498BA00B462CDE3C1B67F827B006E6D06FFD9FF0E0000003337002272DC00351625FF62E754CA4D09FFFF71A58753B7A0BDA6BC71A007D40584130E00A739AC9F3E00309B2DAD7168D8E3FF199AC140DCFF3F1CF0FA00889C8F6163F82644C7610079FF90AD295AFF0AFF970403309FF55E637C6074A08F77389C5CABADD1B08766593F35A8C530 diff --git a/tests/host/modPlayerHost.c b/tests/host/modPlayerHost.c index 6cd1519..8031732 100644 --- a/tests/host/modPlayerHost.c +++ b/tests/host/modPlayerHost.c @@ -51,6 +51,7 @@ #define PERIOD_C3 214u #define PERIOD_B3 113u #define RENDER_SECONDS 3u +#define RENDER_MONO 1u // one channel a frame #define RENDER_SAMPLES (TEST_RATE * RENDER_SECONDS) #define FUZZ_RUNS 300u #define FUZZ_SAMPLES (TEST_RATE * 2u) @@ -68,6 +69,7 @@ static void testEnds(void); static void testFuzz(void); static void testProtracker(void); static void testRender(void); +static void testRenderHalf(void); static void testTables(const char *path); // ----- Module state ----- @@ -247,7 +249,7 @@ static void testFuzz(void) { // Lengths, loops and the file's own length cut short as they come. if (modPlayerStart(gMod, sizeof(gMod) - (run % 2u) * (RAMP_BYTES / 2u), TEST_RATE, (run & 2u) != 0u)) { started++; - (void)modPlayerRenderU8(gRenderA, FUZZ_SAMPLES); + (void)modPlayerRenderU8(gRenderA, FUZZ_SAMPLES, RENDER_MONO); modPlayerStop(); } if (modPlayerStart(gMod, sizeof(gMod), TEST_RATE, true)) { @@ -390,6 +392,50 @@ static void testProtracker(void) { } +// The half unit (modPlayerSetU8Shift): one channel at full volume swings half +// the range; four in phase pass a byte, so the mono render clamps them where +// adding them straight would wrap, and a stereo side's two still fit. +static void testRenderHalf(void) { + char detail[160]; + uint32_t i; + uint8_t low; + uint8_t high; + bool ok; + + modPlayerSetU8Shift(U8_SHIFT_HALF); + modNew(1u); + modNote(0u, 0u, 0u, SAMPLE_SQUARE, PERIOD_C2, 0x0u, 0x00u); + (void)modPlayerStart(gMod, sizeof(gMod), TEST_RATE, true); + (void)modPlayerRenderU8(gRenderA, TEST_RATE, RENDER_MONO); + low = 0xFFu; + high = 0u; + for (i = 0u; i < TEST_RATE; i++) { + low = gRenderA[i] < low ? gRenderA[i] : low; + high = gRenderA[i] > high ? gRenderA[i] : high; + } + snprintf(detail, sizeof(detail), "a full-volume square swings %u..%u (expected %u..%u)", low, high, U8_BIAS + (SQUARE_LOW >> U8_SHIFT_HALF), U8_BIAS + (SQUARE_HIGH >> U8_SHIFT_HALF)); + check("8-bit level, half", low == U8_BIAS + (SQUARE_LOW >> U8_SHIFT_HALF) && high == U8_BIAS + (SQUARE_HIGH >> U8_SHIFT_HALF), detail); + for (i = 0u; i < MOD_CHANNELS; i++) { + modNote(0u, 0u, (uint8_t)i, SAMPLE_SQUARE, PERIOD_C2, 0x0u, 0x00u); + } + (void)modPlayerStart(gMod, sizeof(gMod), TEST_RATE, true); + (void)modPlayerRenderU8(gRenderA, TEST_RATE, RENDER_MONO); + ok = true; + for (i = 0u; i < TEST_RATE; i++) { + ok = ok && (gRenderA[i] == 0u || gRenderA[i] == U8_BIAS || gRenderA[i] == UINT8_MAX); + } + check("8-bit clamp, half", ok, "four channels in phase clamp to 0 and 255 in mono, never wrap"); + (void)modPlayerStart(gMod, sizeof(gMod), TEST_RATE, true); + (void)modPlayerRenderU8(gRenderB, TEST_RATE / S16_STEREO, (uint8_t)S16_STEREO); + ok = true; + for (i = 0u; i < TEST_RATE; i++) { + ok = ok && (gRenderB[i] == 0u || gRenderB[i] == U8_BIAS || gRenderB[i] == U8_BIAS + 2u * (SQUARE_HIGH >> U8_SHIFT_HALF)); + } + check("8-bit sides, half", ok, "two channels in phase fill a stereo side, 0 to 254"); + modPlayerSetU8Shift(U8_SHIFT_QUARTER); +} + + static void testRender(void) { char detail[160]; uint32_t i; @@ -406,15 +452,15 @@ static void testRender(void) { modNew(1u); modNote(0u, 0u, 0u, SAMPLE_SQUARE, PERIOD_C2, 0x0u, 0x00u); (void)modPlayerStart(gMod, sizeof(gMod), TEST_RATE, true); - (void)modPlayerRenderU8(gRenderA, TEST_RATE); + (void)modPlayerRenderU8(gRenderA, TEST_RATE, RENDER_MONO); low = 0xFFu; high = 0u; for (i = 0u; i < TEST_RATE; i++) { low = gRenderA[i] < low ? gRenderA[i] : low; high = gRenderA[i] > high ? gRenderA[i] : high; } - snprintf(detail, sizeof(detail), "a full-volume square swings %u..%u (expected %u..%u)", low, high, U8_BIAS + (SQUARE_LOW >> U8_SHIFT), U8_BIAS + (SQUARE_HIGH >> U8_SHIFT)); - check("8-bit level", low == U8_BIAS + (SQUARE_LOW >> U8_SHIFT) && high == U8_BIAS + (SQUARE_HIGH >> U8_SHIFT), detail); + snprintf(detail, sizeof(detail), "a full-volume square swings %u..%u (expected %u..%u)", low, high, U8_BIAS + (SQUARE_LOW >> U8_SHIFT_QUARTER), U8_BIAS + (SQUARE_HIGH >> U8_SHIFT_QUARTER)); + check("8-bit level", low == U8_BIAS + (SQUARE_LOW >> U8_SHIFT_QUARTER) && high == U8_BIAS + (SQUARE_HIGH >> U8_SHIFT_QUARTER), detail); // Channel 0 is on the left alone, channel 1 on the right. (void)modPlayerStart(gMod, sizeof(gMod), TEST_RATE, true); (void)modPlayerRenderS16(gRenderS16, TEST_RATE, (uint8_t)S16_STEREO); @@ -433,9 +479,9 @@ static void testRender(void) { modNote(0u, 0u, (uint8_t)i, (uint8_t)(i % 2u ? SAMPLE_RAMP : SAMPLE_SQUARE), kPeriods[0][i * 5u], (uint8_t)(i == 2u ? 0x4u : 0x0u), (uint8_t)(i == 2u ? 0x46u : 0x00u)); } (void)modPlayerStart(gMod, sizeof(gMod), TEST_RATE, true); - (void)modPlayerRenderU8(gRenderA, RENDER_SAMPLES); + (void)modPlayerRenderU8(gRenderA, RENDER_SAMPLES, RENDER_MONO); (void)modPlayerStart(gMod, sizeof(gMod), TEST_RATE, true); - (void)modPlayerRenderS16(gRenderS16, RENDER_SAMPLES, 1u); + (void)modPlayerRenderS16(gRenderS16, RENDER_SAMPLES, RENDER_MONO); dot = 0.0; xx = 0.0; yy = 0.0; @@ -451,7 +497,7 @@ static void testRender(void) { for (run = 1u; run <= 1000u; run = run == 1u ? 7u : 1000u) { (void)modPlayerStart(gMod, sizeof(gMod), TEST_RATE, true); for (done = 0u; done < RENDER_SAMPLES; done += run) { - (void)modPlayerRenderU8(gRenderB + done, done + run <= RENDER_SAMPLES ? run : RENDER_SAMPLES - done); + (void)modPlayerRenderU8(gRenderB + done, done + run <= RENDER_SAMPLES ? run : RENDER_SAMPLES - done, RENDER_MONO); } ok = ok && memcmp(gRenderA, gRenderB, RENDER_SAMPLES) == 0; if (run == 1000u) { @@ -463,7 +509,7 @@ static void testRender(void) { modNew(1u); modNote(0u, 0u, 0u, SAMPLE_SQUARE, PERIOD_C2, 0xFu, 0x01u); (void)modPlayerStart(gMod, sizeof(gMod), TEST_RATE, false); - ok = !modPlayerRenderU8(gRenderA, RENDER_SAMPLES) && !modPlayerActive(); + ok = !modPlayerRenderU8(gRenderA, RENDER_SAMPLES, RENDER_MONO) && !modPlayerActive(); for (i = RENDER_SAMPLES - TEST_RATE; i < RENDER_SAMPLES; i++) { ok = ok && gRenderA[i] == MOD_PLAYER_SILENCE_U8; } @@ -527,6 +573,7 @@ int main(int argc, char **argv) { testProtracker(); testEnds(); testRender(); + testRenderHalf(); testTables(argc > 1 ? argv[1] : NULL); testFuzz(); printf("MODPLAYER %u/%u\n", gPassed, gChecks); diff --git a/tests/host/saveHost.c b/tests/host/saveHost.c index 5225800..07531e9 100644 --- a/tests/host/saveHost.c +++ b/tests/host/saveHost.c @@ -43,6 +43,11 @@ bool jlpSaveDelete(const char *path) { } +bool jlpSaveRename(const char *from, const char *to) { + return rename(from, to) == 0; +} + + bool jlpSaveDirEnsure(const char *dir) { struct stat st; diff --git a/tests/host/serialHost.c b/tests/host/serialHost.c index 163b90d..ec2ce89 100644 --- a/tests/host/serialHost.c +++ b/tests/host/serialHost.c @@ -22,6 +22,11 @@ #define MOCK_FIFO_SIZE 256u +// A wait on a busy transmitter whose clock never runs ends at its poll limit. +#define STALL_POLL_LIMIT 1000u +// At a millisecond a poll, the wait reads the clock first at the 64th poll and +// ends at the first read 100 ms or more after that: the 192nd. +#define STALL_POLLS_TIMED 192u // ----- Mock HAL state (the loopback the core talks to) ----- @@ -36,10 +41,16 @@ static bool gMockRts = true; // last RTS level the core hand static uint16_t gMockRtsCalls = 0u; static bool gMockDtr = true; // last DTR level the core handed the HAL static uint16_t gMockDtrCalls = 0u; +static uint32_t gMockMs = 0u; // the clock serialTxStalled reads // ----- Mock HAL (satisfies the jlpSerial* dispatch under -DJL_HAS_SERIAL_*) ----- +uint32_t jlpMillisElapsed(void) { + return gMockMs; +} + + uint16_t jlpSerialAvailable(void) { return (uint16_t)(gFifoTail - gFifoHead); } @@ -152,7 +163,9 @@ static jlSerialConfigT makeConfig(uint32_t baud, uint8_t dataBits, uint8_t stopB int main(void) { jlSerialConfigT cfg; + SerialTxWaitT wait; uint8_t buf[16]; + uint32_t polls; int16_t b; printf("serial-host: core gate/normalize/loopback\n"); @@ -247,7 +260,20 @@ int main(void) { check("open: closed after HAL failure", jlSerialWriteByte('x') == false); gMockOpenResult = true; - // 10. A port left open is closed at exit (checked by checkClosedAtExit). + // 10. A busy transmitter's wait (serialTxStalled): a clock that does not + // run ends it at the poll limit, a running one 100 ms on. + gMockMs = 0u; + serialTxWaitStart(&wait, STALL_POLL_LIMIT); + for (polls = 1u; !serialTxStalled(&wait); polls++) { + } + check("tx stall: a stopped clock ends the wait at the poll limit", polls == STALL_POLL_LIMIT); + serialTxWaitStart(&wait, UINT32_MAX); + for (polls = 1u; !serialTxStalled(&wait); polls++) { + gMockMs++; + } + check("tx stall: a running clock ends it 100 ms after its first read", polls == STALL_POLLS_TIMED); + + // 11. A port left open is closed at exit (checked by checkClosedAtExit). cfg = makeConfig(9600u, 8u, 1u); check("left open for the exit check", jlSerialOpen(JL_SERIAL_DEFAULT, &cfg) == true); diff --git a/tools/gradeAudioConformance.py b/tools/gradeAudioConformance.py index 6447e52..7ef40fc 100644 --- a/tools/gradeAudioConformance.py +++ b/tools/gradeAudioConformance.py @@ -19,10 +19,28 @@ it, and measures it: the pitch rises everywhere, and its steps 2 dB apart sfx a sine of 8 samples a cycle sounds rate / 8 Hz, starting within SFX_LATENCY_MAX of the call; panned left or right - it is in one channel (--stereo ports) + it is in one channel (--stereo ports); and the mix: a + full-scale sample peaks where a voice at attenuation 0 + does, so the sine sounds SFX_VOICE_DB against voice 0 + (VOICE_MATCH_DB), each by its louder channel on a + --stereo port (the Amiga's voices play on one side), the + sine's level the median of the BALANCE_RATE effects + (centre, left, right): one sound an emulator spoils does + not decide it (MAME 0.264 clocks the X68000's MSM6258 at + 4 MHz, not the 8 its CT1 pin says, until the first write + to the OPM's CT1, so the first ADPCM sound after boot + decodes every other nibble) + stream the same sine streamed, plain and raw (in the port's raw + format), likewise; the raw one as clean as the plain one + (RAW_PURE_SHARE of its power at the pitch) + mod stop a module that loops sounds until jlAudioStopMod, and is + silent from SFX_LATENCY_MAX after it mod a module that does not loop sounds, then ends, and jlAudioIsPlayingMod goes false within MOD_END_SLACK of - its sound stopping + its sound stopping; and the mix: AUDPAR's module plays + one channel of a full-scale square at full volume, as + loud as voice 0 at attenuation 0 (MOD_VOICE_DB, + VOICE_MATCH_DB), by the louder channel on a --stereo port tick the callbacks that came, against hz * time silence every gap is quiet: nothing left sounding @@ -84,15 +102,27 @@ PAN_CENTRE_DB = 3.0 CENTRE_CHECK_HZ = 440.0 # the tone whose stereo position is graded NOISE_LOUDNESS_PITCH = 31 # the noise compared with voice 0 for loudness MOD_END_SLACK_S = 0.6 +MOD_STOP_LOOK_S = 0.2 # how long before the stop, and after it, a stopped module is heard SFX_LATENCY_MAX = 0.050 # a sound effect starts this soon after the call ONSET_SEARCH_S = 1.0 +PURE_BAND = 0.10 # of the pitch: the band a pure tone's power is in +RAW_PURE_SHARE = 0.75 # a raw stream keeps at least this share of the plain one's purity ONSET_BAND = 0.08 # of the pitch: the band an onset is heard in ONSET_SHARE = 0.5 MOD_LEAD_S = 0.1 TICK_SHARE = 0.03 +TICK_SLACK = 0.25 # of a call: the window ends a moment after its last tick is due FULL_SCALE = 32768.0 LEVEL_FRAME_S = 0.020 -SFX_SAMPLES = 4000 # AUDPAR's sine: its length at each rate +SFX_SAMPLES = 4000 # AUDPAR's sine, when its log line does not say +SINE_CYCLE = (0, 71, 100, 71, 0, -71, -100, -71) # AUDPAR's kSineCycle +SAMPLE_FULL_SCALE = 127.0 # a signed 8-bit sample's full swing +BALANCE_RATE = '8000' # the sound effects weighed against a voice +MOD_VOICE_DB = 0.0 # assets/test.mod's square against a voice's +MOD_LEVEL_S = 2.0 # how much of the module's start is weighed +# The canonical mix: AUDPAR's sine, peaking under full scale, sounds this +# far under a full-swing square of the same peak (a voice at attenuation 0). +SFX_VOICE_DB = 20.0 * math.log10(math.sqrt(sum(s * s for s in SINE_CYCLE) / len(SINE_CYCLE)) / SAMPLE_FULL_SCALE) class Grader: @@ -199,6 +229,19 @@ def pitchOf(x, rate): return (peak + offset) * rate / size +def purityOf(x, rate, hz): + """The share of a tone's power within PURE_BAND of its pitch: a sine + played as it was made keeps nearly all of it; one whose bytes were + taken in the wrong format (signed for unsigned) spreads it into + harmonics and a DC step.""" + if len(x) < 256: + return 0.0 + spectrum = np.abs(np.fft.rfft((x - x.mean()) * np.hanning(len(x)))) ** 2 + freqs = np.fft.rfftfreq(len(x), 1.0 / rate) + band = (freqs >= hz * (1.0 - PURE_BAND)) & (freqs <= hz * (1.0 + PURE_BAND)) + return float(spectrum[band].sum() / max(spectrum.sum(), 1e-12)) + + def noiseRate(x, rate, expected): """The noise's sample-and-hold rate, read off its first spectral null: noise held at R Hz has a sinc^2 spectrum that first falls to nothing at @@ -291,16 +334,27 @@ def main(): nextStart = events[i + 1][0] if i + 1 < len(events) else event[0] steps.append((event[0], nextStart, event[1], event[2])) + def loudest(start, end, measure, shift=0.0): + """A step's level in its louder channel on a stereo port, else the + mix's.""" + if not args.stereo: + return measure(cut(start, end, shift=shift)) + return max(measure(cut(start, end, c, shift)) for c in (0, 1)) + def centred(start, end, name): left = dbfs(cut(start, end, 0)) right = dbfs(cut(start, end, 1)) grader.check(f"{name} centred", abs(left - right) <= PAN_CENTRE_DB, f"left {left:.1f} dBFS, right {right:.1f} dBFS") lag = 0.0 # the sample path's latency, from the last sfx + plainPure = None # the plain stream's purity, for the raw one's toneLevels = [] voiceLevels = {} noiseLevels = {} noiseCentroids = [] + balanceVoice = None # voice 0 at attenuation 0, and the sound effects + balanceSfx = [] # weighed against it, by loudest() + balanceMod = None for start, end, kind, values in steps: if kind in ('tone', 'sync'): hz = float(values[0]) @@ -319,6 +373,8 @@ def main(): if args.stereo and atten == 0: centred(start, end, f"voice {voice}") voiceLevels[(voice, atten)] = level + if voice == 0 and atten == 0: + balanceVoice = loudest(start, end, lambda y: steadyDbfs(y, rate)) if atten <= 8: got = pitchOf(x, rate) grader.check(f"voice {voice} {int(hz)} Hz atten {atten}", abs(got / hz - 1.0) <= PITCH_TOLERANCE, f"{got:.1f} Hz at {level:.1f} dBFS") @@ -337,17 +393,33 @@ def main(): else: grader.check(f"noise {pitch} sounds", True, f"{level:.1f} dBFS (its rate, {expected:.0f} Hz, is graded by the darkening below)") noiseLevels[(pitch, atten)] = level - elif kind == 'sfx': + elif kind in ('sfx', 'stream'): hz, pan = float(values[2]), int(values[3]) - length = SFX_SAMPLES / float(values[1]) + # The sine's samples follow the pan (a stream's after its raw + # flag), when the log gives them. + samplesAt = 4 if kind == 'sfx' else 5 + samples = int(values[samplesAt]) if len(values) > samplesAt else SFX_SAMPLES + length = samples / float(values[1]) panName = {0: 'left', 1: 'centre', 2: 'right'}.get(pan, str(pan)) + # A stream's name says so, and whether it went raw. + what = 'sfx' if kind == 'sfx' else ('raw stream' if len(values) > 4 and values[4] == '1' else 'stream') found = onset(start, hz) - grader.check(f"sfx {values[1]} Hz {panName} starts", found is not None and found <= SFX_LATENCY_MAX, f"{found * 1000:.0f} ms after the call" if found is not None else "never") + grader.check(f"{what} {values[1]} Hz {panName} starts", found is not None and found <= SFX_LATENCY_MAX, f"{found * 1000:.0f} ms after the call" if found is not None else "never") lag = found if found is not None else 0.0 x = cut(start, start + length, shift=lag) got = pitchOf(x, rate) level = dbfs(x) - grader.check(f"sfx {values[1]} Hz {panName}", abs(got / hz - 1.0) <= PITCH_TOLERANCE and level > SOUND_DBFS, f"{got:.1f} Hz ({hz:.0f} asked) at {level:.1f} dBFS") + pure = purityOf(x, rate, hz) + grader.check(f"{what} {values[1]} Hz {panName}", abs(got / hz - 1.0) <= PITCH_TOLERANCE and level > SOUND_DBFS, f"{got:.1f} Hz ({hz:.0f} asked) at {level:.1f} dBFS, {pure * 100:.1f}% at its pitch") + # The raw stream's bytes in the wrong format sound at the same + # pitch, but spread into harmonics: it must be about as clean + # as the plain stream through the same path. + if what == 'stream': + plainPure = pure + elif what == 'raw stream' and plainPure is not None: + grader.check("raw stream as clean as the plain one", pure >= plainPure * RAW_PURE_SHARE, f"{pure * 100:.1f}% at its pitch, the plain stream {plainPure * 100:.1f}%") + if what == 'sfx' and values[1] == BALANCE_RATE: + balanceSfx.append(loudest(start, start + length, dbfs, lag)) if data.shape[1] >= 2: left = dbfs(cut(start, start + length, 0, lag)) right = dbfs(cut(start, start + length, 1, lag)) @@ -372,8 +444,11 @@ def main(): grader.check('mod', False, "no mod end in the log") continue endAt, endValues = endEvents[0][0], endEvents[0][2] + balanceMod = loudest(start, min(start + MOD_LEVEL_S, endAt), lambda y: steadyDbfs(y, rate)) stillPlaying = len(endValues) > 1 and endValues[1] != '0' - x = cut(start, endAt + 1.0) + # Listened to a second past the end, or up to whatever sounds next. + later = [e[0] for e in events if e[0] > endAt and e[1] != 'silence'] + x = cut(start, min([endAt + 1.0] + later)) frames = int(FRAME_S * rate) loud = [dbfs(x[i:i + frames]) > SOUND_DBFS for i in range(0, len(x) - frames, frames)] sounded = any(loud) @@ -382,10 +457,15 @@ def main(): grader.check('mod sounds', sounded, f"{sum(loud) * FRAME_S:.1f} s of it above {SOUND_DBFS:.0f} dBFS") grader.check('mod ends', not stillPlaying and endAt - start < 11.5, f"jlAudioIsPlayingMod false {endAt - start:.2f} s after the start") grader.check('mod end reported', -MOD_LEAD_S <= endAt - soundEnd <= MOD_END_SLACK_S, f"its sound stopped at {soundEnd - start:.2f} s, reported at {endAt - start:.2f} s") + elif kind == 'mod' and values and values[0] == 'stop': + before = dbfs(cut(start - MOD_STOP_LOOK_S, start)) + after = steadyDbfs(cut(start + SFX_LATENCY_MAX, start + SFX_LATENCY_MAX + MOD_STOP_LOOK_S), rate) + grader.check('mod sounds until its stop', before > SOUND_DBFS, f"{before:.1f} dBFS before jlAudioStopMod") + grader.check('mod stops', after < SILENCE_DBFS, f"{after:.1f} dBFS from {SFX_LATENCY_MAX * 1000:.0f} ms after jlAudioStopMod") elif kind == 'tick': hz, ok, calls, ms = int(values[0]), values[1] == '1', int(values[2]), int(values[3]) expected = hz * ms / 1000.0 - good = ok and abs(calls - expected) <= max(1.0, TICK_SHARE * expected) + good = ok and abs(calls - expected) <= max(1.0, TICK_SHARE * expected) + TICK_SLACK grader.check(f"tick {hz} Hz", good, f"registered {'yes' if ok else 'no'}, {calls} calls in {ms} ms, {expected:.0f} expected") if toneLevels: @@ -405,6 +485,12 @@ def main(): # on the PSG every port's voices are modelled on. if (NOISE_LOUDNESS_PITCH, 0) in noiseLevels: grader.check("noise as loud as a voice", abs(noiseLevels[(NOISE_LOUDNESS_PITCH, 0)] - base) <= VOICE_MATCH_DB, f"{noiseLevels[(NOISE_LOUDNESS_PITCH, 0)]:.1f} against {base:.1f} dBFS") + if balanceVoice is not None and balanceSfx: + apart = float(np.median(balanceSfx)) - balanceVoice + grader.check("sfx as loud as a voice", abs(apart - SFX_VOICE_DB) <= VOICE_MATCH_DB, f"{apart:+.1f} dB against voice 0, {SFX_VOICE_DB:+.1f} asked (a full-scale sample peaks where the voice does)") + if balanceVoice is not None and balanceMod is not None: + apart = balanceMod - balanceVoice + grader.check("mod as loud as a voice", abs(apart - MOD_VOICE_DB) <= VOICE_MATCH_DB, f"{apart:+.1f} dB against voice 0, {MOD_VOICE_DB:+.1f} asked (a module channel at full volume peaks where the voice does)") stepPitch = None for (pitch, atten) in noiseLevels: if atten > 0: