commit e8b8caf378f9436b63f3be4870492772c1510311 Author: akanyan Date: Mon May 20 07:24:05 2024 +0900 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4d0d0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.dll +*.csproj +bin/ +obj/ \ No newline at end of file diff --git a/Inohara.csproj.template b/Inohara.csproj.template new file mode 100644 index 0000000..c0345fb --- /dev/null +++ b/Inohara.csproj.template @@ -0,0 +1,19 @@ + + + net35 + Assembly-CSharp.Inohara.mm + 7EVENDAYS⇔HOLIDAYS + Tachi exporter for mu3 + 1.0.0 + 11.0 + true + latest + x64 + + + + + + + + diff --git a/Inohara/DataTypes.cs b/Inohara/DataTypes.cs new file mode 100644 index 0000000..aecbc51 --- /dev/null +++ b/Inohara/DataTypes.cs @@ -0,0 +1,92 @@ +using System; + +namespace Inohara; + +/** + * Batch manual + */ + +[Serializable] +public struct BatchManual { + public BatchMeta meta; + public BatchScore[] scores; +} + +[Serializable] +public struct BatchMeta { + public string game; + public string playtype; + public string service; +} + +[Serializable] +public struct BatchScore { + public int score; + public string difficulty; + public UInt64 timeAchieved; + public string noteLamp; + public string bellLamp; + public string matchType; + public string identifier; + public BatchJudgements judgements; + public BatchOptional optional; +} + +[Serializable] +public struct BatchJudgements { + public int cbreak; + public int breakMyBonesIWill; + public int hit; + public int miss; +} + +[Serializable] +public struct BatchOptional { + public int fast; + public int slow; + public int bellCount; + public int totalBellCount; + public int damage; + public int platScore; +} + +/** + * BM response 1 + */ + +[Serializable] +public struct BatchResponse { + public bool success; + public BatchResponseBody body; +} + +[Serializable] +public struct BatchResponseBody { + public string url; +} + +/** + * BM response 2 + */ + +[Serializable] +public struct BatchResponse2 { + public bool success; +} + +/** + * API status response + */ + +[Serializable] +public struct StatusResponse { + public bool success; + public StatusBody body; +} + +[Serializable] +public struct StatusBody { + public string version; + public string whoami; + public string[] permissions; +} \ No newline at end of file diff --git a/Inohara/Exporter.cs b/Inohara/Exporter.cs new file mode 100644 index 0000000..78b0901 --- /dev/null +++ b/Inohara/Exporter.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using MU3.CustomUI; +using MU3.Game; +using MU3.User; +using MU3.Util; +using UnityEngine; +using UnityEngine.Networking; +using UnityEngine.UI; + +namespace Inohara; + +public class Exporter: SingletonMonoBehaviour { + private struct Config { + public bool Enable; + public int Timeout; + public string BaseUrl; + public string StatusPoint; + public string ImportPoint; + public bool EnableText; + } + static readonly float REQ2_DELAY = 4f; + static readonly float DRAW_DURATION = 4f; + protected new bool _dontDestroyOnLoad = true; + private Config _cfg; + private readonly Dictionary _tokens = new(); + private string _currToken = ""; + private readonly Font _arial = Resources.GetBuiltinResource("Arial.ttf"); + private List _scores = new(); + + private void Log(object o, bool text) { + Debug.Log("[Inohara] " + o.ToString()); + if(_cfg.EnableText && text) { + StartCoroutine(DrawMessage(o.ToString(), new Color(1f, 1f, 1f, 1.0f))); + } + } + + private void LogSuccess(object o) { + Debug.Log("[Inohara] " + o.ToString()); + if(_cfg.EnableText) { + StartCoroutine(DrawMessage(o.ToString(), new Color(0f, 0.83f, 0.14f, 1.0f))); + } + } + + private void LogError(object o) { + Debug.LogError("[Inohara] " + o.ToString()); + if(_cfg.EnableText) { + StartCoroutine(DrawMessage(o.ToString(), new Color(1f, 0.42f, 0.42f, 1.0f))); + } + } + + public void LoadCfg() { + try { + using StreamReader reader = new(Path.Combine(Application.dataPath, "../inohara.cfg")); + Dictionary options = new(); + Dictionary dict = null; + string line; + while ((line = reader.ReadLine()) != null) { + line = line.Split(new char[] { '#' }, 2)[0]; + if(line.ToLower() == "[keys]") { + dict = _tokens; + } else if(line.ToLower() == "[options]") { + dict = options; + } else if(line.StartsWith("[")) { + dict = null; + } else if(dict != null) { + string[] tokens = line.Split(new char[] { '=' }, 2); + if(tokens.Length == 2) { + dict.Add(tokens[0].Trim().ToLower(), tokens[1].Trim()); + } + } + } + + if(!options.ContainsKey("enable") || !bool.TryParse(options["enable"], out _cfg.Enable)) { + _cfg.Enable = false; + } + if(!options.ContainsKey("enableosd") || !bool.TryParse(options["enableosd"], out _cfg.EnableText)) { + _cfg.EnableText = false; + } + if(!options.ContainsKey("timeout") || !int.TryParse(options["timeout"], out _cfg.Timeout)) { + _cfg.Timeout = 3; + } + if(!options.TryGetValue("baseurl", out _cfg.BaseUrl)) { + LogError("Config error: missing option BaseUrl"); + _cfg.Enable = false; + } + if(!options.TryGetValue("status", out _cfg.StatusPoint)) { + _cfg.StatusPoint = "/api/v1/status"; + } + if(!options.TryGetValue("import", out _cfg.ImportPoint)) { + _cfg.ImportPoint = "/ir/direct-manual/import"; + } + } catch (IOException e) { + LogError("Config loading failed: " + e); + _cfg.Enable = false; + } + + if(_cfg.Enable) { + Log("Score submissions enabled", true); + } else { + Log("Score submissions disabled", true); + } + } + + public void Authorize() { + if(!_cfg.Enable) { + return; + } + + var username = Singleton.instance.UserName + .Normalize(NormalizationForm.FormKC) + .ToLower(); + + _currToken = ""; + _scores.Clear(); + + string tmpBearer; + + if(!_tokens.ContainsKey(username) && !_tokens.ContainsKey("*")) { + LogError("User " + username + " not found"); + return; + } else { + if(!_tokens.TryGetValue(username, out tmpBearer)) { + tmpBearer = _tokens["*"]; + } + } + + StartCoroutine(AuthorizeReq(tmpBearer)); + } + + private IEnumerator AuthorizeReq(string tmpBearer) { + using var req = new UnityWebRequest(_cfg.BaseUrl + _cfg.StatusPoint, "GET"); + req.SetRequestHeader("Authorization", "Bearer " + tmpBearer); + req.downloadHandler = new DownloadHandlerBuffer(); + + yield return req.Send(); + + try { + if(req.responseCode == 200) { + var res = JsonUtility.FromJson(req.downloadHandler.text); + if(res.success == true) { + if(res.body.permissions.Contains("submit_score")) { + LogSuccess("Logged in"); + Log(string.Format("Tachi {0} (user id={1})", res.body.version, res.body.whoami), false); + _currToken = tmpBearer; + } else { + LogError("Missing the submit_score permission"); + } + } else { + LogError("Unable to log into Tachi"); + } + } + } catch(Exception e) { + LogError("Unable to log into Tachi: " + e); + } + } + + public void Export(BattleResult result, SessionInfo info) { + if(_currToken == "" || !_cfg.Enable) { + return; + } + + StartCoroutine(ExportReq(result, info)); + } + + private IEnumerator ExportReq(BattleResult result, SessionInfo info) { + using var req = new UnityWebRequest(_cfg.BaseUrl + _cfg.ImportPoint, "POST"); + req.timeout = _cfg.Timeout; + req.SetRequestHeader("Authorization", "Bearer " + _currToken); + req.SetRequestHeader("Content-Type", "application/json"); + + _scores.Add(Util.CreateScore(result, info)); + var batch = Util.CreateBatch(_scores); + + byte[] jsonToSend = new UTF8Encoding().GetBytes(batch); + req.uploadHandler = new UploadHandlerRaw(jsonToSend); + req.downloadHandler = new DownloadHandlerBuffer(); + + Log("Uploading " + _scores.Count + " score(s)", false); + + yield return req.Send(); + + if(req.responseCode == 200) { + LogSuccess("Upload successful"); + Log("Warning: Non-deferred DIRECT-MANUAL is untested", false); + _scores.Clear(); + } else if (req.responseCode == 202) { + var res = JsonUtility.FromJson(req.downloadHandler.text); + if(res.success == true) { + StartCoroutine(ExportReq2(res.body.url)); + } else { + LogError("Upload failed: " + res.body); + } + } else { + LogError(string.Format("Upload failed ({0}): {1}", req.responseCode, req.error)); + } + } + + private IEnumerator ExportReq2(string url) { + yield return new WaitForSeconds(REQ2_DELAY); + + using var req = new UnityWebRequest(url, "GET"); + req.downloadHandler = new DownloadHandlerBuffer(); + + yield return req.Send(); + + if(req.responseCode == 200) { + var res = JsonUtility.FromJson(req.downloadHandler.text); + if(res.success == true) { + LogSuccess("Upload successful"); + _scores.Clear(); + } else { + LogError("Upload failed"); + } + } else { + LogError(string.Format("Upload failed ({0}): {1}", req.responseCode, req.error)); + } + } + + // This is just for fun + private IEnumerator DrawMessage(string message, Color color) { + GameObject canvasGO = new() { + name = "Canvas" + }; + DontDestroyOnLoad(canvasGO); + canvasGO.AddComponent(); + canvasGO.AddComponent(); + canvasGO.AddComponent(); + + Canvas canvas = canvasGO.GetComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + + GameObject textGO = new(); + textGO.transform.parent = canvasGO.transform; + textGO.AddComponent(); + var text = textGO.GetComponent(); + text.font = _arial; + text.text = message; + text.fontSize = 20; + text.color = color; + text.alignment = TextAnchor.UpperCenter; + + RectTransform rectTransform = text.GetComponent(); + rectTransform.localPosition = new Vector3(0, 0, 0); + rectTransform.sizeDelta = new Vector2(1080, 1400); + + yield return new WaitForSeconds(DRAW_DURATION); + + Destroy(canvasGO); + Destroy(text); + } +} \ No newline at end of file diff --git a/Inohara/Util.cs b/Inohara/Util.cs new file mode 100644 index 0000000..5087baa --- /dev/null +++ b/Inohara/Util.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using MU3.Battle; +using MU3.DataStudio; +using MU3.Game; +using UnityEngine; + +namespace Inohara; + +class Util { + public static string GetLamp(BattleResult result) { + if(result.playResult == PlayResult.Failed) { + return "LOSS"; + } + if(result.notesComboResult == NotesComboResult.None) { + return "CLEAR"; + } + if(result.notesComboResult == NotesComboResult.FullCombo) { + return "FULL COMBO"; + } + if(result.notesComboResult == NotesComboResult.AllBreak) { + return "ALL BREAK"; + } + return "INVALID"; + } + + public static string GetStringDiff(FumenDifficulty diff) { + return diff switch { + FumenDifficulty.Basic => "BASIC", + FumenDifficulty.Advanced => "ADVANCED", + FumenDifficulty.Expert => "EXPERT", + FumenDifficulty.Master => "MASTER", + FumenDifficulty.Lunatic => "LUNATIC", + _ => "INVALID", + }; + } + + public static BatchScore CreateScore(BattleResult result, SessionInfo info) { + var timestampSec = (int)DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalSeconds; + return new BatchScore { + score = result.technicalScore, + difficulty = GetStringDiff(info.musicLevel), + timeAchieved = (ulong)timestampSec * 1000, + matchType = "inGameID", + identifier = info.musicData.id.ToString(), + bellLamp = result.bellComboResult == BellComboResult.None ? "NONE" : "FULL BELL", + noteLamp = GetLamp(result), + optional = new BatchOptional() { + fast = result.numNotesFast, + slow = result.numNotesLate, + bellCount = result.numBellCatch, + totalBellCount = result.numBellAny, + damage = result.countDamage, + platScore = result.platinumScore + }, + judgements = new BatchJudgements() { + cbreak = result.numNotesCBreak, + breakMyBonesIWill = result.numNotesBreak, + hit = result.numNotesHit, + miss = result.numNotesMiss + } + }; + } + + public static string CreateBatch(List scores) { + var bm = new BatchManual { + meta = new BatchMeta { + game = "ongeki", + playtype = "Single", + service = "inohara" + }, + scores = scores.ToArray() + }; + + return JsonUtility.ToJson(bm).Replace("breakMyBonesIWill", "break"); + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..00d2e13 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to \ No newline at end of file diff --git a/MU3.App/patch_ApplicationMU3.cs b/MU3.App/patch_ApplicationMU3.cs new file mode 100644 index 0000000..13d4cc7 --- /dev/null +++ b/MU3.App/patch_ApplicationMU3.cs @@ -0,0 +1,24 @@ +#pragma warning disable CS0626 +#pragma warning disable CS0649 +#pragma warning disable IDE0051 +#pragma warning disable IDE1006 + +using UnityEngine; +using MU3.Util; + +namespace MU3.App; + +public class patch_ApplicationMU3 : ApplicationMU3 { + private extern void orig_Execute_WaitAMDaemonReady(); + + private void Execute_WaitAMDaemonReady() { + GameObject go = new() { + name = "Inohara" + }; + go.AddComponent(); + + DontDestroyOnLoad(go); + orig_Execute_WaitAMDaemonReady(); + SingletonMonoBehaviour.instance.LoadCfg(); + } +} diff --git a/MU3.Battle/patch_GameEngine.cs b/MU3.Battle/patch_GameEngine.cs new file mode 100644 index 0000000..2847eea --- /dev/null +++ b/MU3.Battle/patch_GameEngine.cs @@ -0,0 +1,20 @@ +#pragma warning disable CS0626 +#pragma warning disable CS0649 +#pragma warning disable IDE0051 +#pragma warning disable IDE1006 + +using MU3.Game; +using MU3.Util; + +namespace MU3.Battle; + +public class patch_GameEngine : GameEngine { + private SessionInfo _sessionInfo; + + private extern void orig_applyResultToUserData(SessionResult sessionResult); + + private void applyResultToUserData(SessionResult sessionResult) { + SingletonMonoBehaviour.instance.Export(sessionResult.battleResult, _sessionInfo); + orig_applyResultToUserData(sessionResult); + } +} diff --git a/MU3/patch_Scene_25_Login.cs b/MU3/patch_Scene_25_Login.cs new file mode 100644 index 0000000..4a00639 --- /dev/null +++ b/MU3/patch_Scene_25_Login.cs @@ -0,0 +1,18 @@ +#pragma warning disable CS0626 +#pragma warning disable CS0649 +#pragma warning disable IDE0051 +#pragma warning disable IDE1006 + +using MU3.Util; + +namespace MU3; + +public class patch_Scene_25_Login : Scene_25_Login { + + private extern void orig_finishAime(); + + private void finishAime() { + orig_finishAime(); + SingletonMonoBehaviour.instance.Authorize(); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3695d74 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +## Inohara + +A µ3 score exporter for [Tachi](https://github.com/zkldi/Tachi). + +### Supported versions +- 1.39 +- 1.40 +- 1.45 + +### Installation +Get the config file [here](https://kamai.tachi.ac/client-file-flow/CIa914320cd344a8db712cf0c99254c205ca940463), download the DLL [here](https://gitea.tendokyu.moe/akanyan/inohara/releases) and follow one of the methods below. + +#### The BepInEx method (recommended) +- Download [BepInEx](https://github.com/BepInEx/BepInEx/releases/) +- Copy the `BepInEx` directory into the base game directory (where `mu3.exe` is); omit `winhttp.dll` +- Modify this entry in `segatools.ini`: + ```ini + [unity] + targetAssembly=BepInEx\core\BepInEx.Preloader.dll + ``` + - If you don't have this entry, update segatools. + - If you insist on not updating segatools, instead copy `winhttp.dll` and rename it to `version.dll`. +- Move `Assembly-CSharp.Inohara.mm.dll` to `BepInEx\monomod` +- Put `inohara.cfg` in the base game directory (next to `mu3.exe`) + +#### The MonoMod method +- Download [MonoMod](https://github.com/MonoMod/MonoMod/releases) +- Copy `Assembly-CSharp.Inohara.mm.dll` into `mu3_Data\Managed` +- Run `MonoMod.exe mu3_Data\Managed\Assembly-CSharp.dll` +- Backup `Assembly-CSharp.dll` +- Rename `MONOMODDED_Assembly-CSharp.dll` to `Assembly-CSharp.dll` +- Move `inohara.cfg` to the base game directory (next to `mu3.exe`) + +### Usage +Scores are sent after each play and that's it. You can nonetheless make sure it's running by checking the console or toggling `EnableOSD`. + +### Building +Provide your own `Assembly-CSharp.dll` (or `_unpacked`) and `UnityEngine.UI.dll`, then `dotnet restore`, `dotnet build`. \ No newline at end of file diff --git a/inohara.cfg.example b/inohara.cfg.example new file mode 100644 index 0000000..df66a50 --- /dev/null +++ b/inohara.cfg.example @@ -0,0 +1,22 @@ +[Options] +# Whether to enable score submissions +Enable = true +# Timeout for web requests, in seconds +Timeout = 3 +# Tachi instance base URL +BaseUrl = +# Tachi status endpoint +Status = /api/v1/status +# Tachi score import endpoint +Import = /ir/direct-manual/import +# Display status on-screen (rudimentarily) +EnableOSD = false + +[Keys] +* = %%TACHI_KEY%% + +# If you have a multi-user setup, you can configure +# keys per-profile: +# InGameUsername = TachiApiKey +# +# An * matches everyone else \ No newline at end of file