Update codebase to adhere to flake8
and add linting check (#51)
This commit is contained in:
parent
e1cd6f385d
commit
356bb7b036
@ -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
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,4 +6,4 @@ dist*
|
||||
*.exe
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
__pycache__
|
||||
|
@ -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.
|
||||
SOFTWARE.
|
||||
|
@ -1 +1 @@
|
||||
include src/tja2fumen/hp_values.csv
|
||||
include src/tja2fumen/hp_values.csv
|
||||
|
@ -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"
|
||||
console_output_style = "count"
|
||||
|
||||
[tool.flake8]
|
||||
exclude = "venv/"
|
||||
per-file-ignores = """
|
||||
./src/tja2fumen/types.py: E221
|
||||
./testing/test_conversion.py: E221, E272
|
||||
"""
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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': <padding> (represented by one unsigned short (2 bytes))
|
||||
# - 'iiiiii': branch_info (represented by six integers (24 bytes))
|
||||
# - 'i': <padding> (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': <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],
|
||||
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': <padding> (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': <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 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
|
||||
|
@ -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", raw_bytes[512:516])[0]:
|
||||
def up(self, raw_bytes, type_string, s=None, e=None):
|
||||
if s is not None and e is not None:
|
||||
raw_bytes = raw_bytes[s:e+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):
|
||||
self.order = ''
|
||||
if (self.up(raw_bytes, ">I", 512, 515) <
|
||||
self.up(raw_bytes, "<I", 512, 515)):
|
||||
return ">"
|
||||
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()])
|
||||
|
@ -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:]
|
||||
|
@ -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)
|
||||
|
@ -8,4 +8,3 @@ def pytest_addoption(parser):
|
||||
@pytest.fixture
|
||||
def entry_point(request):
|
||||
return request.config.getoption("--entry-point")
|
||||
|
||||
|
@ -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]
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user