mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-17 01:44:05 +09:00
midi2taud: loop point detection and forced-loop
This commit is contained in:
266
midi2taud.py
266
midi2taud.py
@@ -6,6 +6,7 @@ Usage:
|
|||||||
[--perc-force-mapping BANK INST]
|
[--perc-force-mapping BANK INST]
|
||||||
[--rpb N] [--speed N] [--fadeout N]
|
[--rpb N] [--speed N] [--fadeout N]
|
||||||
[--bend-epsilon CENTS] [--drum-keyoff]
|
[--bend-epsilon CENTS] [--drum-keyoff]
|
||||||
|
[--loop] [--loop-at-eot]
|
||||||
[-v] [--no-project-data]
|
[-v] [--no-project-data]
|
||||||
|
|
||||||
# Batch / directory mode (terranmon.txt:3342-3401):
|
# 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
|
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
|
rows) so the tracker's bar/beat highlighting (sMet beat divisions) lines up
|
||||||
with the music.
|
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
|
import argparse
|
||||||
@@ -103,12 +122,13 @@ from taud_common import (
|
|||||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
||||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||||
NOTE_NOP, NOTE_KEYOFF, NOTE_FASTFADE, TAUD_C4,
|
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,
|
SEL_SET, SEL_FINE,
|
||||||
CUE_INST_NOP, CUE_INST_HALT,
|
CUE_INST_NOP, CUE_INST_HALT,
|
||||||
resample_linear, encode_cue, deduplicate_patterns, encode_song_entry,
|
resample_linear, encode_cue, deduplicate_patterns, encode_song_entry,
|
||||||
compress_blob, build_project_data, cue_instruction_len,
|
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,
|
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()
|
txt = payload.decode('latin-1', errors='replace').strip()
|
||||||
if txt:
|
if txt:
|
||||||
evs.append((tick, ('title', 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)
|
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
|
# nn = numerator, dd = denominator as a negative power of 2
|
||||||
# (2 = quarter, 3 = eighth). cc/bb (clocks-per-click, 32nds-per-
|
# (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:
|
class Song:
|
||||||
__slots__ = ('notes', 'channels', 'tempo_ft', 'tempo_bpm', 'title', 'end_ft',
|
__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:
|
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)
|
timesig_ft, timesig = [], [] # ft → (numerator, denom_power)
|
||||||
title = None
|
title = None
|
||||||
max_ft = 0
|
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):
|
def end_note(n: Note, ft: int):
|
||||||
if n.end_ft is None:
|
if n.end_ft is None:
|
||||||
@@ -386,6 +446,9 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
|
|||||||
elif kind == 'cc':
|
elif kind == 'cc':
|
||||||
_, ch, num, val = ev
|
_, ch, num, val = ev
|
||||||
st = chs[ch]
|
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:
|
if num == 0:
|
||||||
st.bank = val
|
st.bank = val
|
||||||
elif num == 7:
|
elif num == 7:
|
||||||
@@ -449,6 +512,16 @@ def extract_song(division, merged, rpb: int, speed: int) -> Song:
|
|||||||
if title is None:
|
if title is None:
|
||||||
title = ev[1]
|
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.
|
# Close anything still ringing at end-of-file.
|
||||||
for st in chs:
|
for st in chs:
|
||||||
for n in list(st.active.values()):
|
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.timesig = timesig
|
||||||
song.title = title
|
song.title = title
|
||||||
song.end_ft = max_ft
|
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
|
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
|
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 ──
|
# ── Pass 5: T tempo changes ──
|
||||||
bpm0 = midi_bpm_at(shift_ft) # tempo in effect at row 0
|
bpm0 = midi_bpm_at(shift_ft) # tempo in effect at row 0
|
||||||
last = taud_bpm(bpm0)
|
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 ────────────────────────────────
|
# ── 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,
|
def plan_cues(timesig_ft: list, timesig: list, total_rows: int,
|
||||||
shift_ft: int, speed: int, rpb: int) -> tuple:
|
shift_ft: int, speed: int, rpb: int) -> tuple:
|
||||||
"""Plan the cue layout: break a cue at every time-signature change, and pack
|
"""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)
|
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('<H', buf, ob + 6, cs & 0xFFFF)
|
||||||
|
if rs_off > 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('<H', buf, oc + 6, rs_off & 0xFFFF)
|
||||||
|
return bytes(buf), cue_starts, cue_lens, None
|
||||||
|
|
||||||
|
|
||||||
def build_song_section(song: Song, speed: int, rpb: int, src_path: str,
|
def build_song_section(song: Song, speed: int, rpb: int, src_path: str,
|
||||||
args) -> dict:
|
args) -> dict:
|
||||||
"""Per-song pattern/cue build shared by the full .taud and the .tpif paths.
|
"""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,
|
song, None, speed, rpb, eps_units, args.drum_keyoff, shift_ft,
|
||||||
args.max_voices)
|
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 layout: break at time-signature changes, pack into whole-bar cues.
|
||||||
cue_starts, cue_lens, init_bar_rows = plan_cues(
|
cue_starts, cue_lens, init_bar_rows = plan_cues(
|
||||||
song.timesig_ft, song.timesig, total_rows, shift_ft, speed, rpb)
|
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)
|
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
|
# 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
|
# 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
|
# 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
|
# 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.
|
# 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:
|
if 0 <= last_cue < n_cues - 1:
|
||||||
dropped = n_cues - 1 - last_cue
|
dropped = n_cues - 1 - last_cue
|
||||||
n_cues = last_cue + 1
|
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]
|
pat_bin = pat_bin[:n_cues * n_voices * PATTERN_BYTES]
|
||||||
vprint(f" info: trimmed {dropped} trailing note-free cue(s)")
|
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)
|
pat_bin, remap, n_unique = deduplicate_patterns(pat_bin, n_cues * n_voices)
|
||||||
n_breaks = sum(1 for ft in song.timesig_ft
|
n_breaks = sum(1 for ft in song.timesig_ft
|
||||||
if 0 < (ft - shift_ft) // speed < total_rows)
|
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):
|
for ci in range(n_cues):
|
||||||
pats = [remap[ci * n_voices + v] for v in range(n_voices)]
|
pats = [remap[ci * n_voices + v] for v in range(n_voices)]
|
||||||
if ci == n_cues - 1:
|
if ci == n_cues - 1:
|
||||||
# Halt after this cue's own length (a partial final bar plays only its
|
# Loop back via a cue-level JMP when the loop is whole-cue clean; else
|
||||||
# rows instead of the full 64-row pattern).
|
# halt after this cue's own length (an in-pattern B jump, if any, fires
|
||||||
instr = cue_instruction_halt_at(cue_lens[ci])
|
# 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:
|
elif cue_lens[ci] < PATTERN_ROWS:
|
||||||
instr = cue_instruction_len(cue_lens[ci])
|
instr = cue_instruction_len(cue_lens[ci])
|
||||||
else:
|
else:
|
||||||
@@ -2712,6 +2952,18 @@ def main():
|
|||||||
ap.add_argument('--drum-keyoff', action='store_true',
|
ap.add_argument('--drum-keyoff', action='store_true',
|
||||||
help='Emit KEY_OFF for percussion-channel notes too '
|
help='Emit KEY_OFF for percussion-channel notes too '
|
||||||
'(GM drums normally ignore note-off)')
|
'(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',
|
ap.add_argument('--force-synth-loop', action='store_true',
|
||||||
help='For looped samples whose loop sits past the 65535-frame '
|
help='For looped samples whose loop sits past the 65535-frame '
|
||||||
'cap even at 32 kHz (multi-second far-loop instruments, '
|
'cap even at 32 kHz (multi-second far-loop instruments, '
|
||||||
|
|||||||
@@ -388,6 +388,18 @@ def cue_instruction_halt_at(rows: int) -> tuple:
|
|||||||
return (CUE_INST_HALT, 0x40 | (rows & 0x3F))
|
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:
|
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.
|
"""Index of the last cue holding an *actual* note, or -1 if none.
|
||||||
|
|
||||||
|
|||||||
@@ -256,7 +256,24 @@ MMIO
|
|||||||
0b 0000 00rc (r: TTY Raw mode, c: Cursor blink)
|
0b 0000 00rc (r: TTY Raw mode, c: Cursor blink)
|
||||||
7 RW
|
7 RW
|
||||||
Graphics-mode attributes
|
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
|
8 RO
|
||||||
Last used colour (set by poking at the framebuffer)
|
Last used colour (set by poking at the framebuffer)
|
||||||
9 RW
|
9 RW
|
||||||
@@ -266,17 +283,18 @@ MMIO
|
|||||||
11 RO
|
11 RO
|
||||||
Number of Banks, or VRAM size (1 = 256 kB, max 4)
|
Number of Banks, or VRAM size (1 = 256 kB, max 4)
|
||||||
12 RW
|
12 RW
|
||||||
Graphics Mode
|
0b 0000 mmmm, where:
|
||||||
0: 560x448, 256 Colours, 1 layer
|
mmmm: Graphics Mode
|
||||||
1: 280x224, 256 Colours, 4 layers
|
0: 560x448, 256 Colours, 1 layer
|
||||||
2: 280x224, 4096 Colours, 2 layers
|
1: 280x224, 256 Colours, 4 layers
|
||||||
3: 560x448, 256 Colours, 2 layers (if bank 2 is not installed, mode change will not happen)
|
2: 280x224, 4096 Colours, 2 layers
|
||||||
4: 560x448, 4096 Colours, 1 layer (if bank 2 is not installed, mode change will not happen)
|
3: 560x448, 256 Colours, 2 layers (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)
|
4: 560x448, 4096 Colours, 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)
|
5: 560x448, 15-bit colour, 1 layer (if bank 2 is not installed, mode change will not happen)
|
||||||
4096 is also known as "direct colour mode" (4096 colours * 16 transparency -> 65536 colours)
|
8: 560x448, 24-bit colour, 1 layer (if bank 3 and 4 are not installed, mode change will not happen)
|
||||||
Two layers are grouped to make a frame, "low layer" contains RG colours and "high layer" has BA colours,
|
4096 is also known as "direct colour mode" (4096 colours * 16 transparency -> 65536 colours)
|
||||||
Red and Blue occupies MSBs
|
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
|
13 RW
|
||||||
Layer Arrangement
|
Layer Arrangement
|
||||||
If 4 layers are used:
|
If 4 layers are used:
|
||||||
@@ -2879,7 +2897,8 @@ TODO:
|
|||||||
detaching, mirroring the existing active-branch. parent.keyOff survives deactivation and is
|
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
|
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.
|
(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:
|
TODO - list of demo songs that MUST ship with Microtone:
|
||||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||||
@@ -3028,7 +3047,7 @@ Play Head Flags
|
|||||||
Byte 31..32: instruction
|
Byte 31..32: instruction
|
||||||
1000xxxx yyyyyyyy (BAK000) - Go back 0bxxxxyyyyyyyy patterns
|
1000xxxx yyyyyyyy (BAK000) - Go back 0bxxxxyyyyyyyy patterns
|
||||||
1001xxxx yyyyyyyy (FWD000) - Skip forward 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)
|
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 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)
|
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)
|
||||||
|
|||||||
@@ -3852,6 +3852,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
ts.cuePos = when (instr) {
|
ts.cuePos = when (instr) {
|
||||||
is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0)
|
is PlayInstGoBack -> (ts.cuePos - instr.arg).coerceAtLeast(0)
|
||||||
is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023)
|
is PlayInstSkip -> (ts.cuePos + instr.arg).coerceAtMost(1023)
|
||||||
|
is PlayInstJump -> instr.arg.coerceIn(0, 1023)
|
||||||
else -> (ts.cuePos + 1).coerceAtMost(1023)
|
else -> (ts.cuePos + 1).coerceAtMost(1023)
|
||||||
}
|
}
|
||||||
playhead.position = ts.cuePos
|
playhead.position = ts.cuePos
|
||||||
@@ -4113,7 +4114,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
// 00000000 (NOP) default 64-row cue
|
// 00000000 (NOP) default 64-row cue
|
||||||
// 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
|
// 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
|
||||||
// 1001xxxx yyyyyyyy (FWD) skip forward 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() {
|
private fun recomputeInstruction() {
|
||||||
val b30 = instByte30
|
val b30 = instByte30
|
||||||
val b31 = instByte31
|
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))
|
(b30 and 0xF0) == 0x80 -> PlayInstGoBack(((b30 and 0xF) shl 8) or (b31 and 0xFF))
|
||||||
// FWD: 1001xxxx yyyyyyyy — 12-bit arg.
|
// FWD: 1001xxxx yyyyyyyy — 12-bit arg.
|
||||||
(b30 and 0xF0) == 0x90 -> PlayInstSkip(((b30 and 0xF) shl 8) or (b31 and 0xFF))
|
(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
|
else -> PlayInstNop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4176,6 +4178,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
internal open class PlayInstruction(val arg: Int)
|
internal open class PlayInstruction(val arg: Int)
|
||||||
internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg)
|
internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg)
|
||||||
internal class PlayInstSkip(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)
|
internal class PlayInstPatLen(val rows: Int) : PlayInstruction(rows)
|
||||||
/** "Halt at x": play [rows] rows of the pattern (1..64) then stop. */
|
/** "Halt at x": play [rows] rows of the pattern (1..64) then stop. */
|
||||||
internal class PlayInstHaltAt(val rows: Int) : PlayInstruction(rows)
|
internal class PlayInstHaltAt(val rows: Int) : PlayInstruction(rows)
|
||||||
|
|||||||
@@ -19,13 +19,11 @@ import net.torvald.terrarum.DefaultGL32Shaders
|
|||||||
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
|
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
|
||||||
import net.torvald.tsvm.*
|
import net.torvald.tsvm.*
|
||||||
import net.torvald.tsvm.peripheral.GraphicsAdapter.Companion.DRAW_SHADER_FRAG
|
import net.torvald.tsvm.peripheral.GraphicsAdapter.Companion.DRAW_SHADER_FRAG
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.experimental.and
|
import kotlin.experimental.and
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
data class AdapterConfig(
|
data class AdapterConfig(
|
||||||
val theme: String,
|
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 blinkCursor = true
|
||||||
override var ttyRawMode = false
|
override var ttyRawMode = false
|
||||||
private var graphicsUseSprites = false
|
private var graphicsDisableTexts = false
|
||||||
private var lastUsedColour = (-1).toByte()
|
private var lastUsedColour = (-1).toByte()
|
||||||
private var currentChrRom = 0
|
private var currentChrRom = 0
|
||||||
private var chrWidth = 7f
|
private var chrWidth = 7f
|
||||||
@@ -346,7 +344,7 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
|||||||
ttyRawMode.toInt(1) or
|
ttyRawMode.toInt(1) or
|
||||||
blinkCursor.toInt()).toByte()
|
blinkCursor.toInt()).toByte()
|
||||||
|
|
||||||
private fun getGraphicsAttributes(): Byte = graphicsUseSprites.toInt().toByte()
|
private fun getGraphicsAttributes(): Byte = graphicsDisableTexts.toInt().toByte()
|
||||||
|
|
||||||
private fun setTextmodeAttributes(rawbyte: Byte) {
|
private fun setTextmodeAttributes(rawbyte: Byte) {
|
||||||
currentChrRom = rawbyte.toInt().and(0b11110000).ushr(4)
|
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) {
|
private fun setGraphicsAttributes(rawbyte: Byte) {
|
||||||
graphicsUseSprites = rawbyte.and(1) == 1.toByte()
|
graphicsDisableTexts = rawbyte.and(1) == 1.toByte()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun mmio_read(addr: Long): Byte? {
|
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
|
outFBObatch.color = Color.WHITE
|
||||||
|
|
||||||
if (!graphicsUseSprites) {
|
if (!graphicsDisableTexts) {
|
||||||
// draw texts
|
// draw texts
|
||||||
val (cx, cy) = getCursorPos()
|
val (cx, cy) = getCursorPos()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user