#!/usr/bin/env python3 # Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. # See LICENSE.md for license information. # SPDX-License-Identifier: MIT # # ntpIrqWindow.py SRC DST - bound NinjaTrackerPlus's interrupts-off stretches. # # NinjaTrackerPlus moves sample data into the DOC with interrupts masked, # ~4 us a byte (measured under MAME), and the Z8530 SCC's receive FIFO only # lasts ~3.1 ms at 9600 baud (~2.3 ms under MAME). Two stretches outlast it: # - NTPstreamsound fills a stream's 512-byte DOC page with two unrolled # 256-byte copies under its own SEI: ~2.4 ms. stream_window goes between # the halves: pending interrupts run there, then the DOC's mode and # address are put back for the second half. The stream's oscillators are # already halted, and its job is cleared first, so a refill interrupt left # pending by the slot's previous sound cannot stream into the page. # - The DOC interrupt handler refills every streaming oscillator that is # due before it returns, 256 bytes each: ~1 ms apiece, ~2.5 ms for two. # It returns after one instead; the DOC keeps its IRQ up for the rest, so # the handler is re-entered at once, after anything the ROM's interrupt # manager puts first (the SCC is on its fast path). # Both only while the word at offset 27 of the replayer (irq_window, right # after the documented jump table) is nonzero. audio_full.c sets it from # hal.c's tight interrupt-latency bound before every NTPstreamsound, so with # the bound loose the replayer does exactly what it always did. # # The source is the installer's pinned ntpsources.zip (checksummed), so # every anchor below is exact; a miss means NTP changed and this script # must be revisited, so it fails the build instead of guessing. import sys TABLE_END = """ jmp streamsound """ TABLE_END_NEW = """ jmp streamsound ; irq_window (JoeyLib, offset 27): nonzero bounds the interrupts-off ; stretches -- see stream_window and interrupt_handler_exit. irq_window dw 0 """ FILL = """ ldy streamsound_osc2 jsr stream_copy ;fill buffer with 512 bytes for first play jsr stream_copy_no_init """ FILL_NEW = """ ldy streamsound_osc2 jsr stream_copy ;fill buffer with 512 bytes for first play jsr stream_window ;JoeyLib: interrupts in between the halves, if asked jsr stream_copy_no_init """ COPY_HELPERS = """ mx %10 streamsound_copy = * ;copy a byte from stream structure to multiple doc registers """ COPY_HELPERS_NEW = """; stream_window (JoeyLib): between the two halves of streamsound's first ; DOC fill, when irq_window is nonzero, let pending interrupts run, then ; point the DOC back at the second half (a handler may have used it). ; In and out: mx %00, Y = the stream's track * 2. mx %00 stream_window = * lda irq_window beq stream_window_1 sep #$20 mx %10 ldx streamsound_osc ;no refill job left pending from the slot's last sound stz oscillator_job_tbl,x cli nop ;a pending interrupt is taken here sei jsr access_doc_ram rep #$20 mx %00 lda stream_doc_page,y ;the page's second half inc xba sta sound_address stream_window_1 = * rts mx %10 streamsound_copy = * ;copy a byte from stream structure to multiple doc registers """ HANDLER_JOB = """ ldx interrupt_job jsr (interrupt_jump_tbl,x) ]loop lda sound_control """ HANDLER_JOB_NEW = """ ldx interrupt_job jsr (interrupt_jump_tbl,x) lda irq_window ;JoeyLib: one oscillator an interrupt when asked; bne interrupt_handler_exit ;the DOC keeps its IRQ up for any others ]loop lda sound_control """ HANDLER_END = """ bpl interrupt_handler_loop sep #$30 """ HANDLER_END_NEW = """ bpl interrupt_handler_loop interrupt_handler_exit = * sep #$30 """ def patchOnce(text, old, new): if text.count(old) != 1: sys.exit("ntpIrqWindow: anchor not found exactly once -- NinjaTrackerPlus changed:\n" + old) return text.replace(old, new) def main(): if len(sys.argv) != 3: sys.exit("usage: ntpIrqWindow.py SRC DST") with open(sys.argv[1], "r", encoding="ascii") as f: text = f.read() text = patchOnce(text, TABLE_END, TABLE_END_NEW) text = patchOnce(text, FILL, FILL_NEW) text = patchOnce(text, COPY_HELPERS, COPY_HELPERS_NEW) text = patchOnce(text, HANDLER_JOB, HANDLER_JOB_NEW) text = patchOnce(text, HANDLER_END, HANDLER_END_NEW) with open(sys.argv[2], "w", encoding="ascii") as f: f.write(text) main()