1
0
mirror of synced 2024-11-27 22:40:49 +01:00

Fix variable case (camelCase -> snake_case + CONST_CASE) (#47)

Wouldn't have been possible without
https://gist.github.com/Mizzlr/eec29687704aa81bf61dfccda36ddb8c.

Fixes #8 .
This commit is contained in:
Viv 2023-07-19 21:50:09 -04:00 committed by GitHub
parent e5fcc4d497
commit 48718b2303
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 339 additions and 341 deletions

View File

@ -2,9 +2,9 @@ import argparse
import os
import sys
from tja2fumen.parsers import parseTJA
from tja2fumen.writers import writeFumen
from tja2fumen.converters import convertTJAToFumen
from tja2fumen.parsers import parse_tja
from tja2fumen.writers import write_fumen
from tja2fumen.converters import convert_tja_to_fumen
from tja2fumen.constants import COURSE_IDS
@ -20,30 +20,30 @@ def main(argv=None):
help="Path to a Taiko no Tatsujin TJA file.",
)
args = parser.parse_args(argv)
fnameTJA = getattr(args, "file.tja")
baseName = os.path.splitext(fnameTJA)[0]
fname_tja = getattr(args, "file.tja")
base_name = os.path.splitext(fname_tja)[0]
# Parse lines in TJA file
parsedTJA = parseTJA(fnameTJA)
parsed_tja = parse_tja(fname_tja)
# Convert parsed TJA courses to Fumen data, and write each course to `.bin` files
for course in parsedTJA.courses.items():
convert_and_write(course, baseName, singleCourse=(len(parsedTJA.courses) == 1))
for course in parsed_tja.courses.items():
convert_and_write(course, base_name, single_course=(len(parsed_tja.courses) == 1))
def convert_and_write(parsedCourse, baseName, singleCourse=False):
courseName, tjaData = parsedCourse
fumenData = convertTJAToFumen(tjaData)
def convert_and_write(parsed_course, base_name, single_course=False):
course_name, tja_data = parsed_course
fumen_data = convert_tja_to_fumen(tja_data)
# Add course ID (e.g. '_x', '_x_1', '_x_2') to the output file's base name
outputName = baseName
if singleCourse:
output_name = base_name
if single_course:
pass # Replicate tja2bin.exe behavior by excluding course ID if there's only one course
else:
splitName = courseName.split("P") # e.g. 'OniP2' -> ['Oni', '2'], 'Oni' -> ['Oni']
outputName += f"_{COURSE_IDS[splitName[0]]}"
if len(splitName) == 2:
outputName += f"_{splitName[1]}" # Add "_1" or "_2" if P1/P2 chart
writeFumen(f"{outputName}.bin", fumenData)
split_name = course_name.split("P") # e.g. 'OniP2' -> ['Oni', '2'], 'Oni' -> ['Oni']
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
write_fumen(f"{output_name}.bin", fumen_data)
# NB: This entry point is necessary for the Pyinstaller executable

View File

@ -1,4 +1,3 @@
# Note types for TJA files
TJA_NOTE_TYPES = {
'1': 'Don',
'2': 'Ka',
@ -13,8 +12,7 @@ TJA_NOTE_TYPES = {
'B': 'KA', # hands
}
# Note types for fumen files
noteTypes = {
FUMEN_NOTE_TYPES = {
0x1: "Don", # ドン
0x2: "Don2", # ド
0x3: "Don3", # コ
@ -43,9 +41,9 @@ noteTypes = {
0x22: "Unknown13", # ? (Present in some Wii1 songs)
0x62: "Drumroll2" # ?
}
typeNotes = {v: k for k, v in noteTypes.items()}
FUMEN_TYPE_NOTES = {v: k for k, v in FUMEN_NOTE_TYPES.items()}
branchNames = ("normal", "advanced", "master")
BRANCH_NAMES = ("normal", "advanced", "master")
TJA_COURSE_NAMES = []
for difficulty in ['Ura', 'Oni', 'Hard', 'Normal', 'Easy']:

View File

@ -3,7 +3,7 @@ import re
from tja2fumen.types import TJAMeasureProcessed, FumenCourse, FumenNote
def processTJACommands(tja):
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.
@ -21,46 +21,46 @@ def processTJACommands(tja):
In the future, this logic should probably be moved into the TJA parser itself.
"""
tjaBranchesProcessed = {branchName: [] for branchName in tja.branches.keys()}
for branchName, branchMeasuresTJA in tja.branches.items():
currentBPM = tja.BPM
currentScroll = 1.0
currentGogo = False
currentBarline = True
currentDividend = 4
currentDivisor = 4
for measureTJA in branchMeasuresTJA:
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
current_gogo = False
current_barline = True
current_dividend = 4
current_divisor = 4
for measure_tja in branch_measures_tja:
# Split measure into submeasure
measureTJAProcessed = TJAMeasureProcessed(
bpm=currentBPM,
scroll=currentScroll,
gogo=currentGogo,
barline=currentBarline,
time_sig=[currentDividend, currentDivisor],
subdivisions=len(measureTJA.notes),
measure_tja_processed = TJAMeasureProcessed(
bpm=current_bpm,
scroll=current_scroll,
gogo=current_gogo,
barline=current_barline,
time_sig=[current_dividend, current_divisor],
subdivisions=len(measure_tja.notes),
)
for data in measureTJA.combined:
for data in measure_tja.combined:
# Handle note data
if data.name == 'note':
measureTJAProcessed.data.append(data)
measure_tja_processed.data.append(data)
# Handle commands that can only be placed between measures (i.e. no mid-measure variations)
elif data.name == 'delay':
measureTJAProcessed.delay = data.value * 1000 # ms -> s
elif data.name == 'branchStart':
measureTJAProcessed.branchStart = data.value
measure_tja_processed.delay = data.value * 1000 # ms -> s
elif data.name == 'branch_start':
measure_tja_processed.branch_start = data.value
elif data.name == 'section':
measureTJAProcessed.section = data.value
measure_tja_processed.section = data.value
elif data.name == 'barline':
currentBarline = bool(int(data.value))
measureTJAProcessed.barline = currentBarline
current_barline = bool(int(data.value))
measure_tja_processed.barline = current_barline
elif data.name == 'measure':
matchMeasure = re.match(r"(\d+)/(\d+)", data.value)
if not matchMeasure:
match_measure = re.match(r"(\d+)/(\d+)", data.value)
if not match_measure:
continue
currentDividend = int(matchMeasure.group(1))
currentDivisor = int(matchMeasure.group(2))
measureTJAProcessed.time_sig = [currentDividend, currentDivisor]
current_dividend = int(match_measure.group(1))
current_divisor = int(match_measure.group(2))
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
@ -68,118 +68,118 @@ def processTJACommands(tja):
elif data.name in ['bpm', 'scroll', 'gogo']:
# Parse the values
if data.name == 'bpm':
new_val = currentBPM = float(data.value)
new_val = current_bpm = float(data.value)
elif data.name == 'scroll':
new_val = currentScroll = data.value
new_val = current_scroll = data.value
elif data.name == 'gogo':
new_val = currentGogo = bool(int(data.value))
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
if data.pos == 0:
measureTJAProcessed.__setattr__(data.name, new_val)
measure_tja_processed.__setattr__(data.name, new_val)
# - Case 2: Command occurs mid-measure, so start a new sub-measure
else:
measureTJAProcessed.pos_end = data.pos
tjaBranchesProcessed[branchName].append(measureTJAProcessed)
measureTJAProcessed = TJAMeasureProcessed(
bpm=currentBPM,
scroll=currentScroll,
gogo=currentGogo,
barline=currentBarline,
time_sig=[currentDividend, currentDivisor],
subdivisions=len(measureTJA.notes),
measure_tja_processed.pos_end = data.pos
tja_branches_processed[branch_name].append(measure_tja_processed)
measure_tja_processed = TJAMeasureProcessed(
bpm=current_bpm,
scroll=current_scroll,
gogo=current_gogo,
barline=current_barline,
time_sig=[current_dividend, current_divisor],
subdivisions=len(measure_tja.notes),
pos_start=data.pos
)
else:
print(f"Unexpected event type: {data.name}")
measureTJAProcessed.pos_end = len(measureTJA.notes)
tjaBranchesProcessed[branchName].append(measureTJAProcessed)
measure_tja_processed.pos_end = len(measure_tja.notes)
tja_branches_processed[branch_name].append(measure_tja_processed)
hasBranches = all(len(b) for b in tjaBranchesProcessed.values())
if hasBranches:
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:
branchCorrected_lens = [len(b) for b in tjaBranchesProcessed.values()]
if not branchCorrected_lens.count(branchCorrected_lens[0]) == len(branchCorrected_lens):
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.")
return tjaBranchesProcessed
return tja_branches_processed
def convertTJAToFumen(tja):
def convert_tja_to_fumen(tja):
# Preprocess commands
processedTJABranches = processTJACommands(tja)
processed_tja_branches = process_tja_commands(tja)
# Pre-allocate the measures for the converted TJA
fumen = FumenCourse(
measures=len(processedTJABranches['normal']),
scoreInit=tja.scoreInit,
scoreDiff=tja.scoreDiff,
measures=len(processed_tja_branches['normal']),
score_init=tja.score_init,
score_diff=tja.score_diff,
)
# Iterate through the different branches in the TJA
total_notes = {'normal': 0, 'advanced': 0, 'master': 0}
for currentBranch, branchMeasuresTJAProcessed in processedTJABranches.items():
if not len(branchMeasuresTJAProcessed):
for current_branch, branch_measures_tja_processed in processed_tja_branches.items():
if not len(branch_measures_tja_processed):
continue
total_notes_branch = 0
note_counter_branch = 0
currentDrumroll = None
branchConditions = []
courseBalloons = tja.balloon.copy()
current_drumroll = None
branch_conditions = []
course_balloons = tja.balloon.copy()
# Iterate through the measures within the branch
for idx_m, measureTJAProcessed in enumerate(branchMeasuresTJAProcessed):
for idx_m, measure_tja_processed in enumerate(branch_measures_tja_processed):
# Fetch a pair of measures
measureFumenPrev = fumen.measures[idx_m-1] if idx_m != 0 else None
measureFumen = fumen.measures[idx_m]
measure_fumen_prev = fumen.measures[idx_m-1] if idx_m != 0 else None
measure_fumen = fumen.measures[idx_m]
# Copy over basic measure properties from the TJA (that don't depend on notes or commands)
measureFumen.branches[currentBranch].speed = measureTJAProcessed.scroll
measureFumen.gogo = measureTJAProcessed.gogo
measureFumen.bpm = measureTJAProcessed.bpm
measure_fumen.branches[current_branch].speed = measure_tja_processed.scroll
measure_fumen.gogo = measure_tja_processed.gogo
measure_fumen.bpm = measure_tja_processed.bpm
# Compute the duration of the measure
# First, we compute the duration for a full 4/4 measure
measureDurationFullMeasure = 4 * 60_000 / measureTJAProcessed.bpm
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.)
measureSize = measureTJAProcessed.time_sig[0] / measureTJAProcessed.time_sig[1]
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 `measureLength` will be less than the total number of subdivisions.
measureLength = measureTJAProcessed.pos_end - measureTJAProcessed.pos_start
# - In other words, `measureRatio` will be less than 1.0:
measureRatio = (1.0 if measureTJAProcessed.subdivisions == 0.0 # Avoid division by 0 for empty measures
else (measureLength / measureTJAProcessed.subdivisions))
# - 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
measureFumen.duration = measureDuration = measureDurationFullMeasure * measureSize * measureRatio
measure_fumen.duration = measure_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.
if idx_m == 0:
measureFumen.fumenOffsetStart = (tja.offset * 1000 * -1) - measureDurationFullMeasure
measure_fumen.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)
measureFumen.fumenOffsetStart = measureFumenPrev.fumenOffsetEnd + measureTJAProcessed.delay
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 `measureOffsetAdjustment`,
# - 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.
measureOffsetAdjustment = (4 * 60_000 / measureFumen.bpm) - (4 * 60_000 / measureFumenPrev.bpm)
# - When we subtract this adjustment from the fumenOffsetStart, we get the "START EARLY" part:
measureFumen.fumenOffsetStart -= measureOffsetAdjustment
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:
measureFumen.fumenOffsetEnd = measureFumen.fumenOffsetStart + measureFumen.duration
measure_fumen.fumen_offset_end = measure_fumen.fumen_offset_start + measure_fumen.duration
# 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
@ -187,22 +187,22 @@ def convertTJAToFumen(tja):
# For example:
# 1. Measures where #BARLINEOFF has been set
# 2. Sub-measures that don't fall on the barline
if measureTJAProcessed.barline is False or (measureRatio != 1.0 and measureTJAProcessed.pos_start != 0):
measureFumen.barline = False
if measure_tja_processed.barline is False or (measure_ratio != 1.0 and measure_tja_processed.pos_start != 0):
measure_fumen.barline = False
# If a #SECTION command occurs in isolation, and it has a valid condition, then treat it like a branchStart
if (measureTJAProcessed.section is not None and measureTJAProcessed.section != 'not_available'
and not measureTJAProcessed.branchStart):
branchCondition = measureTJAProcessed.section
# 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
else:
branchCondition = measureTJAProcessed.branchStart
branch_condition = measure_tja_processed.branch_start
# Check to see if the measure contains a branching condition
if branchCondition:
if branch_condition:
# Determine which values to assign based on the type of branching condition
if branchCondition[0] == 'p':
if branch_condition[0] == 'p':
vals = []
for percent in branchCondition[1:]:
for percent in branch_condition[1:]:
# Ensure percentage is between 0% and 100%
if 0 <= percent <= 1:
val = total_notes_branch * percent * 20
@ -216,12 +216,12 @@ def convertTJAToFumen(tja):
# If it is below 0%, it is a guaranteed "level up". Fumens use 0 for this.
else:
vals.append(0)
if currentBranch == 'normal':
measureFumen.branchInfo[0:2] = vals
elif currentBranch == 'advanced':
measureFumen.branchInfo[2:4] = vals
elif currentBranch == 'master':
measureFumen.branchInfo[4:6] = vals
if current_branch == 'normal':
measure_fumen.branch_info[0:2] = vals
elif current_branch == 'advanced':
measure_fumen.branch_info[2:4] = vals
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
@ -234,23 +234,23 @@ def convertTJAToFumen(tja):
# * 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」"
elif branchCondition[0] == 'r':
if currentBranch == 'normal':
measureFumen.branchInfo[0:2] = (branchCondition[1:] if measureTJAProcessed.section or
not measureTJAProcessed.section and not branchConditions
elif branch_condition[0] == 'r':
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 currentBranch == 'advanced':
measureFumen.branchInfo[2:4] = branchCondition[1:]
elif currentBranch == 'master':
measureFumen.branchInfo[4:6] = (branchCondition[1:] if measureTJAProcessed.section or
not measureTJAProcessed.section and not branchConditions
else [branchCondition[2]] * 2)
elif current_branch == 'advanced':
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)
# Reset the note counter corresponding to this branch (i.e. reset the accuracy)
total_notes_branch = 0
# Cache branch conditions, but skip conditions where the only purpose is to level down to 'normal'
if measureFumen.branchInfo != [999, 999, 999, 999, 999, 999]:
branchConditions.append(branchCondition)
if measure_fumen.branch_info != [999, 999, 999, 999, 999, 999]:
branch_conditions.append(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:
@ -262,72 +262,72 @@ def convertTJAToFumen(tja):
# Create notes based on TJA measure data
note_counter_branch = 0
note_counter = 0
for idx_d, data in enumerate(measureTJAProcessed.data):
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 = measureDuration * (data.pos - measureTJAProcessed.pos_start) / measureLength
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 currentDrumroll.multimeasure:
currentDrumroll.duration += (note.pos - currentDrumroll.pos)
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:
currentDrumroll.duration += (note.pos - 0.0)
current_drumroll.duration += (note.pos - 0.0)
# 1182, 1385, 1588, 2469, 1568, 752, 1568
currentDrumroll.duration = float(int(currentDrumroll.duration))
currentDrumroll = None
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 currentDrumroll:
if data.value == "Kusudama" and current_drumroll:
continue
# Handle the remaining non-EndDRB, non-double Kusudama notes
note.type = data.value
note.scoreInit = tja.scoreInit
note.scoreDiff = tja.scoreDiff
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 = courseBalloons.pop(0)
currentDrumroll = note
total_notes[currentBranch] -= 1
note.hits = course_balloons.pop(0)
current_drumroll = note
total_notes[current_branch] -= 1
if note.type in ["Drumroll", "DRUMROLL"]:
currentDrumroll = note
total_notes[currentBranch] -= 1
current_drumroll = note
total_notes[current_branch] -= 1
# Count dons, kas, and balloons for the purpose of tracking branching accuracy
if note.type.lower() in ['don', 'ka']:
note_counter_branch += 1
elif note.type.lower() in ['balloon', 'kusudama']:
note_counter_branch += 1.5
measureFumen.branches[currentBranch].notes.append(note)
measure_fumen.branches[current_branch].notes.append(note)
note_counter += 1
# If drumroll hasn't ended by the end of this measure, increase duration by measure timing
if currentDrumroll:
if currentDrumroll.duration == 0.0:
currentDrumroll.duration += (measureDuration - currentDrumroll.pos)
currentDrumroll.multimeasure = True
if current_drumroll:
if current_drumroll.duration == 0.0:
current_drumroll.duration += (measure_duration - current_drumroll.pos)
current_drumroll.multimeasure = True
else:
currentDrumroll.duration += measureDuration
current_drumroll.duration += measure_duration
measureFumen.branches[currentBranch].length = note_counter
total_notes[currentBranch] += note_counter
measure_fumen.branches[current_branch].length = note_counter
total_notes[current_branch] += note_counter
# 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 processedTJABranches.values()]))
fumen.header.b432_b435_has_branches = int(all([len(b) for b in processed_tja_branches.values()]))
fumen.header.set_hp_bytes(total_notes, tja.course, tja.level)
# If song has only drumroll branching conditions, then only drumrolls should contribute to branching
if all([condition[0] == 'r' for condition in branchConditions]):
if all([condition[0] == 'r' for condition in branch_conditions]):
fumen.header.b468_b471_branch_points_good = 0
fumen.header.b484_b487_branch_points_good_BIG = 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.b488_b491_branch_points_ok_big = 0
fumen.header.b496_b499_branch_points_balloon = 0
fumen.header.b500_b503_branch_points_kusudama = 0

View File

@ -2,8 +2,8 @@ import os
import re
from copy import deepcopy
from tja2fumen.utils import readStruct, shortHex
from tja2fumen.constants import NORMALIZE_COURSE, TJA_NOTE_TYPES, branchNames, noteTypes
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)
@ -12,64 +12,64 @@ from tja2fumen.types import (TJASong, TJAMeasure, TJAData,
########################################################################################################################
def parseTJA(fnameTJA):
def parse_tja(fname_tja):
try:
tja_text = open(fnameTJA, "r", encoding="utf-8-sig").read()
tja_text = open(fname_tja, "r", encoding="utf-8-sig").read()
except UnicodeDecodeError:
tja_text = open(fnameTJA, "r", encoding="shift-jis").read()
tja_text = open(fname_tja, "r", encoding="shift-jis").read()
lines = [line for line in tja_text.splitlines() if line.strip() != '']
parsedTJA = getCourseData(lines)
for course in parsedTJA.courses.values():
parseCourseMeasures(course)
parsed_tja = get_course_data(lines)
for course in parsed_tja.courses.values():
parse_course_measures(course)
return parsedTJA
return parsed_tja
def getCourseData(lines):
parsedTJA = None
currentCourse = ''
currentCourseCached = ''
songBPM = 0
songOffset = 0
def get_course_data(lines):
parsed_tja = None
current_course = ''
current_course_cached = ''
song_bpm = 0
song_offset = 0
for line in lines:
# Case 1: Header metadata
match_header = re.match(r"^([A-Z]+):(.*)", line)
if match_header:
nameUpper = match_header.group(1).upper()
name_upper = match_header.group(1).upper()
value = match_header.group(2).strip()
# Global header fields
if nameUpper in ['BPM', 'OFFSET']:
if nameUpper == 'BPM':
songBPM = value
elif nameUpper == 'OFFSET':
songOffset = value
if songBPM and songOffset:
parsedTJA = TJASong(songBPM, songOffset)
if name_upper in ['BPM', 'OFFSET']:
if name_upper == 'BPM':
song_bpm = value
elif name_upper == 'OFFSET':
song_offset = value
if song_bpm and song_offset:
parsed_tja = TJASong(song_bpm, song_offset)
# Course-specific header fields
elif nameUpper == 'COURSE':
currentCourse = NORMALIZE_COURSE[value]
currentCourseCached = currentCourse
if currentCourse not in parsedTJA.courses.keys():
elif name_upper == 'COURSE':
current_course = NORMALIZE_COURSE[value]
current_course_cached = current_course
if current_course not in parsed_tja.courses.keys():
raise ValueError()
elif nameUpper == 'LEVEL':
parsedTJA.courses[currentCourse].level = int(value) if value else 0
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)
elif nameUpper == 'SCOREINIT':
parsedTJA.courses[currentCourse].scoreInit = int(value.split(",")[-1]) if value else 0
elif nameUpper == 'SCOREDIFF':
parsedTJA.courses[currentCourse].scoreDiff = int(value.split(",")[-1]) if value else 0
elif nameUpper == 'BALLOON':
elif name_upper == 'SCOREINIT':
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
elif name_upper == 'BALLOON':
if value:
balloons = [int(v) for v in value.split(",") if v]
parsedTJA.courses[currentCourse].balloon = balloons
elif nameUpper == 'STYLE':
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
if value == 'Single':
currentCourse = currentCourseCached
current_course = current_course_cached
else:
pass # Ignore other header fields such as 'TITLE', 'SUBTITLE', 'WAVE', etc.
@ -78,43 +78,43 @@ def getCourseData(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:
nameUpper = match_command.group(1).upper()
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
if nameUpper == "START":
if name_upper == "START":
if value in ["P1", "P2"]:
currentCourse = currentCourseCached + value
parsedTJA.courses[currentCourse] = deepcopy(parsedTJA.courses[currentCourseCached])
parsedTJA.courses[currentCourse].data = list() # Keep the metadata, but reset the note data
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
elif value:
raise ValueError(f"Invalid value '{value}' for #START command.")
elif match_notes:
nameUpper = 'NOTES'
name_upper = 'NOTES'
value = match_notes.group(1)
parsedTJA.courses[currentCourse].data.append(TJAData(nameUpper, value))
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 courseName, course in parsedTJA.courses.items():
for course_name, course in parsed_tja.courses.items():
if not course.data:
if courseName+"P1" in parsedTJA.courses.keys():
parsedTJA.courses[courseName] = deepcopy(parsedTJA.courses[courseName+"P1"])
if course_name+"P1" in parsed_tja.courses.keys():
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 parsedTJA.courses.items() if not v.data]:
del parsedTJA.courses[course_name]
for course_name in [k for k, v in parsed_tja.courses.items() if not v.data]:
del parsed_tja.courses[course_name]
return parsedTJA
return parsed_tja
def parseCourseMeasures(course):
def parse_course_measures(course):
# Check if the course has branches or not
hasBranches = True if [l for l in course.data if l.name == 'BRANCHSTART'] else False
currentBranch = 'all' if hasBranches else 'normal'
has_branches = True if [l for l in course.data if l.name == 'BRANCHSTART'] else False
current_branch = 'all' if has_branches else 'normal'
branch_condition = None
flagLevelhold = False
flag_levelhold = False
# Process course lines
idx_m = 0
@ -125,53 +125,53 @@ def parseCourseMeasures(course):
notes = line.value
# If measure has ended, then add notes to the current measure, then start a new one by incrementing idx_m
if notes.endswith(','):
for branch in course.branches.keys() if currentBranch == 'all' else [currentBranch]:
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 currentBranch == 'all' else [currentBranch]:
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']:
# Get position of the event
for branch in course.branches.keys() if currentBranch == 'all' else [currentBranch]:
for branch in course.branches.keys() if current_branch == 'all' else [current_branch]:
pos = len(course.branches[branch][idx_m].notes)
# Parse event type
if line.name == 'GOGOSTART':
currentEvent = TJAData('gogo', '1', pos)
current_event = TJAData('gogo', '1', pos)
elif line.name == 'GOGOEND':
currentEvent = TJAData('gogo', '0', pos)
current_event = TJAData('gogo', '0', pos)
elif line.name == 'BARLINEON':
currentEvent = TJAData('barline', '1', pos)
current_event = TJAData('barline', '1', pos)
elif line.name == 'BARLINEOFF':
currentEvent = TJAData('barline', '0', pos)
current_event = TJAData('barline', '0', pos)
elif line.name == 'DELAY':
currentEvent = TJAData('delay', float(line.value), pos)
current_event = TJAData('delay', float(line.value), pos)
elif line.name == 'SCROLL':
currentEvent = TJAData('scroll', float(line.value), pos)
current_event = TJAData('scroll', float(line.value), pos)
elif line.name == 'BPMCHANGE':
currentEvent = TJAData('bpm', float(line.value), pos)
current_event = TJAData('bpm', float(line.value), pos)
elif line.name == 'MEASURE':
currentEvent = TJAData('measure', line.value, pos)
current_event = TJAData('measure', line.value, pos)
elif line.name == 'SECTION':
if branch_condition is None:
currentEvent = TJAData('section', 'not_available', pos)
current_event = TJAData('section', 'not_available', pos)
else:
currentEvent = TJAData('section', branch_condition, pos)
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 course.data[idx_l+1].name == 'BRANCHSTART':
currentBranch = 'all'
current_branch = 'all'
elif line.name == 'BRANCHSTART':
if flagLevelhold:
if flag_levelhold:
continue
currentBranch = 'all' # Ensure that the #BRANCHSTART command is present for all branches
current_branch = 'all' # Ensure that the #BRANCHSTART command is present for all branches
branch_condition = line.value.split(',')
if branch_condition[0] == 'r': # r = drumRoll
branch_condition[1] = int(branch_condition[1]) # # of drumrolls
@ -179,31 +179,31 @@ def parseCourseMeasures(course):
elif branch_condition[0] == 'p': # p = Percentage
branch_condition[1] = float(branch_condition[1]) / 100 # %
branch_condition[2] = float(branch_condition[2]) / 100 # %
currentEvent = TJAData('branchStart', branch_condition, pos)
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
# Append event to the current measure's events
for branch in course.branches.keys() if currentBranch == 'all' else [currentBranch]:
course.branches[branch][idx_m].events.append(currentEvent)
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)
else:
if line.name == 'START' or line.name == 'END':
currentBranch = 'all' if hasBranches else 'normal'
flagLevelhold = False
current_branch = 'all' if has_branches else 'normal'
flag_levelhold = False
elif line.name == 'LEVELHOLD':
flagLevelhold = True
flag_levelhold = True
elif line.name == 'N':
currentBranch = 'normal'
current_branch = 'normal'
idx_m = idx_m_branchstart
elif line.name == 'E':
currentBranch = 'advanced'
current_branch = 'advanced'
idx_m = idx_m_branchstart
elif line.name == 'M':
currentBranch = 'master'
current_branch = 'master'
idx_m = idx_m_branchstart
elif line.name == 'BRANCHEND':
currentBranch = 'all'
current_branch = 'all'
else:
print(f"Ignoring unsupported command '{line.name}'")
@ -214,7 +214,7 @@ def parseCourseMeasures(course):
del branch[-1]
# Merge measure data and measure events in chronological order
for branchName, branch in course.branches.items():
for branch_name, branch in course.branches.items():
for measure in branch:
notes = [TJAData('note', TJA_NOTE_TYPES[note], i)
for i, note in enumerate(measure.notes) if note != '0']
@ -231,7 +231,7 @@ def parseCourseMeasures(course):
measure.combined.append(notes.pop(0))
# Ensure all branches have the same number of measures
if hasBranches:
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.")
@ -247,21 +247,21 @@ def parseCourseMeasures(course):
# TODO: Figure out what the unknown Wii1, Wii4, and PS4 notes represent (just in case they're important somehow)
def readFumen(fumenFile, exclude_empty_measures=False):
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. scoreInit, scoreDiff),
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
"""
file = open(fumenFile, "rb")
file = open(fumen_file, "rb")
size = os.fstat(file.fileno()).st_size
song = FumenCourse(
header=FumenHeader(raw_bytes=file.read(520))
)
for measureNumber in range(song.header.b512_b515_number_of_measures):
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))
@ -269,88 +269,88 @@ def readFumen(fumenFile, exclude_empty_measures=False):
# - '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': branchInfo (represented by six integers (24 bytes))
# - 'iiiiii': branch_info (represented by six integers (24 bytes))
# - 'i': <padding> (represented by one integer (4 bytes)
measureStruct = readStruct(file, song.header.order, format_string="ffBBHiiiiiii")
measure_struct = read_struct(file, song.header.order, format_string="ffBBHiiiiiii")
# Create the measure dictionary using the newly-parsed measure data
measure = FumenMeasure(
bpm=measureStruct[0],
fumenOffsetStart=measureStruct[1],
gogo=measureStruct[2],
barline=measureStruct[3],
padding1=measureStruct[4],
branchInfo=list(measureStruct[5:11]),
padding2=measureStruct[11]
bpm=measure_struct[0],
fumen_offset_start=measure_struct[1],
gogo=measure_struct[2],
barline=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 branchName in branchNames:
for branch_name in BRANCH_NAMES:
# Parse the measure data using the following `format_string`:
# "HHf" (3 format characters, 8 bytes per branch)
# - 'H': totalNotes (represented by one unsigned short (2 bytes))
# - '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)
branchStruct = readStruct(file, song.header.order, format_string="HHf")
branch_struct = read_struct(file, song.header.order, format_string="HHf")
# Create the branch dictionary using the newly-parsed branch data
totalNotes = branchStruct[0]
total_notes = branch_struct[0]
branch = FumenBranch(
length=totalNotes,
padding=branchStruct[1],
speed=branchStruct[2],
length=total_notes,
padding=branch_struct[1],
speed=branch_struct[2],
)
# Iterate through each note in the measure (per branch)
for noteNumber in range(totalNotes):
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': scoreInit
# - 'H': scoreDiff
# - 'H': score_init
# - 'H': score_diff
# - 'f': duration
# NB: 'item' doesn't seem to be used at all in this function.
noteStruct = readStruct(file, song.header.order, format_string="ififHHf")
note_struct = read_struct(file, song.header.order, format_string="ififHHf")
# Validate the note type
noteType = noteStruct[0]
if noteType not in noteTypes:
note_type = note_struct[0]
if note_type not in FUMEN_NOTE_TYPES:
raise ValueError("Error: Unknown note type '{0}' at offset {1}".format(
shortHex(noteType).upper(),
short_hex(note_type).upper(),
hex(file.tell() - 0x18))
)
# Create the note dictionary using the newly-parsed note data
note = FumenNote(
note_type=noteTypes[noteType],
pos=noteStruct[1],
item=noteStruct[2],
padding=noteStruct[3],
note_type=FUMEN_NOTE_TYPES[note_type],
pos=note_struct[1],
item=note_struct[2],
padding=note_struct[3],
)
if noteType == 0xa or noteType == 0xc:
if note_type == 0xa or note_type == 0xc:
# Balloon hits
note.hits = noteStruct[4]
note.hitsPadding = noteStruct[5]
note.hits = note_struct[4]
note.hits_padding = note_struct[5]
else:
song.scoreInit = note.scoreInit = noteStruct[4]
song.scoreDiff = note.scoreDiff = noteStruct[5] // 4
song.score_init = note.score_init = note_struct[4]
song.score_diff = note.score_diff = note_struct[5] // 4
# Drumroll/balloon duration
note.duration = noteStruct[6]
note.duration = note_struct[6]
# Seek forward 8 bytes to account for padding bytes at the end of drumrolls
if noteType == 0x6 or noteType == 0x9 or noteType == 0x62:
note.drumrollBytes = file.read(8)
if note_type == 0x6 or note_type == 0x9 or note_type == 0x62:
note.drumroll_bytes = file.read(8)
# Assign the note to the branch
branch.notes.append(note)
# Assign the branch to the measure
measure.branches[branchName] = branch
measure.branches[branch_name] = branch
# Assign the measure to the song
song.measures.append(measure)

View File

@ -16,11 +16,11 @@ class TJASong:
class TJACourse:
def __init__(self, BPM, offset, course, level=0, balloon=None, scoreInit=0, scoreDiff=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.scoreInit = scoreInit
self.scoreDiff = scoreDiff
self.score_init = score_init
self.score_diff = score_diff
self.BPM = BPM
self.offset = offset
self.course = course
@ -47,7 +47,7 @@ class TJAMeasure:
class TJAMeasureProcessed:
def __init__(self, bpm, scroll, gogo, barline, time_sig, subdivisions,
pos_start=0, pos_end=0, delay=0, section=None, branchStart=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
@ -58,7 +58,7 @@ class TJAMeasureProcessed:
self.pos_end = pos_end
self.delay = delay
self.section = section
self.branchStart = branchStart
self.branch_start = branch_start
self.data = [] if data is None else data
def __repr__(self):
@ -76,30 +76,30 @@ class TJAData:
class FumenCourse:
def __init__(self, measures=None, header=None, scoreInit=0, scoreDiff=0):
def __init__(self, measures=None, header=None, score_init=0, score_diff=0):
if isinstance(measures, int):
self.measures = [FumenMeasure() for _ in range(measures)]
else:
self.measures = [] if measures is None else measures
self.header = FumenHeader() if header is None else header
self.scoreInit = scoreInit
self.scoreDiff = scoreDiff
self.score_init = score_init
self.score_diff = score_diff
def __repr__(self):
return str(self.__dict__)
class FumenMeasure:
def __init__(self, bpm=0.0, fumenOffsetStart=0.0, fumenOffsetEnd=0.0, duration=0.0,
gogo=False, barline=True, branchStart=None, branchInfo=None, padding1=0, padding2=0):
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):
self.bpm = bpm
self.fumenOffsetStart = fumenOffsetStart
self.fumenOffsetEnd = fumenOffsetEnd
self.fumen_offset_start = fumen_offset_start
self.fumen_offset_end = fumen_offset_end
self.duration = duration
self.gogo = gogo
self.barline = barline
self.branchStart = branchStart
self.branchInfo = [-1, -1, -1, -1, -1, -1] if branchInfo is None else branchInfo
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(), 'advanced': FumenBranch(), 'master': FumenBranch()}
self.padding1 = padding1
self.padding2 = padding2
@ -120,12 +120,12 @@ class FumenBranch:
class FumenNote:
def __init__(self, note_type='', pos=0.0, scoreInit=0, scoreDiff=0, padding=0, item=0, duration=0.0,
multimeasure=False, hits=0, hitsPadding=0, drumrollBytes=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.scoreInit = scoreInit
self.scoreDiff = scoreDiff
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)
self.item = item
@ -133,8 +133,8 @@ class FumenNote:
self.duration = duration
self.multimeasure = multimeasure
self.hits = hits
self.hitsPadding = hitsPadding
self.drumrollBytes = drumrollBytes
self.hits_padding = hits_padding
self.drumroll_bytes = drumroll_bytes
def __repr__(self):
return str(self.__dict__)
@ -165,9 +165,9 @@ class FumenHeader:
self.b472_b475_branch_points_ok = 10
self.b476_b479_branch_points_bad = 0
self.b480_b483_branch_points_drumroll = 1
self.b484_b487_branch_points_good_BIG = 20
self.b488_b491_branch_points_ok_BIG = 10
self.b492_b495_branch_points_drumroll_BIG = 1
self.b484_b487_branch_points_good_big = 20
self.b488_b491_branch_points_ok_big = 10
self.b492_b495_branch_points_drumroll_big = 1
self.b496_b499_branch_points_balloon = 30
self.b500_b503_branch_points_kusudama = 30
self.b504_b507_branch_points_unknown = 20
@ -190,9 +190,9 @@ class FumenHeader:
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.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]

View File

@ -1,7 +1,7 @@
import struct
def readStruct(file, order, format_string, seek=None):
def read_struct(file, order, format_string, seek=None):
"""
Interpret bytes as packed binary data.
@ -29,12 +29,12 @@ def readStruct(file, order, format_string, seek=None):
return interpreted_string
def writeStruct(file, order, format_string, value_list, seek=None):
def write_struct(file, order, format_string, value_list, seek=None):
if seek:
file.seek(seek)
packed_bytes = struct.pack(order + format_string, *value_list)
file.write(packed_bytes)
def shortHex(number):
def short_hex(number):
return hex(number)[2:]

View File

@ -1,30 +1,30 @@
from tja2fumen.utils import writeStruct
from tja2fumen.constants import branchNames, typeNotes
from tja2fumen.utils import write_struct
from tja2fumen.constants import BRANCH_NAMES, FUMEN_TYPE_NOTES
def writeFumen(path_out, song):
def write_fumen(path_out, song):
with open(path_out, "wb") as file:
file.write(song.header.raw_bytes)
for measureNumber in range(len(song.measures)):
measure = song.measures[measureNumber]
measureStruct = [measure.bpm, measure.fumenOffsetStart, int(measure.gogo), int(measure.barline)]
measureStruct.extend([measure.padding1] + measure.branchInfo + [measure.padding2])
writeStruct(file, song.header.order, format_string="ffBBHiiiiiii", value_list=measureStruct)
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)
for branchNumber in range(len(branchNames)):
branch = measure.branches[branchNames[branchNumber]]
branchStruct = [branch.length, branch.padding, branch.speed]
writeStruct(file, song.header.order, format_string="HHf", value_list=branchStruct)
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)
for noteNumber in range(branch.length):
note = branch.notes[noteNumber]
noteStruct = [typeNotes[note.type], note.pos, note.item, note.padding]
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]
if note.hits:
noteStruct.extend([note.hits, note.hitsPadding, note.duration])
note_struct.extend([note.hits, note.hits_padding, note.duration])
else:
noteStruct.extend([note.scoreInit, note.scoreDiff * 4, note.duration])
writeStruct(file, song.header.order, format_string="ififHHf", value_list=noteStruct)
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)
if note.type.lower() == "drumroll":
file.write(note.drumrollBytes)
file.write(note.drumroll_bytes)

View File

@ -7,7 +7,7 @@ import glob
import pytest
from tja2fumen import main as convert
from tja2fumen.parsers import readFumen
from tja2fumen.parsers import read_fumen
from tja2fumen.constants import COURSE_IDS, NORMALIZE_COURSE
@ -64,16 +64,16 @@ def test_converted_tja_vs_cached_fumen(id_song, tmp_path, entry_point):
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
# 0. Read fumen data (converted vs. cached)
co_song = readFumen(path_out, exclude_empty_measures=True)
ca_song = readFumen(os.path.join(path_bin, os.path.basename(path_out)), exclude_empty_measures=True)
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)
# 1. Check song headers
checkValidHeader(co_song.header)
checkValidHeader(ca_song.header)
# 2. Check song metadata
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, ca_song, 'scoreInit')
assert_song_property(co_song, ca_song, 'scoreDiff')
assert_song_property(co_song, ca_song, 'score_init')
assert_song_property(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.
@ -83,7 +83,7 @@ def test_converted_tja_vs_cached_fumen(id_song, tmp_path, entry_point):
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, 'fumenOffsetStart', i_measure, abs=0.15)
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)
@ -99,7 +99,7 @@ def test_converted_tja_vs_cached_fumen(id_song, tmp_path, entry_point):
# 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, 'branchInfo', i_measure)
assert_song_property(co_measure, ca_measure, 'branch_info', i_measure)
# 3b. Check measure notes
for i_branch in ['normal', 'advanced', 'master']:
@ -126,8 +126,8 @@ def test_converted_tja_vs_cached_fumen(id_song, tmp_path, entry_point):
except AssertionError:
pass
if ca_note.note_type not in ["Balloon", "Kusudama"]:
assert_song_property(co_note, ca_note, 'scoreInit', i_measure, i_branch, i_note)
assert_song_property(co_note, ca_note, 'scoreDiff', i_measure, i_branch, i_note)
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)
@ -168,9 +168,9 @@ def checkValidHeader(header):
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.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]