From 387cbbcdfaf145db98c11f39bdb0ed724f038362 Mon Sep 17 00:00:00 2001 From: Viv Date: Sun, 30 Jul 2023 18:49:58 -0400 Subject: [PATCH] Make project `pylint`-compliant (and add `pylint` check to test suite) (#59) --- .github/workflows/check_code_quality.yml | 5 +- pyproject.toml | 11 +- src/tja2fumen/__init__.py | 6 +- src/tja2fumen/constants.py | 4 + src/tja2fumen/converters.py | 63 ++++---- src/tja2fumen/parsers.py | 189 ++++++++++++----------- src/tja2fumen/types.py | 131 ++++++++-------- src/tja2fumen/writers.py | 14 +- testing/test_conversion.py | 56 +++---- 9 files changed, 251 insertions(+), 228 deletions(-) diff --git a/.github/workflows/check_code_quality.yml b/.github/workflows/check_code_quality.yml index 49ea761..ccbedcf 100644 --- a/.github/workflows/check_code_quality.yml +++ b/.github/workflows/check_code_quality.yml @@ -25,8 +25,11 @@ jobs: run: | pip install -e .[dev] - - name: Lint project using flake8 + - name: Simple linting using flake8 run: pflake8 + - name: Strict linting using pylint + run: pylint src + - name: Type analysis using mypy run: mypy src --strict diff --git a/pyproject.toml b/pyproject.toml index aa771b5..4fd04c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ tja2fumen = "tja2fumen:main" [project.optional-dependencies] dev = ["pytest", "build", "pyinstaller", "twine", "toml-cli", - "flake8", "pyproject-flake8", "mypy"] + "flake8", "pyproject-flake8", "mypy", "pylint"] [tool.setuptools.packages.find] where = ["src"] @@ -34,3 +34,12 @@ per-file-ignores = """ ./src/tja2fumen/types.py: E221 ./testing/test_conversion.py: E221, E272 """ + +[tool.pylint.'MESSAGES CONTROL'] +disable = """ + too-many-instance-attributes, + too-many-branches, + too-many-arguments, + too-many-locals, + too-many-statements +""" diff --git a/src/tja2fumen/__init__.py b/src/tja2fumen/__init__.py index a6edd0d..5e83b58 100644 --- a/src/tja2fumen/__init__.py +++ b/src/tja2fumen/__init__.py @@ -1,3 +1,7 @@ +""" +Entry points for tja2fumen. +""" + import argparse import os import sys @@ -39,7 +43,7 @@ def main(argv: Sequence[str] = ()) -> None: # Convert parsed TJA courses and write each course to `.bin` files for course_name, course in parsed_tja.courses.items(): convert_and_write(course, course_name, base_name, - single_course=(len(parsed_tja.courses) == 1)) + single_course=len(parsed_tja.courses) == 1) def convert_and_write(tja_data: TJACourse, diff --git a/src/tja2fumen/constants.py b/src/tja2fumen/constants.py index b35f8c1..877b631 100644 --- a/src/tja2fumen/constants.py +++ b/src/tja2fumen/constants.py @@ -1,3 +1,7 @@ +""" +Constant song properties of TJA and fumen files. +""" + # Names for branches in diverge songs BRANCH_NAMES = ("normal", "professional", "master") diff --git a/src/tja2fumen/converters.py b/src/tja2fumen/converters.py index 8e38ade..39e4e75 100644 --- a/src/tja2fumen/converters.py +++ b/src/tja2fumen/converters.py @@ -1,3 +1,7 @@ +""" +Functions for converting TJA song data to Fumen song data. +""" + import re from tja2fumen.types import (TJACourse, TJAMeasureProcessed, @@ -27,7 +31,7 @@ def process_tja_commands(tja: TJACourse) \ branch_name: [] for branch_name in tja.branches.keys() } for branch_name, branch_measures_tja in tja.branches.items(): - current_bpm = tja.BPM + current_bpm = tja.bpm current_scroll = 1.0 current_gogo = False current_barline = True @@ -92,7 +96,7 @@ def process_tja_commands(tja: TJACourse) \ # - 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) + setattr(measure_tja_processed, data.name, new_val) # - Case 2: Command happens in the middle of a measure; # start a new sub-measure else: @@ -117,14 +121,7 @@ def process_tja_commands(tja: TJACourse) \ has_branches = all(len(b) for b in tja_branches_processed.values()) if has_branches: - 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: + if len({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 " @@ -182,13 +179,13 @@ def convert_tja_to_fumen(tja: TJACourse) -> FumenCourse: # 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()] + 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_tja in tja_branches_processed.items(): - if not len(branch_tja): + if not branch_tja: continue branch_points_total = 0 branch_points_measure = 0 @@ -242,8 +239,6 @@ def convert_tja_to_fumen(tja: TJACourse) -> FumenCourse: measure_fumen.set_branch_info( branch_type, branch_cond, branch_points_total, current_branch, - first_branch_condition=(not branch_conditions), - has_section=bool(measure_tja.section), has_levelhold=current_levelhold ) # Reset the points to prepare for the next `#BRANCHSTART p` @@ -274,11 +269,11 @@ def convert_tja_to_fumen(tja: TJACourse) -> FumenCourse: # Create notes based on TJA measure data branch_points_measure = 0 - for idx_d, data in enumerate(measure_tja.data): + for data in measure_tja.data: # Compute the ms position of the note pos_ratio = ((data.pos - measure_tja.pos_start) / (measure_tja.pos_end - measure_tja.pos_start)) - note_pos = (measure_fumen.duration * pos_ratio) + note_pos = measure_fumen.duration * pos_ratio # Handle '8' notes (end of a drumroll/balloon) if data.value == "EndDRB": @@ -320,9 +315,9 @@ def convert_tja_to_fumen(tja: TJACourse) -> FumenCourse: if note.note_type in ["Balloon", "Kusudama"]: try: note.hits = course_balloons.pop(0) - except IndexError: + except IndexError as exc: raise ValueError(f"Not enough values for 'BALLOON: " - f"{course_balloons}'") + f"{course_balloons}'") from exc current_drumroll = note elif note.note_type in ["Drumroll", "DRUMROLL"]: current_drumroll = note @@ -334,13 +329,13 @@ def convert_tja_to_fumen(tja: TJACourse) -> FumenCourse: # Track branch points (to later compute `#BRANCHSTART p` vals) if note.note_type in ['Don', 'Ka']: - pts_to_add = fumen.header.b468_b471_branch_points_good + pts_to_add = fumen.header.b468_b471_branch_pts_good elif note.note_type in ['DON', 'KA']: - pts_to_add = fumen.header.b484_b487_branch_points_good_big + pts_to_add = fumen.header.b484_b487_branch_pts_good_big elif note.note_type == 'Balloon': - pts_to_add = fumen.header.b496_b499_branch_points_balloon + pts_to_add = fumen.header.b496_b499_branch_pts_balloon elif note.note_type == 'Kusudama': - pts_to_add = fumen.header.b500_b503_branch_points_kusudama + pts_to_add = fumen.header.b500_b503_branch_pts_kusudama else: pts_to_add = 0 # Drumrolls not relevant for `p` conditions branch_points_measure += pts_to_add @@ -366,29 +361,29 @@ def convert_tja_to_fumen(tja: TJACourse) -> FumenCourse: # 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_types != [] and branch_conditions != [] and all([ + drumroll_only = branch_types and branch_conditions and all( (branch_type == 'r') or (branch_type == 'p' and cond[0] == 0.0 and cond[1] == 0.0) or (branch_type == 'p' and cond[0] > 1.00 and cond[1] > 1.00) for branch_type, cond in zip(branch_types, 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 + fumen.header.b468_b471_branch_pts_good = 0 + fumen.header.b484_b487_branch_pts_good_big = 0 + fumen.header.b472_b475_branch_pts_ok = 0 + fumen.header.b488_b491_branch_pts_ok_big = 0 + fumen.header.b496_b499_branch_pts_balloon = 0 + fumen.header.b500_b503_branch_pts_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_types != [] and all([ + percentage_only = branch_types and all( (branch_type != 'r') for branch_type in branch_types - ]) + ) if percentage_only: - fumen.header.b480_b483_branch_points_drumroll = 0 - fumen.header.b492_b495_branch_points_drumroll_big = 0 + fumen.header.b480_b483_branch_pts_drumroll = 0 + fumen.header.b492_b495_branch_pts_drumroll_big = 0 # Compute the ratio between normal and professional/master branches if total_notes['professional']: diff --git a/src/tja2fumen/parsers.py b/src/tja2fumen/parsers.py index 5f4a806..3aa24ed 100644 --- a/src/tja2fumen/parsers.py +++ b/src/tja2fumen/parsers.py @@ -1,3 +1,7 @@ +""" +Functions for parsing TJA files (.tja) and Fumen files (.bin) +""" + import os import re import struct @@ -19,9 +23,11 @@ from tja2fumen.constants import (NORMALIZE_COURSE, COURSE_NAMES, BRANCH_NAMES, def parse_tja(fname_tja: str) -> TJASong: """Read in lines of a .tja file and load them into a TJASong object.""" try: - tja_text = open(fname_tja, "r", encoding="utf-8-sig").read() + with open(fname_tja, "r", encoding="utf-8-sig") as tja_file: + tja_text = tja_file.read() except UnicodeDecodeError: - tja_text = open(fname_tja, "r", encoding="shift-jis").read() + with open(fname_tja, "r", encoding="shift-jis") as tja_file: + tja_text = tja_file.read() tja_lines = [line for line in tja_text.splitlines() if line.strip() != ''] tja = split_tja_lines_into_courses(tja_lines) @@ -67,9 +73,9 @@ def split_tja_lines_into_courses(lines: list[str]) -> TJASong: offset = float([line.split(":")[1] for line in lines if line.startswith("OFFSET")][0]) parsed_tja = TJASong( - BPM=bpm, + bpm=bpm, offset=offset, - courses={course: TJACourse(BPM=bpm, offset=offset, course=course) + courses={course: TJACourse(bpm=bpm, offset=offset, course=course) for course in TJA_COURSE_NAMES} ) @@ -87,7 +93,7 @@ def split_tja_lines_into_courses(lines: list[str]) -> TJASong: # Course-specific metadata fields if name_upper == 'COURSE': - if value not in NORMALIZE_COURSE.keys(): + if value not in NORMALIZE_COURSE: raise ValueError(f"Invalid COURSE value: '{value}'") current_course = NORMALIZE_COURSE[value] current_course_basename = current_course @@ -125,7 +131,7 @@ def split_tja_lines_into_courses(lines: list[str]) -> TJASong: current_course = current_course_basename + value parsed_tja.courses[current_course] = \ deepcopy(parsed_tja.courses[current_course_basename]) - parsed_tja.courses[current_course].data = list() + parsed_tja.courses[current_course].data = [] elif value: raise ValueError(f"Invalid value '{value}' for #START.") @@ -275,7 +281,7 @@ def parse_tja_course_data(course: TJACourse) -> None: # 3. Parse commands that don't create an event # (e.g. simply changing the current branch) else: - if command == 'START' or command == 'END': + if command in ('START', 'END'): current_branch = 'all' if has_branches else 'normal' elif command == 'N': current_branch = 'normal' @@ -318,7 +324,7 @@ def parse_tja_course_data(course: TJACourse) -> None: # Ensure all branches have the same number of measures if has_branches: - if len(set([len(b) for b in course.branches.values()])) != 1: + if len({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 " @@ -357,106 +363,103 @@ def parse_fumen(fumen_file: str, ├─ FumenMeasure └─ ... """ - file = open(fumen_file, "rb") - size = os.fstat(file.fileno()).st_size + with open(fumen_file, "rb") as file: + size = os.fstat(file.fileno()).st_size - header = FumenHeader() - header.parse_header_values(file.read(520)) - song = FumenCourse(header=header) + header = FumenHeader() + header.parse_header_values(file.read(520)) + song = FumenCourse(header=header) - 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 (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], - offset_start=measure_struct[1], - gogo=bool(measure_struct[2]), - barline=bool(measure_struct[3]), - padding1=measure_struct[4], - branch_info=list(measure_struct[5:11]), - padding2=measure_struct[11] - ) - - # Iterate through the three branch types - for branch_name in BRANCH_NAMES: + for _ in range(song.header.b512_b515_number_of_measures): # Parse the measure data using the following `format_string`: - # "HHf" (3 format characters, 8 bytes per branch) - # - '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") + # "ffBBHiiiiiii" (12 format characters, 40 bytes per measure) + # - '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 branch dictionary using the newly-parsed branch data - total_notes = branch_struct[0] - branch = FumenBranch( - length=total_notes, - padding=branch_struct[1], - speed=branch_struct[2], + # Create the measure dictionary using the newly-parsed measure data + measure = FumenMeasure( + bpm=measure_struct[0], + offset_start=measure_struct[1], + gogo=bool(measure_struct[2]), + barline=bool(measure_struct[3]), + padding1=measure_struct[4], + branch_info=list(measure_struct[5:11]), + padding2=measure_struct[11] ) - # Iterate through each note in the measure (per branch) - for note_number in range(total_notes): - # Parse the note data using the following `format_string`: - # "ififHHf" (7 format characters, 24 bytes per note cluster) - # - 'i': note type - # - 'f': note position - # - 'i': item - # - 'f': - # - 'H': score_init - # - '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") + # Iterate through the three branch types + 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 ( 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 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], - item=note_struct[2], - padding=note_struct[3], + # Create the branch dictionary using newly-parsed branch data + total_notes = branch_struct[0] + branch = FumenBranch( + length=total_notes, + padding=branch_struct[1], + speed=branch_struct[2], ) - if note_type == 0xa or note_type == 0xc: - # Balloon hits - note.hits = note_struct[4] - note.hits_padding = note_struct[5] - else: - song.score_init = note.score_init = note_struct[4] - song.score_diff = note.score_diff = note_struct[5] // 4 + # Iterate through each note in the measure (per branch) + for _ in range(total_notes): + # Parse the note data using the following `format_string`: + # "ififHHf" (7 format characters, 24b per note cluster) + # - 'i': note type + # - 'f': note position + # - 'i': item + # - 'f': + # - 'H': score_init + # - 'H': score_diff + # - 'f': duration + note_struct = read_struct(file, song.header.order, + format_string="ififHHf") - # Drumroll/balloon duration - note.duration = note_struct[6] + # Create the note dictionary using newly-parsed note data + note_type = note_struct[0] + note = FumenNote( + note_type=FUMEN_NOTE_TYPES[note_type], + pos=note_struct[1], + item=note_struct[2], + padding=note_struct[3], + ) - # 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) + if note_type in (0xa, 0xc): + # Balloon hits + note.hits = note_struct[4] + note.hits_padding = note_struct[5] + else: + song.score_init = note.score_init = note_struct[4] + song.score_diff = note.score_diff = note_struct[5] // 4 - # Assign the note to the branch - branch.notes.append(note) + # Drumroll/balloon duration + note.duration = note_struct[6] - # Assign the branch to the measure - measure.branches[branch_name] = branch + # Account for padding at the end of drumrolls + if note_type in (0x6, 0x9, 0x62): + note.drumroll_bytes = file.read(8) - # Assign the measure to the song - song.measures.append(measure) - if file.tell() >= size: - break + # Assign the note to the branch + branch.notes.append(note) - file.close() + # Assign the branch to the measure + measure.branches[branch_name] = branch + + # Assign the measure to the song + song.measures.append(measure) + if file.tell() >= size: + break # 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 diff --git a/src/tja2fumen/types.py b/src/tja2fumen/types.py index 376bde2..8f14f40 100644 --- a/src/tja2fumen/types.py +++ b/src/tja2fumen/types.py @@ -1,3 +1,7 @@ +""" +Dataclasses used to represent song courses, branches, measures, and notes. +""" + import csv import os import struct @@ -28,7 +32,7 @@ class TJAMeasure: @dataclass(slots=True) class TJACourse: """Contains all the data in a single TJA `COURSE:` section.""" - BPM: float + bpm: float offset: float course: str level: int = 0 @@ -44,7 +48,7 @@ class TJACourse: @dataclass(slots=True) class TJASong: """Contains all the data in a single TJA (`.tja`) chart file.""" - BPM: float + bpm: float offset: float courses: dict[str, TJACourse] @@ -123,7 +127,7 @@ class FumenMeasure: subdivisions: int) -> None: """Compute the millisecond duration of the measure.""" # First, we compute the duration for a full 4/4 measure. - full_duration = (4 * 60_000 / self.bpm) + full_duration = 4 * 60_000 / self.bpm # Next, we adjust this duration based on both: # 1. The *actual* measure size (e.g. #MEASURE 1/8, 5/4, etc.) # 2. Whether this is a "submeasure" (i.e. whether it contains @@ -136,11 +140,12 @@ class FumenMeasure: 1.0 if subdivisions == 0.0 # Avoid DivisionByZeroErrors else (measure_length / subdivisions) ) - self.duration = (full_duration * measure_size * measure_ratio) + self.duration = full_duration * measure_size * measure_ratio def set_first_ms_offsets(self, song_offset: float) -> None: + """Compute the ms offsets for the start/end of the first measure.""" # First, start with song's OFFSET: metadata - self.offset_start = (song_offset * -1 * 1000) # s -> ms + self.offset_start = song_offset * -1 * 1000 # s -> ms # Then, subtract a full 4/4 measure for the current BPM self.offset_start -= (4 * 60_000 / self.bpm) # Compute the end offset by adding the duration to the start offset @@ -149,7 +154,7 @@ class FumenMeasure: def set_ms_offsets(self, delay: float, prev_measure: 'FumenMeasure') -> None: - """Compute the millisecond offsets for the start/end of the measure.""" + """Compute the ms offsets for the start/end of a given measure.""" # First, start with the end timing of the previous measure self.offset_start = prev_measure.offset_end # Add any #DELAY commands @@ -166,8 +171,6 @@ class FumenMeasure: branch_cond: tuple[float, float], branch_points_total: int, current_branch: str, - first_branch_condition: bool, - has_section: bool, has_levelhold: bool) -> None: """Compute the values that represent branching/diverge conditions.""" # If levelhold is set, force the branch to stay the same, @@ -208,7 +211,6 @@ class FumenMeasure: # has a #SECTION command to reset the accuracy. # 3. It's not the first branching condition, and it # doesn't have a #SECTION command. - # TODO: Determine the behavior for these 3 conditions elif branch_type == 'r': vals = [int(v) for v in branch_cond] if current_branch == 'normal': @@ -234,78 +236,78 @@ class FumenHeader: b456_b459_normal_normal_ratio: int = 65536 b460_b463_normal_professional_ratio: int = 65536 b464_b467_normal_master_ratio: int = 65536 - b468_b471_branch_points_good: int = 20 - b472_b475_branch_points_ok: int = 10 - b476_b479_branch_points_bad: int = 0 - b480_b483_branch_points_drumroll: int = 1 - b484_b487_branch_points_good_big: int = 20 - b488_b491_branch_points_ok_big: int = 10 - b492_b495_branch_points_drumroll_big: int = 1 - b496_b499_branch_points_balloon: int = 30 - b500_b503_branch_points_kusudama: int = 30 - b504_b507_branch_points_unknown: int = 20 + b468_b471_branch_pts_good: int = 20 + b472_b475_branch_pts_ok: int = 10 + b476_b479_branch_pts_bad: int = 0 + b480_b483_branch_pts_drumroll: int = 1 + b484_b487_branch_pts_good_big: int = 20 + b488_b491_branch_pts_ok_big: int = 10 + b492_b495_branch_pts_drumroll_big: int = 1 + b496_b499_branch_pts_balloon: int = 30 + b500_b503_branch_pts_kusudama: int = 30 + b504_b507_branch_pts_unknown: int = 20 b508_b511_dummy_data: int = 12345678 b512_b515_number_of_measures: int = 0 b516_b519_unknown_data: int = 0 def parse_header_values(self, raw_bytes: bytes) -> None: """Parse a raw string of 520 bytes to get the header values.""" - self.order = self._parse_order(raw_bytes) - rb = raw_bytes # We use a shortened form just for visual clarity: - 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) + self._parse_order(raw_bytes) + raw = raw_bytes # We use a shortened form just for visual clarity: + self.b000_b431_timing_windows = self.unp(raw, "f"*108, 0, 431) + self.b432_b435_has_branches = self.unp(raw, "i", 432, 435) + self.b436_b439_hp_max = self.unp(raw, "i", 436, 439) + self.b440_b443_hp_clear = self.unp(raw, "i", 440, 443) + self.b444_b447_hp_gain_good = self.unp(raw, "i", 444, 447) + self.b448_b451_hp_gain_ok = self.unp(raw, "i", 448, 451) + self.b452_b455_hp_loss_bad = self.unp(raw, "i", 452, 455) + self.b456_b459_normal_normal_ratio = self.unp(raw, "i", 456, 459) + self.b460_b463_normal_professional_ratio = self.unp(raw, "i", 460, 463) + self.b464_b467_normal_master_ratio = self.unp(raw, "i", 464, 467) + self.b468_b471_branch_pts_good = self.unp(raw, "i", 468, 471) + self.b472_b475_branch_pts_ok = self.unp(raw, "i", 472, 475) + self.b476_b479_branch_pts_bad = self.unp(raw, "i", 476, 479) + self.b480_b483_branch_pts_drumroll = self.unp(raw, "i", 480, 483) + self.b484_b487_branch_pts_good_big = self.unp(raw, "i", 484, 487) + self.b488_b491_branch_pts_ok_big = self.unp(raw, "i", 488, 491) + self.b492_b495_branch_pts_drumroll_big = self.unp(raw, "i", 492, 495) + self.b496_b499_branch_pts_balloon = self.unp(raw, "i", 496, 499) + self.b500_b503_branch_pts_kusudama = self.unp(raw, "i", 500, 503) + self.b504_b507_branch_pts_unknown = self.unp(raw, "i", 504, 507) + self.b508_b511_dummy_data = self.unp(raw, "i", 508, 511) + self.b512_b515_number_of_measures = self.unp(raw, "i", 512, 515) + self.b516_b519_unknown_data = self.unp(raw, "i", 516, 519) - def up(self, raw_bytes: bytes, type_string: str, - s: Optional[int] = None, e: Optional[int] = None) -> Any: + def unp(self, raw_bytes: bytes, type_string: str, + start: Optional[int] = None, end: Optional[int] = None) -> Any: """Unpack a raw byte string according to specific types.""" - if s is not None and e is not None: - raw_bytes = raw_bytes[s:e+1] + if start is not None and end is not None: + raw_bytes = raw_bytes[start:end+1] vals = struct.unpack(self.order + type_string, raw_bytes) return vals[0] if len(vals) == 1 else vals - def _parse_order(self, raw_bytes: bytes) -> str: + def _parse_order(self, raw_bytes: bytes) -> None: """Parse the order of the song (little or big endian).""" self.order = '' # Bytes 512-515 are the number of measures. We check the values using # both little and big endian, then compare to see which is correct. - if (self.up(raw_bytes, ">I", 512, 515) < - self.up(raw_bytes, "" + if (self.unp(raw_bytes, ">I", 512, 515) < + self.unp(raw_bytes, " None: """Compute header bytes related to the soul gauge (HP) behavior.""" # Note: Ura Oni is equivalent to Oni for soul gauge behavior difficulty = 'Oni' if difficulty in ['Ura', 'Edit'] else difficulty - self._get_hp_from_LUTs(n_notes, difficulty, stars) + self._get_hp_from_lookup_tables(n_notes, difficulty, stars) self.b440_b443_hp_clear = {'Easy': 6000, 'Normal': 7000, 'Hard': 7000, 'Oni': 8000}[difficulty] - def _get_hp_from_LUTs(self, n_notes: int, difficulty: str, - stars: int) -> None: + def _get_hp_from_lookup_tables(self, n_notes: int, difficulty: str, + stars: int) -> None: """Fetch pre-computed soul gauge values from lookup tables (LUTs).""" if not 0 < n_notes <= 2500: return @@ -321,8 +323,9 @@ class FumenHeader: } 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 fp: - for num, line in enumerate(csv.DictReader(fp)): + with open(os.path.join(pkg_dir, "hp_values.csv"), + newline='', encoding="utf-8") as csv_file: + for num, line in enumerate(csv.DictReader(csv_file)): if num+1 == n_notes: self.b444_b447_hp_gain_good = int(line[f"good_{key}"]) self.b448_b451_hp_gain_ok = int(line[f"ok_{key}"]) @@ -334,14 +337,14 @@ class FumenHeader: """Represent the header values as a string of raw bytes.""" value_list = [] format_string = self.order - for f in fields(self): - if f.name in ["order", "_raw_bytes"]: + for byte_field in fields(self): + if byte_field.name in ["order", "_raw_bytes"]: pass - elif f.name == "b000_b431_timing_windows": - value_list.extend(list(getattr(self, f.name))) - format_string += "f" * len(getattr(self, f.name)) + elif byte_field.name == "b000_b431_timing_windows": + value_list.extend(list(getattr(self, byte_field.name))) + format_string += "f" * len(getattr(self, byte_field.name)) else: - value_list.append(getattr(self, f.name)) + value_list.append(getattr(self, byte_field.name)) format_string += "i" raw_bytes = struct.pack(format_string, *value_list) assert len(raw_bytes) == 520 diff --git a/src/tja2fumen/writers.py b/src/tja2fumen/writers.py index 54f8884..9d75d4c 100644 --- a/src/tja2fumen/writers.py +++ b/src/tja2fumen/writers.py @@ -1,3 +1,7 @@ +""" +Functions for writing song data to fumen files (.bin) +""" + import struct from typing import BinaryIO, Any @@ -15,8 +19,7 @@ def write_fumen(path_out: str, song: FumenCourse) -> None: with open(path_out, "wb") as file: file.write(song.header.raw_bytes) - for measure_number in range(len(song.measures)): - measure = song.measures[measure_number] + for measure in song.measures: measure_struct = ([measure.bpm, measure.offset_start, int(measure.gogo), int(measure.barline), measure.padding1] + measure.branch_info + @@ -25,15 +28,14 @@ def write_fumen(path_out: str, song: FumenCourse) -> None: format_string="ffBBHiiiiiii", value_list=measure_struct) - for branch_number in range(len(BRANCH_NAMES)): - branch = measure.branches[BRANCH_NAMES[branch_number]] + for branch_name in BRANCH_NAMES: + branch = measure.branches[branch_name] branch_struct = [branch.length, branch.padding, branch.speed] 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] + for note in branch.notes: note_struct = [FUMEN_TYPE_NOTES[note.note_type], note.pos, note.item, note.padding] if note.hits: diff --git a/testing/test_conversion.py b/testing/test_conversion.py index f14969b..3cf15e8 100644 --- a/testing/test_conversion.py +++ b/testing/test_conversion.py @@ -79,15 +79,15 @@ def test_converted_tja_vs_cached_fumen(id_song, tmp_path, entry_point): # cannot be expressed in a TJA file. So, we skip checking the # `branch_point` header values for KAGEKIYO. if id_song != 'genpe': - 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']: + for header_property in ['b468_b471_branch_pts_good', + 'b472_b475_branch_pts_ok', + 'b476_b479_branch_pts_bad', + 'b480_b483_branch_pts_drumroll', + 'b484_b487_branch_pts_good_big', + 'b488_b491_branch_pts_ok_big', + 'b492_b495_branch_pts_drumroll_big', + 'b496_b499_branch_pts_balloon', + 'b500_b503_branch_pts_kusudama']: check(co_song.header, ca_song.header, header_property) # 2. Check song metadata check(co_song, ca_song, 'score_init') @@ -199,22 +199,22 @@ def normalize_type(note_type): def checkValidHeader(header): - assert len(header.raw_bytes) == 520 - assert header.b432_b435_has_branches in [0, 1] - assert header.b436_b439_hp_max == 10000 - assert header.b440_b443_hp_clear in [6000, 7000, 8000] - assert 10 <= header.b444_b447_hp_gain_good <= 1020 - assert 5 <= header.b448_b451_hp_gain_ok <= 1020 - assert -765 <= header.b452_b455_hp_loss_bad <= -20 - assert header.b456_b459_normal_normal_ratio <= 65536 - assert header.b460_b463_normal_professional_ratio <= 65536 - assert header.b464_b467_normal_master_ratio <= 65536 - assert header.b468_b471_branch_points_good in [20, 0, 1, 2] - assert header.b472_b475_branch_points_ok in [10, 0, 1] - assert header.b476_b479_branch_points_bad == 0 - assert header.b480_b483_branch_points_drumroll in [1, 0] - assert header.b484_b487_branch_points_good_big in [20, 0, 1, 2] - assert header.b488_b491_branch_points_ok_big in [10, 0, 1] - 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] + assert len(header.raw_bytes) == 520 + assert header.b432_b435_has_branches in [0, 1] + assert header.b436_b439_hp_max == 10000 + assert header.b440_b443_hp_clear in [6000, 7000, 8000] + assert 10 <= header.b444_b447_hp_gain_good <= 1020 + assert 5 <= header.b448_b451_hp_gain_ok <= 1020 + assert -765 <= header.b452_b455_hp_loss_bad <= -20 + assert header.b456_b459_normal_normal_ratio <= 65536 + assert header.b460_b463_normal_professional_ratio <= 65536 + assert header.b464_b467_normal_master_ratio <= 65536 + assert header.b468_b471_branch_pts_good in [20, 0, 1, 2] + assert header.b472_b475_branch_pts_ok in [10, 0, 1] + assert header.b476_b479_branch_pts_bad == 0 + assert header.b480_b483_branch_pts_drumroll in [1, 0] + assert header.b484_b487_branch_pts_good_big in [20, 0, 1, 2] + assert header.b488_b491_branch_pts_ok_big in [10, 0, 1] + assert header.b492_b495_branch_pts_drumroll_big in [1, 0] + assert header.b496_b499_branch_pts_balloon in [30, 0, 1] + assert header.b500_b503_branch_pts_kusudama in [30, 0]