From 1a7c7ec05a8b82d3b3cb561b884f472a795574be Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Thu, 24 Sep 2026 22:01:56 -0500 Subject: [PATCH] X68k, ST, and Amiga optimizations. --- examples/spacetaxi/assets/genSpeechPcm.py | 27 +- examples/spacetaxi/spacetaxi.c | 98 ++- examples/spacetaxi/spacetaxi.h | 18 +- examples/spacetaxi/stAudio.c | 142 ++++- examples/spacetaxi/stCels.c | 25 + examples/spacetaxi/stCels.h | 4 + examples/spacetaxi/stRender.c | 125 +++- make/amiga.mk | 10 +- make/atarist.mk | 18 +- make/dos.mk | 10 +- make/iigs.mk | 45 +- make/x68000.mk | 20 +- scripts/bench-x68000.sh | 172 ++++++ scripts/captureDos.sh | 19 +- scripts/make-iigs-game-disk.sh | 208 +++++++ scripts/profile-x68000.sh | 302 +++++++++ scripts/run-amiga-headless.sh | 107 ++++ scripts/run-atarist-headless.sh | 83 +++ scripts/verify-iigs-game-disk.sh | 117 ++++ scripts/verify-x68000-audio.sh | 219 +++++++ src/amiga/amigaPlanar.h | 4 + src/amiga/audio.c | 23 +- src/amiga/circle.s | 270 -------- src/amiga/hal.c | 228 ++----- src/atarist/hal.c | 9 +- src/atarist/superExit.s | 43 ++ src/dos/audio.c | 24 +- src/iigs/audio_full.c | 19 +- src/m68k/planarFill.s | 503 +++++++++++++++ src/m68k/planarPlot.s | 428 +++++++++++++ src/m68k/surface68k.s | 401 ------------ src/x68000/audio.c | 22 +- src/x68000/audioPcm.c | 163 ++++- src/x68000/hal.c | 171 ++++- src/x68000/halPlanar.c | 719 +++++++++------------- src/x68000/serial.c | 8 +- tools/staxiharness/harness.c | 2 +- 37 files changed, 3393 insertions(+), 1413 deletions(-) create mode 100755 scripts/bench-x68000.sh create mode 100755 scripts/make-iigs-game-disk.sh create mode 100755 scripts/profile-x68000.sh create mode 100755 scripts/run-amiga-headless.sh create mode 100755 scripts/run-atarist-headless.sh create mode 100755 scripts/verify-iigs-game-disk.sh create mode 100755 scripts/verify-x68000-audio.sh delete mode 100644 src/amiga/circle.s create mode 100644 src/atarist/superExit.s create mode 100644 src/m68k/planarFill.s create mode 100644 src/m68k/planarPlot.s delete mode 100644 src/m68k/surface68k.s diff --git a/examples/spacetaxi/assets/genSpeechPcm.py b/examples/spacetaxi/assets/genSpeechPcm.py index e707924..8943a2e 100755 --- a/examples/spacetaxi/assets/genSpeechPcm.py +++ b/examples/spacetaxi/assets/genSpeechPcm.py @@ -14,15 +14,18 @@ output level running from one utterance into the next, so a phrase whose first word ends on the low level plays its second word inverted. Both versions are stored and the player picks by the running parity. speech.bin (little-endian): - "STSP" u8 version(2) u8 pitchBase u8 pitchCount u8 count + "STSP" u8 version(3) u8 pitchBase u8 pitchCount u8 count u8 format (0 = signed +/-100, 1 = 0x80-biased for the IIgs raw stream) - u8 pad[3] u32 dataBytes - entries[pitchCount * count * 2]: u8 code, u8 parity, u16 samples, - u32 offset -- polarity is the innermost index (0 = as decoded, - 1 = inverted). parity = 1 when the utterance ends on the - opposite level from the one it started on. + u8 polarities (1 or 2) u8 pad[2] u32 dataBytes + entries[pitchCount * count * polarities]: u8 code, u8 parity, + u16 samples, u32 offset -- polarity is the innermost index + (0 = as decoded, 1 = inverted). parity = 1 when the utterance + ends on the opposite level from the one it started on. data: sample bytes -usage: genSpeechPcm.py stSpeechData.c speech.bin [--biased] +With --single only polarity 0 is written, halving the file; the player +negates the samples itself when the running parity calls for it. The +X68000 needs that: its Human68k floppy cannot spare the other 60 KB. +usage: genSpeechPcm.py stSpeechData.c speech.bin [--biased] [--single] """ import re import struct @@ -77,6 +80,8 @@ def decode(rle, perOn): def main(): args = [a for a in sys.argv[1:] if not a.startswith("--")] biased = "--biased" in sys.argv[1:] + single = "--single" in sys.argv[1:] + polarities = 1 if single else 2 src, dst = args[0], args[1] entries, data = parseSource(src) records = [] @@ -86,7 +91,7 @@ def main(): for code, off, length in entries: samples, level = decode(data[off:off + length], perOn) parity = 1 if level < 0 else 0 - for pol in range(2): + for pol in range(polarities): pcm = bytearray() for s in samples: v = -s if pol else s @@ -94,12 +99,14 @@ def main(): records.append((code, parity, len(samples), len(blob))) blob += pcm with open(dst, "wb") as f: - f.write(b"STSP" + struct.pack(" ST_FATAL_ARM_FRAMES && (joyMask() & 0x1Fu) != 0u) { + return; + } + now = (uint8_t)jlFrameCount(); + held = (uint16_t)(held + (uint8_t)(now - last)); + last = now; + jlStagePresent(); + } +} + + +// An asset the game ships with will not load. Say so on screen and stop: +// a mute game, or one running every sprite through the interpreter, reads +// as a slow machine rather than a broken install, and that is worse than +// not starting. +static void fatalStop(const char *line1, const char *line2) { + ST_LOG("spacetaxi: ! FATAL -- %s / %s", line1, line2); + ST_LOG_FLUSH(); + stAudioSilence(); + stAudioNoise(false); + jlMusicStop(); + stRenderFatal(gStage, line1, line2); + gGame.fatal = true; +} + + // $5FBB -- "GAME OVER!" over the play field, sprites hidden. static void gameOverEnter(void) { stSimDrawText(&gSim, 14u, 10u, kTextBlank12, 1u); @@ -505,8 +561,7 @@ static bool loadLevel(uint8_t index) { ST_LOG("spacetaxi: ! cannot load %s", path); return false; } - stRenderLevelCels(gStage, &gLevel); - return true; + return stRenderLevelCels(gStage, &gLevel); } @@ -842,12 +897,25 @@ static void startDemoScreen(void) { (void)stSimAdvancePlayer(&gSim); gSim.levelState++; if (!loadLevel(e->levelIndex)) { - enterTitle(); + fatalStop(kTextFatalLoad, kTextFatalLevel); return; } + // $40CA: blank the character screen first. Without this the two + // green lines land on top of the title, which is still up -- logo, + // byline, copyright and all -- where the original shows them on a + // cleared screen (observed on real hardware, 2026-09-23). Same + // clear stIntroEnter does, and it leaves the sprite mask alone so + // the line below still keeps sprites 0..2. + memset(gSim.screen, ST_CHAR_SPACE, ST_SCREEN_CELLS); + memset(gSim.color, 0, ST_SCREEN_CELLS); + stSimDirtyAll(&gSim); + stSimGlyphReset(&gSim); + gSim.charDirtyAll = true; + gSim.charDirtyCount = 0u; stSimDrawText(&gSim, 10u, 8u, kTextGetReady, 5u); stSimDrawText(&gSim, 10u, 10u, kTextOnTheTaxi, 5u); gSim.frame.enableMask &= 0x07u; + stRenderSceneChanged(&gSim); gGame.waitTicks = ST_GET_READY_TICKS; gGame.state = ST_STATE_GET_READY; } @@ -881,7 +949,7 @@ static void startNewScreen(void) { } gGame.pendingLevel = next; if (!loadLevel(next)) { - enterTitle(); + fatalStop(kTextFatalLoad, kTextFatalLevel); return; } gSim.levelState++; @@ -931,21 +999,31 @@ int main(void) { memset(&gSim, 0, sizeof(gSim)); stRenderInit(stage); stRenderLoading(stage); - stAudioInit(); + if (!stAudioInit()) { + fatalStop(kTextFatalMemory, kTextFatalSpeech); + } // Boot: the playback buffer as the C64 leaves it, and the state a // fresh machine has when the first title intro starts ($4092 + the // title's own sparkle/walk history from the dump). stSimNewGame(&gSim, 1u, false); stSimSeedDemoBuffer(&gSim, kDemoBufferInit, ST_DEMO_BUFFER_BYTES); gSim.waveIdx = 3u; - stRenderPrewarm(stage, &gSim); + if (!gGame.fatal && !stRenderPrewarm(stage, &gSim)) { + fatalStop(kTextFatalMemory, kTextFatalSprites); + } highScoreLoad(); // $23E5 - enterTitle(); + if (!gGame.fatal) { + enterTitle(); + } gGame.lastFrame = (uint8_t)jlFrameCount(); for (;;) { uint8_t ticks; + if (gGame.fatal) { + fatalHold(); + break; + } jlInputPoll(); // $4FCB runStopWatcher, on the game tick, the intro and the menus: // SHIFT held freezes everything with the sound gated off and any @@ -1056,5 +1134,5 @@ int main(void) { stAudioShutdown(); stRenderShutdown(); jlShutdown(); - return 0; + return gGame.fatal ? 1 : 0; } diff --git a/examples/spacetaxi/spacetaxi.h b/examples/spacetaxi/spacetaxi.h index bf9730d..75fd710 100644 --- a/examples/spacetaxi/spacetaxi.h +++ b/examples/spacetaxi/spacetaxi.h @@ -25,9 +25,11 @@ #if defined(__W65816__) || defined(JOEYLIB_PLATFORM_X68000) #define ST_LOG_RESET() ((void)0) #define ST_LOG(...) ((void)0) + #define ST_LOG_FLUSH() ((void)0) #else #define ST_LOG_RESET() jlLogReset() #define ST_LOG(...) jlLogF(__VA_ARGS__) + #define ST_LOG_FLUSH() jlLogFlush() #endif // Level loading through the JoeyLib data path (DATA/levels/...). @@ -54,12 +56,22 @@ void stRenderTitleLogo(StSimT *sim); // animates without a single pixel moving. Call after // stRenderSceneChanged, which turns it back off. void stRenderIntroStars(void); -void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim); +// Fill the stage with a two-line message and present it. Used when an +// asset the game ships with will not load, so a player sees why the +// game stopped instead of a silently degraded ride. +void stRenderFatal(jlSurfaceT *stage, const char *line1, const char *line2); +// Compile every always-on cel. False when a shipped asset is present but +// unusable, so main can stop with a message instead of running the game +// on interpreted cels. +bool stRenderPrewarm(jlSurfaceT *stage, StSimT *sim); // Ready a level's hook cels before its ride (evicts the previous level's). -void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level); +// False only when this level's baked bank exists and will not load; a +// port that ships no per-level banks compiles them and returns true. +bool stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level); // Audio sink setup and the per-tick release timers ($4320). -void stAudioInit(void); +// False when the shipped speech blob will not load. +bool stAudioInit(void); void stAudioShutdown(void); void stAudioTick(void); diff --git a/examples/spacetaxi/stAudio.c b/examples/spacetaxi/stAudio.c index 8d3eefe..d1ed9cb 100644 --- a/examples/spacetaxi/stAudio.c +++ b/examples/spacetaxi/stAudio.c @@ -29,6 +29,13 @@ #define ST_ATTEN_LOUD 4u #define ST_ATTEN_SOFT 8u +// The jet's noise pitch: jlAudioNoise takes 0..31 in YM noise-period +// style, where 31 is the lowest rumble (0 is the brightest hiss). +#define ST_NOISE_JET_PITCH 31u +// Noise sound effects (the crash and the wreck's impact) use the same +// deep end of the range. The C64 takes the rate from the program's +// frequency word; this is the one simplification in that mapping. +#define ST_NOISE_SFX_PITCH 31u #define ST_ATTEN_OFF 15u // NTSC SID: Hz = word * 1022727 / 16777216 = word * 0.06096. @@ -56,10 +63,16 @@ // the ride half a second on every "HEY TAXI"; the 1-bit intermediate // format that followed still needed a per-byte expansion loop. #define ST_SPEECH_FILE "speech.bin" -#define ST_SPEECH_VERSION 2u +#define ST_SPEECH_VERSION 3u #define ST_SPEECH_HEADER_BYTES 16u #define ST_SPEECH_ENTRY_BYTES 8u -#define ST_SPEECH_POLARITIES 2u +// speech.bin carries each utterance in one polarity or two. Two is the +// cheap case -- the player just picks the baked variant -- but it doubles +// the file, and the X68000's Human68k floppy cannot spare the other +// 60 KB, so that port bakes one and the player negates the samples +// itself. Negation is the same byte arithmetic in both sample formats: +// signed 0 - v, and 0x80-biased 0x100 - v, are one operation. +#define ST_SPEECH_POLARITIES_MAX 2u #define ST_SPEECH_FORMAT_SIGNED 0u #define ST_SPEECH_FORMAT_BIASED 1u #define ST_SPEECH_LOAD_CHUNK 16384u @@ -88,6 +101,9 @@ typedef struct { static StSfxSlotT gSfx; // SID voice 1 (programs + sweep) static StSfxSlotT gTone; // SID voice 2 static bool gNoiseOn; +// Release countdown for a NOISE sound effect, in game ticks, mirroring +// the two tone slots' ticksLeft. 0 = no burst running. +static uint16_t gNoiseTicks; // Speech playback state. The fill callback walks a queue of utterances so // a phrase like "HT" ("Hey" + "Taxi") runs as one continuous stream @@ -109,7 +125,9 @@ static const uint8_t *gSpeechPcm; // current utterance's samples static uint16_t gSpeechSample; // next sample index in it static uint16_t gSpeechSamples; // its length static uint8_t gSpeechParity; // it ends on the opposite level -static uint8_t gSpeechPol; // 0 / 1: which baked polarity the next utterance plays +static uint8_t gSpeechPol; // 0 / 1: the polarity the next utterance plays in +static uint8_t gSpeechPolarities; // how many the file carries, 1 or 2 +static bool gSpeechInvert; // negate on the way out (1-polarity file, odd polarity) // Copied-ahead PCM ring for the stream (see speechFill). Allocated by // stAudioInit rather than static: 8 KB of BSS is a fifth of the IIgs // entry bank, which text, rodata, BSS and the C heap all share. @@ -121,7 +139,7 @@ static bool gSpeechDecoding; // the decoder still has stream left static uint16_t sidHz(uint16_t word); -static void speechLoad(void); +static bool speechLoad(void); static bool speechNext(void); static void speechArm(void); static uint16_t speechDecode(uint8_t *dst, uint16_t count); @@ -145,7 +163,7 @@ static uint16_t sidHz(uint16_t word) { // is empty, which ends the stream. // DATA/speech.bin into one jlAlloc block; without it the game is mute // but otherwise fine. -static void speechLoad(void) { +static bool speechLoad(void) { FILE *fp = jlDataOpen(ST_SPEECH_FILE, "rb"); uint8_t head[ST_SPEECH_HEADER_BYTES]; uint8_t format; @@ -155,22 +173,30 @@ static void speechLoad(void) { gSpeechBlob = 0; if (fp == 0) { - return; + ST_LOG("staudio: ! cannot open DATA/%s", ST_SPEECH_FILE); + return false; } format = (jlAudioSfxRawFormat() == JL_SFX_RAW_UNSIGNED8_NOZERO) ? ST_SPEECH_FORMAT_BIASED : ST_SPEECH_FORMAT_SIGNED; - if (fread(head, 1u, sizeof(head), fp) != sizeof(head) || head[0] != 'S' || head[1] != 'T' || head[2] != 'S' || head[3] != 'P' || head[4] != ST_SPEECH_VERSION || head[8] != format) { + if (fread(head, 1u, sizeof(head), fp) != sizeof(head) || head[0] != 'S' || head[1] != 'T' || head[2] != 'S' || head[3] != 'P' || head[4] != ST_SPEECH_VERSION || head[8] != format + || head[9] == 0u || head[9] > ST_SPEECH_POLARITIES_MAX) { + ST_LOG("staudio: ! speech.bin rejected (want v%u fmt%u)", ST_SPEECH_VERSION, format); fclose(fp); - return; + return false; } + gSpeechPolarities = head[9]; gSpeechPitchBase = head[5]; gSpeechPitchCount = head[6]; gSpeechCount = head[7]; dataBytes = (uint32_t)head[12] | ((uint32_t)head[13] << 8) | ((uint32_t)head[14] << 16) | ((uint32_t)head[15] << 24); - total = ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES + dataBytes; + total = ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES + dataBytes; gSpeechBlob = (uint8_t *)jlAlloc(total); if (gSpeechBlob == 0) { + // The blob is ~120 KB of sample bytes, so on the tighter + // machines this is the failure most likely to bite. It stops + // the game: a silent Space Taxi is a broken Space Taxi. + ST_LOG("staudio: ! speech alloc FAILED (%lu bytes)", (unsigned long)total); fclose(fp); - return; + return false; } memcpy(gSpeechBlob, head, sizeof(head)); // The block spans banks on the IIgs; read it in bank-safe pieces. @@ -182,6 +208,7 @@ static void speechLoad(void) { want = ST_SPEECH_LOAD_CHUNK; } if (fread(gSpeechBlob + done, 1u, (size_t)want, fp) != (size_t)want) { + ST_LOG("staudio: ! speech read FAILED at %lu of %lu", (unsigned long)done, (unsigned long)total); jlFree(gSpeechBlob); gSpeechBlob = 0; break; @@ -189,6 +216,11 @@ static void speechLoad(void) { done += want; } fclose(fp); + if (gSpeechBlob == 0) { + return false; + } + ST_LOG("staudio: speech %lu bytes, %u utterances x %u pitches x %u polarities", (unsigned long)total, gSpeechCount, gSpeechPitchCount, gSpeechPolarities); + return true; } @@ -206,19 +238,24 @@ static bool speechNext(void) { } while (gSpeechHead != gSpeechTail) { uint8_t code = gSpeechQueue[gSpeechHead]; - const uint8_t *e = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint16_t)row * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES; + const uint8_t *e = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint16_t)row * gSpeechCount * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES; uint8_t k; gSpeechHead = (uint8_t)((gSpeechHead + 1u) % ST_SPEECH_QUEUE); - for (k = 0u; k < gSpeechCount; k++, e += ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES) { + for (k = 0u; k < gSpeechCount; k++, e += gSpeechPolarities * ST_SPEECH_ENTRY_BYTES) { if (e[0] == code) { - const uint8_t *v = e + (uint16_t)gSpeechPol * ST_SPEECH_ENTRY_BYTES; + // Two polarities baked: take the variant. One: take the + // only one and negate it on the way out when the running + // polarity is the odd one. + uint8_t pol = (gSpeechPolarities == 2u) ? gSpeechPol : 0u; + const uint8_t *v = e + (uint16_t)pol * ST_SPEECH_ENTRY_BYTES; uint32_t offset = (uint32_t)v[4] | ((uint32_t)v[5] << 8) | ((uint32_t)v[6] << 16) | ((uint32_t)v[7] << 24); + gSpeechInvert = (gSpeechPolarities == 1u && gSpeechPol != 0u); gSpeechParity = v[1]; gSpeechSamples = (uint16_t)(v[2] | ((uint16_t)v[3] << 8)); gSpeechSample = 0u; - gSpeechPcm = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES + offset; + gSpeechPcm = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES + offset; return true; } } @@ -252,7 +289,17 @@ static uint16_t speechDecode(uint8_t *dst, uint16_t count) { if (take > left) { take = left; } - jlMemCopy(dst + n, gSpeechPcm + gSpeechSample, take); + if (gSpeechInvert) { + const uint8_t *from = gSpeechPcm + gSpeechSample; + uint8_t *to = dst + n; + uint16_t i; + + for (i = 0u; i < take; i++) { + to[i] = (uint8_t)(0u - (uint16_t)from[i]); + } + } else { + jlMemCopy(dst + n, gSpeechPcm + gSpeechSample, take); + } gSpeechSample = (uint16_t)(gSpeechSample + take); n = (uint16_t)(n + take); } @@ -368,14 +415,27 @@ void stAudioTick(void) { jlAudioVoice(gTone.voice, 0u, ST_ATTEN_OFF); } } + if (gNoiseTicks != 0u) { + gNoiseTicks--; + if (gNoiseTicks == 0u) { + gNoiseOn = false; + jlAudioNoise(0u, ST_ATTEN_OFF); + } else { + // Re-assert: one call buys about 80 ms on the IIgs. + jlAudioNoise(ST_NOISE_SFX_PITCH, ST_ATTEN_LOUD); + } + } } -void stAudioInit(void) { +bool stAudioInit(void) { + bool ok; + (void)jlAudioInit(); gSpeechStage = (uint8_t *)jlAlloc(ST_SPEECH_STAGE_BYTES); - speechLoad(); + ok = (gSpeechStage != 0) && speechLoad(); gSfx.voice = ST_VOICE_SFX; + gNoiseTicks = 0u; gSfx.ticksLeft = 0u; gTone.voice = ST_VOICE_TONE; gTone.ticksLeft = 0u; @@ -385,6 +445,8 @@ void stAudioInit(void) { gSpeechActive = false; gSpeechPitch = ST_SPEECH_PITCH_BASE; gSpeechPol = 0u; + gSpeechInvert = false; + return ok; } @@ -401,16 +463,38 @@ void stAudioShutdown(void) { // $6D6A / $6A3B -- voice 3 jet noise gate. +// $6D6A's voice-3 gate. The C64 holds SID voice 3 open for as long as a +// direction is held, so the jet has to keep sounding for the whole burn. +// +// This re-asserts on EVERY call rather than only on the rising edge, +// because jlAudioNoise is a per-frame assertion and not a latch: the +// IIgs plays one ~80 ms one-shot per call and expects the caller to keep +// asking, exactly as jlMusicPlay's sequencer does for a held noise note +// (include/joey/audio.h). Latching it on the edge is why the thruster +// used to sound one short burst and then die with the taxi still +// climbing. The ports that DO latch take the repeat harmlessly: the ST +// rewrites the same YM registers, and Paula's redundant DMA set is +// click-free by design. void stAudioNoise(bool on) { - if (on == gNoiseOn) { + if (on && gNoiseTicks != 0u) { + // A sound effect owns the voice until its release runs out, so + // the jet does not talk over the crash. The C64 shares SID + // voice 3 between them the same way and silences the jet before + // firing one. An explicit silence below still wins, which + // matters because stAudioMusic clears this voice before a tune. return; } - gNoiseOn = on; if (on) { - jlAudioNoise(31u, ST_ATTEN_SOFT); - } else { - jlAudioNoise(0u, ST_ATTEN_OFF); + gNoiseOn = true; + jlAudioNoise(ST_NOISE_JET_PITCH, ST_ATTEN_SOFT); + return; } + if (!gNoiseOn && gNoiseTicks == 0u) { + return; + } + gNoiseOn = false; + gNoiseTicks = 0u; + jlAudioNoise(0u, ST_ATTEN_OFF); } @@ -423,9 +507,17 @@ void stAudioSfx(const uint8_t *program9) { StSfxSlotT *slot = (program9[8] == 1u) ? &gTone : &gSfx; if ((ctrl & 0x80u) != 0u) { - // Noise waveform: a burst on the noise generator. - jlAudioNoise(31u, ST_ATTEN_LOUD); - gNoiseOn = true; + // Noise waveform: a burst on the noise generator, held for the + // program's release time exactly as the tone slots below hold + // theirs. It used to fire one jlAudioNoise and return, which is + // not a burst at all: that call is a per-frame ASSERTION, not a + // latch (include/joey/audio.h), so on the IIgs the wreck's + // explosion lasted the ~80 ms of one one-shot and was easy to + // miss entirely, while on Paula it would have hung until some + // later stAudioNoise(false). stAudioTick re-asserts it. + gNoiseTicks = (uint16_t)(ticks + 1u); + gNoiseOn = true; + jlAudioNoise(ST_NOISE_SFX_PITCH, ST_ATTEN_LOUD); return; } jlAudioVoice(slot->voice, sidHz(word), ST_ATTEN_LOUD); diff --git a/examples/spacetaxi/stCels.c b/examples/spacetaxi/stCels.c index 4482d01..cb33c46 100644 --- a/examples/spacetaxi/stCels.c +++ b/examples/spacetaxi/stCels.c @@ -44,6 +44,31 @@ const StCelDefT kStCels[] = { const uint16_t kStCelCount = (uint16_t)(sizeof(kStCels) / sizeof(kStCels[0])); +// Cels the title screen draws that are deliberately NOT pre-compiled. +// +// Its five decoration sprites are recoloured by logoColorCycle ($4861), +// which rewrites sprites 3..7 with the next logo colour every cycle +// step, so each is seen in all eight of kLogoColor's values -- forty +// combinations. The parked cab's jet is a forty-first, colour 6 rather +// than the flame list's 7. None of them are worth a bank slot: on the +// 65816 a compile costs about half a second, so baking the forty would +// add some twenty seconds to startup, and raising kStCelCount at all is +// risky -- 37 made the runtime reject the whole precompiled bank (see +// the note in the project memory). They draw interpreted, which +// a static title screen can well afford. stRender consults this list so +// the mid-ride warning stays about real misses. +// A zero colour means "in any colour": the decorations cycle through all +// eight. The jet is listed for the cab colour alone, because the flame +// list above already carries it in colour 7 for the ride itself. +const StCelDefT kStInterpretedCels[] = { + { 0xDB, 0u, 1u, MC0, MC1 }, { 0xDE, 0u, 1u, MC0, MC1 }, + { 0xDF, 0u, 1u, MC0, MC1 }, { 0xE0, 0u, 1u, MC0, MC1 }, + { 0xE1, 0u, 1u, MC0, MC1 }, + { 0xD8, CAB, 1u, MC0, MC1 }, +}; +const uint16_t kStInterpretedCount = (uint16_t)(sizeof(kStInterpretedCels) / sizeof(kStInterpretedCels[0])); + + void stCelBlob(const uint8_t *bm, uint8_t multi, uint8_t color, uint8_t mc0, uint8_t mc1, uint8_t *out) { uint8_t pal[4]; uint8_t tx; diff --git a/examples/spacetaxi/stCels.h b/examples/spacetaxi/stCels.h index 2ab7dc8..f10f959 100644 --- a/examples/spacetaxi/stCels.h +++ b/examples/spacetaxi/stCels.h @@ -30,6 +30,10 @@ typedef struct { extern const StCelDefT kStCels[]; extern const uint16_t kStCelCount; +// Cels the title screen draws interpreted by design; a zero colour +// entry matches any colour. See the note beside the table in stCels.c. +extern const StCelDefT kStInterpretedCels[]; +extern const uint16_t kStInterpretedCount; // The cels a level's hook program can show (stLevelCels.c): at most `max` // into `out`, no duplicates. 0 for a level without hook sprites. diff --git a/examples/spacetaxi/stRender.c b/examples/spacetaxi/stRender.c index 9391631..3dd0e08 100644 --- a/examples/spacetaxi/stRender.c +++ b/examples/spacetaxi/stRender.c @@ -155,6 +155,7 @@ static StSpriteCacheT *cacheFreeSlot(void); static void levelCelsEvict(void); static const uint8_t *levelSpriteBitmap(const StLevelT *level, uint8_t ptr); static void loadingBar(jlSurfaceT *stage, uint8_t done, uint8_t total); +static bool celDrawsInterpreted(uint8_t ptr, uint8_t color); static uint8_t cellCol(uint16_t cell, uint8_t row); static uint8_t cellRow(uint16_t cell); static void collectGlyphs(StSimT *sim); @@ -166,10 +167,11 @@ static bool loadPrecompiledCels(void); static bool logoBuildTile(const StSimT *sim); static uint8_t logoFrameIndex(const StSimT *sim); static void logoPaletteApply(const StSimT *sim, uint8_t frame); -static void starPaletteApply(void); static void paintCells(jlSurfaceT *stage, StSimT *sim); static void pasteCell(jlSurfaceT *stage, const StSimT *sim, uint16_t cell, uint8_t bx, uint8_t by); +static bool prewarmReport(uint16_t expect); static bool spriteOnScreen(int16_t px, int16_t py); +static void starPaletteApply(void); static void tileFromChunky(jlTileT *out, const uint8_t *chunky); @@ -278,7 +280,7 @@ static jlSpriteT *cachedSprite(StSimT *sim, uint8_t idx) { if (idx <= 2u && gPrewarming) { cacheCompile(slot); } - if (!gPrewarming) { + if (!gPrewarming && !celDrawsInterpreted(ptr, color)) { ST_LOG("spacetaxi: ! cel $%02X colour %u (level %u) built mid-ride -- add it to stLevelCelList", ptr, color, level); } slot->lastUse = gRender.cacheStamp; @@ -313,6 +315,26 @@ static const uint8_t kCellRow[125] = { // Column of a cell whose row is known: cell - row * 40 through a table, // because the two-shift form of the multiply came back as a __mulhi3 // library call on the 65816. +// True for a cel that is MEANT to be built here rather than pre-listed, +// so the warning below stays about real misses. Only the title screen's +// decorations qualify: logoColorCycle recolours them every cycle step, +// which is forty ptr/colour pairs that would each cost a compile (see +// the note beside kStTitleDecorCels in stCels.c). +static bool celDrawsInterpreted(uint8_t ptr, uint8_t color) { + uint16_t k; + + for (k = 0u; k < kStInterpretedCount; k++) { + if (kStInterpretedCels[k].ptr != ptr) { + continue; + } + if (kStInterpretedCels[k].color == 0u || kStInterpretedCels[k].color == color) { + return true; + } + } + return false; +} + + static uint8_t cellCol(uint16_t cell, uint8_t row) { static const uint16_t kRowBase[ST_SCREEN_ROWS] = { 0, 40, 80, 120, 160, 200, 240, 280, 320, 360, 400, 440, 480, @@ -874,21 +896,22 @@ static void loadingBar(jlSurfaceT *stage, uint8_t done, uint8_t total) { // here behind the LOADING bar. Either way nothing compiles once the // level is running. Keyed exactly as cachedSprite keys them, so every // lookup during the ride is a hit. -void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) { +bool stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) { static StCelDefT list[ST_LEVEL_CELS_MAX]; jlSpriteT *cels[ST_LEVEL_CELS_MAX]; char path[24]; uint8_t n; uint8_t got; + uint8_t ready = 0u; uint8_t k; levelCelsEvict(); if (level == 0) { - return; + return true; } n = stLevelCelList(level, list, ST_LEVEL_CELS_MAX); if (n == 0u) { - return; + return true; } memcpy(path, "sprites/level", 13u); path[13] = (char)('0' + (level->levelIndex + 1u) / 10u); @@ -938,11 +961,17 @@ void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) { slot->used = true; slot->pinned = true; slot->lastUse = gRender.cacheStamp; + ready++; } gPrewarming = false; if (got != n) { jlFillRect(stage, 60, 110, 200u, 6u, 0u); } + if (ready < n) { + ST_LOG("spacetaxi: ! level %u has %u of %u cels", level->levelIndex, ready, n); + return false; + } + return true; } @@ -976,11 +1005,54 @@ static bool loadPrecompiledCels(void) { e->used = true; e->pinned = true; e->lastUse = 0u; + // The bank's cels arrive compiled ONLY if the arena had room for + // them: jlSpriteBankLoadPrecompiled loads a cel either way and + // leaves the ones that did not fit to the interpreter. So ask + // the sprite instead of assuming, or the flag lies and any + // future cacheCompile skips a cel that really does need work. + e->compiled = (jlSpriteCompiledSize(e->sprite) != 0u); } return true; } +// A fatal message, centred, in the game's own charset. Used when an +// asset that IS on the disk will not load: the game then stops instead +// of playing on in a degraded state, because a mute game or one running +// every sprite through the interpreter looks like a slow machine rather +// than a broken install. +void stRenderFatal(jlSurfaceT *stage, const char *line1, const char *line2) { + const uint8_t *charset = stC64Charset(); + const char *lines[2]; + uint8_t i; + uint8_t k; + + if (stage == 0) { + return; + } + lines[0] = line1; + lines[1] = line2; + jlSurfaceClear(stage, 0u); + for (i = 0u; i < 2u; i++) { + uint8_t len; + uint8_t col; + + if (lines[i] == 0) { + continue; + } + len = 0u; + while (lines[i][len] != '\0' && len < ST_SCREEN_COLS) { + len++; + } + col = (uint8_t)((ST_SCREEN_COLS - len) / 2u); + for (k = 0u; k < len; k++) { + jlTilePasteGlyph(stage, (uint8_t)(col + k), (uint8_t)(ST_LOADING_ROW + i * 2u), + &charset[(uint8_t)lines[i][k] * 8u], ST_LOADING_COLOR, 0u); + } + } + jlStagePresent(); +} + // "LOADING..." centred on an otherwise black stage. The charset is the // game's own, indexed by ASCII (the C64 font's letters sit at their @@ -999,12 +1071,13 @@ void stRenderLoading(jlSurfaceT *stage) { jlStagePresent(); } -void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) { +bool stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) { static const uint8_t kCabPtrs[] = { 0xC0, 0xC1, 0xDC, 0xDD, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0, 0xD1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8 }; static const uint8_t kPassPtrs[] = { 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xD9 }; static const uint8_t kFlamePtrs[] = { 0xD8, 0xD4, 0xD5, 0xD2, 0xD1, 0xD7, 0xD3, 0xD6 }; uint8_t total = (uint8_t)(sizeof(kCabPtrs) + sizeof(kPassPtrs) + sizeof(kFlamePtrs) + 1u); uint8_t done = 0u; + bool ok; uint8_t k; StFrameT saved; @@ -1012,7 +1085,7 @@ void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) { // runtime build below on any port without a matching .spc. if (loadPrecompiledCels()) { ST_LOG("spacetaxi: cels from sprites/staxicels.spc"); - return; + return prewarmReport(kStCelCount); } ST_LOG("spacetaxi: ! no usable sprites/staxicels.spc -- JIT prewarm"); saved = sim->frame; @@ -1050,7 +1123,45 @@ void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) { (void)cachedSprite(sim, 2u); sim->frame = saved; gPrewarming = false; + ok = prewarmReport(total); jlFillRect(stage, 60, 110, 200u, 6u, 0u); + return ok; +} + + +// Say how the prewarmed cels came out, and judge them. A cel past the end +// of the codegen arena still DRAWS, interpreted, so a port whose arena +// holds only some of the cels is slower but sound -- that is the ST at +// 2 MB, 12 of 37 compiled, and it is not an error. Zero compiled is: +// it means the arena itself never came, which on a 1 MB ST is the speech +// blob having taken the heap, and every sprite then runs about forty +// times slower. The caller stops on a false rather than let that pass +// for a slow machine. +static bool prewarmReport(uint16_t expect) { + uint32_t bytes = 0u; + uint8_t used = 0u; + uint8_t hot = 0u; + uint8_t k; + + for (k = 0u; k < ST_SPRITE_CACHE; k++) { + uint32_t n; + + if (!gRender.cache[k].used) { + continue; + } + n = jlSpriteCompiledSize(gRender.cache[k].sprite); + used++; + if (n != 0u) { + hot++; + bytes += n; + } + } + ST_LOG("spacetaxi: prewarm %u cels: %u compiled, %u interpreted, %lu arena bytes", used, hot, (uint8_t)(used - hot), (unsigned long)bytes); + if (used < expect) { + ST_LOG("spacetaxi: ! only %u of %u cels built", used, expect); + return false; + } + return hot != 0u; } diff --git a/make/amiga.mk b/make/amiga.mk index aa8f4ae..aa3b68d 100644 --- a/make/amiga.mk +++ b/make/amiga.mk @@ -121,7 +121,7 @@ STAXI_SPR_GEN := $(patsubst $(STAXI_SRC_DIR)/%.png,$(STAXI_GEN_DIR)/%.spr,$( STAXI_LEVEL_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_LEVEL_GEN)) STAXI_TBK_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_TBK_GEN)) STAXI_SPR_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPR_GEN)) -STAXI_ASSET_DSTS := $(STAXI_LEVEL_RUN) $(STAXICELS_SPC_RUN) +STAXI_ASSET_DSTS := $(STAXI_LEVEL_RUN) # Pre-compiled sprite banks (.spc): baked from the .spr by the host spritebake # tool for THIS target and staged next to the .spr, so the runtime loads # compiled routines with no startup JIT (jlSpriteBankLoadPrecompiled). @@ -151,6 +151,14 @@ STAXI_ASSET_DSTS += $(STAXI_SPEECH_RUN) STAXICELS_SPR_GEN := $(STAXI_GEN_DIR)/sprites/staxicels.spr STAXICELS_SPC_GEN := $(STAXICELS_SPR_GEN:.spr=.spc) STAXICELS_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXICELS_SPC_GEN)) +# Added HERE, not up with the other asset destinations: this variable is +# defined below that point, and the := there expanded it to nothing, so +# the compiled cel bank was never a prerequisite of the port and never +# got copied into the build tree. Every port shipped whatever .spc was +# staged the day the file was first created, and edits to stCels.c did +# not reach the game (found 2026-09-23 when a new cel made the runtime +# reject the bank as too short). +STAXI_ASSET_DSTS += $(STAXICELS_SPC_RUN) STAXI_SPC_GEN := $(STAXI_SPR_GEN:.spr=.spc) STAXI_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPC_GEN)) ASSETBAKE_BIN := $(REPO_DIR)/tools/assetbake/assetbake.py diff --git a/make/atarist.mk b/make/atarist.mk index e9cb472..4622ce3 100644 --- a/make/atarist.mk +++ b/make/atarist.mk @@ -99,7 +99,7 @@ STAXI_SPR_GEN := $(patsubst $(STAXI_SRC_DIR)/%.png,$(STAXI_GEN_DIR)/%.spr,$( STAXI_LEVEL_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_LEVEL_GEN)) STAXI_TBK_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_TBK_GEN)) STAXI_SPR_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPR_GEN)) -STAXI_ASSET_DSTS := $(STAXI_LEVEL_RUN) $(STAXICELS_SPC_RUN) +STAXI_ASSET_DSTS := $(STAXI_LEVEL_RUN) # Pre-compiled sprite banks (.spc): baked from the .spr by the host spritebake # tool for THIS target and staged next to the .spr, so the runtime loads # compiled routines with no startup JIT (jlSpriteBankLoadPrecompiled). ST is @@ -131,6 +131,14 @@ STAXI_ASSET_DSTS += $(STAXI_SPEECH_RUN) STAXICELS_SPR_GEN := $(STAXI_GEN_DIR)/sprites/staxicels.spr STAXICELS_SPC_GEN := $(STAXICELS_SPR_GEN:.spr=.spc) STAXICELS_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXICELS_SPC_GEN)) +# Added HERE, not up with the other asset destinations: this variable is +# defined below that point, and the := there expanded it to nothing, so +# the compiled cel bank was never a prerequisite of the port and never +# got copied into the build tree. Every port shipped whatever .spc was +# staged the day the file was first created, and edits to stCels.c did +# not reach the game (found 2026-09-23 when a new cel made the runtime +# reject the bank as too short). +STAXI_ASSET_DSTS += $(STAXICELS_SPC_RUN) STAXI_SPC_GEN := $(STAXI_SPR_GEN:.spr=.spc) STAXI_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPC_GEN)) ASSETBAKE_BIN := $(REPO_DIR)/tools/assetbake/assetbake.py @@ -331,7 +339,13 @@ $(STAXI_GEN_DIR)/sprites/level%.spr: $(STAXI_GEN_DIR)/levels/level%.dat $(STAXIB # Speech bake rules live down here so they never become the default goal. $(STAXI_SPEECH_GEN): $(EXAMPLES)/spacetaxi/stSpeechData.c $(EXAMPLES)/spacetaxi/assets/genSpeechPcm.py @mkdir -p $(dir $@) - python3 $(EXAMPLES)/spacetaxi/assets/genSpeechPcm.py $< $@ + # --single: one polarity, 60 KB instead of 120 KB. Measured on a + # stock 1 MB machine, the two-polarity file cannot be allocated at + # all -- "speech alloc FAILED (120828 bytes)", so that ST plays the + # whole game mute -- while the half-size one loads there. 2 MB and up + # take either. The samples are identical: the player negates them + # when the running polarity is odd, one subtraction per byte. + python3 $(EXAMPLES)/spacetaxi/assets/genSpeechPcm.py $< $@ --single $(STAXI_SPEECH_RUN): $(STAXI_SPEECH_GEN) @mkdir -p $(dir $@) cp $< $@ diff --git a/make/dos.mk b/make/dos.mk index f65ac39..abd9322 100644 --- a/make/dos.mk +++ b/make/dos.mk @@ -122,7 +122,7 @@ ASSETBAKE_TARGET := dos STAXI_LEVEL_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_LEVEL_GEN)) STAXI_TBK_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_TBK_GEN)) STAXI_SPR_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPR_GEN)) -STAXI_ASSET_DSTS := $(STAXI_LEVEL_RUN) $(STAXICELS_SPC_RUN) +STAXI_ASSET_DSTS := $(STAXI_LEVEL_RUN) # Pre-compiled sprite banks (.spc): baked from the .spr by the host spritebake # tool for THIS target and staged next to the .spr, so the runtime loads # compiled routines with no startup JIT (jlSpriteBankLoadPrecompiled). @@ -152,6 +152,14 @@ STAXI_ASSET_DSTS += $(STAXI_SPEECH_RUN) STAXICELS_SPR_GEN := $(STAXI_GEN_DIR)/sprites/staxicels.spr STAXICELS_SPC_GEN := $(STAXICELS_SPR_GEN:.spr=.spc) STAXICELS_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXICELS_SPC_GEN)) +# Added HERE, not up with the other asset destinations: this variable is +# defined below that point, and the := there expanded it to nothing, so +# the compiled cel bank was never a prerequisite of the port and never +# got copied into the build tree. Every port shipped whatever .spc was +# staged the day the file was first created, and edits to stCels.c did +# not reach the game (found 2026-09-23 when a new cel made the runtime +# reject the bank as too short). +STAXI_ASSET_DSTS += $(STAXICELS_SPC_RUN) STAXI_SPC_GEN := $(STAXI_SPR_GEN:.spr=.spc) STAXI_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPC_GEN)) diff --git a/make/iigs.mk b/make/iigs.mk index c69864b..1f09d90 100644 --- a/make/iigs.mk +++ b/make/iigs.mk @@ -79,7 +79,7 @@ NTP_BIN := $(BUILD)/audio/ntpplayer.bin NTP_ASM := $(BUILD)/audio/ntpdata.s IIGS_MERLIN := $(REPO_DIR)/toolchains/iigs/merlin32/bin/merlin32 -.PHONY: all iigs iigs-lib iigs-clang-smoke iigs-examples iigs-disk iigs-disk-all iigs-verify iigs-verify-all iigs-verify-save iigs-verify-serial iigs-verify-shrtail clean-iigs clean +.PHONY: all iigs iigs-lib iigs-clang-smoke iigs-examples iigs-disk iigs-disk-all iigs-game-disk iigs-game-disks iigs-game-disk-agi iigs-verify-game-disk iigs-verify iigs-verify-all iigs-verify-save iigs-verify-serial iigs-verify-shrtail clean-iigs clean # Default: compile-check the library + run the end-to-end smoke test. all iigs: iigs-lib iigs-clang-smoke @@ -147,6 +147,49 @@ $(EXAMPLES)/spacetaxi/generated/iigs/sprites/%.spc: $(EXAMPLES)/spacetaxi/genera iigs-disk: iigs-examples $(NTP_BIN) $(STAXI_IIGS_SPC) $(STAXILEVEL_IIGS_SPC) $(STAXI_IIGS_SPEECH) BINDIR="$(BINDIR)" NTP_BIN="$(NTP_BIN)" $(REPO_DIR)/scripts/make-iigs-disk.sh $(BINDIR)/joey.2mg +# ONE self-booting volume per game: a GS/OS system stripped to what JoeyLib +# actually uses, a launcher in place of the Finder, and the program in its own +# folder so that folder can be copied to a hard drive and run from there. +# Boot to the program is 21.8 s against 43.4 s for the two-floppy boot disk. +# The size is chosen per example: a program that fits beside a stripped +# system gets a bootable 800KB 3.5" floppy, and one that does not (STAXI, +# AGI) gets a 2048KB volume, which MAME can only boot off a CFFA2 card +# because its 3.5 drive is 800KB GCR: +# mame apple2gs -sl7 cffa2 -hard1 build/iigs/bin/STAXI.2mg +# Pass GAME=name for a different example, GAME_DISK_SIZE to force a size. +# It builds its OWN one-example data image rather than depending on +# iigs-disk: the default joey.2mg carries the small examples and NOT STAXI +# (see make-iigs-disk.sh's JOEY_DISK_EXAMPLES), and the gates depend on +# joey.2mg's exact contents, so this must not rewrite it. +GAME ?= STAXI +iigs-game-disk: iigs-examples $(NTP_BIN) $(STAXI_IIGS_SPC) $(STAXILEVEL_IIGS_SPC) $(STAXI_IIGS_SPEECH) + BINDIR="$(BINDIR)" NTP_BIN="$(NTP_BIN)" JOEY_DISK_EXAMPLES="$(GAME)" \ + $(REPO_DIR)/scripts/make-iigs-disk.sh $(BINDIR)/$(GAME)-data.2mg + JOEY_GAME_DISK_SIZE="$(or $(GAME_DISK_SIZE),auto)" \ + $(REPO_DIR)/scripts/make-iigs-game-disk.sh $(BINDIR)/$(GAME).2mg $(GAME) $(GAME) $(BINDIR)/$(GAME)-data.2mg + +# Every example that ships as its own bootable volume. AGI is excluded +# because its resources come from a different builder (see below). +IIGS_GAME_DISKS := DRAW PATTERN KEYS JOY SPRITE SAVE AUDIO ADV ADV2 UBER SERIAL SERTEST STAXI + +iigs-game-disks: + @for g in $(IIGS_GAME_DISKS); do \ + $(MAKE) --no-print-directory -f make/iigs.mk iigs-game-disk GAME=$$g || exit 1; \ + done + +# AGI needs King's Quest III's resources, which make-agi-iigs-disk.sh lays +# out, so it cannot use the generic one-example data image. It also wants a +# bigger Memory Manager pool: run it with -ramsize 8M. +AGI_GAME ?= kq3 +iigs-game-disk-agi: $(BINDIR)/AGI + $(REPO_DIR)/scripts/make-agi-iigs-disk.sh $(BINDIR)/AGI-data.2mg $(AGI_GAME) + $(REPO_DIR)/scripts/make-iigs-game-disk.sh $(BINDIR)/AGI.2mg AGI AGI $(BINDIR)/AGI-data.2mg + +# Boot one game disk headlessly and assert it reaches the program and draws. +# An 800KB image goes in the 3.5 drive, a larger one on a CFFA2 card. +iigs-verify-game-disk: iigs-game-disk + $(REPO_DIR)/scripts/verify-iigs-game-disk.sh $(BINDIR)/$(GAME).2mg + # Every launchable example on one image. The full set (~1.7 MB of binaries # plus STAXI's data) will not fit an 800KB 3.5" floppy, so this is a 16 MB # ProDOS hard-disk image (joey-all.2mg): boot it in MAME with diff --git a/make/x68000.mk b/make/x68000.mk index 568cbb3..6ad07e9 100644 --- a/make/x68000.mk +++ b/make/x68000.mk @@ -64,6 +64,8 @@ LIB_OBJS := \ $(patsubst $(SRC_DIR)/generic/%.c,$(BUILD)/obj/generic/%.o,$(GENERIC_C_SRCS)) \ $(patsubst $(SRC_DIR)/x68000/%.c,$(BUILD)/obj/port/%.o,$(PORT_C_SRCS)) \ $(patsubst $(SRC_DIR)/x68000/%.s,$(BUILD)/obj/port/%.o,$(PORT_S_SRCS)) \ + $(BUILD)/obj/68k/planarFill.o \ + $(BUILD)/obj/68k/planarPlot.o \ $(BUILD)/obj/68k/spriteEmitPlanar68k.o \ $(BUILD)/obj/codegen/spriteCompile.o \ $(BUILD)/obj/codegen/spriteStage.o @@ -189,7 +191,17 @@ STAXI_ASSET_DSTS := $(STAXI_LEVEL_RUN) $(STAXICELS_SPC_RUN) # for this target, so no cel is JIT-compiled mid-ride. Levels without # hook sprites have no bank; the game JIT-compiles at level entry when a # bank is missing. -STAXI_CEL_LEVELS := 07 08 09 10 12 16 17 21 +# ...except on the X68000, where they do not fit. The Human68k floppy is +# 1232 KB and these eight banks are 787 KB of it (level09 alone is 236 KB, +# level10 175, level17 165), which with the 296 KB binary, the levels and +# the speech put the disk about 300 KB over however small speech gets. +# stRenderLevelCels already handles their absence the right way: it builds +# and compiles the level's cels at ENTRY, behind the LOADING bar, so the +# ride itself still never compiles. That is also what spacetaxi.c's +# codegen-arena comment says the 68k ports are meant to do -- their cels +# are big and their JIT is fast. Set STAXI_CEL_LEVELS to the list above to +# ship them again if the payload ever moves to a hard disk image. +STAXI_CEL_LEVELS := STAXILEVEL_SPR_GEN := $(foreach n,$(STAXI_CEL_LEVELS),$(STAXI_GEN_DIR)/sprites/level$(n).spr) STAXILEVEL_SPC_GEN := $(STAXILEVEL_SPR_GEN:.spr=.spc) STAXILEVEL_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXILEVEL_SPC_GEN)) @@ -203,7 +215,11 @@ STAXI_SPEECH_RUN := $(STAXI_RUN_DIR)/speech.bin STAXI_ASSET_DSTS += $(STAXI_SPEECH_RUN) $(STAXI_SPEECH_GEN): $(EXAMPLES)/spacetaxi/stSpeechData.c $(EXAMPLES)/spacetaxi/assets/genSpeechPcm.py @mkdir -p $(dir $@) - python3 $(EXAMPLES)/spacetaxi/assets/genSpeechPcm.py $< $@ + # --single: one polarity, 60 KB instead of 120 KB. The 1232 KB + # Human68k floppy cannot hold the two-polarity file alongside the + # binary and the levels -- it came up 18 KB short -- and the player + # negates the samples itself when the running polarity is odd. + python3 $(EXAMPLES)/spacetaxi/assets/genSpeechPcm.py $< $@ --single $(STAXI_SPEECH_RUN): $(STAXI_SPEECH_GEN) @mkdir -p $(dir $@) cp $< $@ diff --git a/scripts/bench-x68000.sh b/scripts/bench-x68000.sh new file mode 100755 index 0000000..8377b40 --- /dev/null +++ b/scripts/bench-x68000.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# bench-x68000.sh - X68000 performance run, the twin of bench-atarist.sh. +# +# The sibling of verify-x68000-golden.sh: same staging and same +# completion detection, but UBER keeps its REAL measurement window, so the +# joeylog it leaves behind carries honest per-operation rates instead of +# hashes. Those rates are only trustworthy because this port now has an +# interrupt-driven millisecond clock (src/x68000/hal.c chains Human68k's MFP +# Timer C); before that every figure converged on ~10,000 ops/sec because the +# frame-derived clock under-counted exactly as much as the work cost. +# +# Prints the UBER rows and leaves the full log in build/x68000/joeylog.txt. +# +# Takes a few minutes. The run EXITS ON COMPLETION, not on a frame budget: UBER +# ends by clearing the whole stage to colour index 2 and presenting it before it +# blocks in jlWaitForAnyKey, so the Lua below watches GVRAM for that screen. +# (It used to burn a flat 300,000 frames -- ~90 minutes of emulated time -- a +# budget sized back when this port still drew through the slow generic renderer. +# The actual work is a Human68k boot plus well under 10,000 frames.) +# X68K_BENCH_FRAMES is only a backstop: reaching it means the done-screen was +# never detected, which is a failed run, not a normal exit. The budget is +# larger than the gate's because a real measurement window runs each operation +# for hundreds of frames rather than one. +# +# X68K_SCRATCH= bash scripts/bench-x68000.sh +set -uo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SP="${X68K_SCRATCH:?set X68K_SCRATCH to a work dir containing x68mame/}" +MAME="$repo/toolchains/cache/mame-mame0264/x68k" +TEMPLATE="$SP/x68mame/HUMAN302.XDF" +FRAMES="${X68K_BENCH_FRAMES:-120000}" +WALL="${X68K_BENCH_WALL:-7200}" + +export PATH="$repo/toolchains/x68000/m68k-xelf/bin:$PATH" +work=$(mktemp -d -t joey-x68bench.XXXXXX) +trap 'rm -rf "$work"' EXIT + +# -s (strip) keeps the staged binary small. (It used to be load-bearing: the +# data disk was built by deleting the template's root FILES, which leaves the +# stock ~900 KB of Human68k utilities in SYS/ BIN/ BASIC2/ ... still allocated +# and only ~330 KB usable. `xdftool empty` frees the whole 1221-cluster disk, +# so the size pressure is gone -- the strip is now just tidiness.) +m68k-xelf-gcc -s -O2 -m68000 -fomit-frame-pointer \ + -DJOEYLIB_PLATFORM_X68000 \ + -I"$repo/include" -I"$repo/src/core" -I"$repo/src/x68000" \ + -I"$repo/toolchains/audio/libxmp-lite/include" \ + "$repo/examples/uber/uber.c" \ + "$repo/build/x68000/lib/libjoey.a" "$repo/build/x68000/lib/libxmplite.a" -lm \ + -o "$work/UBER.X" || exit 1 + +# TWO DISKS: the boot disk carries only Human68k and AUTOEXEC, while the binary +# AND joeylog.txt live on the second disk on B:. AUTOEXEC switches to B: before +# launching so the log lands there too. Keeping the writable log off the boot +# disk also means a run can never corrupt the thing that boots it. +printf 'B:\r\nB:\\UBER.X\r\n\x1a' > "$work/AUTOEXEC.BAT" +cp "$TEMPLATE" "$work/boot.xdf" +# Data disk = the template emptied (keeps the BPB and boot sector). `empty` +# rather than a list of filenames: the stock disk's utilities live in +# SUBDIRECTORIES, which `delete` cannot see or free. +cp "$TEMPLATE" "$work/data.xdf" +python3 "$repo/tools/xdftool.py" empty "$work/data.xdf" >/dev/null +# Trim the boot disk of anything not needed to boot and run one .X. +for f in USKCG.SYS BEEP.SYS KEY.SYS STARTUP.ENV; do + python3 "$repo/tools/xdftool.py" delete "$work/boot.xdf" "$f" >/dev/null 2>&1 +done +for d in SYS HIS BIN BASIC2 ASK ETC; do + python3 "$repo/tools/xdftool.py" deltree "$work/boot.xdf" "$d" >/dev/null 2>&1 +done +python3 "$repo/tools/xdftool.py" add "$work/data.xdf" "$work/UBER.X" UBER.X >/dev/null || exit 1 +python3 "$repo/tools/xdftool.py" add "$work/boot.xdf" "$work/AUTOEXEC.BAT" AUTOEXEC.BAT >/dev/null + +# Completion-driven exit. UBER's last act before jlWaitForAnyKey is +# jlSurfaceClear(gStage, 2) + jlStagePresent, so an all-colour-2 stage in GVRAM +# is the "hashes are computed and flushed" signal. The benchmark's own +# jlSurfaceClear/jlStagePresent ops can paint that colour for an instant, so the +# reading only counts once it has held for GREEN_STABLE frames -- the finished +# screen never changes again, a mid-benchmark frame never lasts. +# +# jlLogFlush only fflush()es; the directory entry is finalised by the atexit +# fclose. So the keypress that releases jlWaitForAnyKey still has to be posted, +# and MAME must not be killed until UBER has returned to Human68k -- hence the +# repeated posts and the EXIT_SETTLE wait before exiting. +cat > "$work/bench.lua" < fclose -> back at the Human68k prompt + +-- crtmod 13, 256-colour graphics plane: one 16-bit word per pixel at \$C00000, +-- 512 words per row, the 320x200 stage centred at origin (96, 156). +local function stagePixel(px, py) + return mem:read_u8(0xC00000 + ((156 + py) * 512 + 96 + px) * 2 + 1) +end + +local kProbes = { {4,4}, {160,4}, {315,4}, {4,100}, {160,100}, {315,100}, + {4,196}, {160,196}, {315,196}, {80,50}, {240,150}, {200,80} } + +local function screenIsDone() + for _, p in ipairs(kProbes) do + if stagePixel(p[1], p[2]) ~= 2 then + return false + end + end + return true +end + +emu.register_frame_done(function() + frame = frame + 1 + if done then return end + if frame % 2000 == 0 then io.write("BENCH f"..frame.."\n"); io.flush() end + + if exitAt == 0 then + if screenIsDone() then + greenRun = greenRun + 1 + if greenRun >= GREEN_STABLE then + io.write("BENCH done-screen f"..frame.."\n"); io.flush() + exitAt = frame + EXIT_SETTLE + end + else + greenRun = 0 + end + else + -- Release jlWaitForAnyKey; extra presses land harmlessly at the prompt. + if frame % 60 == 0 then manager.machine.natkeyboard:post(" ") end + if frame >= exitAt then + done = true + io.write("BENCH exit f"..frame.."\n"); io.flush() + manager.machine:exit() + end + end + + -- Backstop only: reaching this means the done-screen never appeared. + if frame > FRAMES then + done = true + io.write("BENCH budget-exhausted f"..frame.."\n"); io.flush() + manager.machine:exit() + end +end) +LUA + +runLog=$(timeout -s KILL "$WALL" "$MAME" x68000 -bios ipl10 \ + -rompath "$SP/x68mame/roms" -flop1 "$work/boot.xdf" -flop2 "$work/data.xdf" \ + -video none -sound none -nothrottle \ + -autoboot_script "$work/bench.lua" &1 | grep '^BENCH') +echo "$runLog" + +# The backstop is not a normal exit: it means UBER never reached its done-screen, +# so whatever log is on the disk (if any) is from an unfinished run. +if echo "$runLog" | grep -q 'budget-exhausted'; then + echo "bench-x68000: FAIL - UBER's done-screen never appeared within $FRAMES frames" >&2 + echo " (raise X68K_BENCH_FRAMES if UBER legitimately got slower; otherwise the run died early)" >&2 + exit 1 +fi +if ! echo "$runLog" | grep -q 'done-screen'; then + echo "bench-x68000: FAIL - MAME stopped before UBER finished (wall timeout ${WALL}s?)" >&2 + exit 1 +fi + +python3 "$repo/tools/xdftool.py" extract "$work/data.xdf" joeylog.txt "$work/x68.txt" || { + echo "bench-x68000: FAIL - no joeylog.txt (run did not reach jlLogFlush)" >&2 + exit 1 +} +tr -d '\r' < "$work/x68.txt" > "$work/x68.clean" +cp "$work/x68.clean" "$repo/build/x68000/joeylog.txt" +grep -E '^UBER:' "$work/x68.clean" +echo "bench-x68000: full log -> $repo/build/x68000/joeylog.txt" diff --git a/scripts/captureDos.sh b/scripts/captureDos.sh index 3bf05ad..9c98a24 100755 --- a/scripts/captureDos.sh +++ b/scripts/captureDos.sh @@ -41,14 +41,19 @@ xvfb_pid=$! sleep 0.3 trap 'kill $xvfb_pid 2>/dev/null || true; kill $dosbox_pid 2>/dev/null || true' EXIT +# Mount with an explicit -c, not by handing DOSBox the directory as a +# positional argument: 0.74-3 ignored it here and dropped the program at +# "Drive C does not exist", which looks exactly like a program that +# failed to start. The cycles override has to be one argv entry too -- +# unquoted it split into three words and ate the path. +dosboxArgs=(-conf "$conf" -set "mouse_capture=seamless") +if [[ -n ${CYCLES:-} ]]; then + dosboxArgs+=(-set "cpu cycles=${CYCLES}") +fi +dosboxArgs+=(-c "mount c \"$mount_dir\"" -c "C:" -c "$file") + DISPLAY=$display SDL_VIDEO_X11_VISUALID="" \ - dosbox \ - -conf "$conf" \ - -set "mouse_capture=seamless" \ - ${CYCLES:+-set "cpu cycles=${CYCLES}"} \ - -c "C:" \ - -c "$file" \ - "$mount_dir" >/tmp/captureDos-dosbox.log 2>&1 & + dosbox "${dosboxArgs[@]}" >/tmp/captureDos-dosbox.log 2>&1 & dosbox_pid=$! # Wait for DOSBox to boot the program and settle. diff --git a/scripts/make-iigs-game-disk.sh b/scripts/make-iigs-game-disk.sh new file mode 100755 index 0000000..9907b5e --- /dev/null +++ b/scripts/make-iigs-game-disk.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# make-iigs-game-disk.sh - Build ONE self-booting GS/OS volume that boots +# straight into a JoeyLib program, with the program in its own folder so +# that folder can be copied to a hard drive and run from there. +# +# Layout of the result: +# //System/... a GS/OS boot system stripped to what JoeyLib +# actually uses (see the drop list below) +# //System/Start tools/iigsStart, which sets prefix 0 and +# QuitGS's to the program below +# /// the program, its ntpplayer and its DATA tree +# +# The program folder is self-contained: JoeyLib opens "ntpplayer" and +# "DATA/..." relative to prefix 0, which GS/OS points at the launched +# program's directory. So the same folder runs from a hard drive after a +# plain Finder copy, and nothing in it names the boot volume. +# +# WHY STRIP: JoeyLib's whole toolbox usage on the IIgs is MMStartUp, +# NewHandle, DisposeHandle, HLock, GetTick, SetHeartBeat, DelHeartBeat, +# ReadBParam, SoundStartUp and SoundShutDown, plus GS/OS file calls and +# QuitGS. Those live in the Memory Manager, the Misc Tool Set and the +# Sound Tool Set, all three in ROM. Not one of the thirteen RAM tool sets +# in /System/Tools is referenced, and neither are the desk accessories, +# the control panels, ProDOS 8 or the Finder's icon database. On the stock +# disk that is over 300 KB read at every boot for nothing, and it is also +# the room the program needs in order to share the volume. +# +# Usage: scripts/make-iigs-game-disk.sh [volume] [data.2mg] +# out .po for an 800KB floppy, .2mg for anything larger +# PROG uppercase binary name, e.g. STAXI +# volume boot volume name, default = PROG +# data.2mg disk holding PROG + ntpplayer + DATA, +# default build/iigs/bin/joey.2mg +# Env: JOEY_GAME_DISK_SIZE cadius volume size, default "auto": +# 800KB when the program fits beside a stripped system on a +# floppy, otherwise 2048KB. cadius takes 800KB, 1440KB, 2048KB +# and 32MB, nothing else. 800KB reuses the stock system image +# in place; every other size builds a fresh volume and copies +# the ProDOS boot blocks onto it. NOTE: MAME's emulated 3.5 +# drive is 800KB GCR and REJECTS a 1440KB image, so anything +# larger has to boot from a CFFA2 card: +# -sl7 cffa2 -hard1 . +# Requires: toolchains/env.sh sourced. +set -euo pipefail + +out=${1:?usage: make-iigs-game-disk.sh [volume] [data.2mg]} +prog=${2:?usage: make-iigs-game-disk.sh [volume] [data.2mg]} +vol=${3:-$prog} + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +: "${LLVM816_ROOT:?make-iigs-game-disk.sh: source toolchains/env.sh first}" +CADIUS="${CADIUS:-$LLVM816_ROOT/tools/cadius/cadius}" +data=${4:-$repo/build/iigs/bin/joey.2mg} +sysDisk="$repo/toolchains/emulators/support/gsos-system.po" +size=${JOEY_GAME_DISK_SIZE:-auto} + +[ -x "$CADIUS" ] || { echo "make-iigs-game-disk.sh: cadius not found at $CADIUS" >&2; exit 2; } +[ -f "$sysDisk" ] || { echo "make-iigs-game-disk.sh: missing $sysDisk" >&2; exit 2; } +[ -f "$data" ] || { echo "make-iigs-game-disk.sh: missing data disk $data" >&2; exit 2; } + +work=$(mktemp -d -t joeylib-gamedisk.XXXXXX) +trap 'rm -rf "$work"' EXIT + +# Volume name of an image, from the one whole line of the form "/NAME/". +# An unanchored match also hits the absolute image path in cadius's header. +volumeName() { + "$CADIUS" CATALOG "$1" 2>/dev/null | grep -oE '^/[A-Za-z0-9._]+/$' | head -1 | tr -d '/' +} +freeBlocks() { "$CADIUS" CATALOG "$1" 2>/dev/null | grep -oE 'Free : [0-9]+' | grep -oE '[0-9]+'; } +totalBlocks() { "$CADIUS" CATALOG "$1" 2>/dev/null | grep -oE 'Block : [0-9]+' | grep -oE '[0-9]+'; } + +sysVol=$(volumeName "$sysDisk") +[ -n "$sysVol" ] || { echo "make-iigs-game-disk.sh: cannot read system volume name" >&2; exit 1; } +dataVol=$(volumeName "$data") +[ -n "$dataVol" ] || { echo "make-iigs-game-disk.sh: cannot read data volume name" >&2; exit 1; } + +# ----- the stripped system, staged on a copy of the stock disk ----- +staged="$work/system.po" +cp "$sysDisk" "$staged" + +drop=( + "BASIC.System" # ProDOS 8 BASIC + "Finder.Data" + "Icons/FType.Apple" # Finder file-type database + "System/Start" # the Finder itself + "System/P8" # the ProDOS 8 kernel + "System/Finder.Data" + "System/CDevs/CDev.Data" + "System/CDevs/Printer" + "System/CDevs/Time" + "System/Desk.Accs/ControlPanel" + "System/Drivers/AppleDisk5.25" + "System/Drivers/Finder.Data" + "System/Fonts/Font.Lists" + "System/Tools/Tool014" # Window Manager + "System/Tools/Tool015" # Menu Manager + "System/Tools/Tool016" # Control Manager + "System/Tools/Tool018" # QuickDraw II Auxiliary + "System/Tools/Tool019" # Print Manager + "System/Tools/Tool020" # LineEdit + "System/Tools/Tool021" # Dialog Manager + "System/Tools/Tool022" # Scrap Manager + "System/Tools/Tool023" # Standard File + "System/Tools/Tool025" # Note Synthesizer + "System/Tools/Tool027" # Font Manager + "System/Tools/Tool028" # List Manager + "System/Tools/Tool034" # TextEdit +) +# What canNOT come off, each one measured on a CFFA2 boot 2026-09-22: +# System/Error.Msg GS/OS halts with "Error loading +# ERROR.MSG file. Error=$0046". +# System.Setup/Tool.Setup + TS3 dropping both hangs in the ROM +# banner before GS/OS starts. +# System.Setup/TS2 the ROM 01 half of the same tool +# patches. Kept on purpose: plenty of +# ROM 01 machines are still out there +# and a game disk has to boot on them. +# It costs 72 blocks. +# System.Setup/Resource.Mgr, +# Drivers/Console.Driver, +# FSTs/Char.FST dropping all three hangs the same way. +# The two groups were bisected separately and each one is load-bearing, so +# the list above is the floor. Boot to the program went from 43.4 s to +# 21.8 s in MAME, and the freed space is what lets the program share the +# volume. +for f in "${drop[@]}"; do + "$CADIUS" DELETEFILE "$staged" "/$sysVol/$f" >/dev/null 2>&1 || true +done + +# How many blocks the program folder will want, and what a stripped +# floppy has left after the strip above. 24 blocks of slack covers the +# launcher and the directory blocks the new subfolders need. +progBlocks=$(( $(totalBlocks "$data") - $(freeBlocks "$data") )) +floppyFree=$(freeBlocks "$staged") +if [ "$size" = "auto" ]; then + if [ $(( progBlocks + 24 )) -le "$floppyFree" ]; then + size=800KB + else + size=2048KB + fi +fi + +# ----- the output volume ----- +# Always a freshly created volume, whatever the size: that way cadius sets +# up the image container the output's extension calls for (.po raw, .2mg +# with its 64-byte header), and one code path covers every size. A +# cadius-created volume has no boot blocks, so blocks 0 and 1 are copied +# from the stock image. That code reads the volume directory and loads +# /PRODOS by name, so it does not care how large the volume is or what it +# is called. +"$CADIUS" CREATEVOLUME "$out" "$vol" "$size" >/dev/null +mkdir -p "$work/sys" +"$CADIUS" EXTRACTVOLUME "$staged" "$work/sys" >/dev/null +sysSrc="$work/sys/$sysVol" +[ -d "$sysSrc" ] || { echo "make-iigs-game-disk.sh: extraction produced no /$sysVol" >&2; exit 1; } +# ADDFOLDER copies a directory's CONTENTS into the named ProDOS folder, +# keeping nested subfolders, and reads back the file types that +# EXTRACTVOLUME recorded in each _FileInformation.txt. One call moves the +# whole system across. +"$CADIUS" ADDFOLDER "$out" "/$vol" "$sysSrc" >/dev/null +srcOff=0; dstOff=0 +[ "$(head -c 4 "$staged")" = "2IMG" ] && srcOff=64 +[ "$(head -c 4 "$out")" = "2IMG" ] && dstOff=64 +dd if="$staged" of="$work/boot.blk" bs=1 skip=$srcOff count=1024 status=none +dd if="$work/boot.blk" of="$out" bs=1 seek=$dstOff count=1024 conv=notrunc status=none + +# ----- the launcher ----- +"$repo/toolchains/iigs/clang-build.sh" \ + -DJOEY_START_PATH="\"/$vol/$prog/$prog\"" \ + -o "$work/Start#B30000" \ + "$repo/tools/iigsStart/iigsStart.c" \ + "$repo/tools/iigsStart/iigsStartQuit.s" >/dev/null +"$CADIUS" ADDFILE "$out" "/$vol/System" "$work/Start#B30000" >/dev/null + +# ----- the program folder, lifted whole off the data disk ----- +mkdir -p "$work/game" +"$CADIUS" EXTRACTVOLUME "$data" "$work/game" >/dev/null +src="$work/game/$dataVol" +[ -d "$src" ] || { echo "make-iigs-game-disk.sh: extraction produced no /$dataVol" >&2; exit 1; } + +free=$(freeBlocks "$out") +if [ "$free" -lt "$progBlocks" ]; then + echo "make-iigs-game-disk.sh: /$prog needs $progBlocks blocks and a $size volume has only $free free." >&2 + echo " Build a larger one (JOEY_GAME_DISK_SIZE=2048KB) and boot it from a CFFA2 card:" >&2 + echo " mame apple2gs -sl7 cffa2 -hard1 " >&2 + exit 1 +fi +"$CADIUS" CREATEFOLDER "$out" "/$vol/$prog" >/dev/null +"$CADIUS" ADDFOLDER "$out" "/$vol/$prog" "$src" >/dev/null + +# Never trust an exit code here: cadius reports most failures on stdout. +# Assert the three things the disk is worthless without. +catalog=$("$CADIUS" CATALOG "$out" 2>/dev/null) +for want in "ProDOS" "Start" "$prog"; do + echo "$catalog" | grep -qE "^ +$want +" || { + echo "make-iigs-game-disk.sh: $want is NOT on $out" >&2 + exit 1 + } +done + +if [ "$size" = "800KB" ]; then + how="bootable 3.5\" floppy" +else + how="boot with: mame apple2gs -sl7 cffa2 -hard1 $(basename "$out")" +fi +echo "iigs-game-disk: $out" +echo " /$vol boots into /$vol/$prog/$prog ($size, $(freeBlocks "$out") blocks free)" +echo " $how" diff --git a/scripts/profile-x68000.sh b/scripts/profile-x68000.sh new file mode 100755 index 0000000..354ea89 --- /dev/null +++ b/scripts/profile-x68000.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# profile-x68000.sh - PC-sampling profiler for a running X68000 example. +# +# Human68k relocates a .X anywhere it likes and the ELF beside it is linked at +# zero, so the first job is finding where the program actually landed. Every +# xdev68k binary opens with the same "#NEWLIB" marker two bytes into .text, so +# the script scans RAM for that -- but the marker appears TWICE, because the +# loader leaves a copy of the file in a disk buffer, and picking the wrong one +# silently attributes every sample to whatever symbol happens to sit at that +# offset. So it collects every candidate and then lets the program counter +# choose -- except the two ranges OVERLAP, so containment is not enough. What +# separates them is that Human68k RELOCATES the copy it runs: at a known +# relocation site the running image holds fileValue + base, while the disk +# buffer still holds fileValue. From then on +# it samples the PC once per emulated frame, buckets by offset into .text, and +# attributes the buckets to functions with nm. +# +# Sampling once per frame is coarse (55 Hz), but this port runs a game frame in +# 150 ms or more, so each frame still gets several samples and the shape of the +# profile is clear. Read it as "which functions dominate", never as a timing. +# +# Usage: scripts/profile-x68000.sh [example] [seconds] [skipSeconds] +# example .X base name in build/x68000/bin, default staxi +# seconds emulated seconds to run, default 240 +# skipSeconds ignore samples before this, default 130 (boot + menu) +# Env: X68K_DIR dir holding HUMAN302.XDF + roms/ (default toolchains/x68mame) +# Requires: the example built WITH symbols (build/x68000/bin/.X.elf). +set -uo pipefail + +prog=${1:-staxi} +seconds=${2:-240} +skip=${3:-130} +PROG=$(echo "$prog" | tr '[:lower:]' '[:upper:]') + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +bin=$repo/build/x68000/bin/$PROG.X +elf=$bin.elf +x68=${X68K_DIR:-$repo/toolchains/x68mame} +template=$x68/HUMAN302.XDF +export PATH="$repo/toolchains/x68000/m68k-xelf/bin:$PATH" + +MAME="${MAME:-$repo/toolchains/cache/mame-mame0264/x68k}" +[[ -x $MAME ]] || MAME="$(command -v mame || true)" +[[ -n ${MAME:-} && -x $MAME ]] || { echo "profile-x68000: no MAME (set MAME=...)" >&2; exit 2; } +[[ -f $bin ]] || { echo "profile-x68000: $bin not built" >&2; exit 2; } +[[ -f $elf ]] || { echo "profile-x68000: $elf missing (need symbols)" >&2; exit 2; } + +# .text size from the ELF, so a candidate base can be tested by containment. +textSize=$(m68k-xelf-readelf -S "$elf" | awk '/\.text/ { print strtonum("0x" $7); exit }') +[[ -n ${textSize:-} && $textSize -gt 0 ]] || { echo "profile-x68000: cannot read .text size" >&2; exit 2; } + +# A 32-bit relocation site in .text, and the value the .X file carries there. +# The .X header is 64 bytes, so the file offset is 64 + the .text offset. +relocSite=$(m68k-xelf-readelf -r "$elf" | awk '$2 ~ /01$/ && $3 == "R_68K_32" { print strtonum("0x" $1); exit }') +[[ -n ${relocSite:-} ]] || { echo "profile-x68000: no R_68K_32 relocation to fingerprint with" >&2; exit 2; } +relocValue=$(python3 -c " +import sys +d = open('$bin','rb').read() +o = 64 + $relocSite +print(int.from_bytes(d[o:o+4],'big')) +") + +# gMouseX is written once per input poll, i.e. once per game frame. +mouseXOff=$(m68k-xelf-nm "$elf" | awk '$3 == "gMouseX" { print strtonum("0x" $1); exit }') +[[ -n ${mouseXOff:-} ]] || { echo "profile-x68000: gMouseX not found; cannot count frames" >&2; exit 2; } + +work=$(mktemp -d -t joey-x68prof.XXXXXX) +trap 'rm -rf "$work"' EXIT + +printf 'B:\r\nB:\\%s.X\r\n\x1a' "$PROG" > "$work/AUTOEXEC.BAT" +cp "$template" "$work/boot.xdf" +cp "$template" "$work/data.xdf" +python3 "$repo/tools/xdftool.py" empty "$work/data.xdf" >/dev/null +for f in USKCG.SYS BEEP.SYS KEY.SYS STARTUP.ENV; do + python3 "$repo/tools/xdftool.py" delete "$work/boot.xdf" "$f" >/dev/null 2>&1 || true +done +for d in SYS HIS BIN BASIC2 ASK ETC; do + python3 "$repo/tools/xdftool.py" deltree "$work/boot.xdf" "$d" >/dev/null 2>&1 || true +done +python3 "$repo/tools/xdftool.py" add "$work/boot.xdf" "$work/AUTOEXEC.BAT" AUTOEXEC.BAT >/dev/null +python3 "$repo/tools/xdftool.py" add "$work/data.xdf" "$bin" "$PROG.X" >/dev/null +data_dir=$repo/build/x68000/bin/DATA +if [[ -d $data_dir ]]; then + while IFS= read -r f; do + rel=${f#"$data_dir"/} + python3 "$repo/tools/xdftool.py" add "$work/data.xdf" "$f" \ + "DATA/$(echo "$rel" | tr '[:lower:]' '[:upper:]')" >/dev/null + done < <(find "$data_dir" -type f | sort) +fi + +cat > "$work/prof.lua" <= 0x400000 then + scanDone = true + return + end + if mem:read_u32(scanAt) == 0x234E4557 and mem:read_u32(scanAt + 4) == 0x4C494200 then + cands[#cands + 1] = scanAt - 2 + end + scanAt = scanAt + 2 + end +end + +-- Which candidate is the copy Human68k is RUNNING? At relocSite the .X file +-- holds relocValue; the loaded image holds relocValue + its own base, because +-- the loader patched it. The untouched disk buffer still matches the file. +-- The PC must also be inside it, which rules out a stale image of an older +-- build sitting in memory. +local function pickBase() + local pc = cpu.state[pcReg].value + for _, c in ipairs(cands) do + if pc >= c and pc < c + textSize then + local got = mem:read_u32(c + relocSite) + if got == ((relocValue + c) & 0xFFFFFFFF) then + return c + end + end + end + return nil +end + +emu.register_frame_done(function() + local t = manager.machine.time:as_double() + if base == nil then + if not scanDone then + scanSlice() + return + end + base = pickBase() + if base ~= nil then + out:write(string.format("base %08X at %.1fs (of %d candidates)\n", base, t, #cands)) + out:flush() + -- gMouseX is written once per jlpInputPoll, which the game calls + -- once a frame, so counting writes to it counts game frames. That + -- turns "how much of the frame is X" into "and how long is a + -- frame", which is the difference between a real speedup and one + -- that only buys more idle time. + fpsTap = mem:install_write_tap(base + mouseXOff, base + mouseXOff + 1, "fps", function(offset, data, mask) + frames = frames + 1 + return data + end) + fpsStart = t + return + end + -- Nothing the PC is inside yet: the program is not loaded, or it is + -- and the sweep predates it. Sweep again. The boot takes a couple of + -- minutes, so the first few passes finding nothing is the normal case. + cands = {} + scanAt = 0x1000 + scanDone = false + return + end + if t < skipUntil then return end + local pc = cpu.state[pcReg].value + local key + if pc >= base and pc < base + textSize then + -- The EXACT offset, not a 64-byte bucket. Buckets straddle function + -- boundaries, and a bucket credited to the symbol at its start hands + -- one function's samples to its neighbour: that is how a vblank wait + -- came out looking like an expensive input poll. + key = pc - base + else + key = -1 -- ROM, IOCS, anything not ours + end + buckets[key] = (buckets[key] or 0) + 1 + total = total + 1 + if t >= stopAt then + out:write(string.format("fps %.2f frames %d window %.1f tapAt %08X\n", + (t > fpsStart) and (frames / (t - fpsStart)) or 0.0, frames, t - fpsStart, base + mouseXOff)) + out:write(string.format("total %d\n", total)) + for k, v in pairs(buckets) do + out:write(string.format("%d %d\n", k, v)) + end + out:write("DONE\n") + out:close() + manager.machine:exit() + end +end) +LUA + +( cd "$work" && SDL_AUDIODRIVER=dummy timeout 3600 "$MAME" x68000 \ + -bios ipl10 -rompath "$x68/roms" \ + -flop1 boot.xdf -flop2 data.xdf \ + -video none -sound none -nothrottle -skip_gameinfo \ + -autoboot_script prof.lua mame.log 2>&1 ) || true + +[[ -s $work/prof.txt ]] || { echo "profile-x68000: FAIL (no samples)" >&2; exit 1; } +grep -q '^base ' "$work/prof.txt" || { echo "profile-x68000: FAIL (never found the load base)" >&2; exit 1; } + +m68k-xelf-nm -n "$elf" > "$work/syms.txt" +python3 - "$work/prof.txt" "$work/syms.txt" <<'PY' +import sys +prof, symfile = sys.argv[1], sys.argv[2] +syms = [] +for line in open(symfile): + p = line.split() + if len(p) == 3 and p[1] in ('t', 'T'): + syms.append((int(p[0], 16), p[2])) +syms.sort() + +def owner(off): + lo, hi = 0, len(syms) - 1 + best = None + while lo <= hi: + mid = (lo + hi) // 2 + if syms[mid][0] <= off: + best = syms[mid][1] + lo = mid + 1 + else: + hi = mid - 1 + return best or "?" + +total = 0 +fps = None +hits = {} +buckets = {} +outside = 0 +for line in open(prof): + p = line.split() + if p and p[0] == 'total': + total = int(p[1]) + elif len(p) == 2 and p[0].lstrip('-').isdigit(): + off, n = int(p[0]), int(p[1]) + buckets[off] = buckets.get(off, 0) + n + if off < 0: + outside += n + else: + hits[owner(off)] = hits.get(owner(off), 0) + n +if total == 0: + sys.exit("profile-x68000: no samples recorded") +rows = sorted(hits.items(), key=lambda kv: -kv[1]) +fpsText = f", game running at {fps:.1f} fps" if fps is not None and fps > 0.0 else " (frame count unavailable)" +print(f"profile-x68000: {total} samples, {outside} ({outside*100.0/total:.1f}%) outside the program (ROM/IOCS){fpsText}") +shown = 0 +for name, n in rows[:25]: + print(f" {n*100.0/total:5.1f}% {n:6d} {name}") + shown += n +print(f" {(total-outside-shown)*100.0/total:5.1f}% {total-outside-shown:6d} (everything else)") + +# The hottest 64-byte buckets, named as symbol+offset. Function totals say +# which routine to look at; these say which LINE of it, which is the +# difference between rewriting a loop and rewriting the wrong loop. +print("\nhottest addresses (symbol + offset into it):") +def ownerOff(off): + lo, hi = 0, len(syms) - 1 + best = None + while lo <= hi: + mid = (lo + hi) // 2 + if syms[mid][0] <= off: + best = syms[mid] + lo = mid + 1 + else: + hi = mid - 1 + return (best[1], off - best[0]) if best else ("?", 0) +for off, n in sorted(buckets.items(), key=lambda kv: -kv[1])[:20]: + if off < 0: + continue + name, delta = ownerOff(off) + print(f" {n*100.0/total:5.1f}% {n:6d} {name}+0x{delta:x}") +PY diff --git a/scripts/run-amiga-headless.sh b/scripts/run-amiga-headless.sh new file mode 100755 index 0000000..e164fea --- /dev/null +++ b/scripts/run-amiga-headless.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# run-amiga-headless.sh - Run one built Amiga example under FS-UAE with no +# window, wait, then hand back the example's joeylog.txt and a screenshot. +# +# This is the smoke test bench-amiga.sh cannot be: that script runs UBER +# and waits for its "press any key to exit" line, but a game never exits. +# Here the run is simply given N seconds and whatever it logged and drew +# is reported. +# +# The Amiga's joeylog.txt lands in the directory mounted as the hard +# drive, so it can be read straight off the host while the machine runs. +# +# Usage: scripts/run-amiga-headless.sh [example] [seconds] [fastKB] +# example binary name in build/amiga/bin, default Taxi +# seconds how long to let it run, default 90 +# fastKB fast memory in KB, default 2048. Pass 0 for a +# genuinely bare 512 KB-chip A500 (slow memory off +# as well), which is the tight case. +# Requires: fs-uae, Xvfb, toolchains/emulators/support/kickstart.rom. +set -euo pipefail + +prog=${1:-Taxi} +seconds=${2:-90} +fastKb=${3:-2048} + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +binDir=$repo/build/amiga/bin +support=$repo/toolchains/emulators/support +kickstart=$support/kickstart.rom +outDir=${AMIGA_OUT_DIR:-$repo/build/amiga/headless} + +# Most examples are a bare binary in bin/; Space Taxi ships as its own +# drawer holding the binary and its DATA tree, so accept either shape. +if [ -d "$binDir/$prog" ]; then + progDir=$binDir/$prog + [ -f "$progDir/$prog" ] || { echo "run-amiga-headless: $progDir/$prog not built" >&2; exit 2; } +elif [ -f "$binDir/$prog" ]; then + progDir="" +else + echo "run-amiga-headless: $binDir/$prog not built" >&2 + exit 2 +fi +[ -f "$kickstart" ] || { echo "run-amiga-headless: missing $kickstart" >&2; exit 2; } + +work=$(mktemp -d -t joeylib-amiga-run.XXXXXX) +mkdir -p "$work/s" "$outDir" +if [ -n "$progDir" ]; then + cp -r "$progDir/." "$work/" +else + find "$binDir" -maxdepth 1 -type f -executable -exec cp -t "$work/" {} + + [ -d "$binDir/DATA" ] && cp -r "$binDir/DATA" "$work/" +fi +printf 'stack 32768\n:%s\n' "$prog" > "$work/s/startup-sequence" + +display=:$((90 + RANDOM % 9)) +Xvfb "$display" -screen 0 800x600x24 -nolisten tcp >/dev/null 2>&1 & +xvfbPid=$! +sleep 1 + +fsArgs=( + --amiga_model=A500 + --hard_drive_0="$work" + --hard_drive_0_label=JOEYLIB + --kickstart_file="$kickstart" + --automatic_input_grab=0 + --fullscreen=0 + --fade_out_duration=0 +) +# Always pass the memory explicitly. Leaving --fast_memory off does NOT +# give a bare machine: FS-UAE's A500 default still fits the 512 KB +# trapdoor expansion, which AvailMem reports as ~300 KB of FAST. A real +# unexpanded A500 is 512 KB of CHIP and nothing else, so fastKB=0 has to +# turn the slow memory off too. +fsArgs+=(--fast_memory="$fastKb") +[ "$fastKb" = "0" ] && fsArgs+=(--slow_memory=0) + +DISPLAY=$display fs-uae "${fsArgs[@]}" > "$work/fsuae.log" 2>&1 & +fsPid=$! +cleanup() { + kill "$fsPid" 2>/dev/null || true + kill "$xvfbPid" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT + +echo "run-amiga-headless: $prog for ${seconds}s on an A500 with ${fastKb} KB fast..." >&2 +elapsed=0 +while [ "$elapsed" -lt "$seconds" ]; do + sleep 5 + elapsed=$((elapsed + 5)) + kill -0 "$fsPid" 2>/dev/null || { echo "run-amiga-headless: FS-UAE exited early" >&2; break; } +done + +DISPLAY=$display import -window root "$outDir/$prog.png" 2>/dev/null || true +cp "$work/joeylog.txt" "$outDir/$prog-joeylog.txt" 2>/dev/null || true +cp "$work/fsuae.log" "$outDir/$prog-fsuae.log" 2>/dev/null || true +cleanup +trap - EXIT +rm -rf "$work" + +echo "run-amiga-headless: wrote $outDir/$prog.png" +if [ -f "$outDir/$prog-joeylog.txt" ]; then + echo "--- joeylog.txt (last 25 lines)" + tail -25 "$outDir/$prog-joeylog.txt" +else + echo "run-amiga-headless: NO joeylog.txt -- the example never logged anything" >&2 +fi diff --git a/scripts/run-atarist-headless.sh b/scripts/run-atarist-headless.sh new file mode 100755 index 0000000..6acd59e --- /dev/null +++ b/scripts/run-atarist-headless.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# run-atarist-headless.sh - Run one built Atari ST example under Hatari +# with no window, wait, then hand back its joeylog.txt and a screenshot. +# +# The ST twin of run-amiga-headless.sh, and for the same reason: +# bench-atarist.sh runs UBER and waits for its "press any key to exit" +# line, which a game never prints. +# +# Hatari mounts the program's directory as GEMDOS drive C, so the app's +# joeylog.txt lands there and can be read off the host while it runs. +# +# Usage: scripts/run-atarist-headless.sh [example] [seconds] +# example PRG base name in build/atarist/bin, default STAXI +# seconds how long to let it run, default 90 +# Env: ST_SOUND=on keep the YM running (default off, it is faster) +# ST_MEMSIZE=n machine RAM in MB, passed to Hatari. Default is +# Hatari's own; 1 is a stock 1040 ST. +# Requires: hatari, Xvfb, toolchains/emulators/support/emutos-512k.img +set -euo pipefail + +prog=${1:-STAXI} +seconds=${2:-90} + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +binDir=$repo/build/atarist/bin +tos=$repo/toolchains/emulators/support/emutos-512k.img +outDir=${ST_OUT_DIR:-$repo/build/atarist/headless} + +# Most examples are a bare PRG in bin/; Space Taxi ships as its own +# folder holding the PRG and its DATA tree. +if [ -d "$binDir/$prog" ]; then + driveDir=$binDir/$prog +else + driveDir=$binDir +fi +[ -f "$driveDir/$prog.PRG" ] || { echo "run-atarist-headless: $driveDir/$prog.PRG not built" >&2; exit 2; } +[ -f "$tos" ] || { echo "run-atarist-headless: missing $tos" >&2; exit 2; } + +mkdir -p "$outDir" +rm -f "$driveDir/joeylog.txt" + +display=:$((90 + RANDOM % 9)) +Xvfb "$display" -screen 0 800x600x24 -nolisten tcp >/dev/null 2>&1 & +xvfbPid=$! +sleep 1 + +DISPLAY=$display hatari \ + --tos "$tos" \ + --harddrive "$driveDir" \ + --gemdos-drive C \ + --drive-a off --drive-b off \ + --sound "${ST_SOUND:-off}" \ + ${ST_MEMSIZE:+--memsize "$ST_MEMSIZE"} \ + --fast-forward on \ + --auto "C:\\$prog.PRG" > "$outDir/$prog-hatari.log" 2>&1 & +hatPid=$! +cleanup() { + kill "$hatPid" 2>/dev/null || true + kill "$xvfbPid" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT + +echo "run-atarist-headless: $prog for ${seconds}s..." >&2 +elapsed=0 +while [ "$elapsed" -lt "$seconds" ]; do + sleep 5 + elapsed=$((elapsed + 5)) + kill -0 "$hatPid" 2>/dev/null || { echo "run-atarist-headless: Hatari exited early" >&2; break; } +done + +DISPLAY=$display import -window root "$outDir/$prog.png" 2>/dev/null || true +cp "$driveDir/joeylog.txt" "$outDir/$prog-joeylog.txt" 2>/dev/null || true +cleanup +trap - EXIT + +echo "run-atarist-headless: wrote $outDir/$prog.png" +if [ -f "$outDir/$prog-joeylog.txt" ]; then + echo "--- joeylog.txt (last 25 lines)" + tail -25 "$outDir/$prog-joeylog.txt" +else + echo "run-atarist-headless: NO joeylog.txt -- the example never logged anything" >&2 +fi diff --git a/scripts/verify-iigs-game-disk.sh b/scripts/verify-iigs-game-disk.sh new file mode 100755 index 0000000..ef2f533 --- /dev/null +++ b/scripts/verify-iigs-game-disk.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# verify-iigs-game-disk.sh - Boot a disk made by make-iigs-game-disk.sh in +# headless MAME and assert it reaches the program and draws. +# +# Two checkpoints, both read out of memory rather than off the screen: +# 1. $E1:9DC8 == 0xAA. tools/iigsStart writes that marker immediately +# before its QuitGS, so it means GS/OS booted and the launcher ran. +# (0xEE would mean QuitGS returned, i.e. the program did not launch.) +# 2. The Super Hi-Res framebuffer, sampled a while later: the count of +# distinct nibble values, as scripts/verify-iigs.sh uses. A blank +# screen is 1. There is no Finder on these disks, so unlike that +# script's ~9-10 desktop baseline anything above 1 is the program's +# own drawing. +# +# An 800KB image goes in the 3.5 drive; anything larger has to come off a +# CFFA2 card because MAME's drive is 800KB GCR only. +# +# Usage: scripts/verify-iigs-game-disk.sh [minDistinct] [ramsize] +# Env: GAME_VERIFY_FRAMES frame at which to sample (default 5400, 90 s) +# GAME_VERIFY_MACHINE MAME machine, default apple2gs (ROM 3). +# Use apple2gsr1 to check a ROM 01 machine; these disks keep +# TS2 so they must boot on both. +# Requires: toolchains/env.sh sourced. +set -euo pipefail + +img=${1:?usage: verify-iigs-game-disk.sh [minDistinct] [ramsize]} +minDistinct=${2:-2} +ramsize=${3:-4M} +readFrame=${GAME_VERIFY_FRAMES:-5400} +machine=${GAME_VERIFY_MACHINE:-apple2gs} + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +MAME="${MAME:-mame}" +rompath="$HOME/.mame/roms;$repo/toolchains/emulators/support/mame-roms" +name=$(basename "$img") + +[ -f "$img" ] || { echo "verify-iigs-game-disk: no such image $img" >&2; exit 2; } + +# MAME opens a hard-disk image read-write, so two runs must never share +# one file. Work on a private copy. +work=$(mktemp -d -t joeylib-gdverify.XXXXXX) +trap 'rm -rf "$work"' EXIT +cp "$img" "$work/$name" + +bytes=$(stat -c %s "$img") +if [ "$bytes" -le 900000 ]; then + media=(-flop3 "$work/$name") + how="3.5 drive" +else + media=(-sl7 cffa2 -hard1 "$work/$name") + how="CFFA2 card" +fi + +cat > "$work/verify.lua" <> 4) & 0xF + local lo = b & 0xF + if not seen[hi] then seen[hi] = true; distinct = distinct + 1 end + if not seen[lo] then seen[lo] = true; distinct = distinct + 1 end + end + io.write(string.format("GAMEDISK-DRAW name=$name frame=%d distinctNibbles=%d nonZeroBytes=%d scb0=%02X\\n", + frame, distinct, nonzero, mem:read_u8(0xE19D00))) + io.flush() + manager.machine:exit() + end +end) +LUA + +out=$(cd "$work" && QT_QPA_PLATFORM=offscreen SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ + timeout 600 "$MAME" "$machine" \ + -ramsize "$ramsize" -rompath "$rompath" "${media[@]}" \ + -video none -sound none -nothrottle -skip_gameinfo \ + -autoboot_script "$work/verify.lua" &1) || true + +boot=$(echo "$out" | grep -m1 'GAMEDISK-BOOT' || true) +draw=$(echo "$out" | grep -m1 'GAMEDISK-DRAW' || true) +[ -n "$boot" ] && echo "$boot" +[ -n "$draw" ] && echo "$draw" + +if [ -z "$boot" ]; then + echo "verify-iigs-game-disk: FAIL ($name never reached the launcher's QuitGS marker; GS/OS did not boot from the $how)" >&2 + exit 1 +fi +if echo "$boot" | grep -q 'mailbox=EE'; then + echo "verify-iigs-game-disk: FAIL ($name QuitGS returned, so the program did not launch)" >&2 + exit 1 +fi +if [ -z "$draw" ]; then + echo "verify-iigs-game-disk: FAIL ($name booted but the screen was never sampled)" >&2 + exit 1 +fi +distinct=$(echo "$draw" | sed -E 's/.*distinctNibbles=([0-9]+).*/\1/') +if [ "$distinct" -lt "$minDistinct" ]; then + echo "verify-iigs-game-disk: FAIL ($name distinctNibbles=$distinct < $minDistinct - screen looks blank)" >&2 + exit 1 +fi +echo "verify-iigs-game-disk: PASS ($name booted off the $how on $machine and drew, distinctNibbles=$distinct >= $minDistinct)" diff --git a/scripts/verify-x68000-audio.sh b/scripts/verify-x68000-audio.sh new file mode 100755 index 0000000..6f4ce17 --- /dev/null +++ b/scripts/verify-x68000-audio.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# verify-x68000-audio.sh - X68000 audio gate, read off the chips. +# +# Boots a built example on a Human68k floppy pair under headless MAME and +# 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. +# 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). +# 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 +# is the rate inside one burst of feeding that counts, not the +# average: the port stops the chip between sounds, so an average +# over a mostly silent run would say nothing. +# +# Space Taxi is the default because its attract demo exercises the whole +# noise curve (all seventeen distinct table values) and speaks. +# +# Usage: scripts/verify-x68000-audio.sh [example] [seconds] +# Env: X68K_DIR dir holding HUMAN302.XDF + roms/ (default toolchains/x68mame) +# Requires: the example built (make -f make/x68000.mk), MAME with x68000. +set -uo pipefail + +prog=${1:-staxi} +seconds=${2:-160} +PROG=$(echo "$prog" | tr '[:lower:]' '[:upper:]') + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +bin=$repo/build/x68000/bin/$PROG.X +x68=${X68K_DIR:-$repo/toolchains/x68mame} +template=$x68/HUMAN302.XDF +source=$repo/src/x68000/audio.c + +MAME="${MAME:-$repo/toolchains/cache/mame-mame0264/x68k}" +if [[ ! -x $MAME ]]; then + MAME="$(command -v mame || true)" +fi +[[ -n ${MAME:-} && -x $MAME ]] || { echo "verify-x68000-audio: no MAME (set MAME=...)" >&2; exit 2; } +[[ -f $bin ]] || { echo "verify-x68000-audio: $bin not built" >&2; exit 2; } +[[ -f $template ]] || { echo "verify-x68000-audio: missing $template" >&2; exit 2; } + +# The noise table, straight out of the port so the two cannot drift apart. +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())) +PY +) || { echo "verify-x68000-audio: cannot read kOpmNoiseFreq from $source" >&2; exit 2; } + +work=$(mktemp -d -t joey-x68audio.XXXXXX) +trap 'rm -rf "$work"' EXIT + +printf 'B:\r\nB:\\%s.X\r\n\x1a' "$PROG" > "$work/AUTOEXEC.BAT" +cp "$template" "$work/boot.xdf" +cp "$template" "$work/data.xdf" +python3 "$repo/tools/xdftool.py" empty "$work/data.xdf" >/dev/null +for f in USKCG.SYS BEEP.SYS KEY.SYS STARTUP.ENV; do + python3 "$repo/tools/xdftool.py" delete "$work/boot.xdf" "$f" >/dev/null 2>&1 || true +done +for d in SYS HIS BIN BASIC2 ASK ETC; do + python3 "$repo/tools/xdftool.py" deltree "$work/boot.xdf" "$d" >/dev/null 2>&1 || true +done +python3 "$repo/tools/xdftool.py" add "$work/boot.xdf" "$work/AUTOEXEC.BAT" AUTOEXEC.BAT >/dev/null +python3 "$repo/tools/xdftool.py" add "$work/data.xdf" "$bin" "$PROG.X" >/dev/null +data_dir=$repo/build/x68000/bin/DATA +if [[ -d $data_dir ]]; then + while IFS= read -r f; do + rel=${f#"$data_dir"/} + python3 "$repo/tools/xdftool.py" add "$work/data.xdf" "$f" \ + "DATA/$(echo "$rel" | tr '[:lower:]' '[:upper:]')" >/dev/null + done < <(find "$data_dir" -type f | sort) +fi + +# The taps keep to arithmetic and file writes: a tap that reads memory +# takes MAME down with it. +cat > "$work/tap.lua" <> 8) & 0xFF end + if offset < 0xE90002 then + sel = v + elseif sel == 0x0F then + out:write(string.format("NOISE %02X\\n", v)) + end + return data +end) +ppiTap = mem:install_write_tap(0xE9A004, 0xE9A005, "ppi", function(offset, data, mask) + local v = data & 0xFF + if (mask & 0xFF) == 0 then v = (data >> 8) & 0xFF end + out:write(string.format("PORTC %02X\\n", v)) + return data +end) +-- The port stops the chip between sounds, so the honest figure is the rate +-- inside one burst of feeding, not an average across the silence. A gap of +-- more than 100 ms ends a burst; bursts shorter than 300 ms are too short to +-- time accurately and are skipped. +local function closeBurst() + if burstLast > burstStart + 0.3 and burstCount > 0 then + local r = burstCount / (burstLast - burstStart) + if r > bestRate then bestRate = r end + end + burstCount = 0 +end +pcmTap = mem:install_write_tap(0xE92000, 0xE92003, "pcm", function(offset, data, mask) + local t = manager.machine.time:as_double() + pcmWrites = pcmWrites + 1 + if burstLast < 0 or (t - burstLast) > 0.1 then + closeBurst() + burstStart = t + end + burstLast = t + burstCount = burstCount + 1 + return data +end) +emu.register_frame_done(function() + local t = manager.machine.time:as_double() + if t >= stop then + closeBurst() + out:write(string.format("PCMTOTAL %d\\n", pcmWrites)) + out:write(string.format("PCMRATE %.0f\\n", bestRate)) + out:write("DONE\\n") + out:close() + manager.machine:exit() + end +end) +LUA + +( cd "$work" && SDL_AUDIODRIVER=dummy timeout 1800 "$MAME" x68000 \ + -bios ipl10 -rompath "$x68/roms" \ + -flop1 boot.xdf -flop2 data.xdf \ + -video none -sound none -nothrottle -skip_gameinfo \ + -autoboot_script tap.lua mame.log 2>&1 ) || true + +[[ -f $work/audio.txt ]] || { echo "verify-x68000-audio: FAIL (MAME produced no tap output)" >&2; exit 1; } + +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 +for line in open(path): + p = line.split() + if not p: + continue + if p[0] == "NOISE": + noise.append(int(p[1], 16)) + elif p[0] == "PORTC": + portc.append(int(p[1], 16)) + elif p[0] == "PCMRATE": + rate = float(p[1]) + elif p[0] == "DONE": + done = True + +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. +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)") + +# 2. Every enabled noise write must name a table entry. +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}") + +# 3. Byte feed rate: two samples per byte at 7812 Hz. +if rate is None: + fails.append("the ADPCM channel was never fed") +elif not (3500.0 <= rate <= 4300.0): + fails.append(f"ADPCM in-burst feed {rate:.0f} bytes/s, want ~3906 " + f"({'half rate, the divider is wrong' if rate < 2600 else 'off rate'})") + +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}") +if fails: + for f in fails: + print(f"verify-x68000-audio: FAIL ({f})", file=sys.stderr) + sys.exit(1) +print("verify-x68000-audio: PASS") +PY diff --git a/src/amiga/amigaPlanar.h b/src/amiga/amigaPlanar.h index a42ca10..068a252 100644 --- a/src/amiga/amigaPlanar.h +++ b/src/amiga/amigaPlanar.h @@ -16,6 +16,10 @@ #define AMIGA_BITPLANES 4 #define AMIGA_BYTES_PER_ROW (SURFACE_WIDTH / 8) #define AMIGA_PLANE_SIZE (AMIGA_BYTES_PER_ROW * SURFACE_HEIGHT) +// What a surface actually allocates per plane: one byte of slack past the end, +// which src/m68k/planarFill.s relies on. See the note at the AllocMem in +// jlpSurfaceAllocPortData. +#define AMIGA_PLANE_ALLOC (AMIGA_PLANE_SIZE + 1u) // jlSurfaceT.portData points to this on Amiga: the 4 plane base // pointers + stride. Every surface (including the stage, which gets diff --git a/src/amiga/audio.c b/src/amiga/audio.c index 68ccace..0230d9b 100644 --- a/src/amiga/audio.c +++ b/src/amiga/audio.c @@ -118,11 +118,26 @@ static BYTE *gNoiseChip = NULL; // chip-RAM LFSR noise for channel 3 // ~28.6 kHz bright hiss, pitch 31 = ~2.0 kHz sample-and-hold rumble. // Every entry is inside Paula's legal 124..65535 range by // construction. Derivation: round(124 * (1773/124)^(i/31)). +// Paula periods for noise pitch 0..31, chosen so the sample plays at the +// SAME rate the other ports give that pitch: rate = 26000 - pitch * 750, +// period = 3546895 / rate (PAL colour clock). That is the IIgs curve +// (NOISE_RATE_MAX / NOISE_RATE_STEP in src/iigs/audio_full.c), and it +// also lands within about a tenth of the ST's YM2149 at the top of the +// range, so all three agree on what a given pitch sounds like. +// +// The old table was geometric, an even ratio per step from 124 to 1773. +// It matched at pitch 0 and drifted ever darker above that: at the +// pitches Space Taxi's level-intro tune actually uses -- 28, 29 and 30, +// its only noise notes -- it ran 2587/2376/2180 Hz against the IIgs's +// 5000/4250/3500 and the ST's 4464/4310/4167. An octave too dark, which +// is why that one screen's music sounded wrong on the Amiga while every +// other song, none of which touches the noise voice, sounded right. +// Smallest period here is 136, comfortably above Paula's 124 floor. static const UWORD kNoisePeriod[NOISE_PITCH_MAX + 1] = { - 124, 135, 147, 160, 175, 190, 208, 226, - 246, 268, 292, 319, 347, 378, 412, 449, - 489, 533, 581, 633, 690, 752, 819, 892, - 972, 1060, 1154, 1258, 1371, 1493, 1627, 1773 + 136, 140, 145, 149, 154, 159, 165, 171, + 177, 184, 192, 200, 209, 218, 229, 240, + 253, 268, 284, 302, 322, 346, 373, 405, + 443, 489, 546, 617, 709, 835, 1013, 1290 }; // ----- Internal helpers ----- diff --git a/src/amiga/circle.s b/src/amiga/circle.s deleted file mode 100644 index e36503c..0000000 --- a/src/amiga/circle.s +++ /dev/null @@ -1,270 +0,0 @@ -| Amiga planar circle outline V4 -- 16-way color-specialized. -| -| Per Bresenham iter: -| 1. Precompute 4 xp records (xp_byte_w + bitMask_b + notMask_b) for -| cx +/- bx and cx +/- by, stored at sp+0..15 (4 records x 4 bytes). -| 2. Precompute 4 yp40 words for cy +/- by and cy +/- bx, stored at -| sp+16..23 (4 words x 2 bytes). -| 3. Plot 8 octant pixels with hardcoded color: each pixel does 4 -| branchless plane RMW ops (or.b for set bits, and.b for clear -| bits) -- no btst, no per-plane branch. -| 4. Bresenham step. -| -| At function entry the color is masked to 4 bits and used as the index -| into a 16-entry jump table that selects the matching main loop. -| Each main loop has the color hardcoded into the per-plane RMW ops. -| -| The branchless plot saves ~20-28 cyc per plane vs V3's btst+branch -| pattern -- ~640-900 cyc per Bresenham iter. -| -| ABI: cdecl. d2-d7/a2-a6 callee-save. -| -| void surface68kAmigaCircleOutline(uint8_t *p0, uint8_t *p1, -| uint8_t *p2, uint8_t *p3, -| uint16_t cx, uint16_t cy, -| uint16_t r, uint8_t color); -| -| Register allocation across the iter loop: -| d2.w = bx (Bresenham) -| d3.w = by (Bresenham) -| d4.w = err (Bresenham) -| d5.w = cx (cached) -| a4 = cy (cached, sign-extended) -| a0..a3 = plane bases -| a5 = bitMaskLut -| d0,d1,d6,d7 = scratch in precompute / plot -| -| Scratch block (24 bytes) at sp+0..23: -| sp+0..3: xp1 record [xp_byte_w, bitMask_b, notMask_b] for cx+bx -| sp+4..7: xp2 record for cx-bx -| sp+8..11: xp3 record for cx+by -| sp+12..15: xp4 record for cx-by -| sp+16..17: yp1 word (cy+by) * 40 -| sp+18..19: yp2 word (cy-by) * 40 -| sp+20..21: yp3 word (cy+bx) * 40 -| sp+22..23: yp4 word (cy-bx) * 40 - - .text - - -| ---- XP_REC: build xp record at sp+slot for xp = cx ---- -| signOp: add or sub -| xreg: %d2 (bx) or %d3 (by) -| slot: 0, 4, 8, or 12 -| Trashes: d0, d1, d6, d7 - - .macro XP_REC slot, signOp, xreg - move.w %d5,%d6 - \signOp\().w \xreg,%d6 | d6 = xp - move.w %d6,%d7 - lsr.w #3,%d7 | d7 = xp >> 3 (xp_byte) - and.w #7,%d6 | d6 = xp & 7 - move.b (%a5,%d6.w),%d6 | d6 = bitMask - move.b %d6,%d1 - not.b %d1 | d1 = notMask - move.w %d7,\slot(%sp) | xp_byte word - move.b %d6,\slot+2(%sp) | bitMask byte - move.b %d1,\slot+3(%sp) | notMask byte - .endm - - -| ---- YP_REC: build yp40 word at sp+slot for yp = cy ---- - - .macro YP_REC slot, signOp, yreg - move.l %a4,%d6 - \signOp\().w \yreg,%d6 | d6.w = yp - move.w %d6,%d0 - lsl.w #3,%d6 | d6 = yp << 3 - lsl.w #5,%d0 | d0 = yp << 5 - add.w %d6,%d0 | d0 = yp * 40 - move.w %d0,\slot(%sp) - .endm - - -| ---- PLOT_FIXED: plot one pixel with hardcoded 4-bit color ---- -| slotYp: 16, 18, 20, or 22 (yp40 word slot) -| slotXp: 0, 4, 8, or 12 (xp record slot) -| color: literal 0..15 -| Trashes: d0, d1, d7 - - .macro PLOT_FIXED slotYp, slotXp, color - move.w \slotYp(%sp),%d0 | d0 = yp40 - add.w \slotXp(%sp),%d0 | d0 += xp_byte - move.b \slotXp+2(%sp),%d1 | d1.b = bitMask - move.b \slotXp+3(%sp),%d7 | d7.b = notMask - .if ((\color) & 1) - or.b %d1,(%a0,%d0.w) - .else - and.b %d7,(%a0,%d0.w) - .endif - .if ((\color) & 2) - or.b %d1,(%a1,%d0.w) - .else - and.b %d7,(%a1,%d0.w) - .endif - .if ((\color) & 4) - or.b %d1,(%a2,%d0.w) - .else - and.b %d7,(%a2,%d0.w) - .endif - .if ((\color) & 8) - or.b %d1,(%a3,%d0.w) - .else - and.b %d7,(%a3,%d0.w) - .endif - .endm - - -| ---- PLOT_8: plot all 8 octant pixels for a given hardcoded color ---- - - .macro PLOT_8 color - PLOT_FIXED 16, 0, \color | (cx+bx, cy+by) - PLOT_FIXED 16, 4, \color | (cx-bx, cy+by) - PLOT_FIXED 18, 0, \color | (cx+bx, cy-by) - PLOT_FIXED 18, 4, \color | (cx-bx, cy-by) - PLOT_FIXED 20, 8, \color | (cx+by, cy+bx) - PLOT_FIXED 20, 12, \color | (cx-by, cy+bx) - PLOT_FIXED 22, 8, \color | (cx+by, cy-bx) - PLOT_FIXED 22, 12, \color | (cx-by, cy-bx) - .endm - - -| ---- CO_BODY: full Bresenham loop body for a hardcoded color ---- -| Generates the per-iter precompute, branchless plot, and Bresenham -| step. Uses unique labels via \color suffix. - - .macro CO_BODY color - XP_REC 0, add, %d2 | xp1 = cx+bx - XP_REC 4, sub, %d2 | xp2 = cx-bx - XP_REC 8, add, %d3 | xp3 = cx+by - XP_REC 12, sub, %d3 | xp4 = cx-by - YP_REC 16, add, %d3 | yp1 = cy+by - YP_REC 18, sub, %d3 | yp2 = cy-by - YP_REC 20, add, %d2 | yp3 = cy+bx - YP_REC 22, sub, %d2 | yp4 = cy-bx - - PLOT_8 \color - - addq.w #1,%d3 - tst.w %d4 - bgt .LcoDecX_\color - add.w %d3,%d4 - add.w %d3,%d4 - addq.w #1,%d4 - bra.w .LcoLoop_\color -.LcoDecX_\color: - subq.w #1,%d2 - add.w %d3,%d4 - add.w %d3,%d4 - sub.w %d2,%d4 - sub.w %d2,%d4 - addq.w #1,%d4 - bra.w .LcoLoop_\color - .endm - - -| ---- CO_LOOP_HDR: emit a labelled loop header for a color ---- - - .macro CO_LOOP_HDR color -.LcoLoop_\color: - cmp.w %d3,%d2 - bcs.w .LcoDone - CO_BODY \color - .endm - - -| ---- Function entry ---- - - .equ SP_SAVED, 44 - .equ SP_LOCAL, 24 - - .equ SP_OFF, (SP_SAVED + 4 + SP_LOCAL) - - .equ SP_P0, SP_OFF + 0 - .equ SP_P1, SP_OFF + 4 - .equ SP_P2, SP_OFF + 8 - .equ SP_P3, SP_OFF + 12 - .equ SP_CX, SP_OFF + 16 + 2 - .equ SP_CY, SP_OFF + 20 + 2 - .equ SP_R, SP_OFF + 24 + 2 - .equ SP_COLOR, SP_OFF + 28 + 3 - - .globl _surface68kAmigaCircleOutline - -_surface68kAmigaCircleOutline: - movem.l %d2-%d7/%a2-%a6,-(%sp) - lea -SP_LOCAL(%sp),%sp - - | Plane bases. - move.l SP_P0(%sp),%a0 - move.l SP_P1(%sp),%a1 - move.l SP_P2(%sp),%a2 - move.l SP_P3(%sp),%a3 - lea bitMaskLut(%pc),%a5 - - | Cache cx in d5, cy (sign-extended) in a4. - move.w SP_CX(%sp),%d5 - move.w SP_CY(%sp),%d6 - ext.l %d6 - movea.l %d6,%a4 - - | Bresenham init. - move.w SP_R(%sp),%d2 | bx = r - moveq #0,%d3 | by = 0 - moveq #1,%d4 - sub.w %d2,%d4 | err = 1 - bx - - | Dispatch on color (low 4 bits) -> one of 16 main loops. - | Each table entry is a bra.w (4 bytes), so index *= 4. - moveq #0,%d6 - move.b SP_COLOR(%sp),%d6 - and.w #0x0F,%d6 - add.w %d6,%d6 - add.w %d6,%d6 - lea .LcoTable(%pc),%a6 - jmp 0(%a6,%d6.w) - -.LcoTable: - bra.w .LcoLoop_0 - bra.w .LcoLoop_1 - bra.w .LcoLoop_2 - bra.w .LcoLoop_3 - bra.w .LcoLoop_4 - bra.w .LcoLoop_5 - bra.w .LcoLoop_6 - bra.w .LcoLoop_7 - bra.w .LcoLoop_8 - bra.w .LcoLoop_9 - bra.w .LcoLoop_10 - bra.w .LcoLoop_11 - bra.w .LcoLoop_12 - bra.w .LcoLoop_13 - bra.w .LcoLoop_14 - bra.w .LcoLoop_15 - - CO_LOOP_HDR 0 - CO_LOOP_HDR 1 - CO_LOOP_HDR 2 - CO_LOOP_HDR 3 - CO_LOOP_HDR 4 - CO_LOOP_HDR 5 - CO_LOOP_HDR 6 - CO_LOOP_HDR 7 - CO_LOOP_HDR 8 - CO_LOOP_HDR 9 - CO_LOOP_HDR 10 - CO_LOOP_HDR 11 - CO_LOOP_HDR 12 - CO_LOOP_HDR 13 - CO_LOOP_HDR 14 - CO_LOOP_HDR 15 - -.LcoDone: - lea SP_LOCAL(%sp),%sp - movem.l (%sp)+,%d2-%d7/%a2-%a6 - rts - - - .align 2 -bitMaskLut: - .byte 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 diff --git a/src/amiga/hal.c b/src/amiga/hal.c index 4aed010..32228f0 100644 --- a/src/amiga/hal.c +++ b/src/amiga/hal.c @@ -633,6 +633,15 @@ bool jlpInit(const jlConfigT *config) { (void)config; + // What the machine actually has, before anything is taken. Worth a + // line: on a bare 512 KB A500 everything competes for CHIP, so a + // later AllocMem failure (a sprite bank, Space Taxi's ~120 KB of + // speech) reads as a mystery unless the starting budget is on + // record. FAST is 0 on an unexpanded machine. + jlLogF("amiga: free at init CHIP=%lu FAST=%lu", + (unsigned long)AvailMem((ULONG)MEMF_CHIP), + (unsigned long)AvailMem((ULONG)MEMF_FAST)); + // Allocate our own BitMap with explicitly-non-interleaved planes, // then hand it to OpenScreen via SA_BitMap. Why not let Intuition // build one for us: @@ -1146,15 +1155,18 @@ void jlpShutdown(void) { } -// Shared 68k fast paths from src/shared68k/surface68k.s. Only the -// planar span fill and the planar circle outline apply on Amiga (pure -// planar after Phase 9); the chunky-layout primitives that file also -// exports are unused here. -extern void surface68kFillSpan4Planes(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, - uint16_t numMid, uint8_t leftMask, uint8_t rightMask, - uint8_t fb0, uint8_t fb1, uint8_t fb2, uint8_t fb3); -extern void surface68kAmigaCircleOutline(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, - uint16_t cx, uint16_t cy, uint16_t r, uint8_t color); +// Hand-written 68k fast paths, all shared with the X68000: both ports keep +// four separate plane buffers of 40 bytes per row, so a span of pixels is the +// 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); +// 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 +// walkers. Straight lines never reach jlpDrawLine -- draw.c diverts those to +// jlFillRect -- so the line body walks the general diagonal case only. +extern void surface68kPlanarCircle(uint8_t *const *planes, uint16_t cx, uint16_t cy, uint16_t r, uint16_t color); +extern void surface68kPlanarLine(uint8_t *const *planes, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t color); // Amiga is pure planar (s->pixels is NULL). Each jlp below does the planar @@ -1697,32 +1709,6 @@ void jlpSurfaceCopyRect(jlSurfaceT *dst, const jlSurfaceT *src, uint16_t x, uint // the four shadow planes. Caller (the per-primitive walker below) has // already validated (x, y) is on-surface. byteOff is computed once // and reused across all four planes since stride is the same in each. -/* Per-pixel plane RMW with the color-bit classification hoisted by - * the caller. set0..set3 are 0xFF if that plane's color bit is 1 - * (OR-in bitMask), 0 if 0 (AND-out bitMask). Each plane updates with - * `*pn = (*pn & ~bitMask) | (setN & bitMask)` -- branch-free, 4 - * RMWs per pixel. Inlined into shape walkers so the outer loop pays - * no function-call overhead. */ -/* Bit-mask LUT for the per-pixel macro -- replaces a runtime - * `0x80u >> (x & 7)` (8 cyc per bit shifted on 68000) with a single - * byte load. Saves ~25 cyc per pixel inside circle / line walkers. */ -static const uint8_t kAmigaPlanePutPixelBitLut[8] = { - 0x80u, 0x40u, 0x20u, 0x10u, 0x08u, 0x04u, 0x02u, 0x01u -}; - -#define AMIGA_PLANE_PUT_PIXEL(pd_, x_, y_, set0_, set1_, set2_, set3_) do { \ - uint16_t byteOff_ = (uint16_t)((uint16_t)(y_) * AMIGA_BYTES_PER_ROW + ((uint16_t)(x_) >> 3)); \ - uint8_t bitMask_ = kAmigaPlanePutPixelBitLut[(uint16_t)(x_) & 7u]; \ - uint8_t notMask_ = (uint8_t)~bitMask_; \ - uint8_t *p0_ = &(pd_)->planes[0][byteOff_]; \ - uint8_t *p1_ = &(pd_)->planes[1][byteOff_]; \ - uint8_t *p2_ = &(pd_)->planes[2][byteOff_]; \ - uint8_t *p3_ = &(pd_)->planes[3][byteOff_]; \ - *p0_ = (uint8_t)((*p0_ & notMask_) | ((set0_) & bitMask_)); \ - *p1_ = (uint8_t)((*p1_ & notMask_) | ((set1_) & bitMask_)); \ - *p2_ = (uint8_t)((*p2_ & notMask_) | ((set2_) & bitMask_)); \ - *p3_ = (uint8_t)((*p3_ & notMask_) | ((set3_) & bitMask_)); \ -} while (0) static void amigaPlanarSetPixel(AmigaPlanarT *pd, int16_t x, int16_t y, uint8_t color) { @@ -1766,56 +1752,13 @@ void jlpDrawPixel(jlSurfaceT *s, uint16_t x, uint16_t y, uint8_t colorIndex) { } -// Bresenham's diagonal line, planar-only walk. Same algorithm as -// cross-platform jlDrawLine's fallback so the pixel set matches it -// bit-for-bit. -static void amigaPlanarLine(AmigaPlanarT *pd, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t color) { - int16_t dx; - int16_t dy; - int16_t sx; - int16_t sy; - int16_t err; - int16_t e2; - uint8_t set0; - uint8_t set1; - uint8_t set2; - uint8_t set3; - - set0 = (color & 0x01u) ? 0xFFu : 0x00u; - set1 = (color & 0x02u) ? 0xFFu : 0x00u; - set2 = (color & 0x04u) ? 0xFFu : 0x00u; - set3 = (color & 0x08u) ? 0xFFu : 0x00u; - - dx = (int16_t)((x1 > x0) ? (x1 - x0) : (x0 - x1)); - dy = (int16_t)(-((y1 > y0) ? (y1 - y0) : (y0 - y1))); - sx = (int16_t)((x0 < x1) ? 1 : -1); - sy = (int16_t)((y0 < y1) ? 1 : -1); - err = (int16_t)(dx + dy); - while (1) { - AMIGA_PLANE_PUT_PIXEL(pd, x0, y0, set0, set1, set2, set3); - if (x0 == x1 && y0 == y1) { - break; - } - e2 = (int16_t)(2 * err); - if (e2 >= dy) { - err = (int16_t)(err + dy); - x0 = (int16_t)(x0 + sx); - } - if (e2 <= dx) { - err = (int16_t)(err + dx); - y0 = (int16_t)(y0 + sy); - } - } -} - - void jlpDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t colorIndex) { AmigaPlanarT *pd; if (s->portData == NULL) { return; } pd = (AmigaPlanarT *)s->portData; - amigaPlanarLine(pd, x0, y0, x1, y1, (uint8_t)(colorIndex & 0x0Fu)); + surface68kPlanarLine(pd->planes, x0, y0, x1, y1, (uint16_t)(colorIndex & 0x0Fu)); } @@ -1825,112 +1768,32 @@ void jlpDrawCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t co return; } pd = (AmigaPlanarT *)s->portData; - surface68kAmigaCircleOutline(pd->planes[0], pd->planes[1], - pd->planes[2], pd->planes[3], - (uint16_t)cx, (uint16_t)cy, r, - (uint8_t)(colorIndex & 0x0Fu)); -} - - -/* Single-row 4-plane span fill via shared68k asm. Caller pre-computes - * the left/right partial-byte masks; fillByte per plane is just - * 0xFF/0x00 based on colorIndex bit. The asm body avoids per-byte - * function-call dispatch and the C compiler's per-iter overhead -- - * critical for jlFillCircle r=40 which pre-asm was paying ~50 ms/call - * for 80 spans. */ -static inline __attribute__((always_inline)) -void amigaFillSpanInline(AmigaPlanarT *pd, int16_t spanX, int16_t spanY, - uint16_t spanW, uint8_t colorIndex) { - static const uint8_t kLM[8] = {0xFFu,0x7Fu,0x3Fu,0x1Fu,0x0Fu,0x07u,0x03u,0x01u}; - static const uint8_t kRM[8] = {0x80u,0xC0u,0xE0u,0xF0u,0xF8u,0xFCu,0xFEu,0xFFu}; - - uint16_t byteFirst = (uint16_t)((uint16_t)spanX >> 3); - uint16_t lastBit = (uint16_t)(spanX + spanW - 1); - uint16_t byteLast = (uint16_t)(lastBit >> 3); - uint8_t leftMask = kLM[(uint16_t)spanX & 7u]; - uint8_t rightMask = kRM[lastBit & 7u]; - uint16_t rowOff = (uint16_t)((uint16_t)spanY * AMIGA_BYTES_PER_ROW + byteFirst); - uint8_t fb0 = ((colorIndex >> 0) & 1u) ? 0xFFu : 0x00u; - uint8_t fb1 = ((colorIndex >> 1) & 1u) ? 0xFFu : 0x00u; - uint8_t fb2 = ((colorIndex >> 2) & 1u) ? 0xFFu : 0x00u; - uint8_t fb3 = ((colorIndex >> 3) & 1u) ? 0xFFu : 0x00u; - uint8_t *p0 = pd->planes[0] + rowOff; - uint8_t *p1 = pd->planes[1] + rowOff; - uint8_t *p2 = pd->planes[2] + rowOff; - uint8_t *p3 = pd->planes[3] + rowOff; - - if (byteFirst == byteLast) { - /* Single-byte case kept in C: the asm path post-increments - * the pointer between leading and trailing RMW, which would - * read the wrong byte if both edges land on the same byte. - * One-byte spans are rare anyway (~1 of 80 in jlFillCircle r=40) - * so the C overhead is fine here. */ - uint8_t mask = (uint8_t)(leftMask & rightMask); - uint8_t notMask = (uint8_t)~mask; - uint8_t *pp[AMIGA_BITPLANES]; - uint8_t fb[AMIGA_BITPLANES]; - uint8_t i; - pp[0] = p0; pp[1] = p1; pp[2] = p2; pp[3] = p3; - fb[0] = fb0; fb[1] = fb1; fb[2] = fb2; fb[3] = fb3; - for (i = 0; i < AMIGA_BITPLANES; i++) { - *pp[i] = (uint8_t)((*pp[i] & notMask) | (fb[i] & mask)); - } - return; - } - - { - uint16_t numMid = (uint16_t)(byteLast - byteFirst - 1u); - surface68kFillSpan4Planes(p0, p1, p2, p3, numMid, leftMask, rightMask, - fb0, fb1, fb2, fb3); - } + surface68kPlanarCircle(pd->planes, (uint16_t)cx, (uint16_t)cy, r, (uint16_t)(colorIndex & 0x0Fu)); } +// src/m68k/planarFill.s does the whole exact-disk walk: for each y in [0, r] +// the largest x with x*x + y*y <= r*r, then one span at cy+y and one at cy-y, +// each row filled exactly once. Row coverage is identical to draw.c's own +// jlFillCircle, so the golden hashes hold by construction. +// +// This replaced a C walk that called an assembly span filler per span. The +// call and the per-span geometry cost more than the pixels did: the masks are +// now computed once for a row's two spans and the walk is 16-bit throughout. +// +// The asm keeps r*r in a word, so it needs r <= 181. draw.c only calls this +// when the bounding box is on-surface and every planar surface here is +// SURFACE_HEIGHT rows tall, which caps r at half of that; the check below +// fails the build rather than let a taller surface wrap silently. bool jlpFillCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t colorIndex) { - AmigaPlanarT *pd; - uint32_t r2; - uint32_t xx; - uint32_t yy; - int32_t x; - uint16_t y; + enum { kMaxWordRadius = 181 }; + + _Static_assert((SURFACE_HEIGHT / 2) <= kMaxWordRadius, "r*r must fit a word in src/m68k/planarFill.s"); if (s->portData == NULL) { return false; } - pd = (AmigaPlanarT *)s->portData; - // Exact-disk span walk (perf-hunt-2 rank 14, the recorded "true - // lever"; algorithm copied from the ST stPlanarCircleFill): for - // each y offset track the largest x with x^2 + y^2 <= r^2 - // incrementally, and fill each row exactly ONCE. The old midpoint - // loop emitted 4 octant spans per iteration whose horizontal spans - // overlapped (~14-16% redundant bytes at r=40) and re-dispatched - // amigaFillSpanInline for each. Row coverage is identical to - // draw.c's jlFillCircle and the ST walker, so the golden hashes - // are unchanged by construction. Spans stay unclipped -- draw.c - // routes every off-surface circle through its own clipped loop - // before jlpFillCircle is called (same contract as before). - r2 = (uint32_t)r * (uint32_t)r; - xx = r2; - yy = 0u; - x = (int32_t)r; - for (y = 0u; y <= r; y++) { - uint32_t xxLimit; - int16_t spanX; - uint16_t spanW; - - xxLimit = r2 - yy; - while (xx > xxLimit) { - xx = xx - ((uint32_t)x + (uint32_t)x - 1u); - x--; - } - spanX = (int16_t)(cx - (int16_t)x); - spanW = (uint16_t)((int16_t)x + (int16_t)x + 1); - amigaFillSpanInline(pd, spanX, (int16_t)(cy + (int16_t)y), spanW, colorIndex); - if (y > 0u) { - amigaFillSpanInline(pd, spanX, (int16_t)(cy - (int16_t)y), spanW, colorIndex); - } - yy = yy + ((uint32_t)y + (uint32_t)y + 1u); - } + surface68kFillCircle(((AmigaPlanarT *)s->portData)->planes, (uint16_t)cx, (uint16_t)cy, r, colorIndex); return true; } @@ -2280,7 +2143,12 @@ void *jlpSurfaceAllocPortData(jlSurfaceT *s, bool isStage) { * 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++) { - pd->planes[i] = (uint8_t *)AllocMem((ULONG)AMIGA_PLANE_SIZE, + // 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)); jlLogF("amiga: shadow plane %u in CHIP (blitter, addr=$%08lX)", (unsigned)i, (unsigned long)pd->planes[i]); @@ -2288,7 +2156,7 @@ void *jlpSurfaceAllocPortData(jlSurfaceT *s, bool isStage) { /* Roll back any planes already allocated. */ while (i > 0u) { i--; - FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_SIZE); + FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_ALLOC); } FreeMem(pd, (ULONG)sizeof(AmigaPlanarT)); return NULL; @@ -2312,7 +2180,7 @@ void jlpSurfaceFreePortData(jlSurfaceT *s, bool isStage, void *portData) { if (pd->ownsPlanes) { for (i = 0; i < AMIGA_BITPLANES; i++) { if (pd->planes[i] != NULL) { - FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_SIZE); + FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_ALLOC); } } } diff --git a/src/atarist/hal.c b/src/atarist/hal.c index d4f7492..dff64fb 100644 --- a/src/atarist/hal.c +++ b/src/atarist/hal.c @@ -188,6 +188,8 @@ static inline __attribute__((always_inline)) uint16_t stFloodStopBits(const uint static uint16_t quantizeColorToSt(uint16_t orgb); static void flattenScbPalettes(const jlSurfaceT *src); static void setShifterVideoBase(const uint8_t *page); +extern void stLeaveSuper(void *savedSsp); + static void writeDiagnostics(void); static long writePrevPaletteRegs(void); @@ -887,7 +889,12 @@ void jlpShutdown(void) { // Drop back to user mode so control returns to GEMDOS the way it // launched us (mirrors the Super(0L) in jlpInit). if (gSuperEntered) { - (void)Super(gSavedSsp); + // NOT Super(gSavedSsp) from C: the stack pointer the trap hands + // back is not the one it was given, so the compiler's epilogue + // reads the saved registers and the return address from the + // wrong slot and rts lands in nothing. superExit.s parks the + // entry SP in a GEMDOS-preserved register and puts it back. + stLeaveSuper(gSavedSsp); gSuperEntered = false; } } diff --git a/src/atarist/superExit.s b/src/atarist/superExit.s new file mode 100644 index 0000000..f7e2c2b --- /dev/null +++ b/src/atarist/superExit.s @@ -0,0 +1,43 @@ +| Atari ST -- leave supervisor mode without losing the caller's frame. +| +| jlpInit enters supervisor for the program's lifetime (the shifter and +| MFP registers are supervisor-only) and jlpShutdown has to hand user +| mode back before returning to GEMDOS. The obvious +| +| (void)Super(gSavedSsp); +| +| in C does leave supervisor, but the stack pointer the trap hands back +| is NOT the one it was called with, so the compiler's epilogue then +| pops the saved registers and the return address from the wrong place +| and rts jumps into nothing. It is a heisenbug: whether the wrong slot +| happens to hold something survivable depends on what the last few +| calls left on the stack, so the same binary can exit cleanly today and +| bus-error tomorrow, and an added printf can "fix" it. EmuTOS reports +| it as a Panic in the caller of Super, one instruction past the rts. +| +| The cure is to note the stack position before the trap and to restore +| it afterwards. In user mode interrupts run on the supervisor stack, so +| the bytes just below the restored SP stay ours to read. +| +| GAS-syntax (binutils m68k); assembled by m68k-atari-mint-as via the +| gcc driver. GEMDOS preserves d3-d7/a3-a6 across trap #1, which is why +| the entry SP is parked in a3. + + .text + .globl _stLeaveSuper + +| void stLeaveSuper(void *savedSsp); +| Must be called in supervisor mode with savedSsp as returned by +| Super(0L). Returns in user mode. +_stLeaveSuper: + movel %d3,%sp@- | 8 bytes of our own + movel %a3,%sp@- + movel %sp@(12),%d3 | savedSsp + lea %sp@(8),%a3 | entry SP (the rts slot) + movel %d3,%sp@- | Super(savedSsp) + movew #0x20,%sp@- + trap #1 + movel %a3,%sp | undo whatever SP came back + movel %sp@(-4),%d3 | our saves are still below + movel %sp@(-8),%a3 + rts diff --git a/src/dos/audio.c b/src/dos/audio.c index d5d9489..a28ca93 100644 --- a/src/dos/audio.c +++ b/src/dos/audio.c @@ -727,6 +727,7 @@ static const uint8_t kAdlibSlotMap[ADLIB_VOICE_COUNT * 2] = { #define ADLIB_BD_RHYTHM 0x20u // reg 0xBD bit 5: rhythm mode enable #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 static bool gAdlibInited = false; static bool gAdlibNoiseInited = false; @@ -1042,10 +1043,25 @@ void jlpAudioNoise(uint8_t pitch, uint8_t atten) { } adlibWrite((uint8_t)(0x40u + ADLIB_SLOT_SD), tl); - // pitch 0..31 -> hyperbolic Hz, the YM noise-period semantic: - // pitch 0 = 6200 Hz bright hiss, pitch 31 = ~194 Hz rumble. - // (Core clamps pitch to <= 31 before the HAL sees it.) - freqHz = (uint16_t)(ADLIB_NOISE_MAX_HZ / ((uint16_t)pitch + 1u)); + // 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. + // + // It replaces MAX / (pitch + 1), a hyperbola that spent nearly all + // its resolution on the first few pitches and squashed everything + // from 10 upward into 194..563 Hz -- pitches 28, 29 and 30 came out + // 213, 206 and 200 Hz, near enough identical, so a tune written + // across them lost its shape entirely. + freqHz = (uint16_t)(ADLIB_NOISE_MAX_HZ + - (uint16_t)(((uint32_t)pitch * (ADLIB_NOISE_MAX_HZ - ADLIB_NOISE_MIN_HZ)) / 31u)); block = adlibFreqToFnum(freqHz, &fnum); // Bit 5 (melodic key-on) must stay clear on 0xB7: in rhythm mode diff --git a/src/iigs/audio_full.c b/src/iigs/audio_full.c index e4554b0..2f573bb 100644 --- a/src/iigs/audio_full.c +++ b/src/iigs/audio_full.c @@ -230,11 +230,20 @@ static uint16_t docFreqWord(uint16_t rateHz) { #define NOISE_SUSTAIN_MUL 41u // len = rate * 41 / 512 -> ~80 ms sustain #define NOISE_LFSR_SEED 0xACE1u #define NOISE_LFSR_TAPS 0xB400u // 16-bit maximal Galois LFSR, period 65535 -// How long one noise burst sounds, in 60 Hz GetTick units. -// jlpAudioNoise plays NOISE_SUSTAIN_MUL/512 of a second of sample -// (41/512 s = 80 ms = 4.8 ticks). 4 retriggers a shade early, which -// keeps the sound gapless. -#define NOISE_HOLD_TICKS 4u +// How long a noise burst is left alone before a repeat call retriggers +// it, in 60 Hz GetTick units. jlpAudioNoise plays NOISE_SUSTAIN_MUL/512 +// of a second of sample (41/512 s = 80 ms = 4.8 ticks), so anything +// below that retriggers before the burst ends and the sound is gapless. +// +// It has to hold for less than HALF the burst, not just under all of it. +// At 4 ticks a caller running at 60 Hz retriggered at 4 ticks and was +// gapless, but a caller running at the ~19 fps Space Taxi manages on the +// IIgs lands at 3.2 ticks -- inside the hold, so its call did nothing and +// the burst then expired at 4.8 with the next call not due until 6.3. +// That 25 ms hole per frame was the stuttering jet. 2 ticks retriggers +// for any caller slower than 33 ms and merely costs a held music note +// one extra trigger per 33 ms. +#define NOISE_HOLD_TICKS 2u // ----- Streaming SFX (jlpAudioPlaySfxStream) ----- // diff --git a/src/m68k/planarFill.s b/src/m68k/planarFill.s new file mode 100644 index 0000000..427fc02 --- /dev/null +++ b/src/m68k/planarFill.s @@ -0,0 +1,503 @@ +| Shared 68000 separate-plane area fills -- hand-rolled. +| +| Used by BOTH separate-plane 68k ports, the Amiga and the X68000: a 320px +| 4bpp surface is four plane buffers of 40 bytes per row on either machine, so +| a span of pixels is the same bytes in the same places. (The Atari ST is +| word-interleaved and keeps its own St* routines.) +| +| Home of the span-filling primitives that write a run of pixels into all +| four plane buffers at once. Currently: +| +| surface68kFillCircle jlFillCircle: the whole exact-disk walk +| surface68kFillRect jlFillRect: a rect that does not span whole rows +| +| A span is a partial left byte, a run of whole middle bytes, and a partial +| right byte, in each of four planes. Three things make this much cheaper +| than the general case, and the PLANEFILL macro below is built around them: +| +| * A PLANE'S FILL VALUE IS ONLY EVER ALL-ONES OR ALL-ZEROS. It is one bit +| of the colour smeared across a byte. So a masked edge is not a read, +| two ANDs, an OR and a store; it is `or.b mask,(a0)` for a set plane and +| `and.b ~mask,(a0)` for a clear one -- one instruction, one bus cycle. +| * THE COLOUR IS FIXED FOR THE WHOLE SHAPE, so which of those two it is +| need not be decided at run time. The body is emitted once per colour +| value and dispatched to through a jump table, exactly as +| src/x68000/circle.s does. Set planes then run as a group with d6 = -1 +| and clear planes as a group with d6 = 0 and the masks complemented +| once, so the middle run is the same code for both. +| * THE MIDDLE RUN IS WHOLE BYTES of that value, so it goes out a long at +| a time through a dbra loop once the pointer is aligned. +| +| ABI: cdecl (gcc-amigaos / elf2x68k), d2-d7/a2-a6 callee-save. 16-bit +| arguments are sign-extended to 4 bytes on the call stack. +| +| Every entry point carries BOTH symbol spellings at the same address: +| gcc-amigaos and gcc-atari-mint prepend an underscore to C names and +| elf2x68k does not, and one extra unused symbol is cheaper than forking the +| file over a naming convention. + + .text + .even + .globl surface68kFillCircle + .globl _surface68kFillCircle + .globl surface68kFillRect + .globl _surface68kFillRect + +| Bytes per plane row: SURFACE_WIDTH / 8, which is AMIGA_BYTES_PER_ROW and +| X68K_BYTES_PER_ROW both. Every planar surface on either port is +| SURFACE_WIDTH x SURFACE_HEIGHT, so it is a constant here as well as in the +| C, and both ports' C guards the radius bound that depends on it. + .equ ROW_BYTES, 40 + +| Stack locals. The walk state lives here rather than in registers because it +| is touched once per row while the span body runs twice, so the registers are +| worth more to the span. + .equ FC_XX, 0 | x*x tracker, word + .equ FC_XLIMIT, 2 | r*r - y*y, word + .equ FC_X, 4 | current x, word + .equ FC_T, 6 | 2*y + 1, word + .equ FC_COUNT, 8 | rows left, word + .equ FC_DOWN, 10 | (cy + y) * 40, word + .equ FC_UP, 12 | (cy - y) * 40, word + .equ FC_FIRST, 14 | span's first byte in a row + .equ FC_LOCAL, 16 +| 16 locals + 44 saved registers + 4 return address + .equ FC_ARG, (FC_LOCAL + 44 + 4) + .equ FC_PLANES, FC_ARG + 0 + .equ FC_CX, FC_ARG + 4 + 2 + .equ FC_CY, FC_ARG + 8 + 2 + .equ FC_R, FC_ARG + 12 + 2 + .equ FC_COLOR, FC_ARG + 16 + 2 + + +| ---- PLANEFILL: one span in one plane ---- +| \areg = that plane's base, \op = or (set) or and (clear). +| d7.w = span's byte offset, d2 = left mask, d3 = right mask, d4 = middle +| byte count, d6 = the fill value (0 or -1, matching \op). +| Trashes d0, d1, d5, a0. + .macro PLANEFILL areg, op + lea (\areg,%d7.w),%a0 + \op\().b %d2,(%a0)+ | left edge + move.w %d4,%d5 + beq.s 9f +1: move.l %a0,%d1 | byte-align, then longs + and.w #3,%d1 + beq.s 2f + move.b %d6,(%a0)+ + subq.w #1,%d5 + bne.s 1b + bra.s 9f +2: move.w %d5,%d0 + and.w #3,%d5 | d5 = trailing bytes + lsr.w #2,%d0 | d0 = whole longs + beq.s 5f + subq.w #1,%d0 +3: move.l %d6,(%a0)+ + dbra %d0,3b +5: tst.w %d5 + beq.s 9f +6: move.b %d6,(%a0)+ + subq.w #1,%d5 + bne.s 6b +9: \op\().b %d3,(%a0) | right edge + .endm + + +| ---- SPAN: one span in all four planes, for a known colour ---- +| Set planes first with d6 = -1, then the clear planes with d6 = 0 and the +| masks complemented, so `and.b %d2` is a single instruction there too. + .macro SPAN colour + .if (\colour) != 0 + moveq #-1,%d6 + .if ((\colour) & 1) + PLANEFILL %a2, or + .endif + .if ((\colour) & 2) + PLANEFILL %a3, or + .endif + .if ((\colour) & 4) + PLANEFILL %a4, or + .endif + .if ((\colour) & 8) + PLANEFILL %a5, or + .endif + .endif + .if (\colour) != 15 + moveq #0,%d6 + not.b %d2 + not.b %d3 + .if !((\colour) & 1) + PLANEFILL %a2, and + .endif + .if !((\colour) & 2) + PLANEFILL %a3, and + .endif + .if !((\colour) & 4) + PLANEFILL %a4, and + .endif + .if !((\colour) & 8) + PLANEFILL %a5, and + .endif + not.b %d2 | the row's second span wants + not.b %d3 | the masks back + .endif + .endm + + +| ---- CIRCLEBODY: the whole exact-disk walk for one colour ---- +| +| Bit-identical to the chunky reference loop in src/core/draw.c jlFillCircle +| and to the C walk this replaces: for each y in [0, r] find the largest x +| with x*x + y*y <= r*r incrementally, then fill the span (cx-x, cx+x) at +| rows cy+y and cy-y. Each row is filled exactly once -- no octant overlap. +| +| The arithmetic is 16-bit throughout. r*r must fit a word, which holds for +| r <= 181; the caller's contract (the bounding box is on-surface, and a +| surface is 200 rows tall) caps r at 99, and halPlanar.c has a build-time +| check on that. + .macro CIRCLEBODY colour +.Lrow\colour: + | walk x down until x*x + y*y <= r*r again + move.w FC_XX(%sp),%d0 + move.w FC_XLIMIT(%sp),%d1 + cmp.w %d1,%d0 + bls.s .Lgeom\colour + move.w FC_X(%sp),%d4 +.Lshrink\colour: + move.w %d4,%d5 + add.w %d5,%d5 + subq.w #1,%d5 | 2x - 1 + sub.w %d5,%d0 | xx -= 2x - 1 + subq.w #1,%d4 | x-- + cmp.w %d1,%d0 + bhi.s .Lshrink\colour + move.w %d0,FC_XX(%sp) + move.w %d4,FC_X(%sp) +.Lgeom\colour: + | span geometry, computed once for the row's two spans + move.w FC_X(%sp),%d0 + move.w %a6,%d1 + sub.w %d0,%d1 | left = cx - x + add.w %a6,%d0 | right = cx + x + move.w %d1,%d4 + lsr.w #3,%d4 | first byte + move.w %d4,FC_FIRST(%sp) + and.w #7,%d1 + move.b (%a1,%d1.w),%d2 | left mask + move.w %d0,%d1 + lsr.w #3,%d1 | last byte + and.w #7,%d0 + move.b 8(%a1,%d0.w),%d3 | right mask + sub.w %d4,%d1 + bne.s .Lmulti\colour + | Both ends in one byte. Combining the masks covers it, and + | zeroing the right mask makes the right edge write the NEXT + | byte back unchanged instead of needing its own code path. + | (The plane allocation carries a spare byte for this.) + and.b %d3,%d2 + moveq #0,%d3 + moveq #0,%d4 + bra.s .Ldown\colour +.Lmulti\colour: + subq.w #1,%d1 + move.w %d1,%d4 | middle byte count +.Ldown\colour: + move.w FC_DOWN(%sp),%d7 + add.w FC_FIRST(%sp),%d7 + SPAN \colour + | the mirrored row, unless y is zero and it IS this row + move.w FC_UP(%sp),%d7 + cmp.w FC_DOWN(%sp),%d7 + beq.w .Lstep\colour + add.w FC_FIRST(%sp),%d7 + SPAN \colour +.Lstep\colour: + addi.w #ROW_BYTES,FC_DOWN(%sp) + subi.w #ROW_BYTES,FC_UP(%sp) + | y*y grows by 2y+1, so the x*x limit shrinks by it + move.w FC_T(%sp),%d0 + sub.w %d0,FC_XLIMIT(%sp) + addq.w #2,%d0 + move.w %d0,FC_T(%sp) + subq.w #1,FC_COUNT(%sp) + bne.w .Lrow\colour + bra.w fcDone + .endm + + +| void surface68kFillCircle(uint8_t *const *planes, uint16_t cx, uint16_t cy, +| uint16_t r, uint16_t color); +surface68kFillCircle: +_surface68kFillCircle: + movem.l %d2-%d7/%a2-%a6,%sp@- | 11 registers = 44 bytes + sub.l #FC_LOCAL,%sp + move.l FC_PLANES(%sp),%a0 + move.l (%a0),%a2 + move.l 4(%a0),%a3 + move.l 8(%a0),%a4 + move.l 12(%a0),%a5 + move.w FC_CX(%sp),%a6 + move.w FC_CY(%sp),%d0 + move.w FC_R(%sp),%d1 + + move.w %d1,%d2 + mulu.w %d2,%d2 | r*r, a word for r <= 181 + move.w %d2,FC_XX(%sp) + move.w %d2,FC_XLIMIT(%sp) + move.w %d1,FC_X(%sp) + move.w #1,FC_T(%sp) + addq.w #1,%d1 + move.w %d1,FC_COUNT(%sp) | r + 1 rows + | cy * 40 as (cy << 5) + (cy << 3) + move.w %d0,%d1 + lsl.w #5,%d0 + lsl.w #3,%d1 + add.w %d1,%d0 + move.w %d0,FC_DOWN(%sp) + move.w %d0,FC_UP(%sp) + lea gFillMaskLut,%a1 + + move.w FC_COLOR(%sp),%d0 + and.w #15,%d0 + add.w %d0,%d0 + move.w fcJump(%pc,%d0.w),%d0 + jmp fcJump(%pc,%d0.w) + +fcJump: + .word fcC0-fcJump, fcC1-fcJump, fcC2-fcJump, fcC3-fcJump + .word fcC4-fcJump, fcC5-fcJump, fcC6-fcJump, fcC7-fcJump + .word fcC8-fcJump, fcC9-fcJump, fcC10-fcJump, fcC11-fcJump + .word fcC12-fcJump, fcC13-fcJump, fcC14-fcJump, fcC15-fcJump + +fcC0: CIRCLEBODY 0 +fcC1: CIRCLEBODY 1 +fcC2: CIRCLEBODY 2 +fcC3: CIRCLEBODY 3 +fcC4: CIRCLEBODY 4 +fcC5: CIRCLEBODY 5 +fcC6: CIRCLEBODY 6 +fcC7: CIRCLEBODY 7 +fcC8: CIRCLEBODY 8 +fcC9: CIRCLEBODY 9 +fcC10: CIRCLEBODY 10 +fcC11: CIRCLEBODY 11 +fcC12: CIRCLEBODY 12 +fcC13: CIRCLEBODY 13 +fcC14: CIRCLEBODY 14 +fcC15: CIRCLEBODY 15 + +fcDone: + add.l #FC_LOCAL,%sp + movem.l %sp@+,%d2-%d7/%a2-%a6 + rts + + + +| ---- RECTBODY: every row of the rect, for one colour ---- +| +| A rect is one span repeated, so the masks and the middle byte count are +| computed once at entry and stay in d2/d3/d4 for the whole shape; the row +| loop only walks the offset. d7 counts up by a row's worth of bytes until it +| meets a6, which is one row past the last. + .macro RECTBODY colour +.Lrect\colour: + SPAN \colour + addi.w #ROW_BYTES,%d7 + cmp.w %a6,%d7 + bne.w .Lrect\colour + bra.w frDone + .endm + + +| ---- THINPLANE / THINBODY: a rect only one byte wide ---- +| +| Worth its own body rather than folding into SPAN. A vertical line is this +| shape (draw.c turns one into a 1px-wide rect), and it has no middle run and +| only one masked byte per row, so the generic span pays for a middle-run test +| and a second edge it does not need. Here the plane loop is outside the row +| loop, which leaves a dbra over a single read-modify-write: d2 holds the one +| combined mask and d4 the row count less one. + .macro THINPLANE areg, op + move.w %d4,%d5 + lea (\areg,%d7.w),%a0 +1: \op\().b %d2,(%a0) + lea ROW_BYTES(%a0),%a0 + dbra %d5,1b + .endm + + + .macro THINBODY colour + .if (\colour) != 0 + .if ((\colour) & 1) + THINPLANE %a2, or + .endif + .if ((\colour) & 2) + THINPLANE %a3, or + .endif + .if ((\colour) & 4) + THINPLANE %a4, or + .endif + .if ((\colour) & 8) + THINPLANE %a5, or + .endif + .endif + .if (\colour) != 15 + not.b %d2 + .if !((\colour) & 1) + THINPLANE %a2, and + .endif + .if !((\colour) & 2) + THINPLANE %a3, and + .endif + .if !((\colour) & 4) + THINPLANE %a4, and + .endif + .if !((\colour) & 8) + THINPLANE %a5, and + .endif + .endif + bra.w frDone + .endm + + +| 44 saved registers + 4 return address + .equ FR_ARG, 48 + .equ FR_PLANES, FR_ARG + 0 + .equ FR_X, FR_ARG + 4 + 2 + .equ FR_Y, FR_ARG + 8 + 2 + .equ FR_W, FR_ARG + 12 + 2 + .equ FR_H, FR_ARG + 16 + 2 + .equ FR_COLOR, FR_ARG + 20 + 2 + +| void surface68kFillRect(uint8_t *const *planes, uint16_t x, uint16_t y, +| uint16_t w, uint16_t h, uint16_t color); +| +| w and h must both be at least 1 -- draw.c clips before it calls, so an empty +| rect never arrives. A rect covering whole rows is better served by one flat +| run of words per plane and halPlanar.c keeps that case to itself; everything +| else, partial pixel edges included, lands here. +surface68kFillRect: +_surface68kFillRect: + movem.l %d2-%d7/%a2-%a6,%sp@- | 11 registers = 44 bytes + move.l FR_PLANES(%sp),%a0 + move.l (%a0),%a2 + move.l 4(%a0),%a3 + move.l 8(%a0),%a4 + move.l 12(%a0),%a5 + lea gFillMaskLut,%a1 + + | span geometry, once for the whole rect + move.w FR_X(%sp),%d0 | left + move.w FR_W(%sp),%d1 + add.w %d0,%d1 + subq.w #1,%d1 | right + move.w %d0,%d5 + lsr.w #3,%d5 | first byte + and.w #7,%d0 + move.b (%a1,%d0.w),%d2 | left mask + move.w %d1,%d0 + lsr.w #3,%d0 | last byte + and.w #7,%d1 + move.b 8(%a1,%d1.w),%d3 | right mask + sub.w %d5,%d0 + bne.s 1f + | One byte wide: one combined mask, and d4 becomes the dbra + | row count for THINBODY instead of a middle-run length. + and.b %d3,%d2 + move.w FR_H(%sp),%d4 + subq.w #1,%d4 + moveq #1,%d6 | take the thin dispatch + bra.s 2f +1: subq.w #1,%d0 + move.w %d0,%d4 | middle byte count + moveq #0,%d6 +2: | d7 = y * ROW_BYTES + firstByte, a6 = one row past the last + move.w FR_Y(%sp),%d0 + move.w %d0,%d1 + lsl.w #5,%d0 + lsl.w #3,%d1 + add.w %d1,%d0 | y * 40, as (y << 5) + (y << 3) + add.w %d5,%d0 + move.w %d0,%d7 + move.w FR_H(%sp),%d0 + move.w %d0,%d1 + lsl.w #5,%d0 + lsl.w #3,%d1 + add.w %d1,%d0 | h * 40 + add.w %d7,%d0 + move.w %d0,%a6 + + move.w FR_COLOR(%sp),%d0 + and.w #15,%d0 + add.w %d0,%d0 + | The jump table goes through a1: a PC-relative index has only + | an 8-bit displacement, and the thin table is 4 KB out. + tst.w %d6 + bne.s .Lthin + lea frJump(%pc),%a1 + bra.s .Ldispatch +.Lthin: + lea ftJump(%pc),%a1 +.Ldispatch: + move.w (%a1,%d0.w),%d1 + jmp (%a1,%d1.w) + +frJump: + .word frC0-frJump, frC1-frJump, frC2-frJump, frC3-frJump + .word frC4-frJump, frC5-frJump, frC6-frJump, frC7-frJump + .word frC8-frJump, frC9-frJump, frC10-frJump, frC11-frJump + .word frC12-frJump, frC13-frJump, frC14-frJump, frC15-frJump + +frC0: RECTBODY 0 +frC1: RECTBODY 1 +frC2: RECTBODY 2 +frC3: RECTBODY 3 +frC4: RECTBODY 4 +frC5: RECTBODY 5 +frC6: RECTBODY 6 +frC7: RECTBODY 7 +frC8: RECTBODY 8 +frC9: RECTBODY 9 +frC10: RECTBODY 10 +frC11: RECTBODY 11 +frC12: RECTBODY 12 +frC13: RECTBODY 13 +frC14: RECTBODY 14 +frC15: RECTBODY 15 + +ftJump: + .word ftC0-ftJump, ftC1-ftJump, ftC2-ftJump, ftC3-ftJump + .word ftC4-ftJump, ftC5-ftJump, ftC6-ftJump, ftC7-ftJump + .word ftC8-ftJump, ftC9-ftJump, ftC10-ftJump, ftC11-ftJump + .word ftC12-ftJump, ftC13-ftJump, ftC14-ftJump, ftC15-ftJump + +ftC0: THINBODY 0 +ftC1: THINBODY 1 +ftC2: THINBODY 2 +ftC3: THINBODY 3 +ftC4: THINBODY 4 +ftC5: THINBODY 5 +ftC6: THINBODY 6 +ftC7: THINBODY 7 +ftC8: THINBODY 8 +ftC9: THINBODY 9 +ftC10: THINBODY 10 +ftC11: THINBODY 11 +ftC12: THINBODY 12 +ftC13: THINBODY 13 +ftC14: THINBODY 14 +ftC15: THINBODY 15 + +frDone: + movem.l %sp@+,%d2-%d7/%a2-%a6 + rts + + + .data + .even +| Eight left-edge masks (bits at and to the right of x&7), then eight +| right-edge masks (bits at and to the left of it). The span body reaches the +| second half at 8(%a1,%dn.w), so the two must stay adjacent. +gFillMaskLut: + .byte 0xFF, 0x7F, 0x3F, 0x1F, 0x0F, 0x07, 0x03, 0x01 + .byte 0x80, 0xC0, 0xE0, 0xF0, 0xF8, 0xFC, 0xFE, 0xFF diff --git a/src/m68k/planarPlot.s b/src/m68k/planarPlot.s new file mode 100644 index 0000000..b40c044 --- /dev/null +++ b/src/m68k/planarPlot.s @@ -0,0 +1,428 @@ +| Shared 68000 separate-plane pixel walkers -- hand-rolled. +| +| surface68kPlanarCircle jlDrawCircle: Bresenham midpoint outline +| surface68kPlanarLine jlDrawLine: Bresenham diagonal +| +| Used by BOTH separate-plane 68k ports, the Amiga and the X68000, which keep +| four plane buffers of 40 bytes per row. Same shape as src/atarist/circle.s, +| adapted from that port's memory layout: the ST keeps one word-interleaved +| buffer, so a pixel there is four words in one group, while here it is a BYTE +| mask at the same offset in all four planes. +| +| Pixel x: byte = x >> 3, bit = 0x80 >> (x & 7). +| Plane N at row y: planes[N] + y*40 + (x >> 3). +| +| Why assembly. The C version plotted through a helper that recomputed the +| row offset, the mask and its complement for every one of the eight +| symmetric points, then did a load-modify-store per plane: four reads and +| four writes where the machine can do four read-modify-writes. Measured at +| 0.65x of the Atari ST on a CPU that is 25% faster. +| +| The two things that make this quick are both borrowed from the ST: +| +| * 16-WAY COLOUR DISPATCH. The colour is fixed for the whole circle, so +| the plot is emitted once per colour value with the set-or-clear choice +| already made. Every plane write is then a single `or.b` or `and.b` +| straight to memory -- no test, no branch, no read-back. +| * PER-ITERATION PRECOMPUTE. One Bresenham step touches four distinct +| rows and four distinct x positions, so their offsets and masks are +| built once into a scratch block and the eight plots just read them. +| +| Bit-exact with the C it replaces, including the tie: err <= 0 must take the +| no-shrink branch, or 114 of the 199 reachable radii plot a different pixel +| set (see the note in halPlanar.c). No clipping, matching the C -- the +| caller guarantees the circle is on-surface. +| +| ABI: cdecl (gcc-amigaos / elf2x68k), d2-d7/a2-a6 callee-save. Both symbol +| spellings are defined at each entry point, because gcc-amigaos prepends an +| underscore to C names and elf2x68k does not. +| +| void surface68kPlanarCircle(uint8_t *const *planes, uint16_t cx, uint16_t cy, +| uint16_t r, uint16_t color); +| +| Register allocation: +| a0..a3 = plane 0..3 base pointers +| a5 = gPlanarBitLut (byte masks) +| d2.w = bx, d3.w = by, d4.w = err +| d5.w = cx, a6.w = cy +| d0,d1,d6,d7 = scratch +| +| Scratch block (24 bytes) at sp+0..23: +| sp+0,+2,+4,+6 xByte for cx+bx, cx-bx, cx+by, cx-by (x >> 3) +| sp+8,+9,+10,+11 bit mask for the same four (byte each) +| sp+12,+14,+16,+18 row offset for cy+by, cy-by, cy+bx, cy-bx (y * 40) +| sp+20,+22 unused padding, keeps the block even + +| Bytes per plane row: SURFACE_WIDTH / 8 on both ports. + .equ ROW_BYTES, 40 + + .text + .even + .globl surface68kPlanarCircle + .globl _surface68kPlanarCircle + .globl surface68kPlanarLine + .globl _surface68kPlanarLine + +| ---- XREC: xByte and bit mask for cx , into slots ---- +| Trashes d0, d6, d7. + .macro XREC slotB, slotM, op, reg + move.w %d5,%d6 + \op\().w \reg,%d6 | d6 = x + move.w %d6,%d7 + lsr.w #3,%d7 | d7 = x >> 3 + move.w %d7,\slotB(%sp) + and.w #7,%d6 + move.b (%a5,%d6.w),%d0 | d0 = 0x80 >> (x & 7) + move.b %d0,\slotM(%sp) + .endm + + +| ---- YREC: row offset for cy , into slot ---- +| y * 40 as (y << 5) + (y << 3): a 68000 has no fast multiply and a table +| would cost an address register this routine does not have spare. +| Trashes d0, d6. + .macro YREC slot, op, reg + move.w %a6,%d6 + \op\().w \reg,%d6 | d6 = y + move.w %d6,%d0 + lsl.w #5,%d0 | y * 32 + lsl.w #3,%d6 | y * 8 + add.w %d0,%d6 | y * 40 + move.w %d6,\slot(%sp) + .endm + + +| ---- PLOT: one pixel, colour known at assembly time ---- +| slotY: row offset slot, slotB: xByte slot, slotM: mask slot. +| Trashes d0, d1, d7. + .macro PLOT slotY, slotB, slotM, colour + move.w \slotY(%sp),%d0 + add.w \slotB(%sp),%d0 | d0 = y*40 + (x >> 3) + move.b \slotM(%sp),%d1 | d1 = bit + .if (\colour) != 15 + move.b %d1,%d7 + not.b %d7 | d7 = ~bit, for the clears + .endif + .if ((\colour) & 1) + or.b %d1,(%a0,%d0.w) + .else + and.b %d7,(%a0,%d0.w) + .endif + .if ((\colour) & 2) + or.b %d1,(%a1,%d0.w) + .else + and.b %d7,(%a1,%d0.w) + .endif + .if ((\colour) & 4) + or.b %d1,(%a2,%d0.w) + .else + and.b %d7,(%a2,%d0.w) + .endif + .if ((\colour) & 8) + or.b %d1,(%a3,%d0.w) + .else + and.b %d7,(%a3,%d0.w) + .endif + .endm + + +| ---- BODY: the whole Bresenham loop for one colour ---- + .macro BODY colour +.Lloop\colour: + XREC 0, 8, add, %d2 | cx + bx + XREC 2, 9, sub, %d2 | cx - bx + XREC 4, 10, add, %d3 | cx + by + XREC 6, 11, sub, %d3 | cx - by + YREC 12, add, %d3 | cy + by + YREC 14, sub, %d3 | cy - by + YREC 16, add, %d2 | cy + bx + YREC 18, sub, %d2 | cy - bx + + PLOT 12, 0, 8, \colour | (cx+bx, cy+by) + PLOT 12, 2, 9, \colour | (cx-bx, cy+by) + PLOT 14, 0, 8, \colour | (cx+bx, cy-by) + PLOT 14, 2, 9, \colour | (cx-bx, cy-by) + PLOT 16, 4, 10, \colour | (cx+by, cy+bx) + PLOT 16, 6, 11, \colour | (cx-by, cy+bx) + PLOT 18, 4, 10, \colour | (cx+by, cy-bx) + PLOT 18, 6, 11, \colour | (cx-by, cy-bx) + + | err <= 0 must NOT shrink the major axis: the tie is what + | keeps this pixel-identical to src/core/draw.c. + tst.w %d4 + bgt.s .Lshrink\colour + move.w %d2,%d0 + add.w %d0,%d0 + addq.w #3,%d0 + add.w %d0,%d4 | err += 2*bx + 3 + bra.s .Lstep\colour +.Lshrink\colour: + move.w %d2,%d0 + sub.w %d3,%d0 + add.w %d0,%d0 + addq.w #5,%d0 + add.w %d0,%d4 | err += 2*(bx - by) + 5 + subq.w #1,%d3 | by-- +.Lstep\colour: + addq.w #1,%d2 | bx++ + cmp.w %d3,%d2 + ble.w .Lloop\colour + bra.w circleDone + .endm + + +surface68kPlanarCircle: +_surface68kPlanarCircle: + movem.l %d2-%d7/%a2-%a6,%sp@- | 11 registers = 44 bytes + sub.l #24,%sp | scratch block + | args: 24 scratch + 44 saved + 4 return = 72 + move.l 72(%sp),%a4 | planes[] + move.l (%a4),%a0 + move.l 4(%a4),%a1 + move.l 8(%a4),%a2 + move.l 12(%a4),%a3 + move.w 78(%sp),%d5 | cx (low word of the int arg) + move.w 82(%sp),%d6 | cy + move.w %d6,%a6 + move.w 86(%sp),%d3 | r -> by + move.w 90(%sp),%d0 | colour + lea gPlanarBitLut,%a5 + + moveq #0,%d2 | bx = 0 + move.w %d3,%d4 + neg.w %d4 + addq.w #1,%d4 | err = 1 - r + cmp.w %d3,%d2 + bgt.w circleDone | never taken for r >= 0; kept as a guard + + and.w #15,%d0 + add.w %d0,%d0 + move.w circleJump(%pc,%d0.w),%d0 + jmp circleJump(%pc,%d0.w) + +circleJump: + .word circleC0-circleJump, circleC1-circleJump + .word circleC2-circleJump, circleC3-circleJump + .word circleC4-circleJump, circleC5-circleJump + .word circleC6-circleJump, circleC7-circleJump + .word circleC8-circleJump, circleC9-circleJump + .word circleC10-circleJump, circleC11-circleJump + .word circleC12-circleJump, circleC13-circleJump + .word circleC14-circleJump, circleC15-circleJump + +circleC0: BODY 0 +circleC1: BODY 1 +circleC2: BODY 2 +circleC3: BODY 3 +circleC4: BODY 4 +circleC5: BODY 5 +circleC6: BODY 6 +circleC7: BODY 7 +circleC8: BODY 8 +circleC9: BODY 9 +circleC10: BODY 10 +circleC11: BODY 11 +circleC12: BODY 12 +circleC13: BODY 13 +circleC14: BODY 14 +circleC15: BODY 15 + +circleDone: + add.l #24,%sp + movem.l %sp@+,%d2-%d7/%a2-%a6 + rts + + +| ---------------- surface68kPlanarLine ---------------- +| +| Bresenham's diagonal line. Straight runs never arrive: src/core/draw.c +| diverts horizontal and vertical lines to jlFillRect, so this walks the +| general case only. Bit-identical to draw.c's own walker, including the +| e2 >= dy / e2 <= dx tie handling that decides which pixels a 45-degree +| line lands on. +| +| Same two ideas as the circle above. The colour is fixed for the whole line, +| so the body is emitted once per colour value and every plane write is a bare +| `or.b` or `and.b` to memory. And the row offset is CARRIED in a6 rather than +| recomputed: y moves by one row at a time, so it is one add of +/-40 per step +| instead of the two shifts and an add a multiply-free y*40 would cost per +| pixel. +| +| void surface68kPlanarLine(uint8_t *const *planes, int16_t x0, int16_t y0, +| int16_t x1, int16_t y1, uint16_t color); +| +| Register allocation: +| a0..a3 = plane 0..3 bases, a5 = gPlanarBitLut, a6.w = y0 * 40 +| d2.w = x0, d3.w = y0, d4.w = err, d5.w = dx, d6.w = dy (negative) +| d0, d1, d7 = scratch +| +| Locals (12 bytes): x1, y1, sx, sy, sy40 + + .equ LN_X1, 0 + .equ LN_Y1, 2 + .equ LN_SX, 4 + .equ LN_SY, 6 + .equ LN_SY40, 8 + .equ LN_LOCAL, 12 +| 12 locals + 44 saved registers + 4 return address + .equ LN_ARG, (LN_LOCAL + 44 + 4) + .equ LN_PLANES, LN_ARG + 0 + .equ LN_X0, LN_ARG + 4 + 2 + .equ LN_Y0, LN_ARG + 8 + 2 + .equ LN_AX1, LN_ARG + 12 + 2 + .equ LN_AY1, LN_ARG + 16 + 2 + .equ LN_COLOR, LN_ARG + 20 + 2 + + +| ---- LPLOT: one pixel at (d2, a6), colour known at assembly time ---- +| Trashes d0, d1, d7. + .macro LPLOT colour + move.w %d2,%d0 + lsr.w #3,%d0 + add.w %a6,%d0 | y*40 + (x >> 3) + move.w %d2,%d1 + and.w #7,%d1 + move.b (%a5,%d1.w),%d1 | 0x80 >> (x & 7) + .if (\colour) != 15 + move.b %d1,%d7 + not.b %d7 + .endif + .if ((\colour) & 1) + or.b %d1,(%a0,%d0.w) + .else + and.b %d7,(%a0,%d0.w) + .endif + .if ((\colour) & 2) + or.b %d1,(%a1,%d0.w) + .else + and.b %d7,(%a1,%d0.w) + .endif + .if ((\colour) & 4) + or.b %d1,(%a2,%d0.w) + .else + and.b %d7,(%a2,%d0.w) + .endif + .if ((\colour) & 8) + or.b %d1,(%a3,%d0.w) + .else + and.b %d7,(%a3,%d0.w) + .endif + .endm + + +| ---- LINEBODY: the whole Bresenham walk for one colour ---- + .macro LINEBODY colour +.Lline\colour: + LPLOT \colour + | done when both coordinates have arrived + cmp.w LN_X1(%sp),%d2 + bne.s .LlineStep\colour + cmp.w LN_Y1(%sp),%d3 + beq.w lineDone +.LlineStep\colour: + move.w %d4,%d0 + add.w %d0,%d0 | e2 = 2 * err + cmp.w %d6,%d0 + blt.s .LlineNoX\colour + add.w %d6,%d4 | err += dy + add.w LN_SX(%sp),%d2 | x += sx +.LlineNoX\colour: + cmp.w %d5,%d0 + bgt.w .Lline\colour + add.w %d5,%d4 | err += dx + add.w LN_SY(%sp),%d3 | y += sy + adda.w LN_SY40(%sp),%a6 | and its row offset + bra.w .Lline\colour + .endm + + +surface68kPlanarLine: +_surface68kPlanarLine: + movem.l %d2-%d7/%a2-%a6,%sp@- | 11 registers = 44 bytes + sub.l #LN_LOCAL,%sp + move.l LN_PLANES(%sp),%a4 + move.l (%a4),%a0 + move.l 4(%a4),%a1 + move.l 8(%a4),%a2 + move.l 12(%a4),%a3 + lea gPlanarBitLut,%a5 + move.w LN_X0(%sp),%d2 + move.w LN_Y0(%sp),%d3 + move.w LN_AX1(%sp),%d0 + move.w %d0,LN_X1(%sp) + move.w LN_AY1(%sp),%d1 + move.w %d1,LN_Y1(%sp) + + | dx = |x1 - x0|, sx = sign + move.w #1,LN_SX(%sp) + move.w %d0,%d5 + sub.w %d2,%d5 + bge.s 1f + neg.w %d5 + move.w #-1,LN_SX(%sp) +1: | dy = -|y1 - y0|, sy = sign + move.w #1,LN_SY(%sp) + move.w #ROW_BYTES,LN_SY40(%sp) + move.w %d1,%d6 + sub.w %d3,%d6 + bge.s 2f + neg.w %d6 + move.w #-1,LN_SY(%sp) + move.w #-ROW_BYTES,LN_SY40(%sp) +2: neg.w %d6 | dy is carried negative + move.w %d5,%d4 + add.w %d6,%d4 | err = dx + dy + + | a6 = y0 * 40, as (y << 5) + (y << 3) + move.w %d3,%d0 + move.w %d3,%d1 + lsl.w #5,%d0 + lsl.w #3,%d1 + add.w %d1,%d0 + move.w %d0,%a6 + + move.w LN_COLOR(%sp),%d0 + and.w #15,%d0 + add.w %d0,%d0 + lea lineJump(%pc),%a4 + move.w (%a4,%d0.w),%d1 + jmp (%a4,%d1.w) + +lineJump: + .word lineC0-lineJump, lineC1-lineJump + .word lineC2-lineJump, lineC3-lineJump + .word lineC4-lineJump, lineC5-lineJump + .word lineC6-lineJump, lineC7-lineJump + .word lineC8-lineJump, lineC9-lineJump + .word lineC10-lineJump, lineC11-lineJump + .word lineC12-lineJump, lineC13-lineJump + .word lineC14-lineJump, lineC15-lineJump + +lineC0: LINEBODY 0 +lineC1: LINEBODY 1 +lineC2: LINEBODY 2 +lineC3: LINEBODY 3 +lineC4: LINEBODY 4 +lineC5: LINEBODY 5 +lineC6: LINEBODY 6 +lineC7: LINEBODY 7 +lineC8: LINEBODY 8 +lineC9: LINEBODY 9 +lineC10: LINEBODY 10 +lineC11: LINEBODY 11 +lineC12: LINEBODY 12 +lineC13: LINEBODY 13 +lineC14: LINEBODY 14 +lineC15: LINEBODY 15 + +lineDone: + add.l #LN_LOCAL,%sp + movem.l %sp@+,%d2-%d7/%a2-%a6 + rts + + + .data + .even +| 0x80 >> (x & 7), the byte mask for a pixel's position within its byte. +gPlanarBitLut: + .byte 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 diff --git a/src/m68k/surface68k.s b/src/m68k/surface68k.s deleted file mode 100644 index e566170..0000000 --- a/src/m68k/surface68k.s +++ /dev/null @@ -1,401 +0,0 @@ -| Shared 68000 fast path for the planar surface buffer. -| -| Holds surface68kFillSpan4Planes -- the per-row body of the 4-plane -| planar rect/circle fill. Only the Amiga port calls it (ST is -| word-interleaved and uses its own St* routines); it lives here -| because the file is globbed into both ports' builds. The earlier -| chunky clear/fill primitives that this file once also held were -| removed once both ports went native-planar. -| -| Calling convention: cdecl (gcc-amigaos / gcc-atari-mint). -| d0/d1/a0/a1 are caller-save (scratch). -| d2-d7/a2-a6 are callee-save (must be preserved). -| 16-bit shorts are sign-extended to int (4 bytes) on the call stack. -| -| GAS m68k syntax. Assembled by the gcc driver via binutils m68k-as. - - .text - - -| ---------------------------------------------------------------- -| void surface68kFillSpan4Planes(uint8_t *p0, uint8_t *p1, -| uint8_t *p2, uint8_t *p3, -| uint16_t numMid, -| uint8_t leftMask, uint8_t rightMask, -| uint8_t fb0, uint8_t fb1, -| uint8_t fb2, uint8_t fb3); -| -| Fill ONE planar row across 4 planes -- the per-row body of -| jlpFillRect lifted into asm. Each pN points at the leading -| byte (already advanced by planeBase + y*40 + byteFirst on the C -| side). leftMask and rightMask are the partial-byte masks for the -| left/right edges; numMid is the count of full bytes between them. -| fbN is 0x00 or 0xFF, the per-plane fill byte (caller pre-classifies -| (colorIndex >> N) & 1 -> 0xFF or 0x00). -| -| Used by Amiga jlpFillCircle (one call per scanline span) and -| Amiga jlpFillRect (one call per row of the rect). Replaces -| the C inner loop whose ~13 cyc/byte was the gating cost on -| jlFillCircle r=40 even after C-side inlining. -| -| Mask convention is uniform for all planes: -| leading byte := (*p & ~leftMask) | (fbN & leftMask) -| middle bytes := fbN -| trailing byte := (*p & ~rightMask) | (fbN & rightMask) -| -- branchless: the same arithmetic produces "set" or "clear" based -| on whether fbN is 0xFF or 0x00. -| -| ABI: m68k cdecl. d2-d7/a2-a6 callee-save. Only a2/a3 are actually -| used by the body (planes 2/3); a4-a6 are not touched, so only -| d2-d7/a2-a3 are movem'd here. -| Stack offset to first arg after MOVEM: 8 regs * 4 = 32 bytes saved -| + 4 ret PC = 36. -| ---------------------------------------------------------------- - .globl _surface68kFillSpan4Planes - - .equ SP_SAVED, 32 - .equ SP_RPC, 4 - .equ SP_OFF, (SP_SAVED + SP_RPC) - - .equ SP_P0, SP_OFF + 0 - .equ SP_P1, SP_OFF + 4 - .equ SP_P2, SP_OFF + 8 - .equ SP_P3, SP_OFF + 12 - .equ SP_NMID, SP_OFF + 16 + 2 | int -> low word at +2 - .equ SP_LMASK, SP_OFF + 20 + 3 | int -> low byte at +3 - .equ SP_RMASK, SP_OFF + 24 + 3 - .equ SP_FB0, SP_OFF + 28 + 3 - .equ SP_FB1, SP_OFF + 32 + 3 - .equ SP_FB2, SP_OFF + 36 + 3 - .equ SP_FB3, SP_OFF + 40 + 3 - -| Per-plane work is fully inlined (hand-unrolled 4x rather than -| bsr+rts, to dodge ~12 cyc per return + the per-plane re-test of -| numMid that an earlier build paid). Register convention across all -| four planes: -| d1 = leftMask, d2 = ~leftMask, d3 = rightMask, d4 = ~rightMask -| d5 = this plane's fb byte, d6/d0/d7 = per-plane scratch. -| -| The unmasked middle run is filled with long stores rather than -| byte-at-a-time. fb is 0x00 or 0xFF, so the long is built with a -| byte->word->long sign-extend (ext.w / ext.l). The mid pointer is -| advanced to a long boundary by storing 0-3 fb bytes (align run), -| then move.l (an)+ fills (midRem >> 2) longs, then a 0-3 byte tail -| drains the remainder. The masked left/right edge bytes keep their -| per-plane read-modify-write; only the unmasked middle changed. -| -| Plain text version of the per-plane body (translate to asm 4x with -| different a-regs and fb stack offsets): -| -| move.b (an),%d6 | --- leading masked edge byte --- -| and.b %d2,%d6 -| move.b fb,%d5 -| move.b %d5,%d7 -| and.b %d1,%d7 -| or.b %d7,%d6 -| move.b %d6,(an)+ -| < if has-middle path: > -| move.b %d5,%d7 | build replicated fill long in d7 -| ext.w %d7 -| ext.l %d7 -| move.l an,%d6 | align = (-an) & 3, clamped to numMid -| neg.l %d6 -| and.w #3,%d6 -| move.w numMid,%d0 -| < clamp d6 to d0; d0 = midRem = numMid - align > -| < store d6 fb bytes to reach a long boundary > -| < midLongs = midRem >> 2; tail = midRem & 3 > -| .longN: -| move.l %d7,(an)+ -| dbra midLongs,.longN -| < store tail fb bytes > -| < trailing masked edge byte: > -| move.b (an),%d6 -| and.b %d4,%d6 -| move.b %d5,%d7 -| and.b %d3,%d7 -| or.b %d7,%d6 -| move.b %d6,(an) - -_surface68kFillSpan4Planes: - movem.l %d2-%d7/%a2-%a3,-(%sp) - - move.b SP_LMASK(%sp),%d1 - move.b %d1,%d2 - not.b %d2 - move.b SP_RMASK(%sp),%d3 - move.b %d3,%d4 - not.b %d4 - - move.l SP_P0(%sp),%a0 - move.l SP_P1(%sp),%a1 - move.l SP_P2(%sp),%a2 - move.l SP_P3(%sp),%a3 - - | One-time numMid test. d0.w = numMid; if 0 jump to - | the no-middle entry. The with-middle path now fills - | the unmasked middle run with long stores (move.l - | (an)+) instead of byte-at-a-time, after aligning the - | mid pointer to a long boundary and draining a 0-3 - | byte tail. The masked left/right edge bytes keep their - | per-plane read-modify-write exactly as before. - | - | The mid fill byte fbN is 0x00 or 0xFF, so the long - | replication is a byte->word->long sign-extend (ext.w - | then ext.l): 0xFF -> 0xFFFFFFFF, 0x00 -> 0x00000000. - | - | Per-plane register use during the mid run: - | d5 = fbN byte (held for tail + trailing edge) - | d7 = fbN replicated to a long (move.l fill value) - | d6 = scratch / align count / midLongs count - | d0 = numMid, then midRem, then tail-byte count - | Masks d1-d4 stay live across all four planes. Local - | numeric labels 1f-7f are reused per plane; each - | reference resolves to the nearest copy. - move.w SP_NMID(%sp),%d0 - beq .LfsNoMid - - | ---- WITH-MIDDLE PATH ---- - | Plane 0 - move.b (%a0),%d6 - and.b %d2,%d6 - move.b SP_FB0(%sp),%d5 - move.b %d5,%d7 - and.b %d1,%d7 - or.b %d7,%d6 - move.b %d6,(%a0)+ - move.b %d5,%d7 - ext.w %d7 - ext.l %d7 - move.l %a0,%d6 - neg.l %d6 - and.w #3,%d6 - move.w SP_NMID(%sp),%d0 - cmp.w %d0,%d6 - bls.s 1f - move.w %d0,%d6 -1: sub.w %d6,%d0 - tst.w %d6 - beq.s 2f -3: move.b %d5,(%a0)+ - subq.w #1,%d6 - bne.s 3b -2: move.w %d0,%d6 - lsr.w #2,%d6 - and.w #3,%d0 - tst.w %d6 - beq.s 5f - subq.w #1,%d6 -4: move.l %d7,(%a0)+ - dbra %d6,4b -5: tst.w %d0 - beq.s 7f -6: move.b %d5,(%a0)+ - subq.w #1,%d0 - bne.s 6b -7: move.b (%a0),%d6 - and.b %d4,%d6 - move.b %d5,%d7 - and.b %d3,%d7 - or.b %d7,%d6 - move.b %d6,(%a0) - - | Plane 1 - move.b (%a1),%d6 - and.b %d2,%d6 - move.b SP_FB1(%sp),%d5 - move.b %d5,%d7 - and.b %d1,%d7 - or.b %d7,%d6 - move.b %d6,(%a1)+ - move.b %d5,%d7 - ext.w %d7 - ext.l %d7 - move.l %a1,%d6 - neg.l %d6 - and.w #3,%d6 - move.w SP_NMID(%sp),%d0 - cmp.w %d0,%d6 - bls.s 1f - move.w %d0,%d6 -1: sub.w %d6,%d0 - tst.w %d6 - beq.s 2f -3: move.b %d5,(%a1)+ - subq.w #1,%d6 - bne.s 3b -2: move.w %d0,%d6 - lsr.w #2,%d6 - and.w #3,%d0 - tst.w %d6 - beq.s 5f - subq.w #1,%d6 -4: move.l %d7,(%a1)+ - dbra %d6,4b -5: tst.w %d0 - beq.s 7f -6: move.b %d5,(%a1)+ - subq.w #1,%d0 - bne.s 6b -7: move.b (%a1),%d6 - and.b %d4,%d6 - move.b %d5,%d7 - and.b %d3,%d7 - or.b %d7,%d6 - move.b %d6,(%a1) - - | Plane 2 - move.b (%a2),%d6 - and.b %d2,%d6 - move.b SP_FB2(%sp),%d5 - move.b %d5,%d7 - and.b %d1,%d7 - or.b %d7,%d6 - move.b %d6,(%a2)+ - move.b %d5,%d7 - ext.w %d7 - ext.l %d7 - move.l %a2,%d6 - neg.l %d6 - and.w #3,%d6 - move.w SP_NMID(%sp),%d0 - cmp.w %d0,%d6 - bls.s 1f - move.w %d0,%d6 -1: sub.w %d6,%d0 - tst.w %d6 - beq.s 2f -3: move.b %d5,(%a2)+ - subq.w #1,%d6 - bne.s 3b -2: move.w %d0,%d6 - lsr.w #2,%d6 - and.w #3,%d0 - tst.w %d6 - beq.s 5f - subq.w #1,%d6 -4: move.l %d7,(%a2)+ - dbra %d6,4b -5: tst.w %d0 - beq.s 7f -6: move.b %d5,(%a2)+ - subq.w #1,%d0 - bne.s 6b -7: move.b (%a2),%d6 - and.b %d4,%d6 - move.b %d5,%d7 - and.b %d3,%d7 - or.b %d7,%d6 - move.b %d6,(%a2) - - | Plane 3 - move.b (%a3),%d6 - and.b %d2,%d6 - move.b SP_FB3(%sp),%d5 - move.b %d5,%d7 - and.b %d1,%d7 - or.b %d7,%d6 - move.b %d6,(%a3)+ - move.b %d5,%d7 - ext.w %d7 - ext.l %d7 - move.l %a3,%d6 - neg.l %d6 - and.w #3,%d6 - move.w SP_NMID(%sp),%d0 - cmp.w %d0,%d6 - bls.s 1f - move.w %d0,%d6 -1: sub.w %d6,%d0 - tst.w %d6 - beq.s 2f -3: move.b %d5,(%a3)+ - subq.w #1,%d6 - bne.s 3b -2: move.w %d0,%d6 - lsr.w #2,%d6 - and.w #3,%d0 - tst.w %d6 - beq.s 5f - subq.w #1,%d6 -4: move.l %d7,(%a3)+ - dbra %d6,4b -5: tst.w %d0 - beq.s 7f -6: move.b %d5,(%a3)+ - subq.w #1,%d0 - bne.s 6b -7: move.b (%a3),%d6 - and.b %d4,%d6 - move.b %d5,%d7 - and.b %d3,%d7 - or.b %d7,%d6 - move.b %d6,(%a3) - - movem.l (%sp)+,%d2-%d7/%a2-%a3 - rts - -.LfsNoMid: - | ---- NO-MIDDLE PATH (just leading + trailing) ---- - | Plane 0 - move.b (%a0),%d6 - and.b %d2,%d6 - move.b SP_FB0(%sp),%d5 - move.b %d5,%d7 - and.b %d1,%d7 - or.b %d7,%d6 - move.b %d6,(%a0)+ - move.b (%a0),%d6 - and.b %d4,%d6 - move.b %d5,%d7 - and.b %d3,%d7 - or.b %d7,%d6 - move.b %d6,(%a0) - - | Plane 1 - move.b (%a1),%d6 - and.b %d2,%d6 - move.b SP_FB1(%sp),%d5 - move.b %d5,%d7 - and.b %d1,%d7 - or.b %d7,%d6 - move.b %d6,(%a1)+ - move.b (%a1),%d6 - and.b %d4,%d6 - move.b %d5,%d7 - and.b %d3,%d7 - or.b %d7,%d6 - move.b %d6,(%a1) - - | Plane 2 - move.b (%a2),%d6 - and.b %d2,%d6 - move.b SP_FB2(%sp),%d5 - move.b %d5,%d7 - and.b %d1,%d7 - or.b %d7,%d6 - move.b %d6,(%a2)+ - move.b (%a2),%d6 - and.b %d4,%d6 - move.b %d5,%d7 - and.b %d3,%d7 - or.b %d7,%d6 - move.b %d6,(%a2) - - | Plane 3 - move.b (%a3),%d6 - and.b %d2,%d6 - move.b SP_FB3(%sp),%d5 - move.b %d5,%d7 - and.b %d1,%d7 - or.b %d7,%d6 - move.b %d6,(%a3)+ - move.b (%a3),%d6 - and.b %d4,%d6 - move.b %d5,%d7 - and.b %d3,%d7 - or.b %d7,%d6 - move.b %d6,(%a3) - - movem.l (%sp)+,%d2-%d7/%a2-%a3 - rts diff --git a/src/x68000/audio.c b/src/x68000/audio.c index b374c9d..e05e10e 100644 --- a/src/x68000/audio.c +++ b/src/x68000/audio.c @@ -179,6 +179,26 @@ bool jlpAudioInit(void) { } +// OPM noise-frequency codes for jlAudioNoise's pitch 0..31, picked so the +// generator runs at the rate every other port gives that pitch: +// rate = 26000 - pitch * 750, the curve src/iigs/audio_full.c defines and +// src/amiga/audio.c follows. The YM2151's generator ticks at +// clock / (32 * (32 - NFRQ)) and the X68000 clocks it at 4 MHz, so +// NFRQ = 32 - 125000 / rate. That tracks the curve within a few percent +// down to pitch 29; the chip's floor is 3906 Hz (NFRQ 0), so pitches 30 +// and 31 clamp there rather than reaching 3500 and 2750. +// +// It replaces a plain `31 - pitch`, which ran the generator from 125 kHz +// down to 3906 Hz -- far brighter than the other ports across the whole +// middle of the range. +static const uint8_t kOpmNoiseFreq[32] = { + 27, 27, 27, 27, 27, 26, 26, 26, + 26, 26, 25, 25, 25, 24, 24, 24, + 23, 23, 22, 21, 21, 20, 19, 18, + 16, 15, 13, 10, 7, 3, 0, 0 +}; + + void jlpAudioNoise(uint8_t pitch, uint8_t atten) { uint8_t slot; @@ -194,7 +214,7 @@ void jlpAudioNoise(uint8_t pitch, uint8_t atten) { } // NFRQ is inverted: 0 is the highest pitch, 31 the lowest. jlAudioNoise // takes 0..31 with 0 lowest, matching the other ports, so flip it. - opmWrite(OPM_REG_NOISE, (uint8_t)(OPM_NOISE_ENABLE | ((31u - (pitch & 31u))))); + opmWrite(OPM_REG_NOISE, (uint8_t)(OPM_NOISE_ENABLE | kOpmNoiseFreq[pitch & 31u])); opmWrite((uint8_t)(OPM_REG_TL + slot), (uint8_t)((atten & 15u) * 4u)); opmWrite(OPM_REG_KEYON, (uint8_t)(OPM_NOISE_CHANNEL | OPM_SLOTS_ALL)); } diff --git a/src/x68000/audioPcm.c b/src/x68000/audioPcm.c index cecffa7..3a9e4d6 100644 --- a/src/x68000/audioPcm.c +++ b/src/x68000/audioPcm.c @@ -19,9 +19,17 @@ // circular, giving gapless playback; jlpAudioFrameTick refills the block the // chip is not currently walking. // -// NOT YET VERIFIED AUDIBLY. This compiles and the control flow is exercised, -// but no waveform has been captured out of the emulator, so treat the encoder -// output and the block hand-off as unproven. +// 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. +// +// ONE ARTEFACT REMAINS: the decoded signal sits on a wandering DC offset of +// about 11% of full scale, so the chip's predictor is not where the encoder +// thinks it is. A round trip of this encoder against the chip's exact decode +// returns to its start, so the encoder is not the cause; something in the +// chain hand-off feeds the chip a stream we did not encode. Inaudible by +// itself, but it is a disagreement and it costs headroom. #include @@ -43,8 +51,34 @@ // makes it affordable: the SFX+encode path benched at ~17 cycles/sample, so // doubling the rate is ~13% of a 10 MHz 68000 rather than the ~6.6% at 3906. #define MSM6258_RATE 7812 -// _iocs_adpcmmod / _iocs_adpcmlot mode byte: rate selector low, pan high. -// 0x05 = 7.8 kHz, both channels. +// 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) +// 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 // Catch the 3906 Hz mistake at COMPILE time rather than as silence at runtime. @@ -52,11 +86,23 @@ // and the only symptom is that MOD playback never starts. typedef char x68kAdpcmRateAboveXmpFloor[(MSM6258_RATE >= XMP_MIN_SRATE) ? 1 : -1]; -// Two blocks of PCM per chain entry. 1024 PCM samples = 512 ADPCM bytes = -// ~131 ms at 7812 Hz, so a dropped frame cannot starve the chip. -#define PCM_BLOCK 1024 +// 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 +// audio tick arrives every 50-300 ms in the ordinary case, with a tail out +// to 500 ms and a handful of 1-2 s hitches when the game is loading. A chain +// shorter than the gap between ticks is replayed by the DMAC, and a replay +// is not silence -- it hands the decoder nibbles the encoder never meant, so +// the audio repeats a word AND the predictor takes a step it never loses. +// +// Eight 512-sample blocks is 524 ms of chain, 458 ms of which can sit ahead +// of the chip. That covers every gap but the loading hitches, which no +// affordable chain would cover. The chain length is also the worst-case +// delay before newly mixed audio is heard, but only for a sound that starts +// while the chip is already running: one that starts from idle restarts the +// chain and is heard at once. +#define PCM_BLOCK 512 #define ADPCM_BLOCK (PCM_BLOCK / 2) -#define BLOCK_COUNT 2 +#define BLOCK_COUNT 8 static xmp_context gXmpCtx = NULL; static bool gXmpLoaded = false; @@ -71,7 +117,12 @@ static AudioSfxSlotT gSfxSlots[JOEY_AUDIO_SFX_SLOTS]; static uint8_t gMixBuf[PCM_BLOCK]; static uint8_t gAdpcm[BLOCK_COUNT][ADPCM_BLOCK]; static struct iocs_chain2 gChain[BLOCK_COUNT]; -static uint8_t gFillBlock = 0; +// The next chain block to fill, in playback order. Everything between it +// and the block the chip is reading is stale and gets refilled. +static uint8_t gNextFill = 0; +// Consecutive silent blocks encoded. At BLOCK_COUNT the chain holds nothing +// but silence and the chip is stopped -- see jlpAudioPcmFrameTick. +static uint8_t gIdleBlocks = 0; // Diagnostics: how many times each stage actually ran. Exposed (non-static) so // a probe can read them without a debugger -- silent no-ops in the refill path // are exactly the failure this port hit. @@ -165,6 +216,22 @@ static void adpcmEncodeBlock(const uint8_t *pcm, uint8_t *out) { } +// Nothing to play: no module running and every SFX slot finished. +static bool pcmIdle(void) { + uint8_t s; + + if (gXmpStarted) { + return false; + } + for (s = 0u; s < JOEY_AUDIO_SFX_SLOTS; s++) { + if (gSfxSlots[s].active) { + return false; + } + } + return true; +} + + // Render one block: module (if playing) then the SFX overlay on top. static void renderBlock(uint8_t *dst) { if (gXmpStarted) { @@ -176,6 +243,31 @@ static void renderBlock(uint8_t *dst) { } +// Which chain block is the chip reading? 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. Returns BLOCK_COUNT when the pointer +// is not in the chain at all, which is how a caller learns the chain is not +// running rather than acting on a garbage index. +static uint8_t adpcmPlayingBlock(void) { + uint32_t mar = ((uint32_t)*DMAC3_MAR_HI << 16) | (uint32_t)*DMAC3_MAR_LO; + uint32_t offset = mar - (uint32_t)&gAdpcm[0][0]; + + if (offset >= (uint32_t)(BLOCK_COUNT * ADPCM_BLOCK)) { + return BLOCK_COUNT; // not in the chain: say so, do nothing + } + return (uint8_t)(offset / ADPCM_BLOCK); +} + + +// 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); +} + + static void startChain(void) { uint8_t b; @@ -186,8 +278,13 @@ static void startChain(void) { gChain[b].len = ADPCM_BLOCK; gChain[b].next = &gChain[(b + 1u) % BLOCK_COUNT]; // circular } - gFillBlock = 0u; + // The chip's decoder starts from a known state, so the encoder that has + // to mirror it must too, or the two are offset for the whole run. + gEncPredictor = 0; + gEncStepIndex = 0; + gNextFill = 0u; // the chain starts playing block 0 _iocs_adpcmlot(&gChain[0], ADPCM_MODE); + adpcmSetRateAndPan(); // AFTER the IOCS call, which sets them wrong gPlaying = true; gX68kPcmPlaying = 1u; gX68kPcmChainStarts++; @@ -288,17 +385,47 @@ void jlpAudioStopSfx(uint8_t slot) { } -// Refill one block per frame. At 3906 Hz a block is ~262 ms and there are two, -// so the chip has roughly half a second of audio queued -- ample margin over a -// 55 Hz frame tick even if several frames are missed. +// Top the chain back up behind the chip, however many blocks that takes. +// Refilling a fixed number of blocks per call was the bug: tie production to +// the caller's rate and it is wrong at both ends -- too fast on a title +// screen at 31 Hz, where it overwrote the block being read, and too slow in +// a ride at 6 Hz, where the DMAC lapped it and replayed stale audio. The +// chip's own read pointer is the only honest clock, so ask it. void jlpAudioPcmFrameTick(void) { + uint8_t playing; + if (!gPcmReady || !gPlaying) { return; } - renderBlock(gMixBuf); - adpcmEncodeBlock(gMixBuf, gAdpcm[gFillBlock]); - gFillBlock = (uint8_t)((gFillBlock + 1u) % BLOCK_COUNT); - gX68kPcmRefills++; + playing = adpcmPlayingBlock(); + if (playing >= BLOCK_COUNT) { + return; // the chain is not running yet + } + // Everything from gNextFill up to the block being played is behind the + // chip and safe to rewrite; the block being played is not. + while (gNextFill != playing) { + renderBlock(gMixBuf); + adpcmEncodeBlock(gMixBuf, gAdpcm[gNextFill]); + gNextFill = (uint8_t)((gNextFill + 1u) % BLOCK_COUNT); + gX68kPcmRefills++; + if (!pcmIdle()) { + gIdleBlocks = 0u; + } else if (gIdleBlocks < BLOCK_COUNT) { + gIdleBlocks++; + } + } + // A whole chain of silence: stop the chip rather than keep a DMA and a + // 4-bit encoder busy saying nothing. Silence is not free here -- ADPCM + // cannot encode "no change", so the smallest step still dithers, and any + // slip between the chip's clock and the DMA leaves the decoder sitting + // on a DC offset that only grows. Stopping resets both sides, and the + // next sound restarts the chain and is heard immediately. + if (gIdleBlocks >= BLOCK_COUNT) { + _iocs_adpcmmod(0); + gPlaying = false; + gIdleBlocks = 0u; + gX68kPcmPlaying = 0u; + } } diff --git a/src/x68000/hal.c b/src/x68000/hal.c index 0b4d451..f967521 100644 --- a/src/x68000/hal.c +++ b/src/x68000/hal.c @@ -123,6 +123,12 @@ #define X68K_MFP_CLOCK_HZ 4000000ul #define X68K_TCDCR_C_SHIFT 4u #define X68K_TCDCR_C_MASK 0x07u +// Display frames to count the tick against at init. Sixteen is ~290 ms, long +// enough that one frame of slop is under a percent. +#define X68K_TICK_CAL_FRAMES 16u +// Spin bound for that calibration: comfortably more polls than sixteen frames +// need, so only a machine that is not going to toggle V-DISP ever hits it. +#define X68K_TICK_CAL_GUARD 4000000ul // ----- Module state --------------------------------------------------------- @@ -154,6 +160,8 @@ static uint32_t tickHzFromMfp(void); uint32_t jlpMillisElapsed(void); uint16_t jlpFrameHz(void); static void buildSpreadTables(void); +static void spreadSetPalBase(uint8_t palBase); +static void calibrateTickHz(void); static void installTickIsr(void); static void uploadGfxPalette(const jlSurfaceT *src); @@ -289,6 +297,64 @@ static void installTickIsr(void) { x68kTickChainAddr = *vec; *vec = (uint32_t)x68kTickIsr; gTickHooked = true; + calibrateTickHz(); +} + + +// Count real ticks across a known number of display frames and believe THAT +// rather than the MFP registers. +// +// Reading the prescale and data registers looks exact and is not: two runs of +// the same benchmark reported 50 s and 116 s of elapsed time for runs that +// really took 118 s and 100 s, a 2.7x disagreement between them, because the +// rate Human68k leaves Timer C at is not the rate those registers describe by +// the time a program reads them. Everything derived from the clock was wrong +// by that factor, which is how a performance change came back looking three +// times slower than the code it replaced. +// +// The display frame is the one rate on this machine that cannot drift: it +// comes from the CRT mode. Sixteen frames is about 290 ms of init. +static void calibrateTickHz(void) { + uint8_t last; + uint16_t frames = 0u; + uint32_t start; + uint32_t ticks; + uint32_t guard; + + // Every spin is bounded. A machine where V-DISP never toggles would + // otherwise hang here in jlInit, which is far worse than a clock that is + // merely wrong: the guard gives up and keeps the register-derived rate. + last = (uint8_t)((*X68K_MFP_GPIP & X68K_GPIP_VDISP) != 0u); + guard = X68K_TICK_CAL_GUARD; + while ((uint8_t)((*X68K_MFP_GPIP & X68K_GPIP_VDISP) != 0u) == last) { + if (--guard == 0ul) { + return; + } + } + last = (uint8_t)((*X68K_MFP_GPIP & X68K_GPIP_VDISP) != 0u); + start = gX68kTicks; + guard = X68K_TICK_CAL_GUARD; + while (frames < X68K_TICK_CAL_FRAMES) { + uint8_t now = (uint8_t)((*X68K_MFP_GPIP & X68K_GPIP_VDISP) != 0u); + + if (now != last) { + last = now; + if (now != 0u) { + frames++; + } + } + if (--guard == 0ul) { + return; + } + } + ticks = gX68kTicks - start; + if (ticks == 0ul) { + return; // ISR never fired: keep the guess + } + gTickHz = (ticks * (uint32_t)jlpFrameHz()) / (uint32_t)X68K_TICK_CAL_FRAMES; + if (gTickHz == 0ul) { + gTickHz = tickHzFromMfp(); + } } @@ -332,9 +398,21 @@ static void uploadGfxPalette(const jlSurfaceT *src) { // together, palBase ORs in replicated, and the result is two long stores per // four pixels -- no shifting in the inner loop at all. #define X68K_SPREAD_NIBBLES 16u +// One table entry, addressed as a byte displacement from a plane base. +#define SPREAD_AT(base, off) (*(const uint32_t *)((base) + (off))) -static uint32_t gSpreadHi[X68K_BITPLANES][X68K_SPREAD_NIBBLES]; -static uint32_t gSpreadLo[X68K_BITPLANES][X68K_SPREAD_NIBBLES]; +// One table, laid out so the expansion loop can reach it with the 68000's +// (d8,An,Xn) mode and nothing else: [plane][nibble] holds the pair of output +// longs (pixels 0-1, then 2-3) that plane contributes, so ONE index serves +// two stores. A plane is 16 nibbles x 8 bytes = 128 bytes, which is exactly +// the reach of the signed 8-bit displacement, so two base pointers parked on +// planes 1 and 3 address all four planes at -128 and 0 with no arithmetic. +// +// Plane 0's entries carry the row's palette base as well. Folding it in there +// costs 16 stores when the SCB changes and saves an OR on every output long: +// the inner loop just ORs four table reads and stores. +static uint32_t gSpreadPair[X68K_BITPLANES][X68K_SPREAD_NIBBLES][2]; +static uint8_t gSpreadPalBase = 0u; static bool gSpreadBuilt = false; @@ -348,16 +426,39 @@ static void buildSpreadTables(void) { for (plane = 0u; plane < X68K_BITPLANES; plane++) { bit = (uint32_t)1ul << plane; for (nib = 0u; nib < X68K_SPREAD_NIBBLES; nib++) { - gSpreadHi[plane][nib] = (((nib >> 3) & 1u) ? (bit << 16) : 0ul) - | (((nib >> 2) & 1u) ? bit : 0ul); - gSpreadLo[plane][nib] = (((nib >> 1) & 1u) ? (bit << 16) : 0ul) - | (((nib >> 0) & 1u) ? bit : 0ul); + gSpreadPair[plane][nib][0] = (((nib >> 3) & 1u) ? (bit << 16) : 0ul) + | (((nib >> 2) & 1u) ? bit : 0ul); + gSpreadPair[plane][nib][1] = (((nib >> 1) & 1u) ? (bit << 16) : 0ul) + | (((nib >> 0) & 1u) ? bit : 0ul); } } + gSpreadPalBase = 0u; gSpreadBuilt = true; } +// Re-fold the row's palette base into plane 0's entries. Sixteen pairs, and +// only when the SCB actually changes from one row to the next -- which for a +// screen with one palette means once per present. +static void spreadSetPalBase(uint8_t palBase) { + uint32_t pal2; + uint16_t nib; + + if (palBase == gSpreadPalBase) { + return; + } + pal2 = ((uint32_t)palBase << 16) | (uint32_t)palBase; + for (nib = 0u; nib < X68K_SPREAD_NIBBLES; nib++) { + uint32_t bare0 = (((nib >> 3) & 1u) ? 0x10000ul : 0ul) | (((nib >> 2) & 1u) ? 1ul : 0ul); + uint32_t bare1 = (((nib >> 1) & 1u) ? 0x10000ul : 0ul) | (((nib >> 0) & 1u) ? 1ul : 0ul); + + gSpreadPair[0][nib][0] = bare0 | pal2; + gSpreadPair[0][nib][1] = bare1 | pal2; + } + gSpreadPalBase = palBase; +} + + // ----- Present -------------------------------------------------------------- // Copy the surface's four planes into the TEXT PLANE at $E00000. @@ -381,10 +482,8 @@ void jlpPresent(const jlSurfaceT *src) { uint16_t y; uint16_t firstByte; uint16_t rowBytes; - uint32_t pal2; + const uint8_t *sp; uint16_t b; - uint16_t hi0, hi1, hi2, hi3; - uint16_t lo0, lo1, lo2, lo3; uint8_t palBase; uint16_t blitRows = 0; @@ -406,6 +505,7 @@ void jlpPresent(const jlSurfaceT *src) { if (pd == NULL) { return; } + sp = (const uint8_t *)&gSpreadPair[0][0][0]; x68kSerialPump(); // the palette upload + spread-table build above are already line time for (y = 0; y < SURFACE_HEIGHT; y++) { if (!STAGE_DIRTY_ROW_TOUCHED(y) || STAGE_DIRTY_ROW_CLEAN(y)) { @@ -425,8 +525,10 @@ void jlpPresent(const jlSurfaceT *src) { rowBytes = (uint16_t)(X68K_BYTES_PER_ROW - firstByte); } // This row's band selects the high nibble of every pixel byte -- the - // whole SCB mechanism, exactly src/dos/hal.c:83. + // whole SCB mechanism, exactly src/dos/hal.c:83. It rides in plane 0's + // table rather than in an OR per output long. palBase = (uint8_t)(src->scb[y] << 4); + spreadSetPalBase(palBase); p0 = pd->planes[0] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; p1 = pd->planes[1] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; p2 = pd->planes[2] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; @@ -438,25 +540,38 @@ void jlpPresent(const jlSurfaceT *src) { + (((uint32_t)(y + X68K_ORIGIN_Y) * X68K_GVRAM_WORDS_ROW) + X68K_ORIGIN_X + ((uint32_t)firstByte * 8u)) * 2u; wordDst = (volatile uint32_t *)rowBase; - // Two long stores per four pixels; each long is two 16-bit GVRAM words. - pal2 = (uint32_t)(((uint32_t)palBase << 16) | (uint32_t)palBase); + // Four long stores per eight pixels; each long is two GVRAM words. + // + // The index is a BYTE offset into the table, never an array + // subscript: (v >> 1) & 0x78 is the high nibble times the 8-byte + // entry size and (v << 3) & 0x78 is the low one, so the compiler has + // nothing left to scale. The plane is ORed into the same index -- + // 0x78 leaves bit 7 upwards free -- which keeps ONE table base live + // in an address register. Handing the compiler a base per plane + // instead made it worse: it folded each into an absolute and then + // reloaded it with an LEA before every single lookup. for (b = 0u; b < rowBytes; b++) { - hi0 = (uint16_t)(p0[b] >> 4); - hi1 = (uint16_t)(p1[b] >> 4); - hi2 = (uint16_t)(p2[b] >> 4); - hi3 = (uint16_t)(p3[b] >> 4); - lo0 = (uint16_t)(p0[b] & 0x0Fu); - lo1 = (uint16_t)(p1[b] & 0x0Fu); - lo2 = (uint16_t)(p2[b] & 0x0Fu); - lo3 = (uint16_t)(p3[b] & 0x0Fu); - *wordDst++ = gSpreadHi[0][hi0] | gSpreadHi[1][hi1] - | gSpreadHi[2][hi2] | gSpreadHi[3][hi3] | pal2; - *wordDst++ = gSpreadLo[0][hi0] | gSpreadLo[1][hi1] - | gSpreadLo[2][hi2] | gSpreadLo[3][hi3] | pal2; - *wordDst++ = gSpreadHi[0][lo0] | gSpreadHi[1][lo1] - | gSpreadHi[2][lo2] | gSpreadHi[3][lo3] | pal2; - *wordDst++ = gSpreadLo[0][lo0] | gSpreadLo[1][lo1] - | gSpreadLo[2][lo2] | gSpreadLo[3][lo3] | pal2; + uint8_t v0 = *p0++; + uint8_t v1 = *p1++; + uint8_t v2 = *p2++; + uint8_t v3 = *p3++; + uint16_t o0 = (uint16_t)((v0 >> 1) & 0x78u); + uint16_t o1 = (uint16_t)((v1 >> 1) & 0x78u); + uint16_t o2 = (uint16_t)((v2 >> 1) & 0x78u); + uint16_t o3 = (uint16_t)((v3 >> 1) & 0x78u); + + *wordDst++ = SPREAD_AT(sp, o0) | SPREAD_AT(sp, o1 | 128) + | SPREAD_AT(sp, o2 | 256) | SPREAD_AT(sp, o3 | 384); + *wordDst++ = SPREAD_AT(sp, o0 + 4) | SPREAD_AT(sp, (o1 | 128) + 4) + | SPREAD_AT(sp, (o2 | 256) + 4) | SPREAD_AT(sp, (o3 | 384) + 4); + o0 = (uint16_t)((v0 << 3) & 0x78u); + o1 = (uint16_t)((v1 << 3) & 0x78u); + o2 = (uint16_t)((v2 << 3) & 0x78u); + o3 = (uint16_t)((v3 << 3) & 0x78u); + *wordDst++ = SPREAD_AT(sp, o0) | SPREAD_AT(sp, o1 | 128) + | SPREAD_AT(sp, o2 | 256) | SPREAD_AT(sp, o3 | 384); + *wordDst++ = SPREAD_AT(sp, o0 + 4) | SPREAD_AT(sp, (o1 | 128) + 4) + | SPREAD_AT(sp, (o2 | 256) + 4) | SPREAD_AT(sp, (o3 | 384) + 4); } } } diff --git a/src/x68000/halPlanar.c b/src/x68000/halPlanar.c index 0a1fb57..43fcc62 100644 --- a/src/x68000/halPlanar.c +++ b/src/x68000/halPlanar.c @@ -19,9 +19,6 @@ #include "spriteInternal.h" #include "x68kPlanar.h" -// Blitter first/last-word mask "all bits" -- the CPU fill takes the same value. -#define X68K_MASK_ALL 0xFFFFu - // The Amiga picks between blitter and CPU walk at 16 per-plane words because // the blitter's fixed setup loses below that. Here BOTH paths are the CPU, and // the memcpy path wins at essentially any size (it is one call per row instead @@ -29,97 +26,27 @@ #define X68K_COPYRECT_MEMCPY_MIN_WORDS 0u - -// ----- C stand-ins for the shared 68k asm ----- +// ----- Hand-written assembly ----- // -// src/m68k/surface68k.s and src/amiga/circle.s export these, but their symbols -// carry the m68k-amigaos/mint leading underscore (_surface68kFillSpan4Planes) -// and elf2x68k is ELF, which has no such prefix. Rather than fork the asm for -// an ABI difference, the port uses C for now; adopting the asm later is a -// symbol-aliasing change, not a rewrite. These are the obvious optimisation -// targets once correctness is banked. +// The area fills live in src/m68k/planarFill.s, shared with the Amiga: both +// ports keep four separate plane buffers of 40 bytes per row, so a span of +// pixels is the same bytes in the same places. (It carries both symbol +// spellings because gcc-amigaos prepends an underscore to C names and +// elf2x68k does not.) The circle OUTLINE is still per-port asm, one file +// each. The pixel walkers are shared too, in src/m68k/planarPlot.s. -// Fill one horizontal span across all four planes: a partial left byte, a run -// of whole middle bytes, then a partial right byte. fbN is the fill byte for -// plane N (0x00 or 0xFF), so a colour is applied by writing each plane's bit. -static void surface68kFillSpan4Planes(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, - uint16_t numMid, uint8_t leftMask, uint8_t rightMask, - uint8_t fb0, uint8_t fb1, uint8_t fb2, uint8_t fb3) { - uint8_t *pl[X68K_BITPLANES]; - uint8_t fb[X68K_BITPLANES]; - uint16_t i; - uint16_t m; - - pl[0] = p0; pl[1] = p1; pl[2] = p2; pl[3] = p3; - fb[0] = fb0; fb[1] = fb1; fb[2] = fb2; fb[3] = fb3; - for (i = 0u; i < X68K_BITPLANES; i++) { - uint8_t *d = pl[i]; - if (leftMask != 0u) { - *d = (uint8_t)((*d & (uint8_t)~leftMask) | (fb[i] & leftMask)); - } - d++; - for (m = 0u; m < numMid; m++) { - *d++ = fb[i]; - } - if (rightMask != 0u) { - *d = (uint8_t)((*d & (uint8_t)~rightMask) | (fb[i] & rightMask)); - } - } -} +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); -// Bresenham midpoint circle outline, plotted straight into the planes. Caller -// has verified the whole bbox is on-surface, so there is no per-pixel clip. -static void x68kPlotPlanes(uint8_t *pl[X68K_BITPLANES], uint16_t x, uint16_t y, uint8_t color) { - uint32_t off; - uint8_t bit; - uint16_t i; - - off = ((uint32_t)y * X68K_BYTES_PER_ROW) + (uint32_t)(x >> 3); - bit = (uint8_t)(0x80u >> (x & 7u)); - for (i = 0u; i < X68K_BITPLANES; i++) { - if ((color >> i) & 1u) { - pl[i][off] |= bit; - } else { - pl[i][off] = (uint8_t)(pl[i][off] & (uint8_t)~bit); - } - } -} - - -static void surface68kAmigaCircleOutline(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, - uint16_t cx, uint16_t cy, uint16_t r, uint8_t color) { - uint8_t *pl[X68K_BITPLANES]; - int16_t x; - int16_t y; - int16_t d; - - pl[0] = p0; pl[1] = p1; pl[2] = p2; pl[3] = p3; - x = 0; - y = (int16_t)r; - d = (int16_t)(1 - (int16_t)r); - while (x <= y) { - x68kPlotPlanes(pl, (uint16_t)(cx + x), (uint16_t)(cy + y), color); - x68kPlotPlanes(pl, (uint16_t)(cx - x), (uint16_t)(cy + y), color); - x68kPlotPlanes(pl, (uint16_t)(cx + x), (uint16_t)(cy - y), color); - x68kPlotPlanes(pl, (uint16_t)(cx - x), (uint16_t)(cy - y), color); - x68kPlotPlanes(pl, (uint16_t)(cx + y), (uint16_t)(cy + x), color); - x68kPlotPlanes(pl, (uint16_t)(cx - y), (uint16_t)(cy + x), color); - x68kPlotPlanes(pl, (uint16_t)(cx + y), (uint16_t)(cy - x), color); - x68kPlotPlanes(pl, (uint16_t)(cx - y), (uint16_t)(cy - x), color); - // The tie d == 0 must take the NO-SHRINK branch. src/core/draw.c:428 - // uses `err <= 0` and src/amiga/circle.s:149 uses tst.w/bgt (shrink - // only when err > 0); `d < 0` shrinks the major axis on the tie and - // plots a different pixel set for 114 of the 199 reachable radii. - if (d <= 0) { - d = (int16_t)(d + (2 * x) + 3); - } else { - d = (int16_t)(d + (2 * (x - y)) + 5); - y--; - } - x++; - } -} +// circle.s +// src/m68k/planarPlot.s, shared with the Amiga: 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 +// walkers. Straight lines never reach jlpDrawLine -- draw.c diverts those to +// jlFillRect -- so the line body walks the general diagonal case only. +extern void surface68kPlanarCircle(uint8_t *const *planes, uint16_t cx, uint16_t cy, uint16_t r, uint16_t color); +extern void surface68kPlanarLine(uint8_t *const *planes, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t color); // ----- CPU stand-ins for the Amiga blitter helpers ----- @@ -129,56 +56,37 @@ static void surface68kAmigaCircleOutline(uint8_t *p0, uint8_t *p1, uint8_t *p2, // after each row (0 for a full-stride operation). // DEST-only block fill: `height` rows of `widthWords` words set or cleared. -static void x68kCpuFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height, - uint16_t dstModBytes, bool setBits) { - uint16_t row; - uint16_t w; - uint16_t *p; - uint16_t val; +// Fill a run of 16-bit words with one value, in longs where it can. Marked +// always_inline on purpose: the masked fill calls it once PER ROW, and at +// 16x16 a row's middle is a word or two, where a real call costs more than +// the stores it saves. Measured: routing that middle through a function made +// jlFillRect 16x16 run at 0.45x of the word loop it replaced. +static inline __attribute__((always_inline)) +void x68kFillWordRun(uint8_t *d, uint16_t words, uint32_t val32, uint16_t val16) { + uint32_t *lp; + uint16_t longs; - val = setBits ? 0xFFFFu : 0x0000u; - for (row = 0u; row < height; row++) { - p = (uint16_t *)dst; - for (w = 0u; w < widthWords; w++) { - *p++ = val; - } - dst += (widthWords * 2u) + dstModBytes; + if (words != 0u && ((uintptr_t)d & 2u) != 0u) { + *(uint16_t *)d = val16; + d += 2; + words--; } -} - - -// Masked fill: as above, but the first and last words are merged through -// `fwm`/`lwm` so a rect can start and end mid-word. The Amiga does this with -// the blitter's first/last-word masks; here it is an explicit read-modify-write -// on just those two words, with the interior written whole. -static void x68kCpuMaskedFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height, - uint16_t dstModBytes, uint16_t fwm, uint16_t lwm, - bool setBits) { - uint16_t row; - uint16_t w; - uint16_t *p; - uint16_t val; - uint16_t mask; - - val = setBits ? 0xFFFFu : 0x0000u; - for (row = 0u; row < height; row++) { - p = (uint16_t *)dst; - for (w = 0u; w < widthWords; w++) { - mask = 0xFFFFu; - if (w == 0u) { - mask &= fwm; - } - if (w == (uint16_t)(widthWords - 1u)) { - mask &= lwm; - } - if (mask == 0xFFFFu) { - *p = val; - } else { - *p = (uint16_t)((*p & (uint16_t)~mask) | (val & mask)); - } - p++; - } - dst += (widthWords * 2u) + dstModBytes; + lp = (uint32_t *)d; + longs = (uint16_t)(words >> 1); + while (longs >= 4u) { + lp[0] = val32; + lp[1] = val32; + lp[2] = val32; + lp[3] = val32; + lp += 4; + longs = (uint16_t)(longs - 4u); + } + while (longs != 0u) { + *lp++ = val32; + longs--; + } + if ((words & 1u) != 0u) { + *(uint16_t *)lp = val16; } } @@ -230,7 +138,10 @@ void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) { for (i = 0; i < X68K_BITPLANES; i++) { setBits = ((colorIndex >> i) & 1u) != 0u; - x68kCpuFillOne(pd->planes[i], (X68K_BYTES_PER_ROW / 2u), SURFACE_HEIGHT, 0u, setBits); + // A whole plane is 200 contiguous rows of 20 words: one flat run. + x68kFillWordRun(pd->planes[i], (uint16_t)((X68K_BYTES_PER_ROW / 2u) * SURFACE_HEIGHT), + setBits ? 0xFFFFFFFFul : 0x00000000ul, + setBits ? 0xFFFFu : 0x0000u); } } @@ -251,80 +162,39 @@ void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) { // bits AND its complement (see the two masked-fill minterms). void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint8_t colorIndex) { X68kPlanarT *pd; - uint16_t wordFirst; - uint16_t wordLast; - uint16_t widthWords; - uint16_t fwm; - uint16_t lwm; - uint16_t dstMod; - uint16_t plane; - uint8_t bitVal; - uint8_t *dst; - // Callers guarantee s != NULL and positive w/h: the jlFillRect - // wrapper validates + clips before fillRectOnSurface, and the - // fill-circle span loop emits spanWidth = 2x+1 >= 1 with h = 1. + // Callers guarantee s != NULL and positive w/h: the jlFillRect wrapper + // validates and clips before fillRectOnSurface. pd = (X68kPlanarT *)s->portData; if (pd == NULL) { jlpGenericFillRect(s, x, y, w, 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- - // word masks that cut the partial-pixel edges (pixel 0 is bit 15 of - // its word, so the left mask drops the high bits of the first word - // and the right mask drops the low bits of the last). One blit per - // plane then fills any width, so there is no byte-aligned or - // per-pixel-edge special case to follow. - wordFirst = (uint16_t)((uint16_t)x >> 4); - wordLast = (uint16_t)(((uint16_t)x + w - 1u) >> 4); - widthWords = (uint16_t)(wordLast - wordFirst + 1u); - fwm = (uint16_t)(X68K_MASK_ALL >> ((uint16_t)x & 15u)); - lwm = (uint16_t)(X68K_MASK_ALL << (15u - (((uint16_t)x + w - 1u) & 15u))); - dstMod = (uint16_t)(X68K_BYTES_PER_ROW - (widthWords << 1)); + // Full-width rows are contiguous within a plane, so a full-width rect is + // ONE flat run of words per plane instead of h separately masked ones -- + // about three cycles a byte against the general path's six. Every + // full-screen wipe is this shape, so it keeps its own case. + if (x == 0 && w == (int16_t)SURFACE_WIDTH) { + uint16_t plane; - /* Full-width fast path: the rect spans every plane word with both - * edge masks all-ones, so each plane row is a whole 20-word store - * with no destination read. jlFillRect 320x200 (full-screen wipes) - * is the dominant case and lands here. DEST-only block fill -- - * (h << 6) | 20 words, BLTDMOD = 0 because consecutive full-width - * rows are contiguous; set bits write $FFFF (minterm $FF), clear - * bits $0000 (minterm $00). One OwnBlitter for all four planes; - * CPU fill completes synchronously, so the planes are ready before - * the present copy or any later CPU draw reads them. */ - if (wordFirst == 0u && wordLast == (uint16_t)((X68K_BYTES_PER_ROW / 2u) - 1u) - && fwm == X68K_MASK_ALL && lwm == X68K_MASK_ALL) { - uint8_t *rowBase; + for (plane = 0u; plane < X68K_BITPLANES; plane++) { + uint8_t bitVal = (uint8_t)((colorIndex >> plane) & 1u); + uint8_t *rowBase = pd->planes[plane] + (uint16_t)y * X68K_BYTES_PER_ROW; - for (plane = 0; plane < X68K_BITPLANES; plane++) { - bitVal = (uint8_t)((colorIndex >> plane) & 1u); - rowBase = pd->planes[plane] + (uint16_t)y * X68K_BYTES_PER_ROW; - x68kCpuFillOne(rowBase, (X68K_BYTES_PER_ROW / 2u), (uint16_t)h, 0u, bitVal != 0u); + x68kFillWordRun(rowBase, (uint16_t)((X68K_BYTES_PER_ROW / 2u) * (uint16_t)h), + bitVal ? 0xFFFFFFFFul : 0x00000000ul, + bitVal ? 0xFFFFu : 0x0000u); } - return; } - /* General masked fill: A is the constant $FFFF cut to the rect by - * the fwm/lwm word masks, C reads and D writes the destination so - * bits outside the mask survive. One blit per plane covers any - * width, partial pixel edges included -- the blitter does the edge - * masking in hardware, replacing the old per-pixel-edge CPU walkers. - * UBER jlFillRect 16x16 / 80x80 lands here. (jlFillCircle spans - * left this path in hunt-2 rank 14: the exact-disk walk feeds - * x68kFillSpanInline directly, CPU-side.) widthWords == 1 needs no - * special case: the blitter ANDs both edge masks onto the single - * word. */ - - for (plane = 0; plane < X68K_BITPLANES; plane++) { - bitVal = (uint8_t)((colorIndex >> plane) & 1u); - dst = pd->planes[plane] - + (uint16_t)y * X68K_BYTES_PER_ROW - + (uint16_t)(wordFirst << 1); - x68kCpuMaskedFillOne(dst, widthWords, (uint16_t)h, dstMod, fwm, lwm, bitVal != 0u); - } - + // Everything else goes to planarFill.s, which shares its span body with + // the filled circle: masked end bytes, long stores between them, and the + // set-or-clear decision made once per colour instead of per plane per row. + // That includes one-pixel-wide rects, which is what draw.c turns a + // vertical line into -- the asm's single-byte case covers them. + surface68kFillRect(pd->planes, (uint16_t)x, (uint16_t)y, (uint16_t)w, (uint16_t)h, colorIndex); } @@ -345,6 +215,93 @@ void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint // pixel's 4bpp color is assembled from / written to the 4 plane bits // at byte x>>3, bit 0x80 >> (x & 7). +// Chunky pixel PAIR -> one 2-bit slice of each plane, packed so four table +// reads build all four plane bytes for eight pixels at once. +// +// Byte lane p of the entry holds the two bits plane p takes from the pair: +// the first pixel in bit 1, the second in bit 0. Accumulating with +// `acc = (acc << 2) | table[b]` four times fills every lane with its eight +// bits, and the lanes never bleed into each other because each starts empty +// and only ever takes two bits at the bottom. +// +// This replaces eight read-modify-writes PER PIXEL PAIR in the unaligned +// sprite walker with four table reads and two masked writes per plane per +// EIGHT pixels, which is what the walker cost: Space Taxi's sprites are +// almost never byte-aligned, so the walker is the path the game actually +// takes, and it measured 13% of the whole frame in restore alone. +// One plane's eight pixels starting at a sub-byte offset, spread to nibbles. +// Reading two bytes and shifting inside a 16-bit window is what makes the +// unaligned case cost the same as the aligned one. +#define X68K_PLANE_SLICE(_p, _off, _shift) \ + (gPlaneToChunky[(uint8_t)(((((uint16_t)(_p)[(_off)] << 8) \ + | (uint16_t)(_p)[(_off) + 1u]) >> (8u - (_shift))) & 0xFFu)]) + +// Merge one plane's eight pixels into the two destination bytes they can +// straddle. `bits` is the plane byte with the first pixel in bit 7; shifting +// it into a 16-bit window and masking keeps neighbouring pixels intact, which +// whole-byte stores would not. +#define X68K_MERGE_PLANE(_p, _off, _bits, _shift, _mHi, _mLo) \ + do { \ + uint16_t w16_ = (uint16_t)(((uint16_t)(_bits) << 8) >> (_shift)); \ + (_p)[(_off)] = (uint8_t)(((_p)[(_off)] & (uint8_t)~(_mHi)) \ + | (uint8_t)((w16_ >> 8) & (_mHi))); \ + if ((_mLo) != 0u) { \ + (_p)[(_off) + 1u] = (uint8_t)(((_p)[(_off) + 1u] & (uint8_t)~(_mLo)) \ + | (uint8_t)(w16_ & (_mLo))); \ + } \ + } while (0) + + +static uint32_t gPairToPlanes[256]; +// The same two pixels' transparency: bit 1 the first pixel, bit 0 the second, +// set when the pixel is not colour 0. Accumulated alongside gPairToPlanes so +// the sprite draw gets its write mask from the same four table reads. +static uint8_t gPairToMask[256]; +// The other direction, for the sprite SAVE: one plane byte (eight pixels) +// spread into eight nibbles' bit 0, packed as four bytes. Plane p's +// contribution is the same table shifted left by p, because each nibble +// carries a single bit and cannot carry into its neighbour -- so four table +// reads and three shifts rebuild eight chunky pixels from four planes. +static uint32_t gPlaneToChunky[256]; +static bool gPairTableBuilt = false; + + +static void buildPairTable(void) { + uint16_t b; + + if (gPairTableBuilt) { + return; + } + for (b = 0u; b < 256u; b++) { + uint8_t first = (uint8_t)(b >> 4); + uint8_t second = (uint8_t)(b & 0x0Fu); + uint32_t entry = 0ul; + uint16_t plane; + + for (plane = 0u; plane < X68K_BITPLANES; plane++) { + uint32_t bits = (uint32_t)((((first >> plane) & 1u) << 1) | ((second >> plane) & 1u)); + + entry |= bits << (plane * 8u); + } + gPairToPlanes[b] = entry; + gPairToMask[b] = (uint8_t)(((first != 0u) ? 2u : 0u) | ((second != 0u) ? 1u : 0u)); + } + for (b = 0u; b < 256u; b++) { + uint32_t spread = 0ul; + uint16_t px; + + // Pixel 0 is bit 7 and lands in the top nibble of the first byte. + for (px = 0u; px < 8u; px++) { + if ((b & (0x80u >> px)) != 0u) { + spread |= 1ul << ((3u - (px >> 1)) * 8u + ((px & 1u) ? 0u : 4u)); + } + } + gPlaneToChunky[b] = spread; + } + gPairTableBuilt = true; +} + + void jlpSpriteSavePlanes(const jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t *dstPlaneBytes) { const X68kPlanarT *pd; uint16_t pairs; @@ -399,30 +356,43 @@ void jlpSpriteSavePlanes(const jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, return; } + // Unaligned span, eight pixels at a time: take each plane's eight-bit + // slice with one 16-bit shift, then rebuild the chunky pairs from four + // table reads (see gPlaneToChunky). The old loop tested four masked + // plane bytes per PIXEL and recomputed the mask and offset each time. + buildPairTable(); pairs = (uint16_t)(w >> 1); - for (row = 0; row < (uint16_t)h; row++) { - const uint8_t *p0 = pd->planes[0] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; - const uint8_t *p1 = pd->planes[1] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; - const uint8_t *p2 = pd->planes[2] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; - const uint8_t *p3 = pd->planes[3] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; - dst = dstPlaneBytes + (uint16_t)(row * pairs); - for (pair = 0; pair < pairs; pair++) { - uint16_t px = (uint16_t)(x + (int16_t)(pair << 1)); - uint16_t byteOff = (uint16_t)(px >> 3); - uint8_t maskHi = (uint8_t)(0x80u >> (px & 7u)); - uint8_t maskLo = (uint8_t)(0x80u >> ((px + 1u) & 7u)); - uint16_t byteOffLo = (uint16_t)((px + 1u) >> 3); - uint8_t hi = 0u; - uint8_t lo = 0u; - if (p0[byteOff] & maskHi) { hi = (uint8_t)(hi | 0x01u); } - if (p1[byteOff] & maskHi) { hi = (uint8_t)(hi | 0x02u); } - if (p2[byteOff] & maskHi) { hi = (uint8_t)(hi | 0x04u); } - if (p3[byteOff] & maskHi) { hi = (uint8_t)(hi | 0x08u); } - if (p0[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x01u); } - if (p1[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x02u); } - if (p2[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x04u); } - if (p3[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x08u); } - dst[pair] = (uint8_t)((hi << 4) | lo); + { + uint16_t shift = (uint16_t)((uint16_t)x & 7u); + uint16_t firstOff = (uint16_t)((uint16_t)x >> 3); + uint16_t rowOff = (uint16_t)((uint16_t)y * X68K_BYTES_PER_ROW + firstOff); + + for (row = 0; row < (uint16_t)h; row++) { + const uint8_t *p0 = pd->planes[0] + rowOff; + const uint8_t *p1 = pd->planes[1] + rowOff; + const uint8_t *p2 = pd->planes[2] + rowOff; + const uint8_t *p3 = pd->planes[3] + rowOff; + uint16_t srcByte = 0u; + + dst = dstPlaneBytes + (uint16_t)(row * pairs); + for (pair = 0u; pair < pairs; pair += 4u) { + uint16_t have = (uint16_t)(pairs - pair); + uint32_t chunky; + uint16_t k; + + if (have > 4u) { + have = 4u; + } + chunky = X68K_PLANE_SLICE(p0, srcByte, shift) + | (X68K_PLANE_SLICE(p1, srcByte, shift) << 1) + | (X68K_PLANE_SLICE(p2, srcByte, shift) << 2) + | (X68K_PLANE_SLICE(p3, srcByte, shift) << 3); + for (k = 0u; k < have; k++) { + dst[pair + k] = (uint8_t)((chunky >> ((3u - k) * 8u)) & 0xFFu); + } + srcByte++; + } + rowOff = (uint16_t)(rowOff + X68K_BYTES_PER_ROW); } } } @@ -477,32 +447,57 @@ void jlpSpriteRestorePlanes(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uin return; } + // Unaligned span. Eight pixels at a time, not one: four table reads build + // all four plane bytes at once (see gPairToPlanes), then each plane is + // shifted into place and merged with two masked writes. The old walker + // did eight read-modify-writes per PIXEL PAIR, thirty-two per eight + // pixels, and recomputed a mask and a byte offset for every one of them. + buildPairTable(); pairs = (uint16_t)(w >> 1); - for (row = 0; row < (uint16_t)h; row++) { - uint8_t *p0 = pd->planes[0] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; - uint8_t *p1 = pd->planes[1] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; - uint8_t *p2 = pd->planes[2] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; - uint8_t *p3 = pd->planes[3] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; - src = srcPlaneBytes + (uint16_t)(row * pairs); - for (pair = 0; pair < pairs; pair++) { - uint8_t b = src[pair]; - uint8_t hi = (uint8_t)(b >> 4); - uint8_t lo = (uint8_t)(b & 0x0Fu); - uint16_t px = (uint16_t)(x + (int16_t)(pair << 1)); - uint16_t byteOff = (uint16_t)(px >> 3); - uint8_t maskHi = (uint8_t)(0x80u >> (px & 7u)); - uint8_t notHi = (uint8_t)(~maskHi); - uint16_t byteOffLo = (uint16_t)((px + 1u) >> 3); - uint8_t maskLo = (uint8_t)(0x80u >> ((px + 1u) & 7u)); - uint8_t notLo = (uint8_t)(~maskLo); - p0[byteOff] = (uint8_t)((p0[byteOff] & notHi) | ((hi & 0x01u) ? maskHi : 0u)); - p1[byteOff] = (uint8_t)((p1[byteOff] & notHi) | ((hi & 0x02u) ? maskHi : 0u)); - p2[byteOff] = (uint8_t)((p2[byteOff] & notHi) | ((hi & 0x04u) ? maskHi : 0u)); - p3[byteOff] = (uint8_t)((p3[byteOff] & notHi) | ((hi & 0x08u) ? maskHi : 0u)); - p0[byteOffLo] = (uint8_t)((p0[byteOffLo] & notLo) | ((lo & 0x01u) ? maskLo : 0u)); - p1[byteOffLo] = (uint8_t)((p1[byteOffLo] & notLo) | ((lo & 0x02u) ? maskLo : 0u)); - p2[byteOffLo] = (uint8_t)((p2[byteOffLo] & notLo) | ((lo & 0x04u) ? maskLo : 0u)); - p3[byteOffLo] = (uint8_t)((p3[byteOffLo] & notLo) | ((lo & 0x08u) ? maskLo : 0u)); + { + uint16_t shift = (uint16_t)((uint16_t)x & 7u); + uint16_t firstOff = (uint16_t)((uint16_t)x >> 3); + uint16_t rowOff = (uint16_t)((uint16_t)y * X68K_BYTES_PER_ROW + firstOff); + + for (row = 0; row < (uint16_t)h; row++) { + uint8_t *p0 = pd->planes[0] + rowOff; + uint8_t *p1 = pd->planes[1] + rowOff; + uint8_t *p2 = pd->planes[2] + rowOff; + uint8_t *p3 = pd->planes[3] + rowOff; + uint16_t dstByte = 0u; + + src = srcPlaneBytes + (uint16_t)(row * pairs); + for (pair = 0u; pair < pairs; pair += 4u) { + uint16_t have = (uint16_t)(pairs - pair); + uint32_t acc = 0ul; + uint16_t k; + uint16_t valid; + uint16_t maskHi; + uint16_t maskLo; + + if (have > 4u) { + have = 4u; + } + for (k = 0u; k < have; k++) { + acc = (acc << 2) | gPairToPlanes[src[pair + k]]; + } + // A short last group leaves its pixels at the bottom of each + // lane; push them up so the first pixel is always bit 7. + if (have < 4u) { + acc <<= (2u * (4u - have)); + } + valid = (uint16_t)(0xFFu << (8u - (have * 2u))); + valid &= 0xFFu; + maskHi = (uint16_t)((valid << 8) >> shift) >> 8; + maskLo = (uint16_t)((valid << 8) >> shift) & 0xFFu; + + X68K_MERGE_PLANE(p0, dstByte, (uint8_t)(acc & 0xFFu), shift, maskHi, maskLo); + X68K_MERGE_PLANE(p1, dstByte, (uint8_t)((acc >> 8) & 0xFFu), shift, maskHi, maskLo); + X68K_MERGE_PLANE(p2, dstByte, (uint8_t)((acc >> 16) & 0xFFu), shift, maskHi, maskLo); + X68K_MERGE_PLANE(p3, dstByte, (uint8_t)((acc >> 24) & 0xFFu), shift, maskHi, maskLo); + dstByte++; + } + rowOff = (uint16_t)(rowOff + X68K_BYTES_PER_ROW); } } } @@ -562,7 +557,6 @@ void jlpSpriteDrawPlanes(jlSurfaceT *s, const jlSpriteT *sp, int16_t x, int16_t int16_t dstByteIdx; uint16_t wTiles; uint8_t shift; - uint8_t bit; uint8_t p0, p1, p2, p3, mask; uint8_t leftMask, notMask; uint8_t rshift, rightMask; @@ -574,6 +568,7 @@ void jlpSpriteDrawPlanes(jlSurfaceT *s, const jlSpriteT *sp, int16_t x, int16_t if (pd == NULL) { return; } + buildPairTable(); wTiles = sp->widthTiles; w = (int16_t)(wTiles * 8u); @@ -608,18 +603,26 @@ void jlpSpriteDrawPlanes(jlSurfaceT *s, const jlSpriteT *sp, int16_t x, int16_t tile = sp->tileData + (uint32_t)((tileY * wTiles + byteCol) * 32u); chunky = tile + inTileY * 4u; - /* c2p: 8 pixels (4 chunky bytes) -> 4 plane bytes + mask. */ - p0 = 0u; p1 = 0u; p2 = 0u; p3 = 0u; mask = 0u; - for (bit = 0; bit < 8u; bit++) { - uint8_t nibble = (bit & 1u) - ? (uint8_t)(chunky[bit >> 1] & 0x0Fu) - : (uint8_t)(chunky[bit >> 1] >> 4); - uint8_t bm = (uint8_t)(0x80u >> bit); - if (nibble != 0u) { mask |= bm; } - if (nibble & 1u) { p0 |= bm; } - if (nibble & 2u) { p1 |= bm; } - if (nibble & 4u) { p2 |= bm; } - if (nibble & 8u) { p3 |= bm; } + /* c2p: 8 pixels (4 chunky bytes) -> 4 plane bytes + mask, four + * table reads instead of eight passes of five conditional ORs. + * See gPairToPlanes: each read carries two bits for every plane, + * so one accumulator fills all four plane bytes at once. */ + { + uint32_t acc = 0ul; + uint16_t m = 0u; + uint16_t k; + + for (k = 0u; k < 4u; k++) { + uint8_t cb = chunky[k]; + + acc = (acc << 2) | gPairToPlanes[cb]; + m = (uint16_t)((m << 2) | gPairToMask[cb]); + } + p0 = (uint8_t)(acc & 0xFFu); + p1 = (uint8_t)((acc >> 8) & 0xFFu); + p2 = (uint8_t)((acc >> 16) & 0xFFu); + p3 = (uint8_t)((acc >> 24) & 0xFFu); + mask = (uint8_t)m; } if (mask == 0u) { continue; @@ -737,32 +740,6 @@ void jlpSurfaceCopyRect(jlSurfaceT *dst, const jlSurfaceT *src, uint16_t x, uint // the four shadow planes. Caller (the per-primitive walker below) has // already validated (x, y) is on-surface. byteOff is computed once // and reused across all four planes since stride is the same in each. -/* Per-pixel plane RMW with the color-bit classification hoisted by - * the caller. set0..set3 are 0xFF if that plane's color bit is 1 - * (OR-in bitMask), 0 if 0 (AND-out bitMask). Each plane updates with - * `*pn = (*pn & ~bitMask) | (setN & bitMask)` -- branch-free, 4 - * RMWs per pixel. Inlined into shape walkers so the outer loop pays - * no function-call overhead. */ -/* Bit-mask LUT for the per-pixel macro -- replaces a runtime - * `0x80u >> (x & 7)` (8 cyc per bit shifted on 68000) with a single - * byte load. Saves ~25 cyc per pixel inside circle / line walkers. */ -static const uint8_t kAmigaPlanePutPixelBitLut[8] = { - 0x80u, 0x40u, 0x20u, 0x10u, 0x08u, 0x04u, 0x02u, 0x01u -}; - -#define AMIGA_PLANE_PUT_PIXEL(pd_, x_, y_, set0_, set1_, set2_, set3_) do { \ - uint16_t byteOff_ = (uint16_t)((uint16_t)(y_) * X68K_BYTES_PER_ROW + ((uint16_t)(x_) >> 3)); \ - uint8_t bitMask_ = kAmigaPlanePutPixelBitLut[(uint16_t)(x_) & 7u]; \ - uint8_t notMask_ = (uint8_t)~bitMask_; \ - uint8_t *p0_ = &(pd_)->planes[0][byteOff_]; \ - uint8_t *p1_ = &(pd_)->planes[1][byteOff_]; \ - uint8_t *p2_ = &(pd_)->planes[2][byteOff_]; \ - uint8_t *p3_ = &(pd_)->planes[3][byteOff_]; \ - *p0_ = (uint8_t)((*p0_ & notMask_) | ((set0_) & bitMask_)); \ - *p1_ = (uint8_t)((*p1_ & notMask_) | ((set1_) & bitMask_)); \ - *p2_ = (uint8_t)((*p2_ & notMask_) | ((set2_) & bitMask_)); \ - *p3_ = (uint8_t)((*p3_ & notMask_) | ((set3_) & bitMask_)); \ -} while (0) static void x68kPlanarSetPixel(X68kPlanarT *pd, int16_t x, int16_t y, uint8_t color) { @@ -806,56 +783,13 @@ void jlpDrawPixel(jlSurfaceT *s, uint16_t x, uint16_t y, uint8_t colorIndex) { } -// Bresenham's diagonal line, planar-only walk. Same algorithm as -// cross-platform jlDrawLine's fallback so the pixel set matches it -// bit-for-bit. -static void x68kPlanarLine(X68kPlanarT *pd, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t color) { - int16_t dx; - int16_t dy; - int16_t sx; - int16_t sy; - int16_t err; - int16_t e2; - uint8_t set0; - uint8_t set1; - uint8_t set2; - uint8_t set3; - - set0 = (color & 0x01u) ? 0xFFu : 0x00u; - set1 = (color & 0x02u) ? 0xFFu : 0x00u; - set2 = (color & 0x04u) ? 0xFFu : 0x00u; - set3 = (color & 0x08u) ? 0xFFu : 0x00u; - - dx = (int16_t)((x1 > x0) ? (x1 - x0) : (x0 - x1)); - dy = (int16_t)(-((y1 > y0) ? (y1 - y0) : (y0 - y1))); - sx = (int16_t)((x0 < x1) ? 1 : -1); - sy = (int16_t)((y0 < y1) ? 1 : -1); - err = (int16_t)(dx + dy); - while (1) { - AMIGA_PLANE_PUT_PIXEL(pd, x0, y0, set0, set1, set2, set3); - if (x0 == x1 && y0 == y1) { - break; - } - e2 = (int16_t)(2 * err); - if (e2 >= dy) { - err = (int16_t)(err + dy); - x0 = (int16_t)(x0 + sx); - } - if (e2 <= dx) { - err = (int16_t)(err + dx); - y0 = (int16_t)(y0 + sy); - } - } -} - - void jlpDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t colorIndex) { X68kPlanarT *pd; if (s->portData == NULL) { return; } pd = (X68kPlanarT *)s->portData; - x68kPlanarLine(pd, x0, y0, x1, y1, (uint8_t)(colorIndex & 0x0Fu)); + surface68kPlanarLine(pd->planes, x0, y0, x1, y1, (uint16_t)(colorIndex & 0x0Fu)); } @@ -865,112 +799,33 @@ void jlpDrawCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t co return; } pd = (X68kPlanarT *)s->portData; - surface68kAmigaCircleOutline(pd->planes[0], pd->planes[1], - pd->planes[2], pd->planes[3], - (uint16_t)cx, (uint16_t)cy, r, - (uint8_t)(colorIndex & 0x0Fu)); -} - - -/* Single-row 4-plane span fill via shared68k asm. Caller pre-computes - * the left/right partial-byte masks; fillByte per plane is just - * 0xFF/0x00 based on colorIndex bit. The asm body avoids per-byte - * function-call dispatch and the C compiler's per-iter overhead -- - * critical for jlFillCircle r=40 which pre-asm was paying ~50 ms/call - * for 80 spans. */ -static inline __attribute__((always_inline)) -void x68kFillSpanInline(X68kPlanarT *pd, int16_t spanX, int16_t spanY, - uint16_t spanW, uint8_t colorIndex) { - static const uint8_t kLM[8] = {0xFFu,0x7Fu,0x3Fu,0x1Fu,0x0Fu,0x07u,0x03u,0x01u}; - static const uint8_t kRM[8] = {0x80u,0xC0u,0xE0u,0xF0u,0xF8u,0xFCu,0xFEu,0xFFu}; - - uint16_t byteFirst = (uint16_t)((uint16_t)spanX >> 3); - uint16_t lastBit = (uint16_t)(spanX + spanW - 1); - uint16_t byteLast = (uint16_t)(lastBit >> 3); - uint8_t leftMask = kLM[(uint16_t)spanX & 7u]; - uint8_t rightMask = kRM[lastBit & 7u]; - uint16_t rowOff = (uint16_t)((uint16_t)spanY * X68K_BYTES_PER_ROW + byteFirst); - uint8_t fb0 = ((colorIndex >> 0) & 1u) ? 0xFFu : 0x00u; - uint8_t fb1 = ((colorIndex >> 1) & 1u) ? 0xFFu : 0x00u; - uint8_t fb2 = ((colorIndex >> 2) & 1u) ? 0xFFu : 0x00u; - uint8_t fb3 = ((colorIndex >> 3) & 1u) ? 0xFFu : 0x00u; - uint8_t *p0 = pd->planes[0] + rowOff; - uint8_t *p1 = pd->planes[1] + rowOff; - uint8_t *p2 = pd->planes[2] + rowOff; - uint8_t *p3 = pd->planes[3] + rowOff; - - if (byteFirst == byteLast) { - /* Single-byte case kept in C: the asm path post-increments - * the pointer between leading and trailing RMW, which would - * read the wrong byte if both edges land on the same byte. - * One-byte spans are rare anyway (~1 of 80 in jlFillCircle r=40) - * so the C overhead is fine here. */ - uint8_t mask = (uint8_t)(leftMask & rightMask); - uint8_t notMask = (uint8_t)~mask; - uint8_t *pp[X68K_BITPLANES]; - uint8_t fb[X68K_BITPLANES]; - uint8_t i; - pp[0] = p0; pp[1] = p1; pp[2] = p2; pp[3] = p3; - fb[0] = fb0; fb[1] = fb1; fb[2] = fb2; fb[3] = fb3; - for (i = 0; i < X68K_BITPLANES; i++) { - *pp[i] = (uint8_t)((*pp[i] & notMask) | (fb[i] & mask)); - } - return; - } - - { - uint16_t numMid = (uint16_t)(byteLast - byteFirst - 1u); - surface68kFillSpan4Planes(p0, p1, p2, p3, numMid, leftMask, rightMask, - fb0, fb1, fb2, fb3); - } + surface68kPlanarCircle(pd->planes, (uint16_t)cx, (uint16_t)cy, r, (uint16_t)(colorIndex & 0x0Fu)); } +// src/m68k/planarFill.s does the whole exact-disk walk: for each y in [0, r] the +// largest x with x*x + y*y <= r*r, then one span at cy+y and one at cy-y, each +// row filled exactly once. Row coverage is identical to draw.c's own +// jlFillCircle, so the golden hashes hold by construction. +// +// The C this replaces called an assembly span filler from a C walk. That was +// already 1.5x the pixel-at-a-time version, but the call and the per-span +// geometry cost more than the pixels did: the masks are now computed once for +// a row's two spans, and the walk's 32-bit arithmetic is 16-bit throughout. +// +// The asm keeps r*r in a word, so it needs r <= 181. draw.c only calls this +// when the bounding box is on-surface and every planar surface on this port is +// SURFACE_HEIGHT rows tall, which caps r at half of that; the check below +// fails the build rather than let a taller surface wrap silently. bool jlpFillCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t colorIndex) { - X68kPlanarT *pd; - uint32_t r2; - uint32_t xx; - uint32_t yy; - int32_t x; - uint16_t y; + enum { kMaxWordRadius = 181 }; + + _Static_assert((SURFACE_HEIGHT / 2) <= kMaxWordRadius, "r*r must fit a word in src/m68k/planarFill.s"); if (s->portData == NULL) { return false; } - pd = (X68kPlanarT *)s->portData; - // Exact-disk span walk (perf-hunt-2 rank 14, the recorded "true - // lever"; algorithm copied from the ST stPlanarCircleFill): for - // each y offset track the largest x with x^2 + y^2 <= r^2 - // incrementally, and fill each row exactly ONCE. The old midpoint - // loop emitted 4 octant spans per iteration whose horizontal spans - // overlapped (~14-16% redundant bytes at r=40) and re-dispatched - // x68kFillSpanInline for each. Row coverage is identical to - // draw.c's jlFillCircle and the ST walker, so the golden hashes - // are unchanged by construction. Spans stay unclipped -- draw.c - // routes every off-surface circle through its own clipped loop - // before jlpFillCircle is called (same contract as before). - r2 = (uint32_t)r * (uint32_t)r; - xx = r2; - yy = 0u; - x = (int32_t)r; - for (y = 0u; y <= r; y++) { - uint32_t xxLimit; - int16_t spanX; - uint16_t spanW; - - xxLimit = r2 - yy; - while (xx > xxLimit) { - xx = xx - ((uint32_t)x + (uint32_t)x - 1u); - x--; - } - spanX = (int16_t)(cx - (int16_t)x); - spanW = (uint16_t)((int16_t)x + (int16_t)x + 1); - x68kFillSpanInline(pd, spanX, (int16_t)(cy + (int16_t)y), spanW, colorIndex); - if (y > 0u) { - x68kFillSpanInline(pd, spanX, (int16_t)(cy - (int16_t)y), spanW, colorIndex); - } - yy = yy + ((uint32_t)y + (uint32_t)y + 1u); - } + surface68kFillCircle(((X68kPlanarT *)s->portData)->planes, (uint16_t)cx, (uint16_t)cy, r, colorIndex); return true; } @@ -1307,7 +1162,11 @@ void *jlpSurfaceAllocPortData(jlSurfaceT *s, bool isStage) { * Amiga these are plain heap: no blitter or DMA reads them, only the * CPU, so there is no Chip RAM constraint to satisfy. */ for (i = 0; i < X68K_BITPLANES; i++) { - pd->planes[i] = (uint8_t *)calloc(1u, (size_t)X68K_PLANE_SIZE); + // One byte of slack past the end. The shifted plane slice the sprite + // save takes reads the byte AFTER the one it needs whenever the span + // is not byte-aligned, which on the last row would otherwise be one + // byte past the buffer. Padding is cheaper than branching per group. + pd->planes[i] = (uint8_t *)calloc(1u, (size_t)X68K_PLANE_SIZE + 1u); jlLogF("x68k: shadow plane %u on heap (CPU only, addr=$%08lX)", (unsigned)i, (unsigned long)pd->planes[i]); if (pd->planes[i] == NULL) { diff --git a/src/x68000/serial.c b/src/x68000/serial.c index d3fb711..be52a1d 100644 --- a/src/x68000/serial.c +++ b/src/x68000/serial.c @@ -98,6 +98,10 @@ static void sccIsrInstall(void); static void sccIsrUninstall(void); static bool gSccIsrInstalled = false; +// Has the app opened the port at all? Without this the polled pump traps into +// IOCS on every call, and jlpPresent calls it once every two blitted rows -- +// about fifty _ISNS232C traps per frame for a game that never touched serial. +static bool gSerialOpened = false; static uint8_t gSccRxVec = 0u; static uint32_t gSccOldRx = 0ul; static uint32_t gSccOldSp = 0ul; @@ -165,7 +169,7 @@ static void sccIsrUninstall(void) { // fallback that keeps the port working, with the caller deaf-tolerant up to the SOFT ring instead // of RSDRV's ~64 bytes. void x68kSerialPump(void) { - if (gSccIsrInstalled) { + if (gSccIsrInstalled || !gSerialOpened) { return; } while (_iocs_isns232c() != 0) { @@ -190,6 +194,7 @@ uint16_t jlpSerialAvailable(void) { void jlpSerialClose(void) { sccIsrUninstall(); // hand channel A RX back to RSDRV before the vectors dangle + gSerialOpened = false; } @@ -212,6 +217,7 @@ bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) { // idiom is a _CRTMOD contract, undocumented for _SET232C -- XEiJ reads the // RSDRV work area instead -- so the write could not be trusted.) (void)config; + gSerialOpened = true; sccIsrInstall(); // own the RX interrupt; falls back to the polled pump if the vectors look wrong return true; } diff --git a/tools/staxiharness/harness.c b/tools/staxiharness/harness.c index 11f5c30..aab0a57 100644 --- a/tools/staxiharness/harness.c +++ b/tools/staxiharness/harness.c @@ -104,7 +104,7 @@ int main(void) { memset(&gGame, 0, sizeof(gGame)); memset(&gSim, 0, sizeof(gSim)); stRenderInit(stage); - stAudioInit(); + (void)stAudioInit(); stSimNewGame(&gSim, 2u, false); // ---- ranking ($4D92) ----