X68k, ST, and Amiga optimizations.

This commit is contained in:
Scott Duensing 2026-09-24 22:01:56 -05:00
parent 5adf55e80f
commit f6c4ad3747
8 changed files with 385 additions and 56 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
versions are stored and the player picks by the running parity.
speech.bin (little-endian):
"STSP" u8 version(2) u8 pitchBase u8 pitchCount u8 count
"STSP" u8 version(3) u8 pitchBase u8 pitchCount u8 count
u8 format (0 = signed +/-100, 1 = 0x80-biased for the IIgs raw stream)
u8 pad[3] u32 dataBytes
entries[pitchCount * count * 2]: u8 code, u8 parity, u16 samples,
u32 offset -- polarity is the innermost index (0 = as decoded,
1 = inverted). parity = 1 when the utterance ends on the
opposite level from the one it started on.
u8 polarities (1 or 2) u8 pad[2] u32 dataBytes
entries[pitchCount * count * polarities]: u8 code, u8 parity,
u16 samples, u32 offset -- polarity is the innermost index
(0 = as decoded, 1 = inverted). parity = 1 when the utterance
ends on the opposite level from the one it started on.
data: sample bytes
usage: genSpeechPcm.py stSpeechData.c speech.bin [--biased]
With --single only polarity 0 is written, halving the file; the player
negates the samples itself when the running parity calls for it. The
X68000 needs that: its Human68k floppy cannot spare the other 60 KB.
usage: genSpeechPcm.py stSpeechData.c speech.bin [--biased] [--single]
"""
import re
import struct
@ -77,6 +80,8 @@ def decode(rle, perOn):
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
biased = "--biased" in sys.argv[1:]
single = "--single" in sys.argv[1:]
polarities = 1 if single else 2
src, dst = args[0], args[1]
entries, data = parseSource(src)
records = []
@ -86,7 +91,7 @@ def main():
for code, off, length in entries:
samples, level = decode(data[off:off + length], perOn)
parity = 1 if level < 0 else 0
for pol in range(2):
for pol in range(polarities):
pcm = bytearray()
for s in samples:
v = -s if pol else s
@ -94,12 +99,14 @@ def main():
records.append((code, parity, len(samples), len(blob)))
blob += pcm
with open(dst, "wb") as f:
f.write(b"STSP" + struct.pack("<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:
f.write(struct.pack("<BBHI", code, parity, n, off))
f.write(blob)
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__":

View file

@ -40,6 +40,11 @@
#define ST_GAME_OVER_TICKS 60u
// Menu joystick repeat delay ($4101 with X = $82).
#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).
#define ST_HS_ENTRIES 10u
@ -87,6 +92,7 @@ typedef struct {
uint8_t hsCursor; // $4F4D the blinking cursor's current glyph
uint8_t hsBlinkTicks; // ticks toward the next cursor flip
bool paused; // $4FCB: SHIFT is holding the game
bool fatal; // an asset on the disk would not load
} StGameT;
// 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",
};
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 StSimT gSim;
@ -163,6 +177,8 @@ static StLevelT gLevel;
static void blankScreen(StSimT *sim, uint8_t border, uint8_t bg);
static void enterTitle(void);
static void fatalHold(void);
static void fatalStop(const char *line1, const char *line2);
static void gameOverEnter(void);
static void gameOverFinish(void);
static void highScoreDraw(void);
@ -207,9 +223,10 @@ static void blankScreen(StSimT *sim, uint8_t border, uint8_t bg) {
static void enterTitle(void) {
gSim.demoMode = 0u;
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);
stRenderSceneChanged(&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.
static void gameOverEnter(void) {
stSimDrawText(&gSim, 14u, 10u, kTextBlank12, 1u);
@ -505,8 +561,7 @@ static bool loadLevel(uint8_t index) {
ST_LOG("spacetaxi: ! cannot load %s", path);
return false;
}
stRenderLevelCels(gStage, &gLevel);
return true;
return stRenderLevelCels(gStage, &gLevel);
}
@ -842,12 +897,25 @@ static void startDemoScreen(void) {
(void)stSimAdvancePlayer(&gSim);
gSim.levelState++;
if (!loadLevel(e->levelIndex)) {
enterTitle();
fatalStop(kTextFatalLoad, kTextFatalLevel);
return;
}
// $40CA: blank the character screen first. Without this the two
// green lines land on top of the title, which is still up -- logo,
// byline, copyright and all -- where the original shows them on a
// cleared screen (observed on real hardware, 2026-09-23). Same
// clear stIntroEnter does, and it leaves the sprite mask alone so
// the line below still keeps sprites 0..2.
memset(gSim.screen, ST_CHAR_SPACE, ST_SCREEN_CELLS);
memset(gSim.color, 0, ST_SCREEN_CELLS);
stSimDirtyAll(&gSim);
stSimGlyphReset(&gSim);
gSim.charDirtyAll = true;
gSim.charDirtyCount = 0u;
stSimDrawText(&gSim, 10u, 8u, kTextGetReady, 5u);
stSimDrawText(&gSim, 10u, 10u, kTextOnTheTaxi, 5u);
gSim.frame.enableMask &= 0x07u;
stRenderSceneChanged(&gSim);
gGame.waitTicks = ST_GET_READY_TICKS;
gGame.state = ST_STATE_GET_READY;
}
@ -881,7 +949,7 @@ static void startNewScreen(void) {
}
gGame.pendingLevel = next;
if (!loadLevel(next)) {
enterTitle();
fatalStop(kTextFatalLoad, kTextFatalLevel);
return;
}
gSim.levelState++;
@ -931,21 +999,31 @@ int main(void) {
memset(&gSim, 0, sizeof(gSim));
stRenderInit(stage);
stRenderLoading(stage);
stAudioInit();
if (!stAudioInit()) {
fatalStop(kTextFatalMemory, kTextFatalSpeech);
}
// Boot: the playback buffer as the C64 leaves it, and the state a
// fresh machine has when the first title intro starts ($4092 + the
// title's own sparkle/walk history from the dump).
stSimNewGame(&gSim, 1u, false);
stSimSeedDemoBuffer(&gSim, kDemoBufferInit, ST_DEMO_BUFFER_BYTES);
gSim.waveIdx = 3u;
stRenderPrewarm(stage, &gSim);
if (!gGame.fatal && !stRenderPrewarm(stage, &gSim)) {
fatalStop(kTextFatalMemory, kTextFatalSprites);
}
highScoreLoad(); // $23E5
enterTitle();
if (!gGame.fatal) {
enterTitle();
}
gGame.lastFrame = (uint8_t)jlFrameCount();
for (;;) {
uint8_t ticks;
if (gGame.fatal) {
fatalHold();
break;
}
jlInputPoll();
// $4FCB runStopWatcher, on the game tick, the intro and the menus:
// SHIFT held freezes everything with the sound gated off and any
@ -1056,5 +1134,5 @@ int main(void) {
stAudioShutdown();
stRenderShutdown();
jlShutdown();
return 0;
return gGame.fatal ? 1 : 0;
}

View file

@ -25,9 +25,11 @@
#if defined(__W65816__) || defined(JOEYLIB_PLATFORM_X68000)
#define ST_LOG_RESET() ((void)0)
#define ST_LOG(...) ((void)0)
#define ST_LOG_FLUSH() ((void)0)
#else
#define ST_LOG_RESET() jlLogReset()
#define ST_LOG(...) jlLogF(__VA_ARGS__)
#define ST_LOG_FLUSH() jlLogFlush()
#endif
// Level loading through the JoeyLib data path (DATA/levels/...).
@ -54,12 +56,22 @@ void stRenderTitleLogo(StSimT *sim);
// animates without a single pixel moving. Call after
// stRenderSceneChanged, which turns it back off.
void stRenderIntroStars(void);
void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim);
// Fill the stage with a two-line message and present it. Used when an
// asset the game ships with will not load, so a player sees why the
// game stopped instead of a silently degraded ride.
void stRenderFatal(jlSurfaceT *stage, const char *line1, const char *line2);
// Compile every always-on cel. False when a shipped asset is present but
// unusable, so main can stop with a message instead of running the game
// on interpreted cels.
bool stRenderPrewarm(jlSurfaceT *stage, StSimT *sim);
// Ready a level's hook cels before its ride (evicts the previous level's).
void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level);
// False only when this level's baked bank exists and will not load; a
// port that ships no per-level banks compiles them and returns true.
bool stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level);
// Audio sink setup and the per-tick release timers ($4320).
void stAudioInit(void);
// False when the shipped speech blob will not load.
bool stAudioInit(void);
void stAudioShutdown(void);
void stAudioTick(void);

142
stAudio.c
View file

@ -29,6 +29,13 @@
#define ST_ATTEN_LOUD 4u
#define ST_ATTEN_SOFT 8u
// The jet's noise pitch: jlAudioNoise takes 0..31 in YM noise-period
// style, where 31 is the lowest rumble (0 is the brightest hiss).
#define ST_NOISE_JET_PITCH 31u
// Noise sound effects (the crash and the wreck's impact) use the same
// deep end of the range. The C64 takes the rate from the program's
// frequency word; this is the one simplification in that mapping.
#define ST_NOISE_SFX_PITCH 31u
#define ST_ATTEN_OFF 15u
// NTSC SID: Hz = word * 1022727 / 16777216 = word * 0.06096.
@ -56,10 +63,16 @@
// the ride half a second on every "HEY TAXI"; the 1-bit intermediate
// format that followed still needed a per-byte expansion loop.
#define ST_SPEECH_FILE "speech.bin"
#define ST_SPEECH_VERSION 2u
#define ST_SPEECH_VERSION 3u
#define ST_SPEECH_HEADER_BYTES 16u
#define ST_SPEECH_ENTRY_BYTES 8u
#define ST_SPEECH_POLARITIES 2u
// speech.bin carries each utterance in one polarity or two. Two is the
// cheap case -- the player just picks the baked variant -- but it doubles
// the file, and the X68000's Human68k floppy cannot spare the other
// 60 KB, so that port bakes one and the player negates the samples
// itself. Negation is the same byte arithmetic in both sample formats:
// signed 0 - v, and 0x80-biased 0x100 - v, are one operation.
#define ST_SPEECH_POLARITIES_MAX 2u
#define ST_SPEECH_FORMAT_SIGNED 0u
#define ST_SPEECH_FORMAT_BIASED 1u
#define ST_SPEECH_LOAD_CHUNK 16384u
@ -88,6 +101,9 @@ typedef struct {
static StSfxSlotT gSfx; // SID voice 1 (programs + sweep)
static StSfxSlotT gTone; // SID voice 2
static bool gNoiseOn;
// Release countdown for a NOISE sound effect, in game ticks, mirroring
// the two tone slots' ticksLeft. 0 = no burst running.
static uint16_t gNoiseTicks;
// Speech playback state. The fill callback walks a queue of utterances so
// a phrase like "HT" ("Hey" + "Taxi") runs as one continuous stream
@ -109,7 +125,9 @@ static const uint8_t *gSpeechPcm; // current utterance's samples
static uint16_t gSpeechSample; // next sample index in it
static uint16_t gSpeechSamples; // its length
static uint8_t gSpeechParity; // it ends on the opposite level
static uint8_t gSpeechPol; // 0 / 1: which baked polarity the next utterance plays
static uint8_t gSpeechPol; // 0 / 1: the polarity the next utterance plays in
static uint8_t gSpeechPolarities; // how many the file carries, 1 or 2
static bool gSpeechInvert; // negate on the way out (1-polarity file, odd polarity)
// Copied-ahead PCM ring for the stream (see speechFill). Allocated by
// stAudioInit rather than static: 8 KB of BSS is a fifth of the IIgs
// entry bank, which text, rodata, BSS and the C heap all share.
@ -121,7 +139,7 @@ static bool gSpeechDecoding; // the decoder still has stream left
static uint16_t sidHz(uint16_t word);
static void speechLoad(void);
static bool speechLoad(void);
static bool speechNext(void);
static void speechArm(void);
static uint16_t speechDecode(uint8_t *dst, uint16_t count);
@ -145,7 +163,7 @@ static uint16_t sidHz(uint16_t word) {
// is empty, which ends the stream.
// DATA/speech.bin into one jlAlloc block; without it the game is mute
// but otherwise fine.
static void speechLoad(void) {
static bool speechLoad(void) {
FILE *fp = jlDataOpen(ST_SPEECH_FILE, "rb");
uint8_t head[ST_SPEECH_HEADER_BYTES];
uint8_t format;
@ -155,22 +173,30 @@ static void speechLoad(void) {
gSpeechBlob = 0;
if (fp == 0) {
return;
ST_LOG("staudio: ! cannot open DATA/%s", ST_SPEECH_FILE);
return false;
}
format = (jlAudioSfxRawFormat() == JL_SFX_RAW_UNSIGNED8_NOZERO) ? ST_SPEECH_FORMAT_BIASED : ST_SPEECH_FORMAT_SIGNED;
if (fread(head, 1u, sizeof(head), fp) != sizeof(head) || head[0] != 'S' || head[1] != 'T' || head[2] != 'S' || head[3] != 'P' || head[4] != ST_SPEECH_VERSION || head[8] != format) {
if (fread(head, 1u, sizeof(head), fp) != sizeof(head) || head[0] != 'S' || head[1] != 'T' || head[2] != 'S' || head[3] != 'P' || head[4] != ST_SPEECH_VERSION || head[8] != format
|| head[9] == 0u || head[9] > ST_SPEECH_POLARITIES_MAX) {
ST_LOG("staudio: ! speech.bin rejected (want v%u fmt%u)", ST_SPEECH_VERSION, format);
fclose(fp);
return;
return false;
}
gSpeechPolarities = head[9];
gSpeechPitchBase = head[5];
gSpeechPitchCount = head[6];
gSpeechCount = head[7];
dataBytes = (uint32_t)head[12] | ((uint32_t)head[13] << 8) | ((uint32_t)head[14] << 16) | ((uint32_t)head[15] << 24);
total = ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES + dataBytes;
total = ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES + dataBytes;
gSpeechBlob = (uint8_t *)jlAlloc(total);
if (gSpeechBlob == 0) {
// The blob is ~120 KB of sample bytes, so on the tighter
// machines this is the failure most likely to bite. It stops
// the game: a silent Space Taxi is a broken Space Taxi.
ST_LOG("staudio: ! speech alloc FAILED (%lu bytes)", (unsigned long)total);
fclose(fp);
return;
return false;
}
memcpy(gSpeechBlob, head, sizeof(head));
// The block spans banks on the IIgs; read it in bank-safe pieces.
@ -182,6 +208,7 @@ static void speechLoad(void) {
want = ST_SPEECH_LOAD_CHUNK;
}
if (fread(gSpeechBlob + done, 1u, (size_t)want, fp) != (size_t)want) {
ST_LOG("staudio: ! speech read FAILED at %lu of %lu", (unsigned long)done, (unsigned long)total);
jlFree(gSpeechBlob);
gSpeechBlob = 0;
break;
@ -189,6 +216,11 @@ static void speechLoad(void) {
done += want;
}
fclose(fp);
if (gSpeechBlob == 0) {
return false;
}
ST_LOG("staudio: speech %lu bytes, %u utterances x %u pitches x %u polarities", (unsigned long)total, gSpeechCount, gSpeechPitchCount, gSpeechPolarities);
return true;
}
@ -206,19 +238,24 @@ static bool speechNext(void) {
}
while (gSpeechHead != gSpeechTail) {
uint8_t code = gSpeechQueue[gSpeechHead];
const uint8_t *e = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint16_t)row * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES;
const uint8_t *e = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint16_t)row * gSpeechCount * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES;
uint8_t k;
gSpeechHead = (uint8_t)((gSpeechHead + 1u) % ST_SPEECH_QUEUE);
for (k = 0u; k < gSpeechCount; k++, e += ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES) {
for (k = 0u; k < gSpeechCount; k++, e += gSpeechPolarities * ST_SPEECH_ENTRY_BYTES) {
if (e[0] == code) {
const uint8_t *v = e + (uint16_t)gSpeechPol * ST_SPEECH_ENTRY_BYTES;
// Two polarities baked: take the variant. One: take the
// only one and negate it on the way out when the running
// polarity is the odd one.
uint8_t pol = (gSpeechPolarities == 2u) ? gSpeechPol : 0u;
const uint8_t *v = e + (uint16_t)pol * ST_SPEECH_ENTRY_BYTES;
uint32_t offset = (uint32_t)v[4] | ((uint32_t)v[5] << 8) | ((uint32_t)v[6] << 16) | ((uint32_t)v[7] << 24);
gSpeechInvert = (gSpeechPolarities == 1u && gSpeechPol != 0u);
gSpeechParity = v[1];
gSpeechSamples = (uint16_t)(v[2] | ((uint16_t)v[3] << 8));
gSpeechSample = 0u;
gSpeechPcm = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES + offset;
gSpeechPcm = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES + offset;
return true;
}
}
@ -252,7 +289,17 @@ static uint16_t speechDecode(uint8_t *dst, uint16_t count) {
if (take > left) {
take = left;
}
jlMemCopy(dst + n, gSpeechPcm + gSpeechSample, take);
if (gSpeechInvert) {
const uint8_t *from = gSpeechPcm + gSpeechSample;
uint8_t *to = dst + n;
uint16_t i;
for (i = 0u; i < take; i++) {
to[i] = (uint8_t)(0u - (uint16_t)from[i]);
}
} else {
jlMemCopy(dst + n, gSpeechPcm + gSpeechSample, take);
}
gSpeechSample = (uint16_t)(gSpeechSample + take);
n = (uint16_t)(n + take);
}
@ -368,14 +415,27 @@ void stAudioTick(void) {
jlAudioVoice(gTone.voice, 0u, ST_ATTEN_OFF);
}
}
if (gNoiseTicks != 0u) {
gNoiseTicks--;
if (gNoiseTicks == 0u) {
gNoiseOn = false;
jlAudioNoise(0u, ST_ATTEN_OFF);
} else {
// Re-assert: one call buys about 80 ms on the IIgs.
jlAudioNoise(ST_NOISE_SFX_PITCH, ST_ATTEN_LOUD);
}
}
}
void stAudioInit(void) {
bool stAudioInit(void) {
bool ok;
(void)jlAudioInit();
gSpeechStage = (uint8_t *)jlAlloc(ST_SPEECH_STAGE_BYTES);
speechLoad();
ok = (gSpeechStage != 0) && speechLoad();
gSfx.voice = ST_VOICE_SFX;
gNoiseTicks = 0u;
gSfx.ticksLeft = 0u;
gTone.voice = ST_VOICE_TONE;
gTone.ticksLeft = 0u;
@ -385,6 +445,8 @@ void stAudioInit(void) {
gSpeechActive = false;
gSpeechPitch = ST_SPEECH_PITCH_BASE;
gSpeechPol = 0u;
gSpeechInvert = false;
return ok;
}
@ -401,16 +463,38 @@ void stAudioShutdown(void) {
// $6D6A / $6A3B -- voice 3 jet noise gate.
// $6D6A's voice-3 gate. The C64 holds SID voice 3 open for as long as a
// direction is held, so the jet has to keep sounding for the whole burn.
//
// This re-asserts on EVERY call rather than only on the rising edge,
// because jlAudioNoise is a per-frame assertion and not a latch: the
// IIgs plays one ~80 ms one-shot per call and expects the caller to keep
// asking, exactly as jlMusicPlay's sequencer does for a held noise note
// (include/joey/audio.h). Latching it on the edge is why the thruster
// used to sound one short burst and then die with the taxi still
// climbing. The ports that DO latch take the repeat harmlessly: the ST
// rewrites the same YM registers, and Paula's redundant DMA set is
// click-free by design.
void stAudioNoise(bool on) {
if (on == gNoiseOn) {
if (on && gNoiseTicks != 0u) {
// A sound effect owns the voice until its release runs out, so
// the jet does not talk over the crash. The C64 shares SID
// voice 3 between them the same way and silences the jet before
// firing one. An explicit silence below still wins, which
// matters because stAudioMusic clears this voice before a tune.
return;
}
gNoiseOn = on;
if (on) {
jlAudioNoise(31u, ST_ATTEN_SOFT);
} else {
jlAudioNoise(0u, ST_ATTEN_OFF);
gNoiseOn = true;
jlAudioNoise(ST_NOISE_JET_PITCH, ST_ATTEN_SOFT);
return;
}
if (!gNoiseOn && gNoiseTicks == 0u) {
return;
}
gNoiseOn = false;
gNoiseTicks = 0u;
jlAudioNoise(0u, ST_ATTEN_OFF);
}
@ -423,9 +507,17 @@ void stAudioSfx(const uint8_t *program9) {
StSfxSlotT *slot = (program9[8] == 1u) ? &gTone : &gSfx;
if ((ctrl & 0x80u) != 0u) {
// Noise waveform: a burst on the noise generator.
jlAudioNoise(31u, ST_ATTEN_LOUD);
gNoiseOn = true;
// Noise waveform: a burst on the noise generator, held for the
// program's release time exactly as the tone slots below hold
// theirs. It used to fire one jlAudioNoise and return, which is
// not a burst at all: that call is a per-frame ASSERTION, not a
// latch (include/joey/audio.h), so on the IIgs the wreck's
// explosion lasted the ~80 ms of one one-shot and was easy to
// miss entirely, while on Paula it would have hung until some
// later stAudioNoise(false). stAudioTick re-asserts it.
gNoiseTicks = (uint16_t)(ticks + 1u);
gNoiseOn = true;
jlAudioNoise(ST_NOISE_SFX_PITCH, ST_ATTEN_LOUD);
return;
}
jlAudioVoice(slot->voice, sidHz(word), ST_ATTEN_LOUD);

View file

@ -44,6 +44,31 @@ const StCelDefT kStCels[] = {
const uint16_t kStCelCount = (uint16_t)(sizeof(kStCels) / sizeof(kStCels[0]));
// Cels the title screen draws that are deliberately NOT pre-compiled.
//
// Its five decoration sprites are recoloured by logoColorCycle ($4861),
// which rewrites sprites 3..7 with the next logo colour every cycle
// step, so each is seen in all eight of kLogoColor's values -- forty
// combinations. The parked cab's jet is a forty-first, colour 6 rather
// than the flame list's 7. None of them are worth a bank slot: on the
// 65816 a compile costs about half a second, so baking the forty would
// add some twenty seconds to startup, and raising kStCelCount at all is
// risky -- 37 made the runtime reject the whole precompiled bank (see
// the note in the project memory). They draw interpreted, which
// a static title screen can well afford. stRender consults this list so
// the mid-ride warning stays about real misses.
// A zero colour means "in any colour": the decorations cycle through all
// eight. The jet is listed for the cab colour alone, because the flame
// list above already carries it in colour 7 for the ride itself.
const StCelDefT kStInterpretedCels[] = {
{ 0xDB, 0u, 1u, MC0, MC1 }, { 0xDE, 0u, 1u, MC0, MC1 },
{ 0xDF, 0u, 1u, MC0, MC1 }, { 0xE0, 0u, 1u, MC0, MC1 },
{ 0xE1, 0u, 1u, MC0, MC1 },
{ 0xD8, CAB, 1u, MC0, MC1 },
};
const uint16_t kStInterpretedCount = (uint16_t)(sizeof(kStInterpretedCels) / sizeof(kStInterpretedCels[0]));
void stCelBlob(const uint8_t *bm, uint8_t multi, uint8_t color, uint8_t mc0, uint8_t mc1, uint8_t *out) {
uint8_t pal[4];
uint8_t tx;

View file

@ -30,6 +30,10 @@ typedef struct {
extern const StCelDefT kStCels[];
extern const uint16_t kStCelCount;
// Cels the title screen draws interpreted by design; a zero colour
// entry matches any colour. See the note beside the table in stCels.c.
extern const StCelDefT kStInterpretedCels[];
extern const uint16_t kStInterpretedCount;
// The cels a level's hook program can show (stLevelCels.c): at most `max`
// into `out`, no duplicates. 0 for a level without hook sprites.

View file

@ -155,6 +155,7 @@ static StSpriteCacheT *cacheFreeSlot(void);
static void levelCelsEvict(void);
static const uint8_t *levelSpriteBitmap(const StLevelT *level, uint8_t ptr);
static void loadingBar(jlSurfaceT *stage, uint8_t done, uint8_t total);
static bool celDrawsInterpreted(uint8_t ptr, uint8_t color);
static uint8_t cellCol(uint16_t cell, uint8_t row);
static uint8_t cellRow(uint16_t cell);
static void collectGlyphs(StSimT *sim);
@ -166,10 +167,11 @@ static bool loadPrecompiledCels(void);
static bool logoBuildTile(const StSimT *sim);
static uint8_t logoFrameIndex(const StSimT *sim);
static void logoPaletteApply(const StSimT *sim, uint8_t frame);
static void starPaletteApply(void);
static void paintCells(jlSurfaceT *stage, StSimT *sim);
static void pasteCell(jlSurfaceT *stage, const StSimT *sim, uint16_t cell, uint8_t bx, uint8_t by);
static bool prewarmReport(uint16_t expect);
static bool spriteOnScreen(int16_t px, int16_t py);
static void starPaletteApply(void);
static void tileFromChunky(jlTileT *out, const uint8_t *chunky);
@ -278,7 +280,7 @@ static jlSpriteT *cachedSprite(StSimT *sim, uint8_t idx) {
if (idx <= 2u && gPrewarming) {
cacheCompile(slot);
}
if (!gPrewarming) {
if (!gPrewarming && !celDrawsInterpreted(ptr, color)) {
ST_LOG("spacetaxi: ! cel $%02X colour %u (level %u) built mid-ride -- add it to stLevelCelList", ptr, color, level);
}
slot->lastUse = gRender.cacheStamp;
@ -313,6 +315,26 @@ static const uint8_t kCellRow[125] = {
// Column of a cell whose row is known: cell - row * 40 through a table,
// because the two-shift form of the multiply came back as a __mulhi3
// library call on the 65816.
// True for a cel that is MEANT to be built here rather than pre-listed,
// so the warning below stays about real misses. Only the title screen's
// decorations qualify: logoColorCycle recolours them every cycle step,
// which is forty ptr/colour pairs that would each cost a compile (see
// the note beside kStTitleDecorCels in stCels.c).
static bool celDrawsInterpreted(uint8_t ptr, uint8_t color) {
uint16_t k;
for (k = 0u; k < kStInterpretedCount; k++) {
if (kStInterpretedCels[k].ptr != ptr) {
continue;
}
if (kStInterpretedCels[k].color == 0u || kStInterpretedCels[k].color == color) {
return true;
}
}
return false;
}
static uint8_t cellCol(uint16_t cell, uint8_t row) {
static const uint16_t kRowBase[ST_SCREEN_ROWS] = {
0, 40, 80, 120, 160, 200, 240, 280, 320, 360, 400, 440, 480,
@ -874,21 +896,22 @@ static void loadingBar(jlSurfaceT *stage, uint8_t done, uint8_t total) {
// here behind the LOADING bar. Either way nothing compiles once the
// level is running. Keyed exactly as cachedSprite keys them, so every
// lookup during the ride is a hit.
void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) {
bool stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) {
static StCelDefT list[ST_LEVEL_CELS_MAX];
jlSpriteT *cels[ST_LEVEL_CELS_MAX];
char path[24];
uint8_t n;
uint8_t got;
uint8_t ready = 0u;
uint8_t k;
levelCelsEvict();
if (level == 0) {
return;
return true;
}
n = stLevelCelList(level, list, ST_LEVEL_CELS_MAX);
if (n == 0u) {
return;
return true;
}
memcpy(path, "sprites/level", 13u);
path[13] = (char)('0' + (level->levelIndex + 1u) / 10u);
@ -938,11 +961,17 @@ void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) {
slot->used = true;
slot->pinned = true;
slot->lastUse = gRender.cacheStamp;
ready++;
}
gPrewarming = false;
if (got != n) {
jlFillRect(stage, 60, 110, 200u, 6u, 0u);
}
if (ready < n) {
ST_LOG("spacetaxi: ! level %u has %u of %u cels", level->levelIndex, ready, n);
return false;
}
return true;
}
@ -976,11 +1005,54 @@ static bool loadPrecompiledCels(void) {
e->used = true;
e->pinned = true;
e->lastUse = 0u;
// The bank's cels arrive compiled ONLY if the arena had room for
// them: jlSpriteBankLoadPrecompiled loads a cel either way and
// leaves the ones that did not fit to the interpreter. So ask
// the sprite instead of assuming, or the flag lies and any
// future cacheCompile skips a cel that really does need work.
e->compiled = (jlSpriteCompiledSize(e->sprite) != 0u);
}
return true;
}
// A fatal message, centred, in the game's own charset. Used when an
// asset that IS on the disk will not load: the game then stops instead
// of playing on in a degraded state, because a mute game or one running
// every sprite through the interpreter looks like a slow machine rather
// than a broken install.
void stRenderFatal(jlSurfaceT *stage, const char *line1, const char *line2) {
const uint8_t *charset = stC64Charset();
const char *lines[2];
uint8_t i;
uint8_t k;
if (stage == 0) {
return;
}
lines[0] = line1;
lines[1] = line2;
jlSurfaceClear(stage, 0u);
for (i = 0u; i < 2u; i++) {
uint8_t len;
uint8_t col;
if (lines[i] == 0) {
continue;
}
len = 0u;
while (lines[i][len] != '\0' && len < ST_SCREEN_COLS) {
len++;
}
col = (uint8_t)((ST_SCREEN_COLS - len) / 2u);
for (k = 0u; k < len; k++) {
jlTilePasteGlyph(stage, (uint8_t)(col + k), (uint8_t)(ST_LOADING_ROW + i * 2u),
&charset[(uint8_t)lines[i][k] * 8u], ST_LOADING_COLOR, 0u);
}
}
jlStagePresent();
}
// "LOADING..." centred on an otherwise black stage. The charset is the
// game's own, indexed by ASCII (the C64 font's letters sit at their
@ -999,12 +1071,13 @@ void stRenderLoading(jlSurfaceT *stage) {
jlStagePresent();
}
void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) {
bool stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) {
static const uint8_t kCabPtrs[] = { 0xC0, 0xC1, 0xDC, 0xDD, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0, 0xD1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8 };
static const uint8_t kPassPtrs[] = { 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xD9 };
static const uint8_t kFlamePtrs[] = { 0xD8, 0xD4, 0xD5, 0xD2, 0xD1, 0xD7, 0xD3, 0xD6 };
uint8_t total = (uint8_t)(sizeof(kCabPtrs) + sizeof(kPassPtrs) + sizeof(kFlamePtrs) + 1u);
uint8_t done = 0u;
bool ok;
uint8_t k;
StFrameT saved;
@ -1012,7 +1085,7 @@ void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) {
// runtime build below on any port without a matching .spc.
if (loadPrecompiledCels()) {
ST_LOG("spacetaxi: cels from sprites/staxicels.spc");
return;
return prewarmReport(kStCelCount);
}
ST_LOG("spacetaxi: ! no usable sprites/staxicels.spc -- JIT prewarm");
saved = sim->frame;
@ -1050,7 +1123,45 @@ void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) {
(void)cachedSprite(sim, 2u);
sim->frame = saved;
gPrewarming = false;
ok = prewarmReport(total);
jlFillRect(stage, 60, 110, 200u, 6u, 0u);
return ok;
}
// Say how the prewarmed cels came out, and judge them. A cel past the end
// of the codegen arena still DRAWS, interpreted, so a port whose arena
// holds only some of the cels is slower but sound -- that is the ST at
// 2 MB, 12 of 37 compiled, and it is not an error. Zero compiled is:
// it means the arena itself never came, which on a 1 MB ST is the speech
// blob having taken the heap, and every sprite then runs about forty
// times slower. The caller stops on a false rather than let that pass
// for a slow machine.
static bool prewarmReport(uint16_t expect) {
uint32_t bytes = 0u;
uint8_t used = 0u;
uint8_t hot = 0u;
uint8_t k;
for (k = 0u; k < ST_SPRITE_CACHE; k++) {
uint32_t n;
if (!gRender.cache[k].used) {
continue;
}
n = jlSpriteCompiledSize(gRender.cache[k].sprite);
used++;
if (n != 0u) {
hot++;
bytes += n;
}
}
ST_LOG("spacetaxi: prewarm %u cels: %u compiled, %u interpreted, %lu arena bytes", used, hot, (uint8_t)(used - hot), (unsigned long)bytes);
if (used < expect) {
ST_LOG("spacetaxi: ! only %u of %u cels built", used, expect);
return false;
}
return hot != 0u;
}

View file

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