3 UC1541 Virtual filesystem
5 This extfs provides an access to disk image files for the Commodore
6 VIC20/C64/C128. It requires the utility c1541 that comes bundled with Vice,
7 the emulator for the VIC20, C64, C128 and other computers made by Commodore.
12 Due to different way of representing file entries on regular D64 disk images,
13 there could be issues with filenames that are transfered from/to the image.
14 Following rules was applied to represent a single file entry:
16 1. An extension is attached to the end of a filename depending on a file type.
17 Possible extensions are: prg, del, seq, usr and rel.
18 2. Every non-ASCII character (which could be some of characters specific to
19 PET-ASCII, or be a control character) will be replaced by dot (.), since
20 c1541 program will list them that way.
21 3. Every slash character (/) will be replaced by pipe character (|).
22 4. Leading space will be replaced by tilda (~).
24 While copying from D64 image to filesystem, filenames will be stored as they
25 are seen on a listing.
27 While copying from filesystem to D64 image, filename conversion will be done:
28 1. Every $ and * characters will be replaced by question mark (?)
29 2. Every pipe (|) and backslash (\) characters will be replaced by slash (/)
30 3. Every tilda (~) will be replaced by a space
31 4. 'prg' extension will be truncated
33 Representation of a directory can be sometimes confusing - in case when one
34 copied file without extension it stays there in such form, till next access
35 (after flushing VFS). Also file sizes are not accurate, since D64 directory
36 entries have sizes stored as 256 bytes blocks.
41 Here are specific for this script variable, which while set, can influence
44 UC1541_DEBUG - if set, uc1541 will produce log in /tmp/uc1541.log file
46 UC1541_VERBOSE - of set, script will be more verbose, i.e. error messages form
47 c1541 program will be passed to Midnight Commander, so that user will be aware
48 of error cause if any.
50 UC1541_HIDE_DEL - if set, no DEL entries will be shown
53 2.5 Fixed bug with filenames started with a '-' sign.
54 2.4 Fixed endless loop bug for reading directory in Python implemented
56 2.3 Re added and missing method _correct_fname used for writing files
58 2.2 Fixed bug(?) with unusual sector end (marked as sector 0, not 255),
59 causing endless directory reading on random locations.
60 2.1 Fixed bug with filenames containing slash.
61 2.0 Added reading raw D64 image, and mapping for jokers. Now it is
62 possible to read files with PET-ASCII/control sequences in filenames.
63 Working with d64 images only. Added workaround for space at the
64 beggining of the filename.
65 1.2 Added configuration env variables: UC1541_VERBOSE and UC1541_HIDE_DEL.
66 First one, if set to any value, will cause that error messages from
67 c1541 program will be redirected as a failure messages visible in MC.
68 The other variable, when set to any value, cause "del" entries to be
69 not shown in the lister.
70 1.1 Added protect bits, added failsafe for argparse module
73 Author: Roman 'gryf' Dobosz <gryf73@gmail.com>
82 from subprocess import Popen, PIPE
84 if os.getenv('UC1541_DEBUG'):
86 LOG = logging.getLogger('UC1541')
87 LOG.setLevel(logging.DEBUG)
88 FILE_HANDLER = logging.FileHandler("/tmp/uc1541.log")
89 FILE_FORMATTER = logging.Formatter("%(asctime)s %(levelname)-8s "
90 "%(lineno)s %(funcName)s - %(message)s")
91 FILE_HANDLER.setFormatter(FILE_FORMATTER)
92 FILE_HANDLER.setLevel(logging.DEBUG)
93 LOG.addHandler(FILE_HANDLER)
97 Dummy logger object. does nothing.
100 def debug(*args, **kwargs):
104 def info(*args, **kwargs):
108 def warning(*args, **kwargs):
112 def error(*args, **kwargs):
116 def critical(*args, **kwargs):
122 Implement d64 directory reader
124 CHAR_MAP = {32: ' ', 33: '!', 34: '"', 35: '#', 37: '%', 38: '&', 39: "'",
125 40: '(', 41: ')', 42: '*', 43: '+', 44: ',', 45: '-', 46: '.',
126 47: '/', 48: '0', 49: '1', 50: '2', 51: '3', 52: '4', 53: '5',
127 54: '6', 55: '7', 56: '8', 57: '9', 59: ';', 60: '<', 61: '=',
128 62: '>', 63: '?', 64: '@', 65: 'a', 66: 'b', 67: 'c', 68: 'd',
129 69: 'e', 70: 'f', 71: 'g', 72: 'h', 73: 'i', 74: 'j', 75: 'k',
130 76: 'l', 77: 'm', 78: 'n', 79: 'o', 80: 'p', 81: 'q', 82: 'r',
131 83: 's', 84: 't', 85: 'u', 86: 'v', 87: 'w', 88: 'x', 89: 'y',
132 90: 'z', 91: '[', 93: ']', 97: 'A', 98: 'B', 99: 'C',
133 100: 'D', 101: 'E', 102: 'F', 103: 'G', 104: 'H', 105: 'I',
134 106: 'J', 107: 'K', 108: 'L', 109: 'M', 110: 'N', 111: 'O',
135 112: 'P', 113: 'Q', 114: 'R', 115: 'S', 116: 'T', 117: 'U',
136 118: 'V', 119: 'W', 120: 'X', 121: 'Y', 122: 'Z', 193: 'A',
137 194: 'B', 195: 'C', 196: 'D', 197: 'E', 198: 'F', 199: 'G',
138 200: 'H', 201: 'I', 202: 'J', 203: 'K', 204: 'L', 205: 'M',
139 206: 'N', 207: 'O', 208: 'P', 209: 'Q', 210: 'R', 211: 'S',
140 212: 'T', 213: 'U', 214: 'V', 215: 'W', 216: 'X', 217: 'Y',
143 FILE_TYPES = {0b000: 'del',
149 def __init__(self, dimage):
153 LOG.debug('image: %s', dimage)
154 dimage = open(dimage, 'rb')
155 self.raw = dimage.read()
158 self.current_sector_data = None
160 self.next_track = None
161 self._dir_contents = []
162 self._already_done = []
164 def _map_filename(self, string):
166 Transcode filename to ASCII compatible. Replace not supported
167 characters with jokers.
173 if ord(chr_) == 160: # shift+space character; $a0
176 character = D64.CHAR_MAP.get(ord(chr_), '?')
177 filename.append(character)
180 if filename[0] == "-":
183 LOG.debug("string: ``%s'' mapped to: ``%s''", string,
185 return "".join(filename)
187 def _go_to_next_sector(self):
189 Fetch (if exist) next sector from a directory chain
190 Return False if the chain ends, True otherwise
193 # Well, self.next_sector _should_ have value $FF, but apparently there
194 # are the cases where it is not, therefore checking for that will not
195 # be performed and value of $00 on the next track will end the
197 if self.next_track == 0:
198 LOG.debug("End of directory")
201 if self.next_track is None:
202 LOG.debug("Going to the track: 18,1")
203 offset = self._get_d64_offset(18, 1)
205 offset = self._get_d64_offset(self.next_track, self.next_sector)
206 LOG.debug("Going to the track: %s,%s", self.next_track,
209 self.current_sector_data = self.raw[offset:offset + 256]
211 self.next_track = ord(self.current_sector_data[0])
212 self.next_sector = ord(self.current_sector_data[1])
214 if (self.next_track, self.next_sector) in self._already_done:
215 # Just a failsafe. Endless loop is not what is expected.
216 LOG.debug("Loop in track/sector pointer at %d,%d",
217 self.next_track, self.next_sector)
218 self._already_done = []
221 self._already_done.append((self.next_track, self.next_sector))
222 LOG.debug("Next track: %s,%s", self.next_track, self.next_sector)
225 def _get_ftype(self, num):
227 Get filetype as a string
229 return D64.FILE_TYPES.get(int("%d%d%d" % (num & 4 and 1,
233 def _get_d64_offset(self, track, sector):
235 Return offset (in bytes) for specified track and sector.
242 offset = 17 * 21 * 256
246 offset += 6 * 19 * 256
250 offset += 5 * 18 * 256
253 track = track - truncate_track
254 offset += track * sector * 256
258 def _harvest_entries(self):
260 Traverse through sectors and store entries in _dir_contents
262 sector = self.current_sector_data
265 ftype = ord(entry[2])
267 if ftype == 0: # deleted
271 type_verbose = self._get_ftype(ftype)
273 protect = ord(entry[2]) & 64 and "<" or " "
276 size = ord(entry[23])
278 size = ord(entry[30]) + ord(entry[31]) * 226
280 self._dir_contents.append({'fname': self._map_filename(fname),
281 'ftype': type_verbose,
288 Return directory list as list of dict with keys:
289 fname, ftype, protect and size
291 while self._go_to_next_sector():
292 self._harvest_entries()
294 return self._dir_contents
297 class Uc1541(object):
299 Class for interact with c1541 program and MC
301 PRG = re.compile(r'(\d+)\s+"([^"]*)".+?\s(del|prg|rel|seq|usr)([\s<])')
303 def __init__(self, archname):
307 self._verbose = os.getenv("UC1541_VERBOSE", False)
308 self._hide_del = os.getenv("UC1541_HIDE_DEL", False)
310 self.pyd64 = D64(archname).list_dir()
316 Output list contents of D64 image.
317 Convert filenames to be Unix filesystem friendly
318 Add suffix to show user what kind of file do he dealing with.
320 LOG.info("List contents of %s", self.arch)
321 directory = self._get_dir()
323 for entry in directory:
324 sys.stdout.write("%(perms)s 1 %(uid)-8d %(gid)-8d %(size)8d "
325 "Jan 01 1980 %(display_name)s\n" % entry)
330 Remove file from D64 image
332 LOG.info("Removing file %s", dst)
333 dst = self._get_masked_fname(dst)
335 if not self._call_command('delete', dst=dst):
336 return self._show_error()
338 # During removing, a message containing ERRORCODE is sent to stdout
339 # instead of stderr. Everything other than 'ERRORCODE 1' (which means:
340 # 'everything fine') is actually a failure. In case of verbose error
341 # output it is needed to copy self.out to self.err.
342 if '\nERRORCODE 1\n' not in self.out:
344 return self._show_error()
348 def copyin(self, dst, src):
350 Copy file to the D64 image. Destination filename has to be corrected.
352 LOG.info("Copy into D64 %s as %s", src, dst)
353 dst = self._correct_fname(dst)
355 if not self._call_command('write', src=src, dst=dst):
356 return self._show_error()
360 def copyout(self, src, dst):
362 Copy file form the D64 image. Source filename has to be corrected,
363 since it's representation differ from the real one inside D64 image.
365 LOG.info("Copy form D64 %s as %s", src, dst)
366 if not src.endswith(".prg"):
369 src = self._get_masked_fname(src)
371 if not self._call_command('read', src=src, dst=dst):
372 return self._show_error()
376 def _correct_fname(self, fname):
378 Return filename with mapped characters, without .prg extension.
379 Characters like $, *, + in filenames are perfectly legal, but c1541
380 program seem to have issues with it while writing, so it will also be
383 char_map = {'|': "/",
389 if fname.lower().endswith(".prg"):
394 trans = char_map.get(char)
395 new_fname.append(trans if trans else char)
397 return "".join(new_fname)
399 def _get_masked_fname(self, fname):
401 Return masked filename with '?' jokers instead of non ASCII
402 characters, useful for copying or deleting files with c1541. In case
403 of several files with same name exists in directory, only first one
404 will be operative (first as appeared in directory).
406 Warning! If there are two different names but the only difference is in
407 non-ASCII characters (some PET ASCII or control characters) there is
408 a risk that one can remove both files.
410 directory = self._get_dir()
412 for entry in directory:
413 if entry['display_name'] == fname:
414 return entry['pattern_name']
418 Retrieve directory via c1541 program
425 if not self._call_command('list'):
426 return self._show_error()
429 for line in self.out.split("\n"):
430 if Uc1541.PRG.match(line):
431 blocks, fname, ext, rw = Uc1541.PRG.match(line).groups()
433 if ext == 'del' and self._hide_del:
436 display_name = ".".join([fname, ext])
437 pattern_name = self.pyd64[idx]['fname']
439 if '/' in display_name:
440 display_name = display_name.replace('/', '|')
442 # workaround for space and dash at the beggining of the
444 char_map = {' ': '~',
446 display_name = "".join([char_map.get(display_name[0],
453 perms = "-r%s-r--r--" % (rw.strip() and "-" or "w")
455 directory.append({'pattern_name': pattern_name,
456 'display_name': display_name,
459 'size': int(blocks) * 256,
464 def _show_error(self):
466 Pass out error output from c1541 execution
473 def _call_command(self, cmd, src=None, dst=None):
475 Return status of the provided command, which can be one of:
481 command = ['c1541', '-attach', self.arch, '-%s' % cmd]
486 command.append(src and src or dst)
488 self.out, self.err = Popen(command, stdout=PIPE,
489 stderr=PIPE).communicate()
493 CALL_MAP = {'list': lambda a: Uc1541(a.ARCH).list(),
494 'copyin': lambda a: Uc1541(a.ARCH).copyin(a.SRC, a.DST),
495 'copyout': lambda a: Uc1541(a.ARCH).copyout(a.SRC, a.DST),
496 'rm': lambda a: Uc1541(a.ARCH).rm(a.DST)}
501 Use ArgumentParser to check for script arguments and execute.
503 parser = ArgumentParser()
504 subparsers = parser.add_subparsers(help='supported commands')
505 parser_list = subparsers.add_parser('list', help="List contents of D64 "
507 parser_copyin = subparsers.add_parser('copyin', help="Copy file into D64 "
509 parser_copyout = subparsers.add_parser('copyout', help="Copy file out of "
511 parser_rm = subparsers.add_parser('rm', help="Delete file from D64 image")
513 parser_list.add_argument('ARCH', help="D64 Image filename")
514 parser_list.set_defaults(func=CALL_MAP['list'])
516 parser_copyin.add_argument('ARCH', help="D64 Image filename")
517 parser_copyin.add_argument('SRC', help="Source filename")
518 parser_copyin.add_argument('DST', help="Destination filename (to be "
519 "written into D64 image)")
520 parser_copyin.set_defaults(func=CALL_MAP['copyin'])
522 parser_copyout.add_argument('ARCH', help="D64 Image filename")
523 parser_copyout.add_argument('SRC', help="Source filename (to be read from"
525 parser_copyout.add_argument('DST', help="Destination filename")
526 parser_copyout.set_defaults(func=CALL_MAP['copyout'])
528 parser_rm.add_argument('ARCH', help="D64 Image filename")
529 parser_rm.add_argument('DST', help="File inside D64 image to be deleted")
530 parser_rm.set_defaults(func=CALL_MAP['rm'])
532 args = parser.parse_args()
533 return args.func(args)
538 Failsafe argument "parsing". Note, that it blindly takes positional
539 arguments without checking them. In case of wrong arguments it will
543 if sys.argv[1] not in ('list', 'copyin', 'copyout', 'rm'):
556 arg.ARCH = sys.argv[2]
557 if sys.argv[1] in ('copyin', 'copyout'):
558 arg.SRC = sys.argv[3]
559 arg.DST = sys.argv[4]
560 elif sys.argv[1] == 'rm':
561 arg.DST = sys.argv[3]
565 CALL_MAP[sys.argv[1]](arg)
567 if __name__ == "__main__":
568 LOG.debug("Script params: %s", str(sys.argv))
570 from argparse import ArgumentParser
571 parse_func = parse_args
573 parse_func = no_parse