badline 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -0
- data/CLAUDE.md +217 -0
- data/CODE_OF_CONDUCT.md +92 -0
- data/CONTRIBUTING.md +51 -0
- data/README.md +224 -31
- data/doc/pinned-behaviour.md +1117 -0
- data/exe/badline +22 -2
- data/exe/badline-sid +34 -0
- data/lib/badline/address_bus.rb +88 -23
- data/lib/badline/audio/aiff.rb +36 -0
- data/lib/badline/audio/bare_player.rb +129 -0
- data/lib/badline/audio/cli.rb +133 -0
- data/lib/badline/audio/console.rb +75 -0
- data/lib/badline/audio/jukebox.rb +82 -0
- data/lib/badline/audio/machine_player.rb +57 -0
- data/lib/badline/audio/options.rb +137 -0
- data/lib/badline/audio/pcm_writer.rb +57 -0
- data/lib/badline/audio/playback.rb +108 -0
- data/lib/badline/audio/renderer.rb +85 -0
- data/lib/badline/audio/sdl_sink.rb +102 -0
- data/lib/badline/audio/wav.rb +23 -0
- data/lib/badline/audio.rb +14 -0
- data/lib/badline/cartridge/action_replay.rb +111 -0
- data/lib/badline/cartridge/atomic_power.rb +24 -0
- data/lib/badline/cartridge/bank.rb +81 -0
- data/lib/badline/cartridge/comal80.rb +48 -0
- data/lib/badline/cartridge/dinamic.rb +34 -0
- data/lib/badline/cartridge/easy_flash.rb +122 -0
- data/lib/badline/cartridge/epyx_fastload.rb +71 -0
- data/lib/badline/cartridge/final_cartridge3.rb +77 -0
- data/lib/badline/cartridge/flash.rb +265 -0
- data/lib/badline/cartridge/freezer.rb +31 -0
- data/lib/badline/cartridge/fun_play.rb +38 -0
- data/lib/badline/cartridge/g_mod2.rb +62 -0
- data/lib/badline/cartridge/game_system.rb +40 -0
- data/lib/badline/cartridge/kcs_power.rb +72 -0
- data/lib/badline/cartridge/mach5.rb +34 -0
- data/lib/badline/cartridge/magic_desk.rb +6 -3
- data/lib/badline/cartridge/ocean.rb +11 -12
- data/lib/badline/cartridge/pagefox.rb +56 -0
- data/lib/badline/cartridge/retro_replay.rb +276 -0
- data/lib/badline/cartridge/rex_utility.rb +33 -0
- data/lib/badline/cartridge/rgcd.rb +48 -0
- data/lib/badline/cartridge/simons_basic.rb +40 -0
- data/lib/badline/cartridge/standard.rb +3 -19
- data/lib/badline/cartridge/super_games.rb +36 -0
- data/lib/badline/cartridge/westermann.rb +34 -0
- data/lib/badline/cartridge/zaxxon.rb +50 -0
- data/lib/badline/cartridge.rb +131 -15
- data/lib/badline/cia/interrupt_register.rb +88 -0
- data/lib/badline/cia/serial.rb +219 -0
- data/lib/badline/cia/timer.rb +43 -31
- data/lib/badline/cia.rb +109 -74
- data/lib/badline/color_memory.rb +14 -5
- data/lib/badline/computer.rb +68 -8
- data/lib/badline/control_ports.rb +54 -9
- data/lib/badline/cpu/addressing.rb +131 -0
- data/lib/badline/cpu/microcode.rb +171 -0
- data/lib/badline/cpu/operations.rb +88 -0
- data/lib/badline/cpu/stack_operations.rb +82 -0
- data/lib/badline/cpu.rb +108 -167
- data/lib/badline/cycleable.rb +3 -0
- data/lib/badline/datasette.rb +81 -0
- data/lib/badline/debug_register.rb +28 -0
- data/lib/badline/gui/application.rb +79 -18
- data/lib/badline/gui/gamepads.rb +97 -0
- data/lib/badline/gui/joy_map.rb +15 -7
- data/lib/badline/gui.rb +1 -0
- data/lib/badline/input/mouse1351.rb +41 -0
- data/lib/badline/input/paddles.rb +36 -0
- data/lib/badline/input.rb +4 -0
- data/lib/badline/instruction_set/arithmetic.rb +16 -21
- data/lib/badline/instruction_set/bitwise.rb +17 -22
- data/lib/badline/instruction_set/branch.rb +20 -19
- data/lib/badline/instruction_set/flag.rb +7 -7
- data/lib/badline/instruction_set/illegal.rb +14 -22
- data/lib/badline/instruction_set/inc_dec.rb +8 -10
- data/lib/badline/instruction_set/stack.rb +5 -57
- data/lib/badline/instruction_set/transfer.rb +9 -9
- data/lib/badline/instruction_set.rb +9 -31
- data/lib/badline/interrupts.rb +104 -0
- data/lib/badline/joystick.rb +9 -9
- data/lib/badline/kernal_trap/channel.rb +33 -0
- data/lib/badline/kernal_trap/drive.rb +239 -0
- data/lib/badline/kernal_trap/file.rb +4 -24
- data/lib/badline/kernal_trap/load.rb +2 -2
- data/lib/badline/kernal_trap/routine.rb +34 -0
- data/lib/badline/kernal_trap/save.rb +1 -1
- data/lib/badline/kernal_trap/serial.rb +139 -0
- data/lib/badline/kernal_trap.rb +4 -0
- data/lib/badline/keyboard.rb +40 -16
- data/lib/badline/media.rb +48 -10
- data/lib/badline/roms/eapi/LICENSE.md +15 -0
- data/lib/badline/roms/eapi/README +41 -0
- data/lib/badline/roms/eapi/eapi-am29f040-14 +0 -0
- data/lib/badline/sid/decimator.rb +46 -0
- data/lib/badline/sid/envelope.rb +232 -0
- data/lib/badline/sid/filter.rb +213 -0
- data/lib/badline/sid/voice.rb +61 -0
- data/lib/badline/sid/waveform/combined.rb +126 -0
- data/lib/badline/sid/waveform/fast_forward.rb +105 -0
- data/lib/badline/sid/waveform/noise_writeback.rb +82 -0
- data/lib/badline/sid/waveform.rb +289 -0
- data/lib/badline/sid.rb +274 -7
- data/lib/badline/status.rb +32 -27
- data/lib/badline/storage/crt_file.rb +18 -2
- data/lib/badline/storage/disk_image.rb +52 -5
- data/lib/badline/storage/host_directory.rb +18 -4
- data/lib/badline/storage/sid_file.rb +274 -0
- data/lib/badline/storage/song_lengths.rb +65 -0
- data/lib/badline/storage/t64.rb +67 -0
- data/lib/badline/storage/tap.rb +68 -0
- data/lib/badline/storage.rb +19 -0
- data/lib/badline/time_of_day.rb +105 -52
- data/lib/badline/version.rb +1 -1
- data/lib/badline/vic/bank.rb +14 -6
- data/lib/badline/vic/border_mask.rb +76 -0
- data/lib/badline/vic/collisions.rb +104 -0
- data/lib/badline/vic/color_patches.rb +33 -0
- data/lib/badline/vic/display_state.rb +92 -29
- data/lib/badline/vic/graphics_mode.rb +78 -53
- data/lib/badline/vic/graphics_shifter.rb +140 -0
- data/lib/badline/vic/register_log.rb +63 -0
- data/lib/badline/vic/registers.rb +3 -0
- data/lib/badline/vic/sequencer.rb +163 -137
- data/lib/badline/vic/sequencer_output.rb +116 -0
- data/lib/badline/vic/sprite/internal_bus.rb +49 -0
- data/lib/badline/vic/sprite/shifter.rb +129 -0
- data/lib/badline/vic/sprite.rb +275 -65
- data/lib/badline/vic/sprites.rb +220 -58
- data/lib/badline/vic.rb +373 -49
- data/lib/badline.rb +5 -0
- data/vendor/.gitkeep +0 -0
- metadata +103 -4
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/md5"
|
|
4
|
+
|
|
5
|
+
module Badline
|
|
6
|
+
module Storage
|
|
7
|
+
# PSID/RSID tunes: a memory image plus the addresses of the routines
|
|
8
|
+
# that initialise it and produce one frame of music.
|
|
9
|
+
class SIDFile
|
|
10
|
+
include IntegerHelper
|
|
11
|
+
|
|
12
|
+
class FormatError < StandardError; end
|
|
13
|
+
|
|
14
|
+
V1_HEADER_SIZE = 0x76
|
|
15
|
+
|
|
16
|
+
# The 6502 stub that starts a tune: call init with the song number,
|
|
17
|
+
# point the KERNAL's IRQ vector at a raster handler that calls play,
|
|
18
|
+
# and spin. Both calls are wrapped in the `$01` save, bank and restore
|
|
19
|
+
# that puts the tune's own RAM under the CPU.
|
|
20
|
+
#
|
|
21
|
+
# A tune owns all of zero page, so the stub never returns to whatever
|
|
22
|
+
# SYSed it: BASIC's READY loop reuses `$19-$21` and would overwrite the
|
|
23
|
+
# tune's variables between init and the first play.
|
|
24
|
+
#
|
|
25
|
+
# jmp boot
|
|
26
|
+
# irq: lda #$01
|
|
27
|
+
# sta $d019
|
|
28
|
+
# lda $01
|
|
29
|
+
# pha
|
|
30
|
+
# lda #iomap
|
|
31
|
+
# sta $01
|
|
32
|
+
# jsr play
|
|
33
|
+
# pla
|
|
34
|
+
# sta $01
|
|
35
|
+
# jmp $ea31
|
|
36
|
+
# boot: sei
|
|
37
|
+
# lda $01
|
|
38
|
+
# pha
|
|
39
|
+
# lda #iomap
|
|
40
|
+
# sta $01
|
|
41
|
+
# lda #song
|
|
42
|
+
# jsr init
|
|
43
|
+
# pla
|
|
44
|
+
# sta $01
|
|
45
|
+
# lda #$7f
|
|
46
|
+
# sta $dc0d
|
|
47
|
+
# lda $dc0d
|
|
48
|
+
# lda $d011
|
|
49
|
+
# and #$7f
|
|
50
|
+
# sta $d011
|
|
51
|
+
# lda #$00
|
|
52
|
+
# sta $d012
|
|
53
|
+
# lda #$01
|
|
54
|
+
# sta $d019
|
|
55
|
+
# sta $d01a
|
|
56
|
+
# lda #<irq
|
|
57
|
+
# sta $0314
|
|
58
|
+
# lda #>irq
|
|
59
|
+
# sta $0315
|
|
60
|
+
# cli
|
|
61
|
+
# idle: jmp idle
|
|
62
|
+
class Driver
|
|
63
|
+
include IntegerHelper
|
|
64
|
+
|
|
65
|
+
IRQ_VECTOR = 0x0314
|
|
66
|
+
KERNAL_IRQ = 0xea31
|
|
67
|
+
CIA1_ICR = 0xdc0d
|
|
68
|
+
RASTER_IRQ_STATUS = 0xd019
|
|
69
|
+
|
|
70
|
+
def initialize(tune, song:, base:)
|
|
71
|
+
@tune = tune
|
|
72
|
+
@song = song
|
|
73
|
+
@base = base
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def bytes
|
|
77
|
+
@bytes ||= playing? ? jmp(boot_address) + handler + boot : boot
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def playing? = @tune.play_address.positive?
|
|
83
|
+
|
|
84
|
+
def handler_address = @base + jmp(0).length
|
|
85
|
+
|
|
86
|
+
def boot_address = playing? ? handler_address + handler.length : @base
|
|
87
|
+
|
|
88
|
+
def handler
|
|
89
|
+
@handler ||= lda_imm(0x01) + sta_abs(RASTER_IRQ_STATUS) +
|
|
90
|
+
banked(@tune.play_address, jsr(@tune.play_address)) +
|
|
91
|
+
jmp(KERNAL_IRQ)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def boot
|
|
95
|
+
start = [0x78] +
|
|
96
|
+
banked(@tune.init_address, lda_imm(@song) + jsr(@tune.init_address)) +
|
|
97
|
+
raster_irq + [0x58]
|
|
98
|
+
start + jmp(boot_address + start.length)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Tunes live under the ROMs as often as not, so a call has to bank out
|
|
102
|
+
# whatever covers the routine first. The 6510 port at `$01` is RAM to
|
|
103
|
+
# nobody, so saving it across the call leaves the caller's banking —
|
|
104
|
+
# and with it the I/O the rest of the stub writes to — as it was.
|
|
105
|
+
def banked(address, call)
|
|
106
|
+
bank = @tune.bank_for(address)
|
|
107
|
+
return call unless bank
|
|
108
|
+
|
|
109
|
+
lda_zp(0x01) + [0x48] + lda_imm(bank) + sta_zp(0x01) +
|
|
110
|
+
call + [0x68] + sta_zp(0x01)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def raster_irq
|
|
114
|
+
return [] unless playing?
|
|
115
|
+
|
|
116
|
+
mask_cia1 + arm_raster + install_vector
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Leaves the raster as the only interrupt source.
|
|
120
|
+
def mask_cia1
|
|
121
|
+
lda_imm(0x7f) + sta_abs(CIA1_ICR) + lda_abs(CIA1_ICR)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def arm_raster
|
|
125
|
+
lda_abs(0xd011) + [0x29, 0x7f] + sta_abs(0xd011) +
|
|
126
|
+
lda_imm(0x00) + sta_abs(0xd012) +
|
|
127
|
+
lda_imm(0x01) + sta_abs(RASTER_IRQ_STATUS) + sta_abs(0xd01a)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def install_vector
|
|
131
|
+
lda_imm(low_byte(handler_address)) + sta_abs(IRQ_VECTOR) +
|
|
132
|
+
lda_imm(high_byte(handler_address)) + sta_abs(IRQ_VECTOR + 1)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def lda_imm(value) = [0xa9, value]
|
|
136
|
+
def lda_zp(addr) = [0xa5, addr]
|
|
137
|
+
def sta_zp(addr) = [0x85, addr]
|
|
138
|
+
def lda_abs(addr) = [0xad, low_byte(addr), high_byte(addr)]
|
|
139
|
+
def sta_abs(addr) = [0x8d, low_byte(addr), high_byte(addr)]
|
|
140
|
+
def jsr(addr) = [0x20, low_byte(addr), high_byte(addr)]
|
|
141
|
+
def jmp(addr) = [0x4c, low_byte(addr), high_byte(addr)]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
attr_reader :format
|
|
145
|
+
|
|
146
|
+
def initialize(path)
|
|
147
|
+
@bytes = File.binread(path).bytes
|
|
148
|
+
@format = @bytes[0, 4].pack("C*")
|
|
149
|
+
raise FormatError, "Missing PSID or RSID signature" unless %w[PSID RSID].include?(@format)
|
|
150
|
+
raise FormatError, "Truncated header" if truncated?
|
|
151
|
+
raise FormatError, "No tune data" if data.empty?
|
|
152
|
+
raise FormatError, "Sidplayer MUS data, which needs a MUS player" if mus?
|
|
153
|
+
raise FormatError, "Written for #{sids} SIDs; only one is emulated" if sids > 1
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def version = word(0x04)
|
|
157
|
+
def data_offset = word(0x06)
|
|
158
|
+
def play_address = word(0x0c)
|
|
159
|
+
def songs = [word(0x0e), 1].max
|
|
160
|
+
def start_song = word(0x10).clamp(1, songs)
|
|
161
|
+
def speed = (word(0x12) << 16) + word(0x14)
|
|
162
|
+
def name = text(0x16)
|
|
163
|
+
def author = text(0x36)
|
|
164
|
+
def released = text(0x56)
|
|
165
|
+
def flags = version > 1 ? word(V1_HEADER_SIZE) : 0
|
|
166
|
+
def init_address = word(0x0a).nonzero? || load_address
|
|
167
|
+
def end_address = load_address + data.length
|
|
168
|
+
def psid? = @format == "PSID"
|
|
169
|
+
|
|
170
|
+
# Flag bit 0 marks a PSID body as Compute's Sidplayer data rather than
|
|
171
|
+
# code.
|
|
172
|
+
def mus? = psid? && flags[0] == 1
|
|
173
|
+
|
|
174
|
+
# A v3 header can place a second SID at `$Dxx0` and a v4 header a
|
|
175
|
+
# third, each given by its middle byte: even, and in `$d420-$d7e0` or
|
|
176
|
+
# `$de00-$dfe0`. Anything else means the SID isn't there.
|
|
177
|
+
def sids
|
|
178
|
+
1 + [0x7a, 0x7b].first([version - 2, 0].max).count do |offset|
|
|
179
|
+
page = @bytes[offset].to_i
|
|
180
|
+
page.even? && ((0x42..0x7e).cover?(page) || (0xe0..0xfe).cover?(page))
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Flag bits 4-5 name the SID the tune was written for: 01 the 6581,
|
|
185
|
+
# 10 the 8580, 11 either and 00 unknown. Only an 8580-only tune gets one.
|
|
186
|
+
def sid_model = flags[4, 2] == 0b10 ? :mos8580 : :mos6581
|
|
187
|
+
|
|
188
|
+
# HVSC keys its song length database on the whole file, header and all.
|
|
189
|
+
def md5 = @md5 ||= Digest::MD5.hexdigest(@bytes.pack("C*"))
|
|
190
|
+
|
|
191
|
+
# The `$01` value a routine at `address` has to run under, following
|
|
192
|
+
# libsidplayfp's iomap: RAM under BASIC from `$a000`, RAM under both
|
|
193
|
+
# ROMs and no I/O for a routine in the I/O window itself, RAM under the
|
|
194
|
+
# KERNAL from `$e000`. An RSID tune banks itself and gets nil.
|
|
195
|
+
def bank_for(address)
|
|
196
|
+
return unless psid?
|
|
197
|
+
return 0x37 if address < 0xa000
|
|
198
|
+
return 0x36 if address < 0xd000
|
|
199
|
+
return 0x34 if address < 0xe000
|
|
200
|
+
|
|
201
|
+
0x35
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# A zero load address puts the real one in the first two bytes of
|
|
205
|
+
# the body, PRG style.
|
|
206
|
+
def load_address
|
|
207
|
+
@load_address ||= word(0x08).nonzero? || uint16(body[0].to_i, body[1].to_i)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def data
|
|
211
|
+
@data ||= body.drop(word(0x08).zero? ? 2 : 0).first(0x10000 - load_address)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A set speed bit asks for CIA timer pacing instead of a raster IRQ.
|
|
215
|
+
# The bare player honours it; the machine driver paces everything off
|
|
216
|
+
# the raster either way.
|
|
217
|
+
def cia_timed?(song = start_song)
|
|
218
|
+
speed[[song - 1, 31].min] == 1
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def driver_address
|
|
222
|
+
@driver_address ||= candidates.find { |addr| !overlaps?(addr) } ||
|
|
223
|
+
raise(FormatError, "No free RAM for the player")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def driver(song: start_song)
|
|
227
|
+
Driver.new(self, song: song.clamp(1, songs) - 1, base: driver_address).bytes
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
|
|
232
|
+
def truncated?
|
|
233
|
+
@bytes.length < V1_HEADER_SIZE || @bytes.length < data_offset
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def body
|
|
237
|
+
@body ||= @bytes[data_offset..] || []
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The tape buffer and the free block below the stack page, neither
|
|
241
|
+
# of which BASIC touches.
|
|
242
|
+
def candidates
|
|
243
|
+
[relocation_address, 0x0334, 0x02a7].compact
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def relocation_address
|
|
247
|
+
page = version > 1 ? @bytes[0x78].to_i : 0
|
|
248
|
+
return if page.zero? || page == 0xff
|
|
249
|
+
|
|
250
|
+
page << 8
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def overlaps?(addr)
|
|
254
|
+
addr < end_address && load_address < addr + driver_size
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def driver_size
|
|
258
|
+
@driver_size ||= Driver.new(self, song: 0, base: 0).bytes.length
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def text(offset)
|
|
262
|
+
@bytes[offset, 32].take_while(&:positive?)
|
|
263
|
+
.pack("C*")
|
|
264
|
+
.force_encoding(Encoding::ISO_8859_1)
|
|
265
|
+
.encode(Encoding::UTF_8)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Header fields are big-endian, unlike everything else the 6502 sees.
|
|
269
|
+
def word(offset)
|
|
270
|
+
uint16(@bytes[offset + 1], @bytes[offset])
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
# HVSC's song length database. Each tune gets a comment naming its path
|
|
6
|
+
# and a line keyed by the MD5 of the whole `.sid` file, holding one
|
|
7
|
+
# `m:ss` time per song, optionally down to milliseconds:
|
|
8
|
+
#
|
|
9
|
+
# ; /DEMOS/0-9/12th_Sector_Music.sid
|
|
10
|
+
# c7c299ce06ec5ccffb2261fb11b42a73=4:33.108
|
|
11
|
+
#
|
|
12
|
+
# The file runs to well over a hundred thousand entries, so a lookup
|
|
13
|
+
# scans for its one key rather than building a table.
|
|
14
|
+
class SongLengths
|
|
15
|
+
class << self
|
|
16
|
+
# The database lives in the DOCUMENTS directory of an HVSC
|
|
17
|
+
# collection, so look for one above the tune, then wherever
|
|
18
|
+
# `$HVSC_BASE` points.
|
|
19
|
+
def locate(tune_path)
|
|
20
|
+
candidates(tune_path).find { |path| File.file?(path) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def candidates(tune_path)
|
|
26
|
+
ancestors(File.dirname(File.expand_path(tune_path)))
|
|
27
|
+
.push(ENV.fetch("HVSC_BASE", nil))
|
|
28
|
+
.compact
|
|
29
|
+
.map { |dir| File.join(dir, "DOCUMENTS", "Songlengths.md5") }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ancestors(dir)
|
|
33
|
+
dirs = []
|
|
34
|
+
until dirs.last == dir
|
|
35
|
+
dirs << dir
|
|
36
|
+
dir = File.dirname(dir)
|
|
37
|
+
end
|
|
38
|
+
dirs
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(path)
|
|
43
|
+
@path = path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The tune's per-song lengths in seconds, or nil when it isn't listed.
|
|
47
|
+
def lengths(md5)
|
|
48
|
+
prefix = "#{md5.downcase}="
|
|
49
|
+
File.foreach(@path) do |line|
|
|
50
|
+
next unless line.start_with?(prefix)
|
|
51
|
+
|
|
52
|
+
return line[prefix.length..].split.map { |time| seconds(time) }
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def seconds(time)
|
|
60
|
+
minutes, rest = time.split(":", 2)
|
|
61
|
+
rest ? (minutes.to_i * 60) + rest.to_f : minutes.to_f
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
class T64
|
|
6
|
+
class FormatError < StandardError; end
|
|
7
|
+
|
|
8
|
+
SIGNATURE = "C64".bytes.freeze
|
|
9
|
+
HEADER_SIZE = 0x40
|
|
10
|
+
ENTRY_SIZE = 32
|
|
11
|
+
NORMAL_FILE = 1
|
|
12
|
+
|
|
13
|
+
def initialize(path)
|
|
14
|
+
@bytes = File.binread(path).bytes
|
|
15
|
+
raise FormatError, "Missing T64 signature" unless @bytes[0, 3] == SIGNATURE
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read_file(name, **)
|
|
19
|
+
pattern = Storage.matcher(name)
|
|
20
|
+
entry = entries.find { |e| pattern.match?(e[:name]) }
|
|
21
|
+
return unless entry
|
|
22
|
+
|
|
23
|
+
# Stated end addresses are frequently wrong; the slice stops at
|
|
24
|
+
# the end of the archive either way.
|
|
25
|
+
entry[:load] + (@bytes[entry[:offset], entry[:length]] || [])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def names
|
|
29
|
+
entries.map { |e| e[:name] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def entries
|
|
35
|
+
@entries ||= (0...entry_count).filter_map { |i| entry_at(i) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Directory size ($22) and used count ($24) disagree in the wild.
|
|
39
|
+
def entry_count
|
|
40
|
+
[word(0x22), word(0x24)].max
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def entry_at(index)
|
|
44
|
+
entry = @bytes[HEADER_SIZE + (index * ENTRY_SIZE), ENTRY_SIZE]
|
|
45
|
+
return unless entry&.length == ENTRY_SIZE && entry[0] == NORMAL_FILE
|
|
46
|
+
|
|
47
|
+
{ name: decode_name(entry[16, 16]),
|
|
48
|
+
load: entry[2, 2],
|
|
49
|
+
offset: little_endian(entry[8, 4]),
|
|
50
|
+
length: little_endian(entry[4, 2]) - little_endian(entry[2, 2]) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Names are padded with spaces, though NUL padding also occurs.
|
|
54
|
+
def decode_name(bytes)
|
|
55
|
+
Storage.ascii(bytes).downcase.sub(/[ \0]+\z/, "")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def word(offset)
|
|
59
|
+
little_endian(@bytes[offset, 2])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def little_endian(bytes)
|
|
63
|
+
bytes.each_with_index.sum { |byte, i| byte << (8 * i) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
# Raw datasette pulse stream. The header holds the signature, the
|
|
6
|
+
# version at $0c and the data size at $10; the data runs from $14, every
|
|
7
|
+
# byte the time to the next falling edge on the tape read line, in units
|
|
8
|
+
# of 8 cycles.
|
|
9
|
+
class TAP
|
|
10
|
+
class FormatError < StandardError; end
|
|
11
|
+
|
|
12
|
+
SIGNATURE = "C64-TAPE-RAW".bytes.freeze
|
|
13
|
+
HEADER_SIZE = 0x14
|
|
14
|
+
OVERFLOW = 0x100 * 8
|
|
15
|
+
|
|
16
|
+
attr_reader :version
|
|
17
|
+
|
|
18
|
+
def initialize(path)
|
|
19
|
+
@bytes = File.binread(path).bytes
|
|
20
|
+
raise FormatError, "Missing TAP signature" unless @bytes[0, SIGNATURE.length] == SIGNATURE
|
|
21
|
+
|
|
22
|
+
@version = @bytes[0x0c].to_i
|
|
23
|
+
raise FormatError, "Unsupported TAP version #{@version}" if @version > 1
|
|
24
|
+
|
|
25
|
+
@end = tape_end
|
|
26
|
+
rewind
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def rewind
|
|
30
|
+
@pos = HEADER_SIZE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def end? = @pos >= @end
|
|
34
|
+
|
|
35
|
+
# Cycles until the next falling edge, or nil once the tape has run out.
|
|
36
|
+
def next_pulse
|
|
37
|
+
return if end?
|
|
38
|
+
|
|
39
|
+
byte = @bytes[@pos]
|
|
40
|
+
@pos += 1
|
|
41
|
+
return byte * 8 unless byte.zero?
|
|
42
|
+
|
|
43
|
+
# $00 is an overflow in version 0 and a 24-bit cycle count in
|
|
44
|
+
# version 1.
|
|
45
|
+
version.zero? ? OVERFLOW : long_pulse
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# The stated size disagrees with the file in the wild, and a few dumps
|
|
51
|
+
# leave it zeroed altogether.
|
|
52
|
+
def tape_end
|
|
53
|
+
size = little_endian(@bytes[0x10, 4] || [])
|
|
54
|
+
size.zero? ? @bytes.length : [HEADER_SIZE + size, @bytes.length].min
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def long_pulse
|
|
58
|
+
bytes = @bytes[@pos, 3]
|
|
59
|
+
@pos += 3
|
|
60
|
+
bytes&.length == 3 ? little_endian(bytes) : OVERFLOW
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def little_endian(bytes)
|
|
64
|
+
bytes.each_with_index.sum { |byte, i| byte << (8 * i) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/badline/storage.rb
CHANGED
|
@@ -6,16 +6,35 @@ require "badline/storage/disk_image"
|
|
|
6
6
|
require "badline/storage/d64_image"
|
|
7
7
|
require "badline/storage/d71_image"
|
|
8
8
|
require "badline/storage/d81_image"
|
|
9
|
+
require "badline/storage/t64"
|
|
10
|
+
require "badline/storage/tap"
|
|
9
11
|
require "badline/storage/crt_file"
|
|
12
|
+
require "badline/storage/sid_file"
|
|
13
|
+
require "badline/storage/song_lengths"
|
|
10
14
|
|
|
11
15
|
module Badline
|
|
12
16
|
module Storage
|
|
17
|
+
FILE_TYPES = { "S" => :seq, "P" => :prg, "U" => :usr }.freeze
|
|
18
|
+
|
|
13
19
|
class << self
|
|
14
20
|
# Folds shifted PETSCII letters to their ASCII equivalents.
|
|
15
21
|
def ascii(bytes)
|
|
16
22
|
bytes.map { |b| b.between?(0xc1, 0xda) ? b - 0x80 : b }.pack("C*")
|
|
17
23
|
end
|
|
18
24
|
|
|
25
|
+
# CBM DOS drive prefix: "0:NAME" selects a drive, "@0:NAME" is
|
|
26
|
+
# save-with-replace
|
|
27
|
+
def strip_drive_prefix(name)
|
|
28
|
+
name.sub(/\A@?\d*:/, "")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# CBM DOS names read "NAME,TYPE,MODE". Returns the bare name and the
|
|
32
|
+
# file type it asks for, or nil when it names none.
|
|
33
|
+
def parse_name(name)
|
|
34
|
+
base, type = strip_drive_prefix(name).split(",", 3)
|
|
35
|
+
[base.to_s, FILE_TYPES[type.to_s.strip[0]&.upcase]]
|
|
36
|
+
end
|
|
37
|
+
|
|
19
38
|
# CBM-style filename pattern: "*" and "?" wildcards, case-insensitive.
|
|
20
39
|
def matcher(name)
|
|
21
40
|
escaped = Regexp.escape(name.downcase)
|