Skip to content

Instantly share code, notes, and snippets.

@zacharied
Last active February 20, 2025 02:41
Show Gist options
  • Save zacharied/46defbda7ea3e9ae4c8955f38e664443 to your computer and use it in GitHub Desktop.
Save zacharied/46defbda7ea3e9ae4c8955f38e664443 to your computer and use it in GitHub Desktop.
Create an SDDT option file from fumen editor outputs
# MakeOption.ps1 - Create an SDDT option file from fumen editor outputs
#
# Requires PowerShell 7.
#
# When you first run this script, it will create a file "MakeOption.ini".
# You must configure MakeOption.ini before running this script again.
#
# This script will process all subdirectories of the "SourceDir" that you set in MakeOption.ini.
#
# If a subdirectory is missing a file called Music.xml, it will be skipped.
# You can generate Music.xml files from the GUI of the fumen editor.
#
# A file "Song.ini" will be created in any song subdirectory that does not yet have one.
# This file defines the start and end point of the preview, as well as the filename of the jacket.
# You may want to run this script again after configuring those values.
#
# Overall, each song folder is expected to be in the following layout:
#
# | MySong
# |-- Song.ini
# |-- Music.xml
# |-- Jacket.png
# |-- MySong MA.nyageki
# |-- MySong MA.nyagekiProj
# |-- track.wav
#
# The file names do not matter except for Song.ini and Music.xml .
# You can have up to 5 .nyagekiProj/.nyageki files per song. The filenames (pre-extension) should end with a space followed by the
# name of the corresponding in-game difficulty (that is, basic, advanced, expert, master, lunatic). Abbreviations
# will also work.
param(
[switch]$Combine,
[switch]$Deploy,
[switch]$Force
)
Remove-Variable * -Exclude ($PSBoundParameters.Keys -join ',') -ErrorAction SilentlyContinue
#region Constants
Set-Variable PauseOnExit -Option Constant -Value $($MyInvocation.Line -eq "" -or $MyInvocation.InvocationName -eq "&")
Set-Variable DefaultProgramConfig -Option Constant -Value @"
[MakeOption]
OptionDir = Option
SourceDir = Nyageki Projects
FumenEditorExe = C:/Users/OngekiPlayer/OngekiFumenEditor/OngekiFumenEditor.exe
DeployPath = \\ONGEKI-MACHINE\geki\package\option
"@
Set-Variable DefaultSongConfig -Option Constant -Value @"
[Song]
Jacket = jacket.png
PreviewBegin = 60000
PreviewEnd = 80000
"@
Set-Variable DifficultyNumberMappings -Option Constant -Value @(
[PSCustomObject]@{ Number = 0; Name = "basic"; },
[PSCustomObject]@{ Number = 1; Name = "advanced"; },
[PSCustomObject]@{ Number = 2; Name = "expert"; },
[PSCustomObject]@{ Number = 3; Name = "master"; },
[PSCustomObject]@{ Number = 4; Name = "lunatic"; }
)
Set-Variable SongConfigFilename -Option Constant -Value "Song.ini"
Set-Variable ProgramOutputPath -Option Constant -Value "MakeOption.log"
Set-Variable ProgramConfigPath -Option Constant -Value "MakeOption.ini"
#endregion
#region Functions
Function Get-IniContent($filePath)
{
$ini = @{}
switch -regex -File $filePath
{
'^\[(.+)\]' # Section
{
$section = $matches[1]
$ini[$section] = @{}
$CommentCount = 0
}
'^(;.*)$' # Comment
{
$value = $matches[1]
$CommentCount = $CommentCount + 1
$name = “Comment” + $CommentCount
$ini[$section][$name] = $value
}
'(.+?)\s*=(.*)' # Key
{
$name,$value = $matches[1..2]
$ini[$section][$name] = $value.Trim()
}
}
return $ini
}
Function Find-Bytes([byte[]]$Bytes, [byte[]]$Search, [int]$Start, [Switch]$All) {
For ($Index = $Start; $Index -le $Bytes.Length - $Search.Length ; $Index++) {
For ($i = 0; $i -lt $Search.Length -and $Bytes[$Index + $i] -eq $Search[$i]; $i++) {}
If ($i -ge $Search.Length) {
$Index
If (!$All) { Return }
}
}
}
Function Get-MostRecentDateProperty([string]$FilePath) {
$SourceAudioProperty = Get-ItemProperty $FilePath
return $(@($SourceAudioProperty.LastWriteTimeUtc, $SourceAudioProperty.CreationTimeUtc) | Measure-Object -Maximum).Maximum
}
Function Test-MusicSourceOutputExists([string]$Path, [string]$MusicId) {
foreach ($file in Get-MusicSourceFiles $MusicId) {
if (!(Test-Path "$Path/$file")) {
return $false
}
}
return $true
}
Function Get-MusicSourceFiles([string]$Id) {
@("music${Id}.acb", "music${Id}.awb", "MusicSource.xml")
}
Function Test-JacketAssetsExist([string]$Id, [string]$AssetsPath) {
Write-Debug "Checking $AssetsPath for $Id"
if (!(Test-Path "$AssetsPath/ui_jacket_$Id") -or !(Test-Path "$AssetsPath/ui_jacket_$($Id)_s")) {
return $false
}
if (!(Find-Bytes -Bytes $(Get-Content -Encoding ansi -ReadCount 0 "$AssetsPath/assets.bytes").ToCharArray() -Search "ui_jacket_$SongId".ToCharArray())) {
return $false
}
if (!(Find-Bytes -Bytes $(Get-Content -Encoding ansi -ReadCount 0 "$AssetsPath/assets.bytes").ToCharArray() -Search "ui_jacket_$($SongId)_s".ToCharArray())) {
return $false
}
return $true
}
Function Start-ConversionStep {
param (
[bool]$Skip,
[string]$ConvertCommand
)
if (!$Skip) {
$ConvertCommand += " 1>nul" + '; $?'
Write-Debug $ConvertCommand
$ErrorOutput = $(Invoke-Expression "$ConvertCommand") 2>&1
if ($ErrorOutput -ne $true) {
Write-Host -ForegroundColor Red "Error"
Write-Host -ForegroundColor Red $ErrorOutput
} else {
Write-Host -ForegroundColor Green "Success"
}
} else {
Write-Host -ForegroundColor DarkGray "Skipped"
}
}
Function Pause-Console {
if ($PauseOnExit) {
Pause
}
}
#endregion
#region Program
if ($PauseOnExit) {
Set-Location $PSScriptRoot
}
if (!(Test-Path $ProgramConfigPath)) {
Write-Host -ForegroundColor Yellow "No MakeOption.ini file found, creating it..."
Write-Output $DefaultProgramConfig > $ProgramConfigPath
Write-Host "$($PsStyle.Bold)$ProgramConfigPath has been created.`nEdit the contents to match your preference and then run this script again."
Write-Host "Exiting..."
Pause-Console
return
}
$ProgramConfig = (Get-IniContent -filePath $ProgramConfigPath)["MakeOption"]
$OptionDir = $ProgramConfig.OptionDir
$SourceDir = $ProgramConfig.SourceDir
$FumenEditorExe = $ProgramConfig.FumenEditorExe
$DeployPath = $ProgramConfig.DeployPath
if (![System.IO.Path]::IsPathFullyQualified($OptionDir)) {
$OptionPath = "$PWD/$OptionDir"
} else {
$OptionPath = $OptionDir
}
$OutMusicDir = "$OptionPath/music"
$OutMusicSourceDir = "$OptionPath/musicSource"
$OutAssetsDir = "$OptionPath/assets"
Set-Variable ProgramConfig -Option ReadOnly
Set-Variable OptionDir -Option Readonly
Set-Variable SourceDir -Option Readonly
Set-Variable FumenEditorExe -Option Readonly
Set-Variable OptionPath -Option Readonly
Set-Variable OutMusicDir -Option Readonly
Set-Variable OutMusicSourceDir -Option Readonly
New-Item -ItemType File -Force $ProgramOutputPath | Out-Null
Clear-Content $ProgramOutputPath | Out-Null
New-Item -ItemType Directory $OptionDir -Force | Out-Null
New-Item -ItemType Directory $OutMusicDir -Force | Out-Null
New-Item -ItemType Directory $OutMusicSourceDir -Force | Out-Null
New-Item -ItemType Directory $OutAssetsDir -Force | Out-Null
$UsedIds = @()
if ($Deploy) {
if (!$DeployPath) {
Write-Error "-Deploy passed but no DeployPath was set"
return
}
if (!$PSBoundParameters.ContainsKey('Combine')) {
Write-Output "-Deploy passed, assuming -Combine as well"
$Combine = $true
} elseif (!$Combine) {
Write-Error "-Combine must be true to deploy"
return 1
}
}
if ($Combine) {
Get-ChildItem -Path $OptionDir -Exclude "music","musicSource","assets" | ForEach-Object { Remove-Item $_ -Recurse }
New-Item -ItemType Directory $OptionDir -Force | Out-Null
New-Item -ItemType Directory $OutMusicDir -Force | Out-Null
New-Item -ItemType Directory $OutMusicSourceDir -Force | Out-Null
New-Item -ItemType Directory $OutAssetsDir -Force | Out-Null
}
else {
Remove-Item $OutMusicDir -Recurse -Force
Remove-Item $OutMusicSourceDir -Recurse -Force
Remove-Item $OutAssetsDir -Recurse -Force
}
# Process each child folder as a song.
Get-ChildItem -Path $SourceDir -ErrorAction Stop | ForEach-Object {
Write-Host "`n----------------`n"
Write-Host -NoNewline "Processing $($PsStyle.Underline)$($_.BaseName)$($PsStyle.UnderlineOff)"
$ChartDir = $_.FullName
$ChartDirName = $_.Name
# Get song ID from Music.xml
$MusicXmlPath = "$ChartDir/Music.xml"
if (!(Test-Path $MusicXmlPath)) {
Write-Host -ForegroundColor DarkGray "`nSkipped (no Music.xml)"
return
}
$MusicXml = Get-ChildItem -Path $MusicXmlPath -File
$SongId = $(Select-Xml -Path $MusicXml -XPath "//MusicData/Name/id").Node.InnerText
Write-Host " ($($PsStyle.Bold)ID $SongId$($PsStyle.BoldOff))"
# Load Song.ini config
$SongConfigPath = "$ChartDir/$SongConfigFilename"
if (!(Test-Path $SongConfigPath)) {
Write-Host -ForegroundColor Yellow "No Song.ini file, generating it..."
Write-Output $DefaultSongConfig > $SongConfigPath
}
$SongConfig = $(Get-IniContent -filePath $SongConfigPath)["Song"]
if (!$SongConfig) {
Write-Host -ForegroundColor Red "Invalid Song.ini file"
return
}
Write-Debug "Song config: $($($SongConfig.Keys | ForEach-Object { "$_ = $($SongConfig[$_].Trim())" }) -join " | ")"
if ($UsedIds.Contains($SongId)) {
Write-Host -ForegroundColor Red "Song ID $SongId has already been used!"
return
}
$UsedIds += $SongId
# Parse nyageki charts for more metadata
$NyagekiCharts = @(Get-ChildItem -Path $ChartDir -Filter "*.nyagekiProj" | ForEach-Object {
$contents = Get-Content $_ | ConvertFrom-Json
return [PSCustomObject]@{
AudioFile = $contents.AudioFilePath;
FumenFile = $contents.FumenFilePath;
}
})
if (@(Get-Unique -InputObject $(ForEach-Object -InputObject $NyagekiCharts { $_.AudioFile })).Count -ne 1) {
Write-Host -ForegroundColor Red "Differing audio files across charts"
return
}
$AudioFile = ($NyagekiCharts | Select-Object -First 1).AudioFile
if (!($AudioFile)) {
Write-Host -ForegroundColor Red "Unable to detect audio file"
return
}
$AudioFilePath = "$ChartDir/$AudioFile"
$TargetChartFileNames = Select-Xml -Path $MusicXml -XPath "//MusicData/FumenData/FumenData/FumenFile/path[string-length(text()) > 0]"
$ChartFileTargetSources = @{}
$UnmatchedChartPaths = @()
if ($TargetChartFileNames.Count -eq 1 -and $NyagekiCharts.Count -eq 1) {
# If there's only one ogkr and one nyageki, we can match them up.
$ChartFileTargetSources.Add($TargetChartFileNames.Node.InnerText, $NyagekiCharts.FumenFile)
} else {
# Find the difficulty of each ogkr chart described in Music.xml
foreach ($chartFileName in $($TargetChartFileNames.Node.InnerText)) {
$diffNumber = [int]($chartFileName -Replace '.ogkr','' -Split '_')[1]
Write-Debug "diffNumber: $diffNumber"
$diffSuffix = ($DifficultyNumberMappings | Where-Object -Property Number -EQ -Value $diffNumber).Name
if (!$diffSuffix) {
Write-Debug "No match for suffix `"$diffSuffix`""
$UnmatchedChartPaths += $chartFileName
continue
}
Write-Debug "Matched ogkr chart $chartFileName to $diffSuffix"
# Find the associated nyageki chart file
$sourceChartFile = $NyagekiCharts | Where-Object {
$fileNoExtension = $_.FumenFile.Split(".")[0]
$lastWord = $fileNoExtension.Split(" ")[-1]
$diffSuffix -match $lastWord
}
if (!($sourceChartFile)) {
Write-Host -ForegroundColor Red "Unable to find a chart to generate `"$chartFileName`""
return
}
$ChartFileTargetSources.Add($chartFileName, $sourceChartFile.FumenFile)
}
}
$ChartOutDir = "$OutMusicDir/music$SongId"
$SongAudioOutDir = "$OutMusicSourceDir/musicSource$SongId"
$JacketOutDir = "$OutAssetsDir"
if (-Not $Combine) {
$SongOptDir = "$OptionPath/$ChartDirName"
New-Item -Path $SongOptDir -ItemType Directory -Force | Out-Null
New-Item -Path "$SongOptDir/music" -ItemType Directory -Force | Out-Null
New-Item -Path "$SongOptDir/musicSource" -ItemType Directory -Force | Out-Null
New-Item -Path "$SongOptDir/assets" -ItemType Directory -Force | Out-Null
$ChartOutDir = "$SongOptDir/music/music$SongId"
$SongAudioOutDir = "$SongOptDir/musicSource/musicSource$SongId"
$JacketOutDir = "$SongOptDir/assets"
}
New-Item -Path $ChartOutDir -ItemType Directory -Force | Out-Null
New-Item -Path $SongAudioOutDir -ItemType Directory -Force | Out-Null
New-Item -Path $JacketOutDir -ItemType Directory -Force | Out-Null
# If source audio hasn't changed, skip conversion
$SkipAudioConversion = $false
$AcbPath = "$SongAudioOutDir/music$SongId.acb"
if (Test-MusicSourceOutputExists -Path $SongAudioOutDir -MusicId $SongId) {
Write-Debug "Checking dates on $SongAudioOutDir"
$AcbProperty = Get-ItemProperty $AcbPath
if ($AcbProperty.CreationTimeUtc -gt $(Get-MostRecentDateProperty $AudioFilePath)) {
$SkipAudioConversion = $true
}
}
# If source jacket hasn't changed, skip conversion
$SkipJacketConversion = $false
$SourceJacketPath = "$ChartDir/$($SongConfig.Jacket)"
if (Test-JacketAssetsExist -Id $SongId -AssetsPath $JacketOutDir) {
$JacketAssetProperty = Get-ItemProperty "$JacketOutDir/ui_jacket_$SongId"
if ($JacketAssetProperty.CreationTimeUtc -gt $(Get-MostRecentDateProperty $SourceJacketPath)) {
$SkipJacketConversion = $true
}
}
# CONVERSION
# Convert charts
foreach ($outputFile in $ChartFileTargetSources.Keys) {
Write-Debug $ChartFileTargetSources[$outputFile]
$sourceFile = $ChartFileTargetSources[$outputFile]
$sourceFilePath = "$ChartDir/$($ChartFileTargetSources[$outputFile])"
$outputFilePath = "$ChartOutDir/$outputFile"
Write-Host -NoNewline "> $sourceFile -> $outputFile ... "
if (Test-Path $outputFilePath) {
$SkipFile = ((Get-MostRecentDateProperty $outputFilePath) -gt $(Get-MostRecentDateProperty $sourceFilePath))
} else {
$SkipFile = $false
}
$ConversionCommand = "$FumenEditorExe convert --standardize --inputFile `"$sourceFilePath`" --outputFile `"$outputFilePath`""
Start-ConversionStep -ConvertCommand $ConversionCommand -Skip $SkipFile
}
# Convert audio
Write-Host -NoNewline "> $AudioFile -> music$SongId.* ... "
$AcbConvertCommand = "$FumenEditorExe acb --musicId $SongId --inputFile `"$AudioFilePath`" --outputFolder `"$SongAudioOutDir`" --previewBegin $($SongConfig.PreviewBegin) --previewEnd $($SongConfig.PreviewEnd)"
Start-ConversionStep -Skip $SkipAudioConversion -ConvertCommand $AcbConvertCommand
# Convert jacket
Write-Host -NoNewline "> $($SongConfig.Jacket) -> ui_jacket_$SongId* ... "
$JacketConvertCommand = "$FumenEditorExe jacket --musicId $SongId --inputFile `"$SourceJacketPath`" --outputFolder `"$JacketOutDir`""
Start-ConversionStep -Skip $SkipJacketConversion -ConvertCommand $JacketConvertCommand
Write-Host "> Copy Music.xml"
Copy-Item "$ChartDir/Music.xml" $ChartOutDir
}
if ($Deploy) {
Write-Output "Deploying option..."
Copy-Item -Path $OptionDir/* -Destination $DeployPath -Force -Recurse
}
Pause-Console
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment