diff --git a/assets/genSpeechPcm.py b/assets/genSpeechPcm.py index e707924..8943a2e 100755 --- a/assets/genSpeechPcm.py +++ b/assets/genSpeechPcm.py @@ -14,15 +14,18 @@ output level running from one utterance into the next, so a phrase whose first word ends on the low level plays its second word inverted. Both versions are stored and the player picks by the running parity. speech.bin (little-endian): - "STSP" u8 version(2) u8 pitchBase u8 pitchCount u8 count + "STSP" u8 version(3) u8 pitchBase u8 pitchCount u8 count u8 format (0 = signed +/-100, 1 = 0x80-biased for the IIgs raw stream) - u8 pad[3] u32 dataBytes - entries[pitchCount * count * 2]: u8 code, u8 parity, u16 samples, - u32 offset -- polarity is the innermost index (0 = as decoded, - 1 = inverted). parity = 1 when the utterance ends on the - opposite level from the one it started on. + u8 polarities (1 or 2) u8 pad[2] u32 dataBytes + entries[pitchCount * count * polarities]: u8 code, u8 parity, + u16 samples, u32 offset -- polarity is the innermost index + (0 = as decoded, 1 = inverted). parity = 1 when the utterance + ends on the opposite level from the one it started on. data: sample bytes -usage: genSpeechPcm.py stSpeechData.c speech.bin [--biased] +With --single only polarity 0 is written, halving the file; the player +negates the samples itself when the running parity calls for it. The +X68000 needs that: its Human68k floppy cannot spare the other 60 KB. +usage: genSpeechPcm.py stSpeechData.c speech.bin [--biased] [--single] """ import re import struct @@ -77,6 +80,8 @@ def decode(rle, perOn): def main(): args = [a for a in sys.argv[1:] if not a.startswith("--")] biased = "--biased" in sys.argv[1:] + single = "--single" in sys.argv[1:] + polarities = 1 if single else 2 src, dst = args[0], args[1] entries, data = parseSource(src) records = [] @@ -86,7 +91,7 @@ def main(): for code, off, length in entries: samples, level = decode(data[off:off + length], perOn) parity = 1 if level < 0 else 0 - for pol in range(2): + for pol in range(polarities): pcm = bytearray() for s in samples: v = -s if pol else s @@ -94,12 +99,14 @@ def main(): records.append((code, parity, len(samples), len(blob))) blob += pcm with open(dst, "wb") as f: - f.write(b"STSP" + struct.pack(" ST_FATAL_ARM_FRAMES && (joyMask() & 0x1Fu) != 0u) { + return; + } + now = (uint8_t)jlFrameCount(); + held = (uint16_t)(held + (uint8_t)(now - last)); + last = now; + jlStagePresent(); + } +} + + +// An asset the game ships with will not load. Say so on screen and stop: +// a mute game, or one running every sprite through the interpreter, reads +// as a slow machine rather than a broken install, and that is worse than +// not starting. +static void fatalStop(const char *line1, const char *line2) { + ST_LOG("spacetaxi: ! FATAL -- %s / %s", line1, line2); + ST_LOG_FLUSH(); + stAudioSilence(); + stAudioNoise(false); + jlMusicStop(); + stRenderFatal(gStage, line1, line2); + gGame.fatal = true; +} + + // $5FBB -- "GAME OVER!" over the play field, sprites hidden. static void gameOverEnter(void) { stSimDrawText(&gSim, 14u, 10u, kTextBlank12, 1u); @@ -505,8 +561,7 @@ static bool loadLevel(uint8_t index) { ST_LOG("spacetaxi: ! cannot load %s", path); return false; } - stRenderLevelCels(gStage, &gLevel); - return true; + return stRenderLevelCels(gStage, &gLevel); } @@ -842,12 +897,25 @@ static void startDemoScreen(void) { (void)stSimAdvancePlayer(&gSim); gSim.levelState++; if (!loadLevel(e->levelIndex)) { - enterTitle(); + fatalStop(kTextFatalLoad, kTextFatalLevel); return; } + // $40CA: blank the character screen first. Without this the two + // green lines land on top of the title, which is still up -- logo, + // byline, copyright and all -- where the original shows them on a + // cleared screen (observed on real hardware, 2026-09-23). Same + // clear stIntroEnter does, and it leaves the sprite mask alone so + // the line below still keeps sprites 0..2. + memset(gSim.screen, ST_CHAR_SPACE, ST_SCREEN_CELLS); + memset(gSim.color, 0, ST_SCREEN_CELLS); + stSimDirtyAll(&gSim); + stSimGlyphReset(&gSim); + gSim.charDirtyAll = true; + gSim.charDirtyCount = 0u; stSimDrawText(&gSim, 10u, 8u, kTextGetReady, 5u); stSimDrawText(&gSim, 10u, 10u, kTextOnTheTaxi, 5u); gSim.frame.enableMask &= 0x07u; + stRenderSceneChanged(&gSim); gGame.waitTicks = ST_GET_READY_TICKS; gGame.state = ST_STATE_GET_READY; } @@ -881,7 +949,7 @@ static void startNewScreen(void) { } gGame.pendingLevel = next; if (!loadLevel(next)) { - enterTitle(); + fatalStop(kTextFatalLoad, kTextFatalLevel); return; } gSim.levelState++; @@ -931,21 +999,31 @@ int main(void) { memset(&gSim, 0, sizeof(gSim)); stRenderInit(stage); stRenderLoading(stage); - stAudioInit(); + if (!stAudioInit()) { + fatalStop(kTextFatalMemory, kTextFatalSpeech); + } // Boot: the playback buffer as the C64 leaves it, and the state a // fresh machine has when the first title intro starts ($4092 + the // title's own sparkle/walk history from the dump). stSimNewGame(&gSim, 1u, false); stSimSeedDemoBuffer(&gSim, kDemoBufferInit, ST_DEMO_BUFFER_BYTES); gSim.waveIdx = 3u; - stRenderPrewarm(stage, &gSim); + if (!gGame.fatal && !stRenderPrewarm(stage, &gSim)) { + fatalStop(kTextFatalMemory, kTextFatalSprites); + } highScoreLoad(); // $23E5 - enterTitle(); + if (!gGame.fatal) { + enterTitle(); + } gGame.lastFrame = (uint8_t)jlFrameCount(); for (;;) { uint8_t ticks; + if (gGame.fatal) { + fatalHold(); + break; + } jlInputPoll(); // $4FCB runStopWatcher, on the game tick, the intro and the menus: // SHIFT held freezes everything with the sound gated off and any @@ -1056,5 +1134,5 @@ int main(void) { stAudioShutdown(); stRenderShutdown(); jlShutdown(); - return 0; + return gGame.fatal ? 1 : 0; } diff --git a/spacetaxi.h b/spacetaxi.h index bf9730d..75fd710 100644 --- a/spacetaxi.h +++ b/spacetaxi.h @@ -25,9 +25,11 @@ #if defined(__W65816__) || defined(JOEYLIB_PLATFORM_X68000) #define ST_LOG_RESET() ((void)0) #define ST_LOG(...) ((void)0) + #define ST_LOG_FLUSH() ((void)0) #else #define ST_LOG_RESET() jlLogReset() #define ST_LOG(...) jlLogF(__VA_ARGS__) + #define ST_LOG_FLUSH() jlLogFlush() #endif // Level loading through the JoeyLib data path (DATA/levels/...). @@ -54,12 +56,22 @@ void stRenderTitleLogo(StSimT *sim); // animates without a single pixel moving. Call after // stRenderSceneChanged, which turns it back off. void stRenderIntroStars(void); -void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim); +// Fill the stage with a two-line message and present it. Used when an +// asset the game ships with will not load, so a player sees why the +// game stopped instead of a silently degraded ride. +void stRenderFatal(jlSurfaceT *stage, const char *line1, const char *line2); +// Compile every always-on cel. False when a shipped asset is present but +// unusable, so main can stop with a message instead of running the game +// on interpreted cels. +bool stRenderPrewarm(jlSurfaceT *stage, StSimT *sim); // Ready a level's hook cels before its ride (evicts the previous level's). -void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level); +// False only when this level's baked bank exists and will not load; a +// port that ships no per-level banks compiles them and returns true. +bool stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level); // Audio sink setup and the per-tick release timers ($4320). -void stAudioInit(void); +// False when the shipped speech blob will not load. +bool stAudioInit(void); void stAudioShutdown(void); void stAudioTick(void); diff --git a/stAudio.c b/stAudio.c index 8d3eefe..d1ed9cb 100644 --- a/stAudio.c +++ b/stAudio.c @@ -29,6 +29,13 @@ #define ST_ATTEN_LOUD 4u #define ST_ATTEN_SOFT 8u +// The jet's noise pitch: jlAudioNoise takes 0..31 in YM noise-period +// style, where 31 is the lowest rumble (0 is the brightest hiss). +#define ST_NOISE_JET_PITCH 31u +// Noise sound effects (the crash and the wreck's impact) use the same +// deep end of the range. The C64 takes the rate from the program's +// frequency word; this is the one simplification in that mapping. +#define ST_NOISE_SFX_PITCH 31u #define ST_ATTEN_OFF 15u // NTSC SID: Hz = word * 1022727 / 16777216 = word * 0.06096. @@ -56,10 +63,16 @@ // the ride half a second on every "HEY TAXI"; the 1-bit intermediate // format that followed still needed a per-byte expansion loop. #define ST_SPEECH_FILE "speech.bin" -#define ST_SPEECH_VERSION 2u +#define ST_SPEECH_VERSION 3u #define ST_SPEECH_HEADER_BYTES 16u #define ST_SPEECH_ENTRY_BYTES 8u -#define ST_SPEECH_POLARITIES 2u +// speech.bin carries each utterance in one polarity or two. Two is the +// cheap case -- the player just picks the baked variant -- but it doubles +// the file, and the X68000's Human68k floppy cannot spare the other +// 60 KB, so that port bakes one and the player negates the samples +// itself. Negation is the same byte arithmetic in both sample formats: +// signed 0 - v, and 0x80-biased 0x100 - v, are one operation. +#define ST_SPEECH_POLARITIES_MAX 2u #define ST_SPEECH_FORMAT_SIGNED 0u #define ST_SPEECH_FORMAT_BIASED 1u #define ST_SPEECH_LOAD_CHUNK 16384u @@ -88,6 +101,9 @@ typedef struct { static StSfxSlotT gSfx; // SID voice 1 (programs + sweep) static StSfxSlotT gTone; // SID voice 2 static bool gNoiseOn; +// Release countdown for a NOISE sound effect, in game ticks, mirroring +// the two tone slots' ticksLeft. 0 = no burst running. +static uint16_t gNoiseTicks; // Speech playback state. The fill callback walks a queue of utterances so // a phrase like "HT" ("Hey" + "Taxi") runs as one continuous stream @@ -109,7 +125,9 @@ static const uint8_t *gSpeechPcm; // current utterance's samples static uint16_t gSpeechSample; // next sample index in it static uint16_t gSpeechSamples; // its length static uint8_t gSpeechParity; // it ends on the opposite level -static uint8_t gSpeechPol; // 0 / 1: which baked polarity the next utterance plays +static uint8_t gSpeechPol; // 0 / 1: the polarity the next utterance plays in +static uint8_t gSpeechPolarities; // how many the file carries, 1 or 2 +static bool gSpeechInvert; // negate on the way out (1-polarity file, odd polarity) // Copied-ahead PCM ring for the stream (see speechFill). Allocated by // stAudioInit rather than static: 8 KB of BSS is a fifth of the IIgs // entry bank, which text, rodata, BSS and the C heap all share. @@ -121,7 +139,7 @@ static bool gSpeechDecoding; // the decoder still has stream left static uint16_t sidHz(uint16_t word); -static void speechLoad(void); +static bool speechLoad(void); static bool speechNext(void); static void speechArm(void); static uint16_t speechDecode(uint8_t *dst, uint16_t count); @@ -145,7 +163,7 @@ static uint16_t sidHz(uint16_t word) { // is empty, which ends the stream. // DATA/speech.bin into one jlAlloc block; without it the game is mute // but otherwise fine. -static void speechLoad(void) { +static bool speechLoad(void) { FILE *fp = jlDataOpen(ST_SPEECH_FILE, "rb"); uint8_t head[ST_SPEECH_HEADER_BYTES]; uint8_t format; @@ -155,22 +173,30 @@ static void speechLoad(void) { gSpeechBlob = 0; if (fp == 0) { - return; + ST_LOG("staudio: ! cannot open DATA/%s", ST_SPEECH_FILE); + return false; } format = (jlAudioSfxRawFormat() == JL_SFX_RAW_UNSIGNED8_NOZERO) ? ST_SPEECH_FORMAT_BIASED : ST_SPEECH_FORMAT_SIGNED; - if (fread(head, 1u, sizeof(head), fp) != sizeof(head) || head[0] != 'S' || head[1] != 'T' || head[2] != 'S' || head[3] != 'P' || head[4] != ST_SPEECH_VERSION || head[8] != format) { + if (fread(head, 1u, sizeof(head), fp) != sizeof(head) || head[0] != 'S' || head[1] != 'T' || head[2] != 'S' || head[3] != 'P' || head[4] != ST_SPEECH_VERSION || head[8] != format + || head[9] == 0u || head[9] > ST_SPEECH_POLARITIES_MAX) { + ST_LOG("staudio: ! speech.bin rejected (want v%u fmt%u)", ST_SPEECH_VERSION, format); fclose(fp); - return; + return false; } + gSpeechPolarities = head[9]; gSpeechPitchBase = head[5]; gSpeechPitchCount = head[6]; gSpeechCount = head[7]; dataBytes = (uint32_t)head[12] | ((uint32_t)head[13] << 8) | ((uint32_t)head[14] << 16) | ((uint32_t)head[15] << 24); - total = ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES + dataBytes; + total = ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES + dataBytes; gSpeechBlob = (uint8_t *)jlAlloc(total); if (gSpeechBlob == 0) { + // The blob is ~120 KB of sample bytes, so on the tighter + // machines this is the failure most likely to bite. It stops + // the game: a silent Space Taxi is a broken Space Taxi. + ST_LOG("staudio: ! speech alloc FAILED (%lu bytes)", (unsigned long)total); fclose(fp); - return; + return false; } memcpy(gSpeechBlob, head, sizeof(head)); // The block spans banks on the IIgs; read it in bank-safe pieces. @@ -182,6 +208,7 @@ static void speechLoad(void) { want = ST_SPEECH_LOAD_CHUNK; } if (fread(gSpeechBlob + done, 1u, (size_t)want, fp) != (size_t)want) { + ST_LOG("staudio: ! speech read FAILED at %lu of %lu", (unsigned long)done, (unsigned long)total); jlFree(gSpeechBlob); gSpeechBlob = 0; break; @@ -189,6 +216,11 @@ static void speechLoad(void) { done += want; } fclose(fp); + if (gSpeechBlob == 0) { + return false; + } + ST_LOG("staudio: speech %lu bytes, %u utterances x %u pitches x %u polarities", (unsigned long)total, gSpeechCount, gSpeechPitchCount, gSpeechPolarities); + return true; } @@ -206,19 +238,24 @@ static bool speechNext(void) { } while (gSpeechHead != gSpeechTail) { uint8_t code = gSpeechQueue[gSpeechHead]; - const uint8_t *e = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint16_t)row * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES; + const uint8_t *e = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint16_t)row * gSpeechCount * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES; uint8_t k; gSpeechHead = (uint8_t)((gSpeechHead + 1u) % ST_SPEECH_QUEUE); - for (k = 0u; k < gSpeechCount; k++, e += ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES) { + for (k = 0u; k < gSpeechCount; k++, e += gSpeechPolarities * ST_SPEECH_ENTRY_BYTES) { if (e[0] == code) { - const uint8_t *v = e + (uint16_t)gSpeechPol * ST_SPEECH_ENTRY_BYTES; + // Two polarities baked: take the variant. One: take the + // only one and negate it on the way out when the running + // polarity is the odd one. + uint8_t pol = (gSpeechPolarities == 2u) ? gSpeechPol : 0u; + const uint8_t *v = e + (uint16_t)pol * ST_SPEECH_ENTRY_BYTES; uint32_t offset = (uint32_t)v[4] | ((uint32_t)v[5] << 8) | ((uint32_t)v[6] << 16) | ((uint32_t)v[7] << 24); + gSpeechInvert = (gSpeechPolarities == 1u && gSpeechPol != 0u); gSpeechParity = v[1]; gSpeechSamples = (uint16_t)(v[2] | ((uint16_t)v[3] << 8)); gSpeechSample = 0u; - gSpeechPcm = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES + offset; + gSpeechPcm = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES + offset; return true; } } @@ -252,7 +289,17 @@ static uint16_t speechDecode(uint8_t *dst, uint16_t count) { if (take > left) { take = left; } - jlMemCopy(dst + n, gSpeechPcm + gSpeechSample, take); + if (gSpeechInvert) { + const uint8_t *from = gSpeechPcm + gSpeechSample; + uint8_t *to = dst + n; + uint16_t i; + + for (i = 0u; i < take; i++) { + to[i] = (uint8_t)(0u - (uint16_t)from[i]); + } + } else { + jlMemCopy(dst + n, gSpeechPcm + gSpeechSample, take); + } gSpeechSample = (uint16_t)(gSpeechSample + take); n = (uint16_t)(n + take); } @@ -368,14 +415,27 @@ void stAudioTick(void) { jlAudioVoice(gTone.voice, 0u, ST_ATTEN_OFF); } } + if (gNoiseTicks != 0u) { + gNoiseTicks--; + if (gNoiseTicks == 0u) { + gNoiseOn = false; + jlAudioNoise(0u, ST_ATTEN_OFF); + } else { + // Re-assert: one call buys about 80 ms on the IIgs. + jlAudioNoise(ST_NOISE_SFX_PITCH, ST_ATTEN_LOUD); + } + } } -void stAudioInit(void) { +bool stAudioInit(void) { + bool ok; + (void)jlAudioInit(); gSpeechStage = (uint8_t *)jlAlloc(ST_SPEECH_STAGE_BYTES); - speechLoad(); + ok = (gSpeechStage != 0) && speechLoad(); gSfx.voice = ST_VOICE_SFX; + gNoiseTicks = 0u; gSfx.ticksLeft = 0u; gTone.voice = ST_VOICE_TONE; gTone.ticksLeft = 0u; @@ -385,6 +445,8 @@ void stAudioInit(void) { gSpeechActive = false; gSpeechPitch = ST_SPEECH_PITCH_BASE; gSpeechPol = 0u; + gSpeechInvert = false; + return ok; } @@ -401,16 +463,38 @@ void stAudioShutdown(void) { // $6D6A / $6A3B -- voice 3 jet noise gate. +// $6D6A's voice-3 gate. The C64 holds SID voice 3 open for as long as a +// direction is held, so the jet has to keep sounding for the whole burn. +// +// This re-asserts on EVERY call rather than only on the rising edge, +// because jlAudioNoise is a per-frame assertion and not a latch: the +// IIgs plays one ~80 ms one-shot per call and expects the caller to keep +// asking, exactly as jlMusicPlay's sequencer does for a held noise note +// (include/joey/audio.h). Latching it on the edge is why the thruster +// used to sound one short burst and then die with the taxi still +// climbing. The ports that DO latch take the repeat harmlessly: the ST +// rewrites the same YM registers, and Paula's redundant DMA set is +// click-free by design. void stAudioNoise(bool on) { - if (on == gNoiseOn) { + if (on && gNoiseTicks != 0u) { + // A sound effect owns the voice until its release runs out, so + // the jet does not talk over the crash. The C64 shares SID + // voice 3 between them the same way and silences the jet before + // firing one. An explicit silence below still wins, which + // matters because stAudioMusic clears this voice before a tune. return; } - gNoiseOn = on; if (on) { - jlAudioNoise(31u, ST_ATTEN_SOFT); - } else { - jlAudioNoise(0u, ST_ATTEN_OFF); + gNoiseOn = true; + jlAudioNoise(ST_NOISE_JET_PITCH, ST_ATTEN_SOFT); + return; } + if (!gNoiseOn && gNoiseTicks == 0u) { + return; + } + gNoiseOn = false; + gNoiseTicks = 0u; + jlAudioNoise(0u, ST_ATTEN_OFF); } @@ -423,9 +507,17 @@ void stAudioSfx(const uint8_t *program9) { StSfxSlotT *slot = (program9[8] == 1u) ? &gTone : &gSfx; if ((ctrl & 0x80u) != 0u) { - // Noise waveform: a burst on the noise generator. - jlAudioNoise(31u, ST_ATTEN_LOUD); - gNoiseOn = true; + // Noise waveform: a burst on the noise generator, held for the + // program's release time exactly as the tone slots below hold + // theirs. It used to fire one jlAudioNoise and return, which is + // not a burst at all: that call is a per-frame ASSERTION, not a + // latch (include/joey/audio.h), so on the IIgs the wreck's + // explosion lasted the ~80 ms of one one-shot and was easy to + // miss entirely, while on Paula it would have hung until some + // later stAudioNoise(false). stAudioTick re-asserts it. + gNoiseTicks = (uint16_t)(ticks + 1u); + gNoiseOn = true; + jlAudioNoise(ST_NOISE_SFX_PITCH, ST_ATTEN_LOUD); return; } jlAudioVoice(slot->voice, sidHz(word), ST_ATTEN_LOUD); diff --git a/stCels.c b/stCels.c index 4482d01..cb33c46 100644 --- a/stCels.c +++ b/stCels.c @@ -44,6 +44,31 @@ const StCelDefT kStCels[] = { const uint16_t kStCelCount = (uint16_t)(sizeof(kStCels) / sizeof(kStCels[0])); +// Cels the title screen draws that are deliberately NOT pre-compiled. +// +// Its five decoration sprites are recoloured by logoColorCycle ($4861), +// which rewrites sprites 3..7 with the next logo colour every cycle +// step, so each is seen in all eight of kLogoColor's values -- forty +// combinations. The parked cab's jet is a forty-first, colour 6 rather +// than the flame list's 7. None of them are worth a bank slot: on the +// 65816 a compile costs about half a second, so baking the forty would +// add some twenty seconds to startup, and raising kStCelCount at all is +// risky -- 37 made the runtime reject the whole precompiled bank (see +// the note in the project memory). They draw interpreted, which +// a static title screen can well afford. stRender consults this list so +// the mid-ride warning stays about real misses. +// A zero colour means "in any colour": the decorations cycle through all +// eight. The jet is listed for the cab colour alone, because the flame +// list above already carries it in colour 7 for the ride itself. +const StCelDefT kStInterpretedCels[] = { + { 0xDB, 0u, 1u, MC0, MC1 }, { 0xDE, 0u, 1u, MC0, MC1 }, + { 0xDF, 0u, 1u, MC0, MC1 }, { 0xE0, 0u, 1u, MC0, MC1 }, + { 0xE1, 0u, 1u, MC0, MC1 }, + { 0xD8, CAB, 1u, MC0, MC1 }, +}; +const uint16_t kStInterpretedCount = (uint16_t)(sizeof(kStInterpretedCels) / sizeof(kStInterpretedCels[0])); + + void stCelBlob(const uint8_t *bm, uint8_t multi, uint8_t color, uint8_t mc0, uint8_t mc1, uint8_t *out) { uint8_t pal[4]; uint8_t tx; diff --git a/stCels.h b/stCels.h index 2ab7dc8..f10f959 100644 --- a/stCels.h +++ b/stCels.h @@ -30,6 +30,10 @@ typedef struct { extern const StCelDefT kStCels[]; extern const uint16_t kStCelCount; +// Cels the title screen draws interpreted by design; a zero colour +// entry matches any colour. See the note beside the table in stCels.c. +extern const StCelDefT kStInterpretedCels[]; +extern const uint16_t kStInterpretedCount; // The cels a level's hook program can show (stLevelCels.c): at most `max` // into `out`, no duplicates. 0 for a level without hook sprites. diff --git a/stRender.c b/stRender.c index 9391631..3dd0e08 100644 --- a/stRender.c +++ b/stRender.c @@ -155,6 +155,7 @@ static StSpriteCacheT *cacheFreeSlot(void); static void levelCelsEvict(void); static const uint8_t *levelSpriteBitmap(const StLevelT *level, uint8_t ptr); static void loadingBar(jlSurfaceT *stage, uint8_t done, uint8_t total); +static bool celDrawsInterpreted(uint8_t ptr, uint8_t color); static uint8_t cellCol(uint16_t cell, uint8_t row); static uint8_t cellRow(uint16_t cell); static void collectGlyphs(StSimT *sim); @@ -166,10 +167,11 @@ static bool loadPrecompiledCels(void); static bool logoBuildTile(const StSimT *sim); static uint8_t logoFrameIndex(const StSimT *sim); static void logoPaletteApply(const StSimT *sim, uint8_t frame); -static void starPaletteApply(void); static void paintCells(jlSurfaceT *stage, StSimT *sim); static void pasteCell(jlSurfaceT *stage, const StSimT *sim, uint16_t cell, uint8_t bx, uint8_t by); +static bool prewarmReport(uint16_t expect); static bool spriteOnScreen(int16_t px, int16_t py); +static void starPaletteApply(void); static void tileFromChunky(jlTileT *out, const uint8_t *chunky); @@ -278,7 +280,7 @@ static jlSpriteT *cachedSprite(StSimT *sim, uint8_t idx) { if (idx <= 2u && gPrewarming) { cacheCompile(slot); } - if (!gPrewarming) { + if (!gPrewarming && !celDrawsInterpreted(ptr, color)) { ST_LOG("spacetaxi: ! cel $%02X colour %u (level %u) built mid-ride -- add it to stLevelCelList", ptr, color, level); } slot->lastUse = gRender.cacheStamp; @@ -313,6 +315,26 @@ static const uint8_t kCellRow[125] = { // Column of a cell whose row is known: cell - row * 40 through a table, // because the two-shift form of the multiply came back as a __mulhi3 // library call on the 65816. +// True for a cel that is MEANT to be built here rather than pre-listed, +// so the warning below stays about real misses. Only the title screen's +// decorations qualify: logoColorCycle recolours them every cycle step, +// which is forty ptr/colour pairs that would each cost a compile (see +// the note beside kStTitleDecorCels in stCels.c). +static bool celDrawsInterpreted(uint8_t ptr, uint8_t color) { + uint16_t k; + + for (k = 0u; k < kStInterpretedCount; k++) { + if (kStInterpretedCels[k].ptr != ptr) { + continue; + } + if (kStInterpretedCels[k].color == 0u || kStInterpretedCels[k].color == color) { + return true; + } + } + return false; +} + + static uint8_t cellCol(uint16_t cell, uint8_t row) { static const uint16_t kRowBase[ST_SCREEN_ROWS] = { 0, 40, 80, 120, 160, 200, 240, 280, 320, 360, 400, 440, 480, @@ -874,21 +896,22 @@ static void loadingBar(jlSurfaceT *stage, uint8_t done, uint8_t total) { // here behind the LOADING bar. Either way nothing compiles once the // level is running. Keyed exactly as cachedSprite keys them, so every // lookup during the ride is a hit. -void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) { +bool stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) { static StCelDefT list[ST_LEVEL_CELS_MAX]; jlSpriteT *cels[ST_LEVEL_CELS_MAX]; char path[24]; uint8_t n; uint8_t got; + uint8_t ready = 0u; uint8_t k; levelCelsEvict(); if (level == 0) { - return; + return true; } n = stLevelCelList(level, list, ST_LEVEL_CELS_MAX); if (n == 0u) { - return; + return true; } memcpy(path, "sprites/level", 13u); path[13] = (char)('0' + (level->levelIndex + 1u) / 10u); @@ -938,11 +961,17 @@ void stRenderLevelCels(jlSurfaceT *stage, const StLevelT *level) { slot->used = true; slot->pinned = true; slot->lastUse = gRender.cacheStamp; + ready++; } gPrewarming = false; if (got != n) { jlFillRect(stage, 60, 110, 200u, 6u, 0u); } + if (ready < n) { + ST_LOG("spacetaxi: ! level %u has %u of %u cels", level->levelIndex, ready, n); + return false; + } + return true; } @@ -976,11 +1005,54 @@ static bool loadPrecompiledCels(void) { e->used = true; e->pinned = true; e->lastUse = 0u; + // The bank's cels arrive compiled ONLY if the arena had room for + // them: jlSpriteBankLoadPrecompiled loads a cel either way and + // leaves the ones that did not fit to the interpreter. So ask + // the sprite instead of assuming, or the flag lies and any + // future cacheCompile skips a cel that really does need work. + e->compiled = (jlSpriteCompiledSize(e->sprite) != 0u); } return true; } +// A fatal message, centred, in the game's own charset. Used when an +// asset that IS on the disk will not load: the game then stops instead +// of playing on in a degraded state, because a mute game or one running +// every sprite through the interpreter looks like a slow machine rather +// than a broken install. +void stRenderFatal(jlSurfaceT *stage, const char *line1, const char *line2) { + const uint8_t *charset = stC64Charset(); + const char *lines[2]; + uint8_t i; + uint8_t k; + + if (stage == 0) { + return; + } + lines[0] = line1; + lines[1] = line2; + jlSurfaceClear(stage, 0u); + for (i = 0u; i < 2u; i++) { + uint8_t len; + uint8_t col; + + if (lines[i] == 0) { + continue; + } + len = 0u; + while (lines[i][len] != '\0' && len < ST_SCREEN_COLS) { + len++; + } + col = (uint8_t)((ST_SCREEN_COLS - len) / 2u); + for (k = 0u; k < len; k++) { + jlTilePasteGlyph(stage, (uint8_t)(col + k), (uint8_t)(ST_LOADING_ROW + i * 2u), + &charset[(uint8_t)lines[i][k] * 8u], ST_LOADING_COLOR, 0u); + } + } + jlStagePresent(); +} + // "LOADING..." centred on an otherwise black stage. The charset is the // game's own, indexed by ASCII (the C64 font's letters sit at their @@ -999,12 +1071,13 @@ void stRenderLoading(jlSurfaceT *stage) { jlStagePresent(); } -void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) { +bool stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) { static const uint8_t kCabPtrs[] = { 0xC0, 0xC1, 0xDC, 0xDD, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0, 0xD1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8 }; static const uint8_t kPassPtrs[] = { 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xD9 }; static const uint8_t kFlamePtrs[] = { 0xD8, 0xD4, 0xD5, 0xD2, 0xD1, 0xD7, 0xD3, 0xD6 }; uint8_t total = (uint8_t)(sizeof(kCabPtrs) + sizeof(kPassPtrs) + sizeof(kFlamePtrs) + 1u); uint8_t done = 0u; + bool ok; uint8_t k; StFrameT saved; @@ -1012,7 +1085,7 @@ void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) { // runtime build below on any port without a matching .spc. if (loadPrecompiledCels()) { ST_LOG("spacetaxi: cels from sprites/staxicels.spc"); - return; + return prewarmReport(kStCelCount); } ST_LOG("spacetaxi: ! no usable sprites/staxicels.spc -- JIT prewarm"); saved = sim->frame; @@ -1050,7 +1123,45 @@ void stRenderPrewarm(jlSurfaceT *stage, StSimT *sim) { (void)cachedSprite(sim, 2u); sim->frame = saved; gPrewarming = false; + ok = prewarmReport(total); jlFillRect(stage, 60, 110, 200u, 6u, 0u); + return ok; +} + + +// Say how the prewarmed cels came out, and judge them. A cel past the end +// of the codegen arena still DRAWS, interpreted, so a port whose arena +// holds only some of the cels is slower but sound -- that is the ST at +// 2 MB, 12 of 37 compiled, and it is not an error. Zero compiled is: +// it means the arena itself never came, which on a 1 MB ST is the speech +// blob having taken the heap, and every sprite then runs about forty +// times slower. The caller stops on a false rather than let that pass +// for a slow machine. +static bool prewarmReport(uint16_t expect) { + uint32_t bytes = 0u; + uint8_t used = 0u; + uint8_t hot = 0u; + uint8_t k; + + for (k = 0u; k < ST_SPRITE_CACHE; k++) { + uint32_t n; + + if (!gRender.cache[k].used) { + continue; + } + n = jlSpriteCompiledSize(gRender.cache[k].sprite); + used++; + if (n != 0u) { + hot++; + bytes += n; + } + } + ST_LOG("spacetaxi: prewarm %u cels: %u compiled, %u interpreted, %lu arena bytes", used, hot, (uint8_t)(used - hot), (unsigned long)bytes); + if (used < expect) { + ST_LOG("spacetaxi: ! only %u of %u cels built", used, expect); + return false; + } + return hot != 0u; } diff --git a/tools/staxiharness/harness.c b/tools/staxiharness/harness.c index 11f5c30..aab0a57 100644 --- a/tools/staxiharness/harness.c +++ b/tools/staxiharness/harness.c @@ -104,7 +104,7 @@ int main(void) { memset(&gGame, 0, sizeof(gGame)); memset(&gSim, 0, sizeof(gSim)); stRenderInit(stage); - stAudioInit(); + (void)stAudioInit(); stSimNewGame(&gSim, 2u, false); // ---- ranking ($4D92) ----