/* * opsu! - an open-source osu! client * Copyright (C) 2014, 2015 Jeffrey Han * * opsu! is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * opsu! is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with opsu!. If not, see . */ package itdelatrisu.opsu; import itdelatrisu.opsu.db.OsuDB; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileReader; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import org.newdawn.slick.Color; import org.newdawn.slick.util.Log; /** * Parser for OSU files. */ public class OsuParser { /** The string lookup database. */ private static HashMap stringdb = new HashMap(); /** The expected pattern for beatmap directories, used to find beatmap set IDs. */ private static final String DIR_MSID_PATTERN = "^\\d+ .*"; /** The current file being parsed. */ private static File currentFile; /** The current directory number while parsing. */ private static int currentDirectoryIndex = -1; /** The total number of directories to parse. */ private static int totalDirectories = -1; /** Parser statuses. */ public enum Status { NONE, PARSING, CACHE, INSERTING }; /** The current status. */ private static Status status = Status.NONE; // This class should not be instantiated. private OsuParser() {} /** * Invokes parser for each OSU file in a root directory and * adds the OsuFiles to a new OsuGroupList. * @param root the root directory (search has depth 1) */ public static void parseAllFiles(File root) { // create a new OsuGroupList OsuGroupList.create(); // parse all directories parseDirectories(root.listFiles()); } /** * Invokes parser for each directory in the given array and * adds the OsuFiles to the existing OsuGroupList. * @param dirs the array of directories to parse * @return the last OsuGroupNode parsed, or null if none */ public static OsuGroupNode parseDirectories(File[] dirs) { if (dirs == null) return null; // progress tracking status = Status.PARSING; currentDirectoryIndex = 0; totalDirectories = dirs.length; // get last modified map from database Map map = OsuDB.getLastModifiedMap(); // OsuFile lists List> allOsuFiles = new LinkedList>(); List cachedOsuFiles = new LinkedList(); // loaded from database List parsedOsuFiles = new LinkedList(); // loaded from parser // parse directories OsuGroupNode lastNode = null; for (File dir : dirs) { currentDirectoryIndex++; if (!dir.isDirectory()) continue; // find all OSU files File[] files = dir.listFiles(new FilenameFilter() { @Override public boolean accept(File dir, String name) { return name.toLowerCase().endsWith(".osu"); } }); if (files.length < 1) continue; // create a new group entry ArrayList osuFiles = new ArrayList(); for (File file : files) { currentFile = file; // check if beatmap is cached String path = String.format("%s/%s", dir.getName(), file.getName()); if (map.containsKey(path)) { // check last modified times long lastModified = map.get(path); if (lastModified == file.lastModified()) { // add to cached beatmap list OsuFile osu = new OsuFile(file); osuFiles.add(osu); cachedOsuFiles.add(osu); continue; } else OsuDB.delete(dir.getName(), file.getName()); } // Parse hit objects only when needed to save time/memory. // Change boolean to 'true' to parse them immediately. OsuFile osu = parseFile(file, dir, osuFiles, false); // add to parsed beatmap list if (osu != null) { osuFiles.add(osu); parsedOsuFiles.add(osu); } } // add group entry if non-empty if (!osuFiles.isEmpty()) { osuFiles.trimToSize(); allOsuFiles.add(osuFiles); } // stop parsing files (interrupted) if (Thread.interrupted()) break; } // load cached entries from database if (!cachedOsuFiles.isEmpty()) { status = Status.CACHE; // Load array fields only when needed to save time/memory. // Change flag to 'LOAD_ALL' to load them immediately. OsuDB.load(cachedOsuFiles, OsuDB.LOAD_NONARRAY); } // add group entries to OsuGroupList for (ArrayList osuFiles : allOsuFiles) { Collections.sort(osuFiles); lastNode = OsuGroupList.get().addSongGroup(osuFiles); } // clear string DB stringdb = new HashMap(); // add beatmap entries to database if (!parsedOsuFiles.isEmpty()) { status = Status.INSERTING; OsuDB.insert(parsedOsuFiles); } status = Status.NONE; currentFile = null; currentDirectoryIndex = -1; totalDirectories = -1; return lastNode; } /** * Parses an OSU file. * @param file the file to parse * @param dir the directory containing the beatmap * @param osuFiles the song group * @param parseObjects if true, hit objects will be fully parsed now * @return the new OsuFile object */ private static OsuFile parseFile(File file, File dir, ArrayList osuFiles, boolean parseObjects) { OsuFile osu = new OsuFile(file); osu.timingPoints = new ArrayList(); try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(file), "UTF-8"))) { String line = in.readLine(); String tokens[] = null; while (line != null) { line = line.trim(); if (!isValidLine(line)) { line = in.readLine(); continue; } switch (line) { case "[General]": while ((line = in.readLine()) != null) { line = line.trim(); if (!isValidLine(line)) continue; if (line.charAt(0) == '[') break; if ((tokens = tokenize(line)) == null) continue; try { switch (tokens[0]) { case "AudioFilename": File audioFileName = new File(dir, tokens[1]); if (!osuFiles.isEmpty()) { // if possible, reuse the same File object from another OsuFile in the group File groupAudioFileName = osuFiles.get(0).audioFilename; if (groupAudioFileName != null && tokens[1].equalsIgnoreCase(groupAudioFileName.getName())) audioFileName = groupAudioFileName; } if (!audioFileName.isFile()) { // try to find the file with a case-insensitive match boolean match = false; for (String s : dir.list()) { if (s.equalsIgnoreCase(tokens[1])) { audioFileName = new File(dir, s); match = true; break; } } if (!match) { Log.error(String.format("Audio file '%s' not found in directory '%s'.", tokens[1], dir.getName())); return null; } } osu.audioFilename = audioFileName; break; case "AudioLeadIn": osu.audioLeadIn = Integer.parseInt(tokens[1]); break; // case "AudioHash": // deprecated // osu.audioHash = tokens[1]; // break; case "PreviewTime": osu.previewTime = Integer.parseInt(tokens[1]); break; case "Countdown": osu.countdown = Byte.parseByte(tokens[1]); break; case "SampleSet": osu.sampleSet = getDBString(tokens[1]); break; case "StackLeniency": osu.stackLeniency = Float.parseFloat(tokens[1]); break; case "Mode": osu.mode = Byte.parseByte(tokens[1]); /* Non-Opsu! standard files not implemented (obviously). */ if (osu.mode != OsuFile.MODE_OSU) return null; break; case "LetterboxInBreaks": osu.letterboxInBreaks = (Integer.parseInt(tokens[1]) == 1); break; case "WidescreenStoryboard": osu.widescreenStoryboard = (Integer.parseInt(tokens[1]) == 1); break; case "EpilepsyWarning": osu.epilepsyWarning = (Integer.parseInt(tokens[1]) == 1); default: break; } } catch (Exception e) { Log.warn(String.format("Failed to read line '%s' for file '%s'.", line, file.getAbsolutePath()), e); } } break; case "[Editor]": while ((line = in.readLine()) != null) { line = line.trim(); if (!isValidLine(line)) continue; if (line.charAt(0) == '[') break; /* Not implemented. */ // if ((tokens = tokenize(line)) == null) // continue; // try { // switch (tokens[0]) { // case "Bookmarks": // String[] bookmarks = tokens[1].split(","); // osu.bookmarks = new int[bookmarks.length]; // for (int i = 0; i < bookmarks.length; i++) // osu.bookmarks[i] = Integer.parseInt(bookmarks[i]); // break; // case "DistanceSpacing": // osu.distanceSpacing = Float.parseFloat(tokens[1]); // break; // case "BeatDivisor": // osu.beatDivisor = Byte.parseByte(tokens[1]); // break; // case "GridSize": // osu.gridSize = Integer.parseInt(tokens[1]); // break; // case "TimelineZoom": // osu.timelineZoom = Integer.parseInt(tokens[1]); // break; // default: // break; // } // } catch (Exception e) { // Log.warn(String.format("Failed to read editor line '%s' for file '%s'.", // line, file.getAbsolutePath()), e); // } } break; case "[Metadata]": while ((line = in.readLine()) != null) { line = line.trim(); if (!isValidLine(line)) continue; if (line.charAt(0) == '[') break; if ((tokens = tokenize(line)) == null) continue; try { switch (tokens[0]) { case "Title": osu.title = getDBString(tokens[1]); break; case "TitleUnicode": osu.titleUnicode = getDBString(tokens[1]); break; case "Artist": osu.artist = getDBString(tokens[1]); break; case "ArtistUnicode": osu.artistUnicode = getDBString(tokens[1]); break; case "Creator": osu.creator = getDBString(tokens[1]); break; case "Version": osu.version = getDBString(tokens[1]); break; case "Source": osu.source = getDBString(tokens[1]); break; case "Tags": osu.tags = getDBString(tokens[1].toLowerCase()); break; case "BeatmapID": osu.beatmapID = Integer.parseInt(tokens[1]); break; case "BeatmapSetID": osu.beatmapSetID = Integer.parseInt(tokens[1]); break; } } catch (Exception e) { Log.warn(String.format("Failed to read metadata '%s' for file '%s'.", line, file.getAbsolutePath()), e); } if (osu.beatmapSetID <= 0) { // try to determine MSID from directory name if (dir != null && dir.isDirectory()) { String dirName = dir.getName(); if (!dirName.isEmpty() && dirName.matches(DIR_MSID_PATTERN)) osu.beatmapSetID = Integer.parseInt(dirName.substring(0, dirName.indexOf(' '))); } } } break; case "[Difficulty]": while ((line = in.readLine()) != null) { line = line.trim(); if (!isValidLine(line)) continue; if (line.charAt(0) == '[') break; if ((tokens = tokenize(line)) == null) continue; try { switch (tokens[0]) { case "HPDrainRate": osu.HPDrainRate = Float.parseFloat(tokens[1]); break; case "CircleSize": osu.circleSize = Float.parseFloat(tokens[1]); break; case "OverallDifficulty": osu.overallDifficulty = Float.parseFloat(tokens[1]); break; case "ApproachRate": osu.approachRate = Float.parseFloat(tokens[1]); break; case "SliderMultiplier": osu.sliderMultiplier = Float.parseFloat(tokens[1]); break; case "SliderTickRate": osu.sliderTickRate = Float.parseFloat(tokens[1]); break; } } catch (Exception e) { Log.warn(String.format("Failed to read difficulty '%s' for file '%s'.", line, file.getAbsolutePath()), e); } } if (osu.approachRate == -1f) // not in old format osu.approachRate = osu.overallDifficulty; break; case "[Events]": while ((line = in.readLine()) != null) { line = line.trim(); if (!isValidLine(line)) continue; if (line.charAt(0) == '[') break; tokens = line.split(","); switch (tokens[0]) { case "0": // background tokens[2] = tokens[2].replaceAll("^\"|\"$", ""); String ext = OsuParser.getExtension(tokens[2]); if (ext.equals("jpg") || ext.equals("png")) osu.bg = getDBString(tokens[2]); break; case "2": // break periods try { if (osu.breaks == null) // optional, create if needed osu.breaks = new ArrayList(); osu.breaks.add(Integer.parseInt(tokens[1])); osu.breaks.add(Integer.parseInt(tokens[2])); } catch (Exception e) { Log.warn(String.format("Failed to read break period '%s' for file '%s'.", line, file.getAbsolutePath()), e); } break; default: /* Not implemented. */ break; } } if (osu.breaks != null) osu.breaks.trimToSize(); break; case "[TimingPoints]": while ((line = in.readLine()) != null) { line = line.trim(); if (!isValidLine(line)) continue; if (line.charAt(0) == '[') break; try { // parse timing point TimingPoint timingPoint = new TimingPoint(line); // calculate BPM if (!timingPoint.isInherited()) { int bpm = Math.round(60000 / timingPoint.getBeatLength()); if (osu.bpmMin == 0) osu.bpmMin = osu.bpmMax = bpm; else if (bpm < osu.bpmMin) osu.bpmMin = bpm; else if (bpm > osu.bpmMax) osu.bpmMax = bpm; } osu.timingPoints.add(timingPoint); } catch (Exception e) { Log.warn(String.format("Failed to read timing point '%s' for file '%s'.", line, file.getAbsolutePath()), e); } } osu.timingPoints.trimToSize(); break; case "[Colours]": LinkedList colors = new LinkedList(); while ((line = in.readLine()) != null) { line = line.trim(); if (!isValidLine(line)) continue; if (line.charAt(0) == '[') break; if ((tokens = tokenize(line)) == null) continue; try { switch (tokens[0]) { case "Combo1": case "Combo2": case "Combo3": case "Combo4": case "Combo5": case "Combo6": case "Combo7": case "Combo8": String[] rgb = tokens[1].split(","); colors.add(new Color( Integer.parseInt(rgb[0]), Integer.parseInt(rgb[1]), Integer.parseInt(rgb[2]) )); default: break; } } catch (Exception e) { Log.warn(String.format("Failed to read color '%s' for file '%s'.", line, file.getAbsolutePath()), e); } } if (!colors.isEmpty()) osu.combo = colors.toArray(new Color[colors.size()]); break; case "[HitObjects]": int type = 0; while ((line = in.readLine()) != null) { line = line.trim(); if (!isValidLine(line)) continue; if (line.charAt(0) == '[') break; /* Only type counts parsed at this time. */ tokens = line.split(","); try { type = Integer.parseInt(tokens[3]); if ((type & OsuHitObject.TYPE_CIRCLE) > 0) osu.hitObjectCircle++; else if ((type & OsuHitObject.TYPE_SLIDER) > 0) osu.hitObjectSlider++; else //if ((type & OsuHitObject.TYPE_SPINNER) > 0) osu.hitObjectSpinner++; } catch (Exception e) { Log.warn(String.format("Failed to read hit object '%s' for file '%s'.", line, file.getAbsolutePath()), e); } } try { // map length = last object end time (TODO: end on slider?) if ((type & OsuHitObject.TYPE_SPINNER) > 0) { // some 'endTime' fields contain a ':' character (?) int index = tokens[5].indexOf(':'); if (index != -1) tokens[5] = tokens[5].substring(0, index); osu.endTime = Integer.parseInt(tokens[5]); } else if (type != 0) osu.endTime = Integer.parseInt(tokens[2]); } catch (Exception e) { Log.warn(String.format("Failed to read hit object end time '%s' for file '%s'.", line, file.getAbsolutePath()), e); } break; default: line = in.readLine(); break; } } } catch (IOException e) { ErrorHandler.error(String.format("Failed to read file '%s'.", file.getAbsolutePath()), e, false); } // no associated audio file? if (osu.audioFilename == null) return null; // if no custom colors, use the default color scheme if (osu.combo == null) osu.combo = Utils.DEFAULT_COMBO; // parse hit objects now? if (parseObjects) parseHitObjects(osu); return osu; } /** * Parses all hit objects in an OSU file. * @param osu the OsuFile to parse */ public static void parseHitObjects(OsuFile osu) { if (osu.objects != null) // already parsed return; osu.objects = new OsuHitObject[(osu.hitObjectCircle + osu.hitObjectSlider + osu.hitObjectSpinner)]; try (BufferedReader in = new BufferedReader(new FileReader(osu.getFile()))) { String line = in.readLine(); while (line != null) { line = line.trim(); if (!line.equals("[HitObjects]")) line = in.readLine(); else break; } if (line == null) { Log.warn(String.format("No hit objects found in OsuFile '%s'.", osu.toString())); return; } // combo info int comboIndex = 0; // color index int comboNumber = 1; // combo number int objectIndex = 0; boolean first = true; while ((line = in.readLine()) != null && objectIndex < osu.objects.length) { line = line.trim(); if (!isValidLine(line)) continue; if (line.charAt(0) == '[') break; // lines must have at minimum 5 parameters int tokenCount = line.length() - line.replace(",", "").length(); if (tokenCount < 4) continue; try { // create a new OsuHitObject for each line OsuHitObject hitObject = new OsuHitObject(line); // set combo info // - new combo: get next combo index, reset combo number // - else: maintain combo index, increase combo number if (hitObject.isNewCombo() || first) { int skip = (hitObject.isSpinner() ? 0 : 1) + hitObject.getComboSkip(); for (int i = 0; i < skip; i++) { comboIndex = (comboIndex + 1) % osu.combo.length; comboNumber = 1; } first = false; } hitObject.setComboIndex(comboIndex); hitObject.setComboNumber(comboNumber++); osu.objects[objectIndex++] = hitObject; } catch (Exception e) { Log.warn(String.format("Failed to read hit object '%s' for OsuFile '%s'.", line, osu.toString()), e); } } } catch (IOException e) { ErrorHandler.error(String.format("Failed to read file '%s'.", osu.getFile().getAbsolutePath()), e, false); } } /** * Returns false if the line is too short or commented. */ private static boolean isValidLine(String line) { return (line.length() > 1 && !line.startsWith("//")); } /** * Splits line into two strings: tag, value. * If no ':' character is present, null will be returned. */ private static String[] tokenize(String line) { int index = line.indexOf(':'); if (index == -1) { Log.debug(String.format("Failed to tokenize line: '%s'.", line)); return null; } String[] tokens = new String[2]; tokens[0] = line.substring(0, index).trim(); tokens[1] = line.substring(index + 1).trim(); return tokens; } /** * Returns the file extension of a file. */ public static String getExtension(String file) { int i = file.lastIndexOf('.'); return (i != -1) ? file.substring(i + 1).toLowerCase() : ""; } /** * Returns the name of the current file being parsed, or null if none. */ public static String getCurrentFileName() { if (status == Status.PARSING) return (currentFile != null) ? currentFile.getName() : null; else return (status == Status.NONE) ? null : ""; } /** * Returns the progress of file parsing, or -1 if not parsing. * @return the completion percent [0, 100] or -1 */ public static int getParserProgress() { if (currentDirectoryIndex == -1 || totalDirectories == -1) return -1; return currentDirectoryIndex * 100 / totalDirectories; } /** * Returns the current parser status. */ public static Status getStatus() { return status; } /** * Returns the String object in the database for the given String. * If none, insert the String into the database and return the original String. * @param s the string to retrieve * @return the string object */ public static String getDBString(String s) { String DBString = stringdb.get(s); if (DBString == null) { stringdb.put(s, s); return s; } else return DBString; } }