X68k, ST, and Amiga optimizations.

This commit is contained in:
Scott Duensing 2026-09-24 22:01:56 -05:00
parent 477d39f7e9
commit 1a7c7ec05a
37 changed files with 3393 additions and 1413 deletions

View file

@ -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 first word ends on the low level plays its second word inverted. Both
versions are stored and the player picks by the running parity. versions are stored and the player picks by the running parity.
speech.bin (little-endian): 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 format (0 = signed +/-100, 1 = 0x80-biased for the IIgs raw stream)
u8 pad[3] u32 dataBytes u8 polarities (1 or 2) u8 pad[2] u32 dataBytes
entries[pitchCount * count * 2]: u8 code, u8 parity, u16 samples, entries[pitchCount * count * polarities]: u8 code, u8 parity,
u32 offset -- polarity is the innermost index (0 = as decoded, u16 samples, u32 offset -- polarity is the innermost index
1 = inverted). parity = 1 when the utterance ends on the (0 = as decoded, 1 = inverted). parity = 1 when the utterance
opposite level from the one it started on. ends on the opposite level from the one it started on.
data: sample bytes 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 re
import struct import struct
@ -77,6 +80,8 @@ def decode(rle, perOn):
def main(): def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")] args = [a for a in sys.argv[1:] if not a.startswith("--")]
biased = "--biased" in sys.argv[1:] biased = "--biased" in sys.argv[1:]
single = "--single" in sys.argv[1:]
polarities = 1 if single else 2
src, dst = args[0], args[1] src, dst = args[0], args[1]
entries, data = parseSource(src) entries, data = parseSource(src)
records = [] records = []
@ -86,7 +91,7 @@ def main():
for code, off, length in entries: for code, off, length in entries:
samples, level = decode(data[off:off + length], perOn) samples, level = decode(data[off:off + length], perOn)
parity = 1 if level < 0 else 0 parity = 1 if level < 0 else 0
for pol in range(2): for pol in range(polarities):
pcm = bytearray() pcm = bytearray()
for s in samples: for s in samples:
v = -s if pol else s v = -s if pol else s
@ -94,12 +99,14 @@ def main():
records.append((code, parity, len(samples), len(blob))) records.append((code, parity, len(samples), len(blob)))
blob += pcm blob += pcm
with open(dst, "wb") as f: with open(dst, "wb") as f:
f.write(b"STSP" + struct.pack("<BBBBBBBBI", 2, PITCH_BASE, PITCH_COUNT, len(entries), 1 if biased else 0, 0, 0, 0, len(blob))) f.write(b"STSP" + struct.pack("<BBBBBBBBI", 3, PITCH_BASE, PITCH_COUNT, len(entries), 1 if biased else 0, polarities, 0, 0, len(blob)))
for code, parity, n, off in records: for code, parity, n, off in records:
f.write(struct.pack("<BBHI", code, parity, n, off)) f.write(struct.pack("<BBHI", code, parity, n, off))
f.write(blob) f.write(blob)
total = sum(n for _, _, n, _ in records) total = sum(n for _, _, n, _ in records)
print(f"{dst}: {len(entries)} utterances x {PITCH_COUNT} pitches x 2 polarities, {total} samples, {len(blob)} data bytes, {'biased' if biased else 'signed'}") print(f"{dst}: {len(entries)} utterances x {PITCH_COUNT} pitches x {polarities} "
f"polarit{'y' if polarities == 1 else 'ies'}, {total} samples, {len(blob)} data bytes, "
f"{'biased' if biased else 'signed'}")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -40,6 +40,11 @@
#define ST_GAME_OVER_TICKS 60u #define ST_GAME_OVER_TICKS 60u
// Menu joystick repeat delay ($4101 with X = $82). // Menu joystick repeat delay ($4101 with X = $82).
#define ST_MENU_REPEAT_TICKS 8u #define ST_MENU_REPEAT_TICKS 8u
// How long a fatal load message stays up, in display frames, and how many
// must pass before a joystick press can cut it short (a button held from
// before must not skip the message).
#define ST_FATAL_HOLD_FRAMES 600u
#define ST_FATAL_ARM_FRAMES 30u
// The immortal cabbies ($4A89 image, $4E35 screen). // The immortal cabbies ($4A89 image, $4E35 screen).
#define ST_HS_ENTRIES 10u #define ST_HS_ENTRIES 10u
@ -87,6 +92,7 @@ typedef struct {
uint8_t hsCursor; // $4F4D the blinking cursor's current glyph uint8_t hsCursor; // $4F4D the blinking cursor's current glyph
uint8_t hsBlinkTicks; // ticks toward the next cursor flip uint8_t hsBlinkTicks; // ticks toward the next cursor flip
bool paused; // $4FCB: SHIFT is holding the game bool paused; // $4FCB: SHIFT is holding the game
bool fatal; // an asset on the disk would not load
} StGameT; } StGameT;
// The immortal cabbies ($4A89): ten entries of a right-aligned "dddd.dd" // The immortal cabbies ($4A89): ten entries of a right-aligned "dddd.dd"
@ -150,6 +156,14 @@ static const uint8_t *kAboutLines[7] = {
(const uint8_t *)"ALSO TRY RESCUE SQUAD BY JOHN KUTCHER", (const uint8_t *)"ALSO TRY RESCUE SQUAD BY JOHN KUTCHER",
}; };
static const uint8_t kTextReturn[] = "PRESS FIRE TO RETURN"; static const uint8_t kTextReturn[] = "PRESS FIRE TO RETURN";
// Fatal load messages. Two short centred lines in the game's own charset,
// upper case because the C64 font has no lower case in this bank.
static const char kTextFatalMemory[] = "OUT OF MEMORY";
static const char kTextFatalSpeech[] = "SPEECH WILL NOT LOAD";
static const char kTextFatalSprites[] = "SPRITES WILL NOT COMPILE";
static const char kTextFatalLoad[] = "DISK ERROR";
static const char kTextFatalTitle[] = "CANNOT LOAD TITLE SCREEN";
static const char kTextFatalLevel[] = "CANNOT LOAD LEVEL";
static StGameT gGame; static StGameT gGame;
static StSimT gSim; static StSimT gSim;
@ -163,6 +177,8 @@ static StLevelT gLevel;
static void blankScreen(StSimT *sim, uint8_t border, uint8_t bg); static void blankScreen(StSimT *sim, uint8_t border, uint8_t bg);
static void enterTitle(void); static void enterTitle(void);
static void fatalHold(void);
static void fatalStop(const char *line1, const char *line2);
static void gameOverEnter(void); static void gameOverEnter(void);
static void gameOverFinish(void); static void gameOverFinish(void);
static void highScoreDraw(void); static void highScoreDraw(void);
@ -207,9 +223,10 @@ static void blankScreen(StSimT *sim, uint8_t border, uint8_t bg) {
static void enterTitle(void) { static void enterTitle(void) {
gSim.demoMode = 0u; gSim.demoMode = 0u;
if (!stLevelLoad(&gLevel, "levels/title.dat")) { if (!stLevelLoad(&gLevel, "levels/title.dat")) {
ST_LOG("spacetaxi: ! title load FAILED"); fatalStop(kTextFatalLoad, kTextFatalTitle);
return;
} }
stRenderLevelCels(gStage, 0); // the last level's cels go (void)stRenderLevelCels(gStage, 0); // the last level's cels go
stTitleEnter(&gSim, &gLevel); stTitleEnter(&gSim, &gLevel);
stRenderSceneChanged(&gSim); stRenderSceneChanged(&gSim);
stRenderTitleLogo(&gSim); stRenderTitleLogo(&gSim);
@ -442,6 +459,45 @@ static void runNameEntry(uint8_t ticks) {
} }
// Hold a fatal message on screen long enough to be read, then let the
// caller shut down. FIRE or a key cuts the wait short.
static void fatalHold(void) {
uint8_t last = (uint8_t)jlFrameCount();
uint16_t held = 0u;
// Count display frames, not loop turns: presenting an unchanged
// screen costs almost nothing, so a turn count would run out in well
// under a second on the faster ports.
while (held < ST_FATAL_HOLD_FRAMES) {
uint8_t now;
jlInputPoll();
if (held > 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. // $5FBB -- "GAME OVER!" over the play field, sprites hidden.
static void gameOverEnter(void) { static void gameOverEnter(void) {
stSimDrawText(&gSim, 14u, 10u, kTextBlank12, 1u); stSimDrawText(&gSim, 14u, 10u, kTextBlank12, 1u);
@ -505,8 +561,7 @@ static bool loadLevel(uint8_t index) {
ST_LOG("spacetaxi: ! cannot load %s", path); ST_LOG("spacetaxi: ! cannot load %s", path);
return false; return false;
} }
stRenderLevelCels(gStage, &gLevel); return stRenderLevelCels(gStage, &gLevel);
return true;
} }
@ -842,12 +897,25 @@ static void startDemoScreen(void) {
(void)stSimAdvancePlayer(&gSim); (void)stSimAdvancePlayer(&gSim);
gSim.levelState++; gSim.levelState++;
if (!loadLevel(e->levelIndex)) { if (!loadLevel(e->levelIndex)) {
enterTitle(); fatalStop(kTextFatalLoad, kTextFatalLevel);
return; 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, 8u, kTextGetReady, 5u);
stSimDrawText(&gSim, 10u, 10u, kTextOnTheTaxi, 5u); stSimDrawText(&gSim, 10u, 10u, kTextOnTheTaxi, 5u);
gSim.frame.enableMask &= 0x07u; gSim.frame.enableMask &= 0x07u;
stRenderSceneChanged(&gSim);
gGame.waitTicks = ST_GET_READY_TICKS; gGame.waitTicks = ST_GET_READY_TICKS;
gGame.state = ST_STATE_GET_READY; gGame.state = ST_STATE_GET_READY;
} }
@ -881,7 +949,7 @@ static void startNewScreen(void) {
} }
gGame.pendingLevel = next; gGame.pendingLevel = next;
if (!loadLevel(next)) { if (!loadLevel(next)) {
enterTitle(); fatalStop(kTextFatalLoad, kTextFatalLevel);
return; return;
} }
gSim.levelState++; gSim.levelState++;
@ -931,21 +999,31 @@ int main(void) {
memset(&gSim, 0, sizeof(gSim)); memset(&gSim, 0, sizeof(gSim));
stRenderInit(stage); stRenderInit(stage);
stRenderLoading(stage); stRenderLoading(stage);
stAudioInit(); if (!stAudioInit()) {
fatalStop(kTextFatalMemory, kTextFatalSpeech);
}
// Boot: the playback buffer as the C64 leaves it, and the state a // Boot: the playback buffer as the C64 leaves it, and the state a
// fresh machine has when the first title intro starts ($4092 + the // fresh machine has when the first title intro starts ($4092 + the
// title's own sparkle/walk history from the dump). // title's own sparkle/walk history from the dump).
stSimNewGame(&gSim, 1u, false); stSimNewGame(&gSim, 1u, false);
stSimSeedDemoBuffer(&gSim, kDemoBufferInit, ST_DEMO_BUFFER_BYTES); stSimSeedDemoBuffer(&gSim, kDemoBufferInit, ST_DEMO_BUFFER_BYTES);
gSim.waveIdx = 3u; gSim.waveIdx = 3u;
stRenderPrewarm(stage, &gSim); if (!gGame.fatal && !stRenderPrewarm(stage, &gSim)) {
fatalStop(kTextFatalMemory, kTextFatalSprites);
}
highScoreLoad(); // $23E5 highScoreLoad(); // $23E5
if (!gGame.fatal) {
enterTitle(); enterTitle();
}
gGame.lastFrame = (uint8_t)jlFrameCount(); gGame.lastFrame = (uint8_t)jlFrameCount();
for (;;) { for (;;) {
uint8_t ticks; uint8_t ticks;
if (gGame.fatal) {
fatalHold();
break;
}
jlInputPoll(); jlInputPoll();
// $4FCB runStopWatcher, on the game tick, the intro and the menus: // $4FCB runStopWatcher, on the game tick, the intro and the menus:
// SHIFT held freezes everything with the sound gated off and any // SHIFT held freezes everything with the sound gated off and any
@ -1056,5 +1134,5 @@ int main(void) {
stAudioShutdown(); stAudioShutdown();
stRenderShutdown(); stRenderShutdown();
jlShutdown(); jlShutdown();
return 0; return gGame.fatal ? 1 : 0;
} }

View file

@ -25,9 +25,11 @@
#if defined(__W65816__) || defined(JOEYLIB_PLATFORM_X68000) #if defined(__W65816__) || defined(JOEYLIB_PLATFORM_X68000)
#define ST_LOG_RESET() ((void)0) #define ST_LOG_RESET() ((void)0)
#define ST_LOG(...) ((void)0) #define ST_LOG(...) ((void)0)
#define ST_LOG_FLUSH() ((void)0)
#else #else
#define ST_LOG_RESET() jlLogReset() #define ST_LOG_RESET() jlLogReset()
#define ST_LOG(...) jlLogF(__VA_ARGS__) #define ST_LOG(...) jlLogF(__VA_ARGS__)
#define ST_LOG_FLUSH() jlLogFlush()
#endif #endif
// Level loading through the JoeyLib data path (DATA/levels/...). // 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 // animates without a single pixel moving. Call after
// stRenderSceneChanged, which turns it back off. // stRenderSceneChanged, which turns it back off.
void stRenderIntroStars(void); 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). // 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). // 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 stAudioShutdown(void);
void stAudioTick(void); void stAudioTick(void);

View file

@ -29,6 +29,13 @@
#define ST_ATTEN_LOUD 4u #define ST_ATTEN_LOUD 4u
#define ST_ATTEN_SOFT 8u #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 #define ST_ATTEN_OFF 15u
// NTSC SID: Hz = word * 1022727 / 16777216 = word * 0.06096. // 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 // the ride half a second on every "HEY TAXI"; the 1-bit intermediate
// format that followed still needed a per-byte expansion loop. // format that followed still needed a per-byte expansion loop.
#define ST_SPEECH_FILE "speech.bin" #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_HEADER_BYTES 16u
#define ST_SPEECH_ENTRY_BYTES 8u #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_SIGNED 0u
#define ST_SPEECH_FORMAT_BIASED 1u #define ST_SPEECH_FORMAT_BIASED 1u
#define ST_SPEECH_LOAD_CHUNK 16384u #define ST_SPEECH_LOAD_CHUNK 16384u
@ -88,6 +101,9 @@ typedef struct {
static StSfxSlotT gSfx; // SID voice 1 (programs + sweep) static StSfxSlotT gSfx; // SID voice 1 (programs + sweep)
static StSfxSlotT gTone; // SID voice 2 static StSfxSlotT gTone; // SID voice 2
static bool gNoiseOn; 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 // Speech playback state. The fill callback walks a queue of utterances so
// a phrase like "HT" ("Hey" + "Taxi") runs as one continuous stream // 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 gSpeechSample; // next sample index in it
static uint16_t gSpeechSamples; // its length static uint16_t gSpeechSamples; // its length
static uint8_t gSpeechParity; // it ends on the opposite level 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 // 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 // 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. // 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 uint16_t sidHz(uint16_t word);
static void speechLoad(void); static bool speechLoad(void);
static bool speechNext(void); static bool speechNext(void);
static void speechArm(void); static void speechArm(void);
static uint16_t speechDecode(uint8_t *dst, uint16_t count); 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. // is empty, which ends the stream.
// DATA/speech.bin into one jlAlloc block; without it the game is mute // DATA/speech.bin into one jlAlloc block; without it the game is mute
// but otherwise fine. // but otherwise fine.
static void speechLoad(void) { static bool speechLoad(void) {
FILE *fp = jlDataOpen(ST_SPEECH_FILE, "rb"); FILE *fp = jlDataOpen(ST_SPEECH_FILE, "rb");
uint8_t head[ST_SPEECH_HEADER_BYTES]; uint8_t head[ST_SPEECH_HEADER_BYTES];
uint8_t format; uint8_t format;
@ -155,22 +173,30 @@ static void speechLoad(void) {
gSpeechBlob = 0; gSpeechBlob = 0;
if (fp == 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; 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); fclose(fp);
return; return false;
} }
gSpeechPolarities = head[9];
gSpeechPitchBase = head[5]; gSpeechPitchBase = head[5];
gSpeechPitchCount = head[6]; gSpeechPitchCount = head[6];
gSpeechCount = head[7]; gSpeechCount = head[7];
dataBytes = (uint32_t)head[12] | ((uint32_t)head[13] << 8) | ((uint32_t)head[14] << 16) | ((uint32_t)head[15] << 24); 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); gSpeechBlob = (uint8_t *)jlAlloc(total);
if (gSpeechBlob == 0) { 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); fclose(fp);
return; return false;
} }
memcpy(gSpeechBlob, head, sizeof(head)); memcpy(gSpeechBlob, head, sizeof(head));
// The block spans banks on the IIgs; read it in bank-safe pieces. // 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; want = ST_SPEECH_LOAD_CHUNK;
} }
if (fread(gSpeechBlob + done, 1u, (size_t)want, fp) != (size_t)want) { 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); jlFree(gSpeechBlob);
gSpeechBlob = 0; gSpeechBlob = 0;
break; break;
@ -189,6 +216,11 @@ static void speechLoad(void) {
done += want; done += want;
} }
fclose(fp); 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) { while (gSpeechHead != gSpeechTail) {
uint8_t code = gSpeechQueue[gSpeechHead]; 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; uint8_t k;
gSpeechHead = (uint8_t)((gSpeechHead + 1u) % ST_SPEECH_QUEUE); 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) { 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); 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]; gSpeechParity = v[1];
gSpeechSamples = (uint16_t)(v[2] | ((uint16_t)v[3] << 8)); gSpeechSamples = (uint16_t)(v[2] | ((uint16_t)v[3] << 8));
gSpeechSample = 0u; 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; return true;
} }
} }
@ -252,7 +289,17 @@ static uint16_t speechDecode(uint8_t *dst, uint16_t count) {
if (take > left) { if (take > left) {
take = left; take = left;
} }
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); jlMemCopy(dst + n, gSpeechPcm + gSpeechSample, take);
}
gSpeechSample = (uint16_t)(gSpeechSample + take); gSpeechSample = (uint16_t)(gSpeechSample + take);
n = (uint16_t)(n + take); n = (uint16_t)(n + take);
} }
@ -368,14 +415,27 @@ void stAudioTick(void) {
jlAudioVoice(gTone.voice, 0u, ST_ATTEN_OFF); 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(); (void)jlAudioInit();
gSpeechStage = (uint8_t *)jlAlloc(ST_SPEECH_STAGE_BYTES); gSpeechStage = (uint8_t *)jlAlloc(ST_SPEECH_STAGE_BYTES);
speechLoad(); ok = (gSpeechStage != 0) && speechLoad();
gSfx.voice = ST_VOICE_SFX; gSfx.voice = ST_VOICE_SFX;
gNoiseTicks = 0u;
gSfx.ticksLeft = 0u; gSfx.ticksLeft = 0u;
gTone.voice = ST_VOICE_TONE; gTone.voice = ST_VOICE_TONE;
gTone.ticksLeft = 0u; gTone.ticksLeft = 0u;
@ -385,6 +445,8 @@ void stAudioInit(void) {
gSpeechActive = false; gSpeechActive = false;
gSpeechPitch = ST_SPEECH_PITCH_BASE; gSpeechPitch = ST_SPEECH_PITCH_BASE;
gSpeechPol = 0u; gSpeechPol = 0u;
gSpeechInvert = false;
return ok;
} }
@ -401,16 +463,38 @@ void stAudioShutdown(void) {
// $6D6A / $6A3B -- voice 3 jet noise gate. // $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) { 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; return;
} }
gNoiseOn = on;
if (on) { if (on) {
jlAudioNoise(31u, ST_ATTEN_SOFT); gNoiseOn = true;
} else { jlAudioNoise(ST_NOISE_JET_PITCH, ST_ATTEN_SOFT);
jlAudioNoise(0u, ST_ATTEN_OFF); 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; StSfxSlotT *slot = (program9[8] == 1u) ? &gTone : &gSfx;
if ((ctrl & 0x80u) != 0u) { if ((ctrl & 0x80u) != 0u) {
// Noise waveform: a burst on the noise generator. // Noise waveform: a burst on the noise generator, held for the
jlAudioNoise(31u, ST_ATTEN_LOUD); // 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; gNoiseOn = true;
jlAudioNoise(ST_NOISE_SFX_PITCH, ST_ATTEN_LOUD);
return; return;
} }
jlAudioVoice(slot->voice, sidHz(word), ST_ATTEN_LOUD); jlAudioVoice(slot->voice, sidHz(word), ST_ATTEN_LOUD);

View file

@ -44,6 +44,31 @@ const StCelDefT kStCels[] = {
const uint16_t kStCelCount = (uint16_t)(sizeof(kStCels) / sizeof(kStCels[0])); 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) { 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 pal[4];
uint8_t tx; uint8_t tx;

View file

@ -30,6 +30,10 @@ typedef struct {
extern const StCelDefT kStCels[]; extern const StCelDefT kStCels[];
extern const uint16_t kStCelCount; 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` // 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. // into `out`, no duplicates. 0 for a level without hook sprites.

View file

@ -155,6 +155,7 @@ static StSpriteCacheT *cacheFreeSlot(void);
static void levelCelsEvict(void); static void levelCelsEvict(void);
static const uint8_t *levelSpriteBitmap(const StLevelT *level, uint8_t ptr); static const uint8_t *levelSpriteBitmap(const StLevelT *level, uint8_t ptr);
static void loadingBar(jlSurfaceT *stage, uint8_t done, uint8_t total); 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 cellCol(uint16_t cell, uint8_t row);
static uint8_t cellRow(uint16_t cell); static uint8_t cellRow(uint16_t cell);
static void collectGlyphs(StSimT *sim); static void collectGlyphs(StSimT *sim);
@ -166,10 +167,11 @@ static bool loadPrecompiledCels(void);
static bool logoBuildTile(const StSimT *sim); static bool logoBuildTile(const StSimT *sim);
static uint8_t logoFrameIndex(const StSimT *sim); static uint8_t logoFrameIndex(const StSimT *sim);
static void logoPaletteApply(const StSimT *sim, uint8_t frame); static void logoPaletteApply(const StSimT *sim, uint8_t frame);
static void starPaletteApply(void);
static void paintCells(jlSurfaceT *stage, StSimT *sim); 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 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 bool spriteOnScreen(int16_t px, int16_t py);
static void starPaletteApply(void);
static void tileFromChunky(jlTileT *out, const uint8_t *chunky); 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) { if (idx <= 2u && gPrewarming) {
cacheCompile(slot); 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); ST_LOG("spacetaxi: ! cel $%02X colour %u (level %u) built mid-ride -- add it to stLevelCelList", ptr, color, level);
} }
slot->lastUse = gRender.cacheStamp; 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, // 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 // because the two-shift form of the multiply came back as a __mulhi3
// library call on the 65816. // 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 uint8_t cellCol(uint16_t cell, uint8_t row) {
static const uint16_t kRowBase[ST_SCREEN_ROWS] = { static const uint16_t kRowBase[ST_SCREEN_ROWS] = {
0, 40, 80, 120, 160, 200, 240, 280, 320, 360, 400, 440, 480, 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 // here behind the LOADING bar. Either way nothing compiles once the
// level is running. Keyed exactly as cachedSprite keys them, so every // level is running. Keyed exactly as cachedSprite keys them, so every
// lookup during the ride is a hit. // 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]; static StCelDefT list[ST_LEVEL_CELS_MAX];
jlSpriteT *cels[ST_LEVEL_CELS_MAX]; jlSpriteT *cels[ST_LEVEL_CELS_MAX];
char path[24]; char path[24];
uint8_t n; uint8_t n;
uint8_t got; uint8_t got;
uint8_t ready = 0u;
uint8_t k; uint8_t k;
levelCelsEvict(); levelCelsEvict();
if (level == 0) { if (level == 0) {
return; return true;
} }
n = stLevelCelList(level, list, ST_LEVEL_CELS_MAX); n = stLevelCelList(level, list, ST_LEVEL_CELS_MAX);
if (n == 0u) { if (n == 0u) {
return; return true;
} }
memcpy(path, "sprites/level", 13u); memcpy(path, "sprites/level", 13u);
path[13] = (char)('0' + (level->levelIndex + 1u) / 10u); path[13] = (char)('0' + (level->levelIndex + 1u) / 10u);
@ -938,11 +961,17 @@ void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) {
slot->used = true; slot->used = true;
slot->pinned = true; slot->pinned = true;
slot->lastUse = gRender.cacheStamp; slot->lastUse = gRender.cacheStamp;
ready++;
} }
gPrewarming = false; gPrewarming = false;
if (got != n) { if (got != n) {
jlFillRect(stage, 60, 110, 200u, 6u, 0u); 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->used = true;
e->pinned = true; e->pinned = true;
e->lastUse = 0u; 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; 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 // "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 // game's own, indexed by ASCII (the C64 font's letters sit at their
@ -999,12 +1071,13 @@ void stRenderLoading(jlSurfaceT *stage) {
jlStagePresent(); 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 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 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 }; 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 total = (uint8_t)(sizeof(kCabPtrs) + sizeof(kPassPtrs) + sizeof(kFlamePtrs) + 1u);
uint8_t done = 0u; uint8_t done = 0u;
bool ok;
uint8_t k; uint8_t k;
StFrameT saved; StFrameT saved;
@ -1012,7 +1085,7 @@ void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) {
// runtime build below on any port without a matching .spc. // runtime build below on any port without a matching .spc.
if (loadPrecompiledCels()) { if (loadPrecompiledCels()) {
ST_LOG("spacetaxi: cels from sprites/staxicels.spc"); ST_LOG("spacetaxi: cels from sprites/staxicels.spc");
return; return prewarmReport(kStCelCount);
} }
ST_LOG("spacetaxi: ! no usable sprites/staxicels.spc -- JIT prewarm"); ST_LOG("spacetaxi: ! no usable sprites/staxicels.spc -- JIT prewarm");
saved = sim->frame; saved = sim->frame;
@ -1050,7 +1123,45 @@ void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) {
(void)cachedSprite(sim, 2u); (void)cachedSprite(sim, 2u);
sim->frame = saved; sim->frame = saved;
gPrewarming = false; gPrewarming = false;
ok = prewarmReport(total);
jlFillRect(stage, 60, 110, 200u, 6u, 0u); 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;
} }

View file

@ -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_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_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_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 # 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 # tool for THIS target and staged next to the .spr, so the runtime loads
# compiled routines with no startup JIT (jlSpriteBankLoadPrecompiled). # 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_SPR_GEN := $(STAXI_GEN_DIR)/sprites/staxicels.spr
STAXICELS_SPC_GEN := $(STAXICELS_SPR_GEN:.spr=.spc) STAXICELS_SPC_GEN := $(STAXICELS_SPR_GEN:.spr=.spc)
STAXICELS_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXICELS_SPC_GEN)) 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_GEN := $(STAXI_SPR_GEN:.spr=.spc)
STAXI_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPC_GEN)) STAXI_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPC_GEN))
ASSETBAKE_BIN := $(REPO_DIR)/tools/assetbake/assetbake.py ASSETBAKE_BIN := $(REPO_DIR)/tools/assetbake/assetbake.py

View file

@ -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_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_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_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 # 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 # tool for THIS target and staged next to the .spr, so the runtime loads
# compiled routines with no startup JIT (jlSpriteBankLoadPrecompiled). ST is # 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_SPR_GEN := $(STAXI_GEN_DIR)/sprites/staxicels.spr
STAXICELS_SPC_GEN := $(STAXICELS_SPR_GEN:.spr=.spc) STAXICELS_SPC_GEN := $(STAXICELS_SPR_GEN:.spr=.spc)
STAXICELS_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXICELS_SPC_GEN)) 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_GEN := $(STAXI_SPR_GEN:.spr=.spc)
STAXI_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPC_GEN)) STAXI_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPC_GEN))
ASSETBAKE_BIN := $(REPO_DIR)/tools/assetbake/assetbake.py 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. # 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 $(STAXI_SPEECH_GEN): $(EXAMPLES)/spacetaxi/stSpeechData.c $(EXAMPLES)/spacetaxi/assets/genSpeechPcm.py
@mkdir -p $(dir $@) @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) $(STAXI_SPEECH_RUN): $(STAXI_SPEECH_GEN)
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
cp $< $@ cp $< $@

View file

@ -122,7 +122,7 @@ ASSETBAKE_TARGET := dos
STAXI_LEVEL_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_LEVEL_GEN)) 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_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_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 # 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 # tool for THIS target and staged next to the .spr, so the runtime loads
# compiled routines with no startup JIT (jlSpriteBankLoadPrecompiled). # 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_SPR_GEN := $(STAXI_GEN_DIR)/sprites/staxicels.spr
STAXICELS_SPC_GEN := $(STAXICELS_SPR_GEN:.spr=.spc) STAXICELS_SPC_GEN := $(STAXICELS_SPR_GEN:.spr=.spc)
STAXICELS_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXICELS_SPC_GEN)) 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_GEN := $(STAXI_SPR_GEN:.spr=.spc)
STAXI_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPC_GEN)) STAXI_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXI_SPC_GEN))

View file

@ -79,7 +79,7 @@ NTP_BIN := $(BUILD)/audio/ntpplayer.bin
NTP_ASM := $(BUILD)/audio/ntpdata.s NTP_ASM := $(BUILD)/audio/ntpdata.s
IIGS_MERLIN := $(REPO_DIR)/toolchains/iigs/merlin32/bin/merlin32 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. # Default: compile-check the library + run the end-to-end smoke test.
all iigs: iigs-lib iigs-clang-smoke 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) 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 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 # 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 # 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 # ProDOS hard-disk image (joey-all.2mg): boot it in MAME with

View file

@ -64,6 +64,8 @@ LIB_OBJS := \
$(patsubst $(SRC_DIR)/generic/%.c,$(BUILD)/obj/generic/%.o,$(GENERIC_C_SRCS)) \ $(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/%.c,$(BUILD)/obj/port/%.o,$(PORT_C_SRCS)) \
$(patsubst $(SRC_DIR)/x68000/%.s,$(BUILD)/obj/port/%.o,$(PORT_S_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/68k/spriteEmitPlanar68k.o \
$(BUILD)/obj/codegen/spriteCompile.o \ $(BUILD)/obj/codegen/spriteCompile.o \
$(BUILD)/obj/codegen/spriteStage.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 # 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 # hook sprites have no bank; the game JIT-compiles at level entry when a
# bank is missing. # 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_SPR_GEN := $(foreach n,$(STAXI_CEL_LEVELS),$(STAXI_GEN_DIR)/sprites/level$(n).spr)
STAXILEVEL_SPC_GEN := $(STAXILEVEL_SPR_GEN:.spr=.spc) STAXILEVEL_SPC_GEN := $(STAXILEVEL_SPR_GEN:.spr=.spc)
STAXILEVEL_SPC_RUN := $(patsubst $(STAXI_GEN_DIR)/%,$(STAXI_RUN_DIR)/%,$(STAXILEVEL_SPC_GEN)) 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_ASSET_DSTS += $(STAXI_SPEECH_RUN)
$(STAXI_SPEECH_GEN): $(EXAMPLES)/spacetaxi/stSpeechData.c $(EXAMPLES)/spacetaxi/assets/genSpeechPcm.py $(STAXI_SPEECH_GEN): $(EXAMPLES)/spacetaxi/stSpeechData.c $(EXAMPLES)/spacetaxi/assets/genSpeechPcm.py
@mkdir -p $(dir $@) @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) $(STAXI_SPEECH_RUN): $(STAXI_SPEECH_GEN)
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
cp $< $@ cp $< $@

172
scripts/bench-x68000.sh Executable file
View file

@ -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=<dir with x68mame/> 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" <<LUA
local mem = manager.machine.devices[":maincpu"].spaces["program"]
local frame = 0
local greenRun = 0
local exitAt = 0
local done = false
local FRAMES = $FRAMES
local GREEN_STABLE = 600 -- ~11 s of emulated time held steady
local EXIT_SETTLE = 900 -- keypress -> 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" </dev/null 2>&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"

View file

@ -41,14 +41,19 @@ xvfb_pid=$!
sleep 0.3 sleep 0.3
trap 'kill $xvfb_pid 2>/dev/null || true; kill $dosbox_pid 2>/dev/null || true' EXIT 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="" \ DISPLAY=$display SDL_VIDEO_X11_VISUALID="" \
dosbox \ dosbox "${dosboxArgs[@]}" >/tmp/captureDos-dosbox.log 2>&1 &
-conf "$conf" \
-set "mouse_capture=seamless" \
${CYCLES:+-set "cpu cycles=${CYCLES}"} \
-c "C:" \
-c "$file" \
"$mount_dir" >/tmp/captureDos-dosbox.log 2>&1 &
dosbox_pid=$! dosbox_pid=$!
# Wait for DOSBox to boot the program and settle. # Wait for DOSBox to boot the program and settle.

208
scripts/make-iigs-game-disk.sh Executable file
View file

@ -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:
# /<VOL>/System/... a GS/OS boot system stripped to what JoeyLib
# actually uses (see the drop list below)
# /<VOL>/System/Start tools/iigsStart, which sets prefix 0 and
# QuitGS's to the program below
# /<VOL>/<PROG>/ 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 <out> <PROG> [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 <image>.
# Requires: toolchains/env.sh sourced.
set -euo pipefail
out=${1:?usage: make-iigs-game-disk.sh <out> <PROG> [volume] [data.2mg]}
prog=${2:?usage: make-iigs-game-disk.sh <out> <PROG> [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 <image>" >&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"

302
scripts/profile-x68000.sh Executable file
View file

@ -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/<NAME>.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" <<LUA
local cpu = manager.machine.devices[":maincpu"]
local mem = cpu.spaces["program"]
local out = io.open("prof.txt", "w")
-- MAME's 68000 core does NOT expose a register called "PC": it has CURPC
-- (where the CPU is now) and rPC. Asking for the wrong one throws inside the
-- frame callback, once per frame, forever -- the run just gets slower and
-- writes no samples. Resolve it once, loudly.
local pcReg = nil
for _, name in ipairs({ "CURPC", "rPC", "PC" }) do
if cpu.state[name] ~= nil then
pcReg = name
break
end
end
if pcReg == nil then
out:write("no program-counter register on this CPU\n")
out:close()
manager.machine:exit()
end
local base = nil
local cands = {}
local textSize = $textSize
local mouseXOff = $mouseXOff
local frames = 0
local fpsStart = 0
local relocSite = $relocSite
local relocValue = $relocValue
local scanAt = 0x1000
local buckets = {}
local total = 0
local skipUntil = $skip
local stopAt = $seconds
-- "#NEWLIB" sits two bytes into every xdev68k .text. 0x234E4557 is the long at
-- offset 2, and 0x4C494200 follows it.
-- A slice per frame so MAME keeps running, and it WRAPS: Human68k does not
-- load the program until the boot finishes, which is a couple of minutes in,
-- so a single pass over RAM finds nothing and must start again.
local scanDone = false
local function scanSlice()
local limit = scanAt + 0x40000
while scanAt < limit do
if scanAt >= 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 </dev/null >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

107
scripts/run-amiga-headless.sh Executable file
View file

@ -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

83
scripts/run-atarist-headless.sh Executable file
View file

@ -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

117
scripts/verify-iigs-game-disk.sh Executable file
View file

@ -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 <image> [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 <image> [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" <<LUA
local cpu = manager.machine.devices[":maincpu"]
local mem = cpu.spaces["program"]
local frame = 0
local quitAt = nil
emu.register_frame_done(function()
frame = frame + 1
if quitAt == nil then
local mb = mem:read_u8(0xE19DC8)
if mb == 0xAA or mb == 0xEE then
quitAt = frame
io.write(string.format("GAMEDISK-BOOT name=$name mailbox=%02X frame=%d seconds=%.1f\\n", mb, frame, frame / 60.0))
io.flush()
end
end
if frame == $readFrame then
local seen = {}
local distinct = 0
local nonzero = 0
for a = 0xE12000, 0xE12000 + 31999 do
local b = mem:read_u8(a)
if b ~= 0 then nonzero = nonzero + 1 end
local hi = (b >> 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" </dev/null 2>&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)"

219
scripts/verify-x68000-audio.sh Executable file
View file

@ -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" <<LUA
local cpu = manager.machine.devices[":maincpu"]
local mem = cpu.spaces["program"]
local out = io.open("audio.txt", "w")
local sel = -1
local pcmWrites = 0
local burstCount = 0
local burstStart = 0
local burstLast = -1
local bestRate = 0
local stop = $seconds
-- A local tap object is garbage collected and stops firing. Keep globals.
opmTap = mem:install_write_tap(0xE90000, 0xE90003, "opm", function(offset, data, mask)
local v = data & 0xFF
if (mask & 0xFF) == 0 then v = (data >> 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 </dev/null >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

View file

@ -16,6 +16,10 @@
#define AMIGA_BITPLANES 4 #define AMIGA_BITPLANES 4
#define AMIGA_BYTES_PER_ROW (SURFACE_WIDTH / 8) #define AMIGA_BYTES_PER_ROW (SURFACE_WIDTH / 8)
#define AMIGA_PLANE_SIZE (AMIGA_BYTES_PER_ROW * SURFACE_HEIGHT) #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 // jlSurfaceT.portData points to this on Amiga: the 4 plane base
// pointers + stride. Every surface (including the stage, which gets // pointers + stride. Every surface (including the stage, which gets

View file

@ -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. // ~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 // Every entry is inside Paula's legal 124..65535 range by
// construction. Derivation: round(124 * (1773/124)^(i/31)). // 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] = { static const UWORD kNoisePeriod[NOISE_PITCH_MAX + 1] = {
124, 135, 147, 160, 175, 190, 208, 226, 136, 140, 145, 149, 154, 159, 165, 171,
246, 268, 292, 319, 347, 378, 412, 449, 177, 184, 192, 200, 209, 218, 229, 240,
489, 533, 581, 633, 690, 752, 819, 892, 253, 268, 284, 302, 322, 346, 373, 405,
972, 1060, 1154, 1258, 1371, 1493, 1627, 1773 443, 489, 546, 617, 709, 835, 1013, 1290
}; };
// ----- Internal helpers ----- // ----- Internal helpers -----

View file

@ -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> <xreg> ----
| 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 <signOp> <yreg> ----
.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

View file

@ -633,6 +633,15 @@ bool jlpInit(const jlConfigT *config) {
(void)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, // Allocate our own BitMap with explicitly-non-interleaved planes,
// then hand it to OpenScreen via SA_BitMap. Why not let Intuition // then hand it to OpenScreen via SA_BitMap. Why not let Intuition
// build one for us: // build one for us:
@ -1146,15 +1155,18 @@ void jlpShutdown(void) {
} }
// Shared 68k fast paths from src/shared68k/surface68k.s. Only the // Hand-written 68k fast paths, all shared with the X68000: both ports keep
// planar span fill and the planar circle outline apply on Amiga (pure // four separate plane buffers of 40 bytes per row, so a span of pixels is the
// planar after Phase 9); the chunky-layout primitives that file also // same bytes in the same places. src/m68k/planarFill.s does the area fills,
// exports are unused here. // src/m68k/planarPlot.s the pixel walkers.
extern void surface68kFillSpan4Planes(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, extern void surface68kFillCircle(uint8_t *const *planes, uint16_t cx, uint16_t cy, uint16_t r, uint16_t color);
uint16_t numMid, uint8_t leftMask, uint8_t rightMask, // src/m68k/planarPlot.s, shared with the X68000: Bresenham walkers with 16-way
uint8_t fb0, uint8_t fb1, uint8_t fb2, uint8_t fb3); // colour dispatch, so every plane write is one memory read-modify-write with
extern void surface68kAmigaCircleOutline(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, // the set-or-clear choice already made. Both are bit-identical to draw.c's own
uint16_t cx, uint16_t cy, uint16_t r, uint8_t color); // 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<Op> below does the planar // Amiga is pure planar (s->pixels is NULL). Each jlp<Op> 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 // the four shadow planes. Caller (the per-primitive walker below) has
// already validated (x, y) is on-surface. byteOff is computed once // already validated (x, y) is on-surface. byteOff is computed once
// and reused across all four planes since stride is the same in each. // 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) { 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) { void jlpDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t colorIndex) {
AmigaPlanarT *pd; AmigaPlanarT *pd;
if (s->portData == NULL) { if (s->portData == NULL) {
return; return;
} }
pd = (AmigaPlanarT *)s->portData; 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; return;
} }
pd = (AmigaPlanarT *)s->portData; pd = (AmigaPlanarT *)s->portData;
surface68kAmigaCircleOutline(pd->planes[0], pd->planes[1], surface68kPlanarCircle(pd->planes, (uint16_t)cx, (uint16_t)cy, r, (uint16_t)(colorIndex & 0x0Fu));
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);
}
} }
// 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) { bool jlpFillCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t colorIndex) {
AmigaPlanarT *pd; enum { kMaxWordRadius = 181 };
uint32_t r2;
uint32_t xx; _Static_assert((SURFACE_HEIGHT / 2) <= kMaxWordRadius, "r*r must fit a word in src/m68k/planarFill.s");
uint32_t yy;
int32_t x;
uint16_t y;
if (s->portData == NULL) { if (s->portData == NULL) {
return false; return false;
} }
pd = (AmigaPlanarT *)s->portData; surface68kFillCircle(((AmigaPlanarT *)s->portData)->planes, (uint16_t)cx, (uint16_t)cy, r, colorIndex);
// 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);
}
return true; return true;
} }
@ -2280,7 +2143,12 @@ void *jlpSurfaceAllocPortData(jlSurfaceT *s, bool isStage) {
* must be MEMF_CHIP unconditionally now -- a fast-RAM fallback * must be MEMF_CHIP unconditionally now -- a fast-RAM fallback
* would put the blitter helpers out of reach and corrupt output. */ * would put the blitter helpers out of reach and corrupt output. */
for (i = 0; i < AMIGA_BITPLANES; i++) { 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)); (ULONG)(MEMF_CHIP | MEMF_CLEAR));
jlLogF("amiga: shadow plane %u in CHIP (blitter, addr=$%08lX)", jlLogF("amiga: shadow plane %u in CHIP (blitter, addr=$%08lX)",
(unsigned)i, (unsigned long)pd->planes[i]); (unsigned)i, (unsigned long)pd->planes[i]);
@ -2288,7 +2156,7 @@ void *jlpSurfaceAllocPortData(jlSurfaceT *s, bool isStage) {
/* Roll back any planes already allocated. */ /* Roll back any planes already allocated. */
while (i > 0u) { while (i > 0u) {
i--; i--;
FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_SIZE); FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_ALLOC);
} }
FreeMem(pd, (ULONG)sizeof(AmigaPlanarT)); FreeMem(pd, (ULONG)sizeof(AmigaPlanarT));
return NULL; return NULL;
@ -2312,7 +2180,7 @@ void jlpSurfaceFreePortData(jlSurfaceT *s, bool isStage, void *portData) {
if (pd->ownsPlanes) { if (pd->ownsPlanes) {
for (i = 0; i < AMIGA_BITPLANES; i++) { for (i = 0; i < AMIGA_BITPLANES; i++) {
if (pd->planes[i] != NULL) { if (pd->planes[i] != NULL) {
FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_SIZE); FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_ALLOC);
} }
} }
} }

View file

@ -188,6 +188,8 @@ static inline __attribute__((always_inline)) uint16_t stFloodStopBits(const uint
static uint16_t quantizeColorToSt(uint16_t orgb); static uint16_t quantizeColorToSt(uint16_t orgb);
static void flattenScbPalettes(const jlSurfaceT *src); static void flattenScbPalettes(const jlSurfaceT *src);
static void setShifterVideoBase(const uint8_t *page); static void setShifterVideoBase(const uint8_t *page);
extern void stLeaveSuper(void *savedSsp);
static void writeDiagnostics(void); static void writeDiagnostics(void);
static long writePrevPaletteRegs(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 // Drop back to user mode so control returns to GEMDOS the way it
// launched us (mirrors the Super(0L) in jlpInit). // launched us (mirrors the Super(0L) in jlpInit).
if (gSuperEntered) { 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; gSuperEntered = false;
} }
} }

43
src/atarist/superExit.s Normal file
View file

@ -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

View file

@ -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_RHYTHM 0x20u // reg 0xBD bit 5: rhythm mode enable
#define ADLIB_BD_KEY_SD 0x08u // reg 0xBD bit 3: snare key #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_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 gAdlibInited = false;
static bool gAdlibNoiseInited = 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); adlibWrite((uint8_t)(0x40u + ADLIB_SLOT_SD), tl);
// pitch 0..31 -> hyperbolic Hz, the YM noise-period semantic: // pitch 0..31 spread evenly from ADLIB_NOISE_MAX_HZ down to
// pitch 0 = 6200 Hz bright hiss, pitch 31 = ~194 Hz rumble. // ADLIB_NOISE_MIN_HZ. (Core clamps pitch to <= 31 before the HAL
// (Core clamps pitch to <= 31 before the HAL sees it.) // sees it.)
freqHz = (uint16_t)(ADLIB_NOISE_MAX_HZ / ((uint16_t)pitch + 1u)); //
// 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); block = adlibFreqToFnum(freqHz, &fnum);
// Bit 5 (melodic key-on) must stay clear on 0xB7: in rhythm mode // Bit 5 (melodic key-on) must stay clear on 0xB7: in rhythm mode

View file

@ -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_SUSTAIN_MUL 41u // len = rate * 41 / 512 -> ~80 ms sustain
#define NOISE_LFSR_SEED 0xACE1u #define NOISE_LFSR_SEED 0xACE1u
#define NOISE_LFSR_TAPS 0xB400u // 16-bit maximal Galois LFSR, period 65535 #define NOISE_LFSR_TAPS 0xB400u // 16-bit maximal Galois LFSR, period 65535
// How long one noise burst sounds, in 60 Hz GetTick units. // How long a noise burst is left alone before a repeat call retriggers
// jlpAudioNoise plays NOISE_SUSTAIN_MUL/512 of a second of sample // it, in 60 Hz GetTick units. jlpAudioNoise plays NOISE_SUSTAIN_MUL/512
// (41/512 s = 80 ms = 4.8 ticks). 4 retriggers a shade early, which // of a second of sample (41/512 s = 80 ms = 4.8 ticks), so anything
// keeps the sound gapless. // below that retriggers before the burst ends and the sound is gapless.
#define NOISE_HOLD_TICKS 4u //
// 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) ----- // ----- Streaming SFX (jlpAudioPlaySfxStream) -----
// //

503
src/m68k/planarFill.s Normal file
View file

@ -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

428
src/m68k/planarPlot.s Normal file
View file

@ -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 <op> <reg>, 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 <op> <reg>, 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

View file

@ -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

View file

@ -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) { void jlpAudioNoise(uint8_t pitch, uint8_t atten) {
uint8_t slot; 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 // 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. // 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((uint8_t)(OPM_REG_TL + slot), (uint8_t)((atten & 15u) * 4u));
opmWrite(OPM_REG_KEYON, (uint8_t)(OPM_NOISE_CHANNEL | OPM_SLOTS_ALL)); opmWrite(OPM_REG_KEYON, (uint8_t)(OPM_NOISE_CHANNEL | OPM_SLOTS_ALL));
} }

View file

@ -19,9 +19,17 @@
// circular, giving gapless playback; jlpAudioFrameTick refills the block the // circular, giving gapless playback; jlpAudioFrameTick refills the block the
// chip is not currently walking. // chip is not currently walking.
// //
// NOT YET VERIFIED AUDIBLY. This compiles and the control flow is exercised, // VERIFIED AUDIBLY 2026-09-23 by capturing -wavwrite out of MAME while Space
// but no waveform has been captured out of the emulator, so treat the encoder // Taxi's attract demo spoke; the captured burst cross-correlates with the
// output and the block hand-off as unproven. // 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 <string.h> #include <string.h>
@ -43,8 +51,34 @@
// makes it affordable: the SFX+encode path benched at ~17 cycles/sample, so // 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. // doubling the rate is ~13% of a 10 MHz 68000 rather than the ~6.6% at 3906.
#define MSM6258_RATE 7812 #define MSM6258_RATE 7812
// _iocs_adpcmmod / _iocs_adpcmlot mode byte: rate selector low, pan high. // The MSM6258's sample rate and panning do not live in the chip at all --
// 0x05 = 7.8 kHz, both channels. // 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 #define ADPCM_MODE 0x05
// Catch the 3906 Hz mistake at COMPILE time rather than as silence at runtime. // 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. // and the only symptom is that MOD playback never starts.
typedef char x68kAdpcmRateAboveXmpFloor[(MSM6258_RATE >= XMP_MIN_SRATE) ? 1 : -1]; typedef char x68kAdpcmRateAboveXmpFloor[(MSM6258_RATE >= XMP_MIN_SRATE) ? 1 : -1];
// Two blocks of PCM per chain entry. 1024 PCM samples = 512 ADPCM bytes = // Chain geometry, set by how often the game actually gets here rather than
// ~131 ms at 7812 Hz, so a dropped frame cannot starve the chip. // by a round number. MEASURED on the X68000 across 250 s of Space Taxi: the
#define PCM_BLOCK 1024 // 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 ADPCM_BLOCK (PCM_BLOCK / 2)
#define BLOCK_COUNT 2 #define BLOCK_COUNT 8
static xmp_context gXmpCtx = NULL; static xmp_context gXmpCtx = NULL;
static bool gXmpLoaded = false; static bool gXmpLoaded = false;
@ -71,7 +117,12 @@ static AudioSfxSlotT gSfxSlots[JOEY_AUDIO_SFX_SLOTS];
static uint8_t gMixBuf[PCM_BLOCK]; static uint8_t gMixBuf[PCM_BLOCK];
static uint8_t gAdpcm[BLOCK_COUNT][ADPCM_BLOCK]; static uint8_t gAdpcm[BLOCK_COUNT][ADPCM_BLOCK];
static struct iocs_chain2 gChain[BLOCK_COUNT]; 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 // 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 // a probe can read them without a debugger -- silent no-ops in the refill path
// are exactly the failure this port hit. // 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. // Render one block: module (if playing) then the SFX overlay on top.
static void renderBlock(uint8_t *dst) { static void renderBlock(uint8_t *dst) {
if (gXmpStarted) { 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) { static void startChain(void) {
uint8_t b; uint8_t b;
@ -186,8 +278,13 @@ static void startChain(void) {
gChain[b].len = ADPCM_BLOCK; gChain[b].len = ADPCM_BLOCK;
gChain[b].next = &gChain[(b + 1u) % BLOCK_COUNT]; // circular 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); _iocs_adpcmlot(&gChain[0], ADPCM_MODE);
adpcmSetRateAndPan(); // AFTER the IOCS call, which sets them wrong
gPlaying = true; gPlaying = true;
gX68kPcmPlaying = 1u; gX68kPcmPlaying = 1u;
gX68kPcmChainStarts++; 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, // Top the chain back up behind the chip, however many blocks that takes.
// so the chip has roughly half a second of audio queued -- ample margin over a // Refilling a fixed number of blocks per call was the bug: tie production to
// 55 Hz frame tick even if several frames are missed. // 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) { void jlpAudioPcmFrameTick(void) {
uint8_t playing;
if (!gPcmReady || !gPlaying) { if (!gPcmReady || !gPlaying) {
return; return;
} }
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); renderBlock(gMixBuf);
adpcmEncodeBlock(gMixBuf, gAdpcm[gFillBlock]); adpcmEncodeBlock(gMixBuf, gAdpcm[gNextFill]);
gFillBlock = (uint8_t)((gFillBlock + 1u) % BLOCK_COUNT); gNextFill = (uint8_t)((gNextFill + 1u) % BLOCK_COUNT);
gX68kPcmRefills++; 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;
}
} }

View file

@ -123,6 +123,12 @@
#define X68K_MFP_CLOCK_HZ 4000000ul #define X68K_MFP_CLOCK_HZ 4000000ul
#define X68K_TCDCR_C_SHIFT 4u #define X68K_TCDCR_C_SHIFT 4u
#define X68K_TCDCR_C_MASK 0x07u #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 --------------------------------------------------------- // ----- Module state ---------------------------------------------------------
@ -154,6 +160,8 @@ static uint32_t tickHzFromMfp(void);
uint32_t jlpMillisElapsed(void); uint32_t jlpMillisElapsed(void);
uint16_t jlpFrameHz(void); uint16_t jlpFrameHz(void);
static void buildSpreadTables(void); static void buildSpreadTables(void);
static void spreadSetPalBase(uint8_t palBase);
static void calibrateTickHz(void);
static void installTickIsr(void); static void installTickIsr(void);
static void uploadGfxPalette(const jlSurfaceT *src); static void uploadGfxPalette(const jlSurfaceT *src);
@ -289,6 +297,64 @@ static void installTickIsr(void) {
x68kTickChainAddr = *vec; x68kTickChainAddr = *vec;
*vec = (uint32_t)x68kTickIsr; *vec = (uint32_t)x68kTickIsr;
gTickHooked = true; 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 // together, palBase ORs in replicated, and the result is two long stores per
// four pixels -- no shifting in the inner loop at all. // four pixels -- no shifting in the inner loop at all.
#define X68K_SPREAD_NIBBLES 16u #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]; // One table, laid out so the expansion loop can reach it with the 68000's
static uint32_t gSpreadLo[X68K_BITPLANES][X68K_SPREAD_NIBBLES]; // (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; static bool gSpreadBuilt = false;
@ -348,16 +426,39 @@ static void buildSpreadTables(void) {
for (plane = 0u; plane < X68K_BITPLANES; plane++) { for (plane = 0u; plane < X68K_BITPLANES; plane++) {
bit = (uint32_t)1ul << plane; bit = (uint32_t)1ul << plane;
for (nib = 0u; nib < X68K_SPREAD_NIBBLES; nib++) { for (nib = 0u; nib < X68K_SPREAD_NIBBLES; nib++) {
gSpreadHi[plane][nib] = (((nib >> 3) & 1u) ? (bit << 16) : 0ul) gSpreadPair[plane][nib][0] = (((nib >> 3) & 1u) ? (bit << 16) : 0ul)
| (((nib >> 2) & 1u) ? bit : 0ul); | (((nib >> 2) & 1u) ? bit : 0ul);
gSpreadLo[plane][nib] = (((nib >> 1) & 1u) ? (bit << 16) : 0ul) gSpreadPair[plane][nib][1] = (((nib >> 1) & 1u) ? (bit << 16) : 0ul)
| (((nib >> 0) & 1u) ? bit : 0ul); | (((nib >> 0) & 1u) ? bit : 0ul);
} }
} }
gSpreadPalBase = 0u;
gSpreadBuilt = true; 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 -------------------------------------------------------------- // ----- Present --------------------------------------------------------------
// Copy the surface's four planes into the TEXT PLANE at $E00000. // 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 y;
uint16_t firstByte; uint16_t firstByte;
uint16_t rowBytes; uint16_t rowBytes;
uint32_t pal2; const uint8_t *sp;
uint16_t b; uint16_t b;
uint16_t hi0, hi1, hi2, hi3;
uint16_t lo0, lo1, lo2, lo3;
uint8_t palBase; uint8_t palBase;
uint16_t blitRows = 0; uint16_t blitRows = 0;
@ -406,6 +505,7 @@ void jlpPresent(const jlSurfaceT *src) {
if (pd == NULL) { if (pd == NULL) {
return; return;
} }
sp = (const uint8_t *)&gSpreadPair[0][0][0];
x68kSerialPump(); // the palette upload + spread-table build above are already line time x68kSerialPump(); // the palette upload + spread-table build above are already line time
for (y = 0; y < SURFACE_HEIGHT; y++) { for (y = 0; y < SURFACE_HEIGHT; y++) {
if (!STAGE_DIRTY_ROW_TOUCHED(y) || STAGE_DIRTY_ROW_CLEAN(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); rowBytes = (uint16_t)(X68K_BYTES_PER_ROW - firstByte);
} }
// This row's band selects the high nibble of every pixel byte -- the // 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); palBase = (uint8_t)(src->scb[y] << 4);
spreadSetPalBase(palBase);
p0 = pd->planes[0] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; p0 = pd->planes[0] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte;
p1 = pd->planes[1] + ((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; 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) + (((uint32_t)(y + X68K_ORIGIN_Y) * X68K_GVRAM_WORDS_ROW)
+ X68K_ORIGIN_X + ((uint32_t)firstByte * 8u)) * 2u; + X68K_ORIGIN_X + ((uint32_t)firstByte * 8u)) * 2u;
wordDst = (volatile uint32_t *)rowBase; wordDst = (volatile uint32_t *)rowBase;
// Two long stores per four pixels; each long is two 16-bit GVRAM words. // Four long stores per eight pixels; each long is two GVRAM words.
pal2 = (uint32_t)(((uint32_t)palBase << 16) | (uint32_t)palBase); //
// 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++) { for (b = 0u; b < rowBytes; b++) {
hi0 = (uint16_t)(p0[b] >> 4); uint8_t v0 = *p0++;
hi1 = (uint16_t)(p1[b] >> 4); uint8_t v1 = *p1++;
hi2 = (uint16_t)(p2[b] >> 4); uint8_t v2 = *p2++;
hi3 = (uint16_t)(p3[b] >> 4); uint8_t v3 = *p3++;
lo0 = (uint16_t)(p0[b] & 0x0Fu); uint16_t o0 = (uint16_t)((v0 >> 1) & 0x78u);
lo1 = (uint16_t)(p1[b] & 0x0Fu); uint16_t o1 = (uint16_t)((v1 >> 1) & 0x78u);
lo2 = (uint16_t)(p2[b] & 0x0Fu); uint16_t o2 = (uint16_t)((v2 >> 1) & 0x78u);
lo3 = (uint16_t)(p3[b] & 0x0Fu); uint16_t o3 = (uint16_t)((v3 >> 1) & 0x78u);
*wordDst++ = gSpreadHi[0][hi0] | gSpreadHi[1][hi1]
| gSpreadHi[2][hi2] | gSpreadHi[3][hi3] | pal2; *wordDst++ = SPREAD_AT(sp, o0) | SPREAD_AT(sp, o1 | 128)
*wordDst++ = gSpreadLo[0][hi0] | gSpreadLo[1][hi1] | SPREAD_AT(sp, o2 | 256) | SPREAD_AT(sp, o3 | 384);
| gSpreadLo[2][hi2] | gSpreadLo[3][hi3] | pal2; *wordDst++ = SPREAD_AT(sp, o0 + 4) | SPREAD_AT(sp, (o1 | 128) + 4)
*wordDst++ = gSpreadHi[0][lo0] | gSpreadHi[1][lo1] | SPREAD_AT(sp, (o2 | 256) + 4) | SPREAD_AT(sp, (o3 | 384) + 4);
| gSpreadHi[2][lo2] | gSpreadHi[3][lo3] | pal2; o0 = (uint16_t)((v0 << 3) & 0x78u);
*wordDst++ = gSpreadLo[0][lo0] | gSpreadLo[1][lo1] o1 = (uint16_t)((v1 << 3) & 0x78u);
| gSpreadLo[2][lo2] | gSpreadLo[3][lo3] | pal2; 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);
} }
} }
} }

View file

@ -19,9 +19,6 @@
#include "spriteInternal.h" #include "spriteInternal.h"
#include "x68kPlanar.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 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 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 // 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 #define X68K_COPYRECT_MEMCPY_MIN_WORDS 0u
// ----- Hand-written assembly -----
// ----- C stand-ins for the shared 68k asm -----
// //
// src/m68k/surface68k.s and src/amiga/circle.s export these, but their symbols // The area fills live in src/m68k/planarFill.s, shared with the Amiga: both
// carry the m68k-amigaos/mint leading underscore (_surface68kFillSpan4Planes) // ports keep four separate plane buffers of 40 bytes per row, so a span of
// and elf2x68k is ELF, which has no such prefix. Rather than fork the asm for // pixels is the same bytes in the same places. (It carries both symbol
// an ABI difference, the port uses C for now; adopting the asm later is a // spellings because gcc-amigaos prepends an underscore to C names and
// symbol-aliasing change, not a rewrite. These are the obvious optimisation // elf2x68k does not.) The circle OUTLINE is still per-port asm, one file
// targets once correctness is banked. // 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 extern void surface68kFillCircle(uint8_t *const *planes, uint16_t cx, uint16_t cy, uint16_t r, uint16_t color);
// of whole middle bytes, then a partial right byte. fbN is the fill byte for extern void surface68kFillRect(uint8_t *const *planes, uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color);
// 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));
}
}
}
// Bresenham midpoint circle outline, plotted straight into the planes. Caller // circle.s
// has verified the whole bbox is on-surface, so there is no per-pixel clip. // src/m68k/planarPlot.s, shared with the Amiga: Bresenham walkers with 16-way
static void x68kPlotPlanes(uint8_t *pl[X68K_BITPLANES], uint16_t x, uint16_t y, uint8_t color) { // colour dispatch, so every plane write is one memory read-modify-write with
uint32_t off; // the set-or-clear choice already made. Both are bit-identical to draw.c's own
uint8_t bit; // walkers. Straight lines never reach jlpDrawLine -- draw.c diverts those to
uint16_t i; // 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);
off = ((uint32_t)y * X68K_BYTES_PER_ROW) + (uint32_t)(x >> 3); extern void surface68kPlanarLine(uint8_t *const *planes, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t color);
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++;
}
}
// ----- CPU stand-ins for the Amiga blitter helpers ----- // ----- 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). // after each row (0 for a full-stride operation).
// DEST-only block fill: `height` rows of `widthWords` words set or cleared. // DEST-only block fill: `height` rows of `widthWords` words set or cleared.
static void x68kCpuFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height, // Fill a run of 16-bit words with one value, in longs where it can. Marked
uint16_t dstModBytes, bool setBits) { // always_inline on purpose: the masked fill calls it once PER ROW, and at
uint16_t row; // 16x16 a row's middle is a word or two, where a real call costs more than
uint16_t w; // the stores it saves. Measured: routing that middle through a function made
uint16_t *p; // jlFillRect 16x16 run at 0.45x of the word loop it replaced.
uint16_t val; 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; if (words != 0u && ((uintptr_t)d & 2u) != 0u) {
for (row = 0u; row < height; row++) { *(uint16_t *)d = val16;
p = (uint16_t *)dst; d += 2;
for (w = 0u; w < widthWords; w++) { words--;
*p++ = val;
} }
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--;
// 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)) { if ((words & 1u) != 0u) {
mask &= lwm; *(uint16_t *)lp = val16;
}
if (mask == 0xFFFFu) {
*p = val;
} else {
*p = (uint16_t)((*p & (uint16_t)~mask) | (val & mask));
}
p++;
}
dst += (widthWords * 2u) + dstModBytes;
} }
} }
@ -230,7 +138,10 @@ void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) {
for (i = 0; i < X68K_BITPLANES; i++) { for (i = 0; i < X68K_BITPLANES; i++) {
setBits = ((colorIndex >> i) & 1u) != 0u; 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). // 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) { void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint8_t colorIndex) {
X68kPlanarT *pd; 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 // Callers guarantee s != NULL and positive w/h: the jlFillRect wrapper
// wrapper validates + clips before fillRectOnSurface, and the // validates and clips before fillRectOnSurface.
// fill-circle span loop emits spanWidth = 2x+1 >= 1 with h = 1.
pd = (X68kPlanarT *)s->portData; pd = (X68kPlanarT *)s->portData;
if (pd == NULL) { if (pd == NULL) {
jlpGenericFillRect(s, x, y, w, h, colorIndex); jlpGenericFillRect(s, x, y, w, h, colorIndex);
return; return;
} }
// Word-granular rect geometry. wordFirst/wordLast index the 16-pixel // Full-width rows are contiguous within a plane, so a full-width rect is
// plane words the rect touches; fwm/lwm are the blitter first/last- // ONE flat run of words per plane instead of h separately masked ones --
// word masks that cut the partial-pixel edges (pixel 0 is bit 15 of // about three cycles a byte against the general path's six. Every
// its word, so the left mask drops the high bits of the first word // full-screen wipe is this shape, so it keeps its own case.
// and the right mask drops the low bits of the last). One blit per if (x == 0 && w == (int16_t)SURFACE_WIDTH) {
// plane then fills any width, so there is no byte-aligned or uint16_t plane;
// 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 fast path: the rect spans every plane word with both for (plane = 0u; plane < X68K_BITPLANES; plane++) {
* edge masks all-ones, so each plane row is a whole 20-word store uint8_t bitVal = (uint8_t)((colorIndex >> plane) & 1u);
* with no destination read. jlFillRect 320x200 (full-screen wipes) uint8_t *rowBase = pd->planes[plane] + (uint16_t)y * X68K_BYTES_PER_ROW;
* 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 = 0; plane < X68K_BITPLANES; plane++) { x68kFillWordRun(rowBase, (uint16_t)((X68K_BYTES_PER_ROW / 2u) * (uint16_t)h),
bitVal = (uint8_t)((colorIndex >> plane) & 1u); bitVal ? 0xFFFFFFFFul : 0x00000000ul,
rowBase = pd->planes[plane] + (uint16_t)y * X68K_BYTES_PER_ROW; bitVal ? 0xFFFFu : 0x0000u);
x68kCpuFillOne(rowBase, (X68K_BYTES_PER_ROW / 2u), (uint16_t)h, 0u, bitVal != 0u);
} }
return; return;
} }
/* General masked fill: A is the constant $FFFF cut to the rect by // Everything else goes to planarFill.s, which shares its span body with
* the fwm/lwm word masks, C reads and D writes the destination so // the filled circle: masked end bytes, long stores between them, and the
* bits outside the mask survive. One blit per plane covers any // set-or-clear decision made once per colour instead of per plane per row.
* width, partial pixel edges included -- the blitter does the edge // That includes one-pixel-wide rects, which is what draw.c turns a
* masking in hardware, replacing the old per-pixel-edge CPU walkers. // vertical line into -- the asm's single-byte case covers them.
* UBER jlFillRect 16x16 / 80x80 lands here. (jlFillCircle spans surface68kFillRect(pd->planes, (uint16_t)x, (uint16_t)y, (uint16_t)w, (uint16_t)h, colorIndex);
* 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);
}
} }
@ -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 // pixel's 4bpp color is assembled from / written to the 4 plane bits
// at byte x>>3, bit 0x80 >> (x & 7). // 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) { void jlpSpriteSavePlanes(const jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t *dstPlaneBytes) {
const X68kPlanarT *pd; const X68kPlanarT *pd;
uint16_t pairs; uint16_t pairs;
@ -399,30 +356,43 @@ void jlpSpriteSavePlanes(const jlSurfaceT *s, int16_t x, int16_t y, uint16_t w,
return; 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); pairs = (uint16_t)(w >> 1);
{
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++) { 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 *p0 = pd->planes[0] + rowOff;
const uint8_t *p1 = pd->planes[1] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; const uint8_t *p1 = pd->planes[1] + rowOff;
const uint8_t *p2 = pd->planes[2] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; const uint8_t *p2 = pd->planes[2] + rowOff;
const uint8_t *p3 = pd->planes[3] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; const uint8_t *p3 = pd->planes[3] + rowOff;
uint16_t srcByte = 0u;
dst = dstPlaneBytes + (uint16_t)(row * pairs); dst = dstPlaneBytes + (uint16_t)(row * pairs);
for (pair = 0; pair < pairs; pair++) { for (pair = 0u; pair < pairs; pair += 4u) {
uint16_t px = (uint16_t)(x + (int16_t)(pair << 1)); uint16_t have = (uint16_t)(pairs - pair);
uint16_t byteOff = (uint16_t)(px >> 3); uint32_t chunky;
uint8_t maskHi = (uint8_t)(0x80u >> (px & 7u)); uint16_t k;
uint8_t maskLo = (uint8_t)(0x80u >> ((px + 1u) & 7u));
uint16_t byteOffLo = (uint16_t)((px + 1u) >> 3); if (have > 4u) {
uint8_t hi = 0u; have = 4u;
uint8_t lo = 0u; }
if (p0[byteOff] & maskHi) { hi = (uint8_t)(hi | 0x01u); } chunky = X68K_PLANE_SLICE(p0, srcByte, shift)
if (p1[byteOff] & maskHi) { hi = (uint8_t)(hi | 0x02u); } | (X68K_PLANE_SLICE(p1, srcByte, shift) << 1)
if (p2[byteOff] & maskHi) { hi = (uint8_t)(hi | 0x04u); } | (X68K_PLANE_SLICE(p2, srcByte, shift) << 2)
if (p3[byteOff] & maskHi) { hi = (uint8_t)(hi | 0x08u); } | (X68K_PLANE_SLICE(p3, srcByte, shift) << 3);
if (p0[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x01u); } for (k = 0u; k < have; k++) {
if (p1[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x02u); } dst[pair + k] = (uint8_t)((chunky >> ((3u - k) * 8u)) & 0xFFu);
if (p2[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x04u); } }
if (p3[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x08u); } srcByte++;
dst[pair] = (uint8_t)((hi << 4) | lo); }
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; 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); pairs = (uint16_t)(w >> 1);
{
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++) { for (row = 0; row < (uint16_t)h; row++) {
uint8_t *p0 = pd->planes[0] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; uint8_t *p0 = pd->planes[0] + rowOff;
uint8_t *p1 = pd->planes[1] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; uint8_t *p1 = pd->planes[1] + rowOff;
uint8_t *p2 = pd->planes[2] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; uint8_t *p2 = pd->planes[2] + rowOff;
uint8_t *p3 = pd->planes[3] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW; uint8_t *p3 = pd->planes[3] + rowOff;
uint16_t dstByte = 0u;
src = srcPlaneBytes + (uint16_t)(row * pairs); src = srcPlaneBytes + (uint16_t)(row * pairs);
for (pair = 0; pair < pairs; pair++) { for (pair = 0u; pair < pairs; pair += 4u) {
uint8_t b = src[pair]; uint16_t have = (uint16_t)(pairs - pair);
uint8_t hi = (uint8_t)(b >> 4); uint32_t acc = 0ul;
uint8_t lo = (uint8_t)(b & 0x0Fu); uint16_t k;
uint16_t px = (uint16_t)(x + (int16_t)(pair << 1)); uint16_t valid;
uint16_t byteOff = (uint16_t)(px >> 3); uint16_t maskHi;
uint8_t maskHi = (uint8_t)(0x80u >> (px & 7u)); uint16_t maskLo;
uint8_t notHi = (uint8_t)(~maskHi);
uint16_t byteOffLo = (uint16_t)((px + 1u) >> 3); if (have > 4u) {
uint8_t maskLo = (uint8_t)(0x80u >> ((px + 1u) & 7u)); have = 4u;
uint8_t notLo = (uint8_t)(~maskLo); }
p0[byteOff] = (uint8_t)((p0[byteOff] & notHi) | ((hi & 0x01u) ? maskHi : 0u)); for (k = 0u; k < have; k++) {
p1[byteOff] = (uint8_t)((p1[byteOff] & notHi) | ((hi & 0x02u) ? maskHi : 0u)); acc = (acc << 2) | gPairToPlanes[src[pair + k]];
p2[byteOff] = (uint8_t)((p2[byteOff] & notHi) | ((hi & 0x04u) ? maskHi : 0u)); }
p3[byteOff] = (uint8_t)((p3[byteOff] & notHi) | ((hi & 0x08u) ? maskHi : 0u)); // A short last group leaves its pixels at the bottom of each
p0[byteOffLo] = (uint8_t)((p0[byteOffLo] & notLo) | ((lo & 0x01u) ? maskLo : 0u)); // lane; push them up so the first pixel is always bit 7.
p1[byteOffLo] = (uint8_t)((p1[byteOffLo] & notLo) | ((lo & 0x02u) ? maskLo : 0u)); if (have < 4u) {
p2[byteOffLo] = (uint8_t)((p2[byteOffLo] & notLo) | ((lo & 0x04u) ? maskLo : 0u)); acc <<= (2u * (4u - have));
p3[byteOffLo] = (uint8_t)((p3[byteOffLo] & notLo) | ((lo & 0x08u) ? maskLo : 0u)); }
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; int16_t dstByteIdx;
uint16_t wTiles; uint16_t wTiles;
uint8_t shift; uint8_t shift;
uint8_t bit;
uint8_t p0, p1, p2, p3, mask; uint8_t p0, p1, p2, p3, mask;
uint8_t leftMask, notMask; uint8_t leftMask, notMask;
uint8_t rshift, rightMask; uint8_t rshift, rightMask;
@ -574,6 +568,7 @@ void jlpSpriteDrawPlanes(jlSurfaceT *s, const jlSpriteT *sp, int16_t x, int16_t
if (pd == NULL) { if (pd == NULL) {
return; return;
} }
buildPairTable();
wTiles = sp->widthTiles; wTiles = sp->widthTiles;
w = (int16_t)(wTiles * 8u); 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); tile = sp->tileData + (uint32_t)((tileY * wTiles + byteCol) * 32u);
chunky = tile + inTileY * 4u; chunky = tile + inTileY * 4u;
/* c2p: 8 pixels (4 chunky bytes) -> 4 plane bytes + mask. */ /* c2p: 8 pixels (4 chunky bytes) -> 4 plane bytes + mask, four
p0 = 0u; p1 = 0u; p2 = 0u; p3 = 0u; mask = 0u; * table reads instead of eight passes of five conditional ORs.
for (bit = 0; bit < 8u; bit++) { * See gPairToPlanes: each read carries two bits for every plane,
uint8_t nibble = (bit & 1u) * so one accumulator fills all four plane bytes at once. */
? (uint8_t)(chunky[bit >> 1] & 0x0Fu) {
: (uint8_t)(chunky[bit >> 1] >> 4); uint32_t acc = 0ul;
uint8_t bm = (uint8_t)(0x80u >> bit); uint16_t m = 0u;
if (nibble != 0u) { mask |= bm; } uint16_t k;
if (nibble & 1u) { p0 |= bm; }
if (nibble & 2u) { p1 |= bm; } for (k = 0u; k < 4u; k++) {
if (nibble & 4u) { p2 |= bm; } uint8_t cb = chunky[k];
if (nibble & 8u) { p3 |= bm; }
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) { if (mask == 0u) {
continue; 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 // the four shadow planes. Caller (the per-primitive walker below) has
// already validated (x, y) is on-surface. byteOff is computed once // already validated (x, y) is on-surface. byteOff is computed once
// and reused across all four planes since stride is the same in each. // 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) { 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) { void jlpDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t colorIndex) {
X68kPlanarT *pd; X68kPlanarT *pd;
if (s->portData == NULL) { if (s->portData == NULL) {
return; return;
} }
pd = (X68kPlanarT *)s->portData; 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; return;
} }
pd = (X68kPlanarT *)s->portData; pd = (X68kPlanarT *)s->portData;
surface68kAmigaCircleOutline(pd->planes[0], pd->planes[1], surface68kPlanarCircle(pd->planes, (uint16_t)cx, (uint16_t)cy, r, (uint16_t)(colorIndex & 0x0Fu));
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);
}
} }
// 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) { bool jlpFillCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t colorIndex) {
X68kPlanarT *pd; enum { kMaxWordRadius = 181 };
uint32_t r2;
uint32_t xx; _Static_assert((SURFACE_HEIGHT / 2) <= kMaxWordRadius, "r*r must fit a word in src/m68k/planarFill.s");
uint32_t yy;
int32_t x;
uint16_t y;
if (s->portData == NULL) { if (s->portData == NULL) {
return false; return false;
} }
pd = (X68kPlanarT *)s->portData; surface68kFillCircle(((X68kPlanarT *)s->portData)->planes, (uint16_t)cx, (uint16_t)cy, r, colorIndex);
// 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);
}
return true; 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 * Amiga these are plain heap: no blitter or DMA reads them, only the
* CPU, so there is no Chip RAM constraint to satisfy. */ * CPU, so there is no Chip RAM constraint to satisfy. */
for (i = 0; i < X68K_BITPLANES; i++) { 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)", jlLogF("x68k: shadow plane %u on heap (CPU only, addr=$%08lX)",
(unsigned)i, (unsigned long)pd->planes[i]); (unsigned)i, (unsigned long)pd->planes[i]);
if (pd->planes[i] == NULL) { if (pd->planes[i] == NULL) {

View file

@ -98,6 +98,10 @@ static void sccIsrInstall(void);
static void sccIsrUninstall(void); static void sccIsrUninstall(void);
static bool gSccIsrInstalled = false; 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 uint8_t gSccRxVec = 0u;
static uint32_t gSccOldRx = 0ul; static uint32_t gSccOldRx = 0ul;
static uint32_t gSccOldSp = 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 // fallback that keeps the port working, with the caller deaf-tolerant up to the SOFT ring instead
// of RSDRV's ~64 bytes. // of RSDRV's ~64 bytes.
void x68kSerialPump(void) { void x68kSerialPump(void) {
if (gSccIsrInstalled) { if (gSccIsrInstalled || !gSerialOpened) {
return; return;
} }
while (_iocs_isns232c() != 0) { while (_iocs_isns232c() != 0) {
@ -190,6 +194,7 @@ uint16_t jlpSerialAvailable(void) {
void jlpSerialClose(void) { void jlpSerialClose(void) {
sccIsrUninstall(); // hand channel A RX back to RSDRV before the vectors dangle 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 // idiom is a _CRTMOD contract, undocumented for _SET232C -- XEiJ reads the
// RSDRV work area instead -- so the write could not be trusted.) // RSDRV work area instead -- so the write could not be trusted.)
(void)config; (void)config;
gSerialOpened = true;
sccIsrInstall(); // own the RX interrupt; falls back to the polled pump if the vectors look wrong sccIsrInstall(); // own the RX interrupt; falls back to the polled pump if the vectors look wrong
return true; return true;
} }

View file

@ -104,7 +104,7 @@ int main(void) {
memset(&gGame, 0, sizeof(gGame)); memset(&gGame, 0, sizeof(gGame));
memset(&gSim, 0, sizeof(gSim)); memset(&gSim, 0, sizeof(gSim));
stRenderInit(stage); stRenderInit(stage);
stAudioInit(); (void)stAudioInit();
stSimNewGame(&gSim, 2u, false); stSimNewGame(&gSim, 2u, false);
// ---- ranking ($4D92) ---- // ---- ranking ($4D92) ----