Last active
February 20, 2025 02:41
-
-
Save zacharied/46defbda7ea3e9ae4c8955f38e664443 to your computer and use it in GitHub Desktop.
Create an SDDT option file from fumen editor outputs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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