diff --git a/midi2taud.py b/midi2taud.py index 44de519..620272c 100644 --- a/midi2taud.py +++ b/midi2taud.py @@ -6,6 +6,7 @@ Usage: [--perc-force-mapping BANK INST] [--rpb N] [--speed N] [--fadeout N] [--bend-epsilon CENTS] [--drum-keyoff] + [--loop] [--loop-at-eot] [-v] [--no-project-data] # Batch / directory mode (terranmon.txt:3342-3401): @@ -86,6 +87,24 @@ Behaviour (per midi2taud.md): into whole-bar cues (the largest multiple of its bar length that fits in 64 rows) so the tracker's bar/beat highlighting (sMet beat divisions) lines up with the music. + * Looping. A MIDI that carries its own loop markers is ALWAYS made to loop at + those points (regardless of --loop); --loop additionally loops a marker-less + MIDI start-to-end. Recognised loop-marker conventions (case-insensitive, + first occurrence wins; resolved in this priority): + - FF 06 / FF 01 text STARTING with "loops" (loop start) / "loope" (end); + - CC #116 (loop start) + CC #117 (loop end); + - CC #110 (loop start) + CC #111 (loop end); + - CC #111 alone = loop START, loop end = End-of-Track (FF 2F 00). + A missing loop end defaults to End-of-Track. The loop is realised as a cue + jump: when it spans complete full-length cues from a cue boundary, the final + cue's HALT is replaced with a JMP back to the loop-start cue; otherwise an + in-pattern order jump (effect B → loop-start cue, plus effect C → row when + the loop start is mid-cue) is placed on the last looped row. Cues after the + loop end are dropped. + --loop (whole song) rounds its loop-end UP to the next bar line by default, + so the loop seam stays on the beat grid (and usually lands on a full cue → a + clean JMP); --loop-at-eot loops exactly at End-of-Track instead. Bar rounding + never applies to explicit MIDI loop markers — those loop verbatim. """ import argparse @@ -103,12 +122,13 @@ from taud_common import ( SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT, PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, NOTE_NOP, NOTE_KEYOFF, NOTE_FASTFADE, TAUD_C4, - TOP_G, TOP_M, TOP_S, TOP_T, + TOP_B, TOP_C, TOP_G, TOP_M, TOP_S, TOP_T, SEL_SET, SEL_FINE, CUE_INST_NOP, CUE_INST_HALT, resample_linear, encode_cue, deduplicate_patterns, encode_song_entry, compress_blob, build_project_data, cue_instruction_len, - cue_instruction_halt_at, last_note_cue_index, nearest_minifloat, + cue_instruction_halt_at, cue_instruction_jump, + last_note_cue_index, nearest_minifloat, IXMP_PAN_NO_OVERRIDE, atten_cb_to_octet, ) @@ -172,6 +192,15 @@ def _parse_track(data: bytes, pos: int, end: int) -> list: txt = payload.decode('latin-1', errors='replace').strip() if txt: evs.append((tick, ('title', txt))) + elif mtype in (0x01, 0x06): # text (0x01) / marker (0x06) + # Loop points by text convention: a marker/text whose ASCII + # STARTS with "loops" / "loope" (case-insensitive) — see the + # loop-convention list in the module docstring. + tag = payload.decode('latin-1', errors='replace').strip().lower() + if tag.startswith('loops'): + evs.append((tick, ('loopstart',))) + elif tag.startswith('loope'): + evs.append((tick, ('loopend',))) elif mtype == 0x58 and ln >= 2: # time signature (FF 58 04 nn dd cc bb) # nn = numerator, dd = denominator as a negative power of 2 # (2 = quarter, 3 = eighth). cc/bb (clocks-per-click, 32nds-per- @@ -314,7 +343,35 @@ def _curve_push(fts: list, vals: list, ft: int, val): class Song: __slots__ = ('notes', 'channels', 'tempo_ft', 'tempo_bpm', 'title', 'end_ft', - 'timesig_ft', 'timesig') + 'timesig_ft', 'timesig', 'loop_start_ft', 'loop_end_ft', 'eot_ft') + + +# CC numbers used as loop start / end markers by various sequencers (see the +# loop-convention list in the module docstring). Values are ignored — only the +# tick matters. +CC_LOOP_START_A = 110 # 0x6E (paired with CC 111 as end) +CC_LOOP_END_A = 111 # 0x6F (also a loop-START when 110 is absent → end = EoT) +CC_LOOP_START_B = 116 # 0x74 +CC_LOOP_END_B = 117 # 0x75 + + +def _resolve_loop(text_start, text_end, cc, eot_ft, max_ft): + """Resolve the song's loop region (start_ft, end_ft) from the collected loop + markers, or (None, None) when the MIDI defines none. `cc` maps each loop CC + number to its first occurrence ft. Priority: text markers > CC 116/117 > + CC 110/111 > CC 111-only (RPG-Maker style; loop-end = End-of-Track). An + absent end falls back to End-of-Track (or the last event when no EoT).""" + end_default = eot_ft if eot_ft is not None else max_ft + if text_start is not None or text_end is not None: + return (text_start if text_start is not None else 0, + text_end if text_end is not None else end_default) + if CC_LOOP_START_B in cc: + return cc[CC_LOOP_START_B], cc.get(CC_LOOP_END_B, end_default) + if CC_LOOP_START_A in cc: # 110 present ⇒ 111 is the end + return cc[CC_LOOP_START_A], cc.get(CC_LOOP_END_A, end_default) + if CC_LOOP_END_A in cc: # 111 alone ⇒ it is the start + return cc[CC_LOOP_END_A], end_default + return None, None def extract_song(division, merged, rpb: int, speed: int) -> Song: @@ -340,6 +397,9 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song: timesig_ft, timesig = [], [] # ft → (numerator, denom_power) title = None max_ft = 0 + loop_text_start = loop_text_end = None # FF 06/01 "loops"/"loope" ft + loop_cc = {} # loop CC number → first occurrence ft + eot_ft = None # latest End-of-Track ft (loop-end fallback) def end_note(n: Note, ft: int): if n.end_ft is None: @@ -386,6 +446,9 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song: elif kind == 'cc': _, ch, num, val = ev st = chs[ch] + if num in (CC_LOOP_START_A, CC_LOOP_END_A, + CC_LOOP_START_B, CC_LOOP_END_B): + loop_cc.setdefault(num, ft) # first occurrence wins if num == 0: st.bank = val elif num == 7: @@ -449,6 +512,16 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song: if title is None: title = ev[1] + elif kind == 'loopstart': + if loop_text_start is None: + loop_text_start = ft + elif kind == 'loopend': + if loop_text_end is None: + loop_text_end = ft + elif kind == 'eot': + if eot_ft is None or ft > eot_ft: + eot_ft = ft + # Close anything still ringing at end-of-file. for st in chs: for n in list(st.active.values()): @@ -473,6 +546,9 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song: song.timesig = timesig song.title = title song.end_ft = max_ft + song.eot_ft = eot_ft + song.loop_start_ft, song.loop_end_ft = _resolve_loop( + loop_text_start, loop_text_end, loop_cc, eot_ft, max_ft) return song @@ -2285,6 +2361,22 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int, total_rows = max(r for (_v, r) in cells) + 1 + # Anchor the song's length to the MIDI End-of-Track, NOT to the last + # surviving note. Preset resolution drops notes whose SoundFont preset is + # missing, so a sparse bank can drop the trailing notes and silently shorten + # the song (E2M1: 3453 rows on a full GM bank vs 3449 when the last notes are + # dropped) — the cue layout and final HALT then depend on the SoundFont. EoT + # is a fixed MIDI property, so the row count is the same for every bank: the + # dropped notes' time becomes trailing silence instead of vanishing. `max` + # with the last trigger row still honours a note that (pathologically) starts + # past EoT. The EoT row itself is the end-of-song boundary (exclusive), so a + # final key-off landing exactly on it is dropped (the HALT cuts the note), + # which also avoids the lone-key-off terminus cue the trim below handled. + if song.eot_ft is not None: + eot_row = (song.eot_ft - shift_ft) // speed + last_trigger = max((n.start_ft // speed for n in notes), default=0) + total_rows = max(eot_row, last_trigger + 1) + # ── Pass 5: T tempo changes ── bpm0 = midi_bpm_at(shift_ft) # tempo in effect at row 0 last = taud_bpm(bpm0) @@ -2330,6 +2422,23 @@ def emit_cells(song: Song, insts: dict, speed: int, rpb: int, # ── Pattern / cue emission and final assembly ──────────────────────────────── +def _bar_align_up(row: int, timesig_ft: list, timesig: list, + shift_ft: int, speed: int, rpb: int) -> int: + """Smallest bar-line row >= `row`, using the time signature of the section + `row` falls in (sections start at time-sig changes, as in plan_cues). A row + already on a bar line is returned unchanged. Used to bar-align the --loop + whole-song loop-end so the loop seam lands on a bar (and a full cue).""" + def bar_rows_of(r): + ft = r * speed + shift_ft + i = bisect.bisect_right(timesig_ft, ft) - 1 + num, dpow = timesig[i] if i >= 0 else (4, 2) + return max(1, round(num * 4.0 / (2 ** dpow) * rpb)) + breaks = sorted({0} | {(ft - shift_ft) // speed for ft in timesig_ft}) + sec_start = max(b for b in breaks if b <= row) + br = bar_rows_of(sec_start) + return sec_start + ((row - sec_start + br - 1) // br) * br + + def plan_cues(timesig_ft: list, timesig: list, total_rows: int, shift_ft: int, speed: int, rpb: int) -> tuple: """Plan the cue layout: break a cue at every time-signature change, and pack @@ -2397,6 +2506,79 @@ def build_pattern_bin(cells: dict, n_voices: int, return bytes(out) +def _inject_loop(pat_bin: bytes, cue_starts: list, cue_lens: list, n_voices: int, + rs: int, re: int) -> tuple: + """Make the song loop from row `re` (exclusive) back to row `rs`. + + Returns (pat_bin, cue_starts, cue_lens, jump_instr). When the loop covers + complete full-length cues from a cue boundary, `jump_instr` is the 2-byte JMP + cue instruction to place on the (new) last cue — the clean "replace HALT with + JMP000" case. Otherwise an in-pattern order jump (effect B → cue cs, plus + effect C → row when the loop-start is mid-cue) is written on the last looped + row and `jump_instr` is None. Cues after the loop-end are unreachable once we + loop, so they are dropped.""" + n_cues = len(cue_starts) + song_end_row = cue_starts[-1] + cue_lens[-1] + rs = max(0, min(rs, song_end_row - 1)) + re = max(rs + 1, min(re, song_end_row)) + last_played = re - 1 + + def cue_of(row): + for ci in range(n_cues): + if cue_starts[ci] <= row < cue_starts[ci] + cue_lens[ci]: + return ci + return n_cues - 1 + cs = cue_of(rs) + ce = cue_of(last_played) + rs_off = rs - cue_starts[cs] + re_off = last_played - cue_starts[ce] + + # Drop the now-unreachable tail cues (after the loop-end cue). + if ce < n_cues - 1: + cue_starts = cue_starts[:ce + 1] + cue_lens = cue_lens[:ce + 1] + pat_bin = pat_bin[:(ce + 1) * n_voices * PATTERN_BYTES] + n_cues = ce + 1 + + # Clean whole-cue case: loop-start on a cue boundary AND loop-end on the last + # row of a FULL 64-row cue. A cue-level JMP fires at the cue's full-pattern + # end, which only lines up here; partial / mid-pattern loops use effect B. + if rs_off == 0 and re_off == cue_lens[ce] - 1 and cue_lens[ce] == PATTERN_ROWS: + return pat_bin, cue_starts, cue_lens, cue_instruction_jump(cs) + + buf = bytearray(pat_bin) + + def free_voice(skip): + # Prefer a fully-empty cell, then any cell without an effect. + for want_empty in (True, False): + for v in range(n_voices): + if v in skip: + continue + off = (ce * n_voices + v) * PATTERN_BYTES + re_off * 8 + if buf[off + 5] != 0: + continue + if want_empty and (buf[off] != 0 or buf[off + 1] != 0): + continue + return v, off + return None, None + + vb, ob = free_voice(set()) + if vb is None: # every cell on this row already has an effect + vb, ob = 0, (ce * n_voices) * PATTERN_BYTES + re_off * 8 + vprint(" warning: loop-end row crowded — overwriting an effect with the B jump") + buf[ob + 5] = TOP_B + struct.pack_into(' 0: + vc, oc = free_voice({vb}) + if vc is None: + vprint(" warning: no free slot for the C row-jump — looping to the " + "loop-start cue's first row instead") + else: + buf[oc + 5] = TOP_C + struct.pack_into(' dict: """Per-song pattern/cue build shared by the full .taud and the .tpif paths. @@ -2422,6 +2604,18 @@ def build_song_section(song: Song, speed: int, rpb: int, src_path: str, song, None, speed, rpb, eps_units, args.drum_keyoff, shift_ft, args.max_voices) + # --loop (whole song, no MIDI markers) bar-aligns its loop-end by DEFAULT: + # extend the song to the next bar line so the loop seam stays on the beat grid + # (and lands on a full cue → a clean cue-level JMP). --loop-at-eot opts out, + # looping exactly at End-of-Track. The extension is silent padding past EoT; + # MIDI loop markers are always honoured verbatim, never bar-rounded. + if args.loop and song.loop_start_ft is None and not args.loop_at_eot: + aligned = _bar_align_up(total_rows, song.timesig_ft, song.timesig, + shift_ft, speed, rpb) + if aligned != total_rows: + vprint(f" loop: bar-aligning song end {total_rows} → {aligned} rows") + total_rows = aligned + # Cue layout: break at time-signature changes, pack into whole-bar cues. cue_starts, cue_lens, init_bar_rows = plan_cues( song.timesig_ft, song.timesig, total_rows, shift_ft, speed, rpb) @@ -2436,12 +2630,29 @@ def build_song_section(song: Song, speed: int, rpb: int, src_path: str, pat_bin = build_pattern_bin(cells, n_voices, cue_starts, cue_lens) + # The song length is anchored to End-of-Track (emit_cells), so when the MIDI + # carries an EoT the last cue IS the EoT cue: keep the whole span (n_cues-1 + # floor) so the trailing-rest trim can't shorten it — dropped trailing notes + # must stay as silence, not vanish, or the length would again depend on the + # SoundFont. Without an EoT, fall back to the note-based trim; an explicit + # loop-end marker still floors it at the cue holding the loop-end row (the + # marker may sit in a trailing rest before EoT). + keep_floor = 0 + if song.eot_ft is not None: + keep_floor = n_cues - 1 + elif song.loop_end_ft is not None: + end_row = max(0, (song.loop_end_ft - shift_ft) // speed - 1) + for ci in range(n_cues): + if cue_starts[ci] <= end_row < cue_starts[ci] + cue_lens[ci]: + keep_floor = ci + break + # Trim trailing note-free cues: the MIDI release pass emits a final cue that # is just key-offs (and the silence after the song's last note), which shows # up as a dead bar at the end (e.g. M_E1M1's lone-key-off terminus cue). Drop # whole cues with no actual note; the new last cue then HALTs at its own # length. Special notes (key-off/cut/fade) are not notes here. - last_cue = last_note_cue_index(pat_bin, n_cues, n_voices) + last_cue = max(last_note_cue_index(pat_bin, n_cues, n_voices), keep_floor) if 0 <= last_cue < n_cues - 1: dropped = n_cues - 1 - last_cue n_cues = last_cue + 1 @@ -2450,6 +2661,33 @@ def build_song_section(song: Song, speed: int, rpb: int, src_path: str, pat_bin = pat_bin[:n_cues * n_voices * PATTERN_BYTES] vprint(f" info: trimmed {dropped} trailing note-free cue(s)") + # Looping. MIDI loop markers (loops/loope text, CC 110/111/116/117) ALWAYS + # convert to a pattern jump; the --loop flag additionally loops a marker-less + # MIDI start-to-end. Markers win when both are present. + loop_jump = None + song_end_row = cue_starts[-1] + cue_lens[-1] + if song.loop_start_ft is not None: + rs = (song.loop_start_ft - shift_ft) // speed + re = (song.loop_end_ft - shift_ft) // speed + loop_src = "MIDI loop marker" + elif args.loop: + rs, re = 0, song_end_row + loop_src = "--loop (whole song)" + else: + rs = re = None + if rs is not None: + rs = max(0, min(rs, song_end_row - 1)) + re = min(re, song_end_row) + if re <= rs: + vprint(f" warning: {loop_src} loop region empty after clamping — not looping") + else: + pat_bin, cue_starts, cue_lens, loop_jump = _inject_loop( + pat_bin, cue_starts, cue_lens, n_voices, rs, re) + n_cues = len(cue_starts) + how = "JMP cue jump" if loop_jump is not None else "in-pattern B jump" + vprint(f" loop: {loop_src} → rows [{rs}, {re}) via {how} " + f"({n_cues} cue(s) after loop trim)") + pat_bin, remap, n_unique = deduplicate_patterns(pat_bin, n_cues * n_voices) n_breaks = sum(1 for ft in song.timesig_ft if 0 < (ft - shift_ft) // speed < total_rows) @@ -2463,9 +2701,11 @@ def build_song_section(song: Song, speed: int, rpb: int, src_path: str, for ci in range(n_cues): pats = [remap[ci * n_voices + v] for v in range(n_voices)] if ci == n_cues - 1: - # Halt after this cue's own length (a partial final bar plays only its - # rows instead of the full 64-row pattern). - instr = cue_instruction_halt_at(cue_lens[ci]) + # Loop back via a cue-level JMP when the loop is whole-cue clean; else + # halt after this cue's own length (an in-pattern B jump, if any, fires + # first, so the HALT is just the never-reached safety terminus). + instr = loop_jump if loop_jump is not None \ + else cue_instruction_halt_at(cue_lens[ci]) elif cue_lens[ci] < PATTERN_ROWS: instr = cue_instruction_len(cue_lens[ci]) else: @@ -2712,6 +2952,18 @@ def main(): ap.add_argument('--drum-keyoff', action='store_true', help='Emit KEY_OFF for percussion-channel notes too ' '(GM drums normally ignore note-off)') + ap.add_argument('--loop', action='store_true', + help='Loop a non-looping MIDI start-to-end (replace the final ' + 'HALT with a jump back to the first cue). The loop-end is ' + 'rounded up to the next BAR LINE by default so the loop ' + 'seam stays on the beat (use --loop-at-eot to loop exactly ' + 'at End-of-Track instead). MIDIs that carry their own loop ' + 'markers (loops/loope text, or CC 110/111/116/117) are ' + 'ALWAYS looped at those points regardless of this flag') + ap.add_argument('--loop-at-eot', action='store_true', + help='With --loop: loop precisely at End-of-Track instead of ' + 'rounding the loop-end up to a full bar (no effect without ' + '--loop, or when the MIDI has its own loop markers)') ap.add_argument('--force-synth-loop', action='store_true', help='For looped samples whose loop sits past the 65535-frame ' 'cap even at 32 kHz (multi-second far-loop instruments, ' diff --git a/taud_common.py b/taud_common.py index cd073c2..0d027b6 100644 --- a/taud_common.py +++ b/taud_common.py @@ -388,6 +388,18 @@ def cue_instruction_halt_at(rows: int) -> tuple: return (CUE_INST_HALT, 0x40 | (rows & 0x3F)) +def cue_instruction_jump(cue: int) -> tuple: + """Build the 2-byte JMP cue instruction (terranmon.txt §"Cue Sheet"). + + Go to absolute cue `cue` (0..4095) when this cue finishes its full pattern — + the engine never halts here, so it loops. Encoding is byte30 = 0b1111xxxx + (high nybble of the 12-bit cue) and byte31 = low 8 bits. + """ + if not 0 <= cue <= 0xFFF: + raise ValueError(f"JMP cue target must be 0..4095, got {cue}") + return (0xF0 | ((cue >> 8) & 0x0F), cue & 0xFF) + + def last_note_cue_index(pat_bin: bytes, num_cues: int, num_channels: int) -> int: """Index of the last cue holding an *actual* note, or -1 if none. diff --git a/terranmon.txt b/terranmon.txt index 06fbaeb..285a11e 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -256,7 +256,24 @@ MMIO 0b 0000 00rc (r: TTY Raw mode, c: Cursor blink) 7 RW Graphics-mode attributes - 0b 0000 rrrr (r: Resolution/colour depth) + 0b 00ii 000t (t: disable text, i: interlaced mode) + + When interlace is enabled (i > 0), the layers are overlaind in the checkerboard pattern, allowing blending. + + On graphics mode 2, the pattern is: + [L1 L2] + [L2 L1] + for all nonzero i. + + On graphics mode 3, the pattern is: + i=1 [L1 L2] + [L2 L1] (where L1: mid-low layer, L2: mid-high layer; low-layer is drawn behind, high-layer is drawn over) + i=2 [L1 L2] + [L3 L1] (where L1: low layer, L2: mid-low layer, L3: mid-high layer; high-layer is always drawn over) + i=3 [L1 L2] + [L3 L4] + + On the other graphics modes, the flag has no effect and always zero when read 8 RO Last used colour (set by poking at the framebuffer) 9 RW @@ -266,17 +283,18 @@ MMIO 11 RO Number of Banks, or VRAM size (1 = 256 kB, max 4) 12 RW - Graphics Mode - 0: 560x448, 256 Colours, 1 layer - 1: 280x224, 256 Colours, 4 layers - 2: 280x224, 4096 Colours, 2 layers - 3: 560x448, 256 Colours, 2 layers (if bank 2 is not installed, mode change will not happen) - 4: 560x448, 4096 Colours, 1 layer (if bank 2 is not installed, mode change will not happen) - 5: 560x448, 15-bit colour, 1 layer (if bank 2 is not installed, mode change will not happen) - 8: 560x448, 24-bit colour, 1 layer (if bank 3 and 4 are not installed, mode change will not happen) - 4096 is also known as "direct colour mode" (4096 colours * 16 transparency -> 65536 colours) - Two layers are grouped to make a frame, "low layer" contains RG colours and "high layer" has BA colours, - Red and Blue occupies MSBs + 0b 0000 mmmm, where: + mmmm: Graphics Mode + 0: 560x448, 256 Colours, 1 layer + 1: 280x224, 256 Colours, 4 layers + 2: 280x224, 4096 Colours, 2 layers + 3: 560x448, 256 Colours, 2 layers (if bank 2 is not installed, mode change will not happen) + 4: 560x448, 4096 Colours, 1 layer (if bank 2 is not installed, mode change will not happen) + 5: 560x448, 15-bit colour, 1 layer (if bank 2 is not installed, mode change will not happen) + 8: 560x448, 24-bit colour, 1 layer (if bank 3 and 4 are not installed, mode change will not happen) + 4096 is also known as "direct colour mode" (4096 colours * 16 transparency -> 65536 colours) + Two layers are grouped to make a frame, "low layer" contains RG colours and "high layer" has BA colours, + Red and Blue occupies MSBs 13 RW Layer Arrangement If 4 layers are used: @@ -2879,7 +2897,8 @@ TODO: detaching, mirroring the existing active-branch. parent.keyOff survives deactivation and is reset on retrigger, so a true value means this note was released. A parent that ended naturally (no release) still leaves the child to finish on its own. - [ ] Some ways to decouple Sample+Inst and patterns into separate files (tsvm-doom needs separate file access; samplepack can be uploaded once on init) + [x] Some ways to decouple Sample+Inst and patterns into separate files (tsvm-doom needs separate file access; samplepack can be uploaded once on init) + [ ] The same VT-aware patch thing for all fullscreen apps, possibly made simple by only requiring one row of simple code TODO - list of demo songs that MUST ship with Microtone: * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes @@ -3028,7 +3047,7 @@ Play Head Flags Byte 31..32: instruction 1000xxxx yyyyyyyy (BAK000) - Go back 0bxxxxyyyyyyyy patterns 1001xxxx yyyyyyyy (FWD000) - Skip forward 0bxxxxyyyyyyyy patterns - 1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy + 1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy (decoded by AudioAdapter as of 2026-06-15; emitted by midi2taud --loop / loop-marker conversion to jump back to the loop-start cue) 00000010 00xxxxxx (LEN 00) - Pattern length for this cue (0..63), where 0: 1 row, 63: 64 rows (decoded by AudioAdapter as of 2026-05-05; emitted by xm2taud / it2taud for non-multiple-of-64 source patterns) 00000001 00000000 - Halt (HALT ) - Play the full length of the pattern then stop the playback 00000001 01xxxxxx - Halt at x (HALT00) - Play the specified length of the pattern then stop the playback. x = 0 is identical to regular HALT. (decoded by AudioAdapter as of 2026-06-14; emitted as the final cue by midi2taud / it2taud / xm2taud so a partial last bar halts at its own length) diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 10adc2b..b4c81d6 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -3852,6 +3852,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { ts.cuePos = when (instr) { is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0) is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023) + is PlayInstJump -> instr.arg.coerceIn(0, 1023) else -> (ts.cuePos + 1).coerceAtMost(1023) } playhead.position = ts.cuePos @@ -4113,7 +4114,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { // 00000000 (NOP) default 64-row cue // 1000xxxx yyyyyyyy (BAK) go back 12-bit arg // 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg - // 1111xxxx yyyyyyyy (JMP) go to absolute pattern (currently unused) + // 1111xxxx yyyyyyyy (JMP) go to absolute cue (loop back to cue arg) private fun recomputeInstruction() { val b30 = instByte30 val b31 = instByte31 @@ -4130,7 +4131,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { (b30 and 0xF0) == 0x80 -> PlayInstGoBack(((b30 and 0xF) shl 8) or (b31 and 0xFF)) // FWD: 1001xxxx yyyyyyyy — 12-bit arg. (b30 and 0xF0) == 0x90 -> PlayInstSkip(((b30 and 0xF) shl 8) or (b31 and 0xFF)) - // JMP: 1111xxxx yyyyyyyy — reserved (decoder TBD). + // JMP: 1111xxxx yyyyyyyy — go to absolute cue 0bxxxxyyyyyyyy. + (b30 and 0xF0) == 0xF0 -> PlayInstJump(((b30 and 0xF) shl 8) or (b31 and 0xFF)) else -> PlayInstNop } } @@ -4176,6 +4178,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { internal open class PlayInstruction(val arg: Int) internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg) internal class PlayInstSkip(arg: Int) : PlayInstruction(arg) + /** "JMP": go to absolute cue [arg]. Used by looping converters to jump back + * to the loop-start cue (e.g. midi2taud's whole-song / loop-marker loop). */ + internal class PlayInstJump(arg: Int) : PlayInstruction(arg) internal class PlayInstPatLen(val rows: Int) : PlayInstruction(rows) /** "Halt at x": play [rows] rows of the pattern (1..64) then stop. */ internal class PlayInstHaltAt(val rows: Int) : PlayInstruction(rows) diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/GraphicsAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/GraphicsAdapter.kt index 62c8ab2..e879ea6 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/GraphicsAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/GraphicsAdapter.kt @@ -19,13 +19,11 @@ import net.torvald.terrarum.DefaultGL32Shaders import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint import net.torvald.tsvm.* import net.torvald.tsvm.peripheral.GraphicsAdapter.Companion.DRAW_SHADER_FRAG -import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.util.* import kotlin.experimental.and import kotlin.math.floor -import kotlin.math.min data class AdapterConfig( val theme: String, @@ -114,7 +112,7 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi override var blinkCursor = true override var ttyRawMode = false - private var graphicsUseSprites = false + private var graphicsDisableTexts = false private var lastUsedColour = (-1).toByte() private var currentChrRom = 0 private var chrWidth = 7f @@ -346,7 +344,7 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi ttyRawMode.toInt(1) or blinkCursor.toInt()).toByte() - private fun getGraphicsAttributes(): Byte = graphicsUseSprites.toInt().toByte() + private fun getGraphicsAttributes(): Byte = graphicsDisableTexts.toInt().toByte() private fun setTextmodeAttributes(rawbyte: Byte) { currentChrRom = rawbyte.toInt().and(0b11110000).ushr(4) @@ -355,7 +353,7 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi } private fun setGraphicsAttributes(rawbyte: Byte) { - graphicsUseSprites = rawbyte.and(1) == 1.toByte() + graphicsDisableTexts = rawbyte.and(1) == 1.toByte() } override fun mmio_read(addr: Long): Byte? { @@ -1248,7 +1246,7 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi outFBObatch.color = Color.WHITE - if (!graphicsUseSprites) { + if (!graphicsDisableTexts) { // draw texts val (cx, cy) = getCursorPos()