commit 8bf09565bfc01b91b76b2135d1e5bfd098d75d54 Author: beerpsi Date: Wed May 22 03:29:18 2024 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdfb943 --- /dev/null +++ b/.gitignore @@ -0,0 +1,493 @@ +# Created by https://www.toptal.com/developers/gitignore/api/csharp,rider,dotnetcore +# Edit at https://www.toptal.com/developers/gitignore?templates=csharp,rider,dotnetcore + +### Csharp ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +### DotnetCore ### +# .NET Core build folders +bin/ +obj/ + +# Common node modules locations +/node_modules +/wwwroot/node_modules + +### Rider ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# End of https://www.toptal.com/developers/gitignore/api/csharp,rider,dotnetcore + +External +!External/.gitkeep \ No newline at end of file diff --git a/.idea/.idea.Rizu/.idea/.gitignore b/.idea/.idea.Rizu/.idea/.gitignore new file mode 100644 index 0000000..b396053 --- /dev/null +++ b/.idea/.idea.Rizu/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/projectSettingsUpdater.xml +/.idea.Rizu.iml +/contentModel.xml +/modules.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.Rizu/.idea/encodings.xml b/.idea/.idea.Rizu/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.Rizu/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.Rizu/.idea/indexLayout.xml b/.idea/.idea.Rizu/.idea/indexLayout.xml new file mode 100644 index 0000000..f5a863a --- /dev/null +++ b/.idea/.idea.Rizu/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..21e79e3 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Rizu +A Tachi score hook for maimai DX. + +Tested versions: +- BUDDiES + +## Installation +First, get the [config file](https://kamai.tachi.ac/client-file-flow/CIaa7b4d91041688189231cfc696c0754120b1790b) and +place it in the same folder as the game executable (Sinmai.exe), then follow one of the methods below (**either** +BepInEx **or** hard-patching using MonoMod). + +### BepInEx +#### Installing BepInEx +- Update [segatools](https://gitea.tendokyu.moe/Dniel97/segatools/releases/latest). +- Download [BepInEx 5](https://github.com/BepInEx/BepInEx/releases/tag/v5.4.23.1). +- Extract the `BepInEx` folder from the archive into the base game folder, ignoring other files. +- Edit `segatools.ini`, adding this entry: +```ini +[unity] +targetAssembly=BepInEx\core\BepInEx.Preloader.dll +``` + +#### Installing the MonoMod loader for BepInEx +- Download the [MonoMod loader for BepInEx](https://github.com/BepInEx/BepInEx.MonoMod.Loader/releases/latest) +- Extract the `BepInEx` folder from the archive into the base game folder. + +#### Installing the score hook +- Download `Assembly-CSharp.Rizu.mm.dll` from [releases](https://gitea.tendokyu.moe/beerpsi/Rizu/releases/latest) and +place it in `BepInEx/monomod`. + +In the end, your game directory should look like this: +``` +└───BepInEx + └───monomod + └───Assembly-CSharp.Rizu.mm.dll +├───Sinmai_Data +├───Rizu.cfg +├───Sinmai.exe +└───segatools.ini +``` + +### Hard-patching using MonoMod +- Download [MonoMod](https://github.com/MonoMod/MonoMod/releases/latest). +- Download `Assembly-CSharp.Rizu.mm.dll` from[releases](https://gitea.tendokyu.moe/beerpsi/Rizu/releases/latest) +and place it in `Sinmai_Data/Managed`. +- Run `MonoMod.exe path\to\Sinmai_Data\Managed\Assembly-CSharp.dll` in a command prompt. +- Rename `MONOMODDED_Assembly-CSharp.dll` to `Assembly-CSharp.dll`, optionally backing up the original file. + +## Development +Copy these files from `Sinmai_Data/Managed` into `Rizu/External`: +- `Assembly-CSharp.dll` +- `UnityEngine.dll` +- `UnityEngine.CoreModule.dll` +- `UnityEngine.JSONSerializeModule.dll` +- `UnityEngine.UnityWebRequestModule.dll` + +You will also need to download [MonoMod](https://github.com/MonoMod/MonoMod/releases/latest). and extract to `Rizu/External`. + +After that, the project can be restored and built normally. diff --git a/Rizu.sln b/Rizu.sln new file mode 100644 index 0000000..7519b97 --- /dev/null +++ b/Rizu.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rizu", "Rizu\Rizu.csproj", "{888E076C-8A77-453F-87DC-BC0186FDBB55}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {888E076C-8A77-453F-87DC-BC0186FDBB55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {888E076C-8A77-453F-87DC-BC0186FDBB55}.Debug|Any CPU.Build.0 = Debug|Any CPU + {888E076C-8A77-453F-87DC-BC0186FDBB55}.Release|Any CPU.ActiveCfg = Release|Any CPU + {888E076C-8A77-453F-87DC-BC0186FDBB55}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Rizu/Exporter.cs b/Rizu/Exporter.cs new file mode 100644 index 0000000..f688fa4 --- /dev/null +++ b/Rizu/Exporter.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using MAI2.Util; +using MAI2System; +using Manager; +using Manager.UserDatas; +using Rizu.Models; +using UnityEngine; +using UnityEngine.Networking; +using Path = System.IO.Path; + +namespace Rizu; + +public class Exporter : SingletonMonoBehaviour +{ + protected Exporter() + { + _dontDestroyOnLoad = true; + } + + // ReSharper disable Unity.PerformanceAnalysis + public IEnumerator ExportScore(GameScoreList score) + { + if (!_config.Enabled) + { + yield break; + } + + var user = Singleton.Instance.GetUserData(score.PlayerIndex); + var import = ScoreConversion.CreateScoreBatchManual(score); + + yield return SubmitImport(import, user.Detail.AccessCode); + } + + public IEnumerator ExportDan(UserDetail userDetail) + { + if (!_config.Enabled) + { + yield break; + } + + var import = ScoreConversion.CreateDanBatchManual((int)userDetail.CourseRank); + + if (string.IsNullOrEmpty(import)) + { + yield break; + } + + yield return SubmitImport(import, userDetail.AccessCode); + } + + private string GetTokenForAccessCode(string accessCode) + { + if (accessCode != null && _config.AccessTokens.TryGetValue(accessCode, out var accessToken)) + { + return accessToken; + } + + if (_config.AccessTokens.TryGetValue("default", out accessToken)) + { + return accessToken; + } + + if (accessCode == null) + { + Logger.Error("No `default` token was set for guest plays. Not sending import."); + return null; + } + + Logger.Error( + "Access code {0}******** does not have an associated API key, and no `default` token was set. Not sending import.", + accessCode.Substring(0, 12)); + + return null; + } + + private IEnumerator SubmitImport(string import, string accessCode) + { + var accessToken = GetTokenForAccessCode(accessCode); + + if (accessToken == null) + { + yield break; + } + + Logger.Debug("Sending import to Tachi: {0}", import); + + using var req = new UnityWebRequest(_config.TachiBaseUrl + _config.TachiImportEndpoint); + using var certHandler = new ForceAcceptAllCertificateHandler(); + req.method = UnityWebRequest.kHttpVerbPOST; + req.timeout = _config.NetworkTimeout; + // req.certificateHandler = certHandler; + req.uploadHandler = new UploadHandlerRaw(new UTF8Encoding().GetBytes(import)); + req.downloadHandler = new DownloadHandlerBuffer(); + req.SetRequestHeader("Content-Type", "application/json"); + req.SetRequestHeader("X-User-Intent", "false"); + req.SetRequestHeader("Authorization", $"Bearer {accessToken}"); + + yield return req.SendWebRequest(); + + if (req.isNetworkError || req.isHttpError) + { + Logger.Error("Could not send score to Tachi: {0}", req.error); + + if (string.IsNullOrEmpty(_config.FailedImportsFolder)) + { + yield break; + } + + var filename = $"{accessCode ?? "GUEST"}_{DateTime.Now:yyyyMMdd'_'HHmmss}.json"; + var path = Path.Combine(_config.FailedImportsFolder, filename); + + ThreadPool.QueueUserWorkItem(_ => SaveFailedImport(path, import)); + + Logger.Info("Saved failed import to {0}", path); + + yield break; + } + + TachiResponse resp; + + try + { + resp = JsonUtility.FromJson>(req.downloadHandler.text); + } + catch (Exception e) + { + Logger.Error("Could not parse response from Tachi: {0}", e); + yield break; + } + + if (!resp.success) + { + Logger.Error("Score import not successful: {0}", resp.description); + yield break; + } + + Logger.Info("{0}", resp.description); + + var pollUrl = resp.body?.url; + + if (string.IsNullOrEmpty(pollUrl)) + { + yield break; + } + + Logger.Info("Poll URL: {0}", pollUrl); + + yield return PollImport(pollUrl, accessToken); + } + + private IEnumerator PollImport(string pollUrl, string accessToken) + { + while (true) + { + using var pollReq = UnityWebRequest.Get(pollUrl); + using var certHandler = new ForceAcceptAllCertificateHandler(); + pollReq.timeout = _config.NetworkTimeout; + pollReq.certificateHandler = certHandler; + pollReq.downloadHandler = new DownloadHandlerBuffer(); + pollReq.SetRequestHeader("Authorization", $"Bearer {accessToken}"); + + yield return pollReq.SendWebRequest(); + + TachiResponse pollResp; + + try + { + pollResp = JsonUtility.FromJson>(pollReq.downloadHandler.text); + } + catch (Exception e) + { + Logger.Error("Could not parse response from Tachi: {0}", e); + yield break; + } + + if (!pollResp.success) + { + Logger.Error("Import failed: {0}", pollResp.description); + yield break; + } + + if (pollResp.body.importStatus == "completed") + { + Logger.Info( + "{0} ({1} scores, {2} sessions, {3} errors)", + pollResp.description, + pollResp.body.import.scoreIDs.Length, + pollResp.body.import.createdSessions.Length, + pollResp.body.import.errors.Length); + yield break; + } + + yield return new WaitForSeconds(1); + } + } + + private void SaveFailedImport(string path, string import) + { + var parent = Path.GetDirectoryName(path); + + if (!Directory.Exists(parent) && parent != null) + { + Directory.CreateDirectory(parent); + } + + File.WriteAllText(path, import); + } + + public void LoadConfig() + { + using var ini = new IniFile(".\\Rizu.cfg"); + + _config.Enabled = ini.getValue("General", "Enable", true); + _config.NetworkTimeout = ini.getValue("General", "NetworkTimeout", 30); + _config.FailedImportsFolder = ini.getValue("General", "FailedImportsFolder", ""); + _config.TachiBaseUrl = ini.getValue("Tachi", "BaseUrl", "https://kamai.tachi.ac"); + _config.TachiImportEndpoint = ini.getValue("Tachi", "Import", "/ir/direct-manual/import"); + + var keysSection = ini.findSection("Keys"); + + if (keysSection == null) + { + return; + } + + for (var section = keysSection.childHead; section != null; section = section.next) + { + _config.AccessTokens[section.name] = section.value; + } + + Logger.Info("Loaded configuration"); + Logger.Info("> Enabled: {0}", _config.Enabled); + Logger.Info("> Network timeout: {0}", _config.NetworkTimeout); + Logger.Info("> Tachi import URL: {0}{1}", _config.TachiBaseUrl, _config.TachiImportEndpoint); + Logger.Info("> {0} API key{1} loaded", _config.AccessTokens.Count, _config.AccessTokens.Count != 1 ? "s" : ""); + } + + private readonly Config _config = new(); + + private class Config + { + public bool Enabled; + public int NetworkTimeout; + public string FailedImportsFolder; + public string TachiBaseUrl; + public string TachiImportEndpoint; + public Dictionary AccessTokens = new(); + } +} diff --git a/Rizu/External/.gitkeep b/Rizu/External/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Rizu/ForceAcceptAllCertificateHandler.cs b/Rizu/ForceAcceptAllCertificateHandler.cs new file mode 100644 index 0000000..0b3031a --- /dev/null +++ b/Rizu/ForceAcceptAllCertificateHandler.cs @@ -0,0 +1,11 @@ +using UnityEngine.Networking; + +namespace Rizu; + +public class ForceAcceptAllCertificateHandler : CertificateHandler +{ + protected override bool ValidateCertificate(byte[] certificateData) + { + return true; + } +} \ No newline at end of file diff --git a/Rizu/Logger.cs b/Rizu/Logger.cs new file mode 100644 index 0000000..4e6bb66 --- /dev/null +++ b/Rizu/Logger.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using JetBrains.Annotations; + +namespace Rizu; + +// UnityEngine.Debug logs don't show up in BepInEx logs for some reason +public static class Logger +{ + private static readonly string LogPrefix = "[Rizu]"; + + [Conditional("DEBUG")] + public static void Debug(string msg) + { + Log("DEBUG", msg); + } + + [Conditional("DEBUG")] + [StringFormatMethod("format")] + public static void Debug(string format, params object[] args) + { + Log("DEBUG", format, args); + } + + public static void Info(string msg) + { + Log("INFO", msg); + } + + [StringFormatMethod("format")] + public static void Info(string format, params object[] args) + { + Log("INFO", format, args); + } + + public static void Error(string msg) + { + Log("ERROR", msg); + } + + [StringFormatMethod("format")] + public static void Error(string format, params object[] args) + { + Log("ERROR", format, args); + } + + private static void Log(string level, string msg) + { + + System.Console.WriteLine($"{LogPrefix} [{level}] {msg}"); + } + + [StringFormatMethod("format")] + private static void Log(string level, string format, params object[] args) + { + System.Console.WriteLine($"{LogPrefix} [{level}] {format}", args); + } + +} \ No newline at end of file diff --git a/Rizu/Models/BatchManual.cs b/Rizu/Models/BatchManual.cs new file mode 100644 index 0000000..e0e18bf --- /dev/null +++ b/Rizu/Models/BatchManual.cs @@ -0,0 +1,11 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class BatchManual +{ + public BatchManualMeta meta = new(); + public BatchManualScore[] scores; + public BatchManualMatchingClass classes; +} diff --git a/Rizu/Models/BatchManualDan.cs b/Rizu/Models/BatchManualDan.cs new file mode 100644 index 0000000..46a09a1 --- /dev/null +++ b/Rizu/Models/BatchManualDan.cs @@ -0,0 +1,9 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class BatchManualDan +{ + public string dan; +} \ No newline at end of file diff --git a/Rizu/Models/BatchManualMatchingClass.cs b/Rizu/Models/BatchManualMatchingClass.cs new file mode 100644 index 0000000..f5cc639 --- /dev/null +++ b/Rizu/Models/BatchManualMatchingClass.cs @@ -0,0 +1,9 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class BatchManualMatchingClass +{ + public string matchingClass; +} diff --git a/Rizu/Models/BatchManualMeta.cs b/Rizu/Models/BatchManualMeta.cs new file mode 100644 index 0000000..4ff3c03 --- /dev/null +++ b/Rizu/Models/BatchManualMeta.cs @@ -0,0 +1,11 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class BatchManualMeta +{ + public string game = "maimaidx"; + public string playtype = "Single"; + public string service = "Rizu"; +} diff --git a/Rizu/Models/BatchManualOptional.cs b/Rizu/Models/BatchManualOptional.cs new file mode 100644 index 0000000..4b7d61b --- /dev/null +++ b/Rizu/Models/BatchManualOptional.cs @@ -0,0 +1,11 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class BatchManualOptional +{ + public uint fast; + public uint slow; + public uint maxCombo; +} diff --git a/Rizu/Models/BatchManualRankUp.cs b/Rizu/Models/BatchManualRankUp.cs new file mode 100644 index 0000000..0ef0131 --- /dev/null +++ b/Rizu/Models/BatchManualRankUp.cs @@ -0,0 +1,11 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class BatchManualRankUp +{ + public BatchManualMeta meta = new(); + public BatchManualScore[] scores; + public BatchManualDan classes; +} \ No newline at end of file diff --git a/Rizu/Models/BatchManualResponseBody.cs b/Rizu/Models/BatchManualResponseBody.cs new file mode 100644 index 0000000..7fa7acd --- /dev/null +++ b/Rizu/Models/BatchManualResponseBody.cs @@ -0,0 +1,9 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class BatchManualResponseBody +{ + public string url; +} diff --git a/Rizu/Models/BatchManualScore.cs b/Rizu/Models/BatchManualScore.cs new file mode 100644 index 0000000..6a6cbf4 --- /dev/null +++ b/Rizu/Models/BatchManualScore.cs @@ -0,0 +1,16 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class BatchManualScore +{ + public float percent; + public string lamp; + public string matchType = "songTitle"; + public string identifier; + public string difficulty; + public long timeAchieved; + public BatchManualScoreJudgements judgements; + public BatchManualOptional optional; +} diff --git a/Rizu/Models/BatchManualScoreJudgements.cs b/Rizu/Models/BatchManualScoreJudgements.cs new file mode 100644 index 0000000..127352a --- /dev/null +++ b/Rizu/Models/BatchManualScoreJudgements.cs @@ -0,0 +1,13 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class BatchManualScoreJudgements +{ + public uint pcrit; + public uint perfect; + public uint great; + public uint good; + public uint miss; +} diff --git a/Rizu/Models/ImportDocument.cs b/Rizu/Models/ImportDocument.cs new file mode 100644 index 0000000..13dbce1 --- /dev/null +++ b/Rizu/Models/ImportDocument.cs @@ -0,0 +1,11 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class ImportDocument +{ + public string[] scoreIDs; + public ImportErrContent[] errors; + public SessionInfoReturn[] createdSessions; +} diff --git a/Rizu/Models/ImportErrContent.cs b/Rizu/Models/ImportErrContent.cs new file mode 100644 index 0000000..98ff261 --- /dev/null +++ b/Rizu/Models/ImportErrContent.cs @@ -0,0 +1,10 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class ImportErrContent +{ + public string type; + public string message; +} \ No newline at end of file diff --git a/Rizu/Models/ImportProgress.cs b/Rizu/Models/ImportProgress.cs new file mode 100644 index 0000000..22f36ae --- /dev/null +++ b/Rizu/Models/ImportProgress.cs @@ -0,0 +1,10 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class ImportProgress +{ + public string description; + public int value; +} diff --git a/Rizu/Models/ImportStatusResponseBody.cs b/Rizu/Models/ImportStatusResponseBody.cs new file mode 100644 index 0000000..9aae759 --- /dev/null +++ b/Rizu/Models/ImportStatusResponseBody.cs @@ -0,0 +1,11 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class ImportStatusResponseBody +{ + public string importStatus; + public ImportProgress progress; + public ImportDocument import; +} diff --git a/Rizu/Models/SessionInfoReturn.cs b/Rizu/Models/SessionInfoReturn.cs new file mode 100644 index 0000000..a8e6a9b --- /dev/null +++ b/Rizu/Models/SessionInfoReturn.cs @@ -0,0 +1,10 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class SessionInfoReturn +{ + public string type; + public string sessionID; +} \ No newline at end of file diff --git a/Rizu/Models/TachiResponse.cs b/Rizu/Models/TachiResponse.cs new file mode 100644 index 0000000..8c3abbf --- /dev/null +++ b/Rizu/Models/TachiResponse.cs @@ -0,0 +1,11 @@ +using System; + +namespace Rizu.Models; + +[Serializable] +public class TachiResponse +{ + public bool success; + public string description; + public T body; +} diff --git a/Rizu/Properties/AssemblyInfo.cs b/Rizu/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..4ebea15 --- /dev/null +++ b/Rizu/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Rizu")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Rizu")] +[assembly: AssemblyCopyright("Copyright © 2024")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("888E076C-8A77-453F-87DC-BC0186FDBB55")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/Rizu/Resources/Rizu.cfg b/Rizu/Resources/Rizu.cfg new file mode 100644 index 0000000..d2cd608 --- /dev/null +++ b/Rizu/Resources/Rizu.cfg @@ -0,0 +1,38 @@ +[General] + +## Whether to enable score submissions +# Setting type: Boolean +# Default value: true +Enable = true + +## Timeout for score submission in seconds +# Setting type: Int32 +# Default value: 30 +NetworkTimeout = 30 + +## Folder for storing imports that failed due to network errors. +## Leave empty to disable +# Setting type: String +# Default value: +FailedImportsFolder = + +[Keys] + +## Kamaitachi API keys to use for score submissions, in the format +## of ` = `. The `default` key is used as fallback +## if an access code does not have an API key set, and can be removed. +# Setting type: String +# Default value: +default = + +[Tachi] + +## Tachi instance base URL +# Setting type: String +# Default value: https://kamai.tachi.ac +BaseUrl = https://kamai.tachi.ac + +## Tachi score import endpoint +# Setting type: String +# Default value: /ir/direct-manual/import +Import = /ir/direct-manual/import diff --git a/Rizu/Rizu.csproj b/Rizu/Rizu.csproj new file mode 100644 index 0000000..417271c --- /dev/null +++ b/Rizu/Rizu.csproj @@ -0,0 +1,111 @@ + + + + + Debug + AnyCPU + {888E076C-8A77-453F-87DC-BC0186FDBB55} + Library + Properties + Rizu + Assembly-CSharp.Rizu.mm + net46 + 512 + latest + + https://api.nuget.org/v3/index.json; + https://nuget.bepinex.dev/v3/index.json; + https://nuget.samboy.dev/v3/index.json + + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + External\Assembly-CSharp.dll + False + + + External\MonoMod.exe + False + + + External\UnityEngine.dll + False + + + External\UnityEngine.CoreModule.dll + False + + + External\UnityEngine.JSONSerializeModule.dll + False + + + External\UnityEngine.UnityWebRequestModule.dll + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Rizu/ScoreConversion.cs b/Rizu/ScoreConversion.cs new file mode 100644 index 0000000..eba5133 --- /dev/null +++ b/Rizu/ScoreConversion.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using DB; +using MAI2.Util; +using Manager; +using Rizu.Models; +using UnityEngine; + +namespace Rizu; + +public static class ScoreConversion +{ + private static readonly string[] Difficulties = ["Basic", "Advanced", "Expert", "Master", "Re:Master"]; + + private static readonly Dictionary DanIDToName = new() + { + { 1, "DAN_1" }, + { 2, "DAN_2" }, + { 3, "DAN_3" }, + { 4, "DAN_4" }, + { 5, "DAN_5" }, + { 6, "DAN_6" }, + { 7, "DAN_7" }, + { 8, "DAN_8" }, + { 9, "DAN_9" }, + { 10, "DAN_10" }, + { 12, "SHINDAN_1" }, + { 13, "SHINDAN_2" }, + { 14, "SHINDAN_3" }, + { 15, "SHINDAN_4" }, + { 16, "SHINDAN_5" }, + { 17, "SHINDAN_6" }, + { 18, "SHINDAN_7" }, + { 19, "SHINDAN_8" }, + { 20, "SHINDAN_9" }, + { 21, "SHINDAN_10" }, + { 22, "SHINKAIDEN" }, + { 23, "URAKAIDEN" }, + }; + + private const int KIRISAGI_STATION_MUSIC_ID = 11422; + private const int LINK_ORIGINAL_MUSIC_ID = 131; + + public static string CreateScoreBatchManual(GameScoreList scoreList) + { + var music = Singleton.Instance.GetMusic(scoreList.SessionInfo.musicId); + var score = new BatchManualScore + { + percent = (float)scoreList.Achivement, + lamp = scoreList.ComboType switch + { + PlayComboflagID.AllPerfectPlus => "ALL PERFECT+", + PlayComboflagID.AllPerfect => "ALL PERFECT", + PlayComboflagID.Gold => "FULL COMBO+", + PlayComboflagID.Silver => "FULL COMBO", + _ => scoreList.IsClear ? "CLEAR" : "FAILED", + }, + identifier = music.name.str, + difficulty = (scoreList.SessionInfo.musicId >= 10000 ? "DX " : "") + Difficulties[scoreList.SessionInfo.difficulty], + timeAchieved = scoreList.UnixTime * 1000L, + judgements = new BatchManualScoreJudgements + { + // .TrueCriticalNum stores the actual number of criticals, + // even when critical judgements are hidden. + pcrit = scoreList.TrueCriticalNum, + perfect = scoreList.TruePerfectNum, + great = scoreList.GreatNum, + good = scoreList.GoodNum, + miss = scoreList.MissNum, + }, + optional = new BatchManualOptional + { + fast = scoreList.Fast, + slow = scoreList.Late, + maxCombo = scoreList.MaxCombo, + } + }; + + // Kirasagi Station, title in-game is U+3000 but title in Tachi is empty + if (scoreList.SessionInfo.musicId == KIRISAGI_STATION_MUSIC_ID) + { + score.identifier = ""; + } + + // There are two songs named Link. + if (score.identifier == "Link") + { + // IDs from https://github.com/TNG-dev/Tachi/blob/staging/database-seeds/collections/songs-maimaidx.json + score.identifier = scoreList.SessionInfo.musicId == LINK_ORIGINAL_MUSIC_ID ? "68" : "244"; + score.matchType = "tachiSongID"; + } + + return JsonUtility.ToJson(new BatchManual + { + meta = new BatchManualMeta(), + scores = [score], + classes = new BatchManualMatchingClass + { + matchingClass = scoreList.Dan.ToString().Replace("Class_", ""), + }, + }); + } + + public static string CreateDanBatchManual(int rankID) + { + if (!DanIDToName.TryGetValue(rankID, out var dan)) + { + System.Console.WriteLine("[Rizu] Unknown course rank ID {0}", rankID); + return null; + } + + return JsonUtility.ToJson(new BatchManualRankUp + { + meta = new BatchManualMeta(), + scores = [], + classes = new BatchManualDan { dan = dan }, + }); + } +} diff --git a/Rizu/patch_AmManager.cs b/Rizu/patch_AmManager.cs new file mode 100644 index 0000000..2c569bb --- /dev/null +++ b/Rizu/patch_AmManager.cs @@ -0,0 +1,21 @@ +// ReSharper disable CheckNamespace +// ReSharper disable InconsistentNaming + +using MAI2.Util; +using UnityEngine; + +namespace Manager; + +public class patch_AmManager : AmManager +{ + private extern void orig_Execute_WaitAmDaemonReady(); + + private void Execute_WaitAmDaemonReady() + { + var go = new GameObject { name = "Rizu" }; + go.AddComponent(); + SingletonMonoBehaviour.instance.LoadConfig(); + + orig_Execute_WaitAmDaemonReady(); + } +} \ No newline at end of file diff --git a/Rizu/patch_GameScoreList.cs b/Rizu/patch_GameScoreList.cs new file mode 100644 index 0000000..7631fa2 --- /dev/null +++ b/Rizu/patch_GameScoreList.cs @@ -0,0 +1,30 @@ +// ReSharper disable CheckNamespace +// ReSharper disable InconsistentNaming + +using MAI2.Util; + +namespace Manager; + +public class patch_GameScoreList(int index) : GameScoreList(index) +{ + private extern void orig_SetPlayAfterRate(int musicRate, int danRate, UdemaeID dan, int classValue); + + public new void SetPlayAfterRate(int musicRate, int danRate, UdemaeID dan, int classValue) + { + orig_SetPlayAfterRate(musicRate, danRate, dan, classValue); + + if (!IsEnable) + { + return; + } + + if (SessionInfo.isAdvDemo || SessionInfo.isTutorial || !string.IsNullOrEmpty(SessionInfo.utageKanjiText)) + { + return; + } + + var exporter = SingletonMonoBehaviour.instance; + + exporter.StartCoroutine(exporter.ExportScore(this)); + } +} diff --git a/Rizu/patch_UserDetail.cs b/Rizu/patch_UserDetail.cs new file mode 100644 index 0000000..5c98f99 --- /dev/null +++ b/Rizu/patch_UserDetail.cs @@ -0,0 +1,33 @@ +// ReSharper disable CheckNamespace +// ReSharper disable InconsistentNaming + +using MAI2.Util; + +namespace Manager.UserDatas; + +public class patch_UserDetail : UserDetail +{ + private extern void orig_set_CourseRank(uint value); + + public void set_CourseRank(uint value) + { + // Don't send an import if it's the same rank + if (value == CourseRank) + { + orig_set_CourseRank(value); + return; + } + + orig_set_CourseRank(value); + + // Don't send an import if it's rank 0 (invalid) + if (value == 0) + { + return; + } + + var exporter = SingletonMonoBehaviour.instance; + + exporter.StartCoroutine(exporter.ExportDan(this)); + } +}