1
0
mirror of synced 2025-02-25 22:28:08 +01:00

Make project pylint-compliant (and add pylint check to test suite) (#59)

This commit is contained in:
Viv 2023-07-30 18:49:58 -04:00 committed by GitHub
parent 5229db4aab
commit 387cbbcdfa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 251 additions and 228 deletions

View File

@ -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

View File

@ -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
"""

View File

@ -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,

View File

@ -1,3 +1,7 @@
"""
Constant song properties of TJA and fumen files.
"""
# Names for branches in diverge songs
BRANCH_NAMES = ("normal", "professional", "master")

View File

@ -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']:

View File

@ -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': <padding> (one unsigned short (2 bytes))
# - 'iiiiii': branch_info (six integers (24 bytes))
# - 'i': <padding> (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': <padding> ( 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': <padding> (one unsigned short (2 bytes))
# - 'iiiiii': branch_info (six integers (24 bytes))
# - 'i': <padding> (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': <padding>
# - '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': <padding> ( 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': <padding>
# - '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

View File

@ -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, "<I", 512, 515)):
return ">"
if (self.unp(raw_bytes, ">I", 512, 515) <
self.unp(raw_bytes, "<I", 512, 515)):
self.order = ">"
else:
return "<"
self.order = "<"
def set_hp_bytes(self, n_notes: int, difficulty: str,
stars: int) -> 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

View File

@ -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:

View File

@ -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]