// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // // Does a serial receiver survive the longest deaf window a JoeyLib program has? // // The host peer (scripts/verify-iigs-serial-loss.sh) waits for "READY" from // this program, then streams SERLOSS_BYTES bytes whose value is the low byte of // their index, paced by the emulated line at 9600 baud. Meanwhile every loop // iteration dirties the whole stage and presents it -- a full-screen present is // the longest stretch a program spends away from its own code -- and then reads // whatever the port buffered, calling jlSerialPoll once per frame as the API // asks. A receiver that fills its ring from an interrupt loses nothing; one that // is drained only when the program gets around to it loses the bytes that // arrive while the hardware FIFO is full. // // Build variants (the harness passes these with -D): // SERLOSS_SLOT=n open a Super Serial Card in slot n instead of the modem port // SERLOSS_CLEAR dirty the stage with jlSurfaceClear rather than jlFillRect // SERLOSS_IDLE no drawing at all: only the interrupts the system itself takes // SERLOSS_BAUD=n line rate (default 9600; the harness sets the peer to match) // SERLOSS_JOY also poll the input (joystick included) once per frame and // report the spread of the calibrated stick readings; the // harness holds the paddles still (SERLOSS_PADDLE), so any // spread is read jitter // SERLOSS_SFX also keep a sound effect streaming and retrigger a one-shot // effect once per frame, so NinjaTrackerPlus's entry calls and // its DOC refill interrupt run throughout the receive; the // stream's refills are counted, the proof audio is running // SERLOSS_LOOSE drop the serial add-on's hold on the tight interrupt-latency // bound once the port is open: the negative control, which // must lose bytes wherever a masked stretch outlasts the FIFO // // Results go to a mailbox in the unused tail of the SHR SCB page, which the // present never writes and the harness reads straight out of emulated memory. #include #include #define MB_BASE ((volatile uint8_t *)0x00E19DD0L) #define MB_SIG_0 0 #define MB_SIG_1 1 #define MB_PHASE 2 #define MB_RECEIVED 4 #define MB_IN_SEQUENCE 6 #define MB_BREAKS 8 #define MB_PRESENTS 10 #define MB_TICKS 12 #define MB_OVERRUNS 14 #define MB_DROPPED 16 #define MB_SUM 18 #define MB_NOW 20 #define MB_FILL_TICKS 22 #define MB_PRESENT_TICKS 24 #define MB_JOY_POLLS 26 #define MB_JOY_MIN_X 28 // stick readings, biased by MB_JOY_BIAS #define MB_JOY_MAX_X 29 #define MB_JOY_MIN_Y 30 #define MB_JOY_MAX_Y 31 #define MB_SFX_SHOTS 32 #define MB_SFX_FILLS 34 #define MB_SIG_VALUE_0 0xA5u #define MB_SIG_VALUE_1 0x5Au #define MB_JOY_BIAS 128 #define PHASE_INIT 1u #define PHASE_OPEN 2u #define PHASE_READY 3u #define PHASE_RECEIVING 4u #define PHASE_DONE 5u #define PHASE_FAILED 0xEEu #ifndef SERLOSS_BAUD #define SERLOSS_BAUD 9600u #endif #define SERLOSS_BYTES 4096u #define IDLE_TICKS 120u // done once streaming has been silent for 2 s #define START_TICKS 3600u // give up if nothing arrives in 60 s #define READ_CHUNK 64u #define BENCH_FRAMES 60u // fills (+ presents) timed before the port opens #define SCREEN_W 320 #define SCREEN_H 200 // SERLOSS_SFX: a streamed effect on one slot plus a one-shot retriggered once // per frame on another. The stream rate sets how often the replayer's DOC // interrupt refills it (every 256 samples). The stream is raw (DOC-ready, // 0x00-free bytes) and filled by jlMemCopy, as Space Taxi's speech is, so // producing it costs the loop next to nothing. #define SFX_STREAM_SLOT 4u #define SFX_STREAM_RATE 22050u #define SFX_SHOT_SLOT 3u #define SFX_SHOT_RATE 11025u #define SFX_SHOT_BYTES 2048u #define SFX_SHOT_PERIOD 32u // one-shot square wave period, in samples #define SFX_LEVEL 96 // one-shot square amplitude #define SFX_WAVE_BYTES 256u // one period of the stream's sawtooth // Slot I/O select: bit n routes slot n's $Cn00 space and I/O to the card rather // than the built-in port (slot 2 defaults to the internal modem port, which // shadows a card's 6551 at $C0A8). #define SLTROMSEL ((volatile uint8_t *)0x00C02DL) extern uint16_t jlgIigsRxOverruns; extern uint16_t jlgIigsRxDropped; #ifdef SERLOSS_LOOSE extern void jlIigsIrqLatencyRelax(void); #endif #ifdef SERLOSS_JOY typedef struct { uint16_t polls; int8_t minX; int8_t maxX; int8_t minY; int8_t maxY; } JoySpreadT; #endif #ifdef SERLOSS_SFX static int8_t gSfxShot[SFX_SHOT_BYTES]; static uint8_t gSfxWave[SFX_WAVE_BYTES]; static uint16_t gSfxFills = 0u; #endif static uint16_t benchLoop(jlSurfaceT *stage, bool present); #ifdef SERLOSS_JOY static void joyPoll(JoySpreadT *spread); #endif static void mailbox16(uint16_t slot, uint16_t value); static void publish(uint16_t received, uint16_t inSequence, uint16_t breaks, uint16_t presents, uint16_t ticks, uint16_t sum, uint16_t now); #ifdef SERLOSS_SFX static void sfxStart(void); static uint32_t sfxStreamFill(void *ctx, int8_t *dst, uint32_t count); #endif // Ticks for BENCH_FRAMES full-stage fills, each followed by a present if asked: the difference // between the two runs is what a full-screen present costs. static uint16_t benchLoop(jlSurfaceT *stage, bool present) { uint16_t start; uint16_t frame; start = jlFrameCount(); for (frame = 0u; frame < BENCH_FRAMES; frame++) { jlFillRect(stage, 0, 0, SCREEN_W, SCREEN_H, (uint8_t)(frame & 0x0Fu)); if (present) { jlStagePresent(); } } return (uint16_t)(jlFrameCount() - start); } #ifdef SERLOSS_JOY // One input poll (the joystick read is the long part), folded into the spread and published. The // stick was calibrated before the loop, so a still stick reads 0 and anything else is jitter. static void joyPoll(JoySpreadT *spread) { int8_t x; int8_t y; jlInputPoll(); x = jlJoystickX(JL_JOYSTICK_0); y = jlJoystickY(JL_JOYSTICK_0); if (spread->polls == 0u || x < spread->minX) { spread->minX = x; } if (spread->polls == 0u || x > spread->maxX) { spread->maxX = x; } if (spread->polls == 0u || y < spread->minY) { spread->minY = y; } if (spread->polls == 0u || y > spread->maxY) { spread->maxY = y; } spread->polls++; mailbox16(MB_JOY_POLLS, spread->polls); MB_BASE[MB_JOY_MIN_X] = (uint8_t)(spread->minX + MB_JOY_BIAS); MB_BASE[MB_JOY_MAX_X] = (uint8_t)(spread->maxX + MB_JOY_BIAS); MB_BASE[MB_JOY_MIN_Y] = (uint8_t)(spread->minY + MB_JOY_BIAS); MB_BASE[MB_JOY_MAX_Y] = (uint8_t)(spread->maxY + MB_JOY_BIAS); } #endif static void mailbox16(uint16_t slot, uint16_t value) { MB_BASE[slot] = (uint8_t)(value & 0xFFu); MB_BASE[slot + 1u] = (uint8_t)(value >> 8); } __attribute__((noinline)) static void publish(uint16_t received, uint16_t inSequence, uint16_t breaks, uint16_t presents, uint16_t ticks, uint16_t sum, uint16_t now) { mailbox16(MB_RECEIVED, received); mailbox16(MB_IN_SEQUENCE, inSequence); mailbox16(MB_BREAKS, breaks); mailbox16(MB_PRESENTS, presents); mailbox16(MB_TICKS, ticks); mailbox16(MB_OVERRUNS, jlgIigsRxOverruns); mailbox16(MB_DROPPED, jlgIigsRxDropped); mailbox16(MB_SUM, sum); mailbox16(MB_NOW, now); } #ifdef SERLOSS_SFX // Build the one-shot's square wave and the stream's sawtooth, and start the endless stream // (jlInit started audio). static void sfxStart(void) { uint16_t i; for (i = 0u; i < SFX_SHOT_BYTES; i++) { gSfxShot[i] = (int8_t)(((i / (SFX_SHOT_PERIOD / 2u)) & 1u) != 0u ? SFX_LEVEL : -SFX_LEVEL); } for (i = 0u; i < SFX_WAVE_BYTES; i++) { gSfxWave[i] = (uint8_t)(i == 0u ? 1u : i); // 0x00 would stop the DOC } // The first trigger of a sample converts it for the DOC (~15 ms for this one); do it before // the port opens, so the loop's triggers are the cheap repeats and a slow first frame cannot // overflow the receive ring. jlAudioPlaySfx((uint8_t)SFX_SHOT_SLOT, (const uint8_t *)gSfxShot, SFX_SHOT_BYTES, SFX_SHOT_RATE); jlAudioPlaySfxStreamRaw((uint8_t)SFX_STREAM_SLOT, sfxStreamFill, (void *)0, SFX_STREAM_RATE); } // Endless sawtooth, whole periods: never returns 0, so the stream runs until the program ends. // Only a running engine asks for refills, so their count proves audio is up. static uint32_t sfxStreamFill(void *ctx, int8_t *dst, uint32_t count) { uint32_t done; (void)ctx; gSfxFills++; mailbox16(MB_SFX_FILLS, gSfxFills); done = 0u; while (count - done >= SFX_WAVE_BYTES) { jlMemCopy(dst + done, gSfxWave, SFX_WAVE_BYTES); done += SFX_WAVE_BYTES; } return done; } #endif int main(void) { static const char ready[] = "READY\r\n"; jlConfigT config; jlSerialConfigT cfg; jlSurfaceT *stage; uint8_t buf[READ_CHUNK]; uint16_t received; uint16_t inSequence; uint16_t breaks; uint16_t presents; uint16_t sum; uint16_t lastTick; uint16_t firstTick; uint16_t now; uint16_t got; uint16_t i; uint16_t n; uint8_t expect; uint8_t color; bool started; #if defined(SERLOSS_JOY) || defined(SERLOSS_SFX) uint16_t frameTick; #endif #ifdef SERLOSS_JOY JoySpreadT spread; #endif #ifdef SERLOSS_SFX uint16_t shots; #endif config.codegenBytes = 0u; #ifdef SERLOSS_SFX config.noAudio = false; #else config.noAudio = true; #endif if (!jlInit(&config)) { MB_BASE[MB_PHASE] = PHASE_FAILED; for (;;) { } } // Signed only after jlInit, which rewrites the SCB page this mailbox shares. MB_BASE[MB_SIG_0] = MB_SIG_VALUE_0; MB_BASE[MB_SIG_1] = MB_SIG_VALUE_1; MB_BASE[MB_PHASE] = PHASE_INIT; mailbox16(MB_JOY_POLLS, 0u); mailbox16(MB_SFX_SHOTS, 0u); mailbox16(MB_SFX_FILLS, 0u); stage = jlStageGet(); mailbox16(MB_FILL_TICKS, benchLoop(stage, false)); mailbox16(MB_PRESENT_TICKS, benchLoop(stage, true)); #ifdef SERLOSS_SFX sfxStart(); shots = 0u; #endif cfg.baud = SERLOSS_BAUD; cfg.dataBits = 8u; cfg.stopBits = 1u; cfg.parity = JL_SERIAL_PARITY_NONE; cfg.flow = JL_SERIAL_FLOW_NONE; #ifdef SERLOSS_SLOT cfg.unit = SERLOSS_SLOT; *SLTROMSEL = (uint8_t)(*SLTROMSEL | (uint8_t)(1u << SERLOSS_SLOT)); started = jlSerialOpen(JL_SERIAL_SLOT, &cfg); #else cfg.unit = 0u; started = jlSerialOpen(JL_SERIAL_MODEM, &cfg); #endif if (!started) { MB_BASE[MB_PHASE] = PHASE_FAILED; for (;;) { } } #ifdef SERLOSS_LOOSE jlIigsIrqLatencyRelax(); #endif #ifdef SERLOSS_JOY // Calibrate on the first poll, in the latency mode the rest will run in, so a still stick // reads 0 from then on. jlJoystickReset(JL_JOYSTICK_0, 0u); spread.polls = 0u; joyPoll(&spread); #endif MB_BASE[MB_PHASE] = PHASE_OPEN; (void)jlSerialWrite((const uint8_t *)ready, (uint16_t)(sizeof(ready) - 1u)); MB_BASE[MB_PHASE] = PHASE_READY; received = 0u; inSequence = 0u; breaks = 0u; presents = 0u; sum = 0u; expect = 0u; color = 1u; started = false; firstTick = jlFrameCount(); lastTick = firstTick; now = firstTick; #if defined(SERLOSS_JOY) || defined(SERLOSS_SFX) frameTick = firstTick; #endif for (;;) { // Published at the top so every exit below is a bare compare-and-branch: llvm816 has // been seen hoisting this function's byte splits between a cmp and its branch, // clobbering the flags the branch reads (see the report). publish(received, inSequence, breaks, presents, (uint16_t)(lastTick - firstTick), sum, now); if (received >= SERLOSS_BYTES) { break; } #if defined(SERLOSS_CLEAR) jlSurfaceClear(stage, color); jlStagePresent(); #elif !defined(SERLOSS_IDLE) jlFillRect(stage, 0, 0, SCREEN_W, SCREEN_H, color); jlStagePresent(); #endif presents++; color = (uint8_t)((color & 0x0Fu) + 1u); #if defined(SERLOSS_JOY) || defined(SERLOSS_SFX) // Once per frame, like a game: an idle loop would otherwise run these back to back. if (jlFrameCount() != frameTick) { frameTick = jlFrameCount(); #ifdef SERLOSS_JOY joyPoll(&spread); #endif #ifdef SERLOSS_SFX jlAudioPlaySfx((uint8_t)SFX_SHOT_SLOT, (const uint8_t *)gSfxShot, SFX_SHOT_BYTES, SFX_SHOT_RATE); jlAudioFrameTick(); shots++; mailbox16(MB_SFX_SHOTS, shots); #endif } #endif jlSerialPoll(); got = 0u; n = jlSerialRead(buf, READ_CHUNK); while (n > 0u) { for (i = 0u; i < n; i++) { if (buf[i] == expect) { inSequence++; } else { breaks++; } expect = (uint8_t)(buf[i] + 1u); sum = (uint16_t)(sum + buf[i]); } got = (uint16_t)(got + n); n = jlSerialRead(buf, READ_CHUNK); } received = (uint16_t)(received + got); now = jlFrameCount(); if (got > 0u) { if (!started) { started = true; firstTick = now; MB_BASE[MB_PHASE] = PHASE_RECEIVING; } lastTick = now; } if (started) { if ((uint16_t)(now - lastTick) > IDLE_TICKS) { break; } } else if ((uint16_t)(now - firstTick) > START_TICKS) { break; } } publish(received, inSequence, breaks, presents, (uint16_t)(lastTick - firstTick), sum, now); MB_BASE[MB_PHASE] = PHASE_DONE; for (;;) { } }