diff --git a/.github/workflows/test_and_publish_release.yml b/.github/workflows/test_and_publish_release.yml index b9d9d54..dcaaef6 100644 --- a/.github/workflows/test_and_publish_release.yml +++ b/.github/workflows/test_and_publish_release.yml @@ -1,3 +1,4 @@ +#file: noinspection LongLine name: "Test and publish release" on: @@ -32,6 +33,9 @@ jobs: run: | pip install -e .[dev] + - name: Lint project + run: pflake8 + - name: Run tests (Python API) run: | pytest testing --entry-point python-api diff --git a/.gitignore b/.gitignore index 412e92f..ee8fd14 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ dist* *.exe *.egg-info .pytest_cache -__pycache__ \ No newline at end of file +__pycache__ diff --git a/LICENSE.txt b/LICENSE.txt index a5c3e50..ab5c206 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in index 7f66b9c..76e7432 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include src/tja2fumen/hp_values.csv \ No newline at end of file +include src/tja2fumen/hp_values.csv diff --git a/pyproject.toml b/pyproject.toml index c556058..ee91e3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,19 @@ keywords = ["taiko", "tatsujin", "fumen", "TJA"] tja2fumen = "tja2fumen:main" [project.optional-dependencies] -dev = ["pytest", "build", "pyinstaller", "twine", "toml-cli"] +dev = ["pytest", "build", "pyinstaller", "twine", "toml-cli", + "flake8", "pyproject-flake8"] [tool.setuptools.packages.find] where = ["src"] [tool.pytest.ini_options] addopts = "-vv --tb=short" -console_output_style = "count" \ No newline at end of file +console_output_style = "count" + +[tool.flake8] +exclude = "venv/" +per-file-ignores = """ + ./src/tja2fumen/types.py: E221 + ./testing/test_conversion.py: E221, E272 +""" diff --git a/src/tja2fumen/__init__.py b/src/tja2fumen/__init__.py index 8927676..4963821 100644 --- a/src/tja2fumen/__init__.py +++ b/src/tja2fumen/__init__.py @@ -26,9 +26,10 @@ def main(argv=None): # Parse lines in TJA file parsed_tja = parse_tja(fname_tja) - # Convert parsed TJA courses to Fumen data, and write each course to `.bin` files + # Convert parsed TJA courses and write each course to `.bin` files for course in parsed_tja.courses.items(): - convert_and_write(course, base_name, single_course=(len(parsed_tja.courses) == 1)) + convert_and_write(course, base_name, + single_course=(len(parsed_tja.courses) == 1)) def convert_and_write(parsed_course, base_name, single_course=False): @@ -37,12 +38,12 @@ def convert_and_write(parsed_course, base_name, single_course=False): # Add course ID (e.g. '_x', '_x_1', '_x_2') to the output file's base name output_name = base_name if single_course: - pass # Replicate tja2bin.exe behavior by excluding course ID if there's only one course + pass # Replicate tja2bin.exe behavior by excluding course ID else: - split_name = course_name.split("P") # e.g. 'OniP2' -> ['Oni', '2'], 'Oni' -> ['Oni'] + split_name = course_name.split("P") # e.g. 'OniP2' -> ['Oni', '2'] output_name += f"_{COURSE_IDS[split_name[0]]}" if len(split_name) == 2: - output_name += f"_{split_name[1]}" # Add "_1" or "_2" if P1/P2 chart + output_name += f"_{split_name[1]}" # Add "_1"/"_2" if P1/P2 chart write_fumen(f"{output_name}.bin", fumen_data) diff --git a/src/tja2fumen/converters.py b/src/tja2fumen/converters.py index a1e59a5..9ece238 100644 --- a/src/tja2fumen/converters.py +++ b/src/tja2fumen/converters.py @@ -5,23 +5,30 @@ from tja2fumen.types import TJAMeasureProcessed, FumenCourse, FumenNote def process_tja_commands(tja): """ - Merge TJA 'data' and 'event' fields into a single measure property, and split - measures into sub-measures whenever a mid-measure BPM/SCROLL/GOGO change occurs. + Merge TJA 'data' and 'event' fields into a single measure property, and + split measures into sub-measures whenever a mid-measure BPM/SCROLL/GOGO + change occurs. The TJA parser produces measure objects with two important properties: - - 'data': Contains the note data (1: don, 2: ka, etc.) along with spacing (s) - - 'events' Contains event commands such as MEASURE, BPMCHANGE, GOGOTIME, etc. + - 'data': Contains the note data (1: don, 2: ka, etc.) along with + spacing (s) + - 'events' Contains event commands such as MEASURE, BPMCHANGE, + GOGOTIME, etc. - However, notes and events can be intertwined within a single measure. So, it's - not possible to process them separately; they must be considered as single sequence. + However, notes and events can be intertwined within a single measure. So, + it's not possible to process them separately; they must be considered as + single sequence. - A particular danger is BPM changes. TJA allows multiple BPMs within a single measure, - but the fumen format permits one BPM per measure. So, a TJA measure must be split up - if it has multiple BPM changes within a measure. + A particular danger is BPM changes. TJA allows multiple BPMs within a + single measure, but the fumen format permits one BPM per measure. So, a + TJA measure must be split up if it has multiple BPM changes within a + measure. - In the future, this logic should probably be moved into the TJA parser itself. + In the future, this logic should probably be moved into the TJA parser + itself. """ - tja_branches_processed = {branch_name: [] for branch_name in tja.branches.keys()} + tja_branches_processed = {branch_name: [] + for branch_name in tja.branches.keys()} for branch_name, branch_measures_tja in tja.branches.items(): current_bpm = tja.BPM current_scroll = 1.0 @@ -33,10 +40,10 @@ def process_tja_commands(tja): # Split measure into submeasure measure_tja_processed = TJAMeasureProcessed( bpm=current_bpm, - scroll=current_scroll, - gogo=current_gogo, - barline=current_barline, - time_sig=[current_dividend, current_divisor], + scroll=current_scroll, + gogo=current_gogo, + barline=current_barline, + time_sig=[current_dividend, current_divisor], subdivisions=len(measure_tja.notes), ) for data in measure_tja.combined: @@ -44,7 +51,8 @@ def process_tja_commands(tja): if data.name == 'note': measure_tja_processed.data.append(data) - # Handle commands that can only be placed between measures (i.e. no mid-measure variations) + # Handle commands that can only be placed between measures + # (i.e. no mid-measure variations) elif data.name == 'delay': measure_tja_processed.delay = data.value * 1000 # ms -> s elif data.name == 'branch_start': @@ -60,11 +68,14 @@ def process_tja_commands(tja): continue current_dividend = int(match_measure.group(1)) current_divisor = int(match_measure.group(2)) - measure_tja_processed.time_sig = [current_dividend, current_divisor] + measure_tja_processed.time_sig = [current_dividend, + current_divisor] - # Handle commands that can be placed in the middle of a measure. - # NB: For fumen files, if there is a mid-measure change to BPM/SCROLL/GOGO, then the measure will - # actually be split into two small submeasures. So, we need to start a new measure in those cases. + # Handle commands that can be placed in the middle of a + # measure. (For fumen files, if there is a mid-measure change + # to BPM/SCROLL/GOGO, then the measure will actually be split + # into two small submeasures. So, we need to start a new + # measure in those cases. elif data.name in ['bpm', 'scroll', 'gogo']: # Parse the values if data.name == 'bpm': @@ -74,13 +85,16 @@ def process_tja_commands(tja): elif data.name == 'gogo': new_val = current_gogo = bool(int(data.value)) # Check for mid-measure commands - # - Case 1: Command happens at the start of a measure; just change the value directly + # - Case 1: Command happens at the start of a measure; + # just change the value directly if data.pos == 0: measure_tja_processed.__setattr__(data.name, new_val) - # - Case 2: Command occurs mid-measure, so start a new sub-measure + # - Case 2: Command happens in the middle of a measure; + # start a new sub-measure else: measure_tja_processed.pos_end = data.pos - tja_branches_processed[branch_name].append(measure_tja_processed) + tja_branches_processed[branch_name]\ + .append(measure_tja_processed) measure_tja_processed = TJAMeasureProcessed( bpm=current_bpm, scroll=current_scroll, @@ -99,32 +113,47 @@ def process_tja_commands(tja): has_branches = all(len(b) for b in tja_branches_processed.values()) if has_branches: - branch_lens = [len(b) for b in tja.branches.values()] - if not branch_lens.count(branch_lens[0]) == len(branch_lens): - raise ValueError("Branches do not have the same number of measures.") - else: - branch_corrected_lens = [len(b) for b in tja_branches_processed.values()] - if not branch_corrected_lens.count(branch_corrected_lens[0]) == len(branch_corrected_lens): - raise ValueError("Branches do not have matching GOGO/SCROLL/BPM commands.") + if len(set([len(b) for b in tja.branches.values()])) != 1: + raise ValueError( + "Branches do not have the same number of measures. (This " + "check was performed prior to splitting up the measures due " + "to mid-measure commands. Please check the number of ',' you" + "have in each branch.)" + ) + if len(set([len(b) for b in tja_branches_processed.values()])) != 1: + raise ValueError( + "Branches do not have the same number of measures. (This " + "check was performed after splitting up the measures due " + "to mid-measure commands. Please check any GOGO, BPMCHANGE, " + "and SCROLL commands you have in your branches, and make sure" + "that each branch has the same number of commands.)" + ) return tja_branches_processed def convert_tja_to_fumen(tja): # Preprocess commands - processed_tja_branches = process_tja_commands(tja) + tja_branches_processed = process_tja_commands(tja) # Pre-allocate the measures for the converted TJA + n_measures = len(tja_branches_processed['normal']) fumen = FumenCourse( - measures=len(processed_tja_branches['normal']), + measures=n_measures, score_init=tja.score_init, score_diff=tja.score_diff, ) + # Set song metadata using information from the processed measures + fumen.header.b512_b515_number_of_measures = n_measures + fumen.header.b432_b435_has_branches = int(all( + [len(b) for b in tja_branches_processed.values()] + )) + # Iterate through the different branches in the TJA total_notes = {'normal': 0, 'professional': 0, 'master': 0} - for current_branch, branch_measures_tja_processed in processed_tja_branches.items(): - if not len(branch_measures_tja_processed): + for current_branch, branch_tja in tja_branches_processed.items(): + if not len(branch_tja): continue branch_points_total = 0 branch_points_measure = 0 @@ -133,90 +162,86 @@ def convert_tja_to_fumen(tja): course_balloons = tja.balloon.copy() # Iterate through the measures within the branch - for idx_m, measure_tja_processed in enumerate(branch_measures_tja_processed): + for idx_m, measure_tja in enumerate(branch_tja): # Fetch a pair of measures - measure_fumen_prev = fumen.measures[idx_m-1] if idx_m != 0 else None + measure_fumen_prev = fumen.measures[idx_m-1] if idx_m else None measure_fumen = fumen.measures[idx_m] - # Copy over basic measure properties from the TJA (that don't depend on notes or commands) - measure_fumen.branches[current_branch].speed = measure_tja_processed.scroll - measure_fumen.gogo = measure_tja_processed.gogo - measure_fumen.bpm = measure_tja_processed.bpm + # Copy over basic measure properties from the TJA + measure_fumen.branches[current_branch].speed = measure_tja.scroll + measure_fumen.gogo = measure_tja.gogo + measure_fumen.bpm = measure_tja.bpm # Compute the duration of the measure # First, we compute the duration for a full 4/4 measure - measure_duration_full_measure = 4 * 60_000 / measure_tja_processed.bpm # Next, we adjust this duration based on both: - # 1. The *actual* measure size (e.g. #MEASURE 1/8, #MEASURE 5/4, etc.) - measure_size = measure_tja_processed.time_sig[0] / measure_tja_processed.time_sig[1] - # 2. Whether this is a "submeasure" (i.e. it contains mid-measure commands, which split up the measure) - # - If this is a submeasure, then `measure_length` will be less than the total number of subdivisions. - measure_length = measure_tja_processed.pos_end - measure_tja_processed.pos_start - # - In other words, `measure_ratio` will be less than 1.0: - measure_ratio = (1.0 if measure_tja_processed.subdivisions == 0.0 # Avoid division by 0 for empty measures - else (measure_length / measure_tja_processed.subdivisions)) - # Apply the 2 adjustments to the measure duration - measure_fumen.duration = measure_duration = measure_duration_full_measure * measure_size * measure_ratio + # 1. The *actual* measure size (e.g. #MEASURE 1/8, 5/4, etc.) + # 2. Whether this is a "submeasure" (i.e. whether it contains + # mid-measure commands, which split up the measure) + # - If this is a submeasure, then `measure_length` will be + # less than the total number of subdivisions. + # - In other words, `measure_ratio` will be less than 1.0. + measure_duration_full_measure = (240000 / measure_fumen.bpm) + measure_size = (measure_tja.time_sig[0] / measure_tja.time_sig[1]) + measure_length = (measure_tja.pos_end - measure_tja.pos_start) + measure_ratio = ( + 1.0 if measure_tja.subdivisions == 0.0 # Avoid "/0" + else (measure_length / measure_tja.subdivisions) + ) + measure_fumen.duration = (measure_duration_full_measure + * measure_size * measure_ratio) - # Compute the millisecond offsets for the start and end of each measure - # - Start: When the notes first appear on screen (to the right) - # - End: When the notes arrive at the judgment line, and the note gets hit. + # Compute the millisecond offsets for the start of each measure + # First, start the measure using the end timing of the + # previous measure (plus any #DELAY commands) + # Next, adjust the start timing to account for #BPMCHANGE + # commands (!!! Discovered by tana :3 !!!) if idx_m == 0: - measure_fumen.fumen_offset_start = (tja.offset * 1000 * -1) - measure_duration_full_measure + measure_fumen.offset_start = ( + (tja.offset * 1000 * -1) - measure_duration_full_measure + ) else: - # First, start the measure using the end timing of the previous measure (plus any #DELAY commands) - measure_fumen.fumen_offset_start = measure_fumen_prev.fumen_offset_end + measure_tja_processed.delay - # Next, adjust the start timing to account for #BPMCHANGE commands (!!! Discovered by tana :3 !!!) - # To understand what's going on here, imagine the following simple example: - # * You have a very slow-moving note (i.e. low BPM), like the big DON in Donkama 2000. - # * All the other notes move fast (i.e. high BPM), moving past the big slow note. - # * To get this overlapping to work, you need the big slow note to START EARLY, but also END LATE: - # - An early start means you need to subtract a LOT of time from the starting fumenOffset. - # - Thankfully, the low BPM of the slow note will create a HUGE `measure_offset_adjustment`, - # since we are dividing by the BPMs, and dividing by a small number will result in a big number. - measure_offset_adjustment = (4 * 60_000 / measure_fumen.bpm) - (4 * 60_000 / measure_fumen_prev.bpm) - # - When we subtract this adjustment from the fumen_offset_start, we get the "START EARLY" part: - measure_fumen.fumen_offset_start -= measure_offset_adjustment - # - The low BPM of the slow note will also create a HUGE measure duration. - # - When we add this long duration to the EARLY START, we end up with the "END LATE" part: - measure_fumen.fumen_offset_end = measure_fumen.fumen_offset_start + measure_fumen.duration + measure_fumen.offset_start = measure_fumen_prev.offset_end + measure_fumen.offset_start += measure_tja.delay + measure_fumen.offset_start += (240000 / measure_fumen_prev.bpm) + measure_fumen.offset_start -= (240000 / measure_fumen.bpm) - # Best guess at what 'barline' status means for each measure: - # - 'True' means the measure lands on a barline (i.e. most measures), and thus barline should be shown - # - 'False' means that the measure doesn't land on a barline, and thus barline should be hidden. - # For example: + # Compute the millisecond offset for the end of each measure + measure_fumen.offset_end = (measure_fumen.offset_start + + measure_fumen.duration) + + # Handle whether barline should be hidden: # 1. Measures where #BARLINEOFF has been set # 2. Sub-measures that don't fall on the barline - if measure_tja_processed.barline is False or (measure_ratio != 1.0 and measure_tja_processed.pos_start != 0): + barline_off = measure_tja.barline is False + is_submeasure = (measure_ratio != 1.0 and + measure_tja.pos_start != 0) + if barline_off or is_submeasure: measure_fumen.barline = False - # If a #SECTION command occurs in isolation, and it has a valid condition, then treat it like a branch_start - if (measure_tja_processed.section is not None and measure_tja_processed.section != 'not_available' - and not measure_tja_processed.branch_start): - branch_condition = measure_tja_processed.section + # If a #SECTION command occurs in isolation, and it has a valid + # condition, then treat it like a branch_start + if (measure_tja.section is not None + and measure_tja.section != 'not_available' + and not measure_tja.branch_start): + branch_condition = measure_tja.section else: - branch_condition = measure_tja_processed.branch_start + branch_condition = measure_tja.branch_start # Check to see if the measure contains a branching condition if branch_condition: - # Determine which values to assign based on the type of branching condition + # Handle branch conditions for percentage accuracy + # There are three cases for interpreting #BRANCHSTART p: + # 1. Percentage is between 0% and 100% + # 2. Percentage is above 100% (guaranteed level down) + # 3. Percentage is 0% (guaranteed level up) if branch_condition[0] == 'p': vals = [] for percent in branch_condition[1:]: - # Ensure percentage is between 0% and 100% if 0 < percent <= 1: - # If there is a proper branch condition, make sure that drumrolls do not contribute - fumen.header.b480_b483_branch_points_drumroll = 0 - fumen.header.b492_b495_branch_points_drumroll_big = 0 - val = branch_points_total * percent - # If the result is very close, then round to account for lack of precision in percentage - if abs(val - round(val)) < 0.1: - val = round(val) - vals.append(int(val)) - # If it is above 100%, then it means a guaranteed "level down". Fumens use 999 for this. + vals.append(int(branch_points_total * percent)) elif percent > 1: vals.append(999) - # If it is below 0%, it is a guaranteed "level up". Fumens use 0 for this. else: vals.append(0) if current_branch == 'normal': @@ -226,120 +251,164 @@ def convert_tja_to_fumen(tja): elif current_branch == 'master': measure_fumen.branch_info[4:6] = vals - # If it's a drumroll, then the values to use depends on whether there is a #SECTION in the same measure - # - If there is a #SECTION, then accuracy is reset, so repeat the same condition for all 3 branches - # - If there isn't a #SECTION, but it's the first branch condition, repeat for all 3 branches as well - # - If there isn't a #SECTION, and there are previous branch conditions, the outcomes now matter: - # * If the player failed to go from Normal -> Advanced/Master, then they must stay in Normal, - # hence the 999 values (which force them to stay in Normal) - # * If the player made it to Advanced, then both condition values still apply (for either - # staying in Advanced or leveling up to Master) - # * If the player made it to Master, then only use the "master condition" value (2), otherwise - # they fall back to Normal. - # - The "no-#SECTION" behavior can be seen in songs like "Shoutoku Taiko no 「Hi Izuru Made Asuka」" + # Handle branch conditions for drumroll accuracy + # There are three cases for interpreting #BRANCHSTART r: + # 1. It's the first branching condition. + # 2. It's not the first branching condition, but it + # has a #SECTION command to reset the accuracy. + # 3. It's not the first branching condition, and it + # doesn't have a #SECTION command. + # For the first two cases, the branching conditions are the + # same no matter what branch you're currently on, so we just + # use the values as-is: [c1, c2, c1, c2, c1, c2] + # But, for the third case, since there is no #SECTION, the + # accuracy is not reset. This results in the following + # condition: [999, 999, c1, c2, c2, c2] + # - Normal can't advance to professional/master + # - Professional can stay, or advance to master. + # - Master can only stay in master. elif branch_condition[0] == 'r': - # Ensure that only drumrolls contribute to the branching accuracy check - fumen.header.b468_b471_branch_points_good = 0 - fumen.header.b484_b487_branch_points_good_big = 0 - fumen.header.b472_b475_branch_points_ok = 0 - fumen.header.b488_b491_branch_points_ok_big = 0 - fumen.header.b496_b499_branch_points_balloon = 0 - fumen.header.b500_b503_branch_points_kusudama = 0 - if current_branch == 'normal': - measure_fumen.branch_info[0:2] = (branch_condition[1:] if measure_tja_processed.section or - not measure_tja_processed.section and not branch_conditions - else [999, 999]) - elif current_branch == 'professional': - measure_fumen.branch_info[2:4] = branch_condition[1:] - elif current_branch == 'master': - measure_fumen.branch_info[4:6] = (branch_condition[1:] if measure_tja_processed.section or - not measure_tja_processed.section and not branch_conditions - else [branch_condition[2]] * 2) + is_first_branch_condition = not branch_conditions + has_section = bool(measure_tja.section) + if is_first_branch_condition or has_section: + measure_fumen.branch_info = branch_condition[1:] * 3 + else: + measure_fumen.branch_info = ( + [999, 999] + + [branch_condition[1]] + + [branch_condition[2]] * 3 + ) - # Reset the points corresponding to this branch (i.e. reset the accuracy) + # Reset the points to prepare for the next #BRANCHSTART p branch_points_total = 0 - # Keep track of branch conditions (to later determine how to set the header bytes for branches) + # Keep track of the branch conditions (to later determine how + # to set the header bytes for branches) branch_conditions.append(branch_condition) - # NB: We update the branch condition note counter *after* we check the current measure's branch condition. + # NB: We update the branch condition note counter *after* + # we check the current measure's branch condition. # This is because the TJA spec says: - # "The requirement is calculated one measure before #BRANCHSTART, changing the branch visually when it + # "The requirement is calculated one measure before + # #BRANCHSTART, changing the branch visually when it # is calculated and changing the notes after #BRANCHSTART." - # So, by delaying the summation by one measure, we perform the calculation with notes "one measure before". + # So, by delaying the summation by one measure, we perform the + # calculation with notes "one measure before". branch_points_total += branch_points_measure # Create notes based on TJA measure data branch_points_measure = 0 - note_counter = 0 - for idx_d, data in enumerate(measure_tja_processed.data): - if data.name == 'note': - note = FumenNote() - # Note positions must be calculated using the base measure duration (that uses a single BPM value) - # (In other words, note positions do not take into account any mid-measure BPM change adjustments.) - note.pos = measure_duration * (data.pos - measure_tja_processed.pos_start) / measure_length - # Handle the note that represents the end of a drumroll/balloon - if data.value == "EndDRB": - # If a drumroll spans a single measure, then add the difference between start/end position - if not current_drumroll.multimeasure: - current_drumroll.duration += (note.pos - current_drumroll.pos) - # Otherwise, if a drumroll spans multiple measures, then we want to add the duration between - # the start of the measure (i.e. pos=0.0) and the drumroll's end position. - else: - current_drumroll.duration += (note.pos - 0.0) - # 1182, 1385, 1588, 2469, 1568, 752, 1568 - current_drumroll.duration = float(int(current_drumroll.duration)) - current_drumroll = None - continue - # The TJA spec technically allows you to place double-Kusudama notes: - # "Use another 9 to specify when to lower the points for clearing." - # But this is unsupported in fumens, so just skip the second Kusudama note. - if data.value == "Kusudama" and current_drumroll: - continue - # Handle the remaining non-EndDRB, non-double Kusudama notes - note.type = data.value - note.score_init = tja.score_init - note.score_diff = tja.score_diff - # Handle drumroll/balloon-specific metadata - if note.type in ["Balloon", "Kusudama"]: - note.hits = course_balloons.pop(0) - current_drumroll = note - total_notes[current_branch] -= 1 - if note.type in ["Drumroll", "DRUMROLL"]: - current_drumroll = note - total_notes[current_branch] -= 1 - measure_fumen.branches[current_branch].notes.append(note) - note_counter += 1 + for idx_d, data in enumerate(measure_tja.data): + # Compute the ms position of the note + pos_ratio = ((data.pos - measure_tja.pos_start) + / measure_length) + note_pos = (measure_fumen.duration * pos_ratio) - # Track branch points for the current measure, to later compute `#BRANCHSTART p` bytes - if note.type in ['Don', 'Ka']: - branch_points_measure += fumen.header.b468_b471_branch_points_good - elif note.type in ['DON', 'KA']: - branch_points_measure += fumen.header.b484_b487_branch_points_good_big - elif note.type == 'Balloon': - branch_points_measure += fumen.header.b496_b499_branch_points_balloon - elif note.type == 'Kusudama': - branch_points_measure += fumen.header.b500_b503_branch_points_kusudama + # Handle '8' notes (end of a drumroll/balloon) + if data.value == "EndDRB": + # If a drumroll spans a single measure, then add the + # difference between start/end position + if not current_drumroll.multimeasure: + current_drumroll.duration += (note_pos - + current_drumroll.pos) + # Otherwise, if a drumroll spans multiple measures, + # then we want to add the duration between the start + # of the measure and the drumroll's end position. + else: + current_drumroll.duration += (note_pos - 0.0) + current_drumroll.duration = float(int( + current_drumroll.duration + )) + current_drumroll = None + continue - # If drumroll hasn't ended by the end of this measure, increase duration by measure timing - if current_drumroll: - if current_drumroll.duration == 0.0: - current_drumroll.duration += (measure_duration - current_drumroll.pos) - current_drumroll.multimeasure = True + # The TJA spec technically allows you to place + # double-Kusudama notes. But this is unsupported in + # fumens, so just skip the second Kusudama note. + if data.value == "Kusudama" and current_drumroll: + continue + + # Handle note metadata + note = FumenNote() + note.pos = note_pos + note.type = data.value + note.score_init = tja.score_init + note.score_diff = tja.score_diff + + # Handle drumroll notes + if note.type in ["Balloon", "Kusudama"]: + note.hits = course_balloons.pop(0) + current_drumroll = note + elif note.type in ["Drumroll", "DRUMROLL"]: + current_drumroll = note + + # Track Don/Ka notes (to later compute header values) + elif note.type.lower() in ['don', 'ka']: + total_notes[current_branch] += 1 + + # Track branch points (to later compute `#BRANCHSTART p` vals) + if note.type in ['Don', 'Ka']: + pts_to_add = fumen.header.b468_b471_branch_points_good + elif note.type in ['DON', 'KA']: + pts_to_add = fumen.header.b484_b487_branch_points_good_big + elif note.type == 'Balloon': + pts_to_add = fumen.header.b496_b499_branch_points_balloon + elif note.type == 'Kusudama': + pts_to_add = fumen.header.b500_b503_branch_points_kusudama else: - current_drumroll.duration += measure_duration + pts_to_add = 0 # Drumrolls not relevant for `p` conditions + branch_points_measure += pts_to_add - measure_fumen.branches[current_branch].length = note_counter - total_notes[current_branch] += note_counter + # Add the note to the branch for this measure + measure_fumen.branches[current_branch].notes.append(note) + measure_fumen.branches[current_branch].length += 1 - # Set song-specific metadata - fumen.header.b512_b515_number_of_measures = len(fumen.measures) - fumen.header.b432_b435_has_branches = int(all([len(b) for b in processed_tja_branches.values()])) + # If drumroll hasn't ended by this measure, increase duration + if current_drumroll: + # If drumroll spans multiple measures, add full duration + if current_drumroll.multimeasure: + current_drumroll.duration += measure_fumen.duration + # Otherwise, add the partial duration spanned by the drumroll + else: + current_drumroll.multimeasure = True + current_drumroll.duration += (measure_fumen.duration - + current_drumroll.pos) + + # Compute the header bytes that dictate the soul gauge bar behavior fumen.header.set_hp_bytes(total_notes['normal'], tja.course, tja.level) - # Compute the ratio between normal and professional/master branches (just in case the note counts differ) + # If song has only drumroll branching conditions (also allowing percentage + # conditions that force a level up/level down), then set the header bytes + # so that only drumrolls contribute to branching. + drumroll_only = branch_conditions != [] and all([ + (cond[0] == 'r') or + (cond[0] == 'p' and cond[1] == 0.0 and cond[2] == 0.0) or + (cond[0] == 'p' and cond[1] > 1.00 and cond[2] > 1.00) + for cond in branch_conditions + ]) + if drumroll_only: + fumen.header.b468_b471_branch_points_good = 0 + fumen.header.b484_b487_branch_points_good_big = 0 + fumen.header.b472_b475_branch_points_ok = 0 + fumen.header.b488_b491_branch_points_ok_big = 0 + fumen.header.b496_b499_branch_points_balloon = 0 + fumen.header.b500_b503_branch_points_kusudama = 0 + + # Alternatively, if the song has only percentage-based conditions, then set + # the header bytes so that only notes and balloons contribute to branching. + percentage_only = branch_conditions != [] and all([ + (condition[0] != 'r') + for condition in branch_conditions + ]) + if percentage_only: + fumen.header.b480_b483_branch_points_drumroll = 0 + fumen.header.b492_b495_branch_points_drumroll_big = 0 + + # Compute the ratio between normal and professional/master branches if total_notes['professional']: - fumen.header.b460_b463_normal_professional_ratio = int(65536 * (total_notes['normal'] / total_notes['professional'])) + fumen.header.b460_b463_normal_professional_ratio = \ + int(65536 * (total_notes['normal'] / total_notes['professional'])) if total_notes['master']: - fumen.header.b464_b467_normal_master_ratio = int(65536 * (total_notes['normal'] / total_notes['master'])) + fumen.header.b464_b467_normal_master_ratio = \ + int(65536 * (total_notes['normal'] / total_notes['master'])) return fumen diff --git a/src/tja2fumen/parsers.py b/src/tja2fumen/parsers.py index 7258943..5cb3cdd 100644 --- a/src/tja2fumen/parsers.py +++ b/src/tja2fumen/parsers.py @@ -2,14 +2,15 @@ import os import re from copy import deepcopy -from tja2fumen.utils import read_struct, short_hex -from tja2fumen.constants import NORMALIZE_COURSE, TJA_NOTE_TYPES, BRANCH_NAMES, FUMEN_NOTE_TYPES -from tja2fumen.types import (TJASong, TJAMeasure, TJAData, - FumenCourse, FumenMeasure, FumenBranch, FumenNote, FumenHeader) +from tja2fumen.utils import read_struct +from tja2fumen.types import (TJASong, TJAMeasure, TJAData, FumenCourse, + FumenMeasure, FumenBranch, FumenNote, FumenHeader) +from tja2fumen.constants import (NORMALIZE_COURSE, TJA_NOTE_TYPES, + BRANCH_NAMES, FUMEN_NOTE_TYPES) -######################################################################################################################## -# TJA-parsing functions ( Original source: https://github.com/WHMHammer/tja-tools/blob/master/src/js/parseTJA.js) -######################################################################################################################## +############################################################################### +# TJA-parsing functions # +############################################################################### def parse_tja(fname_tja): @@ -56,54 +57,69 @@ def get_course_data(lines): if current_course not in parsed_tja.courses.keys(): raise ValueError() elif name_upper == 'LEVEL': - parsed_tja.courses[current_course].level = int(value) if value else 0 - # NB: If there are multiple SCOREINIT/SCOREDIFF values, use the last one (shinuti) + parsed_tja.courses[current_course].level = \ + int(value) if value else 0 elif name_upper == 'SCOREINIT': - parsed_tja.courses[current_course].score_init = int(value.split(",")[-1]) if value else 0 + parsed_tja.courses[current_course].score_init = \ + int(value.split(",")[-1]) if value else 0 elif name_upper == 'SCOREDIFF': - parsed_tja.courses[current_course].score_diff = int(value.split(",")[-1]) if value else 0 + parsed_tja.courses[current_course].score_diff = \ + int(value.split(",")[-1]) if value else 0 elif name_upper == 'BALLOON': if value: balloons = [int(v) for v in value.split(",") if v] parsed_tja.courses[current_course].balloon = balloons elif name_upper == 'STYLE': - # Reset the course name to remove "P1/P2" that may have been added by a previous STYLE:DOUBLE chart + # Reset the course name to remove "P1/P2" that may have been + # added by a previous STYLE:DOUBLE chart if value == 'Single': current_course = current_course_cached else: - pass # Ignore other header fields such as 'TITLE', 'SUBTITLE', 'WAVE', etc. + pass # Ignore 'TITLE', 'SUBTITLE', 'WAVE', etc. - # Case 2: Commands and note data (to be further processed course-by-course later on) + # Case 2: Commands and note data (to be further processed + # course-by-course later on) elif not re.match(r"//.*", line): # Exclude comment-only lines ('//') match_command = re.match(r"^#([A-Z]+)(?:\s+(.+))?", line) match_notes = re.match(r"^(([0-9]|A|B|C|F|G)*,?).*$", line) if match_command: name_upper = match_command.group(1).upper() - value = match_command.group(2).strip() if match_command.group(2) else '' - # For STYLE:Double, #START P1/P2 indicates the start of a new chart - # But, we want multiplayer charts to inherit the metadata from the course as a whole, so we deepcopy + value = (match_command.group(2).strip() + if match_command.group(2) else '') + # For STYLE:Double, #START P1/P2 indicates the start of a new + # chart. But, we want multiplayer charts to inherit the + # metadata from the course as a whole, so we deepcopy. if name_upper == "START": if value in ["P1", "P2"]: current_course = current_course_cached + value - parsed_tja.courses[current_course] = deepcopy(parsed_tja.courses[current_course_cached]) - parsed_tja.courses[current_course].data = list() # Keep the metadata, but reset the note data - value = '' # Once we've made the new course, we can reset this to a normal #START command + parsed_tja.courses[current_course] = \ + deepcopy(parsed_tja.courses[current_course_cached]) + parsed_tja.courses[current_course].data = list() + # Once we've made the new course, we can reset + # #START P1/P2 to a normal #START command + value = '' elif value: - raise ValueError(f"Invalid value '{value}' for #START command.") + raise ValueError(f"Invalid value '{value}' for " + f"#START command.") elif match_notes: name_upper = 'NOTES' value = match_notes.group(1) - parsed_tja.courses[current_course].data.append(TJAData(name_upper, value)) - - # If a course has no song data, then this is likely because the course has "STYLE: Double" but no "STYLE: Single". - # To fix this, we copy over the P1 chart from "STYLE: Double" to fill the "STYLE: Single" role. + parsed_tja.courses[current_course].data.append( + TJAData(name_upper, value) + ) + + # If a course has no song data, then this is likely because the course has + # "STYLE: Double" but no "STYLE: Single". To fix this, we copy over the P1 + # chart from "STYLE: Double" to fill the "STYLE: Single" role. for course_name, course in parsed_tja.courses.items(): if not course.data: if course_name+"P1" in parsed_tja.courses.keys(): - parsed_tja.courses[course_name] = deepcopy(parsed_tja.courses[course_name+"P1"]) + parsed_tja.courses[course_name] = \ + deepcopy(parsed_tja.courses[course_name+"P1"]) - # Remove any charts (e.g. P1/P2) not present in the TJA file - for course_name in [k for k, v in parsed_tja.courses.items() if not v.data]: + # Remove any charts (e.g. P1/P2) not present in the TJA file (empty data) + for course_name in [k for k, v in parsed_tja.courses.items() + if not v.data]: del parsed_tja.courses[course_name] return parsed_tja @@ -111,7 +127,8 @@ def get_course_data(lines): def parse_course_measures(course): # Check if the course has branches or not - has_branches = True if [l for l in course.data if l.name == 'BRANCHSTART'] else False + has_branches = (True if [d for d in course.data if d.name == 'BRANCHSTART'] + else False) current_branch = 'all' if has_branches else 'normal' branch_condition = None flag_levelhold = False @@ -123,22 +140,29 @@ def parse_course_measures(course): # 1. Parse measure notes if line.name == 'NOTES': notes = line.value - # If measure has ended, then add notes to the current measure, then start a new one by incrementing idx_m + # If measure has ended, then add notes to the current measure, + # then start a new measure by incrementing idx_m if notes.endswith(','): - for branch in course.branches.keys() if current_branch == 'all' else [current_branch]: + for branch in (course.branches.keys() + if current_branch == 'all' + else [current_branch]): course.branches[branch][idx_m].notes += notes[0:-1] course.branches[branch].append(TJAMeasure()) idx_m += 1 # Otherwise, keep adding notes to the current measure ('idx_m') else: - for branch in course.branches.keys() if current_branch == 'all' else [current_branch]: + for branch in (course.branches.keys() + if current_branch == 'all' + else [current_branch]): course.branches[branch][idx_m].notes += notes # 2. Parse measure commands that produce an "event" - elif line.name in ['GOGOSTART', 'GOGOEND', 'BARLINEON', 'BARLINEOFF', 'DELAY', - 'SCROLL', 'BPMCHANGE', 'MEASURE', 'SECTION', 'BRANCHSTART']: + elif line.name in ['GOGOSTART', 'GOGOEND', 'BARLINEON', 'BARLINEOFF', + 'DELAY', 'SCROLL', 'BPMCHANGE', 'MEASURE', + 'SECTION', 'BRANCHSTART']: # Get position of the event - for branch in course.branches.keys() if current_branch == 'all' else [current_branch]: + for branch in (course.branches.keys() if current_branch == 'all' + else [current_branch]): pos = len(course.branches[branch][idx_m].notes) # Parse event type @@ -163,30 +187,35 @@ def parse_course_measures(course): current_event = TJAData('section', 'not_available', pos) else: current_event = TJAData('section', branch_condition, pos) - # If the command immediately after #SECTION is #BRANCHSTART, then we need to make sure that #SECTION - # is put on every branch. (We can't do this unconditionally because #SECTION commands can also exist - # in isolation in the middle of separate branches.) + # If the command immediately after #SECTION is #BRANCHSTART, + # then we need to make sure that #SECTION is put on every + # branch. (We can't do this unconditionally because #SECTION + # commands can also exist in isolation.) if course.data[idx_l+1].name == 'BRANCHSTART': current_branch = 'all' elif line.name == 'BRANCHSTART': if flag_levelhold: continue - current_branch = 'all' # Ensure that the #BRANCHSTART command is present for all branches + # Ensure that the #BRANCHSTART command is added to all branches + current_branch = 'all' branch_condition = line.value.split(',') if branch_condition[0] == 'r': # r = drumRoll - branch_condition[1] = int(branch_condition[1]) # # of drumrolls - branch_condition[2] = int(branch_condition[2]) # # of drumrolls + branch_condition[1] = int(branch_condition[1]) # drumrolls + branch_condition[2] = int(branch_condition[2]) # drumrolls elif branch_condition[0] == 'p': # p = Percentage branch_condition[1] = float(branch_condition[1]) / 100 # % branch_condition[2] = float(branch_condition[2]) / 100 # % current_event = TJAData('branch_start', branch_condition, pos) - idx_m_branchstart = idx_m # Preserve the index of the BRANCHSTART command to re-use for each branch + # Preserve the index of the BRANCHSTART command to re-use + idx_m_branchstart = idx_m # Append event to the current measure's events - for branch in course.branches.keys() if current_branch == 'all' else [current_branch]: + for branch in (course.branches.keys() if current_branch == 'all' + else [current_branch]): course.branches[branch][idx_m].events.append(current_event) - # 3. Parse commands that don't create an event (e.g. simply changing the current branch) + # 3. Parse commands that don't create an event + # (e.g. simply changing the current branch) else: if line.name == 'START' or line.name == 'END': current_branch = 'all' if has_branches else 'normal' @@ -208,7 +237,8 @@ def parse_course_measures(course): else: print(f"Ignoring unsupported command '{line.name}'") - # Delete the last measure in the branch if no notes or events were added to it (due to preallocating empty measures) + # Delete the last measure in the branch if no notes or events + # were added to it (due to preallocating empty measures) for branch in course.branches.values(): if not branch[-1].notes and not branch[-1].events: del branch[-1] @@ -232,27 +262,22 @@ def parse_course_measures(course): # Ensure all branches have the same number of measures if has_branches: - branch_lens = [len(b) for b in course.branches.values()] - if not branch_lens.count(branch_lens[0]) == len(branch_lens): - raise ValueError("Branches do not have the same number of measures.") + if len(set([len(b) for b in course.branches.values()])) != 1: + raise ValueError( + "Branches do not have the same number of measures. (This " + "check was performed prior to splitting up the measures due " + "to mid-measure commands. Please check the number of ',' you" + "have in each branch.)" + ) -######################################################################################################################## -# Fumen-parsing functions -######################################################################################################################## - -# Fumen format reverse engineering TODOs -# TODO: Figure out what drumroll bytes are (8 bytes after every drumroll) -# NB: fumen2osu.py assumed these were padding bytes, but they're not!! They contain some sort of metadata. -# TODO: Figure out what the unknown Wii1, Wii4, and PS4 notes represent (just in case they're important somehow) - +############################################################################### +# Fumen-parsing functions # +############################################################################### def read_fumen(fumen_file, exclude_empty_measures=False): """ - Parse bytes of a fumen .bin file into nested measure, branch, and note dictionaries. - - For more information on any of the terms used in this function (e.g. score_init, score_diff), - please refer to KatieFrog's excellent guide: https://gist.github.com/KatieFrogs/e000f406bbc70a12f3c34a07303eec8b + Parse bytes of a fumen .bin file into nested measures, branches, and notes. """ file = open(fumen_file, "rb") size = os.fstat(file.fileno()).st_size @@ -264,19 +289,20 @@ def read_fumen(fumen_file, exclude_empty_measures=False): for measure_number in range(song.header.b512_b515_number_of_measures): # Parse the measure data using the following `format_string`: # "ffBBHiiiiiii" (12 format characters, 40 bytes per measure) - # - 'f': BPM (represented by one float (4 bytes)) - # - 'f': fumenOffset (represented by one float (4 bytes)) - # - 'B': gogo (represented by one unsigned char (1 byte)) - # - 'B': barline (represented by one unsigned char (1 byte)) - # - 'H': (represented by one unsigned short (2 bytes)) - # - 'iiiiii': branch_info (represented by six integers (24 bytes)) - # - 'i': (represented by one integer (4 bytes) - measure_struct = read_struct(file, song.header.order, format_string="ffBBHiiiiiii") + # - 'f': BPM (one float (4 bytes)) + # - 'f': fumenOffset (one float (4 bytes)) + # - 'B': gogo (one unsigned char (1 byte)) + # - 'B': barline (one unsigned char (1 byte)) + # - 'H': (one unsigned short (2 bytes)) + # - 'iiiiii': branch_info (six integers (24 bytes)) + # - 'i': (one integer (4 bytes) + measure_struct = read_struct(file, song.header.order, + format_string="ffBBHiiiiiii") # Create the measure dictionary using the newly-parsed measure data measure = FumenMeasure( bpm=measure_struct[0], - fumen_offset_start=measure_struct[1], + offset_start=measure_struct[1], gogo=measure_struct[2], barline=measure_struct[3], padding1=measure_struct[4], @@ -288,10 +314,11 @@ def read_fumen(fumen_file, exclude_empty_measures=False): for branch_name in BRANCH_NAMES: # Parse the measure data using the following `format_string`: # "HHf" (3 format characters, 8 bytes per branch) - # - 'H': total_notes (represented by one unsigned short (2 bytes)) - # - 'H': (represented by one unsigned short (2 bytes)) - # - 'f': speed (represented by one float (4 bytes) - branch_struct = read_struct(file, song.header.order, format_string="HHf") + # - 'H': total_notes ( one unsigned short (2 bytes)) + # - 'H': ( one unsigned short (2 bytes)) + # - 'f': speed ( one float (4 bytes) + branch_struct = read_struct(file, song.header.order, + format_string="HHf") # Create the branch dictionary using the newly-parsed branch data total_notes = branch_struct[0] @@ -313,17 +340,11 @@ def read_fumen(fumen_file, exclude_empty_measures=False): # - 'H': score_diff # - 'f': duration # NB: 'item' doesn't seem to be used at all in this function. - note_struct = read_struct(file, song.header.order, format_string="ififHHf") - - # Validate the note type - note_type = note_struct[0] - if note_type not in FUMEN_NOTE_TYPES: - raise ValueError("Error: Unknown note type '{0}' at offset {1}".format( - short_hex(note_type).upper(), - hex(file.tell() - 0x18)) - ) + note_struct = read_struct(file, song.header.order, + format_string="ififHHf") # Create the note dictionary using the newly-parsed note data + note_type = note_struct[0] note = FumenNote( note_type=FUMEN_NOTE_TYPES[note_type], pos=note_struct[1], @@ -342,7 +363,7 @@ def read_fumen(fumen_file, exclude_empty_measures=False): # Drumroll/balloon duration note.duration = note_struct[6] - # Seek forward 8 bytes to account for padding bytes at the end of drumrolls + # Account for padding at the end of drumrolls if note_type == 0x6 or note_type == 0x9 or note_type == 0x62: note.drumroll_bytes = file.read(8) @@ -359,12 +380,16 @@ def read_fumen(fumen_file, exclude_empty_measures=False): file.close() - # NB: Official fumens often include empty measures as a way of inserting barlines for visual effect. - # But, TJA authors tend not to add these empty measures, because even without them, the song plays correctly. - # So, in tests, if we want to only compare the timing of the non-empty measures between an official fumen and - # a converted non-official TJA, then it's useful to exclude the empty measures. + # NB: Official fumens often include empty measures as a way of inserting + # barlines for visual effect. But, TJA authors tend not to add these empty + # measures, because even without them, the song plays correctly. So, in + # tests, if we want to only compare the timing of the non-empty measures + # between an official fumen and a converted non-official TJA, then it's + # useful to exclude the empty measures. if exclude_empty_measures: song.measures = [m for m in song.measures - if m.branches['normal'].length or m.branches['professional'].length or m.branches['master'].length] + if m.branches['normal'].length + or m.branches['professional'].length + or m.branches['master'].length] return song diff --git a/src/tja2fumen/types.py b/src/tja2fumen/types.py index e55a985..5739bd9 100644 --- a/src/tja2fumen/types.py +++ b/src/tja2fumen/types.py @@ -2,21 +2,24 @@ import csv import os import struct -from tja2fumen.constants import TJA_COURSE_NAMES +from tja2fumen.constants import TJA_COURSE_NAMES, BRANCH_NAMES class TJASong: def __init__(self, BPM=None, offset=None): self.BPM = float(BPM) self.offset = float(offset) - self.courses = {course: TJACourse(self.BPM, self.offset, course) for course in TJA_COURSE_NAMES} + self.courses = {course: TJACourse(self.BPM, self.offset, course) + for course in TJA_COURSE_NAMES} def __repr__(self): - return f"{{'BPM': {self.BPM}, 'offset': {self.offset}, 'courses': {list(self.courses.keys())}}}" + return (f"{{'BPM': {self.BPM}, 'offset': {self.offset}, " + f"'courses': {list(self.courses.keys())}}}") class TJACourse: - def __init__(self, BPM, offset, course, level=0, balloon=None, score_init=0, score_diff=0): + def __init__(self, BPM, offset, course, level=0, balloon=None, + score_init=0, score_diff=0): self.level = level self.balloon = [] if balloon is None else balloon self.score_init = score_init @@ -47,7 +50,8 @@ class TJAMeasure: class TJAMeasureProcessed: def __init__(self, bpm, scroll, gogo, barline, time_sig, subdivisions, - pos_start=0, pos_end=0, delay=0, section=None, branch_start=None, data=None): + pos_start=0, pos_end=0, delay=0, section=None, + branch_start=None, data=None): self.bpm = bpm self.scroll = scroll self.gogo = gogo @@ -90,17 +94,18 @@ class FumenCourse: class FumenMeasure: - def __init__(self, bpm=0.0, fumen_offset_start=0.0, fumen_offset_end=0.0, duration=0.0, - gogo=False, barline=True, branch_start=None, branch_info=None, padding1=0, padding2=0): + def __init__(self, bpm=0.0, offset_start=0.0, offset_end=0.0, + duration=0.0, gogo=False, barline=True, branch_start=None, + branch_info=None, padding1=0, padding2=0): self.bpm = bpm - self.fumen_offset_start = fumen_offset_start - self.fumen_offset_end = fumen_offset_end + self.offset_start = offset_start + self.offset_end = offset_end self.duration = duration self.gogo = gogo self.barline = barline self.branch_start = branch_start - self.branch_info = [-1, -1, -1, -1, -1, -1] if branch_info is None else branch_info - self.branches = {'normal': FumenBranch(), 'professional': FumenBranch(), 'master': FumenBranch()} + self.branch_info = [-1] * 6 if branch_info is None else branch_info + self.branches = {b: FumenBranch() for b in BRANCH_NAMES} self.padding1 = padding1 self.padding2 = padding2 @@ -120,14 +125,17 @@ class FumenBranch: class FumenNote: - def __init__(self, note_type='', pos=0.0, score_init=0, score_diff=0, padding=0, item=0, duration=0.0, - multimeasure=False, hits=0, hits_padding=0, drumroll_bytes=b'\x00\x00\x00\x00\x00\x00\x00\x00'): + def __init__(self, note_type='', pos=0.0, score_init=0, score_diff=0, + padding=0, item=0, duration=0.0, multimeasure=False, + hits=0, hits_padding=0, + drumroll_bytes=b'\x00\x00\x00\x00\x00\x00\x00\x00'): self.note_type = note_type self.pos = pos self.score_init = score_init self.score_diff = score_diff self.padding = padding - # TODO: Determine how to properly set the item byte (https://github.com/vivaria/tja2fumen/issues/17) + # TODO: Determine how to properly set the item byte + # (https://github.com/vivaria/tja2fumen/issues/17) self.item = item # These attributes are only used for drumrolls/balloons self.duration = duration @@ -150,8 +158,9 @@ class FumenHeader: self._parse_header_values(raw_bytes) def _assign_default_header_values(self): - self.b000_b431_timing_windows = struct.unpack(self.order + ("fff" * 36), - b'43\xc8Ag&\x96B"\xe2\xd8B' * 36) + # This byte string corresponds to + timing_windows = self.up(b'43\xc8Ag&\x96B"\xe2\xd8B' * 36, "fff" * 36) + self.b000_b431_timing_windows = timing_windows self.b432_b435_has_branches = 0 self.b436_b439_hp_max = 10000 self.b440_b443_hp_clear = 8000 @@ -176,33 +185,42 @@ class FumenHeader: self.b516_b519_unknown_data = 0 def _parse_header_values(self, raw_bytes): - self.b000_b431_timing_windows = struct.unpack(self.order + ("fff" * 36), raw_bytes[0:432]) - self.b432_b435_has_branches = struct.unpack(self.order + "i", raw_bytes[432:436])[0] - self.b436_b439_hp_max = struct.unpack(self.order + "i", raw_bytes[436:440])[0] - self.b440_b443_hp_clear = struct.unpack(self.order + "i", raw_bytes[440:444])[0] - self.b444_b447_hp_gain_good = struct.unpack(self.order + "i", raw_bytes[444:448])[0] - self.b448_b451_hp_gain_ok = struct.unpack(self.order + "i", raw_bytes[448:452])[0] - self.b452_b455_hp_loss_bad = struct.unpack(self.order + "i", raw_bytes[452:456])[0] - self.b456_b459_normal_normal_ratio = struct.unpack(self.order + "i", raw_bytes[456:460])[0] - self.b460_b463_normal_professional_ratio = struct.unpack(self.order + "i", raw_bytes[460:464])[0] - self.b464_b467_normal_master_ratio = struct.unpack(self.order + "i", raw_bytes[464:468])[0] - self.b468_b471_branch_points_good = struct.unpack(self.order + "i", raw_bytes[468:472])[0] - self.b472_b475_branch_points_ok = struct.unpack(self.order + "i", raw_bytes[472:476])[0] - self.b476_b479_branch_points_bad = struct.unpack(self.order + "i", raw_bytes[476:480])[0] - self.b480_b483_branch_points_drumroll = struct.unpack(self.order + "i", raw_bytes[480:484])[0] - self.b484_b487_branch_points_good_big = struct.unpack(self.order + "i", raw_bytes[484:488])[0] - self.b488_b491_branch_points_ok_big = struct.unpack(self.order + "i", raw_bytes[488:492])[0] - self.b492_b495_branch_points_drumroll_big = struct.unpack(self.order + "i", raw_bytes[492:496])[0] - self.b496_b499_branch_points_balloon = struct.unpack(self.order + "i", raw_bytes[496:500])[0] - self.b500_b503_branch_points_kusudama = struct.unpack(self.order + "i", raw_bytes[500:504])[0] - self.b504_b507_branch_points_unknown = struct.unpack(self.order + "i", raw_bytes[504:508])[0] - self.b508_b511_dummy_data = struct.unpack(self.order + "i", raw_bytes[508:512])[0] - self.b512_b515_number_of_measures = struct.unpack(self.order + "i", raw_bytes[512:516])[0] - self.b516_b519_unknown_data = struct.unpack(self.order + "i", raw_bytes[516:520])[0] + rb = raw_bytes + self.b000_b431_timing_windows = self.up(rb, "f" * 108, + 0, 431) + self.b432_b435_has_branches = self.up(rb, "i", 432, 435) + self.b436_b439_hp_max = self.up(rb, "i", 436, 439) + self.b440_b443_hp_clear = self.up(rb, "i", 440, 443) + self.b444_b447_hp_gain_good = self.up(rb, "i", 444, 447) + self.b448_b451_hp_gain_ok = self.up(rb, "i", 448, 451) + self.b452_b455_hp_loss_bad = self.up(rb, "i", 452, 455) + self.b456_b459_normal_normal_ratio = self.up(rb, "i", 456, 459) + self.b460_b463_normal_professional_ratio = self.up(rb, "i", 460, 463) + self.b464_b467_normal_master_ratio = self.up(rb, "i", 464, 467) + self.b468_b471_branch_points_good = self.up(rb, "i", 468, 471) + self.b472_b475_branch_points_ok = self.up(rb, "i", 472, 475) + self.b476_b479_branch_points_bad = self.up(rb, "i", 476, 479) + self.b480_b483_branch_points_drumroll = self.up(rb, "i", 480, 483) + self.b484_b487_branch_points_good_big = self.up(rb, "i", 484, 487) + self.b488_b491_branch_points_ok_big = self.up(rb, "i", 488, 491) + self.b492_b495_branch_points_drumroll_big = self.up(rb, "i", 492, 495) + self.b496_b499_branch_points_balloon = self.up(rb, "i", 496, 499) + self.b500_b503_branch_points_kusudama = self.up(rb, "i", 500, 503) + self.b504_b507_branch_points_unknown = self.up(rb, "i", 504, 507) + self.b508_b511_dummy_data = self.up(rb, "i", 508, 511) + self.b512_b515_number_of_measures = self.up(rb, "i", 512, 515) + self.b516_b519_unknown_data = self.up(rb, "i", 516, 519) - @staticmethod - def _parse_order(raw_bytes): - if struct.unpack(">I", raw_bytes[512:516])[0] < struct.unpack("I", 512, 515) < + self.up(raw_bytes, "" else: return "<" @@ -210,31 +228,42 @@ class FumenHeader: def set_hp_bytes(self, n_notes, difficulty, stars): difficulty = 'Oni' if difficulty in ['Ura', 'Edit'] else difficulty self._get_hp_from_LUTs(n_notes, difficulty, stars) - self.b440_b443_hp_clear = {'Easy': 6000, 'Normal': 7000, 'Hard': 7000, 'Oni': 8000}[difficulty] + self.b440_b443_hp_clear = {'Easy': 6000, 'Normal': 7000, + 'Hard': 7000, 'Oni': 8000}[difficulty] def _get_hp_from_LUTs(self, n_notes, difficulty, stars): if n_notes > 2500: return star_to_key = { - 'Oni': {1: '17', 2: '17', 3: '17', 4: '17', 5: '17', 6: '17', 7: '17', 8: '8', 9: '910', 10: '910'}, - 'Hard': {1: '12', 2: '12', 3: '3', 4: '4', 5: '58', 6: '58', 7: '58', 8: '58', 9: '58', 10: '58'}, - 'Normal': {1: '12', 2: '12', 3: '3', 4: '4', 5: '57', 6: '57', 7: '57', 8: '57', 9: '57', 10: '57'}, - 'Easy': {1: '1', 2: '23', 3: '23', 4: '45', 5: '45', 6: '45', 7: '45', 8: '45', 9: '45', 10: '45'}, + 'Oni': {1: '17', 2: '17', 3: '17', 4: '17', 5: '17', + 6: '17', 7: '17', 8: '8', 9: '910', 10: '910'}, + 'Hard': {1: '12', 2: '12', 3: '3', 4: '4', 5: '58', + 6: '58', 7: '58', 8: '58', 9: '58', 10: '58'}, + 'Normal': {1: '12', 2: '12', 3: '3', 4: '4', 5: '57', + 6: '57', 7: '57', 8: '57', 9: '57', 10: '57'}, + 'Easy': {1: '1', 2: '23', 3: '23', 4: '45', 5: '45', + 6: '45', 7: '45', 8: '45', 9: '45', 10: '45'}, } key = f"{difficulty}-{star_to_key[difficulty][stars]}" pkg_dir = os.path.dirname(os.path.realpath(__file__)) - with open(os.path.join(pkg_dir, "hp_values.csv"), newline='') as csvfile: - rows = [row for row in csv.reader(csvfile, delimiter=',')] - self.b444_b447_hp_gain_good = int(rows[n_notes][rows[0].index(f"good_{key}")]) - self.b448_b451_hp_gain_ok = int(rows[n_notes][rows[0].index(f"ok_{key}")]) - self.b452_b455_hp_loss_bad = int(rows[n_notes][rows[0].index(f"bad_{key}")]) + with open(os.path.join(pkg_dir, "hp_values.csv"), newline='') as fp: + # Parse row data + rows = [row for row in csv.reader(fp, delimiter=',')] + # Get column numbers by indexing header row + column_good = rows[0].index(f"good_{key}") + column_ok = rows[0].index(f"ok_{key}") + column_bad = rows[0].index(f"bad_{key}") + # Fetch values from the row corresponding to the number of notes + self.b444_b447_hp_gain_good = int(rows[n_notes][column_good]) + self.b448_b451_hp_gain_ok = int(rows[n_notes][column_ok]) + self.b452_b455_hp_loss_bad = int(rows[n_notes][column_bad]) @property def raw_bytes(self): value_list = [] format_string = self.order for key, val in self.__dict__.items(): - if key == "order": + if key in ["order", "_raw_bytes"]: pass elif key == "b000_b431_timing_windows": value_list.extend(list(val)) @@ -247,6 +276,7 @@ class FumenHeader: return raw_bytes def __repr__(self): + # Display truncated version of timing windows return str([v if not isinstance(v, tuple) - else [round(timing, 2) for timing in v[:3]] # Display truncated version of timing windows + else [round(timing, 2) for timing in v[:3]] for v in self.__dict__.values()]) diff --git a/src/tja2fumen/utils.py b/src/tja2fumen/utils.py index 481eb21..bcc7e91 100644 --- a/src/tja2fumen/utils.py +++ b/src/tja2fumen/utils.py @@ -8,9 +8,9 @@ def read_struct(file, order, format_string, seek=None): Arguments: - file: The fumen's file object (presumably in 'rb' mode). - order: '<' or '>' (little or big endian). - - format_string: String made up of format characters that describes the data layout. - Full list of available format characters: - (https://docs.python.org/3/library/struct.html#format-characters) + - format_string: String made up of format characters that describes + the data layout. Full list of available characters: + (https://docs.python.org/3/library/struct.html#format-characters) - seek: The position of the read pointer to be used within the file. Return values: @@ -34,7 +34,3 @@ def write_struct(file, order, format_string, value_list, seek=None): file.seek(seek) packed_bytes = struct.pack(order + format_string, *value_list) file.write(packed_bytes) - - -def short_hex(number): - return hex(number)[2:] diff --git a/src/tja2fumen/writers.py b/src/tja2fumen/writers.py index 47445c4..34bfeaa 100644 --- a/src/tja2fumen/writers.py +++ b/src/tja2fumen/writers.py @@ -8,23 +8,33 @@ def write_fumen(path_out, song): for measure_number in range(len(song.measures)): measure = song.measures[measure_number] - measure_struct = [measure.bpm, measure.fumen_offset_start, int(measure.gogo), int(measure.barline)] - measure_struct.extend([measure.padding1] + measure.branch_info + [measure.padding2]) - write_struct(file, song.header.order, format_string="ffBBHiiiiiii", value_list=measure_struct) + measure_struct = ([measure.bpm, measure.offset_start, + int(measure.gogo), int(measure.barline), + measure.padding1] + measure.branch_info + + [measure.padding2]) + write_struct(file, song.header.order, + format_string="ffBBHiiiiiii", + value_list=measure_struct) for branch_number in range(len(BRANCH_NAMES)): branch = measure.branches[BRANCH_NAMES[branch_number]] branch_struct = [branch.length, branch.padding, branch.speed] - write_struct(file, song.header.order, format_string="HHf", value_list=branch_struct) + write_struct(file, song.header.order, + format_string="HHf", + value_list=branch_struct) for note_number in range(branch.length): note = branch.notes[note_number] - note_struct = [FUMEN_TYPE_NOTES[note.type], note.pos, note.item, note.padding] + note_struct = [FUMEN_TYPE_NOTES[note.type], note.pos, + note.item, note.padding] if note.hits: - note_struct.extend([note.hits, note.hits_padding, note.duration]) + extra_vals = [note.hits, note.hits_padding] else: - note_struct.extend([note.score_init, note.score_diff * 4, note.duration]) - write_struct(file, song.header.order, format_string="ififHHf", value_list=note_struct) + extra_vals = [note.score_init, note.score_diff * 4] + note_struct.extend(extra_vals + [note.duration]) + write_struct(file, song.header.order, + format_string="ififHHf", + value_list=note_struct) if note.type.lower() == "drumroll": file.write(note.drumroll_bytes) diff --git a/testing/conftest.py b/testing/conftest.py index 61603fa..0abec32 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -8,4 +8,3 @@ def pytest_addoption(parser): @pytest.fixture def entry_point(request): return request.config.getoption("--entry-point") - diff --git a/testing/test_conversion.py b/testing/test_conversion.py index dda6b27..f239a8b 100644 --- a/testing/test_conversion.py +++ b/testing/test_conversion.py @@ -12,14 +12,15 @@ from tja2fumen.constants import COURSE_IDS, NORMALIZE_COURSE @pytest.mark.parametrize('id_song', [ - pytest.param('shoto9', marks=pytest.mark.skip("TJA structure does not match fumen yet.")), + pytest.param('butou5'), + pytest.param('shoto9', + marks=pytest.mark.skip("TJA measures do not match fumen.")), pytest.param('genpe'), pytest.param('gimcho'), pytest.param('imcanz'), pytest.param('clsca'), pytest.param('linda'), pytest.param('senpac'), - pytest.param('butou5'), pytest.param('hol6po'), pytest.param('mikdp'), pytest.param('ia6cho'), @@ -43,14 +44,16 @@ def test_converted_tja_vs_cached_fumen(id_song, tmp_path, entry_point): elif entry_point == "python-cli": os.system(f"tja2fumen {path_tja_tmp}") elif entry_point == "exe": - exe_path = glob.glob(os.path.join(os.path.split(path_test)[0], "dist", "*.exe"))[0] + exe_path = glob.glob(os.path.join(os.path.split(path_test)[0], + "dist", "*.exe"))[0] os.system(f"{exe_path} {path_tja_tmp}") # Fetch output fumen paths paths_out = glob.glob(os.path.join(path_temp, "*.bin")) assert paths_out, f"No bin files generated in {path_temp}" order = "xmhne" # Ura Oni -> Oni -> Hard -> Normal -> Easy - paths_out = sorted(paths_out, key=lambda s: [order.index(c) if c in order else len(order) for c in s]) + paths_out = sorted(paths_out, key=lambda s: [order.index(c) if c in order + else len(order) for c in s]) # Extract cached fumen files to working directory path_binzip = os.path.join(path_test, "data", f"{id_song}.zip") @@ -62,99 +65,131 @@ def test_converted_tja_vs_cached_fumen(id_song, tmp_path, entry_point): for path_out in paths_out: # Difficulty introspection to help with debugging i_difficult_id = os.path.basename(path_out).split(".")[0].split("_")[1] - i_difficulty = NORMALIZE_COURSE[{v: k for k, v in COURSE_IDS.items()}[i_difficult_id]] # noqa + i_difficulty = NORMALIZE_COURSE[{v: k for k, v in # noqa F841 + COURSE_IDS.items()}[i_difficult_id]] # noqa # 0. Read fumen data (converted vs. cached) + path_out_fumen = os.path.join(path_bin, os.path.basename(path_out)) co_song = read_fumen(path_out, exclude_empty_measures=True) - ca_song = read_fumen(os.path.join(path_bin, os.path.basename(path_out)), exclude_empty_measures=True) + ca_song = read_fumen(path_out_fumen, exclude_empty_measures=True) # 1. Check song headers checkValidHeader(co_song.header) checkValidHeader(ca_song.header) - assert_song_property(co_song.header, ca_song.header, 'order') - assert_song_property(co_song.header, ca_song.header, 'b432_b435_has_branches') - assert_song_property(co_song.header, ca_song.header, 'b436_b439_hp_max') - assert_song_property(co_song.header, ca_song.header, 'b440_b443_hp_clear') - assert_song_property(co_song.header, ca_song.header, 'b444_b447_hp_gain_good', abs=1) - assert_song_property(co_song.header, ca_song.header, 'b448_b451_hp_gain_ok', abs=1) - assert_song_property(co_song.header, ca_song.header, 'b452_b455_hp_loss_bad', abs=1) - assert_song_property(co_song.header, ca_song.header, 'b456_b459_normal_normal_ratio') - assert_song_property(co_song.header, ca_song.header, 'b460_b463_normal_professional_ratio') - assert_song_property(co_song.header, ca_song.header, 'b464_b467_normal_master_ratio') - # NB: KAGEKIYO's branching condition is very unique (BIG only), which cannot be expressed in a TJA file - # So, skip checking the `branch_point` header values for KAGEKIYO. + for header_property in ['order', + 'b432_b435_has_branches', + 'b436_b439_hp_max', + 'b440_b443_hp_clear', + 'b444_b447_hp_gain_good', + 'b448_b451_hp_gain_ok', + 'b452_b455_hp_loss_bad', + 'b456_b459_normal_normal_ratio', + 'b460_b463_normal_professional_ratio', + 'b464_b467_normal_master_ratio']: + check(co_song.header, ca_song.header, header_property, abs=1) + # NB: KAGEKIYO's branching condition is very unique (BIG only), which + # cannot be expressed in a TJA file. So, we skip checking the + # `branch_point` header values for KAGEKIYO. if id_song != 'genpe': - assert_song_property(co_song.header, ca_song.header, 'b468_b471_branch_points_good') - assert_song_property(co_song.header, ca_song.header, 'b472_b475_branch_points_ok') - assert_song_property(co_song.header, ca_song.header, 'b476_b479_branch_points_bad') - assert_song_property(co_song.header, ca_song.header, 'b480_b483_branch_points_drumroll') - assert_song_property(co_song.header, ca_song.header, 'b484_b487_branch_points_good_big') - assert_song_property(co_song.header, ca_song.header, 'b488_b491_branch_points_ok_big') - assert_song_property(co_song.header, ca_song.header, 'b492_b495_branch_points_drumroll_big') - assert_song_property(co_song.header, ca_song.header, 'b496_b499_branch_points_balloon') - assert_song_property(co_song.header, ca_song.header, 'b500_b503_branch_points_kusudama') + for header_property in ['b468_b471_branch_points_good', + 'b472_b475_branch_points_ok', + 'b476_b479_branch_points_bad', + 'b480_b483_branch_points_drumroll', + 'b484_b487_branch_points_good_big', + 'b488_b491_branch_points_ok_big', + 'b492_b495_branch_points_drumroll_big', + 'b496_b499_branch_points_balloon', + 'b500_b503_branch_points_kusudama']: + check(co_song.header, ca_song.header, header_property) # 2. Check song metadata - assert_song_property(co_song, ca_song, 'score_init') - assert_song_property(co_song, ca_song, 'score_diff') + check(co_song, ca_song, 'score_init') + check(co_song, ca_song, 'score_diff') # 3. Check measure data - for i_measure in range(max([len(co_song.measures), len(ca_song.measures)])): - # NB: We could assert that len(measures) is the same for both songs, then iterate through zipped measures. - # But, if there is a mismatched number of measures, we want to know _where_ it occurs. So, we let the - # comparison go on using the max length of both songs until something else fails. + for i_measure in range(max([len(co_song.measures), + len(ca_song.measures)])): + # NB: We could assert that len(measures) is the same for both + # songs, then iterate through zipped measures. But, if there is a + # mismatched number of measures, we want to know _where_ it + # occurs. So, we let the comparison go on using the max length of + # both songs until something else fails. co_measure = co_song.measures[i_measure] ca_measure = ca_song.measures[i_measure] # 3a. Check measure metadata - assert_song_property(co_measure, ca_measure, 'bpm', i_measure, abs=0.01) - assert_song_property(co_measure, ca_measure, 'fumen_offset_start', i_measure, abs=0.15) - assert_song_property(co_measure, ca_measure, 'gogo', i_measure) - assert_song_property(co_measure, ca_measure, 'barline', i_measure) + check(co_measure, ca_measure, 'bpm', i_measure, abs=0.01) + check(co_measure, ca_measure, 'offset_start', i_measure, abs=0.15) + check(co_measure, ca_measure, 'gogo', i_measure) + check(co_measure, ca_measure, 'barline', i_measure) - # NB: KAGEKIYO's fumen has some strange details that can't be replicated using the TJA charting format. - # So, for now, we use a special case to skip checking A) notes for certain measures and B) branchInfo + # NB: KAGEKIYO's fumen has some strange details that can't be + # replicated using the TJA charting format. So, for now, we use a + # special case to skip checking: + # A) notes for certain measures and + # B) branchInfo if id_song == 'genpe': - # A) The 2/4 measures in the Ura of KAGEKIYO's official Ura fumen don't match the wikiwiki.jp/TJA - # charts. In the official fumen, the note ms offsets of branches 5/12/17/etc. go _past_ the duration of - # the measure. This behavior is impossible to represent using the TJA format, so we skip checking notes - # for these measures, since the rest of the measures have perfect note ms offsets anyway. - if i_difficult_id == "x" and i_measure in [5, 6, 12, 13, 17, 18, 26, 27, 46, 47, 51, 52, 56, 57]: + # A) The 2/4 measures in the Ura of KAGEKIYO's official Ura + # fumen don't match the wikiwiki.jp/TJA charts. In the official + # fumen, the note ms offsets of branches 5/12/17/etc. go _past_ + # the duration of the measure. This behavior is impossible to + # represent using the TJA format, so we skip checking notes + # for these measures, since the rest of the measures have + # perfect note ms offsets anyway. + if (i_difficult_id == "x" and + i_measure in [5, 6, 12, 13, 17, 18, 26, 27, + 46, 47, 51, 52, 56, 57]): continue - # B) The branching condition for KAGEKIYO is very strange (accuracy for the 7 big notes in the song) - # So, we only test the branchInfo bytes for non-KAGEKIYO songs: + # B) The branching condition for KAGEKIYO is very strange + # (accuracy for the 7 big notes in the song) So, we only test + # the branchInfo bytes for non-KAGEKIYO songs: else: - assert_song_property(co_measure, ca_measure, 'branch_info', i_measure) + check(co_measure, ca_measure, 'branch_info', i_measure) # 3b. Check measure notes for i_branch in ['normal', 'professional', 'master']: co_branch = co_measure.branches[i_branch] ca_branch = ca_measure.branches[i_branch] - # NB: We only check speed for non-empty branches, as fumens store speed changes even for empty branches + # NB: We only check speed for non-empty branches, as fumens + # store speed changes even for empty branches. if co_branch.length != 0: - assert_song_property(co_branch, ca_branch, 'speed', i_measure, i_branch) - # NB: We could assert that len(notes) is the same for both songs, then iterate through zipped notes. - # But, if there is a mismatched number of notes, we want to know _where_ it occurs. So, we let the - # comparison go on using the max length of both branches until something else fails. + check(co_branch, ca_branch, 'speed', i_measure, i_branch) + # NB: We could assert that len(notes) is the same for both + # songs, then iterate through zipped notes. But, if there is a + # mismatched number of notes, we want to know _where_ it + # occurs. So, we let the comparison go on using the max length + # of both branches until something else fails. for i_note in range(max([co_branch.length, ca_branch.length])): co_note = co_branch.notes[i_note] ca_note = ca_branch.notes[i_note] - assert_song_property(co_note, ca_note, 'note_type', i_measure, i_branch, i_note, func=normalize_type) - assert_song_property(co_note, ca_note, 'pos', i_measure, i_branch, i_note, abs=0.1) - # NB: Drumroll duration doesn't always end exactly on a beat. Plus, TJA charters often eyeball - # drumrolls, leading them to be often off by a 1/4th/8th/16th/32th/etc. These charting errors - # are fixable, but tedious to do when writing tests. So, I've added a try/except so that they - # can be checked locally with a breakpoint when adding new songs, but so that fixing every - # duration-related chart error isn't 100% mandatory. + check(co_note, ca_note, 'note_type', i_measure, + i_branch, i_note, func=normalize_type) + check(co_note, ca_note, 'pos', i_measure, + i_branch, i_note, abs=0.1) + # NB: Drumroll duration doesn't always end exactly on a + # beat. Plus, TJA charters often eyeball drumrolls, + # leading them to be often off by a 1/4th/8th/16th/etc. + # These charting errors are fixable, but tedious to do + # when writing tests. So, I've added a try/except so that + # they can be checked locally with a breakpoint when + # adding new songs, but so that fixing every + # duration-related chart error isn't 100% mandatory. try: - assert_song_property(co_note, ca_note, 'duration', i_measure, i_branch, i_note, abs=25.0) + check(co_note, ca_note, 'duration', i_measure, + i_branch, i_note, abs=25.0) except AssertionError: pass if ca_note.note_type not in ["Balloon", "Kusudama"]: - assert_song_property(co_note, ca_note, 'score_init', i_measure, i_branch, i_note) - assert_song_property(co_note, ca_note, 'score_diff', i_measure, i_branch, i_note) - # NB: 'item' still needs to be implemented: https://github.com/vivaria/tja2fumen/issues/17 - # assert_song_property(co_note, ca_note, 'item', i_measure, i_branch, i_note) + check(co_note, ca_note, 'score_init', i_measure, + i_branch, i_note) + check(co_note, ca_note, 'score_diff', i_measure, + i_branch, i_note) + # NB: 'item' still needs to be implemented: + # https://github.com/vivaria/tja2fumen/issues/17 + # check(co_note, ca_note, 'item', i_measure, + # i_branch, i_note) -def assert_song_property(converted_obj, cached_obj, prop, measure=None, branch=None, note=None, func=None, abs=None): - # NB: TJA parser/converter uses 0-based indexing, but TJA files use 1-based indexing. - # So, we increment 1 in the error message to more easily identify problematic lines in TJA files. +def check(converted_obj, cached_obj, prop, measure=None, + branch=None, note=None, func=None, abs=None): + # NB: TJA parser/converter uses 0-based indexing, but TJA files use + # 1-based indexing. So, we increment 1 in the error message to more easily + # identify problematic lines in TJA files. msg_failure = f"'{prop}' mismatch" msg_failure += f": measure '{measure+1}'" if measure is not None else "" msg_failure += f", branch '{branch}'" if branch is not None else "" @@ -193,4 +228,3 @@ def checkValidHeader(header): assert header.b492_b495_branch_points_drumroll_big in [1, 0] assert header.b496_b499_branch_points_balloon in [30, 0, 1] assert header.b500_b503_branch_points_kusudama in [30, 0] -