├── .gitignore ├── LICENSE ├── README.md ├── awyeah.py ├── bwv578.mid ├── invent13.mid ├── midi ├── LICENSE └── midi.py ├── pico8 ├── LICENSE ├── __init__.py ├── demos │ ├── __init__.py │ └── upsidedown.py ├── game │ ├── __init__.py │ └── game.py ├── gff │ ├── __init__.py │ └── gff.py ├── gfx │ ├── __init__.py │ └── gfx.py ├── lua │ ├── __init__.py │ ├── lexer.py │ ├── lua.py │ └── parser.py ├── map │ ├── __init__.py │ └── map.py ├── music │ ├── __init__.py │ └── music.py ├── sfx │ ├── __init__.py │ └── sfx.py ├── tool.py └── util.py └── translator ├── __init__.py ├── note.py ├── sfx.py ├── sfxcompactor.py └── translator.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Python cache stuff 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # PICO-8 carts 6 | *.p8 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Andrew Anderson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kittenm4ster's MIDI to PICO-8 Tracker Translator 2 | "It just works, sometimes!" 3 | 4 | ## Prequisites 5 | * python 3.5 6 | 7 | ## How To Use 8 | usage: awyeah.py [-h] [--legato] [--staccato] [--no-fix-octaves] 9 | [--no-quantize] [-t MIDI_BASE_TICKS] [-d NOTE_DURATION] 10 | [--midi-offset MIDI_OFFSET] [--sfx-offset SFX_OFFSET] 11 | [--pattern-offset PATTERN_OFFSET] [--no-compact] 12 | [--no-trim-silence] [--waveform [WAVEFORM [WAVEFORM ...]]] 13 | [--octave-shift [OCTAVE_SHIFT [OCTAVE_SHIFT ...]]] 14 | [--volume-shift [VOLUME_SHIFT [VOLUME_SHIFT ...]]] 15 | [--mute [MUTE [MUTE ...]]] 16 | midiPath [cartPath] 17 | 18 | positional arguments: 19 | midiPath The path to the MIDI file to be translated 20 | cartPath The path to PICO-8 cartridge file to be generated 21 | 22 | optional arguments: 23 | -h, --help show this help message and exit 24 | --legato Disable fadeout effect at the end of any notes (even 25 | repeated notes) 26 | --staccato Add a fadeout effect at the end of every note 27 | --no-fix-octaves Do not change octaves of tracks to keep them in PICO-8 28 | range 29 | --no-quantize Do not perform any quantization of note lengths 30 | -t MIDI_BASE_TICKS, --midi-base-ticks MIDI_BASE_TICKS 31 | Override MIDI ticks per PICO-8 note setting (normally 32 | auto-detected) 33 | -d NOTE_DURATION, --note-duration NOTE_DURATION 34 | Override PICO-8 note duration setting (normally auto- 35 | detected from MIDI tempo) 36 | --midi-offset MIDI_OFFSET 37 | Change the start point in the MIDI file (in # of 38 | PICO-8 SFX) 39 | --sfx-offset SFX_OFFSET 40 | Change the starting SFX slot in PICO-8 41 | --pattern-offset PATTERN_OFFSET 42 | Change the starting music pattern slot in PICO-8 43 | --no-compact Don't try to compact groups of repeated notes into 44 | fewer notes played for longer (this compacting is 45 | sometimes slow, so using this flag will speed up 46 | processing time at the cost of possibly occupying more 47 | SFXes in the PICO-8 cart) 48 | --no-trim-silence Don't trim silence off the beginning 49 | --waveform [WAVEFORM [WAVEFORM ...]] 50 | Specify which PICO-8 waveform (instrument) number to 51 | use for each MIDI track 52 | --octave-shift [OCTAVE_SHIFT [OCTAVE_SHIFT ...]] 53 | Specify the number of octaves to shift each MIDI track 54 | --volume-shift [VOLUME_SHIFT [VOLUME_SHIFT ...]] 55 | Specify a number to add to the volume of all notes in 56 | each MIDI track (volume for each note will be limited 57 | to the range 1-7 58 | --mute [MUTE [MUTE ...]] 59 | Specify whether to "mute" each MIDI track (1 = mute, 0 60 | = do not mute). Notes for a muted track will be 61 | excluded from the PICO-8 cartridge entirely 62 | 63 | 64 | ## Please Note 65 | MIDI format stores music in a conceptually different way than PICO-8's tracker 66 | does. Because of this fundamental difference, conversion from MIDI to PICO-8 67 | tracker format will never be 100% perfect in all cases. 68 | 69 | Furthermore, in order to keep the scope of this program manageable, I have 70 | chosen (at least for now) to concentrate only on conversion of a *certain type* 71 | of MIDI file which has the following characterstics: 72 | 73 | * Each "voice" should be on a different track 74 | * This also means that MIDI file format Type 0 (the format that puts all 75 | voices on only 1 track) is not currently supported. 76 | * Multiple notes should never be playing at the same time on the same track 77 | * The tempo should not change during the song 78 | * The song should have a maximum of 4 tracks playing at once 79 | * This program maps MIDI tracks to PICO-8 channels, so only 4 can exist at 80 | the same time. The program does dynamically (every SFX boundary) switch 81 | which 4 MIDI tracks are playing, however, to try to accomodate more tracks: 82 | if a MIDI track is *silent* during one (SFX-aligned) 32-note section, that 83 | track will be skipped in favor of the next higher-numbered track(s) that 84 | *are* currently playing notes. 85 | * The rhythms should be regular/quantized 86 | * This program will attempt to do a very basic level of quantization, but if 87 | the MIDI file has rhythms that are not very regular to begin with, the 88 | result will not be good and tracks may get out of sync. 89 | * It should be short enough (and/or use a low enough rhythm resolution) to fit 90 | within the 64 SFX banks of the PICO-8 91 | * The program will try to fit as much of the song in as it can, including 92 | re-using repeated SFXes, but it can't perform miracles. 93 | * It should not use any drums (channel 10) 94 | * Right now, any MIDI notes on channel 10 will be discarded. 95 | 96 | TLDR: 97 | If you use a MIDI with the right characteristics, this program can and does 98 | produce a great result! If you use anything else, don't be surprised if the 99 | result is a grotesque monstrosity :) 100 | 101 | ## Bugs 102 | There are probably lots of bugs! This is a thing written for fun and it should 103 | be considered to be in an "work in progress" state. 104 | 105 | ## Libraries/Special Thanks 106 | * [picotool](https://github.com/dansanderson/picotool) 107 | * [python3-midi-parser](https://github.com/akionux/python3-midi-parser) 108 | 109 | ## To Do List 110 | These are things that are totally unimplemented now but that I may try to 111 | implement in the future: 112 | * An automatic best guess MIDI-instrument-to-PICO-8-waveform mapping table 113 | * Automatic combination of multiple tracks into one (in places where both are 114 | not playing notes at the same time) 115 | * MIDI file type 0 support 116 | * Drums (channel 10) support 117 | * Tempo changes during the song 118 | * Pitch-bend or other effects??? 119 | -------------------------------------------------------------------------------- /awyeah.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.5 2 | 3 | import argparse 4 | import math 5 | import sys 6 | from translator import translator 7 | from midi import midi 8 | from pico8.game import game 9 | 10 | # Constants 11 | PICO8_NUM_CHANNELS = 4 12 | PICO8_NUM_SFX = 64 13 | PICO8_NUM_MUSIC = 64 14 | PICO8_MAX_PITCH = 63 15 | PICO8_MIN_VOLUME = 1 16 | PICO8_MAX_VOLUME = 7 17 | MIDI_MAX_TRACKS = 128 18 | 19 | # Defaults for Song-Specific Config 20 | songConfig = { 21 | 'mute': MIDI_MAX_TRACKS * [0], 22 | 'octaveShift': MIDI_MAX_TRACKS * [0], 23 | 'volumeShift': MIDI_MAX_TRACKS * [0], 24 | 'waveform': MIDI_MAX_TRACKS * [0] 25 | } 26 | 27 | # Assign default waveforms to the first 128 MIDI tracks 28 | w = 0 29 | for track in range(MIDI_MAX_TRACKS): 30 | songConfig['waveform'][track] = w 31 | w += 1 32 | if w == 6: 33 | w = 0 34 | 35 | # Parse command-line arguments 36 | argParser = argparse.ArgumentParser() 37 | argParser.add_argument( 38 | 'midiPath', 39 | help="The path to the MIDI file to be translated") 40 | argParser.add_argument( 41 | 'cartPath', 42 | help="The path to PICO-8 cartridge file to be generated", 43 | nargs='?', 44 | default='midi_out.p8') 45 | argParser.add_argument( 46 | '--legato', 47 | help="Disable fadeout effect at the end of any notes (even repeated " + 48 | "notes)", 49 | action="store_true") 50 | argParser.add_argument( 51 | '--staccato', 52 | help="Add a fadeout effect at the end of every note", 53 | action='store_true') 54 | argParser.add_argument( 55 | '--no-fix-octaves', 56 | help="Do not change octaves of tracks to keep them in PICO-8 range", 57 | action='store_true') 58 | argParser.add_argument( 59 | '--no-quantize', 60 | help="Do not perform any quantization of note lengths", 61 | action='store_true') 62 | argParser.add_argument( 63 | '-t', 64 | '--midi-base-ticks', 65 | help="Override MIDI ticks per PICO-8 note setting (normally " + 66 | "auto-detected)", 67 | type=int) 68 | argParser.add_argument( 69 | '-d', 70 | '--note-duration', 71 | help="Override PICO-8 note duration setting (normally auto-detected " + 72 | "from MIDI tempo)", 73 | type=int) 74 | argParser.add_argument( 75 | '--midi-offset', 76 | help="Change the start point in the MIDI file (in # of PICO-8 SFX)", 77 | type=int, 78 | default=0) 79 | argParser.add_argument( 80 | '--sfx-offset', 81 | help="Change the starting SFX slot in PICO-8", 82 | type=int, 83 | default=0) 84 | argParser.add_argument( 85 | '--pattern-offset', 86 | help="Change the starting music pattern slot in PICO-8", 87 | type=int, 88 | default=0) 89 | argParser.add_argument( 90 | '--no-compact', 91 | help="Don't try to compact groups of repeated notes into fewer " + 92 | "notes played for longer (this compacting is sometimes slow, " + 93 | "so using this flag will speed up processing time at the cost " + 94 | "of possibly occupying more SFXes in the PICO-8 cart)", 95 | action='store_true') 96 | argParser.add_argument( 97 | '--no-trim-silence', 98 | help="Don't trim silence off the beginning", 99 | action='store_true') 100 | argParser.add_argument( 101 | '--waveform', 102 | help="Specify which PICO-8 waveform (instrument) number to use for " + 103 | "each MIDI track", 104 | nargs='*', 105 | type=int, 106 | default=songConfig['waveform']) 107 | argParser.add_argument( 108 | '--octave-shift', 109 | help="Specify the number of octaves to shift each MIDI track", 110 | nargs='*', 111 | type=int, 112 | default=songConfig['octaveShift']) 113 | argParser.add_argument( 114 | '--volume-shift', 115 | help='Specify a number to add to the volume of all notes in each ' + 116 | 'MIDI track (volume for each note will be limited to the ' + 117 | 'range 1-7', 118 | nargs='*', 119 | type=int, 120 | default=songConfig['volumeShift']) 121 | argParser.add_argument( 122 | '--mute', 123 | help='Specify whether to "mute" each MIDI track ' + 124 | '(1 = mute, 0 = do not mute). Notes for a muted track will be ' + 125 | 'excluded from the PICO-8 cartridge entirely', 126 | nargs='*', 127 | type=int, 128 | default=songConfig['mute']) 129 | 130 | args = argParser.parse_args() 131 | 132 | # Set translator settings according to command-line arugments 133 | translatorSettings = translator.TranslatorSettings() 134 | translatorSettings.quantization = not args.no_quantize 135 | translatorSettings.ticksPerNoteOverride = args.midi_base_ticks 136 | translatorSettings.staccato = args.staccato 137 | translatorSettings.legato = args.legato 138 | translatorSettings.fixOctaves = not args.no_fix_octaves 139 | translatorSettings.noteDurationOverride = args.note_duration 140 | translatorSettings.sfxCompactor = not args.no_compact 141 | translatorSettings.trimSilence = not args.no_trim_silence 142 | 143 | # Set song-specific tracker-related settings from command-line arguments 144 | for i, value in enumerate(args.waveform): 145 | songConfig['waveform'][i] = value 146 | for i, value in enumerate(args.octave_shift): 147 | songConfig['octaveShift'][i] = value 148 | for i, value in enumerate(args.volume_shift): 149 | songConfig['volumeShift'][i] = value 150 | for i, value in enumerate(args.mute): 151 | songConfig['mute'][i] = value 152 | 153 | # Open the MIDI file 154 | midiFile = midi.MidiFile() 155 | midiFile.open(args.midiPath) 156 | midiFile.read() 157 | 158 | translator = translator.Translator(midiFile, translatorSettings) 159 | 160 | translator.analyze() 161 | 162 | # Get all the notes converted to "tracks" where a "track" is a list of 163 | # translator.Sfx objects 164 | tracks = translator.get_sfx_lists() 165 | 166 | # Make an empty PICO-8 catridge 167 | cart = game.Game.make_empty_game() 168 | lines = [ 169 | 'music(' + str(args.pattern_offset) + ')\n', 170 | 'function _update()\n', 171 | 'end'] 172 | cart.lua.update_from_lines(lines) 173 | 174 | if args.midi_offset > 0: 175 | # Remove SFXes from the beginning of each track, based on the "start 176 | # offset" parameter 177 | for t, track in enumerate(tracks): 178 | tracks[t] = track[args.midi_offset:] 179 | 180 | def clamp(n, minn, maxn): 181 | return max(min(maxn, n), minn) 182 | 183 | # SfxDuplicateDetector creates a map of each trackSfx (which is a group of 32 184 | # notes in a track) and the PICO-8 SFX index to which it was written, so that 185 | # it can check if a given trackSfx was already written to the PICO-8 catridge 186 | # and instead return the existing SFX index so the music pattern can use that. 187 | class SfxDuplicateDetector: 188 | def __init__(self): 189 | self.map = {} 190 | map = {} 191 | 192 | def record_tracksfx_index(self, sfxIndex, trackSfx): 193 | self.map[sfxIndex] = trackSfx 194 | 195 | @staticmethod 196 | def sfx_match(sfx1, sfx2): 197 | if len(sfx1.notes) != len(sfx2.notes) or sfx1.noteDuration != sfx2.noteDuration: 198 | return False 199 | 200 | for i in range(len(sfx1.notes)): 201 | note1 = sfx1.notes[i] 202 | note2 = sfx2.notes[i] 203 | 204 | if (note1.pitch != note2.pitch or 205 | note1.volume != note2.volume or 206 | note1.waveform != note2.waveform or 207 | note1.effect != note2.effect or 208 | note1.length != note2.length): 209 | return False 210 | 211 | return True 212 | 213 | def find_duplicate_sfx_index(self, sfx): 214 | for sfxIndex, existingSfx in self.map.items(): 215 | if SfxDuplicateDetector.sfx_match(existingSfx, sfx): 216 | return sfxIndex 217 | 218 | sfxDuplicateDetector = SfxDuplicateDetector() 219 | duplicateSfxSavingsCount = 0 220 | 221 | trackSfxIndex = 0 222 | musicIndex = args.pattern_offset 223 | sfxIndex = args.sfx_offset 224 | while sfxIndex < PICO8_NUM_SFX: 225 | wroteAnythingToMusic = False 226 | channelIndex = 0 227 | for t, track in enumerate(tracks): 228 | wroteAnyNotesToSfx = False 229 | wroteAnythingToChannel = False 230 | 231 | # Get the trackSfx, which is the next group of 32 notes in this track 232 | if len(track) - 1 < trackSfxIndex: 233 | continue 234 | trackSfx = track[trackSfxIndex] 235 | 236 | # If there is a "mute" specified for this track 237 | if songConfig['mute'][t] == 1: 238 | continue 239 | 240 | # Check if this SFX is a duplicate of any that have already been 241 | # written 242 | duplicateSfxIndex = sfxDuplicateDetector.find_duplicate_sfx_index( 243 | trackSfx) 244 | if duplicateSfxIndex != None: 245 | # Add the SFX to a music pattern 246 | cart.music.set_channel(musicIndex, channelIndex, duplicateSfxIndex) 247 | wroteAnythingToMusic = True 248 | wroteAnythingToChannel = True 249 | duplicateSfxSavingsCount += 1 250 | elif sfxIndex < PICO8_NUM_SFX: 251 | # Set the properites for this SFX 252 | cart.sfx.set_properties( 253 | sfxIndex, 254 | editor_mode=1, 255 | loop_start=0, 256 | loop_end=0, 257 | note_duration=trackSfx.noteDuration) 258 | 259 | # Add the 32 notes in this trackSfx 260 | for n, note in enumerate(trackSfx.notes): 261 | if note.volume > 0: 262 | pitch = note.pitch 263 | volume = note.volume 264 | 265 | # If there is a manual octave shift specified for this track 266 | octaveShift = songConfig['octaveShift'][t] 267 | if octaveShift != 0: 268 | pitch = note.pitch + (12 * octaveShift) 269 | 270 | # Shift the volume as specified for this track 271 | volume += songConfig['volumeShift'][t] 272 | volume = clamp(volume, PICO8_MIN_VOLUME, PICO8_MAX_VOLUME) 273 | 274 | wroteAnyNotesToSfx = True 275 | noteIsInRange = (pitch >= 0 and pitch <= PICO8_MAX_PITCH) 276 | if noteIsInRange: 277 | # Add this note to the current PICO-8 SFX 278 | cart.sfx.set_note( 279 | sfxIndex, 280 | n, 281 | pitch = pitch, 282 | volume = volume, 283 | effect = note.effect, 284 | waveform = songConfig['waveform'][t]) 285 | 286 | if wroteAnyNotesToSfx: 287 | # Store the PICO-8 track number that this section of the track went in 288 | sfxDuplicateDetector.record_tracksfx_index(sfxIndex, trackSfx) 289 | 290 | # Add the SFX to a music pattern 291 | cart.music.set_channel(musicIndex, channelIndex, sfxIndex) 292 | wroteAnythingToMusic = True 293 | wroteAnythingToChannel = True 294 | 295 | # Move to the next SFX 296 | sfxIndex += 1 297 | 298 | if wroteAnythingToChannel: 299 | # Move to the next PICO-8 music channel 300 | channelIndex += 1 301 | if channelIndex > PICO8_NUM_CHANNELS - 1: 302 | break 303 | 304 | trackSfxIndex += 1 305 | if wroteAnythingToMusic: 306 | musicIndex += 1 307 | if musicIndex > PICO8_NUM_MUSIC - 1: 308 | print('reached max music patterns') 309 | break 310 | 311 | # Check if the trackSfxIndex is past the end of all tracks 312 | allTracksAreEnded = True 313 | for track in tracks: 314 | if trackSfxIndex < len(track): 315 | allTracksAreEnded = False 316 | break 317 | if allTracksAreEnded: 318 | break 319 | 320 | if (duplicateSfxSavingsCount > 0): 321 | print('optimized {0} occurences of duplicate SFX'.format(duplicateSfxSavingsCount)) 322 | 323 | # Write the cart 324 | with open(args.cartPath, 'w', encoding='utf-8') as fh: 325 | cart.to_p8_file(fh) 326 | -------------------------------------------------------------------------------- /bwv578.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andmatand/midi-to-pico8/371e52a584631d668ed127f7d43678cd0916fc1f/bwv578.mid -------------------------------------------------------------------------------- /invent13.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andmatand/midi-to-pico8/371e52a584631d668ed127f7d43678cd0916fc1f/invent13.mid -------------------------------------------------------------------------------- /midi/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012 Akio Nishimura 4 | 5 | Copyright (c) 2001 Will Ware 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /midi/midi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | midi.py -- MIDI classes and parser in Python 6 | Convered Python 2 code to Python 3 code in December 2012 by Akio Nishimura 7 | Placed into the public domain in December 2001 by Will Ware 8 | Python MIDI classes: meaningful data structures that represent MIDI 9 | events 10 | and other objects. You can read MIDI files to create such objects, or 11 | generate a collection of objects and use them to write a MIDI file. 12 | Helpful MIDI info: 13 | http://crystal.apana.org.au/ghansper/midi_introduction/midi_file_form... 14 | http://www.argonet.co.uk/users/lenny/midi/mfile.html 15 | """ 16 | import sys, string, types, io 17 | debugflag = 0 18 | #import io 19 | #sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') 20 | def showstr(tmpstr, n=16): 21 | for x in tmpstr[:n]: 22 | print(('%02x' % x), end=' ') 23 | print() 24 | def getNumber(tmpstr, length): 25 | # MIDI uses big-endian for everything 26 | sum = 0 27 | for i in range(length): 28 | sum = (sum << 8) + tmpstr[i] 29 | return sum, tmpstr[length:] 30 | def getVariableLengthNumber(tmpstr): 31 | sum = 0 32 | i = 0 33 | while 1: 34 | x = tmpstr[i] 35 | i = i + 1 36 | sum = (sum << 7) + (x & 0x7F) 37 | if not (x & 0x80): 38 | return sum, tmpstr[i:] 39 | def putNumber(num, length): 40 | # MIDI uses big-endian for everything 41 | lst = [ ] 42 | for i in range(length): 43 | n = 8 * (length - 1 - i) 44 | lst.append(chr((num >> n) & 0xFF)) 45 | return str([lst, ""]) 46 | def putVariableLengthNumber(x): 47 | lst = [ ] 48 | while 1: 49 | y, x = x & 0x7F, x >> 7 50 | lst.append(chr(y + 0x80)) 51 | if x == 0: 52 | break 53 | lst.reverse() 54 | lst[-1] = chr(ord(lst[-1]) & 0x7f) 55 | return str([lst, ""]) 56 | class EnumException(Exception): 57 | pass 58 | class Enumeration: 59 | def __init__(self, enumList): 60 | lookup = { } 61 | reverseLookup = { } 62 | i = 0 63 | uniqueNames = [ ] 64 | uniqueValues = [ ] 65 | for x in enumList: 66 | if type(x) == tuple: 67 | x, i = x 68 | if type(x) != str: 69 | raise EnumException("enum name is not a string: " + x) 70 | if type(i) != int: 71 | raise EnumException("enum value is not an integer: " + i) 72 | if x in uniqueNames: 73 | raise EnumException("enum name is not unique: " + x) 74 | if i in uniqueValues: 75 | raise EnumException("enum value is not unique for " + x) 76 | uniqueNames.append(x) 77 | uniqueValues.append(i) 78 | lookup[x] = i 79 | reverseLookup[i] = x 80 | i = i + 1 81 | self.lookup = lookup 82 | self.reverseLookup = reverseLookup 83 | def __add__(self, other): 84 | lst = [ ] 85 | for k in list(self.lookup.keys()): 86 | lst.append((k, self.lookup[k])) 87 | for k in list(other.lookup.keys()): 88 | lst.append((k, other.lookup[k])) 89 | return Enumeration(lst) 90 | def hasattr(self, attr): 91 | return attr in self.lookup 92 | def has_value(self, attr): 93 | return attr in self.reverseLookup 94 | def __getattr__(self, attr): 95 | if attr not in self.lookup: 96 | raise AttributeError 97 | return self.lookup[attr] 98 | def whatis(self, value): 99 | return self.reverseLookup[value] 100 | channelVoiceMessages = Enumeration([("NOTE_OFF", 0x80), 101 | ("NOTE_ON", 0x90), 102 | ("POLYPHONIC_KEY_PRESSURE", 0xA0), 103 | ("CONTROLLER_CHANGE", 0xB0), 104 | ("PROGRAM_CHANGE", 0xC0), 105 | ("CHANNEL_KEY_PRESSURE", 0xD0), 106 | ("PITCH_BEND", 0xE0)]) 107 | channelModeMessages = Enumeration([("ALL_SOUND_OFF", 0x78), 108 | ("RESET_ALL_CONTROLLERS", 0x79), 109 | ("LOCAL_CONTROL", 0x7A), 110 | ("ALL_NOTES_OFF", 0x7B), 111 | ("OMNI_MODE_OFF", 0x7C), 112 | ("OMNI_MODE_ON", 0x7D), 113 | ("MONO_MODE_ON", 0x7E), 114 | ("POLY_MODE_ON", 0x7F)]) 115 | metaEvents = Enumeration([("SEQUENCE_NUMBER", 0x00), 116 | ("TEXT_EVENT", 0x01), 117 | ("COPYRIGHT_NOTICE", 0x02), 118 | ("SEQUENCE_TRACK_NAME", 0x03), 119 | ("INSTRUMENT_NAME", 0x04), 120 | ("LYRIC", 0x05), 121 | ("MARKER", 0x06), 122 | ("CUE_POINT", 0x07), 123 | ("MIDI_CHANNEL_PREFIX", 0x20), 124 | ("MIDI_PORT", 0x21), 125 | ("END_OF_TRACK", 0x2F), 126 | ("SET_TEMPO", 0x51), 127 | ("SMTPE_OFFSET", 0x54), 128 | ("TIME_SIGNATURE", 0x58), 129 | ("KEY_SIGNATURE", 0x59), 130 | ("SEQUENCER_SPECIFIC_META_EVENT", 0x7F)]) 131 | # runningStatus appears to want to be an attribute of a MidiTrack. But 132 | # it doesn't seem to do any harm to implement it as a global. 133 | runningStatus = None 134 | class MidiEvent: 135 | def __init__(self, track): 136 | self.track = track 137 | self.time = None 138 | self.channel = self.pitch = self.velocity = self.data = None 139 | def __cmp__(self, other): 140 | # assert self.time != None and other.time != None 141 | return cmp(self.time, other.time) 142 | def __repr__(self): 143 | r = ("" 152 | def read(self, time, tmpstr): 153 | global runningStatus 154 | self.time = time 155 | #print('%02x' % tmpstr[0]) 156 | # do we need to use running status? 157 | if not (tmpstr[0] & 0x80): 158 | tmpstr = bytes([runningStatus]) + tmpstr 159 | runningStatus = x = tmpstr[0] 160 | y = x & 0xF0 161 | z = tmpstr[1] 162 | if channelVoiceMessages.has_value(y): 163 | self.channel = (x & 0x0F) + 1 164 | self.type = channelVoiceMessages.whatis(y) 165 | if (self.type == "PROGRAM_CHANGE" or 166 | self.type == "CHANNEL_KEY_PRESSURE"): 167 | self.data = z 168 | return tmpstr[2:] 169 | else: 170 | self.pitch = z 171 | self.velocity = tmpstr[2] 172 | channel = self.track.channels[self.channel - 1] 173 | if (self.type == "NOTE_OFF" or 174 | (self.velocity == 0 and self.type == "NOTE_ON")): 175 | channel.noteOff(self.pitch, self.time) 176 | elif self.type == "NOTE_ON": 177 | channel.noteOn(self.pitch, self.time, self.velocity) 178 | return tmpstr[3:] 179 | elif y == 0xB0 and channelModeMessages.has_value(z): 180 | self.channel = (x & 0x0F) + 1 181 | self.type = channelModeMessages.whatis(z) 182 | if self.type == "LOCAL_CONTROL": 183 | self.data = (tmpstr[2] == 0x7F) 184 | elif self.type == "MONO_MODE_ON": 185 | self.data = tmpstr[2] 186 | return tmpstr[3:] 187 | elif x == 0xF0 or x == 0xF7: 188 | self.type = {0xF0: "F0_SYSEX_EVENT", 189 | 0xF7: "F7_SYSEX_EVENT"}[x] 190 | length, tmpstr = getVariableLengthNumber(tmpstr[1:]) 191 | self.data = tmpstr[:length] 192 | return tmpstr[length:] 193 | elif x == 0xFF: 194 | if not metaEvents.has_value(z): 195 | print("Unknown meta event: FF %02X" % z) 196 | sys.stdout.flush() 197 | raise Exception("Unknown midi event type") 198 | self.type = metaEvents.whatis(z) 199 | length, tmpstr = getVariableLengthNumber(tmpstr[2:]) 200 | self.data = tmpstr[:length] 201 | return tmpstr[length:] 202 | raise Exception("Unknown midi event type") 203 | def write(self): 204 | sysex_event_dict = {"F0_SYSEX_EVENT": 0xF0, 205 | "F7_SYSEX_EVENT": 0xF7} 206 | if channelVoiceMessages.hasattr(self.type): 207 | x = chr((self.channel - 1) + 208 | getattr(channelVoiceMessages, self.type)) 209 | if (self.type != "PROGRAM_CHANGE" and 210 | self.type != "CHANNEL_KEY_PRESSURE"): 211 | data = chr(self.pitch) + chr(self.velocity) 212 | else: 213 | data = chr(self.data) 214 | return x + data 215 | elif channelModeMessages.hasattr(self.type): 216 | x = getattr(channelModeMessages, self.type) 217 | x = (chr(0xB0 + (self.channel - 1)) + 218 | chr(x) + 219 | chr(self.data)) 220 | return x 221 | elif self.type in sysex_event_dict: 222 | tmpstr = chr(sysex_event_dict[self.type]) 223 | tmpstr = tmpstr + putVariableLengthNumber(len(self.data)) 224 | return tmpstr + str(self.data) 225 | elif metaEvents.hasattr(self.type): 226 | tmpstr = chr(0xFF) + chr(getattr(metaEvents, self.type)) 227 | tmpstr = tmpstr + putVariableLengthNumber(len(self.data)) 228 | return tmpstr + str(self.data) 229 | else: 230 | raise Exception("unknown midi event type: " + self.type) 231 | """ 232 | register_note() is a hook that can be overloaded from a script that 233 | imports this module. Here is how you might do that, if you wanted to 234 | store the notes as tuples in a list. Including the distinction 235 | between track and channel offers more flexibility in assigning voices. 236 | import midi 237 | notelist = [ ] 238 | def register_note(t, c, p, v, t1, t2): 239 | notelist.append((t, c, p, v, t1, t2)) 240 | midi.register_note = register_note 241 | """ 242 | def register_note(track_index, channel_index, pitch, velocity, 243 | keyDownTime, keyUpTime): 244 | pass 245 | class MidiChannel: 246 | """A channel (together with a track) provides the continuity 247 | connecting 248 | a NOTE_ON event with its corresponding NOTE_OFF event. Together, 249 | those 250 | define the beginning and ending times for a Note.""" 251 | def __init__(self, track, index): 252 | self.index = index 253 | self.track = track 254 | self.pitches = { } 255 | def __repr__(self): 256 | return "" % self.index 257 | def noteOn(self, pitch, time, velocity): 258 | self.pitches[pitch] = (time, velocity) 259 | def noteOff(self, pitch, time): 260 | if pitch in self.pitches: 261 | keyDownTime, velocity = self.pitches[pitch] 262 | register_note(self.track.index, self.index, pitch, velocity, 263 | keyDownTime, time) 264 | del self.pitches[pitch] 265 | # The case where the pitch isn't in the dictionary is illegal, 266 | # I think, but we probably better just ignore it. 267 | class DeltaTime(MidiEvent): 268 | type = "DeltaTime" 269 | def read(self, oldtmpstr): 270 | self.time, newtmpstr = getVariableLengthNumber(oldtmpstr) 271 | return self.time, newtmpstr 272 | def write(self): 273 | tmpstr = putVariableLengthNumber(self.time) 274 | return tmpstr 275 | class MidiTrack: 276 | def __init__(self, index): 277 | self.index = index 278 | self.events = [ ] 279 | self.channels = [ ] 280 | self.length = 0 281 | for i in range(16): 282 | self.channels.append(MidiChannel(self, i+1)) 283 | def read(self, tmpstr): 284 | time = 0 285 | assert tmpstr[:4] == b"MTrk" 286 | length, tmpstr = getNumber(tmpstr[4:], 4) 287 | self.length = length 288 | mytmpstr = tmpstr[:length] 289 | remainder = tmpstr[length:] 290 | while mytmpstr: 291 | delta_t = DeltaTime(self) 292 | dt, mytmpstr = delta_t.read(mytmpstr) 293 | time = time + dt 294 | self.events.append(delta_t) 295 | e = MidiEvent(self) 296 | mytmpstr = e.read(time, mytmpstr) 297 | self.events.append(e) 298 | return remainder 299 | def write(self): 300 | time = self.events[0].time 301 | # build tmpstr using MidiEvents 302 | tmpstr = "" 303 | for e in self.events: 304 | tmpstr = tmpstr + e.write() 305 | return "MTrk" + putNumber(len(tmpstr), 4) + tmpstr 306 | def __repr__(self): 307 | r = "" 312 | class MidiFile: 313 | def __init__(self): 314 | self.file = None 315 | self.format = 1 316 | self.tracks = [ ] 317 | self.ticksPerQuarterNote = None 318 | self.ticksPerSecond = None 319 | def open(self, filename, attrib="rb"): 320 | if filename == None: 321 | if attrib in ["r", "rb"]: 322 | self.file = sys.stdin 323 | else: 324 | self.file = sys.stdout 325 | else: 326 | self.file = open(filename, attrib) 327 | def __repr__(self): 328 | r = "" 332 | def close(self): 333 | #self.file.close() 334 | pass 335 | def read(self): 336 | self.readstr(self.file.read()) 337 | def readstr(self, tmpstr): 338 | assert tmpstr[:4] == b"MThd" 339 | length, tmpstr = getNumber(tmpstr[4:], 4) 340 | assert length == 6 341 | format, tmpstr = getNumber(tmpstr, 2) 342 | self.format = format 343 | assert format == 0 or format == 1 # dunno how to handle 2 344 | numTracks, tmpstr = getNumber(tmpstr, 2) 345 | division, tmpstr = getNumber(tmpstr, 2) 346 | if division & 0x8000: 347 | framesPerSecond = -((division >> 8) | -128) 348 | ticksPerFrame = division & 0xFF 349 | assert ticksPerFrame == 24 or ticksPerFrame == 25 or \ 350 | ticksPerFrame == 29 or ticksPerFrame == 30 351 | if ticksPerFrame == 29: ticksPerFrame = 30 # drop frame 352 | self.ticksPerSecond = ticksPerFrame * framesPerSecond 353 | else: 354 | self.ticksPerQuarterNote = division & 0x7FFF 355 | for i in range(numTracks): 356 | trk = MidiTrack(i) 357 | #print('Track#%d' % i); 358 | tmpstr = trk.read(tmpstr) 359 | self.tracks.append(trk) 360 | def write(self): 361 | self.file.write(self.writestr()) 362 | def writestr(self): 363 | division = self.ticksPerQuarterNote 364 | # Don't handle ticksPerSecond yet, too confusing 365 | assert (division & 0x8000) == 0 366 | tmpstr = "MThd" + putNumber(6, 4) + putNumber(self.format, 2) 367 | tmpstr = tmpstr + putNumber(len(self.tracks), 2) 368 | tmpstr = tmpstr + putNumber(division, 2) 369 | for trk in self.tracks: 370 | tmpstr = tmpstr + trk.write() 371 | return tmpstr 372 | def main(argv): 373 | global debugflag 374 | import getopt 375 | infile = None 376 | outfile = None 377 | printflag = 0 378 | optlist, args = getopt.getopt(argv[1:], "i:o:pd") 379 | for (option, value) in optlist: 380 | if option == '-i': 381 | infile = value 382 | elif option == '-o': 383 | outfile = value 384 | elif option == '-p': 385 | printflag = 1 386 | elif option == '-d': 387 | debugflag = 1 388 | m = MidiFile() 389 | m.open(infile) 390 | m.read() 391 | m.close() 392 | if printflag: 393 | print(m) 394 | else: 395 | m.open(outfile, "wb") 396 | m.write() 397 | m.close() 398 | if __name__ == "__main__": 399 | main(sys.argv) 400 | 401 | -------------------------------------------------------------------------------- /pico8/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dan Sanderson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /pico8/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andmatand/midi-to-pico8/371e52a584631d668ed127f7d43678cd0916fc1f/pico8/__init__.py -------------------------------------------------------------------------------- /pico8/demos/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andmatand/midi-to-pico8/371e52a584631d668ed127f7d43678cd0916fc1f/pico8/demos/__init__.py -------------------------------------------------------------------------------- /pico8/demos/upsidedown.py: -------------------------------------------------------------------------------- 1 | """The main routines for the upsidedown demo. 2 | 3 | Limitations: 4 | 5 | * spr / sspr drawing a rectangle tiles is not yet supported. To do 6 | this, we need to flip the entire spritesheet (not individual 7 | sprites), re-calculate sprite IDs on the map, and translate sprite 8 | ID arguments. 9 | 10 | * print / cursor naturally prints left to right and right-side up, so 11 | the tool compromises and only adjusts the y coordinate. Any cart 12 | that relies on two consecutive positionless prints won't quite do 13 | the right thing. 14 | 15 | * This increases the token count, so large carts can't be turned 16 | upside down. In many cases, the tool will succeed because picotool 17 | under-counts tokens, but the cart won't run when loaded into Pico-8. 18 | 19 | And probably other shortcomings of the tool or the parser that I 20 | haven't found yet. 21 | """ 22 | 23 | __all__ = ['main'] 24 | 25 | import argparse 26 | import tempfile 27 | import textwrap 28 | 29 | from .. import util 30 | from ..game import game 31 | from ..lua import lexer 32 | from ..lua import lua 33 | from ..lua import parser 34 | 35 | 36 | def _get_argparser(): 37 | """Builds and returns the argument parser.""" 38 | parser = argparse.ArgumentParser( 39 | formatter_class=argparse.RawDescriptionHelpFormatter, 40 | usage='%(prog)s [--help] [--smallmap] []', 41 | description=textwrap.dedent(''' 42 | Turns a Pico-8 cart upside down. 43 | 44 | p8upsidedown mycart.p8.png mycart_upsidedown.p8 45 | ''')) 46 | parser.add_argument('--smallmap', action='store_true', 47 | help='assume the cart\'s shared gfx/map region is used ' 48 | 'as gfx; the default is to assume it is used as map') 49 | parser.add_argument('--flipbuttons', action='store_true', 50 | help='switch buttons left and right, up and down') 51 | parser.add_argument('--flipsounds', action='store_true', 52 | help='reverse sound effect patterns') 53 | parser.add_argument('infile', type=str, 54 | help='the cart to turn upside down; can be .p8 ' 55 | 'or .p8.png') 56 | parser.add_argument('outfile', type=str, nargs='?', 57 | help='the filename of the new cart; must end in .p8; ' 58 | 'if not specified, adds _upsidedown.p8 to the original ' 59 | 'base name') 60 | return parser 61 | 62 | 63 | class UpsideDownASTTransform(lua.BaseASTWalker): 64 | """Transforms Lua code to invert coordinates of drawing functions.""" 65 | def __init__(self, *args, **kwargs): 66 | self._smallmap = None 67 | self._flipbuttons = None 68 | for argname in ['smallmap', 'flipbuttons']: 69 | if argname in kwargs: 70 | setattr(self, '_' + argname, kwargs[argname]) 71 | del kwargs[argname] 72 | super().__init__(*args, **kwargs) 73 | 74 | def _make_binop(self, val_or_exp1, exp2, binop='-'): 75 | """Makes an ExpBinOp equivalent to maxval - exp. 76 | 77 | Args: 78 | val_or_exp1: a number or an expression node to be the left exp. 79 | exp2: an expression node to be the right exp. 80 | binop: a string representing the binary operator. 81 | 82 | Returns: 83 | The equivalent of ExpBinOp(val_or_exp1, binop, exp2). 84 | """ 85 | if not isinstance(val_or_exp1, parser.Node): 86 | val_or_exp1 = parser.ExpValue(lexer.TokNumber(str(val_or_exp1))) 87 | exp2 = parser.ExpValue(exp2) 88 | 89 | return parser.ExpBinOp(val_or_exp1, 90 | lexer.TokSymbol(binop), 91 | exp2) 92 | 93 | def _walk_FunctionCall(self, node): 94 | if not isinstance(node.exp_prefix, parser.VarName): 95 | # upsidedown only supports directly named calls to graphics 96 | # functions. 97 | return 98 | func_name = node.exp_prefix.name.code 99 | 100 | if self._flipbuttons and (func_name == 'btn' or func_name == 'btnp'): 101 | # It's not too tricky to swap odds and evens in a Lua expression, but it'd be a pain to write out the AST 102 | # for it. So instead, we only support calls to btn/btnp where the first argument is a numeric constant, 103 | # so we can swap them statically. 104 | if isinstance(node.args.explist.exps[0].value, lexer.TokNumber): 105 | numval = int(node.args.explist.exps[0].value.code) 106 | if numval % 2 == 0: 107 | numval += 1 108 | else: 109 | numval -= 1 110 | node.args.explist.exps[0].value = lexer.TokNumber(str(numval)) 111 | 112 | elif func_name == 'pget': 113 | # pget x y 114 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[0]) 115 | node.args.explist.exps[1] = self._make_binop(127, node.args.explist.exps[1]) 116 | 117 | elif func_name == 'pset': 118 | # pset x y [c] 119 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[0]) 120 | node.args.explist.exps[1] = self._make_binop(127, node.args.explist.exps[1]) 121 | 122 | elif func_name == 'sget': 123 | # sget x y 124 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[0]) 125 | node.args.explist.exps[1] = self._make_binop(127 if self._smallmap else 63, 126 | node.args.explist.exps[1]) 127 | 128 | elif func_name == 'sset': 129 | # sset x y [c] 130 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[0]) 131 | node.args.explist.exps[1] = self._make_binop(127 if self._smallmap else 63, 132 | node.args.explist.exps[1]) 133 | 134 | elif func_name == 'print': 135 | # print str [x y [col]] 136 | if len(node.args.explist.exps) > 1: 137 | # Printing is always left to right, so only invert the y 138 | # coordinate, and leave a line's worth of space. 139 | # (This is still insufficient for carts that use 'cursor' then 140 | # print more than one line.) 141 | node.args.explist.exps[2] = self._make_binop(119, node.args.explist.exps[2]) 142 | 143 | elif func_name == 'cursor': 144 | # cursor x y 145 | node.args.explist.exps[1] = self._make_binop(119, node.args.explist.exps[1]) 146 | 147 | elif func_name == 'camera': 148 | # camera [x y] 149 | if node.args.explist is not None: 150 | # Invert the sign of the camera offset. 151 | node.args.explist.exps[0] = self._make_binop(0, node.args.explist.exps[0]) 152 | node.args.explist.exps[1] = self._make_binop(0, node.args.explist.exps[1]) 153 | 154 | elif func_name == 'circ': 155 | # circ x y r [col] 156 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[0]) 157 | node.args.explist.exps[1] = self._make_binop(127, node.args.explist.exps[1]) 158 | 159 | elif func_name == 'circfill': 160 | # circfill x y r [col] 161 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[0]) 162 | node.args.explist.exps[1] = self._make_binop(127, node.args.explist.exps[1]) 163 | 164 | elif func_name == 'line': 165 | # line x0 y0 x1 y1 [col] 166 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[0]) 167 | node.args.explist.exps[1] = self._make_binop(127, node.args.explist.exps[1]) 168 | node.args.explist.exps[2] = self._make_binop(127, node.args.explist.exps[2]) 169 | node.args.explist.exps[3] = self._make_binop(127, node.args.explist.exps[3]) 170 | 171 | elif func_name == 'rect': 172 | # rect x0 y0 x1 y1 [col] 173 | # swap x0<->x1, y0<->y1 174 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[2]) 175 | node.args.explist.exps[1] = self._make_binop(127, node.args.explist.exps[1]) 176 | node.args.explist.exps[2] = self._make_binop(127, node.args.explist.exps[0]) 177 | node.args.explist.exps[3] = self._make_binop(127, node.args.explist.exps[3]) 178 | 179 | elif func_name == 'rectfill': 180 | # rectfill x0 y0 x1 y1 [col] 181 | # swap x0<->x1, y0<->y1 182 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[2]) 183 | node.args.explist.exps[1] = self._make_binop(127, node.args.explist.exps[1]) 184 | node.args.explist.exps[2] = self._make_binop(127, node.args.explist.exps[0]) 185 | node.args.explist.exps[3] = self._make_binop(127, node.args.explist.exps[3]) 186 | 187 | elif func_name == 'spr': 188 | # spr n x y [w h] [flip_x] [flip_y] 189 | if len(node.args.explist.exps) > 3: 190 | util.error('Unsupported: can\'t invert blitting more than one tile\n') 191 | # TODO: invert sprite sheet to support this 192 | # TODO: uh, not sure why this is needed to get Jelpi to look right. Why 113? 193 | node.args.explist.exps[1] = self._make_binop(113, node.args.explist.exps[1]) 194 | node.args.explist.exps[2] = self._make_binop(113, node.args.explist.exps[2]) 195 | 196 | elif func_name == 'sspr': 197 | # sspr sx sy sw sh dx dy [dw dh] [flip_x] [flip_y] 198 | util.error('Unsupported: can\'t invert sspr\n') 199 | # TODO: invert sprite sheet to support this 200 | 201 | elif func_name == 'mget': 202 | # mget x y 203 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[0]) 204 | node.args.explist.exps[1] = self._make_binop(31 if self._smallmap else 63, 205 | node.args.explist.exps[1]) 206 | 207 | elif func_name == 'mset': 208 | # mset x y v 209 | node.args.explist.exps[0] = self._make_binop(127, node.args.explist.exps[0]) 210 | node.args.explist.exps[1] = self._make_binop(31 if self._smallmap else 63, 211 | node.args.explist.exps[1]) 212 | 213 | elif func_name == 'map' or func_name == 'mapdraw': 214 | # map cel_x cel_y sx sy cel_w cel_h [layer] 215 | cel_x = node.args.explist.exps[0] 216 | cel_y = node.args.explist.exps[1] 217 | sx = node.args.explist.exps[2] 218 | sy = node.args.explist.exps[3] 219 | cel_w = node.args.explist.exps[4] 220 | cel_h = node.args.explist.exps[5] 221 | 222 | # new cel_x = 128 - cel_x - cel_w 223 | cel_x = self._make_binop(self._make_binop(128, cel_x), cel_w) 224 | node.args.explist.exps[0] = cel_x 225 | 226 | # new cel_y = (32 or 64) - cel_y - cel_h 227 | cel_y = self._make_binop(self._make_binop(32 if self._smallmap else 64, 228 | cel_y), cel_h) 229 | node.args.explist.exps[1] = cel_y 230 | 231 | # new sx = 128 - sx - 8 * cel_w 232 | sx = self._make_binop(self._make_binop( 233 | 128, sx), self._make_binop(8, cel_w, binop='*')) 234 | node.args.explist.exps[2] = sx 235 | 236 | # new sy = 128 - sy - 8 * cel_h 237 | sy = self._make_binop(self._make_binop( 238 | 128, sy), self._make_binop(8, cel_h, binop='*')) 239 | node.args.explist.exps[3] = sy 240 | 241 | yield 242 | 243 | 244 | def upsidedown_game(g, smallmap=False, flipbuttons=False, flipsounds=False): 245 | """Turn a game upside down. 246 | 247 | This modifies the game in-place. 248 | 249 | Args: 250 | g: The Game to turn upside down. 251 | smallmap: True if the gfx/map shared region is used as gfx, False 252 | otherwise. 253 | flipbuttons: If True, reverses functions regarding reading buttons 254 | to swap left and right, up and down. 255 | flipsounds: If True, reverses sound effect / music pattern data. 256 | """ 257 | last_sprite = 256 if smallmap else 128 258 | for id in range(last_sprite): 259 | sprite = g.gfx.get_sprite(id) 260 | flipped_sprite = reversed(list(reversed(row) for row in sprite)) 261 | g.gfx.set_sprite(id, flipped_sprite) 262 | 263 | last_map_row = 32 if smallmap else 64 264 | tile_rect = g.map.get_rect_tiles(0, 0, 128, last_map_row) 265 | flipped_map = reversed(list(reversed(row) for row in tile_rect)) 266 | g.map.set_rect_tiles(flipped_map, 0, 0) 267 | 268 | if flipsounds: 269 | for id in range(63): 270 | notes = [g.sfx.get_note(id, n) for n in range(32)] 271 | notes.reverse() 272 | for n in range(32): 273 | g.sfx.set_note(id, n, *notes[n]) 274 | (editor_mode, note_duration, loop_start, loop_end) = g.sfx.get_properties(id) 275 | if loop_start: 276 | g.sfx.set_properties(id, loop_start=63-loop_end) 277 | if loop_end: 278 | g.sfx.set_properties(id, loop_end=63-loop_start) 279 | 280 | transform = UpsideDownASTTransform(g.lua.tokens, g.lua.root, 281 | smallmap=smallmap, 282 | flipbuttons=flipbuttons) 283 | try: 284 | it = transform.walk() 285 | while True: 286 | it.__next__() 287 | except StopIteration: 288 | pass 289 | 290 | 291 | def main(orig_args): 292 | arg_parser = _get_argparser() 293 | args = arg_parser.parse_args(args=orig_args) 294 | 295 | if args.outfile: 296 | if not args.outfile.endswith('.p8'): 297 | util.error('Filename {} must end in .p8\n'.format(args.outfile)) 298 | return 1 299 | out_fname = args.outfile 300 | else: 301 | if args.infile.endswith('.p8'): 302 | basename = args.infile[:-len('.p8')] 303 | elif args.infile.endswith('.p8.png'): 304 | basename = args.infile[:-len('.p8.png')] 305 | else: 306 | util.error('Filename {} must end in .p8 or ' 307 | '.p8.png\n'.format(args.infile)) 308 | return 1 309 | out_fname = basename + '_upsidedown.p8' 310 | 311 | g = game.Game.from_filename(args.infile) 312 | 313 | upsidedown_game(g, args.smallmap, args.flipbuttons, args.flipsounds) 314 | 315 | g.lua.reparse(writer_cls=lua.LuaASTEchoWriter, writer_args={'ignore_tokens': True}) 316 | 317 | with tempfile.TemporaryFile(mode='w+', encoding='utf-8') as outfh: 318 | g.to_p8_file(outfh, filename=out_fname, 319 | lua_writer_cls=lua.LuaMinifyTokenWriter) 320 | outfh.seek(0) 321 | with open(out_fname, 'w', encoding='utf-8') as finalfh: 322 | finalfh.write(outfh.read()) 323 | 324 | return 0 325 | -------------------------------------------------------------------------------- /pico8/game/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andmatand/midi-to-pico8/371e52a584631d668ed127f7d43678cd0916fc1f/pico8/game/__init__.py -------------------------------------------------------------------------------- /pico8/game/game.py: -------------------------------------------------------------------------------- 1 | """A container for a Pico-8 game, and routines to load and save game files.""" 2 | 3 | __all__ = [ 4 | 'Game', 5 | 'InvalidP8HeaderError', 6 | 'InvalidP8SectionError' 7 | ] 8 | 9 | import re 10 | from .. import util 11 | from ..lua.lua import Lua 12 | from ..lua.lua import PICO8_LUA_CHAR_LIMIT 13 | from ..lua.lua import PICO8_LUA_TOKEN_LIMIT 14 | from ..gfx.gfx import Gfx 15 | from ..gff.gff import Gff 16 | from ..map.map import Map 17 | from ..sfx.sfx import Sfx 18 | from ..music.music import Music 19 | 20 | HEADER_TITLE_STR = 'pico-8 cartridge // http://www.pico-8.com\n' 21 | HEADER_VERSION_RE = re.compile('version (\d+)\n') 22 | HEADER_VERSION_PAT = 'version {}\n' 23 | SECTION_DELIM_RE = re.compile('__(\w+)__\n') 24 | SECTION_DELIM_PAT = '__{}__\n' 25 | 26 | 27 | class InvalidP8HeaderError(util.InvalidP8DataError): 28 | """Exception for invalid .p8 file header.""" 29 | 30 | def __str__(self): 31 | return 'Invalid .p8: missing or corrupt header' 32 | 33 | 34 | class InvalidP8SectionError(util.InvalidP8DataError): 35 | """Exception for invalid .p8 file section delimiter.""" 36 | 37 | def __init__(self, bad_delim): 38 | self.bad_delim = bad_delim 39 | 40 | def __str__(self): 41 | return 'Invalid .p8: bad section delimiter {}'.format( 42 | repr(self.bad_delim)) 43 | 44 | 45 | class Game(): 46 | """A Pico-8 game.""" 47 | 48 | def __init__(self, filename=None, compressed_size=None): 49 | """Initializer. 50 | 51 | Prefer factory functions such as Game.from_p8_file(). 52 | 53 | Args: 54 | filename: The filename, if any, for tool messages. 55 | compressed_size: The byte size of the compressed Lua data region, 56 | or None if the Lua region was not compressed (.p8 or v0 .p8.png). 57 | """ 58 | self.filename = filename 59 | self.compressed_size = compressed_size 60 | 61 | self.lua = None 62 | self.gfx = None 63 | self.gff = None 64 | self.map = None 65 | self.sfx = None 66 | self.music = None 67 | 68 | self.version = None 69 | 70 | @classmethod 71 | def make_empty_game(cls, filename=None): 72 | """Create an empty game. 73 | 74 | Args: 75 | filename: An optional filename to use with error messages. 76 | 77 | Returns: 78 | A Game instance with valid but empty data regions. 79 | """ 80 | g = cls(filename=filename) 81 | 82 | g.lua = Lua(version=5) 83 | g.lua.update_from_lines([]) 84 | g.gfx = Gfx.empty(version=5) 85 | g.gff = Gff.empty(version=5) 86 | g.map = Map.empty(version=5, gfx=g.gfx) 87 | g.sfx = Sfx.empty(version=5) 88 | g.music = Music.empty(version=5) 89 | g.version = 5 90 | 91 | return g 92 | 93 | @classmethod 94 | def from_filename(cls, filename): 95 | """Loads a game from a named file. 96 | 97 | Args: 98 | filename: The name of the file. Must end in either ".p8" or ".p8.png". 99 | 100 | Returns: 101 | A Game containing the game data. 102 | 103 | Raises: 104 | lexer.LexerError 105 | parser.ParserError 106 | InvalidP8HeaderError 107 | """ 108 | assert filename.endswith('.p8.png') or filename.endswith('.p8') 109 | if filename.endswith('.p8'): 110 | with open(filename, 'r', encoding='utf-8') as fh: 111 | g = Game.from_p8_file(fh, filename=filename) 112 | else: 113 | with open(filename, 'rb') as fh: 114 | g = Game.from_p8png_file(fh, filename=filename) 115 | return g 116 | 117 | @classmethod 118 | def from_p8_file(cls, instr, filename=None): 119 | """Loads a game from a .p8 file. 120 | 121 | Args: 122 | instr: The input stream. 123 | filename: The filename, if any, for tool messages. 124 | 125 | Returns: 126 | A Game containing the game data. 127 | 128 | Raises: 129 | InvalidP8HeaderError 130 | """ 131 | header_title_str = instr.readline() 132 | if header_title_str != HEADER_TITLE_STR: 133 | raise InvalidP8HeaderError() 134 | header_version_str = instr.readline() 135 | version_m = HEADER_VERSION_RE.match(header_version_str) 136 | if version_m is None: 137 | raise InvalidP8HeaderError() 138 | version = int(version_m.group(1)) 139 | 140 | section = None 141 | section_lines = {} 142 | while True: 143 | line = instr.readline() 144 | if not line: 145 | break 146 | section_delim_m = SECTION_DELIM_RE.match(line) 147 | if section_delim_m: 148 | section = section_delim_m.group(1) 149 | section_lines[section] = [] 150 | elif section: 151 | section_lines[section].append(line) 152 | 153 | new_game = cls.make_empty_game(filename=filename) 154 | new_game.version = version 155 | for section in section_lines: 156 | if section == 'lua': 157 | new_game.lua = Lua.from_lines( 158 | section_lines[section], version=version) 159 | elif section == 'gfx': 160 | new_game.gfx = Gfx.from_lines( 161 | section_lines[section], version=version) 162 | my_map = getattr(new_game, 'map') 163 | if my_map is not None: 164 | my_map._gfx = new_game.gfx 165 | elif section == 'gff': 166 | new_game.gff = Gff.from_lines( 167 | section_lines[section], version=version) 168 | elif section == 'map': 169 | my_gfx = getattr(new_game, 'gfx') 170 | new_game.map = Map.from_lines( 171 | section_lines[section], version=version, gfx=my_gfx) 172 | elif section == 'sfx': 173 | new_game.sfx = Sfx.from_lines( 174 | section_lines[section], version=version) 175 | elif section == 'music': 176 | new_game.music = Music.from_lines( 177 | section_lines[section], version=version) 178 | else: 179 | raise InvalidP8SectionError(section) 180 | 181 | return new_game 182 | 183 | @classmethod 184 | def from_p8png_file(cls, instr, filename=None): 185 | """Loads a game from a .p8.png file. 186 | 187 | Args: 188 | instr: The input stream. 189 | filename: The filename, if any, for tool messages. 190 | 191 | Returns: 192 | A Game containing the game data. 193 | """ 194 | # To install: python3 -m pip install pypng 195 | import png 196 | r = png.Reader(file=instr) 197 | 198 | (width, height, data, attrs) = r.read() 199 | picodata = [0] * width * height 200 | 201 | row_i = 0 202 | for row in data: 203 | for col_i in range(width): 204 | picodata[row_i * width + col_i] |= ( 205 | (row[col_i * attrs['planes'] + 2] & 3) << (0 * 2)) 206 | picodata[row_i * width + col_i] |= ( 207 | (row[col_i * attrs['planes'] + 1] & 3) << (1 * 2)) 208 | picodata[row_i * width + col_i] |= ( 209 | (row[col_i * attrs['planes'] + 0] & 3) << (2 * 2)) 210 | picodata[row_i * width + col_i] |= ( 211 | (row[col_i * attrs['planes'] + 3] & 3) << (3 * 2)) 212 | row_i += 1 213 | 214 | gfx = picodata[0x0:0x2000] 215 | p8map = picodata[0x2000:0x3000] 216 | gfx_props = picodata[0x3000:0x3100] 217 | song = picodata[0x3100:0x3200] 218 | sfx = picodata[0x3200:0x4300] 219 | code = picodata[0x4300:0x8000] 220 | version = picodata[0x8000] 221 | 222 | compressed_size = None 223 | 224 | if version == 0 or bytes(code[:4]) != b':c:\x00': 225 | # code is ASCII 226 | 227 | # (I assume this fails if uncompressed code completely 228 | # fills the code area, in which case code_length = 229 | # 0x8000-0x4300.) 230 | code_length = code.index(0) 231 | 232 | code = ''.join(chr(c) for c in code[:code_length]) + '\n' 233 | 234 | elif version == 1 or version == 5: 235 | # code is compressed 236 | code_length = (code[4] << 8) | code[5] 237 | assert bytes(code[6:8]) == b'\x00\x00' 238 | 239 | chars = list(b'#\n 0123456789abcdefghijklmnopqrstuvwxyz!#%(){}[]<>+=/*:;.,~_') 240 | out = [0] * code_length 241 | in_i = 8 242 | out_i = 0 243 | while out_i < code_length and in_i < len(code): 244 | if code[in_i] == 0x00: 245 | in_i += 1 246 | out[out_i] = code[in_i] 247 | out_i += 1 248 | elif code[in_i] <= 0x3b: 249 | out[out_i] = chars[code[in_i]] 250 | out_i += 1 251 | else: 252 | in_i += 1 253 | offset = (code[in_i - 1] - 0x3c) * 16 + (code[in_i] & 0xf) 254 | length = (code[in_i] >> 4) + 2 255 | out[out_i:out_i + length] = out[out_i - offset:out_i - offset + length] 256 | out_i += length 257 | in_i += 1 258 | 259 | code = ''.join(chr(c) for c in out) + '\n' 260 | compressed_size = in_i 261 | 262 | new_game = cls(filename=filename, compressed_size=compressed_size) 263 | new_game.version = version 264 | new_game.lua = Lua.from_lines( 265 | [code], version=version) 266 | new_game.gfx = Gfx.from_bytes( 267 | gfx, version=version) 268 | new_game.gff = Gff.from_bytes( 269 | gfx_props, version=version) 270 | new_game.map = Map.from_bytes( 271 | p8map, version=version, gfx=new_game.gfx) 272 | new_game.sfx = Sfx.from_bytes( 273 | sfx, version=version) 274 | new_game.music = Music.from_bytes( 275 | song, version=version) 276 | 277 | return new_game 278 | 279 | def to_p8_file(self, outstr, lua_writer_cls=None, lua_writer_args=None, 280 | filename=None): 281 | """Write the game data as a .p8 file. 282 | 283 | Args: 284 | outstr: The output stream. 285 | lua_writer_cls: The Lua writer class to use. If None, defaults to 286 | LuaEchoWriter. 287 | lua_writer_args: Args to pass to the Lua writer. 288 | filename: The output filename, for error messages. 289 | """ 290 | outstr.write(HEADER_TITLE_STR) 291 | 292 | # Even though we can get the original cart version, we 293 | # hard-code version 5 for output because we only know how to 294 | # write v5 .p8 files. There are minor changes from previous 295 | # versions of .p8 that don't apply to .p8.png (such as the gff 296 | # section). 297 | outstr.write(HEADER_VERSION_PAT.format(5)) 298 | 299 | # Sanity-check the Lua written by the writer. 300 | transformed_lua = Lua.from_lines( 301 | self.lua.to_lines(writer_cls=lua_writer_cls, 302 | writer_args=lua_writer_args), 303 | version=(self.version or 0)) 304 | if transformed_lua.get_char_count() > PICO8_LUA_CHAR_LIMIT: 305 | if filename is not None: 306 | util.error('{}: '.format(filename)) 307 | util.error('warning: character count {} exceeds the Pico-8 ' 308 | 'limit of {}'.format( 309 | transformed_lua.get_char_count(), 310 | PICO8_LUA_CHAR_LIMIT)) 311 | if transformed_lua.get_token_count() > PICO8_LUA_TOKEN_LIMIT: 312 | if filename is not None: 313 | util.error('{}: '.format(filename)) 314 | util.error('warning: token count {} exceeds the Pico-8 ' 315 | 'limit of {}'.format( 316 | transformed_lua.get_char_count(), 317 | PICO8_LUA_CHAR_LIMIT)) 318 | 319 | outstr.write(SECTION_DELIM_PAT.format('lua')) 320 | ended_in_newline = None 321 | for l in self.lua.to_lines(writer_cls=lua_writer_cls, 322 | writer_args=lua_writer_args): 323 | outstr.write(l) 324 | ended_in_newline = l.endswith('\n') 325 | if not ended_in_newline: 326 | outstr.write('\n') 327 | 328 | outstr.write(SECTION_DELIM_PAT.format('gfx')) 329 | for l in self.gfx.to_lines(): 330 | outstr.write(l) 331 | 332 | outstr.write(SECTION_DELIM_PAT.format('gff')) 333 | for l in self.gff.to_lines(): 334 | outstr.write(l) 335 | 336 | outstr.write(SECTION_DELIM_PAT.format('map')) 337 | for l in self.map.to_lines(): 338 | outstr.write(l) 339 | 340 | outstr.write(SECTION_DELIM_PAT.format('sfx')) 341 | for l in self.sfx.to_lines(): 342 | outstr.write(l) 343 | 344 | outstr.write(SECTION_DELIM_PAT.format('music')) 345 | for l in self.music.to_lines(): 346 | outstr.write(l) 347 | 348 | outstr.write('\n') 349 | 350 | def write_cart_data(self, data, start_addr=0): 351 | """Write binary data to an arbitrary cart address. 352 | 353 | Args: 354 | data: The data to write, as a byte string or bytearray. 355 | start_addr: The address to start writing. 356 | """ 357 | if start_addr + len(data) > 0x4300: 358 | raise ValueError('Data too large: {} bytes starting at {} exceeds ' 359 | '0x4300'.format(len(data), start_addr)) 360 | memmap = ((0x0,0x2000,self.gfx._data), 361 | (0x2000,0x3000,self.map._data), 362 | (0x3000,0x3100,self.gff._data), 363 | (0x3100,0x3200,self.music._data), 364 | (0x3200,0x4300,self.sfx._data)) 365 | for start_a, end_a, section_data in memmap: 366 | if (start_addr > end_a or 367 | start_addr + len(data) < start_a): 368 | continue 369 | data_start_a = (start_addr - start_a 370 | if start_addr > start_a 371 | else 0) 372 | data_end_a = (start_addr + len(data) - start_a 373 | if start_addr + len(data) < end_a 374 | else end_a) 375 | text_start_a = (0 if start_addr > start_a 376 | else start_a - start_addr) 377 | text_end_a = (len(data) 378 | if start_addr + len(data) < end_a 379 | else -(start_addr + len(data) - end_a)) 380 | section_data[data_start_a:data_end_a] = \ 381 | data[text_start_a:text_end_a] 382 | -------------------------------------------------------------------------------- /pico8/gff/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pico8/gff/gff.py: -------------------------------------------------------------------------------- 1 | """The graphics flags section of a Pico-8 cart. 2 | 3 | The graphics properties region consists of 256 bytes. The .p8 4 | representation is 2 lines of 256 hexadecimal digits (128 bytes). 5 | 6 | This represents eight flags for each of the 256 tiles in the main gfx 7 | area. In the graphics editor, the flags are arranged left to right 8 | from LSB to MSB: red=1, orange=2, yellow=4, green=8, blue=16, purple=32, 9 | pink=64, peach=128. 10 | 11 | """ 12 | 13 | __all__ = ['Gff'] 14 | 15 | from .. import util 16 | 17 | 18 | # Constants for flags. 19 | RED = 1 20 | ORANGE = 2 21 | YELLOW = 4 22 | GREEN = 8 23 | BLUE = 16 24 | PURPLE = 32 25 | PINK = 64 26 | PEACH = 128 27 | ALL = 255 28 | 29 | 30 | class Gff(util.BaseSection): 31 | """The graphics properties section of a Pico-8 cart.""" 32 | HEX_LINE_LENGTH_BYTES = 128 33 | 34 | @classmethod 35 | def empty(cls, version=4): 36 | """Create an empty instance. 37 | 38 | Returns: 39 | A Gff instance. 40 | """ 41 | return cls(data=bytearray(b'\x00' * 256), version=version) 42 | 43 | def get_flags(self, id, flags): 44 | """Gets the value of a specific flag or flags. 45 | 46 | Given a tile ID and a flag, returns the value of the flag if 47 | it is set, or zero otherwise: 48 | if gff_obj.get_flags(0, BLUE): 49 | # The blue flag is set. 50 | 51 | You can bitwise-or (|) flag constants together to get more 52 | than one with a single call. The result is all of the set 53 | flags bitwise-or'd together: 54 | is_set = gff_obj.get_flags(0, RED | BLUE | PEACH) 55 | if is_set | BLUE: 56 | # The blue flag is set. 57 | 58 | Call with the ALL constant to get all flags in a single value. 59 | flags = gff_obj.get_flags(0, ALL) 60 | """ 61 | assert 0 <= id <= 255 62 | return self._data[id] & flags 63 | 64 | def set_flags(self, id, flags): 65 | """Sets one or more flags for a tile. 66 | 67 | This sets the specified flags, and leaves the other flags unchanged. 68 | 69 | You can bitwise-or (|) flag constants together to set more than one with 70 | a single call: 71 | gff_obj.set_flags(0, RED | BLUE | PEACH) 72 | 73 | Args: 74 | id: The Pico-8 ID of the tile (0-255). 75 | flags: The flags to set, bitwise-or'd together. 76 | """ 77 | assert 0 <= id <= 255 78 | self._data[id] |= (flags & ALL) 79 | 80 | def clear_flags(self, id, flags): 81 | """Clears one or more flags for a tile. 82 | 83 | This clears the specified flags, and leaves the other flags unchanged. 84 | 85 | You can bitwise-or (|) flag constants together to clear more than one 86 | with a single call: 87 | gff_obj.clear_flags(0, RED | BLUE | PEACH) 88 | 89 | Args: 90 | id: The Pico-8 ID of the tile (0-255). 91 | flags: The flags to clear, bitwise-or'd together. 92 | """ 93 | assert 0 <= id <= 255 94 | self._data[id] &= (~flags & ALL) 95 | 96 | def reset_flags(self, id, flags): 97 | """Resets all flags for a tile, then sets the given flags. 98 | 99 | This changes all flags for the tile so that only the specified flags are 100 | set. 101 | 102 | Args: 103 | id: The Pico-8 ID of the tile (0-255). 104 | flags: All flags to be set in the final state, bitwise-or'd together. 105 | """ 106 | assert 0 <= id <= 255 107 | self._data[id] = flags & ALL 108 | -------------------------------------------------------------------------------- /pico8/gfx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andmatand/midi-to-pico8/371e52a584631d668ed127f7d43678cd0916fc1f/pico8/gfx/__init__.py -------------------------------------------------------------------------------- /pico8/gfx/gfx.py: -------------------------------------------------------------------------------- 1 | """The sprite graphics section of a Pico-8 cart. 2 | 3 | The graphics region consists of 8192 bytes. The .p8 representation is 4 | 128 lines of 128 hexadecimal digits (64 bytes). 5 | 6 | The in-memory representation is similar, but with nibble-pairs 7 | swapped. (The .p8 representation resembles pixel left-to-right 8 | ordering, while the RAM representation uses the most significant 9 | nibble for the right pixel of each pixel pair.) 10 | """ 11 | 12 | __all__ = ['Gfx'] 13 | 14 | from .. import util 15 | 16 | 17 | # Constants for the Pico-8 color values. 18 | BLACK = 0 19 | DARK_BLUE = 1 20 | DARK_PURPLE = 2 21 | DARK_GREEN = 3 22 | BROWN = 4 23 | DARK_GRAY = 5 24 | LIGHT_GRAY = 6 25 | WHITE = 7 26 | RED = 8 27 | ORANGE = 9 28 | YELLOW = 10 29 | GREEN = 11 30 | BLUE = 12 31 | INDIGO = 13 32 | PINK = 14 33 | PEACH = 15 34 | 35 | # A special color value accepted by set_sprite() to leave an existing pixel on 36 | # the spritesheet unchanged. 37 | TRANSPARENT = 16 38 | 39 | 40 | class Gfx(util.BaseSection): 41 | """The sprite graphics section for a Pico-8 cart.""" 42 | HEX_LINE_LENGTH_BYTES = 64 43 | 44 | @classmethod 45 | def empty(cls, version=4): 46 | """Creates an empty instance. 47 | 48 | Returns: 49 | A Gfx instance. 50 | """ 51 | return cls(data=bytearray(b'\x00' * 128 * 64), version=version) 52 | 53 | @classmethod 54 | def from_lines(cls, lines, version): 55 | """Create an instance based on .p8 data lines. 56 | 57 | The base implementation reads lines of ASCII-encoded hexadecimal bytes. 58 | 59 | Args: 60 | lines: .p8 lines for the section. 61 | version: The Pico-8 data version from the game file header. 62 | 63 | Returns: 64 | A Gfx instance. 65 | """ 66 | datastrs = [] 67 | for l in lines: 68 | if len(l) != 129: 69 | continue 70 | 71 | larray = list(l.rstrip()) 72 | for i in range(0,128,2): 73 | (larray[i], larray[i+1]) = (larray[i+1], larray[i]) 74 | 75 | datastrs.append(bytearray.fromhex(''.join(larray))) 76 | 77 | data = b''.join(datastrs) 78 | return cls(data=data, version=version) 79 | 80 | def to_lines(self): 81 | """Generates lines of ASCII-encoded hexadecimal strings. 82 | 83 | The .p8 for the gfx section writes data bytes with the nibbles 84 | (4-bits) swapped to represent pixel order. 85 | 86 | Yields: 87 | One line of a hex string. 88 | """ 89 | for start_i in range(0, len(self._data), self.HEX_LINE_LENGTH_BYTES): 90 | end_i = start_i + self.HEX_LINE_LENGTH_BYTES 91 | newdata = [] 92 | for b in self._data[start_i:end_i]: 93 | newdata.append((b & 0x0f) << 4 | (b & 0xf0) >> 4) 94 | 95 | yield bytes(newdata).hex() + '\n' 96 | 97 | def get_sprite(self, id, tile_width=1, tile_height=1): 98 | """Retrieves the graphics data for a sprite. 99 | 100 | The return value is a list of bytearrays, where each bytearray 101 | represents a row of pixels. Each value is a color value for a 102 | pixel (0-15). 103 | 104 | By default, this returns a sprite consisting of the tile with 105 | the given Pico-8 tile ID, an 8x8 pixel region. You can request 106 | multiple tiles in a single sprite using the tile_width and 107 | tile_height arguments. The complete sprite is calculated from 108 | the 16 tile x 16 tile spritesheet with the tile of the given 109 | ID in the upper left corner, similar to the Pico-8 spr 110 | function. If the given width or height extend off the edge of 111 | the spritesheet, the extraneous space is filled with zeroes. 112 | 113 | Pico-8 tile IDs start with 0 in the upper left corner, and 114 | increase left to right, then top to bottom, in the 16 tile x 115 | 16 tile spritesheet. 116 | 117 | Args: 118 | id: The Pico-8 tile ID that is the upper-left corner of the requested 119 | sprite. 120 | tile_width: The width of the requested sprite, as a number of tiles. 121 | Must be 1 or greater. 122 | tile_height: The height of the requested sprite, as a number of tiles. 123 | Must be 1 or greater. 124 | 125 | Returns: 126 | A list of bytearrays, one bytearray per row, where each cell 127 | is a color from 0 (transparent/black) to 15 (peach). (See 128 | the color constants defined in the gfx module.) 129 | """ 130 | assert 0 <= id <= 255 131 | assert 1 <= tile_width 132 | assert 1 <= tile_height 133 | first_tile_row = id // 16 134 | first_tile_col = id % 16 135 | result = [] 136 | for ty in range(first_tile_row, first_tile_row + tile_height): 137 | for y_offset in range(8): 138 | row = bytearray() 139 | for tx in range(first_tile_col, first_tile_col + tile_width): 140 | if tx > 15 or ty > 15: 141 | row.extend([0] * 8) 142 | else: 143 | for x_offset in range(8): 144 | data_loc = (ty * 64 * 8 + 145 | y_offset * 64 + 146 | tx * 4 + 147 | x_offset // 2) 148 | b = self._data[data_loc] 149 | if x_offset % 2 == 0: 150 | row.append(b & 0x0f) 151 | else: 152 | row.append((b & 0xf0) >> 4) 153 | result.append(row) 154 | return result 155 | 156 | def set_sprite(self, id, sprite, tile_x_offset=0, tile_y_offset=0): 157 | """Sets pixel data in the spritesheet. 158 | 159 | The given sprite pattern is drawn onto the spritesheet using 160 | the given tile ID as the upper left corner of the sprite. The 161 | sprite data is an iterable (rows) of iterables (columns) of 162 | bytes (pixel colors), similar to the data structure returned 163 | by get_sprite(). The pixels are drawn onto the 16 tile x 16 164 | tile spritesheet. If the given sprite data extends to the 165 | right or below the spritesheet, the excess is clipped. 166 | 167 | To draw the given sprite data offset from the upper left 168 | corner of a tile, specify a non-zero tile_x_offset or 169 | tile_y_offset. 170 | 171 | If a pixel value is gfx.TRANSPARENT, the existing pixel data 172 | in that location is preserved. A pixel value of gfx.BLACK 173 | overwrites the pixel (even though "black" is transparent by 174 | default when blitting sprites in Pico-8). 175 | 176 | All rows of the sprite data are assumed to be 177 | left-aligned. Rows can be of different lengths to leave pixels 178 | to the right of each row unchanged. To leave pixels on the 179 | left side of a row unchanged, use gfx.TRANSPARENT values. 180 | 181 | Using a combination of these features, it is possible to paint 182 | graphics data of an arbitrary shape at an arbitrary location 183 | on the spritesheet. 184 | 185 | Args: 186 | id: The Pico-8 tile ID that is the upper-left corner of the requested 187 | sprite. 188 | sprite: The sprite pixel data, as an iterable of iterables of pixel 189 | color values. 190 | tile_x_offset: If non-zero, start drawing the sprite data onto the 191 | spritesheet this many pixels to the right of the left edge of the 192 | tile with the given ID. 193 | tile_y_offset: If non-zero, start drawing the sprite data onto the 194 | spritesheet this many pixels below the top edge of the tile with 195 | the given ID. 196 | """ 197 | first_tile_row = id // 16 198 | first_tile_col = id % 16 199 | first_x_coord = first_tile_col * 8 + tile_x_offset 200 | first_y_coord = first_tile_row * 8 + tile_y_offset 201 | for y, row in enumerate(sprite): 202 | for x, val in enumerate(row): 203 | if ((val == TRANSPARENT) or 204 | ((first_y_coord + y) > 128) or 205 | ((first_x_coord + x) > 128)): 206 | continue 207 | data_loc = (first_y_coord + y) * 64 + (first_x_coord + x) // 2 208 | b = self._data[data_loc] 209 | if (first_x_coord + x) % 2 == 0: 210 | b = (b & 0xf0) + val 211 | else: 212 | b = (b & 0x0f) + (val << 4) 213 | self._data[data_loc] = b 214 | -------------------------------------------------------------------------------- /pico8/lua/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /pico8/lua/lexer.py: -------------------------------------------------------------------------------- 1 | """The Lua lexer.""" 2 | 3 | import re 4 | 5 | from .. import util 6 | 7 | 8 | __all__ = [ 9 | 'LexerError', 10 | 'Token', 11 | 'TokSpace', 12 | 'TokNewline', 13 | 'TokComment', 14 | 'TokString', 15 | 'TokNumber', 16 | 'TokName', 17 | 'TokLabel', 18 | 'TokKeyword', 19 | 'TokSymbol', 20 | 'Lexer', 21 | 'LUA_KEYWORDS' 22 | ] 23 | 24 | LUA_KEYWORDS = { 25 | 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 26 | 'function', 'goto', 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', 27 | 'return', 'then', 'true', 'until', 'while' 28 | } 29 | 30 | 31 | class LexerError(util.InvalidP8DataError): 32 | """A lexer error.""" 33 | def __init__(self, msg, lineno, charno): 34 | self.msg = msg 35 | self.lineno = lineno 36 | self.charno = charno 37 | 38 | def __str__(self): 39 | return '{} at line {} char {}'.format( 40 | self.msg, self.lineno, self.charno) 41 | 42 | 43 | class Token(): 44 | """A base class for all tokens.""" 45 | 46 | def __init__(self, data, lineno=None, charno=None): 47 | """Initializer. 48 | 49 | Args: 50 | data: The code data for the token. 51 | lineno: The source file line number of the first character. 52 | charno: The character number on the line of the first character. 53 | """ 54 | self._data = data 55 | self._lineno = lineno 56 | self._charno = charno 57 | 58 | def __len__(self): 59 | """The length of the code string for the token.""" 60 | return len(self.code) 61 | 62 | def __repr__(self): 63 | """A textual representation for debugging.""" 64 | return '{}<{}, line {} char {}>'.format( 65 | self.__class__.__name__, repr(self._data), 66 | self._lineno, self._charno) 67 | 68 | def __eq__(self, other): 69 | """Equality operator. 70 | 71 | Two tokens are equal if they are of the same type and have 72 | equal data. Positions are insignificant. 73 | 74 | Args: 75 | other: The other Token to compare. 76 | """ 77 | if (type(self) != type(other) or 78 | not isinstance(self, other.__class__)): 79 | return False 80 | if (isinstance(self, TokKeyword) and 81 | isinstance(other, TokKeyword)): 82 | return self._data.lower() == other._data.lower() 83 | return self._data == other._data 84 | 85 | def matches(self, other): 86 | """Matches the token against either a token class or token data. 87 | 88 | This is shorthand for the parser, which either wants to know 89 | whether the token is of a particular kind (e.g. a TokName) or 90 | of a particular value (a specific TokSymbol or TokKeyword). 91 | 92 | Args: 93 | other: The other Token to compare. 94 | """ 95 | if isinstance(other, type): 96 | return isinstance(self, other) 97 | return self == other 98 | 99 | @property 100 | def value(self): 101 | """The parsed value of the token.""" 102 | return self._data 103 | 104 | @property 105 | def code(self): 106 | """The original code of the token.""" 107 | return self._data 108 | 109 | 110 | class TokSpace(Token): 111 | """A block of whitespace, not including newlines.""" 112 | name = 'whitespace' 113 | 114 | 115 | class TokNewline(Token): 116 | """A single newline.""" 117 | name = 'newline' 118 | 119 | 120 | class TokComment(Token): 121 | """A Lua comment, including the '--' characters.""" 122 | name = 'comment' 123 | 124 | 125 | class TokString(Token): 126 | """A string literal.""" 127 | name = 'string literal' 128 | def __init__(self, *args, **kwargs): 129 | if 'quote' in kwargs: 130 | self._quote = kwargs['quote'] 131 | del kwargs['quote'] 132 | else: 133 | self._quote = '"' 134 | super().__init__(*args, **kwargs) 135 | 136 | @property 137 | def code(self): 138 | escaped_chrs = [] 139 | for c in self._data: 140 | if c in _STRING_REVERSE_ESCAPES: 141 | escaped_chrs.append('\\' + _STRING_REVERSE_ESCAPES[c]) 142 | elif c == self._quote: 143 | escaped_chrs.append('\\' + c) 144 | else: 145 | escaped_chrs.append(c) 146 | return self._quote + ''.join(escaped_chrs) + self._quote 147 | 148 | 149 | class TokNumber(Token): 150 | """A number literal. 151 | 152 | Negative number literals are tokenized as two tokens: a 153 | TokSymbol('-'), and a TokNumber(...) representing the non-negative 154 | number part. 155 | """ 156 | name = 'number' 157 | 158 | # self._data is the original string representation of the number, 159 | # so we don't have to jump through hoops to recreate it later. 160 | @property 161 | def value(self): 162 | return float(self._data) 163 | 164 | 165 | class TokName(Token): 166 | """A variable or function name.""" 167 | name = 'name' 168 | 169 | 170 | class TokLabel(Token): 171 | """A goto label.""" 172 | name = 'label' 173 | 174 | 175 | class TokKeyword(Token): 176 | """A Lua keyword.""" 177 | name = 'keyword' 178 | 179 | 180 | class TokSymbol(Token): 181 | """A Lua symbol.""" 182 | name = 'symbol' 183 | 184 | 185 | # A mapping of characters that can be escaped in Lua string literals using a 186 | # "\" character, mapped to their unescaped values. 187 | _STRING_ESCAPES = { 188 | '\n': '\n', 'a': '\a', 'b': '\b', 'f': '\f', 'n': '\n', 189 | 'r': '\r', 't': '\t', 'v': '\v', '\\': '\\', '"': '"', 190 | "'": "'" 191 | } 192 | _STRING_REVERSE_ESCAPES = dict((v,k) for k,v in _STRING_ESCAPES.items()) 193 | del _STRING_REVERSE_ESCAPES["'"] 194 | del _STRING_REVERSE_ESCAPES['"'] 195 | 196 | # A list of single-line token matching patterns and corresponding token 197 | # classes. A token class of None causes the lexer to consume the pattern 198 | # without emitting a token. The patterns are matched in order. 199 | _TOKEN_MATCHERS = [] 200 | _TOKEN_MATCHERS.extend([ 201 | (re.compile(r'--.*'), TokComment), 202 | (re.compile(r'[ \t]+'), TokSpace), 203 | (re.compile(r'\r\n'), TokNewline), 204 | (re.compile(r'\n'), TokNewline), 205 | (re.compile(r'\r'), TokNewline), 206 | (re.compile(r'0[xX][0-9a-fA-F]+(\.[0-9a-fA-F]+)?'), TokNumber), 207 | (re.compile(r'0[xX]\.[0-9a-fA-F]+'), TokNumber), 208 | (re.compile(r'[0-9]+(\.[0-9]+)?([eE]-?[0-9]+)?'), TokNumber), 209 | (re.compile(r'\.[0-9]+([eE]-?[0-9]+)?'), TokNumber), 210 | (re.compile(r'::[a-zA-Z_][a-zA-Z0-9_]*::'), TokLabel), 211 | ]) 212 | _TOKEN_MATCHERS.extend([ 213 | (re.compile(r'\b'+keyword+r'\b'), TokKeyword) for keyword in LUA_KEYWORDS]) 214 | _TOKEN_MATCHERS.extend([ 215 | (re.compile(symbol), TokSymbol) for symbol in [ 216 | r'\+=', '-=', r'\*=', '/=', '%=', 217 | '==', '~=', '!=', '<=', '>=', 218 | r'\+', '-', r'\*', '/', '%', r'\^', '#', 219 | '<', '>', '=', 220 | r'\(', r'\)', '{', '}', r'\[', r'\]', ';', ':', ',', 221 | r'\.\.\.', r'\.\.', r'\.']]) 222 | _TOKEN_MATCHERS.extend([ 223 | (re.compile(r'[a-zA-Z_][a-zA-Z0-9_]*'), TokName) 224 | ]) 225 | 226 | 227 | class Lexer(): 228 | """The lexer. 229 | 230 | A lexer object maintains state between calls to process_line() to 231 | manage tokens that span multiple lines. 232 | """ 233 | 234 | def __init__(self, version): 235 | """Initializer. 236 | 237 | Args: 238 | version: The Pico-8 data version from the game file header. 239 | """ 240 | self._version = version 241 | self._tokens = [] 242 | self._cur_lineno = 0 243 | self._cur_charno = 0 244 | 245 | # If inside a string literal (else None): 246 | # * the pos of the start of the string 247 | self._in_string_lineno = None 248 | self._in_string_charno = None 249 | # * a list of chars 250 | self._in_string = None 251 | # * the starting delimiter, either " or ' 252 | self._in_string_delim = None 253 | 254 | def _process_token(self, s): 255 | """Process a token's worth of chars from a string, if possible. 256 | 257 | If a token is found, it is added to self._tokens. A call might 258 | process characters but not emit a token. 259 | 260 | Args: 261 | s: The string to process. 262 | 263 | Returns: 264 | The number of characters processed from the beginning of the string. 265 | """ 266 | i = 0 267 | 268 | # TODO: Pico-8 doesn't allow multiline strings, so this probably 269 | # shouldn't either. 270 | 271 | if self._in_string is not None: 272 | # Continue string literal. 273 | while i < len(s): 274 | c = s[i] 275 | 276 | if c == self._in_string_delim: 277 | # End string literal. 278 | self._tokens.append( 279 | TokString(str(''.join(self._in_string)), 280 | self._in_string_lineno, 281 | self._in_string_charno, 282 | quote=self._in_string_delim)) 283 | self._in_string_delim = None 284 | self._in_string_lineno = None 285 | self._in_string_charno = None 286 | self._in_string = None 287 | i += 1 288 | break 289 | 290 | if c == '\\': 291 | # Escape character. 292 | num_m = re.match(r'\d{1,3}', s[i+1:]) 293 | if num_m: 294 | c = chr(int(num_m.group(0))) 295 | i += len(num_m.group(0)) 296 | else: 297 | next_c = s[i+1] 298 | if next_c in _STRING_ESCAPES: 299 | c = _STRING_ESCAPES[next_c] 300 | i += 1 301 | 302 | self._in_string.append(c) 303 | i += 1 304 | 305 | elif s.startswith("'") or s.startswith('"'): 306 | # Begin string literal. 307 | self._in_string_delim = s[0] 308 | self._in_string_lineno = self._cur_lineno 309 | self._in_string_charno = self._cur_charno 310 | self._in_string = [] 311 | i = 1 312 | 313 | else: 314 | # Match one-line patterns. 315 | for (pat, tok_class) in _TOKEN_MATCHERS: 316 | m = pat.match(s) 317 | if m: 318 | if tok_class is not None: 319 | token = tok_class(m.group(0), 320 | self._cur_lineno, 321 | self._cur_charno) 322 | self._tokens.append(token) 323 | i = len(m.group(0)) 324 | break 325 | 326 | for c in s[:i]: 327 | if c == '\n': 328 | self._cur_lineno += 1 329 | self._cur_charno = 0 330 | else: 331 | self._cur_charno += 1 332 | return i 333 | 334 | def _process_line(self, line): 335 | """Processes a line of Lua source code. 336 | 337 | The line does not have to be a complete Lua statement or 338 | block. However, complete and valid code must have been 339 | processed before you can call a write_*() method. 340 | 341 | Args: 342 | line: The line of Lua source. 343 | 344 | Raises: 345 | LexerError: The line contains text that could not be mapped to known 346 | tokens (a syntax error). 347 | """ 348 | i = 0 349 | while True: 350 | i = self._process_token(line) 351 | if i == 0: 352 | break 353 | line = line[i:] 354 | if line: 355 | raise LexerError('Syntax error (remaining:%r)' % (line,), 356 | self._cur_lineno + 1, 357 | self._cur_charno + 1) 358 | 359 | def process_lines(self, lines): 360 | """Process lines of Lua code. 361 | 362 | Args: 363 | lines: The Lua code to process, as an iterable of strings. Newline 364 | characters are expected to appear in the strings as they do in the 365 | original source, though each string in lines need not end with a 366 | newline. 367 | """ 368 | for line in lines: 369 | self._process_line(line) 370 | 371 | @property 372 | def tokens(self): 373 | """The tokens produced by the lexer. 374 | 375 | This includes TokComment, TokSpace, and TokNewline 376 | tokens. These are not tokens of the Lua grammar, but are 377 | needed to reconstruct the original source with its formatting, 378 | or to reformat the original source while preserving comments 379 | and newlines. 380 | """ 381 | return self._tokens 382 | -------------------------------------------------------------------------------- /pico8/lua/parser.py: -------------------------------------------------------------------------------- 1 | """The Lua parser.""" 2 | 3 | from .. import util 4 | from . import lexer 5 | 6 | 7 | __all__ = [ 8 | 'Parser', 9 | 'ParserError', 10 | 'Node', 11 | 'Chunk', 12 | 'StatAssignment', 13 | 'StatFunctionCall', 14 | 'StatDo', 15 | 'StatWhile', 16 | 'StatRepeat', 17 | 'StatIf', 18 | 'StatForStep', 19 | 'StatForIn', 20 | 'StatFunction', 21 | 'StatLocalFunction', 22 | 'StatLocalAssignment', 23 | 'StatGoto', 24 | 'StatLabel', 25 | 'StatBreak', 26 | 'StatReturn', 27 | 'FunctionName', 28 | 'FunctionArgs', 29 | 'VarList', 30 | 'VarName', 31 | 'VarIndex', 32 | 'VarAttribute', 33 | 'NameList', 34 | 'ExpList', 35 | 'ExpValue', 36 | 'VarargDots', 37 | 'ExpBinOp', 38 | 'ExpUnOp', 39 | 'FunctionCall', 40 | 'FunctionCallMethod', 41 | 'Function', 42 | 'FunctionBody', 43 | 'TableConstructor', 44 | 'FieldOtherThing', 45 | 'FieldNamed', 46 | 'FieldExp', 47 | ] 48 | 49 | 50 | class ParserError(util.InvalidP8DataError): 51 | """A lexer error.""" 52 | def __init__(self, msg, token=None): 53 | self.msg = msg 54 | self.token = token 55 | 56 | def __str__(self): 57 | if self.token is None: 58 | return '{} at end of file'.format(self.msg) 59 | return '{} at line {} char {}'.format( 60 | self.msg, self.token._lineno + 1, self.token._charno) 61 | 62 | 63 | class Node(): 64 | """A base class for all AST nodes.""" 65 | 66 | @property 67 | def start_pos(self): 68 | return self._start_token_pos 69 | 70 | @property 71 | def end_pos(self): 72 | return self._end_token_pos 73 | 74 | 75 | # These are all Node subclasses that initialize members with 76 | # (required) positional arguments. They are created and added to the 77 | # module's namespace in the loop below the list. 78 | _ast_node_types = ( 79 | ('Chunk', ('stats',)), 80 | ('StatAssignment', ('varlist', 'assignop', 'explist')), 81 | ('StatFunctionCall', ('functioncall',)), 82 | ('StatDo', ('block',)), 83 | ('StatWhile', ('exp', 'block')), 84 | ('StatRepeat', ('block', 'exp')), 85 | ('StatIf', ('exp_block_pairs',)), 86 | ('StatForStep', ('name', 'exp_init', 'exp_end', 'exp_step', 'block')), 87 | ('StatForIn', ('namelist', 'explist', 'block')), 88 | ('StatFunction', ('funcname', 'funcbody')), 89 | 90 | # StatLocalFunction funcname is a TokName, not a FunctionName 91 | ('StatLocalFunction', ('funcname', 'funcbody')), 92 | 93 | ('StatLocalAssignment', ('namelist', 'explist')), 94 | ('StatGoto', ('label',)), 95 | ('StatLabel', ('label',)), 96 | ('StatBreak', ()), 97 | ('StatReturn', ('explist',)), 98 | ('FunctionName', ('namepath', 'methodname')), 99 | ('FunctionArgs', ('explist',)), 100 | ('VarList', ('vars',)), 101 | ('VarName', ('name',)), 102 | ('VarIndex', ('exp_prefix', 'exp_index')), 103 | ('VarAttribute', ('exp_prefix', 'attr_name')), 104 | ('NameList', ('names',)), 105 | ('ExpList', ('exps',)), 106 | 107 | # TODO: rewrite expression parsing so that the AST captures associativity. 108 | # (See _exp_binop). Right now, all binary operators chain right to left: 109 | # 1 + 2 - 3 => ExpBinOp(ExpBinOp(1, +, 2), -, 3) 110 | # 111 | # value: None, False, True, TokNumber, TokString, Function, 112 | # TableConstructor, Var*, FunctionCall, Exp* 113 | ('ExpValue', ('value',)), 114 | ('VarargDots', ()), 115 | ('ExpBinOp', ('exp1', 'binop', 'exp2')), 116 | ('ExpUnOp', ('unop', 'exp')), 117 | 118 | # args: None, ExpList, TableConstructor, str 119 | ('FunctionCall', ('exp_prefix', 'args')), 120 | ('FunctionCallMethod', ('exp_prefix', 'methodname', 'args')), 121 | 122 | ('Function', ('funcbody',)), 123 | ('FunctionBody', ('parlist', 'dots', 'block')), 124 | ('TableConstructor', ('fields',)), 125 | ('FieldExpKey', ('key_exp', 'exp')), 126 | ('FieldNamedKey', ('key_name', 'exp')), 127 | ('FieldExp', ('exp',)), 128 | ) 129 | for (name, fields) in _ast_node_types: 130 | def node_init(self, *args, **kwargs): 131 | self._start_token_pos = kwargs.get('start') 132 | self._end_token_pos = kwargs.get('end') 133 | if 'start' in kwargs: 134 | del kwargs['start'] 135 | if 'end' in kwargs: 136 | del kwargs['end'] 137 | if len(args) != len(self._fields): 138 | raise TypeError( 139 | 'Initializer for {} requires {} fields, saw {}'.format( 140 | self._name, len(self._fields), len(args))) 141 | for i in range(len(self._fields)): 142 | setattr(self, self._fields[i], args[i]) 143 | for k in kwargs: 144 | setattr(self, k, kwargs[k]) 145 | cls = type(name, (Node,), {'__init__': node_init, 146 | '_name': name, '_fields': fields}) 147 | globals()[name] = cls 148 | 149 | 150 | # (!= is PICO-8 specific.) 151 | BINOP_PATS = ([lexer.TokSymbol(sym) for sym in [ 152 | '<', '>', '<=', '>=', '~=', '!=', '==', '..', '+', '-', '*', '/', '%', '^' 153 | ]] + [lexer.TokKeyword('and'), lexer.TokKeyword('or')]) 154 | 155 | 156 | 157 | class Parser(): 158 | """The parser.""" 159 | 160 | def __init__(self, version): 161 | """Initializer. 162 | 163 | Args: 164 | version: The Pico-8 data version from the game file header. 165 | """ 166 | self._version = version 167 | self._tokens = None 168 | self._pos = None 169 | self._ast = None 170 | 171 | # If _max_pos is not None, _accept will not advance the cursor beyond 172 | # it and will return None for any action that would. 173 | self._max_pos = None 174 | 175 | def _peek(self): 176 | """Return the token under the cursor. 177 | 178 | Returns: 179 | The token under the cursor, or None if there is no next token. 180 | """ 181 | if self._pos < len(self._tokens): 182 | return self._tokens[self._pos] 183 | return None 184 | 185 | def _accept(self, tok_pattern): 186 | """Match the token under the cursor, and advance the cursor if matched. 187 | 188 | If tok_pattern is not TokSpace, TokNewline, or TokComment, 189 | this method consumes all whitespace, newline, and comment 190 | tokens prior to the matched token, and returns them with the 191 | token. If the first non-space token does not match, the cursor 192 | returns to where it was before the call. 193 | 194 | If self._max_pos is not None, then the cursor is not allowed 195 | to advance past that position. If consuming whitespace and the 196 | accepted token would leave the cursor past this point, the 197 | cursor is rewound to the beginning and the method returns 198 | None. This mechanism is exclusively for supporting short-if. 199 | 200 | Args: 201 | tok_pattern: The lexer.Token subclass or subclass instance 202 | to match. If tok is a subclass, the current token matches 203 | if it has the same subclass. If tok is an instance, the 204 | current token matches if it has the same subclass and 205 | equal data. 206 | 207 | Returns: 208 | If the token under the cursor matches, returns the token. Otherwise 209 | None. 210 | 211 | """ 212 | start_pos = self._pos 213 | 214 | # Find the first non-space token (unless accepting a space). 215 | while True: 216 | cur_tok = self._peek() 217 | if (cur_tok is None or 218 | cur_tok.matches(tok_pattern) or 219 | (not isinstance(cur_tok, lexer.TokSpace) and 220 | not isinstance(cur_tok, lexer.TokNewline) and 221 | not isinstance(cur_tok, lexer.TokComment))): 222 | break 223 | self._pos += 1 224 | 225 | if (cur_tok is not None and 226 | cur_tok.matches(tok_pattern) and 227 | (self._max_pos is None or self._pos < self._max_pos)): 228 | self._pos += 1 229 | return cur_tok 230 | 231 | self._pos = start_pos 232 | return None 233 | 234 | def _expect(self, tok_pattern): 235 | """Accepts a token, or raises a ParserError if not found. 236 | 237 | Args: 238 | tok_pattern: The lexer.Token subclass or subclass instance 239 | to match, as described by TokenBuffer.accept(). 240 | 241 | Returns: 242 | The token under the cursor if it matches, otherwise None. 243 | 244 | Raises: 245 | ParserError: The pattern doesn't match the next token. 246 | """ 247 | tok = self._accept(tok_pattern) 248 | if tok is not None: 249 | return tok 250 | if isinstance(tok_pattern, type): 251 | name = getattr(tok_pattern, 'name', tok_pattern.__name__) 252 | raise ParserError('Expected {}'.format(name), 253 | token=self._peek()) 254 | raise ParserError('Expected {}'.format(tok_pattern._data), 255 | token=self._peek()) 256 | 257 | def _assert(self, node_or_none, desc): 258 | """Asserts that a node parsed, or raises a ParserError. 259 | 260 | Args: 261 | node_or_none: The result of a parsing function. 262 | 263 | Returns: 264 | The node, if not None. 265 | 266 | Raises: 267 | ParserError: The node is None. 268 | """ 269 | if node_or_none is not None: 270 | return node_or_none 271 | raise ParserError(desc, token=self._peek()) 272 | 273 | def _chunk(self): 274 | """Parse a chunk / block. 275 | 276 | chunk :: = {stat [';']} [laststat [';']] 277 | 278 | Returns: 279 | Chunk(stats) 280 | """ 281 | pos = self._pos 282 | stats = [] 283 | while True: 284 | # Eat leading and intervening semicolons. 285 | while self._accept(lexer.TokSymbol(';')) is not None: 286 | pass 287 | stat = self._stat() 288 | if stat is None: 289 | break 290 | stats.append(stat) 291 | 292 | # Eat leading and intervening semicolons. 293 | while self._accept(lexer.TokSymbol(';')) is not None: 294 | pass 295 | 296 | laststat = self._laststat() 297 | if laststat is not None: 298 | stats.append(laststat) 299 | 300 | # Eat trailing semicolons. 301 | while self._accept(lexer.TokSymbol(';')) is not None: 302 | pass 303 | 304 | return Chunk(stats, start=pos, end=self._pos) 305 | 306 | def _stat(self): 307 | """Parse a stat. 308 | 309 | stat ::= varlist '=' explist | 310 | functioncall | 311 | do block end | 312 | while exp do block end | 313 | repeat block until exp | 314 | if exp then block {elseif exp then block} [else block] end | 315 | for Name '=' exp ',' exp [',' exp] do block end | 316 | for namelist in explist do block end | 317 | function funcname funcbody | 318 | local function Name funcbody | 319 | local namelist ['=' explist] | 320 | ::label:: 321 | 322 | Returns: 323 | StatAssignment(varlist, assignop, explist) 324 | StatFunctionCall(functioncall) 325 | StatDo(block) 326 | StatWhile(exp, block) 327 | StatRepeat(block, exp) 328 | StatIf(exp_block_pairs) 329 | StatForStep(name, exp_init, exp_end, exp_step, block) 330 | StatForIn(namelist, explist, block) 331 | StatFunction(funcname, funcbody) 332 | StatLocalFunction(funcname, funcbody) 333 | StatLocalAssignment(namelist, explist) 334 | StatGoto(label) 335 | StatLabel(label) 336 | """ 337 | pos = self._pos 338 | 339 | varlist = self._varlist() 340 | if varlist is not None: 341 | # (Missing '=' is not a fatal error because varlist might also match 342 | # the beginning of a functioncall.) 343 | assign_op = (self._accept(lexer.TokSymbol('=')) or 344 | self._accept(lexer.TokSymbol('+=')) or 345 | self._accept(lexer.TokSymbol('-=')) or 346 | self._accept(lexer.TokSymbol('*=')) or 347 | self._accept(lexer.TokSymbol('/=')) or 348 | self._accept(lexer.TokSymbol('%='))) 349 | if assign_op is not None: 350 | explist = self._assert(self._explist(), 351 | 'Expected expression in assignment') 352 | return StatAssignment(varlist, assign_op, explist, 353 | start=pos, end=self._pos) 354 | self._pos = pos 355 | 356 | functioncall = self._functioncall() 357 | if functioncall is not None: 358 | return StatFunctionCall(functioncall, start=pos, end=self._pos) 359 | self._pos = pos 360 | 361 | if self._accept(lexer.TokKeyword('do')) is not None: 362 | block = self._assert(self._chunk(), 'block in do') 363 | self._expect(lexer.TokKeyword('end')) 364 | return StatDo(block, start=pos, end=self._pos) 365 | 366 | if self._accept(lexer.TokKeyword('while')) is not None: 367 | exp = self._assert(self._exp(), 'exp in while') 368 | self._expect(lexer.TokKeyword('do')) 369 | block = self._assert(self._chunk(), 'block in while') 370 | self._expect(lexer.TokKeyword('end')) 371 | return StatWhile(exp, block, start=pos, end=self._pos) 372 | 373 | if self._accept(lexer.TokKeyword('repeat')) is not None: 374 | block = self._assert(self._chunk(), 375 | 'block in repeat') 376 | self._expect(lexer.TokKeyword('until')) 377 | exp = self._assert(self._exp(), 378 | 'expression in repeat') 379 | return StatRepeat(block, exp, start=pos, end=self._pos) 380 | 381 | if self._accept(lexer.TokKeyword('if')) is not None: 382 | exp_block_pairs = [] 383 | exp = self._exp() 384 | 385 | then_pos = self._pos 386 | if (self._accept(lexer.TokKeyword('then')) is None and 387 | (self._tokens[exp._end_token_pos - 1] == lexer.TokSymbol(')'))): 388 | # Check for Pico-8 short form. 389 | 390 | then_end_pos = exp._end_token_pos 391 | while (then_end_pos < len(self._tokens) and 392 | not self._tokens[then_end_pos].matches(lexer.TokNewline)): 393 | then_end_pos += 1 394 | 395 | try: 396 | self._max_pos = then_end_pos 397 | block = self._assert(self._chunk(), 398 | 'valid chunk in short-if') 399 | else_block = None 400 | if self._accept(lexer.TokKeyword('else')) is not None: 401 | # Pico-8 accepts an else with nothing after it. 402 | else_block = self._chunk() 403 | finally: 404 | self._max_pos = None 405 | 406 | # (Use exp.value here to unwrap the condition from the 407 | # bracketed expression.) 408 | exp_block_pairs = [(exp.value, block)] 409 | if else_block is not None and len(else_block.stats) > 0: 410 | exp_block_pairs.append((None, else_block)) 411 | return StatIf(exp_block_pairs, start=pos, end=self._pos, 412 | short_if=True) 413 | 414 | self._pos = then_pos 415 | 416 | # TODO: hack: accept "do" for "then" to support seven carts that 417 | # exploit a loophole in short-if. 418 | self._expect(lexer.TokKeyword('then')) 419 | block = self._chunk() 420 | self._assert(block, 'Expected block in if') 421 | exp_block_pairs.append((exp, block)) 422 | while self._accept(lexer.TokKeyword('elseif')) is not None: 423 | exp = self._exp() 424 | self._expect(lexer.TokKeyword('then')) 425 | block = self._chunk() 426 | self._assert(block, 'Expected block in elseif') 427 | exp_block_pairs.append((exp, block)) 428 | if self._accept(lexer.TokKeyword('else')) is not None: 429 | block = self._chunk() 430 | self._assert(block, 'Expected block in else') 431 | exp_block_pairs.append((None, block)) 432 | self._expect(lexer.TokKeyword('end')) 433 | return StatIf(exp_block_pairs, start=pos, end=self._pos) 434 | 435 | if self._accept(lexer.TokKeyword('for')) is not None: 436 | for_pos = self._pos 437 | 438 | name = self._accept(lexer.TokName) 439 | eq_sym = self._accept(lexer.TokSymbol('=')) 440 | if eq_sym is not None: 441 | exp_init = self._assert(self._exp(), 'exp-init in for') 442 | self._expect(lexer.TokSymbol(',')) 443 | exp_end = self._assert(self._exp(), 'exp-end in for') 444 | exp_step = None 445 | if self._accept(lexer.TokSymbol(',')): 446 | exp_step = self._assert(self._exp(), 'exp-step in for') 447 | self._expect(lexer.TokKeyword('do')) 448 | block = self._assert(self._chunk(), 'block in for') 449 | self._expect(lexer.TokKeyword('end')) 450 | return StatForStep(name, exp_init, exp_end, exp_step, block, 451 | start=pos, end=self._pos) 452 | self._pos = for_pos 453 | 454 | namelist = self._assert(self._namelist(), 'namelist in for-in') 455 | self._expect(lexer.TokKeyword('in')) 456 | explist = self._assert(self._explist(), 'explist in for-in') 457 | self._expect(lexer.TokKeyword('do')) 458 | block = self._assert(self._chunk(), 'block in for-in') 459 | self._expect(lexer.TokKeyword('end')) 460 | return StatForIn(namelist, explist, block, start=pos, end=self._pos) 461 | 462 | if self._accept(lexer.TokKeyword('function')) is not None: 463 | funcname = self._assert(self._funcname(), 'funcname in function') 464 | funcbody = self._assert(self._funcbody(), 'funcbody in function') 465 | return StatFunction(funcname, funcbody, start=pos, end=self._pos) 466 | 467 | if self._accept(lexer.TokKeyword('local')) is not None: 468 | if self._accept(lexer.TokKeyword('function')) is not None: 469 | funcname = self._expect(lexer.TokName) 470 | funcbody = self._assert(self._funcbody(), 471 | 'funcbody in local function') 472 | return StatLocalFunction(funcname, funcbody, 473 | start=pos, end=self._pos) 474 | namelist = self._assert(self._namelist(), 475 | 'namelist in local assignment') 476 | explist = None 477 | if self._accept(lexer.TokSymbol('=')) is not None: 478 | explist = self._assert(self._explist(), 479 | 'explist in local assignment') 480 | return StatLocalAssignment(namelist, explist, 481 | start=pos, end=self._pos) 482 | 483 | if self._accept(lexer.TokKeyword('goto')) is not None: 484 | label = self._expect(lexer.TokName) 485 | return StatGoto(label.value, start=pos, end=self._pos) 486 | 487 | label = self._accept(lexer.TokLabel) 488 | if label is not None: 489 | # Remove colons from label. 490 | label_name = label.value[2:-2] 491 | return StatLabel(label_name, start=pos, end=self._pos) 492 | 493 | self._pos = pos 494 | return None 495 | 496 | def _laststat(self): 497 | """Parse a laststat. 498 | 499 | laststat ::= return [explist] | break 500 | 501 | Returns: 502 | StatBreak() 503 | StatReturn(explist) 504 | """ 505 | pos = self._pos 506 | if self._accept(lexer.TokKeyword('break')) is not None: 507 | return StatBreak(start=pos, end=self._pos) 508 | if self._accept(lexer.TokKeyword('return')) is not None: 509 | explist = self._explist() 510 | return StatReturn(explist, start=pos, end=self._pos) 511 | self._pos = pos 512 | return None 513 | 514 | def _funcname(self): 515 | """Parse a funcname. 516 | 517 | funcname ::= Name {'.' Name} [':' Name] 518 | 519 | Returns: 520 | FunctionName(namepath, methodname) 521 | """ 522 | pos = self._pos 523 | namepath = [] 524 | methodname = None 525 | 526 | name = self._accept(lexer.TokName) 527 | if name is None: 528 | return None 529 | namepath.append(name) 530 | while self._accept(lexer.TokSymbol('.')) is not None: 531 | namepath.append(self._expect(lexer.TokName)) 532 | if self._accept(lexer.TokSymbol(':')) is not None: 533 | methodname = self._expect(lexer.TokName) 534 | 535 | return FunctionName(namepath, methodname, start=pos, end=self._pos) 536 | 537 | def _varlist(self): 538 | """Parse a varlist. 539 | 540 | varlist ::= var {',' var} 541 | 542 | Returns: 543 | VarList(vars) 544 | """ 545 | pos = self._pos 546 | _vars = [] 547 | var = self._var() 548 | if var is None: 549 | return None 550 | _vars.append(var) 551 | while self._accept(lexer.TokSymbol(',')) is not None: 552 | _vars.append(self._assert(self._var(), 'var in varlist')) 553 | return VarList(_vars, start=pos, end=self._pos) 554 | 555 | def _var(self): 556 | """Parse a var. 557 | 558 | var ::= Name | prefixexp '[' exp ']' | prefixexp '.' Name 559 | 560 | Returns: 561 | VarName(name) 562 | VarIndex(exp_prefix, exp_index) 563 | VarAttribute(exp_prefix, attr_name) 564 | """ 565 | exp_prefix = self._prefixexp() 566 | if (isinstance(exp_prefix, VarName) or 567 | isinstance(exp_prefix, VarAttribute) or 568 | isinstance(exp_prefix, VarIndex)): 569 | return exp_prefix 570 | return None 571 | 572 | def _namelist(self): 573 | """Parse a namelist. 574 | 575 | namelist ::= Name {',' Name} 576 | 577 | Returns: 578 | NameList(names) 579 | """ 580 | pos = self._pos 581 | names = [] 582 | name = self._accept(lexer.TokName) 583 | if name is None: 584 | return None 585 | names.append(name) 586 | last_pos = self._pos 587 | while self._accept(lexer.TokSymbol(',')) is not None: 588 | name = self._accept(lexer.TokName) 589 | if name is None: 590 | # Don't eat the trailing separator if there is one. 591 | self._pos = last_pos 592 | break 593 | names.append(name) 594 | last_pos = self._pos 595 | 596 | return NameList(names, start=pos, end=self._pos) 597 | 598 | def _explist(self): 599 | """Parse an explist. 600 | 601 | explist ::= {exp ','} exp 602 | 603 | Returns: 604 | ExpList(exps) 605 | """ 606 | pos = self._pos 607 | exps = [] 608 | exp = self._exp() 609 | if exp is None: 610 | self._pos = pos 611 | return None 612 | exps.append(exp) 613 | while True: 614 | if self._accept(lexer.TokSymbol(',')) is None: 615 | break 616 | exp = self._assert(self._exp(), 'exp after comma') 617 | exps.append(exp) 618 | if len(exps) == 0: 619 | self._pos = pos 620 | return None 621 | return ExpList(exps, start=pos, end=self._pos) 622 | 623 | def _exp(self): 624 | """Parse an exp. 625 | 626 | exp ::= exp_term exp_binop 627 | 628 | Returns: 629 | ExpValue(value) 630 | VarargDots() 631 | ExpUnOp(unop, exp) 632 | ExpBinOp(exp1, binop, exp2) 633 | """ 634 | pos = self._pos 635 | exp_term = self._exp_term() 636 | if exp_term is None: 637 | return None 638 | return self._exp_binop(exp_term) 639 | 640 | def _exp_binop(self, exp_first): 641 | """Parse the recursive part of a binary-op expression. 642 | 643 | exp_binop ::= binop exp_term exp_binop | 644 | 645 | Args: 646 | exp_first: The already-made first argument to the operator. 647 | 648 | Returns: 649 | ExpBinOp(exp_first, binop, exp_term, exp_binop) 650 | exp_first 651 | """ 652 | pos = self._pos 653 | 654 | # TODO: rewrite binary expression parsing so that the AST captures 655 | # associativity: 656 | # or 657 | # and 658 | # < > <= >= ~= != == 659 | # .. (right associative) 660 | # + - 661 | # * / % 662 | # not # - (unary) 663 | # ^ (right associative) 664 | 665 | for pat in BINOP_PATS: 666 | binop = self._accept(pat) 667 | if binop is not None: 668 | exp_second = self._assert(self._exp_term(), 'exp2 in binop') 669 | this_binop = ExpBinOp(exp_first, binop, exp_second, 670 | start=pos, end=self._pos) 671 | return self._exp_binop(this_binop) 672 | 673 | self._pos = pos 674 | return exp_first 675 | 676 | def _exp_term(self): 677 | """Parse a non-recursive expression term. 678 | 679 | exp_term ::= nil | false | true | Number | String | '...' | function | 680 | prefixexp | tableconstructor | unop exp 681 | 682 | Returns: 683 | ExpValue(value) 684 | VarargDots() 685 | ExpUnOp(unop, exp) 686 | """ 687 | pos = self._pos 688 | if self._accept(lexer.TokKeyword('nil')) is not None: 689 | return ExpValue(None, start=pos, end=self._pos) 690 | if self._accept(lexer.TokKeyword('false')) is not None: 691 | return ExpValue(False, start=pos, end=self._pos) 692 | if self._accept(lexer.TokKeyword('true')) is not None: 693 | return ExpValue(True, start=pos, end=self._pos) 694 | val = self._accept(lexer.TokNumber) 695 | if val is not None: 696 | return ExpValue(val, start=pos, end=self._pos) 697 | val = self._accept(lexer.TokString) 698 | if val is not None: 699 | return ExpValue(val, start=pos, end=self._pos) 700 | if self._accept(lexer.TokSymbol('...')) is not None: 701 | return VarargDots(start=pos, end=self._pos) 702 | val = self._function() 703 | if val is not None: 704 | return ExpValue(val, start=pos, end=self._pos) 705 | val = self._prefixexp() 706 | if val is not None: 707 | return ExpValue(val, start=pos, end=self._pos) 708 | val = self._tableconstructor() 709 | if val is not None: 710 | return ExpValue(val, start=pos, end=self._pos) 711 | 712 | unop = self._accept(lexer.TokSymbol('-')) 713 | if unop is None: 714 | unop = self._accept(lexer.TokKeyword('not')) 715 | if unop is None: 716 | unop = self._accept(lexer.TokSymbol('#')) 717 | if unop is None: 718 | return None 719 | exp = self._assert(self._exp(), 'exp after unary op') 720 | return ExpUnOp(unop, exp, start=pos, end=self._pos) 721 | 722 | def _prefixexp(self): 723 | """Parse a prefixexp. 724 | 725 | prefixexp ::= var | functioncall | '(' exp ')' 726 | 727 | functioncall ::= prefixexp args | prefixexp ':' Name args 728 | 729 | args ::= '(' [explist] ')' | tableconstructor | String 730 | 731 | This expands to: 732 | 733 | prefixexp ::= Name | prefixexp '[' exp ']' | prefixexp '.' Name | 734 | prefixexp args | prefixexp ':' Name args | '(' exp ')' 735 | 736 | Or: 737 | 738 | prefixexp ::= Name prefixexp_recur | 739 | '(' exp ')' prefixexp_recur 740 | 741 | Returns: 742 | VarList(vars) 743 | VarName(name) 744 | VarIndex(exp_prefix, exp_index) 745 | VarAttribute(exp_prefix, attr_name) 746 | FunctionCall(exp_prefix, args) 747 | FunctionCallMethod(exp_prefix, methodname, args) 748 | ExpValue(value) 749 | VarargDots() 750 | ExpBinOp(exp1, binop, exp2) 751 | ExpUnOp(unop, exp) 752 | """ 753 | pos = self._pos 754 | name = self._accept(lexer.TokName) 755 | if name is not None: 756 | return self._prefixexp_recur( 757 | VarName(name, start=pos, end=self._pos)) 758 | 759 | if self._accept(lexer.TokSymbol('(')) is not None: 760 | # (exp can be None.) 761 | exp = self._exp() 762 | self._expect(lexer.TokSymbol(')')) 763 | return self._prefixexp_recur(exp) 764 | 765 | return None 766 | 767 | def _prefixexp_recur(self, prefixexp_first): 768 | """Parse the recurring part of a prefixexp. 769 | 770 | prefixexp_recur ::= '[' exp ']' prefixexp_recur | # VarIndex 771 | '.' Name prefixexp_recur | # VarAttribute 772 | args prefixexp_recur | # FunctionCall 773 | ':' Name args prefixexp_recur | # FunctionCallMethod 774 | 775 | 776 | Args: 777 | prefixexp_first: The first part of the prefixexp. 778 | 779 | Returns: 780 | VarIndex(exp_prefix, exp_index) 781 | VarAttribute(exp_prefix, attr_name) 782 | FunctionCall(exp_prefix, args) 783 | FunctionCallMethod(exp_prefix, methodname, args) 784 | prefixexp_first 785 | """ 786 | pos = self._pos 787 | if self._accept(lexer.TokSymbol('[')) is not None: 788 | exp = self._assert(self._exp(), 'exp in prefixexp index') 789 | self._expect(lexer.TokSymbol(']')) 790 | return self._prefixexp_recur(VarIndex(prefixexp_first, exp, 791 | start=pos, end=self._pos)) 792 | if self._accept(lexer.TokSymbol('.')) is not None: 793 | name = self._expect(lexer.TokName) 794 | return self._prefixexp_recur(VarAttribute(prefixexp_first, name, 795 | start=pos, end=self._pos)) 796 | args = self._args() 797 | if args is not None: 798 | return self._prefixexp_recur(FunctionCall(prefixexp_first, args, 799 | start=pos, end=self._pos)) 800 | if self._accept(lexer.TokSymbol(':')) is not None: 801 | name = self._expect(lexer.TokName) 802 | args = self._assert(self._args(), 'args for method call') 803 | return self._prefixexp_recur( 804 | FunctionCallMethod(prefixexp_first, name, args, 805 | start=pos, end=self._pos)) 806 | return prefixexp_first 807 | 808 | def _functioncall(self): 809 | """Parse a functioncall. 810 | 811 | Returns: 812 | FunctionCall(exp_prefix, args) 813 | FunctionCallMethod(exp_prefix, methodname, args) 814 | """ 815 | pos = self._pos 816 | 817 | full_exp = self._prefixexp() 818 | if (full_exp is None or 819 | (not isinstance(full_exp, FunctionCall) and 820 | not isinstance(full_exp, FunctionCallMethod))): 821 | self._pos = pos 822 | return None 823 | return full_exp 824 | 825 | def _args(self): 826 | """Parse functioncall args. 827 | 828 | Returns: 829 | ExpList(exps) 830 | TableConstructor(fields) 831 | lexer.TokString 832 | None 833 | """ 834 | pos = self._pos 835 | if self._accept(lexer.TokSymbol('(')): 836 | explist = self._explist() 837 | self._expect(lexer.TokSymbol(')')) 838 | return FunctionArgs(explist, start=pos, end=self._pos) 839 | 840 | tableconstructor = self._tableconstructor() 841 | if tableconstructor is not None: 842 | return tableconstructor 843 | 844 | string_lit = self._accept(lexer.TokString) 845 | if string_lit is not None: 846 | return string_lit 847 | 848 | return None 849 | 850 | def _function(self): 851 | """Parse a function. 852 | 853 | function ::= function funcbody 854 | 855 | Returns: 856 | Function(funcbody) 857 | """ 858 | pos = self._pos 859 | if self._accept(lexer.TokKeyword('function')): 860 | funcbody = self._assert(self._funcbody(), 'funcbody in function') 861 | return Function(funcbody, start=pos, end=self._pos) 862 | return None 863 | 864 | def _funcbody(self): 865 | """Parse a funcbody. 866 | 867 | funcbody ::= '(' [parlist] ')' block end 868 | 869 | parlist ::= namelist [',' '...'] | '...' 870 | 871 | Returns: 872 | FunctionBody(parlist, dots, block) 873 | """ 874 | pos = self._pos 875 | if self._accept(lexer.TokSymbol('(')) is None: 876 | return None 877 | 878 | namelist = self._namelist() 879 | dots = None 880 | if namelist is not None: 881 | if self._accept(lexer.TokSymbol(',')) is not None: 882 | dots_pos = self._pos 883 | dots = self._expect(lexer.TokSymbol('...')) 884 | else: 885 | dots_pos = self._pos 886 | dots = self._accept(lexer.TokSymbol('...')) 887 | if dots is not None: 888 | dots = VarargDots(start=dots_pos, end=self._pos) 889 | 890 | self._expect(lexer.TokSymbol(')')) 891 | block = self._assert(self._chunk(), 'block in funcbody') 892 | self._expect(lexer.TokKeyword('end')) 893 | 894 | return FunctionBody(namelist, dots, block, start=pos, end=self._pos) 895 | 896 | def _tableconstructor(self): 897 | """Parse a tableconstructor. 898 | 899 | tableconstructor ::= '{' [fieldlist] '}' 900 | 901 | fieldlist ::= field {fieldsep field} [fieldsep] 902 | 903 | fieldsep ::= ',' | ';' 904 | 905 | Returns: 906 | TableConstructor(fields) 907 | """ 908 | pos = self._pos 909 | if self._accept(lexer.TokSymbol('{')) is None: 910 | return None 911 | 912 | fields = [] 913 | field = self._field() 914 | if field is not None: 915 | fields.append(field) 916 | while (self._accept(lexer.TokSymbol(',')) is not None or 917 | self._accept(lexer.TokSymbol(';')) is not None): 918 | field = self._field() 919 | if field is None: 920 | break 921 | fields.append(field) 922 | 923 | self._expect(lexer.TokSymbol('}')) 924 | return TableConstructor(fields, start=pos, end=self._pos) 925 | 926 | def _field(self): 927 | """Parse a field. 928 | 929 | field ::= '[' exp ']' '=' exp | Name '=' exp | exp 930 | 931 | Returns: 932 | FieldExpKey(key_exp, exp) 933 | FieldNamedKey(key_name, exp) 934 | FieldExp(exp) 935 | """ 936 | pos = self._pos 937 | if self._accept(lexer.TokSymbol('[')): 938 | key_exp = self._assert(self._exp(), 'exp key in field') 939 | self._expect(lexer.TokSymbol(']')) 940 | self._expect(lexer.TokSymbol('=')) 941 | exp = self._assert(self._exp(), 'exp value in field') 942 | return FieldExpKey(key_exp, exp, start=pos, end=self._pos) 943 | 944 | key_name = self._accept(lexer.TokName) 945 | if (key_name is not None and 946 | self._accept(lexer.TokSymbol('=')) is not None): 947 | exp = self._assert(self._exp(), 'exp value in field') 948 | return FieldNamedKey(key_name, exp, start=pos, end=self._pos) 949 | self._pos = pos 950 | 951 | exp = self._exp() 952 | if exp is not None: 953 | return FieldExp(exp, start=pos, end=self._pos) 954 | 955 | return None 956 | 957 | def process_tokens(self, tokens): 958 | """Process a list of tokens into an AST. 959 | 960 | This method must be single-threaded. To process multiple 961 | tokens in multiple threads, use one Parser instance per 962 | thread. 963 | 964 | Args: 965 | tokens: An iterable of lexer.Token objects. All tokens will 966 | be loaded into memory for processing. 967 | 968 | Raises: 969 | ParserError: Some pattern of tokens did not match the grammar. 970 | """ 971 | self._tokens = list(tokens) 972 | self._pos = 0 973 | self._ast = self._assert(self._chunk(), 'input to be a program') 974 | 975 | @property 976 | def root(self): 977 | """The root of the AST produced by process_tokens().""" 978 | return self._ast 979 | -------------------------------------------------------------------------------- /pico8/map/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andmatand/midi-to-pico8/371e52a584631d668ed127f7d43678cd0916fc1f/pico8/map/__init__.py -------------------------------------------------------------------------------- /pico8/map/map.py: -------------------------------------------------------------------------------- 1 | """The map section of a Pico-8 cart. 2 | 3 | The map region consists of 4096 bytes. The .p8 representation is 32 4 | lines of 256 hexadecimal digits (128 bytes). 5 | 6 | The map is 128 tiles wide by 64 tiles high. Each tile is one of the 7 | 256 tiles from the spritesheet. Map memory describes the top 32 rows 8 | (128 * 32 = 4096). If the developer draws tiles in the bottom 32 rows, 9 | this is stored in the bottom of the gfx memory region. 10 | """ 11 | 12 | __all__ = ['Map'] 13 | 14 | from .. import util 15 | 16 | 17 | class Map(util.BaseSection): 18 | """The map region of a Pico-8 cart.""" 19 | HEX_LINE_LENGTH_BYTES = 128 20 | 21 | def __init__(self, *args, **kwargs): 22 | """The initializer. 23 | 24 | The Map initializer takes an optional gfx keyword argument 25 | whose value is a reference to the Gfx instance where lower map 26 | data is stored. 27 | """ 28 | self._gfx = None 29 | if 'gfx' in kwargs: 30 | self._gfx = kwargs['gfx'] 31 | del kwargs['gfx'] 32 | super().__init__(*args, **kwargs) 33 | 34 | @classmethod 35 | def empty(cls, version=4, gfx=None): 36 | """Creates an empty instance. 37 | 38 | Args: 39 | version: The Pico-8 file version. 40 | gfx: The Gfx object where lower map data is written. 41 | 42 | Returns: 43 | A Map instance. 44 | """ 45 | return cls(data=bytearray(b'\x00' * 4096), version=version, gfx=gfx) 46 | 47 | @classmethod 48 | def from_lines(cls, *args, **kwargs): 49 | gfx = None 50 | if 'gfx' in kwargs: 51 | gfx = kwargs['gfx'] 52 | del kwargs['gfx'] 53 | result = super().from_lines(*args, **kwargs) 54 | result._gfx = gfx 55 | return result 56 | 57 | @classmethod 58 | def from_bytes(cls, *args, **kwargs): 59 | gfx = None 60 | if 'gfx' in kwargs: 61 | gfx = kwargs['gfx'] 62 | del kwargs['gfx'] 63 | result = super().from_bytes(*args, **kwargs) 64 | result._gfx = gfx 65 | return result 66 | 67 | def get_cell(self, x, y): 68 | """Gets the tile ID for a map cell. 69 | 70 | Args: 71 | x: The map cell x (column) coordinate. (0-127) 72 | y: The map cell y (row) coordinate. Map must have a Gfx if y > 31. 73 | (0-63) 74 | 75 | Returns: 76 | The tile ID for the cell. 77 | """ 78 | assert 0 <= x <= 127 79 | assert (0 <= y <= 31) or ((0 <= y <= 63) and self._gfx is not None) 80 | if y <= 31: 81 | return self._data[y * 128 + x] 82 | return self._gfx._data[4096 + (y - 32) * 128 + x] 83 | 84 | def set_cell(self, x, y, val): 85 | """Sets the tile ID for a map cell. 86 | 87 | Args: 88 | x: The map cell x (column) coordinate. (0-127) 89 | y: The map cell y (row) coordinate. (0-63) If y > 31, Map must have a 90 | Gfx, and this method updates the shared data region in the Gfx. 91 | val: The new tile ID for the cell. (0-255) 92 | """ 93 | assert 0 <= x <= 127 94 | assert (0 <= y <= 31) or ((0 <= y <= 63) and self._gfx is not None) 95 | assert 0 <= val <= 255 96 | if y <= 31: 97 | self._data[y * 128 + x] = val 98 | else: 99 | self._gfx._data[4096 + (y - 32) * 128 + x] = val 100 | 101 | def get_rect_tiles(self, x, y, width=1, height=1): 102 | """Gets a rectangle of map tiles. 103 | 104 | The map is a grid of 128x32 tiles, or 128x64 if using the 105 | gfx/map shared memory for map data. This method returns a 106 | rectangle of tile IDs on the map, as a list of bytearrays. 107 | 108 | If the requested rectangle size goes off the edge of the map, 109 | the off-edge tiles are returned as 0. The bottom edge is 110 | always assumed to be beyond the 64th row in the gfx/map shared 111 | memory region. 112 | 113 | Args: 114 | x: The map cell x (column) coordinate. (0-127) 115 | y: The map cell y (row) coordinate. (0-63) If y + height > 31, Map 116 | must have a Gfx. 117 | width: The width of the rectangle, as a number of tiles. 118 | height: The height of the rectangle, as a number of tiles. 119 | 120 | Returns: 121 | The rectangle of tile IDs, as a list of bytearrays. 122 | """ 123 | assert 0 <= x <= 127 124 | assert 1 <= width 125 | assert 1 <= height 126 | assert ((0 <= y + height <= 32) or 127 | ((0 <= y + height <= 64) and self._gfx is not None)) 128 | result = [] 129 | for tile_y in range(y, y + height): 130 | row = bytearray() 131 | for tile_x in range(x, x + width): 132 | if (tile_y > 63) or (tile_x > 127): 133 | row.append(0) 134 | else: 135 | row.append(self.get_cell(tile_x, tile_y)) 136 | result.append(row) 137 | return result 138 | 139 | def set_rect_tiles(self, rect, x, y): 140 | """Writes a rectangle of tiles to the map. 141 | 142 | If writing the given rectangle at the given coordinates causes 143 | the rectangle to extend off the edge of the map, the remainer 144 | is discarded. 145 | 146 | Args: 147 | rect: A rectangle of tile IDs, as an iterable of iterables of IDs. 148 | x: The map tile x coordinate (column) of the upper left corner to 149 | start writing. 150 | y: The map tile y coordinate (row) of the upper left corner to 151 | start writing. 152 | """ 153 | for tile_y, row in enumerate(rect): 154 | for tile_x, val in enumerate(row): 155 | if ((tile_y + y) > 127) or ((tile_x + x) > 127): 156 | continue 157 | self.set_cell(tile_x + x, tile_y + y, val) 158 | 159 | def get_rect_pixels(self, x, y, width=1, height=1): 160 | 161 | """Gets a rectangel of map tiles as pixels. 162 | 163 | This is similar to get_rect_tiles() except the tiles are 164 | extracted from Gfx data and returned as a rectangle of pixels. 165 | 166 | Just like Pico-8, tile ID 0 is rendered as empty (all 0's), 167 | not the actual tile at ID 0. 168 | 169 | Args: 170 | x: The map cell x (column) coordinate. (0-127) 171 | y: The map cell y (row) coordinate. (0-63) If y + height > 31, Map 172 | must have a Gfx. 173 | width: The width of the rectangle, as a number of tiles. 174 | height: The height of the rectangle, as a number of tiles. 175 | 176 | Returns: 177 | The rectangle of pixels, as a list of bytearrays of pixel colors. 178 | 179 | """ 180 | assert self._gfx is not None 181 | assert 0 <= x <= 127 182 | assert 1 <= width 183 | assert 1 <= height 184 | assert 0 <= y + height <= 64 185 | tile_rect = self.get_rect_tiles(x, y, width, height) 186 | result = [] 187 | for tile_row in tile_rect: 188 | pixel_row = [bytearray(), bytearray(), bytearray(), bytearray(), 189 | bytearray(), bytearray(), bytearray(), bytearray()] 190 | for id in tile_row: 191 | if id == 0: 192 | sprite = [bytearray(b'\x00' * 8)] * 8 193 | else: 194 | sprite = self._gfx.get_sprite(id) 195 | for i in range(0, 8): 196 | pixel_row[i].extend(sprite[i]) 197 | for i in range(0, 8): 198 | result.append(pixel_row[i]) 199 | return result 200 | -------------------------------------------------------------------------------- /pico8/music/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andmatand/midi-to-pico8/371e52a584631d668ed127f7d43678cd0916fc1f/pico8/music/__init__.py -------------------------------------------------------------------------------- /pico8/music/music.py: -------------------------------------------------------------------------------- 1 | """The music (song) region of a Pico-8 cart. 2 | 3 | The music region consists of 256 bytes. The .p8 representation is one 4 | line for each of 64 patterns, with a hex-encoded flags byte, a space, 5 | and four hex-encoded one-byte sound numbers. 6 | 7 | The flags are: 8 | 1: begin pattern loop 9 | 2: end pattern loop 10 | 4: stop at end of pattern 11 | 12 | The sound numbers represents four channels played simultaneously up to 13 | the shortest pattern. The sounds are numbered 0 through 63 14 | (0x00-0x3f). If a channel is silent for a song pattern, its number is 15 | 64 + the channel number (0x41, 0x42, 0x43, or 0x44). 16 | 17 | The in-memory (and PNG) representation is slightly different from the 18 | .p8 representation. Instead of storing the flags in a separate byte, 19 | the flags are stored in the highest bit of the first three channels, 20 | for a total of four bytes: 21 | chan1 & 128: stop at end of pattern 22 | chan2 & 128: end pattern loop 23 | chan3 & 128: begin pattern loop 24 | """ 25 | 26 | __all__ = ['Music'] 27 | 28 | from .. import util 29 | 30 | 31 | class Music(util.BaseSection): 32 | @classmethod 33 | def empty(cls, version): 34 | """Creates an empty instance. 35 | 36 | Args: 37 | version: The Pico-8 file version. 38 | 39 | Returns: 40 | A Music instance. 41 | """ 42 | return cls(data=bytearray(b'\x41\x42\x43\x44' * 64), version=version) 43 | 44 | @classmethod 45 | def from_lines(cls, lines, version): 46 | """Parse the music .p8 section into memory bytes. 47 | 48 | Args: 49 | lines: .p8 lines for the music section. 50 | version: The Pico-8 data version from the game file header. 51 | 52 | Returns: 53 | A Music instance with the loaded data. 54 | """ 55 | data = bytearray() 56 | for l in lines: 57 | if l.find(' ') == -1: 58 | continue 59 | flagstr, chanstr = l.split(' ') 60 | flags = bytes.fromhex(flagstr)[0] 61 | fstop = (flags & 4) >> 2 62 | frepeat = (flags & 2) >> 1 63 | fnext = flags & 1 64 | 65 | chan1 = bytes.fromhex(chanstr[0:2]) 66 | chan2 = bytes.fromhex(chanstr[2:4]) 67 | chan3 = bytes.fromhex(chanstr[4:6]) 68 | chan4 = bytes.fromhex(chanstr[6:8]) 69 | data.append(chan1[0] | fnext << 7) 70 | data.append(chan2[0] | frepeat << 7) 71 | data.append(chan3[0] | fstop << 7) 72 | data.append(chan4[0]) 73 | 74 | return cls(data=data, version=version) 75 | 76 | def to_lines(self): 77 | """Generates lines for the music section of a .p8 file. 78 | 79 | Yields: 80 | One line. 81 | """ 82 | for start_i in range(0, len(self._data), 4): 83 | fstop = (self._data[start_i+2] & 128) >> 7 84 | frepeat = (self._data[start_i+1] & 128) >> 7 85 | fnext = (self._data[start_i] & 128) >> 7 86 | p8flags = (fstop << 2) | (frepeat << 1) | fnext 87 | chan1 = self._data[start_i] & 127 88 | chan2 = self._data[start_i+1] & 127 89 | chan3 = self._data[start_i+2] & 127 90 | chan4 = self._data[start_i+3] & 127 91 | yield (bytes([p8flags]).hex() + ' ' + 92 | bytes([chan1, chan2, chan3, chan4]).hex() + '\n') 93 | 94 | def get_channel(self, id, channel): 95 | """Gets the sfx ID on a channel for a given pattern. 96 | 97 | Args: 98 | id: The music ID. (0-63) 99 | channel: The channel. (0-3) 100 | 101 | Returns: 102 | The sfx ID on the channel, or None if the channel is silent. 103 | """ 104 | assert 0 <= id <= 63 105 | assert 0 <= channel <= 3 106 | pattern = self._data[id * 4 + channel] & 0x7f 107 | if pattern > 63: 108 | return None 109 | return pattern 110 | 111 | def set_channel(self, id, channel, pattern): 112 | """Sets the sfx ID on a channel of a pattern. 113 | 114 | Args: 115 | id: The music ID. (0-63) 116 | channel: The channel. (0-3) 117 | pattern: The sfx ID, or None to set the channel to silent. 118 | """ 119 | assert 0 <= id <= 63 120 | assert 0 <= channel <= 3 121 | assert (pattern is None) or (0 <= pattern <= 63) 122 | if pattern is None: 123 | pattern = 0x40 + channel + 1 124 | self._data[id * 4 + channel] = ((self._data[id * 4 + channel] & 0x80) | 125 | pattern) 126 | 127 | def get_properties(self, id): 128 | """Gets the properties of the music pattern. 129 | 130 | begin is True if the music pattern is the beginning of a looping region. 131 | 132 | end is True if the music pattern is the end of a looping region. 133 | 134 | stop is True if the music stops after this pattern is played. 135 | 136 | Args: 137 | id: The music ID. (0-63) 138 | 139 | Returns: 140 | A tuple: (being, end, stop). These are Booleans (True or False). 141 | """ 142 | assert 0 <= id <= 63 143 | begin = (self._data[id * 4] & 0x80) > 0 144 | end = (self._data[id * 4 + 1] & 0x80) > 0 145 | stop = (self._data[id * 4 + 2] & 0x80) > 0 146 | return (begin, end, stop) 147 | 148 | def set_properties(self, id, begin=None, end=None, stop=None): 149 | """Sets the properties of the music pattern. 150 | 151 | Specify values of True or False to change a property, or None to leave 152 | the property unchanged. 153 | 154 | Args: 155 | id: The music ID. (0-63) 156 | begin: True to set the flag, False to unset the flag, or None to 157 | leave it unchanged. 158 | end: True to set the flag, False to unset the flag, or None to 159 | leave it unchanged. 160 | stop: True to set the flag, False to unset the flag, or None to 161 | leave it unchanged. 162 | """ 163 | if begin is not None: 164 | self._data[id * 4] = ((self._data[id * 4] & 0x7f) | 165 | (0x80 if begin else 0x00)) 166 | if end is not None: 167 | self._data[id * 4 + 1] = ((self._data[id * 4 + 1] & 0x7f) | 168 | (0x80 if end else 0x00)) 169 | if stop is not None: 170 | self._data[id * 4 + 2] = ((self._data[id * 4 + 2] & 0x7f) | 171 | (0x80 if stop else 0x00)) 172 | -------------------------------------------------------------------------------- /pico8/sfx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andmatand/midi-to-pico8/371e52a584631d668ed127f7d43678cd0916fc1f/pico8/sfx/__init__.py -------------------------------------------------------------------------------- /pico8/sfx/sfx.py: -------------------------------------------------------------------------------- 1 | """The sound effects section of a Pico-8 cart. 2 | 3 | The sound effects region consists of 4352 bytes. The .p8 4 | representation is 64 lines of 168 hexadecimal digits (84 bytes). 5 | 6 | Each line represents one sound effect/music pattern. The values are as follows: 7 | 0 The editor mode: 0 for pitch mode, 1 for note entry mode. 8 | 1 The note duration, in multiples of 1/128 second. 9 | 2 Loop range start, as a note number (0-63). 10 | 3 Loop range end, as a note number (0-63). 11 | 4-84 32 notes: 12 | 0: pitch (0-63): c-0 to d#-5, chromatic scale 13 | 1-high: waveform (0-7): 14 | 0 sine, 1 triangle, 2 sawtooth, 3 long square, 4 short square, 15 | 5 ringing, 6 noise, 7 ringing sine 16 | 1-low: volume (0-7) 17 | 2-high: effect (0-7): 18 | 0 none, 1 slide, 2 vibrato, 3 drop, 4 fade_in, 5 fade_out, 19 | 6 arp fast, 7 arp slow; arpeggio commands loop over groups of 20 | four notes at speed 2 (fast) and 4 (slow) 21 | One note uses five nibbles, so two notes use five bytes. 22 | 23 | The RAM representation is different. Each pattern is 68 bytes, with 24 | two bytes for each of 32 notes, one byte for the editor mode, one byte 25 | for the speed, and two bytes for the loop range (start, end). Each 26 | note is encoded in 16 bits, LSB first, like so: 27 | 28 | w2-w1-pppppp ?-eee-vvv-w3 29 | 30 | eee: effect (0-7) 31 | vvv: volume (0-7) 32 | w3w2w1: waveform (0-7) 33 | pppppp: pitch (0-63) 34 | 35 | The highest bit appears to be unused. In RAM, Pico-8 sets it for the 2nd 36 | note in the pattern for some reason, but this is not written to the PNG. 37 | """ 38 | 39 | __all__ = ['Sfx'] 40 | 41 | from .. import util 42 | 43 | 44 | WAVEFORM_SINE = 0 45 | WAVEFORM_TRIANGLE = 1 46 | WAVEFORM_SAWTOOTH = 2 47 | WAVEFORM_LONG_SQUARE = 3 48 | WAVEFORM_SHORT_SQUARE = 4 49 | WAVEFORM_RINGING = 5 50 | WAVEFORM_NOISE = 6 51 | WAVEFORM_RINGING_SINE = 7 52 | EFFECT_NONE = 0 53 | EFFECT_SLIDE = 1 54 | EFFECT_VIBRATO = 2 55 | EFFECT_DROP = 3 56 | EFFECT_FADE_IN = 4 57 | EFFECT_FADE_OUT = 5 58 | EFFECT_ARP_FAST = 6 59 | EFFECT_ARP_SLOW = 7 60 | 61 | 62 | class Sfx(util.BaseSection): 63 | """The sfx region of a Pico-8 cart.""" 64 | 65 | HEX_LINE_LENGTH_BYTES = 84 66 | 67 | @classmethod 68 | def empty(cls, version): 69 | """Creates an empty instance. 70 | 71 | Args: 72 | version: The Pico-8 file version. 73 | 74 | Returns: 75 | A Sfx instance. 76 | """ 77 | result = cls(data=bytearray(b'\x00' * 4352), version=version) 78 | 79 | # Emulate Pico-8 defaults: 80 | result.set_properties(0, note_duration=1) 81 | for i in range(1,64): 82 | result.set_properties(i, note_duration=16) 83 | 84 | return result 85 | 86 | @classmethod 87 | def from_lines(cls, lines, version): 88 | """Create an instance based on .p8 data lines. 89 | 90 | Args: 91 | lines: .p8 lines for the section. 92 | version: The Pico-8 data version from the game file header. 93 | """ 94 | result = cls.empty(version=version) 95 | id = 0 96 | 97 | for l in lines: 98 | if len(l) != 169: 99 | continue 100 | editor_mode = int(l[0:2], 16) 101 | note_duration = int(l[2:4], 16) 102 | loop_start = int(l[4:6], 16) 103 | loop_end = int(l[6:8], 16) 104 | result.set_properties(id, 105 | editor_mode=editor_mode, 106 | note_duration=note_duration, 107 | loop_start=loop_start, 108 | loop_end=loop_end) 109 | note = 0 110 | for i in range(8,168,5): 111 | pitch = int(l[i:i+2], 16) 112 | waveform = int(l[i+2], 16) 113 | volume = int(l[i+3], 16) 114 | effect = int(l[i+4], 16) 115 | result.set_note(id, note, 116 | pitch=pitch, 117 | waveform=waveform, 118 | volume=volume, 119 | effect=effect) 120 | note += 1 121 | id += 1 122 | 123 | return result 124 | 125 | def to_lines(self): 126 | """Generates lines of ASCII-encoded hexadecimal strings. 127 | 128 | Yields: 129 | One line of a hex string. 130 | """ 131 | for id in range(0, 64): 132 | hexstrs = [bytes(self.get_properties(id)).hex()] 133 | for note in range(0, 32): 134 | pitch, waveform, volume, effect = self.get_note(id, note) 135 | hexstrs.append(bytes([pitch, waveform << 4 | volume]).hex()) 136 | hexstrs.append(bytes([effect]).hex()[1]) 137 | yield ''.join(hexstrs) + '\n' 138 | 139 | def get_note(self, id, note): 140 | """Gets a note from a pattern. 141 | 142 | pitch is a value (0-63), representing the notes on a chromatic scale 143 | from c-0 to d#-5. 144 | 145 | waveform is one fo the WAVEFORM_* constants (0-7). 146 | 147 | volume is 0-7: 0 is off, 7 is loudest. 148 | 149 | effect is one of the EFFECT_* constants (0-7). 150 | 151 | Args: 152 | id: The pattern ID. (0-63) 153 | note: The note number. (0-31) 154 | 155 | Returns: 156 | A tuple: (pitch, waveform, volume, effect). 157 | """ 158 | lsb = self._data[id * 68 + note * 2] 159 | msb = self._data[id * 68 + note * 2 + 1] 160 | pitch = lsb & 0x3f 161 | waveform = ((msb & 0x01) << 2) | ((lsb & 0xc0) >> 6) 162 | volume = (msb & 0x0e) >> 1 163 | effect = (msb & 0x70) >> 4 164 | return (pitch, waveform, volume, effect) 165 | 166 | def set_note(self, id, note, pitch=None, waveform=None, volume=None, 167 | effect=None): 168 | """Sets a note in a pattern. 169 | 170 | (See get_note() for definitions.) 171 | 172 | Args: 173 | id: The pattern ID. (0-63) 174 | note: The note number. (0-31) 175 | pitch: The pitch value, or None to leave unchanged. (0-63) 176 | waveform: The waveform type, or None to leave unchanged. (0-7) 177 | volume: The volume level, or None to leave unchanged. (0-7) 178 | effect: The effect type, or None to leave unchanged. (0-7) 179 | """ 180 | lsb = self._data[id * 68 + note * 2] 181 | msb = self._data[id * 68 + note * 2 + 1] 182 | 183 | if pitch is not None: 184 | assert 0 <= pitch <= 63 185 | lsb = (lsb & 0xc0) | pitch 186 | if waveform is not None: 187 | assert 0 <= waveform <= 7 188 | lsb = (lsb & 0x3f) | ((waveform & 3) << 6) 189 | msb = (msb & 0xfe) | ((waveform & 4) >> 2) 190 | if volume is not None: 191 | assert 0 <= volume <= 7 192 | msb = (msb & 0xf1) | (volume << 1) 193 | if effect is not None: 194 | assert 0 <= effect <= 7 195 | msb = (msb & 0x8f) | (effect << 4) 196 | 197 | self._data[id * 68 + note * 2] = lsb 198 | self._data[id * 68 + note * 2 + 1] = msb 199 | 200 | def get_properties(self, id): 201 | """Gets properties for a pattern. 202 | 203 | editor_mode is 0 for pitch mode, 1 for note mode. 204 | 205 | note_duration is the duration of each note, in 1/128ths of a second. 206 | (0-255) 207 | 208 | loop_start is the loop range start, as a note number. (0-63) 209 | 210 | loop_end is the loop range end, as a note number. (0-63) 211 | 212 | Args: 213 | id: The pattern ID. (0-63) 214 | 215 | Returns: 216 | A tuple: (editor_mode, note_duration, loop_start, loop_end). 217 | """ 218 | return (self._data[id * 68 + 64], 219 | self._data[id * 68 + 65], 220 | self._data[id * 68 + 66], 221 | self._data[id * 68 + 67]) 222 | 223 | def set_properties(self, id, editor_mode=None, note_duration=None, 224 | loop_start=None, loop_end=None): 225 | """Sets properteis for a pattern. 226 | 227 | Args: 228 | id: The pattern ID. (0-63) 229 | editor_mode: 0 for pitch mode, 1 for note mode, None to leave 230 | unchanged. 231 | note_duration: The duration for each note in the pattern, in 1/128ths 232 | of a second. (0-255) None to leave unchanged. 233 | loop_start: The loop range start, as a note number (0-63). None to 234 | leave unchanged. 235 | loop_end: The loop range end, as a note number (0-63). None to 236 | leave unchanged. 237 | """ 238 | if editor_mode is not None: 239 | assert 0 <= editor_mode <= 1 240 | self._data[id * 68 + 64] = editor_mode 241 | if note_duration is not None: 242 | assert 0 <= note_duration <= 255 243 | self._data[id * 68 + 65] = note_duration 244 | if loop_start is not None: 245 | assert 0 <= loop_start <= 63 246 | self._data[id * 68 + 66] = loop_start 247 | if loop_end is not None: 248 | assert 0 <= loop_end <= 63 249 | self._data[id * 68 + 67] = loop_end 250 | -------------------------------------------------------------------------------- /pico8/tool.py: -------------------------------------------------------------------------------- 1 | """The main routines for the command-line tool.""" 2 | 3 | __all__ = ['main'] 4 | 5 | 6 | import argparse 7 | import csv 8 | import os 9 | import re 10 | import sys 11 | import tempfile 12 | import textwrap 13 | import traceback 14 | 15 | from . import util 16 | from .game import game 17 | from .lua import lexer 18 | from .lua import lua 19 | from .lua import parser 20 | 21 | 22 | def _get_argparser(): 23 | """Builds and returns the argument parser.""" 24 | parser = argparse.ArgumentParser( 25 | formatter_class=argparse.RawDescriptionHelpFormatter, 26 | usage='%(prog)s [--help] [] ' 27 | '[ ...]', 28 | description=textwrap.dedent(''' 29 | Commands: 30 | stats [--csv] [...] 31 | Display stats about one or more carts. 32 | listlua [...] 33 | List the Lua code for a cart to the console. 34 | writep8 [...] 35 | Convert a .p8.png cart to a .p8 cart. 36 | luamin [...] 37 | Minify the Lua code for a cart, reducing the character count. 38 | luafmt [--overwrite] [--indentwidth=2] [...] 39 | Make the Lua code for a cart easier to read by adjusting indentation. 40 | luafind [--listfiles] [...] 41 | Find a string or pattern in the code of one or more carts. 42 | 43 | listtokens [...] 44 | List the tokens for a cart to the console (for debugging picotool). 45 | printast [...] 46 | Print the picotool parser tree to the console (for debugging picotool). 47 | 48 | By default, commands that write to files (writep8, luamin, 49 | luafmt) will create or replace a file named similar to the 50 | cart filename but ending in "_fmt.p8". The luafmt command 51 | accepts an optional --overwrite argument that causes it to 52 | overwrite the original .p8 file instead. 53 | ''')) 54 | parser.add_argument( 55 | 'command', type=str, 56 | help='the command to execute') 57 | parser.add_argument( 58 | '--indentwidth', type=int, action='store', default=2, 59 | help='for luafmt, the indent width as a number of spaces') 60 | parser.add_argument( 61 | '--overwrite', action='store_true', 62 | help='for luafmt, given a filename, overwrites the original file ' 63 | 'instead of creating a separate *_fmt.p8 file') 64 | parser.add_argument( 65 | '--csv', action='store_true', 66 | help='for stats, output a CSV file instead of text') 67 | parser.add_argument( 68 | '--listfiles', action='store_true', 69 | help='for luafind, only list filenames, do not print matching lines') 70 | parser.add_argument( 71 | '-q', '--quiet', action='store_true', 72 | help='suppresses inessential messages') 73 | parser.add_argument( 74 | '--debug', action='store_true', 75 | help='write extra messages for debugging the tool') 76 | parser.add_argument( 77 | 'filename', type=str, nargs='+', 78 | help='the names of files to process') 79 | 80 | return parser 81 | 82 | 83 | def _games_for_filenames(filenames): 84 | """Yields games for the given filenames. 85 | 86 | If a file does not load or parse as a game, this writes a message 87 | to stderr and yields None. Processing of the argument list will 88 | continue if the caller continues. 89 | 90 | Args: 91 | filenames: The list of filenames. 92 | 93 | Yields: 94 | (filename, game), or (filename, None) if the file did not parse. 95 | """ 96 | for fname in filenames: 97 | if not fname.endswith('.p8.png') and not fname.endswith('.p8'): 98 | util.error('{}: filename must end in .p8 or .p8.png\n'.format( 99 | fname)) 100 | continue 101 | 102 | g = None 103 | try: 104 | g = game.Game.from_filename(fname) 105 | except lexer.LexerError as e: 106 | util.error('{}: {}\n'.format(fname, e)) 107 | util.debug(traceback.format_exc()) 108 | yield (fname, None) 109 | except parser.ParserError as e: 110 | util.error('{}: {}\n'.format(fname, e)) 111 | util.debug(traceback.format_exc()) 112 | yield (fname, None) 113 | else: 114 | yield (fname, g) 115 | 116 | 117 | def stats(args): 118 | """Run the stats tool. 119 | 120 | Args: 121 | args: The argparser parsed args object. 122 | 123 | Returns: 124 | 0 on success, 1 on failure. 125 | """ 126 | csv_writer = None 127 | if args.csv: 128 | csv_writer = csv.writer(sys.stdout) 129 | csv_writer.writerow([ 130 | 'Filename', 131 | 'Title', 132 | 'Byline', 133 | 'Code Version', 134 | 'Char Count', 135 | 'Token Count', 136 | 'Line Count', 137 | 'Compressed Code Size' 138 | ]) 139 | 140 | for fname, g in _games_for_filenames(args.filename): 141 | if g is None: 142 | util.error('{}: could not load cart\n'.format(fname)) 143 | continue 144 | 145 | if args.csv: 146 | csv_writer.writerow([ 147 | os.path.basename(fname), 148 | g.lua.get_title(), 149 | g.lua.get_byline(), 150 | g.lua.version, 151 | g.lua.get_char_count(), 152 | g.lua.get_token_count(), 153 | g.lua.get_line_count(), 154 | g.compressed_size 155 | ]) 156 | else: 157 | title = g.lua.get_title() 158 | byline = g.lua.get_byline() 159 | 160 | if title is not None: 161 | util.write('{} ({})\n'.format( 162 | title, os.path.basename(g.filename))) 163 | else: 164 | util.write(os.path.basename(g.filename) + '\n') 165 | if byline is not None: 166 | util.write(byline + '\n') 167 | util.write('- version: {}\n- lines: {}\n- chars: {}\n' 168 | '- tokens: {}\n- compressed chars: {}\n'.format( 169 | g.lua.version, g.lua.get_line_count(), 170 | g.lua.get_char_count(), g.lua.get_token_count(), 171 | g.compressed_size if g.compressed_size is not None 172 | else '(not compressed)')) 173 | util.write('\n') 174 | 175 | return 0 176 | 177 | 178 | def listlua(args): 179 | """Run the listlua tool. 180 | 181 | Args: 182 | args: The argparser parsed args object. 183 | 184 | Returns: 185 | 0 on success, 1 on failure. 186 | """ 187 | for fname, g in _games_for_filenames(args.filename): 188 | if len(args.filename) > 1: 189 | util.write('=== {} ===\n'.format(g.filename)) 190 | for l in g.lua.to_lines(): 191 | try: 192 | util.write(l) 193 | except UnicodeEncodeError as e: 194 | new_l = ''.join(c if ord(c) < 128 else '_' for c in l) 195 | util.write(new_l) 196 | util.write('\n') 197 | 198 | return 0 199 | 200 | 201 | def listtokens(args): 202 | """Run the listlua tool. 203 | 204 | Args: 205 | args: The argparser parsed args object. 206 | 207 | Returns: 208 | 0 on success, 1 on failure. 209 | """ 210 | for fname, g in _games_for_filenames(args.filename): 211 | if len(args.filename) > 1: 212 | util.write('=== {} ===\n'.format(g.filename)) 213 | pos = 0 214 | for t in g.lua.tokens: 215 | if isinstance(t, lexer.TokNewline): 216 | util.write('\n') 217 | elif (isinstance(t, lexer.TokSpace) or 218 | isinstance(t, lexer.TokComment)): 219 | util.write('<{}>'.format(t.value)) 220 | else: 221 | util.write('<{}:{}>'.format(pos, t.value)) 222 | pos += 1 223 | util.write('\n') 224 | return 0 225 | 226 | 227 | def process_game_files(filenames, procfunc, overwrite=False, args=None): 228 | """Processes cart files in a common way. 229 | 230 | Args: 231 | filenames: The cart filenames as input. 232 | procfunc: A function called for each cart. This is called with arguments: 233 | a Game, an output stream, an output filename, and an argparse args 234 | object. 235 | overwrite: If True, overwrites the input file instead of making a _fmt.p8 236 | file, if the input file is a .p8 file. 237 | args: The argparse parsed args. 238 | 239 | Returns: 240 | 0 on success, 1 on failure. 241 | """ 242 | has_errors = False 243 | for fname, g in _games_for_filenames(filenames): 244 | if g is None: 245 | has_errors = True 246 | continue 247 | 248 | if overwrite and fname.endswith('.p8'): 249 | out_fname = fname 250 | else: 251 | if fname.endswith('.p8.png'): 252 | out_fname = fname[:-len('.p8.png')] + '_fmt.p8' 253 | else: 254 | out_fname = fname[:-len('.p8')] + '_fmt.p8' 255 | 256 | util.write('{} -> {}\n'.format(fname, out_fname)) 257 | with tempfile.TemporaryFile(mode='w+', encoding='utf-8') as outfh: 258 | procfunc(g, outfh, out_fname, args=args) 259 | 260 | outfh.seek(0) 261 | with open(out_fname, 'w', encoding='utf-8') as finalfh: 262 | finalfh.write(outfh.read()) 263 | 264 | if has_errors: 265 | return 1 266 | return 0 267 | 268 | 269 | def writep8(g, outfh, out_fname, args=None): 270 | """Writes the game to a .p8 file. 271 | 272 | If the original was a .p8.png file, this converts it to a .p8 file. 273 | 274 | If the original was a .p8 file, this just echos the game data into a new 275 | file. (This is mostly useful to validate the picotool library.) 276 | 277 | Args: 278 | g: The Game. 279 | outfh: The output filehandle. 280 | out_fname: The output filename, for error messages. 281 | args: The argparse parsed args object, or None. 282 | """ 283 | g.to_p8_file(outfh, filename=out_fname) 284 | 285 | 286 | def luamin(g, outfh, out_fname, args=None): 287 | """Reduces the Lua code for a cart to use a minimal number of characters. 288 | 289 | Args: 290 | g: The Game. 291 | outfh: The output filehandle. 292 | out_fname: The output filename, for error messages. 293 | args: The argparse parsed args object, or None. 294 | """ 295 | g.to_p8_file(outfh, filename=out_fname, 296 | lua_writer_cls=lua.LuaMinifyTokenWriter) 297 | 298 | 299 | def luafmt(g, outfh, out_fname, args=None): 300 | """Rewrite the Lua code for a cart to use regular formatting. 301 | 302 | Args: 303 | g: The Game. 304 | outfh: The output filehandle. 305 | out_fname: The output filename, for error messages. 306 | args: The argparse parsed args object, or None. 307 | """ 308 | g.to_p8_file(outfh, filename=out_fname, 309 | lua_writer_cls=lua.LuaFormatterWriter, 310 | lua_writer_args={'indentwidth': args.indentwidth}) 311 | 312 | 313 | _PRINTAST_INDENT_SIZE = 2 314 | def _printast_node(value, indent=0, prefix=''): 315 | """Recursive procedure for printast. 316 | 317 | Args: 318 | value: An element from the AST: a Node, a list, or a tuple. 319 | indent: The indentation level for this value. 320 | prefix: A string prefix for this value. 321 | """ 322 | if isinstance(value, parser.Node): 323 | util.write('{}{}{}\n'.format(' ' * indent, prefix, 324 | value.__class__.__name__)) 325 | for field in value._fields: 326 | _printast_node(getattr(value, field), 327 | indent=indent+_PRINTAST_INDENT_SIZE, 328 | prefix='* {}: '.format(field)) 329 | elif isinstance(value, list) or isinstance(value, tuple): 330 | util.write('{}{}[list:]\n'.format(' ' * indent, prefix)) 331 | for item in value: 332 | _printast_node(item, 333 | indent=indent+_PRINTAST_INDENT_SIZE, 334 | prefix='- ') 335 | else: 336 | util.write('{}{}{}\n'.format(' ' * indent, prefix, value)) 337 | 338 | 339 | def printast(args): 340 | """Prints the parser's internal representation of Lua code. 341 | 342 | Args: 343 | args: The argparser parsed args object. 344 | 345 | Returns: 346 | 0 on success, 1 on failure. 347 | """ 348 | for fname, g in _games_for_filenames(args.filename): 349 | if len(args.filename) > 1: 350 | util.write('=== {} ===\n'.format(g.filename)) 351 | _printast_node(g.lua.root) 352 | return 0 353 | 354 | 355 | def luafind(args): 356 | """Looks for Lua code lines that match a pattern in one or more carts. 357 | 358 | Args: 359 | args: The argparser parsed args object. 360 | 361 | Returns: 362 | 0 on success, 1 on failure. 363 | """ 364 | # (The first argument is the pattern, but it's stored in args.filename.) 365 | filenames = list(args.filename) 366 | if len(filenames) < 2: 367 | util.error( 368 | 'Usage: p8tool luafind [...]\n') 369 | return 1 370 | pattern = re.compile(filenames.pop(0)) 371 | 372 | # TODO: Tell the Lua class not to bother parsing, since we only need the 373 | # token stream to get the lines of code. 374 | for fname, g in _games_for_filenames(filenames): 375 | line_count = 0 376 | for l in g.lua.to_lines(): 377 | line_count += 1 378 | if pattern.search(l) is None: 379 | continue 380 | if args.listfiles: 381 | util.write(fname + '\n') 382 | break 383 | try: 384 | util.write('{}:{}:{}'.format(fname, line_count, l)) 385 | except UnicodeEncodeError as e: 386 | new_l = ''.join(c if ord(c) < 128 else '_' for c in l) 387 | util.write('{}:{}:{}'.format(fname, line_count, new_l)) 388 | 389 | return 0 390 | 391 | 392 | def main(orig_args): 393 | try: 394 | arg_parser = _get_argparser() 395 | args = arg_parser.parse_args(args=orig_args) 396 | if args.debug: 397 | util.set_verbosity(util.VERBOSITY_DEBUG) 398 | elif args.quiet: 399 | util.set_verbosity(util.VERBOSITY_QUIET) 400 | 401 | if args.command == 'stats': 402 | return stats(args) 403 | elif args.command == 'listlua': 404 | return listlua(args) 405 | elif args.command == 'listtokens': 406 | return listtokens(args) 407 | elif args.command == 'printast': 408 | return printast(args) 409 | elif args.command == 'writep8': 410 | return process_game_files(args.filename, writep8, args=args) 411 | elif args.command == 'luamin': 412 | return process_game_files(args.filename, luamin, args=args) 413 | elif args.command == 'luafmt': 414 | return process_game_files(args.filename, luafmt, 415 | overwrite=args.overwrite, args=args) 416 | elif args.command == 'luafind': 417 | return luafind(args) 418 | 419 | arg_parser.print_help() 420 | return 1 421 | 422 | except KeyboardInterrupt: 423 | util.error('\nInterrupted with Control-C, stopping.\n') 424 | return 1 425 | -------------------------------------------------------------------------------- /pico8/util.py: -------------------------------------------------------------------------------- 1 | """Utility classes and functions for the picotool tools and libraries.""" 2 | 3 | import sys 4 | 5 | __all__ = [ 6 | 'Error', 7 | 'InvalidP8DataError', 8 | 'set_quiet', 9 | 'write', 10 | 'error' 11 | ] 12 | 13 | 14 | VERBOSITY_QUIET = 1 # error 15 | VERBOSITY_NORMAL = 2 # write and error 16 | VERBOSITY_DEBUG = 3 # debug, write and error 17 | _verbosity = VERBOSITY_NORMAL 18 | 19 | _write_stream = sys.stdout 20 | _error_stream = sys.stderr 21 | 22 | 23 | class Error(Exception): 24 | """A base class for all errors in the picotool libraries.""" 25 | pass 26 | 27 | 28 | class InvalidP8DataError(Error): 29 | """A base class for all invalid game file errors.""" 30 | pass 31 | 32 | 33 | def set_verbosity(level=VERBOSITY_NORMAL): 34 | global _verbosity 35 | _verbosity = level 36 | 37 | 38 | def debug(msg): 39 | """Writes a debug message. 40 | 41 | This does nothing unless the user specifies the --debug argument. 42 | 43 | When working with named files, this function writes to 44 | stdout. When working with stdin, file output goes to stdout and 45 | messages go to stderr. 46 | 47 | Args: 48 | msg: The message to write. 49 | """ 50 | if _verbosity >= VERBOSITY_DEBUG: 51 | _write_stream.write(msg) 52 | 53 | 54 | def write(msg): 55 | """Writes a message to the user. 56 | 57 | Messages written with this function can be suppressed by the user 58 | with the --quiet argument. 59 | 60 | When working with named files, this function writes to 61 | stdout. When working with stdin, file output goes to stdout and 62 | messages go to stderr. 63 | 64 | Args: 65 | msg: The message to write. 66 | """ 67 | if _verbosity >= VERBOSITY_NORMAL: 68 | _write_stream.write(msg) 69 | 70 | 71 | def error(msg): 72 | """Writes an error message to the user. 73 | 74 | All error messages are written to stderr. 75 | 76 | Args: 77 | msg: The error message to write. 78 | """ 79 | _error_stream.write(msg) 80 | 81 | 82 | class BaseSection(): 83 | """A base class for Pico-8 section objects.""" 84 | 85 | def __init__(self, data, version): 86 | """Initializer. 87 | 88 | If loading from a file, prefer from_lines() or from_bytes(). 89 | 90 | Args: 91 | version: The Pico-8 data version from the game file header. 92 | data: The data region, as a sequence of bytes. 93 | """ 94 | self._version = version 95 | self._data = bytearray(data) 96 | 97 | @classmethod 98 | def from_lines(cls, lines, version): 99 | """Create an instance based on .p8 data lines. 100 | 101 | The base implementation reads lines of ASCII-encoded hexadecimal bytes. 102 | 103 | Args: 104 | lines: .p8 lines for the section. 105 | version: The Pico-8 data version from the game file header. 106 | """ 107 | data = b''.join(bytearray.fromhex(l.rstrip()) for l in lines) 108 | return cls(data=data, version=version) 109 | 110 | HEX_LINE_LENGTH_BYTES = 64 111 | def to_lines(self): 112 | """Generates lines of ASCII-encoded hexadecimal strings. 113 | 114 | Yields: 115 | One line of a hex string. 116 | """ 117 | for start_i in range(0, len(self._data), self.HEX_LINE_LENGTH_BYTES): 118 | end_i = start_i + self.HEX_LINE_LENGTH_BYTES 119 | if end_i > len(self._data): 120 | end_i = len(self._data) 121 | yield bytes(self._data[start_i:end_i]).hex() + '\n' 122 | 123 | @classmethod 124 | def from_bytes(cls, data, version): 125 | """ 126 | Args: 127 | data: Binary data for the section, as a sequence of bytes. 128 | version: The Pico-8 data version from the game file header. 129 | """ 130 | return cls(data=data, version=version) 131 | -------------------------------------------------------------------------------- /translator/__init__.py: -------------------------------------------------------------------------------- 1 | MIDI_DEFAULT_BPM = 120 2 | MIDI_MAX_CHANNELS = 16 3 | 4 | MIDI_TO_PICO8_PITCH_SUBTRAHEND = 36 5 | 6 | PICO8_MAX_PITCH = 63 7 | PICO8_MIN_NOTE_DURATION = 1 8 | PICO8_NOTES_PER_SFX = 32 9 | 10 | PICO8_NUM_SFX = 64 11 | -------------------------------------------------------------------------------- /translator/note.py: -------------------------------------------------------------------------------- 1 | import math 2 | from . import MIDI_TO_PICO8_PITCH_SUBTRAHEND 3 | 4 | class Note: 5 | def __init__(self, event=None): 6 | # MIDI properties 7 | self.midiDuration = 0 8 | self.midiPitch = None 9 | self.midiChannel = None 10 | self.midiVelocity = None 11 | 12 | # PICO-8 tracker properties 13 | self.pitch = None 14 | self.volume = 0 15 | self.waveform = None 16 | self.effect = None 17 | self.length = 0 18 | 19 | if event != None: 20 | self.midiPitch = event.pitch 21 | self.midiChannel = event.channel 22 | self.midiVelocity = event.velocity 23 | self.pitch = event.pitch - MIDI_TO_PICO8_PITCH_SUBTRAHEND 24 | self.volume = math.floor((event.velocity / 127) * 7) 25 | 26 | -------------------------------------------------------------------------------- /translator/sfx.py: -------------------------------------------------------------------------------- 1 | class Sfx: 2 | def __init__(self, notes): 3 | self.notes = notes 4 | self.noteDuration = None 5 | -------------------------------------------------------------------------------- /translator/sfxcompactor.py: -------------------------------------------------------------------------------- 1 | from .note import Note 2 | from .sfx import Sfx 3 | 4 | from . import PICO8_NUM_SFX 5 | 6 | # SfxCompactor does the following: 7 | # Look for spots across all tracks at the same time with N consecutive SFXes 8 | # that can be combined into one with each note-run's (where a "note-run" is the 9 | # same note repeated several times) length reduced (i.e. divided by N) and the 10 | # note duration increased (i.e. multiplied by N) to compensate 11 | class SfxCompactor: 12 | # "tracks" is a list of SFX lists 13 | def __init__(self, tracks): 14 | self.tracks = tracks 15 | 16 | def get_longest_track_sfx_count(self): 17 | longestTrackSfxCount = 0 18 | for track in self.tracks: 19 | length = len(track) 20 | if length > longestTrackSfxCount: 21 | longestTrackSfxCount = length 22 | 23 | # TODO don't check past what will fit in PICO-8; this needs to take start_offset into account 24 | return longestTrackSfxCount 25 | 26 | def run(self): 27 | while True: 28 | anyCompressionOccurred = False 29 | 30 | # TODO: figure out optimal highest N to try first 31 | n = 32 32 | while n > 1: 33 | anyCompressionOccurred = self.optimize_sfx_speeds(n) 34 | n -= 1 35 | 36 | if not anyCompressionOccurred: 37 | break 38 | 39 | return self.tracks 40 | 41 | def get_track_section(self, trackIndex, sfxIndexStart, n): 42 | trackSection = TrackSection(trackIndex) 43 | track = self.tracks[trackIndex] 44 | trackSection.sfxList = track[sfxIndexStart:sfxIndexStart + n] 45 | 46 | for s, sfx in enumerate(trackSection.sfxList): 47 | noteRuns = self.find_note_runs(sfx.notes) 48 | trackSection.sfxNoteRunLists.append(noteRuns) 49 | 50 | if len(trackSection.sfxList) > 0: 51 | return trackSection 52 | 53 | def get_track_sections(self, sfxIndexStart, n): 54 | trackSections = [] 55 | for t, track in enumerate(self.tracks): 56 | trackSection = self.get_track_section(t, sfxIndexStart, n) 57 | if trackSection != None: 58 | trackSections.append(trackSection) 59 | 60 | return trackSections 61 | 62 | def optimize_sfx_speeds(self, n): 63 | anyCompressionOccurred = False 64 | savedSfxCount = 0 65 | sfxIndexStart = 0 66 | while sfxIndexStart < self.get_longest_track_sfx_count(): 67 | trackSections = self.get_track_sections(sfxIndexStart, n) 68 | 69 | # Check if all note runs have lengths divisible by N 70 | allRunsDivideEvenly = True 71 | for trackSection in trackSections: 72 | for runList in trackSection.sfxNoteRunLists: 73 | for run in runList: 74 | if len(run) % n != 0: 75 | allRunsDivideEvenly = False 76 | 77 | if allRunsDivideEvenly: 78 | anyCompressionOccurred = True 79 | savedSfxCount += (n - 1) 80 | 81 | for t, trackSection in enumerate(trackSections): 82 | # Remove notes from each run and collect all the notes into 83 | # a contiguous list 84 | allNotes = [] 85 | for runList in trackSection.sfxNoteRunLists: 86 | for r, run in enumerate(runList): 87 | newLength = int(len(run) / n) 88 | allNotes.extend(run[-newLength:]) 89 | 90 | # Replace the first SFX's notes with the concatenation of 91 | # all the now-shortened notes in the group of SFX 92 | trackSection.sfxList[0].notes = allNotes 93 | trackSection.sfxList[0].noteDuration *= n 94 | 95 | # Delete the now-empty SFXes in this track 96 | del self.tracks[trackSection.trackIndex][ 97 | sfxIndexStart + 1 : sfxIndexStart + n] 98 | sfxIndexStart += 1 99 | 100 | if savedSfxCount > 0: 101 | print('saved {0} SFX slots (note group length: {1})'.format(savedSfxCount, n)) 102 | 103 | return anyCompressionOccurred 104 | 105 | # Find all the note runs (where a "run" is a list of consecutive PICO-8 106 | # notes that are all representing the same MIDI note) in a given list of 107 | # notes 108 | def find_note_runs(self, notes): 109 | runs = [] 110 | 111 | run = [] 112 | for n, note in enumerate(notes): 113 | currentRunShouldEnd = False 114 | 115 | noteBelongsToCurrentRun = False 116 | if len(run) > 0: 117 | prevNote = run[-1] 118 | else: 119 | prevNote = None 120 | 121 | if prevNote != None: 122 | if (note.pitch == prevNote.pitch and 123 | note.volume == prevNote.volume and 124 | note.waveform == prevNote.waveform): 125 | noteBelongsToCurrentRun = True 126 | elif note.volume == 0 and prevNote.volume == 0: 127 | noteBelongsToCurrentRun = True 128 | 129 | # If this note should be added to the current run 130 | if noteBelongsToCurrentRun or len(run) == 0: 131 | run.append(note) 132 | 133 | # If this note has an effect, it must be the last in the run 134 | if note.effect != None: 135 | currentRunShouldEnd = True 136 | else: 137 | currentRunShouldEnd = True 138 | 139 | if n == len(notes) - 1: 140 | currentRunShouldEnd = True 141 | 142 | if currentRunShouldEnd: 143 | # End the current run 144 | runs.append(run) 145 | 146 | # Add the current note to a new run 147 | run = [note] 148 | 149 | return runs 150 | 151 | 152 | class TrackSection: 153 | def __init__(self, trackIndex): 154 | self.trackIndex = trackIndex 155 | self.sfxList = [] 156 | self.sfxNoteRunLists = [] 157 | -------------------------------------------------------------------------------- /translator/translator.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import math 3 | import statistics 4 | 5 | from .note import Note 6 | from .sfx import Sfx 7 | from .sfxcompactor import SfxCompactor 8 | 9 | from . import PICO8_MIN_NOTE_DURATION 10 | from . import PICO8_MAX_PITCH 11 | from . import PICO8_NOTES_PER_SFX 12 | 13 | # According to https://eev.ee/blog/2016/05/30/extracting-music-from-the-pico-8/ 14 | PICO8_MS_PER_TICK = (183 / 22050) * 1000 15 | 16 | class TranslatorSettings: 17 | def __init__(self): 18 | self.quantization = True 19 | self.ticksPerNoteOverride = None 20 | self.staccato = False 21 | self.legato = False 22 | self.fixOctaves = True 23 | self.noteDurationOverride = None 24 | self.sfxCompactor = True 25 | 26 | class Translator: 27 | def __init__(self, midiFile, settings: TranslatorSettings=None): 28 | self.midiFile = midiFile 29 | 30 | if settings != None: 31 | self.settings = settings 32 | else: 33 | self.settings = TranslatorSettings() 34 | 35 | if self.settings.ticksPerNoteOverride != None: 36 | print('setting ticks per note to override setting of ' + 37 | str(self.settings.ticksPerNoteOverride)) 38 | self.baseTicks = self.settings.ticksPerNoteOverride 39 | 40 | def find_notes(self, track, channel): 41 | notes = [] 42 | activeNote = None 43 | deltaTime = 0 44 | firstNoteHasBeenAdded = False 45 | 46 | events = [ 47 | event for event in track.events 48 | if event.channel == channel or event.type == 'DeltaTime'] 49 | 50 | for e, event in enumerate(events): 51 | if event.type == 'DeltaTime': 52 | deltaTime += event.time 53 | if event.type == 'NOTE_ON' or event.type == 'NOTE_OFF': 54 | # Skip all drums for now 55 | if event.channel == 10: 56 | continue 57 | 58 | if activeNote != None: 59 | activeNote.midiDuration = deltaTime 60 | activeNote = None 61 | elif deltaTime > 0: 62 | note = Note() 63 | note.midiDuration = deltaTime 64 | notes.append(note) 65 | 66 | if event.type == 'NOTE_ON' and event.velocity > 0: 67 | note = Note(event) 68 | activeNote = note 69 | notes.append(note) 70 | 71 | deltaTime = 0 72 | 73 | return notes 74 | 75 | def analyze(self): 76 | print('MIDI format is type ' + str(self.midiFile.format)) 77 | 78 | # Get a list of unique note lengths and the number of occurrences of 79 | # each length 80 | uniqueLengths = {} 81 | noteCount = 0 82 | for track in self.midiFile.tracks: 83 | occupiedChannels = self.find_occupied_channels(track) 84 | for channel in occupiedChannels: 85 | notes = self.find_notes(track, channel) 86 | 87 | for note in notes: 88 | noteCount += 1 89 | length = note.midiDuration 90 | if length > 0: 91 | if not length in uniqueLengths: 92 | uniqueLengths[length] = 0 93 | uniqueLengths[length] += 1 94 | 95 | # DEBUG 96 | import operator 97 | #print('unique lengths:') 98 | #sortedUniqueLengths = sorted( 99 | # uniqueLengths.items(), 100 | # key=operator.itemgetter(1), 101 | # reverse=True) 102 | #print(sortedUniqueLengths) 103 | 104 | mostFrequentLength = None 105 | highestCount = 0 106 | for length, count in uniqueLengths.items(): 107 | if count > highestCount: 108 | 109 | highestCount = count 110 | mostFrequentLength = length 111 | 112 | print('note count: ' + str(noteCount)) 113 | print('most frequent length: ' + str(mostFrequentLength)) 114 | 115 | # Find the average number of occurrences for a unique length 116 | averageOccurences = statistics.mean(uniqueLengths.values()) 117 | print('mean occurrences: ' + str(averageOccurences)) 118 | print('median occurrences: ' + 119 | str(statistics.median(uniqueLengths.values()))) 120 | 121 | # Remove lengths from uniqueLengths that have less than the average 122 | # number of occurrences 123 | newUniqueLengths = {} 124 | for length, occurrences in uniqueLengths.items(): 125 | if occurrences >= averageOccurences: 126 | newUniqueLengths[length] = occurrences 127 | uniqueLengths = newUniqueLengths 128 | 129 | # Take each length and divide the other lengths by it, counting how 130 | # many other lengths it divides evenly into 131 | candidateBaseLengths = {} 132 | for length, occurrences in uniqueLengths.items(): 133 | for otherLength in uniqueLengths.keys(): 134 | if otherLength % length == 0: 135 | if not length in candidateBaseLengths: 136 | candidateBaseLengths[length] = 0 137 | candidateBaseLengths[length] += 1 138 | 139 | # DEBUG 140 | print('candidate base lengths:') 141 | sortedCandidateBaseLengths = sorted( 142 | candidateBaseLengths.items(), 143 | key=operator.itemgetter(1), 144 | reverse=True) 145 | for length, score in sortedCandidateBaseLengths: 146 | print(length, score) 147 | 148 | 149 | # Find the best of the candidate base-lengths, where "best" is the one 150 | # with the most even divisions into other lengths. If there is a tie, 151 | # prefer the shortest candidate base-length. 152 | bestBaseLength = None 153 | highestNumberOfEvenDivisions = 0 154 | for length, evenDivisionCount in candidateBaseLengths.items(): 155 | if evenDivisionCount > highestNumberOfEvenDivisions: 156 | highestNumberOfEvenDivisions = evenDivisionCount 157 | bestBaseLength = length 158 | elif evenDivisionCount == highestNumberOfEvenDivisions: 159 | if length < bestBaseLength: 160 | bestBaseLength = length 161 | 162 | if self.baseTicks == None: 163 | print('setting MIDI base ticks per note to ' + str(bestBaseLength)) 164 | self.baseTicks = bestBaseLength 165 | 166 | self.noteDuration = self.find_note_duration() 167 | print('PICO-8 note duration: ' + str(self.noteDuration)) 168 | 169 | 170 | def quantize_length(self, ticks): 171 | return int(self.baseTicks * round(ticks / self.baseTicks)) 172 | 173 | # Find the first SET_TEMPO event and take that to be the tempo of the whole 174 | # song. Then, use math along with the BPM to convert that to the 175 | # equivalent PICO-8 note duration 176 | def find_note_duration(self): 177 | if self.settings.noteDurationOverride != None: 178 | return self.settings.noteDurationOverride 179 | 180 | ppq = self.midiFile.ticksPerQuarterNote 181 | ## Find the PPQ from the first TIME_SIGNATURE event 182 | #for track in self.midiFile.tracks: 183 | # for e, event in enumerate(track.events): 184 | # if event.type == 'TIME_SIGNATURE': 185 | # print(event) 186 | # ppq = event.data[2] 187 | # #break 188 | 189 | # Find the microseconds per MIDI quarter note from the first SET_TEMPO 190 | # event 191 | mpqn = None 192 | for track in self.midiFile.tracks: 193 | for e, event in enumerate(track.events): 194 | if event.type == 'SET_TEMPO': 195 | mpqn = int.from_bytes( 196 | event.data, 197 | byteorder='big', 198 | signed=False) 199 | break 200 | 201 | 202 | if mpqn != None: 203 | bpm = 60000000 / mpqn 204 | else: 205 | bpm = MIDI_DEFAULT_BPM 206 | 207 | midiMsPerTick = 60000 / (bpm * ppq) 208 | 209 | # DEBUG 210 | #print('ppq: ' + str(ppq)) 211 | #print('mpqn: ' + str(mpqn)) 212 | #print('bpm: ' + str(bpm)) 213 | #print('MIDI msPerTick: ' + str(midiMsPerTick)) 214 | #print('PICO-8 msPerTick: ' + str(PICO8_MS_PER_TICK)) 215 | 216 | d = round(self.baseTicks * (midiMsPerTick / PICO8_MS_PER_TICK)) 217 | if d < PICO8_MIN_NOTE_DURATION: 218 | d = PICO8_MIN_NOTE_DURATION 219 | return d 220 | 221 | def convert_ticks_to_notelength(self, deltaTime): 222 | if self.settings.quantization: 223 | originalDeltaTime = deltaTime 224 | deltaTime = self.quantize_length(deltaTime) 225 | 226 | if deltaTime != originalDeltaTime: 227 | print('quantized deltaTime {0} to {1}'.format( 228 | originalDeltaTime, deltaTime)) 229 | 230 | return int(deltaTime / self.baseTicks) 231 | 232 | def get_pico_notes(self, track, channel): 233 | picoTrack = [] 234 | 235 | notes = self.find_notes(track, channel) 236 | for n, note in enumerate(notes): 237 | note.length = self.convert_ticks_to_notelength(note.midiDuration) 238 | for i in range(note.length): 239 | # Create a copy of the note 240 | noteCopy = copy.copy(note) 241 | 242 | if not self.settings.legato: 243 | # If this is the last copy of this note 244 | if i == note.length - 1: 245 | # Find the next note 246 | if n < len(notes) - 1: 247 | nextNote = notes[n + 1] 248 | else: 249 | nextNote = None 250 | 251 | # If the next note is the same pitch 252 | if nextNote and nextNote.pitch == noteCopy.pitch: 253 | nextNoteIsSamePitch = True 254 | else: 255 | nextNoteIsSamePitch = False 256 | if nextNoteIsSamePitch or self.settings.staccato: 257 | # Give the note a fadeout effect 258 | noteCopy.effect = 5 259 | 260 | picoTrack.append(noteCopy) 261 | 262 | return picoTrack 263 | 264 | def find_occupied_channels(self, track): 265 | occupiedChannels = [] 266 | 267 | for event in track.events: 268 | if not event.channel in occupiedChannels: 269 | occupiedChannels.append(event.channel) 270 | 271 | return occupiedChannels 272 | 273 | @staticmethod 274 | def find_first_audible_note_index(picoNoteLists): 275 | for t, noteList in enumerate(picoNoteLists): 276 | for n, note in enumerate(noteList): 277 | if note.volume > 0: 278 | return n 279 | 280 | @staticmethod 281 | def trim_silence_from_beginning_of_pico_notes(picoNoteLists): 282 | firstNoteIndex = Translator.find_first_audible_note_index(picoNoteLists) 283 | if firstNoteIndex > 0: 284 | # Trim empty notes off the beginning of all tracks 285 | for i in range(0, len(picoNoteLists)): 286 | picoNoteLists[i] = picoNoteLists[i][firstNoteIndex:] 287 | print('trimmed {0} silent notes from the beginning'.format( 288 | firstNoteIndex)) 289 | 290 | @staticmethod 291 | def trim_empty_notes_from_end_of_sfx_list(sfxes): 292 | if len(sfxes) > 0: 293 | # Trim empty notes off the end of the last Sfx 294 | lastNoteIndex = None 295 | for i in range(len(sfxes[-1].notes) - 1, -1, -1): 296 | if sfxes[-1].notes[i].volume > 0: 297 | lastNoteIndex = i 298 | break 299 | if lastNoteIndex != None: 300 | sfxes[-1].notes = sfxes[-1].notes[:lastNoteIndex + 1] 301 | 302 | 303 | def split_into_sfxes(self, notes): 304 | sfxes = [] 305 | 306 | for i in range(0, len(notes), PICO8_NOTES_PER_SFX): 307 | sfx = Sfx(notes[i:i + PICO8_NOTES_PER_SFX]) 308 | sfx.noteDuration = self.find_note_duration() 309 | sfxes.append(sfx) 310 | 311 | return sfxes 312 | 313 | def get_sfx_lists(self): 314 | picoNoteLists = [] 315 | 316 | for t, midiTrack in enumerate(self.midiFile.tracks): 317 | # Find the channels in use in this track 318 | usedChannels = self.find_occupied_channels(midiTrack) 319 | 320 | # Separate each channel into a "track" 321 | for channel in usedChannels: 322 | picoNotes = self.get_pico_notes(midiTrack, channel) 323 | 324 | hasAudibleNotes = False 325 | for note in picoNotes: 326 | if note.volume > 0: 327 | hasAudibleNotes = True 328 | break 329 | 330 | # If this track has any notes 331 | if len(picoNotes) > 0 and hasAudibleNotes: 332 | picoNoteLists.append(picoNotes) 333 | 334 | if self.settings.fixOctaves: 335 | picoNoteLists = self.adjust_octaves(picoNoteLists) 336 | 337 | print('got a total of {0} translated tracks'.format(len(picoNoteLists))) 338 | 339 | # OPTIMIZATION TODO: Try to combine tracks if they have no overlapping 340 | # notes 341 | 342 | # Trim silence from the beginning of the song as a whole 343 | if self.settings.trimSilence: 344 | Translator.trim_silence_from_beginning_of_pico_notes(picoNoteLists) 345 | 346 | # Split each noteList into "SFX"es (i.e. 32-note chunks) 347 | sfxLists = [] 348 | for t, noteList in enumerate(picoNoteLists): 349 | sfxes = self.split_into_sfxes(noteList) 350 | Translator.trim_empty_notes_from_end_of_sfx_list(sfxes) 351 | sfxLists.append(sfxes) 352 | 353 | if self.settings.sfxCompactor: 354 | print('trying to save SFX slots by compacting repeated notes...') 355 | sfxCompactor = SfxCompactor(sfxLists) 356 | sfxLists = sfxCompactor.run() 357 | 358 | return sfxLists 359 | 360 | def adjust_octaves(self, tracks): 361 | for t, track in enumerate(tracks): 362 | raised = False 363 | lowered = False 364 | while True: 365 | trackGoesTooLow = False 366 | trackGoesTooHigh = False 367 | 368 | # Check if any notes are out of range 369 | for note in track: 370 | if note.volume > 0 and note.pitch != None: 371 | if note.pitch < 0: 372 | trackGoesTooLow = True 373 | elif note.pitch > PICO8_MAX_PITCH: 374 | trackGoesTooHigh = True 375 | 376 | if trackGoesTooLow and trackGoesTooHigh: 377 | print('track {0} goes out of range in both directions; ' + 378 | 'octave will not be adjusted'.format(t)) 379 | break 380 | elif trackGoesTooLow: 381 | print('pitching out-of-range track {0} up an octave'. 382 | format(t)) 383 | # Add an octave to every note in this track 384 | raised = True 385 | for note in track: 386 | if note.pitch != None: 387 | note.pitch += 12 388 | 389 | elif trackGoesTooHigh: 390 | print('pitching out-of-range track {0} down an octave'. 391 | format(t)) 392 | # Subtract an octave from every note in this track 393 | lowered = True 394 | for note in track: 395 | if note.pitch != None: 396 | note.pitch -= 12 397 | else: 398 | break 399 | 400 | # Prevent ping-ponging back and forth infinitely 401 | if raised and lowered: 402 | break 403 | 404 | return tracks 405 | 406 | 407 | 408 | --------------------------------------------------------------------------------