badline 0.1.0 → 0.2.1
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 +185 -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 +96 -25
- 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 +132 -16
- data/lib/badline/cia/interrupt_register.rb +102 -0
- data/lib/badline/cia/serial.rb +219 -0
- data/lib/badline/cia/timer.rb +43 -31
- data/lib/badline/cia.rb +138 -88
- data/lib/badline/color_memory.rb +14 -5
- data/lib/badline/computer.rb +97 -12
- data/lib/badline/control_ports.rb +54 -9
- data/lib/badline/cpu/addressing.rb +131 -0
- data/lib/badline/cpu/microcode.rb +169 -0
- data/lib/badline/cpu/operations.rb +88 -0
- data/lib/badline/cpu/stack_operations.rb +82 -0
- data/lib/badline/cpu.rb +109 -168
- 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 +51 -0
- data/lib/badline/kernal_trap/drive/status.rb +49 -0
- data/lib/badline/kernal_trap/drive.rb +277 -0
- data/lib/badline/kernal_trap/file.rb +5 -25
- data/lib/badline/kernal_trap/load.rb +86 -24
- data/lib/badline/kernal_trap/routine.rb +51 -0
- data/lib/badline/kernal_trap/save.rb +46 -14
- data/lib/badline/kernal_trap/serial.rb +171 -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/memory.rb +5 -0
- 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 +251 -23
- data/lib/badline/storage/crt_file.rb +18 -2
- data/lib/badline/storage/d64_image.rb +6 -0
- data/lib/badline/storage/d71_image.rb +4 -0
- data/lib/badline/storage/d81_image.rb +4 -0
- data/lib/badline/storage/disk_image.rb +99 -13
- data/lib/badline/storage/host_directory.rb +30 -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 +389 -55
- data/lib/badline.rb +5 -0
- data/vendor/.gitkeep +0 -0
- metadata +104 -4
|
@@ -6,32 +6,114 @@ module Badline
|
|
|
6
6
|
SECTOR_SIZE = 256
|
|
7
7
|
ENTRY_SIZE = 32
|
|
8
8
|
ENTRIES_PER_SECTOR = 8
|
|
9
|
-
|
|
9
|
+
FILETYPES = { seq: 0x01, prg: 0x02, usr: 0x03 }.freeze
|
|
10
10
|
NAME_PADDING = 0xa0
|
|
11
11
|
|
|
12
|
+
# Error table codes and the DOS errors they stand for: 20 to 29 are
|
|
13
|
+
# the read, write and ID errors, 74 is DRIVE NOT READY.
|
|
14
|
+
DOS_ERRORS = (2..11).to_h { |code| [code, code + 18] }.merge(15 => 74).freeze
|
|
15
|
+
|
|
12
16
|
def initialize(path)
|
|
13
17
|
@bytes = File.binread(path).bytes
|
|
18
|
+
@errors = split_error_table
|
|
14
19
|
end
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
# A LOAD reads only PRG files. An OPEN names the type it wants, or
|
|
22
|
+
# takes the first file of any type with a nil `type`.
|
|
23
|
+
def read_file(name, type: :prg)
|
|
24
|
+
entry = find_entry(name, type)
|
|
19
25
|
read_chain(entry[:track], entry[:sector]) if entry
|
|
20
26
|
end
|
|
21
27
|
|
|
28
|
+
# The first block in a file's chain that the error table marks bad:
|
|
29
|
+
# its DOS error, its track and sector, and how many of the file's
|
|
30
|
+
# bytes come before it. Nil when the whole chain reads cleanly.
|
|
31
|
+
def read_error(name, type: :prg)
|
|
32
|
+
entry = find_entry(name, type)
|
|
33
|
+
return unless @errors && entry
|
|
34
|
+
|
|
35
|
+
offset = 0
|
|
36
|
+
each_sector(entry[:track], entry[:sector]) do |data, track, sector|
|
|
37
|
+
error = block_error(track, sector)
|
|
38
|
+
return { error:, track:, sector:, offset: } if error
|
|
39
|
+
|
|
40
|
+
offset += data[0].zero? ? data[1] - 1 : SECTOR_SIZE - 2
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Raw block access for the DOS `U1` command. Returns nil for blocks
|
|
46
|
+
# outside the image's geometry.
|
|
47
|
+
def read_block(track, sector)
|
|
48
|
+
return unless block?(track, sector)
|
|
49
|
+
|
|
50
|
+
sector_at(track, sector)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The DOS error a read of the block raises, from the error table an
|
|
54
|
+
# image can carry after its last block, or nil when it reads cleanly.
|
|
55
|
+
def block_error(track, sector)
|
|
56
|
+
return unless @errors && block?(track, sector)
|
|
57
|
+
|
|
58
|
+
DOS_ERRORS[@errors[track_offset(track) + sector]]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The block holding the disk name and ID, at the start of the
|
|
62
|
+
# directory track.
|
|
63
|
+
def header_block = [directory_track, 0]
|
|
64
|
+
|
|
65
|
+
# The directory block a new file's entry goes into: the first with a
|
|
66
|
+
# free slot, or the last one when every slot is taken.
|
|
67
|
+
def new_entry_block
|
|
68
|
+
last = nil
|
|
69
|
+
each_sector(directory_track, directory_sector) do |data, track, sector|
|
|
70
|
+
return [track, sector] if (0...ENTRIES_PER_SECTOR).any? { |i| data[(i * ENTRY_SIZE) + 2].zero? }
|
|
71
|
+
|
|
72
|
+
last = [track, sector]
|
|
73
|
+
end
|
|
74
|
+
last
|
|
75
|
+
end
|
|
76
|
+
|
|
22
77
|
private
|
|
23
78
|
|
|
79
|
+
# An error table holds one byte per block after the last one. The image
|
|
80
|
+
# sizes that carry one are listed exactly, since a 40-track D64 with a
|
|
81
|
+
# table is also a whole number of 256-byte blocks.
|
|
82
|
+
def split_error_table
|
|
83
|
+
blocks = error_tables[@bytes.length]
|
|
84
|
+
@bytes.pop(blocks) if blocks
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def block?(track, sector)
|
|
88
|
+
return false unless track.between?(1, 255) &&
|
|
89
|
+
sector.between?(0, sectors_in(track) - 1)
|
|
90
|
+
|
|
91
|
+
sector_offset(track, sector) + SECTOR_SIZE <= @bytes.length
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def find_entry(name, type)
|
|
95
|
+
pattern = Storage.matcher(name)
|
|
96
|
+
entries.find do |e|
|
|
97
|
+
(type.nil? || e[:type] == type) && pattern.match?(e[:name])
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
24
101
|
def entries
|
|
25
|
-
@entries ||=
|
|
26
|
-
|
|
102
|
+
@entries ||= begin
|
|
103
|
+
list = []
|
|
104
|
+
each_sector(directory_track, directory_sector) { |data| list.concat(parse_entries(data)) }
|
|
105
|
+
list
|
|
106
|
+
end
|
|
27
107
|
end
|
|
28
108
|
|
|
29
109
|
def parse_entries(data)
|
|
30
110
|
(0...ENTRIES_PER_SECTOR).filter_map do |i|
|
|
31
111
|
entry = data[i * ENTRY_SIZE, ENTRY_SIZE]
|
|
32
|
-
|
|
112
|
+
type = FILETYPES.key(entry[2] & 0x07)
|
|
113
|
+
next unless type
|
|
33
114
|
|
|
34
115
|
{ name: decode_name(entry[5, 16]),
|
|
116
|
+
type:,
|
|
35
117
|
track: entry[3],
|
|
36
118
|
sector: entry[4] }
|
|
37
119
|
end
|
|
@@ -42,19 +124,19 @@ module Badline
|
|
|
42
124
|
end
|
|
43
125
|
|
|
44
126
|
def read_chain(track, sector)
|
|
45
|
-
|
|
46
|
-
|
|
127
|
+
bytes = []
|
|
128
|
+
each_sector(track, sector) do |data|
|
|
129
|
+
bytes.concat(data[0].zero? ? data[2..data[1]] : data[2..])
|
|
47
130
|
end
|
|
131
|
+
bytes
|
|
48
132
|
end
|
|
49
133
|
|
|
50
134
|
def each_sector(track, sector)
|
|
51
|
-
return to_enum(:each_sector, track, sector) unless block_given?
|
|
52
|
-
|
|
53
135
|
visited = {}
|
|
54
136
|
while track != 0 && !visited[[track, sector]]
|
|
55
137
|
visited[[track, sector]] = true
|
|
56
138
|
data = sector_at(track, sector)
|
|
57
|
-
yield data
|
|
139
|
+
yield data, track, sector
|
|
58
140
|
track, sector = data[0, 2]
|
|
59
141
|
end
|
|
60
142
|
end
|
|
@@ -64,7 +146,11 @@ module Badline
|
|
|
64
146
|
end
|
|
65
147
|
|
|
66
148
|
def sector_offset(track, sector)
|
|
67
|
-
((
|
|
149
|
+
(track_offset(track) + sector) * SECTOR_SIZE
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def track_offset(track)
|
|
153
|
+
(1...track).sum { |t| sectors_in(t) }
|
|
68
154
|
end
|
|
69
155
|
end
|
|
70
156
|
end
|
|
@@ -2,22 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Storage
|
|
5
|
+
# A host directory served as a drive: .prg files by their host name,
|
|
6
|
+
# .p00 and .t64 entries by their embedded names. A host file that can't
|
|
7
|
+
# be read is left out, and a failed write reports false.
|
|
5
8
|
class HostDirectory
|
|
6
9
|
def initialize(path)
|
|
7
10
|
@path = path
|
|
8
11
|
end
|
|
9
12
|
|
|
10
|
-
def read_file(name)
|
|
13
|
+
def read_file(name, **)
|
|
11
14
|
entry = find(name)
|
|
12
15
|
return unless entry
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
archive = entry[:archive]
|
|
18
|
+
archive ? archive.read_file(entry[:name]) : file_bytes(entry[:file])
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def write_file(name, bytes)
|
|
19
22
|
host_name = "#{name.downcase.tr('/', '_')}.prg"
|
|
20
23
|
File.binwrite(File.join(@path, host_name), bytes.pack("C*"))
|
|
24
|
+
true
|
|
25
|
+
rescue SystemCallError
|
|
26
|
+
false
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
private
|
|
@@ -28,7 +34,9 @@ module Badline
|
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def entries
|
|
31
|
-
Dir.children(@path).sort.
|
|
37
|
+
Dir.children(@path).sort.flat_map { |f| entry_for(f) }.compact
|
|
38
|
+
rescue SystemCallError
|
|
39
|
+
[]
|
|
32
40
|
end
|
|
33
41
|
|
|
34
42
|
def entry_for(file)
|
|
@@ -37,12 +45,30 @@ module Badline
|
|
|
37
45
|
{ name: File.basename(file, ".*").downcase, file: }
|
|
38
46
|
when ".p00"
|
|
39
47
|
p00_entry(file)
|
|
48
|
+
when ".t64"
|
|
49
|
+
t64_entries(file)
|
|
40
50
|
end
|
|
41
51
|
end
|
|
42
52
|
|
|
43
53
|
def p00_entry(file)
|
|
44
54
|
header = File.binread(File.join(@path, file), P00::HEADER_SIZE).bytes
|
|
45
55
|
{ name: P00.name(header), file: } if P00.wraps?(header)
|
|
56
|
+
rescue SystemCallError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def t64_entries(file)
|
|
61
|
+
archive = T64.new(File.join(@path, file))
|
|
62
|
+
archive.names.map { |name| { name:, archive: } }
|
|
63
|
+
rescue T64::FormatError, SystemCallError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def file_bytes(file)
|
|
68
|
+
bytes = File.binread(File.join(@path, file)).bytes
|
|
69
|
+
P00.wraps?(bytes) ? P00.data(bytes) : bytes
|
|
70
|
+
rescue SystemCallError
|
|
71
|
+
nil
|
|
46
72
|
end
|
|
47
73
|
end
|
|
48
74
|
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
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
|