initial commit

This commit is contained in:
あかニャン 2024-05-20 07:24:05 +09:00
commit e8b8caf378
11 changed files with 594 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.dll
*.csproj
bin/
obj/

19
Inohara.csproj.template Normal file
View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net35</TargetFramework>
<AssemblyName>Assembly-CSharp.Inohara.mm</AssemblyName>
<Company>7EVENDAYS⇔HOLIDAYS</Company>
<Description>Tachi exporter for mu3</Description>
<Version>1.0.0</Version>
<LangVersion>11.0</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>latest</LangVersion>
<Platform>x64</Platform>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="UnityEngine.Modules" Version="5.6.4" IncludeAssets="compile" />
<Reference Include="UnityEngine.UI"><HintPath></HintPath></Reference>
<Reference Include="Mu3Assembly"><HintPath></HintPath></Reference>
</ItemGroup>
</Project>

92
Inohara/DataTypes.cs Normal file
View File

@ -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;
}

256
Inohara/Exporter.cs Normal file
View File

@ -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<Exporter> {
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<string, string> _tokens = new();
private string _currToken = "";
private readonly Font _arial = Resources.GetBuiltinResource<Font>("Arial.ttf");
private List<BatchScore> _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<string, string> options = new();
Dictionary<string, string> 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<UserManager>.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<StatusResponse>(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<BatchResponse>(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<BatchResponse2>(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<Canvas>();
canvasGO.AddComponent<CanvasScaler>();
canvasGO.AddComponent<GraphicRaycaster>();
Canvas canvas = canvasGO.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
GameObject textGO = new();
textGO.transform.parent = canvasGO.transform;
textGO.AddComponent<MU3Text>();
var text = textGO.GetComponent<MU3Text>();
text.font = _arial;
text.text = message;
text.fontSize = 20;
text.color = color;
text.alignment = TextAnchor.UpperCenter;
RectTransform rectTransform = text.GetComponent<RectTransform>();
rectTransform.localPosition = new Vector3(0, 0, 0);
rectTransform.sizeDelta = new Vector2(1080, 1400);
yield return new WaitForSeconds(DRAW_DURATION);
Destroy(canvasGO);
Destroy(text);
}
}

77
Inohara/Util.cs Normal file
View File

@ -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<BatchScore> scores) {
var bm = new BatchManual {
meta = new BatchMeta {
game = "ongeki",
playtype = "Single",
service = "inohara"
},
scores = scores.ToArray()
};
return JsonUtility.ToJson(bm).Replace("breakMyBonesIWill", "break");
}
}

24
LICENSE Normal file
View File

@ -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 <http://unlicense.org/>

View File

@ -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<Inohara.Exporter>();
DontDestroyOnLoad(go);
orig_Execute_WaitAMDaemonReady();
SingletonMonoBehaviour<Inohara.Exporter>.instance.LoadCfg();
}
}

View File

@ -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<Inohara.Exporter>.instance.Export(sessionResult.battleResult, _sessionInfo);
orig_applyResultToUserData(sessionResult);
}
}

View File

@ -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<Inohara.Exporter>.instance.Authorize();
}
}

38
README.md Normal file
View File

@ -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`.

22
inohara.cfg.example Normal file
View File

@ -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