diff --git a/.forgejo/workflows/publish-release.yml b/.forgejo/workflows/publish-release.yml new file mode 100644 index 0000000..3638055 --- /dev/null +++ b/.forgejo/workflows/publish-release.yml @@ -0,0 +1,74 @@ +name: Build new release + +on: + workflow_dispatch: + +jobs: + Build: + runs-on: debian-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Dependencies + run: | + apt update + apt install curl p7zip nsis -y + + - name: Setup dotnet 8 SDK + run: curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 8.0 + + - name: Build for Windows/Linux + run: | + export DOTNET_ROOT=$HOME/.dotnet + export PATH=$PATH:$HOME/.dotnet:$HOME/.dotnet/tools + export DOTNET_CLI_TELEMETRY_OPTOUT=true + + echo "VERSION_TAG=$(cat VERSION.txt)" >> "$FORGEJO_ENV" + echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> "$FORGEJO_ENV" + + dotnet tool install --global Obfuscar.GlobalTool + dotnet publish -r win-x64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true + dotnet publish -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true + + mkdir pkg-linux + mkdir pkg-win + cp -r modmenu pkg-linux/ + cp -r modmenu pkg-win/ + cp icon.ico pkg-linux/ + cp icon.ico pkg-win/ + cp assets/config.json pkg-linux/ + cp assets/config.json pkg-win/ + cp bin/Release/net8.0/linux-x64/publish/SplatTool pkg-linux/SplatTool + cp bin/Release/net8.0/linux-x64/publish/Photino.Native.so pkg-linux/Photino.Native.so + cp bin/Release/net8.0/linux-x64/publish/libnfd.so pkg-linux/libnfd.so + cp bin/Release/net8.0/win-x64/publish/SplatTool.exe pkg-win/SplatTool.exe + cp bin/Release/net8.0/win-x64/publish/Photino.Native.dll pkg-win/Photino.Native.dll + cp bin/Release/net8.0/win-x64/publish/nfd.dll pkg-win/nfd.dll + cp bin/Release/net8.0/win-x64/publish/WebView2Loader.dll pkg-win/WebView2Loader.dll + + - name: Package up for Release + run: | + cd pkg-linux + tar -cvzf ../SplatTool-${{ env.VERSION_TAG }}-${{ env.SHORT_SHA }}-x86_64-linux.tar.gz * && cd .. + cd pkg-win + 7z a ../SplatTool-${{ env.VERSION_TAG }}-${{ env.SHORT_SHA }}-x86_64-win.7z * && cd .. + makensis "-DVERSION=${{ env.VERSION_TAG }}" "-DSHORT_SHA=${{ env.SHORT_SHA }}" ./SplatTool_Installer.nsi + + - name: Upload Windows Installer + uses: forgejo/upload-artifact@v4 + with: + name: SplatTool-Win-Installer + path: SplatTool-${{ env.VERSION_TAG }}-${{ env.SHORT_SHA }}-Setup.exe + + - name: Upload Windows Portable + uses: forgejo/upload-artifact@v4 + with: + name: SplatTool-Win-Portable + path: SplatTool-${{ env.VERSION_TAG }}-${{ env.SHORT_SHA }}-x86_64-win.7z + + - name: Upload Linux Build + uses: forgejo/upload-artifact@v4 + with: + name: SplatTool-Linux + path: SplatTool-${{ env.VERSION_TAG }}-${{ env.SHORT_SHA }}-x86_64-linux.tar.gz \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33590ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/* +obj/* +obj/Debug/* +obj/Release/* +.DS_Store +dist/* +*.dmg +*.sln \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2474744 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "omnisharp.enableEditorConfigSupport": false, + "omnisharp.useModernNet": false, + "csharp.format.enable": false, + "[csharp]": { + "editor.formatOnSave": false, + "editor.formatOnType": false, + "editor.formatOnPaste": false + }, + "dotnet.formatting.organizeImportsOnFormat": false, + "editor.useEditorConfig": false +} \ No newline at end of file diff --git a/FreezeThreads.cs b/FreezeThreads.cs new file mode 100644 index 0000000..de2d462 --- /dev/null +++ b/FreezeThreads.cs @@ -0,0 +1,86 @@ +using ModMenu; +using Func; +using System; +using System.Threading; + +namespace FreezeThread { + public class WorkThread { + private Thread MainThread; + private bool running = false; + public enum ThreadStatus { Running, Paused, Stopped } + private ThreadStatus status = ThreadStatus.Stopped; + private readonly object statusLock = new object(); + + public ThreadStatus Status { + get { + lock (statusLock) { + return status; + } + } + private set { + lock (statusLock) { + status = value; + } + } + } + + private readonly ManualResetEventSlim pauseEvent = new ManualResetEventSlim(true); + private readonly Action ThreadAction; + + public WorkThread(Action ThreadAction) { + this.ThreadAction = ThreadAction + ?? throw new ArgumentNullException(nameof(ThreadAction)); + } + + private void ThreadLoop() { + while (running) { + try { + pauseEvent.Wait(); + + if (pauseEvent.IsSet) + Status = ThreadStatus.Running; + + ThreadAction(); + Thread.Sleep(1); // Prevent CPU Exhaustion by adding small delay + } catch (ArgumentOutOfRangeException ex) { + running = false; + Status = ThreadStatus.Stopped; + Console.WriteLine($"Thread Killed! Caught: {ex}"); + } + } + + Status = ThreadStatus.Stopped; + } + + public void Start() { + if (running) + return; + + MainThread = new Thread(ThreadLoop); + MainThread.IsBackground = true; + running = true; + MainThread.Start(); + } + + public void Pause() { + if (Status == ThreadStatus.Running) { + pauseEvent.Reset(); + Status = ThreadStatus.Paused; + } + } + + public void Resume() { + if (Status == ThreadStatus.Paused) { + pauseEvent.Set(); + Status = ThreadStatus.Running; + } + } + + public void Stop() { + running = false; + pauseEvent.Set(); + MainThread?.Join(); + Status = ThreadStatus.Stopped; + } + } +} diff --git a/Func.cs b/Func.cs new file mode 100644 index 0000000..539b1df --- /dev/null +++ b/Func.cs @@ -0,0 +1,204 @@ +// Modules +using ModMenu; +using System.Net.Http; +using System.Threading.Tasks; +using System.Xml.Linq; +using System.Text; +using System.Drawing; +using DrawingColor = System.Drawing.Color; +using System.Net; +using System.Dynamic; +using System.Text.Json; +using System.IO; +using System.Runtime.InteropServices; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Tga; +using NativeFileDialogSharp; + +namespace Func { + public class Player { + public string nnidHex { get; set; } + public uint PID { get; set; } + public string NNID { get; set; } + public string Name { get; set; } + } + + public class SessionData { + public uint SessionID { get; set; } + public List Players { get; set; } = new List(); + } + + public class ExtFunc { + public static async Task GetNNID(int pid, bool isPretendo = true) { + using (HttpClient client = new HttpClient()) { + client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0"); + // Thanks @tombun2 for pointing this out + // src: https://github.com/Milk-Cool/PretendoLookup/blob/932263e2c5e7f2e7527ff5dc690a8701dbf39885/miidata.js + client.DefaultRequestHeaders.Add("X-Nintendo-Client-ID", "a2efa818a34fa16b8afbc8a74eba3eda"); + client.DefaultRequestHeaders.Add("X-Nintendo-Client-Secret", "c91cdb5658bd4954ade78533a339cf9a"); + + try { + // src: https://github.com/kinnay/NintendoClients/wiki/Account-Server + if (isPretendo) { + HttpResponseMessage response = await client.GetAsync($"http://account.pretendo.cc/v1/api/miis?pids={pid}"); + if (response.IsSuccessStatusCode) { + string content = await response.Content.ReadAsStringAsync(); + XDocument doc = XDocument.Parse(content); + string userId = doc.Root?.Element("mii")?.Element("user_id")?.Value; + return userId; + } else { + return "0"; + } + } else { + HttpResponseMessage response = await client.GetAsync($"https://account.spfn.net/v1/api/admin/mapped_ids?input_type=pid&output_type=user_id&input={pid}"); + if (response.IsSuccessStatusCode) { + string content = await response.Content.ReadAsStringAsync(); + XDocument doc = XDocument.Parse(content); + string userId = doc.Root?.Element("mapped_id")?.Element("out_id")?.Value; + return userId; + } else { + return "0"; + } + } + } + catch (Exception ex) { + Console.WriteLine(ex.Message); + return "0"; + } + } + } + public static int FindPattern(byte[] data, byte[] pattern) { + for (int i = 0; i <= data.Length - pattern.Length; i++) { + bool match = true; + for (int j = 0; j < pattern.Length; j++) { + if (data[i + j] != pattern[j]) { + match = false; + break; + } + } + + if (match) + return i; + } + + return -1; + } + } + + public class Codes { + public static void DisconnectFromLobby() { + Int16 timer = 0; + var client_time_ptr = Program.readUInt32((uint)(Program.readUInt32((uint)(Program.readUInt32((uint)(Program.readUInt32(0x101E5660) + 0x28)) + 0x24)) + 0x40)); + Program.writeInt16((uint)(client_time_ptr + 0x1F8), timer); + } + public static void NameChanger(string name) { + Console.WriteLine($"Changing Name to: {name}..."); + var name_pointer = Program.readUInt32(0x101E80A4); + Program.writeBytes((uint)(name_pointer + 0x8C), new byte[32]); + Program.writeBytes((uint)(name_pointer + 0x8C), Encoding.BigEndianUnicode.GetBytes(name)); + } + public static string GetSessionData() { + bool isPretendo = true; + bool isSpfnTried = false; + string nnidStr = null; + httpMenu.session.SessionID = 0; + httpMenu.session.Players.Clear(); + httpMenu.session = new SessionData(); + + for (var i = 0; i < 8; i++) { + var ptrToPlayerInfo = Program.readUInt32((uint)(Program.readUInt32((uint)(Program.readUInt32(0x101DD330) + 0x10)) + (uint)(i * 4))); // PlayerInfo Pointer + var name = Encoding.BigEndianUnicode.GetString(Program.readBytes(ptrToPlayerInfo + 0x6, 32)).Replace("\n", "").Replace("\r", "").TrimEnd('\0'); + var pidRaw = Program.readUInt32(ptrToPlayerInfo + 0xd0); + if (pidRaw != 0) { + nnidStr = ExtFunc.GetNNID((int)pidRaw, isPretendo).GetAwaiter().GetResult(); + } else { + nnidStr = "0"; + } + if (nnidStr == "0" && !isSpfnTried && pidRaw != 0) { + nnidStr = ExtFunc.GetNNID((int)pidRaw, false).GetAwaiter().GetResult(); + if (nnidStr != "0") { + isPretendo = false; + } else { + isSpfnTried = true; + } + } + string nnidHex = BitConverter.ToString(BitConverter.GetBytes(pidRaw)).Replace("-", ""); + + httpMenu.session.Players.Add(new Player { + nnidHex = nnidHex, + PID = pidRaw, + NNID = nnidStr, + Name = name + }); + } + var ptr = Program.readUInt32(0x101E8980); + uint sessionID; + if (ptr != 0) { + var index = Program.readBytes(ptr + 0xBD, 1)[0]; + sessionID = Program.readUInt32(ptr + index + 0xCC); + } else { + sessionID = 0; + } + httpMenu.session.SessionID = sessionID; + + string json = JsonSerializer.Serialize(httpMenu.session, new JsonSerializerOptions { WriteIndented = true }); + return json; + } + public static void forceSessiondId(uint sessionId) { + // Console.WriteLine(sessionId); + var p = Program.readUInt32(0x101DD330); + Program.writeUInt32((uint)(p + 0x260), (uint)sessionId); + } + public static void faceimgtga(int format) { + var p = Program.readUInt32(0x101DCDB0) + 0x150; + var localPlayer = Program.readBytes(p, 5376); + byte[] pattern = new byte[]{ 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x80, 0x00 }; + int faceimg_offset = ExtFunc.FindPattern(localPlayer, pattern); + if (faceimg_offset == -1) { + Console.WriteLine("Could not find FaceImg.tga pattern!"); + } else { + var faceimg = Program.readBytes((uint)(p + faceimg_offset), 65580); + string path = null; + string defaultPath = null; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + defaultPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + } else { + defaultPath = Path.GetDirectoryName(Environment.ProcessPath); + } + + if (format == 0) { + path = Dialog.FileSave("tga", defaultPath).Path; + if (string.IsNullOrEmpty(path)) return; + File.WriteAllBytes(path, faceimg); + } else if (format == 1) { + path = Dialog.FileSave("png", defaultPath).Path; + if (string.IsNullOrEmpty(path)) return; + using (Image image = Image.Load(faceimg)) { + image.SaveAsPng(path); + } + } + if (string.IsNullOrEmpty(path)) return; + // on *nix systems set file to be owned by everyone. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); + } + } + } + public static string TotalWins() { + try { + var p_SaveDataMgr = Program.readUInt32((uint)(Program.readUInt32(SaveEditor.SAVEDATAMGR_ROOT_PTR) + 0xC)); + return $"{Program.readUInt32((uint)(p_SaveDataMgr + 0xCB38))}"; + } catch { + return "Game not fully loaded yet..."; + } + } + public static string TotalLoss() { + try { + var p_SaveDataMgr = Program.readUInt32((uint)(Program.readUInt32(SaveEditor.SAVEDATAMGR_ROOT_PTR) + 0xC)); + return $"{Program.readUInt32((uint)(p_SaveDataMgr + 0xCB3C))}"; + } catch { + return "Game not fully loaded yet..."; + } + } + } +} diff --git a/ProcessWrapper.cs b/ProcessWrapper.cs new file mode 100644 index 0000000..06935a7 --- /dev/null +++ b/ProcessWrapper.cs @@ -0,0 +1,420 @@ +// modules +using System.Runtime.InteropServices; + +namespace ProcessWrapper { + public interface IProcessWrapper { + byte[] ReadProcessMemory(int pid, ulong address, uint length); + bool WriteProcessMemory(int pid, ulong address, byte[] data); + int getuid(); + ulong FindCemuBase(int pid, ulong minSize); + } + + // Mac implementation + public class MacProcessWrapper : IProcessWrapper { + // vars + const int KERN_SUCCESS = 0; + const string LIBSYSTEM = "/usr/lib/libSystem.B.dylib"; + const int VM_REGION_BASIC_INFO_64 = 9; + const int VM_REGION_BASIC_INFO_COUNT_64 = 10; + const int VM_PROT_READ = 0x01; + + // Struct + [StructLayout(LayoutKind.Sequential)] + public struct vm_region_basic_info_64 { + public int protection; + public int max_protection; + public int inheritance; + public int shared; + public int reserved; + public ulong offset; + public int behavior; + public short user_wired_count; + public short user_tag; + } + + // https://github.com/attilathedud/macos_task_for_pid#overview + // Well documenated apis, thanks Apple. + [DllImport(LIBSYSTEM, SetLastError = true)] + private static extern int task_for_pid(IntPtr task, + int pid, + out IntPtr targetTask); + + [DllImport(LIBSYSTEM, SetLastError = true)] + private static extern IntPtr mach_task_self(); + + [DllImport(LIBSYSTEM, SetLastError = true)] + private static extern int mach_vm_read(IntPtr target_task, + ulong address, + ulong size, + out IntPtr data, + out ulong data_count); + + [DllImport(LIBSYSTEM, SetLastError = true)] + private static extern int mach_vm_write(IntPtr target_task, + ulong address, + byte[] data, + uint dataCnt); + + [DllImport(LIBSYSTEM, SetLastError = true)] + private static extern int vm_deallocate(IntPtr task, + IntPtr address, + ulong size); + + [DllImport(LIBSYSTEM, SetLastError = true)] + private static extern int mach_vm_region(IntPtr task, + ref ulong address, + out ulong size, + int flavor, + IntPtr info, + ref uint count, + out ulong object_name); + + [DllImport(LIBSYSTEM, SetLastError = true)] + private static extern int geteuid(); + + public int getuid() { + return geteuid(); + } + + public byte[] ReadProcessMemory(int pid, ulong address, uint length) { + IntPtr task; + IntPtr localTask = mach_task_self(); + + int result = task_for_pid(localTask, pid, out task); + if (result != KERN_SUCCESS) { + Console.WriteLine($"task_for_pid failed with code {result}"); + return new byte[0]; + } + + IntPtr bufferPtr; + ulong size; + result = mach_vm_read(task, address, length, out bufferPtr, out size); + if (result != KERN_SUCCESS) { + //Console.WriteLine($"mach_vm_read failed with code {result}"); + return new byte[0]; + } + + byte[] buffer = new byte[size]; + Marshal.Copy(bufferPtr, buffer, 0, (int)size); + + // Clean up + vm_deallocate(localTask, bufferPtr, size); + + return buffer; + } + + public bool WriteProcessMemory(int pid, ulong address, byte[] data) { + IntPtr task; + IntPtr localTask = mach_task_self(); + + int result = task_for_pid(localTask, pid, out task); + if (result != KERN_SUCCESS) { + //throw new Exception($"task_for_pid failed: {result}"); + return false; + } + + result = mach_vm_write(task, address, data, (uint)data.Length); + if (result != KERN_SUCCESS) { + //throw new Exception($"mach_vm_write failed: {result}"); + Console.WriteLine($"mach_vm_write failed: {result}"); + return false; + } else { + return true; + } + + // Console.WriteLine($"Wrote {data.Length} bytes to 0x{address:X}"); + } + + public ulong FindCemuBase(int pid, ulong maxSize) { + IntPtr task; + IntPtr localTask = mach_task_self(); + byte?[] patternBytes = new byte?[] { 0x02, 0xD4, 0xE7 }; + + int result = task_for_pid(localTask, pid, out task); + if (result != KERN_SUCCESS) { + Console.WriteLine($"task_for_pid failed: {result}"); + return 0; + } + + ulong address = 0; + while (true) { + ulong size; + uint count = VM_REGION_BASIC_INFO_COUNT_64; + IntPtr infoPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + ulong objectName; + + result = mach_vm_region(task, + ref address, + out size, + VM_REGION_BASIC_INFO_64, + infoPtr, + ref count, + out objectName + ); + + if (result != KERN_SUCCESS) { + Marshal.FreeHGlobal(infoPtr); + break; + } + + var regionInfo = Marshal.PtrToStructure(infoPtr); + Marshal.FreeHGlobal(infoPtr); + + bool readable = (regionInfo.protection & VM_PROT_READ) != 0; + + if (readable && size < maxSize || size == maxSize) { + var bytes = ReadProcessMemory(pid, (ulong)(address + 0xE000000), 20); + + if (bytes != null && bytes.Length > 0) { + int patternLen = patternBytes.Length; + for (int i = 0, j = 0; i + patternLen <= 20; i++) { + if (patternBytes[j] == null || bytes[i] == patternBytes[j]) { + j++; + } else { + j = 0; + } + + if (j >= patternLen) { + return address; + } + } + } + } + + address += size; + } + return 0; + } + } + + // Linux implementation + public class LinuxProcessWrapper : IProcessWrapper { + // IOVec Struct + [StructLayout(LayoutKind.Sequential)] + private struct IOVec { + public UIntPtr Base; + public UIntPtr Length; + } + + [DllImport("libc", SetLastError = true)] + private static extern long process_vm_readv(int pid, + IOVec[] local_iov, ulong liovcnt, + IOVec[] remote_iov, ulong riovcnt, + ulong flags); + + [DllImport("libc", SetLastError = true)] + private static extern long process_vm_writev(int pid, + IOVec[] local_iov, ulong liovcnt, + IOVec[] remote_iov, ulong riovcnt, + ulong flags); + + [DllImport("libc", SetLastError = true)] + private static extern int geteuid(); + + public int getuid() { + return geteuid(); + } + + public byte[] ReadProcessMemory(int pid, ulong address, uint length) { + byte[] buffer = new byte[length]; + GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); + + try { + var local = new IOVec { + Base = new UIntPtr((ulong)handle.AddrOfPinnedObject().ToInt64()), + Length = (UIntPtr)length + }; + + var remote = new IOVec { + Base = (UIntPtr)address, + Length = (UIntPtr)length + }; + + long nread = process_vm_readv(pid, new[] { local }, 1, new[] { remote }, 1, 0); + if (nread == -1) { + // Console.WriteLine($"Failed to read memory. Error code: {Marshal.GetLastWin32Error()}"); + return new byte[0]; + } else { + // Console.WriteLine($"Read {nread} bytes from process {pid} at 0x{address:X}"); + } + } finally { + handle.Free(); + } + + return buffer; + } + + public bool WriteProcessMemory(int pid, ulong address, byte[] data) { + GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); + + try { + var local = new IOVec { + Base = new UIntPtr((ulong)handle.AddrOfPinnedObject().ToInt64()), + Length = (UIntPtr)data.Length + }; + + var remote = new IOVec { + Base = (UIntPtr)address, + Length = (UIntPtr)data.Length + }; + + long nwritten = process_vm_writev(pid, new[] { local }, 1, new[] { remote }, 1, 0); + if (nwritten == -1) { + // Console.WriteLine($"Failed to write memory. Error code: {Marshal.GetLastWin32Error()}"); + return false; + } else { + // Console.WriteLine($"Wrote {nwritten} bytes to process {pid} at 0x{address:X}"); + return true; + } + } + finally { + handle.Free(); + } + } + + public ulong FindCemuBase(int pid, ulong maxSize) { + // vars + string mapsPath = $"/proc/{pid}/maps"; + byte?[] patternBytes = new byte?[] { 0x02, 0xD4, 0xE7 }; + + string[] mapsLines = File.ReadAllLines(mapsPath); + foreach (string line in mapsLines) { + var split = line.Split(" "); + var addrP = split[0].Split("-"); + ulong min = Convert.ToUInt64(addrP[0], 16); + ulong max = Convert.ToUInt64(addrP[1], 16); + + if (!split[1].Contains("r")) continue; + var len = (max - min); + if (len > maxSize || len == maxSize) { + var bytes = ReadProcessMemory(pid, (ulong)(min + 0xE000000), 20); + + if (bytes != null && bytes.Length > 0) { + int patternLen = patternBytes.Length; + for (int i = 0, j = 0; i + patternLen <= 20; i++) { + if (patternBytes[j] == null || bytes[i] == patternBytes[j]) { + j++; + } else { + j = 0; + } + + if (j >= patternLen) { + return min; + } + } + } + } + } + return 0; + } + } + + public class WindowsProcessWrapper : IProcessWrapper { + // vars + const uint PROCESS_VM_READ = 0x0010; + const uint PROCESS_VM_WRITE = 0x0020; + const uint PROCESS_VM_OPERATION = 0x0008; + const uint PROCESS_QUERY_INFORMATION = 0x0400; + + [StructLayout(LayoutKind.Sequential)] + public struct MEMORY_BASIC_INFORMATION { + public IntPtr BaseAddress; + public IntPtr AllocationBase; + public uint AllocationProtect; + public IntPtr RegionSize; + public uint State; + public uint Protect; + public uint Type; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint dwDesiredAccess, + bool bInheritHandle, + int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory(IntPtr hProcess, + IntPtr lpBaseAddress, + [Out] byte[] lpBuffer, + int dwSize, + out int lpNumberOfBytesRead); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WriteProcessMemory(IntPtr hProcess, + IntPtr lpBaseAddress, + byte[] lpBuffer, + int dwSize, + out int lpNumberOfBytesWritten); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern int VirtualQueryEx(IntPtr hProcess, + IntPtr lpAddress, + out MEMORY_BASIC_INFORMATION lpBuffer, + uint dwLength); + + // Windows doesnt need this. + public int getuid() { + return -1; + } + + public byte[] ReadProcessMemory(int pid, ulong address, uint length) { + IntPtr hProcess = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid); + if (hProcess == IntPtr.Zero) { + return new byte[0]; + } + + byte[] buffer = new byte[length]; + if (ReadProcessMemory(hProcess, (IntPtr)address, buffer, (int)length, out int bytesRead) && bytesRead > 0) { + if (bytesRead < length) { + Array.Resize(ref buffer, bytesRead); + } + return buffer; + } + + return new byte[0]; + } + + public bool WriteProcessMemory(int pid, ulong address, byte[] data) { + IntPtr hProcess = OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, false, pid); + if (hProcess == IntPtr.Zero) { + return false; + } + + return WriteProcessMemory(hProcess, (IntPtr)address, data, data.Length, out int bytesWritten) && bytesWritten == data.Length; + } + + public ulong FindCemuBase(int pid, ulong maxSize) { + byte?[] patternBytes = new byte?[] { 0x02, 0xD4, 0xE7 }; + IntPtr address = IntPtr.Zero; + MEMORY_BASIC_INFORMATION mbi; + + IntPtr hProcess = OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, false, pid); + if (hProcess == IntPtr.Zero) { + return 0; + } + + while (VirtualQueryEx(hProcess, address, out mbi, (uint)Marshal.SizeOf(typeof(MEMORY_BASIC_INFORMATION))) != 0) { + bool readable = (mbi.State == 0x1000) && (mbi.Protect != 0x01); + ulong regionSize = (ulong)mbi.RegionSize.ToInt64(); + if (readable && regionSize > maxSize || regionSize == maxSize) { + var bytes = ReadProcessMemory(pid, (ulong)(mbi.BaseAddress + 0xE000000), 20); + if (bytes != null && bytes.Length > 0) { + int patternLen = patternBytes.Length; + for (int i = 0, j = 0; i + patternLen <= 20; i++) { + if (patternBytes[j] == null || bytes[i] == patternBytes[j]) { + j++; + } else { + j = 0; + } + if (j >= patternLen) { + return (ulong)mbi.BaseAddress; + } + } + } + } + address = new IntPtr(mbi.BaseAddress.ToInt64() + mbi.RegionSize.ToInt64()); + } + return 0; + } + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..548af78 --- /dev/null +++ b/Program.cs @@ -0,0 +1,253 @@ +using ProcessWrapper; +using Func; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.IO; +using Photino.NET; +using System.Text.Json; + +namespace ModMenu { + public class Program { + public static int pid = 0; + public static int OsType = 0; + public static IProcessWrapper OsProcessWrapper = null; + public static ulong cbase = 0; + public static ulong cbaseCorrected = 0; + public static Dictionary config = null; + + public static byte[] readBytes(uint address, uint length) { + return OsProcessWrapper.ReadProcessMemory(pid, (ulong)(cbase + 0xE000000) + address - 0x10000000, length); + } + + public static uint readUInt32(uint address) { + return BitConverter.ToUInt32(OsProcessWrapper.ReadProcessMemory(pid, (ulong)(cbase + 0xE000000) + address - 0x10000000, 4).Reverse().ToArray(), 0); + } + + public static float readFloat(uint address) { + var b = OsProcessWrapper.ReadProcessMemory(pid, (ulong)(cbase + 0xE000000) + address - 0x10000000, 4); + Array.Reverse(b, 0, b.Length); + return BitConverter.ToSingle(b, 0); + } + + public static void writeBytes(uint address, byte[] bytes) { + OsProcessWrapper.WriteProcessMemory(pid, (ulong)(cbase + 0xE000000) + address - 0x10000000, bytes); + } + + public static void writeUInt32(uint address, uint value) { + byte[] b = BitConverter.GetBytes(value); + Array.Reverse(b, 0, b.Length); + OsProcessWrapper.WriteProcessMemory(pid, (ulong)(cbase + 0xE000000) + address - 0x10000000, b); + } + + public static void writeInt16(uint address, Int16 value) { + byte[] b = BitConverter.GetBytes(value); + Array.Reverse(b, 0, b.Length); + OsProcessWrapper.WriteProcessMemory(pid, (ulong)(cbase + 0xE000000) + address - 0x10000000, b); + } + + public static void writeFloat(uint address, float value) { + byte[] b = BitConverter.GetBytes(value); + Array.Reverse(b, 0, b.Length); + OsProcessWrapper.WriteProcessMemory(pid, (ulong)(cbase + 0xE000000) + address - 0x10000000, b); + } + + public static string FindExecutable(string name) { + // Not required for Mac/Windows + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return null; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return null; + + var pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(pathEnv)) return null; + var paths = pathEnv.Split(':'); + + foreach (var path in paths) { + var fullPath = Path.Combine(path, name); + if (File.Exists(fullPath)) { + return fullPath; + } + } + + return null; + } + + public static bool IsWayland() { + string runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + if (string.IsNullOrEmpty(runtimeDir)) return false; + + return File.Exists(Path.Combine(runtimeDir, "wayland-0")); + } + + [STAThread] + public static void Main(string[] args) { + Console.OutputEncoding = System.Text.Encoding.UTF8; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { + OsType = 1; + OsProcessWrapper = new LinuxProcessWrapper(); + if (OsProcessWrapper.getuid() != 0) { + var pkexecPath = FindExecutable("pkexec"); + + if (!string.IsNullOrEmpty(pkexecPath)) { + string exePath = Process.GetCurrentProcess().MainModule.FileName; + string display; + string runtimeDir; + string command = null; + + Console.WriteLine($"Wayland is: {IsWayland()}"); + + if (IsWayland()) { + display = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + command = $"--disable-internal-agent env XDG_RUNTIME_DIR={runtimeDir} WAYLAND_DISPLAY={display} \"{exePath}\""; + } else { + display = Environment.GetEnvironmentVariable("DISPLAY"); + command = $"--disable-internal-agent env DISPLAY={display} \"{exePath}\""; + } + + if (string.IsNullOrEmpty(command)) { + Console.WriteLine("Please run as root..."); + System.Environment.Exit(1); + } + + var startInfo = new ProcessStartInfo { + FileName = pkexecPath, + Arguments = command, + UseShellExecute = false + }; + + try { + using (Process elevatedProcess = Process.Start(startInfo)) { + elevatedProcess?.WaitForExit(); + Environment.Exit(elevatedProcess?.ExitCode ?? 0); + }; + } catch (Exception ex) { + Console.WriteLine($"Elevation failed: {ex.Message}"); + Environment.Exit(1); + } + } else { + Console.WriteLine("Please run as root..."); + System.Environment.Exit(1); + } + } + } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { + OsType = 2; + OsProcessWrapper = new MacProcessWrapper(); + if (OsProcessWrapper.getuid() != 0) { + string basePath = AppContext.BaseDirectory; + string rootHelperPath = Path.Combine(basePath, "root_helper"); + + if (Path.Exists(rootHelperPath)) { + var startInfo = new ProcessStartInfo { + FileName = rootHelperPath, + UseShellExecute = false + }; + + try { + Process.Start(startInfo); + Environment.Exit(0); + } catch (Exception ex) { + Console.WriteLine($"Elevation failed: {ex.Message}"); + Environment.Exit(1); + } + } else { + Console.WriteLine("Please run as root..."); + System.Environment.Exit(1); + } + } + } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + OsType = 3; + OsProcessWrapper = new WindowsProcessWrapper(); + } else { + Console.WriteLine("Unsupported Operating System exiting..."); + return; + } + + // Root access needed past here for Linux/Mac + string[] targetNames = { "cemu", "xapfish", ".cemu-wrapped", "cemu_release" }; + Process targetProcess = Process.GetProcesses() + .FirstOrDefault(p => targetNames.Any(name => p.ProcessName.Equals(name, StringComparison.OrdinalIgnoreCase))); + + if (targetProcess == null) { + new PhotinoWindow() + .SetSize(0, 0) + .SetMinimized(true) + .SetNotificationRegistrationId("8FDF1B15-3408-47A6-8EF5-2B0676B76277") + .SetNotificationsEnabled(false) + .RegisterWindowCreatedHandler((sender, e) => { + var window = sender as PhotinoWindow; + window.ShowMessage("Error", "Could not find Cemu process.", PhotinoDialogButtons.Ok, PhotinoDialogIcon.Error); + window.Close(); + System.Environment.Exit(0); + }) + .LoadRawString("") + .WaitForClose(); + return; + } + pid = targetProcess.Id; + cbase = OsProcessWrapper.FindCemuBase(pid, 1308622848); + Console.WriteLine($"Found target process: {targetProcess.ProcessName} (PID: {pid})"); + + if (cbase != 0) { + Console.WriteLine("SplatTool v3.2.0 by CrafterPika"); + Console.WriteLine("Special Thanks: Winterberry, Javi.ig and Tombuntu, KittenTM."); + + cbaseCorrected = (Program.cbase + 0xE000000) - 0x10000000; + + string indexPath; + string iconPath; + string configPath; + + if (OsType == 2) { + indexPath = Path.Combine(AppContext.BaseDirectory, "..", "Resources", "modmenu", "index.html"); + iconPath = Path.Combine(AppContext.BaseDirectory, "..", "Resources", "icon.ico"); + configPath = Path.Combine(AppContext.BaseDirectory, "..", "Resources", "config.json"); + } else { + indexPath = Path.Combine(AppContext.BaseDirectory, "modmenu", "index.html"); + iconPath = Path.Combine(AppContext.BaseDirectory, "icon.ico"); + configPath = Path.Combine(AppContext.BaseDirectory, "config.json"); + } + + string configJson = File.ReadAllText(configPath); + config = JsonSerializer.Deserialize>(configJson); + + var window = new PhotinoWindow() + .SetTitle("SplatTool Mod Menu") + .SetNotificationRegistrationId("8FDF1B15-3408-47A6-8EF5-2B0676B76277") + .SetNotificationsEnabled(false) + .SetUseOsDefaultSize(false) + .SetSize(900, 700) + .Center() + .RegisterWebMessageReceivedHandler((object sender, string message) => { + var w = sender as PhotinoWindow; + var data = JsonSerializer.Deserialize(message); + if (data != null) { + string result = httpMenu.RunCode(data); + w.SendWebMessage(result); + } + }); + + if (File.Exists(iconPath)) { + window.SetIconFile(iconPath); + } + + window.Load(indexPath).WaitForClose(); + + } else { + new PhotinoWindow() + .SetSize(0, 0) + .SetNotificationRegistrationId("8FDF1B15-3408-47A6-8EF5-2B0676B76277") + .SetNotificationsEnabled(false) + .SetMinimized(true) + .RegisterWindowCreatedHandler((sender, e) => { + var window = sender as PhotinoWindow; + window.ShowMessage("Error", "Cemu base not found! Is Splatoon running?!", PhotinoDialogButtons.Ok, PhotinoDialogIcon.Error); + window.Close(); + System.Environment.Exit(0); + }) + .LoadRawString("") + .WaitForClose(); + return; + } + } + } +} diff --git a/README.md b/README.md index 08cbde5..53c759a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,28 @@ -# SplatTool-public +# SplatTool (Public ver.) +A cross-platform mod menu for Splatoon v288 on [Cemu](https://github.com/cemu-project/Cemu/releases) 2.6/2.7 (Experimental)
+This version has been scrubbed of anything abusive. + +![SplatTool Showoff Image](assets/demo.png)
+SplatTool running on macOS 26.4.1 + + +## Running + +## Linux + +Since Linux actually demands root priviliages to read another applications memory, you have to run it as root.
+You can either run the binary directly with `sudo` or `doas` or launch it directly for pkexec popup. + +Since the UI relies on [Photino](https://www.tryphotino.io/) it needs `webkit2gtk-4.1` installed. + +On x11 you may need to run first `xhost +SI:localuser:root` to allow forwarding gtk to your window manager from root. Unsure if the same has to be done for wayland. + +## MacOS + +Same situation with Linux. The application bundles a small root helper program to assist with this.
+But since i dont pay Apple 99$/year you first have to `xattr -cr /Applications/SplatTool.app` to bypass their stupid gatekeeper or do so in System Preferences -> Privacy & Security -> Open Anyway. + +## Windows + +Nothing special required to run. diff --git a/SaveEditor.cs b/SaveEditor.cs new file mode 100644 index 0000000..d886d3e --- /dev/null +++ b/SaveEditor.cs @@ -0,0 +1,178 @@ +using ModMenu; +using Func; +using System; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Collections.Generic; +using System.Linq; + +namespace ModMenu { + [StructLayout(LayoutKind.Sequential)] + public struct Gear { + public uint GearID; + public uint UnlockedSlots; + public uint AvailableSlots; + public uint Slot1; + public uint Slot2; + public uint Slot3; + public uint Unk1; // Default: 0x00000000 + public uint Unk2; // Default: 0x00000000 + public uint Unk3; // Default: 0x00000000 + public uint TimestampLastUsed; + public uint HideNewIcon; // Enabled: 0x00010000 | Disabled: 0x00000000 + public uint Unk4; // Default: 0x00000000 + } + + public class GearQueueSlot { + public string Class { get; set; } + public uint Slot { get; set; } + public byte[] Data { get; set; } + } + + public class SaveEditor { + // [[0x106E975C] + 0xC] + 0x61A0 # Start of Headgear Definitions + // [[0x106E975C] + 0xC] + 0x31A0 # Start of Clothes Definitions + // [[0x106E975C] + 0xC] + 0x1A0 # Start of Shoes Definitions + // Each Gear class is 0x3000 big and each indivdual gear is 0x30 bytes (48 Bytes) + // 0x3000 / 0x30 = 0x100 Slots (256 Slots) + // 48 Bytes are split evenly according to struct in order above. + public static uint SAVEDATAMGR_ROOT_PTR = 0x101E675C; // Begin of Save Data [0x101E675C] + 0xC + + public static string FetchAllGear() { + var p_save_start = Program.readUInt32((uint)(Program.readUInt32(SAVEDATAMGR_ROOT_PTR) + 0xC)); + uint GearSectionSize = 0x3000; + var results = new Dictionary>(); + int structSize = Marshal.SizeOf(); + + var categories = new (string Name, uint Offset)[] { + ("headgear", 0x61A0), + ("clothing", 0x31A0), + ("shoes", 0x1A0) + }; + + foreach (var cat in categories) { + var gearList = new List(); + + byte[] sectionRaw = Program.readBytes(p_save_start + cat.Offset, GearSectionSize); + + for (int i = 0; i < GearSectionSize; i += structSize) { + byte[] structBuffer = new byte[structSize]; + Buffer.BlockCopy(sectionRaw, i, structBuffer, 0, structSize); + + for (int j = 0; j < structBuffer.Length; j += 4) { + Array.Reverse(structBuffer, j, 4); + } + Gear item = ByteArrayToStructure(structBuffer); + + gearList.Add(item); + } + results.Add(cat.Name, gearList); + } + + return JsonSerializer.Serialize(results, new JsonSerializerOptions { + WriteIndented = true, + IncludeFields = true + }); + } + + public static void QueueGearSlot(uint Slot, string Class, uint GearID, uint Slot1, uint Slot2, uint Slot3) { + if (Slot > 255) { + Console.WriteLine("You can only write to Slot 0-255..."); + return; + } + + Gear newGear = new Gear { + GearID = GearID, + UnlockedSlots = 4, + AvailableSlots = 4, + Slot1 = Slot1, + Slot2 = Slot2, + Slot3 = Slot3, + Unk1 = 0, + Unk2 = 0, + Unk3 = 0, + TimestampLastUsed = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + HideNewIcon = 0x00010000, + Unk4 = 0 + }; + + byte[] rawData = StructureToByteArray(newGear); + for (int i = 0; i < rawData.Length; i += 4) { + Array.Reverse(rawData, i, 4); + } + + httpMenu.GearQueue.Add(new GearQueueSlot { Class = Class, Slot = Slot, Data = rawData }); + } + + public static void WriteGearQueue() { + var p_save_start = Program.readUInt32((uint)(Program.readUInt32(SAVEDATAMGR_ROOT_PTR) + 0xC)); + uint start_offset = 0; + + foreach (var Gear in httpMenu.GearQueue.ToList()) { + switch (Gear.Class.ToLower()) { + case "headgear": start_offset = 0x61A0; break; + case "clothing": start_offset = 0x31A0; break; + case "shoes": start_offset = 0x1A0; break; + default: + Console.WriteLine("Invalid Class selected"); + return; + } + + Program.writeBytes((uint)(p_save_start + start_offset + (Gear.Slot * 0x30)), Gear.Data); + } + + } + + public static void ChangeGender(uint gender) { + if (gender > 2) { + gender = 0; + } + var p_save_start = Program.readUInt32((uint)(Program.readUInt32(SAVEDATAMGR_ROOT_PTR) + 0xC)); + Program.writeUInt32((uint)(p_save_start + 0x190), gender); + } + + public static void ChangeLevel(uint level) { + uint ActualLevel = level - 1; + var p_save_start = Program.readUInt32((uint)(Program.readUInt32(SAVEDATAMGR_ROOT_PTR) + 0xC)); + Program.writeUInt32((uint)(p_save_start + 0xA5A8), ActualLevel); + } + + public static void ChangeCash(uint amount) { + if (amount > 9999999) { + amount = 9999999; + } + var p_save_start = Program.readUInt32((uint)(Program.readUInt32(SAVEDATAMGR_ROOT_PTR) + 0xC)); + Program.writeUInt32((uint)(p_save_start + 0xA5A0), amount); + } + + public static void ChangeSeaSnails(uint amount) { + if (amount > 999) { + amount = 999; + } + var p_save_start = Program.readUInt32((uint)(Program.readUInt32(SAVEDATAMGR_ROOT_PTR) + 0xC)); + Program.writeUInt32((uint)(p_save_start + 0xA5B4), amount); + } + + private static T ByteArrayToStructure(byte[] bytes) where T : struct { + GCHandle handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); + try { + return Marshal.PtrToStructure(handle.AddrOfPinnedObject()); + } finally { + handle.Free(); + } + } + + private static byte[] StructureToByteArray(object obj) { + int len = Marshal.SizeOf(obj); + byte[] arr = new byte[len]; + IntPtr ptr = Marshal.AllocHGlobal(len); + try { + Marshal.StructureToPtr(obj, ptr, true); + Marshal.Copy(ptr, arr, 0, len); + } finally { + Marshal.FreeHGlobal(ptr); + } + return arr; + } + } +} diff --git a/SplatTool.csproj b/SplatTool.csproj new file mode 100644 index 0000000..9daf0be --- /dev/null +++ b/SplatTool.csproj @@ -0,0 +1,27 @@ + + + + Exe + net8.0 + enable + disable + true + true + icon.ico + + + + + + + + + PreserveNewest + + + config.json + PreserveNewest + + + + diff --git a/SplatTool.slnx b/SplatTool.slnx new file mode 100644 index 0000000..0e161fe --- /dev/null +++ b/SplatTool.slnx @@ -0,0 +1,3 @@ + + + diff --git a/SplatTool_Installer.nsi b/SplatTool_Installer.nsi new file mode 100644 index 0000000..38f840f --- /dev/null +++ b/SplatTool_Installer.nsi @@ -0,0 +1,55 @@ +!define PKG_DIR "pkg-win" + +!ifndef VERSION + !error "VERSION not defined (pass with -DVERSION=...)" +!endif + +!ifndef SHORT_SHA + !error "SHORT_SHA not defined (pass with -DSHORT_SHA=...)" +!endif + +Name "SplatTool" +OutFile "SplatTool-${VERSION}-${SHORT_SHA}-Setup.exe" +InstallDir "$PROGRAMFILES64\SplatTool" +RequestExecutionLevel admin ; Needed to write to Program Files + +Page directory +Page instfiles + +Section "Install Files" + + SetOutPath "$INSTDIR" + + File /r "${PKG_DIR}\*.*" + + ; Create shortcuts + CreateDirectory "$SMPROGRAMS\SplatTool" + CreateShortCut "$DESKTOP\SplatTool.lnk" "$INSTDIR\SplatTool.exe" "" "$INSTDIR\icon.ico" + CreateShortCut "$SMPROGRAMS\SplatTool\SplatTool.lnk" "$INSTDIR\SplatTool.exe" "" "$INSTDIR\icon.ico" + + WriteUninstaller "$INSTDIR\Uninstall.exe" + + ; Register in Add/Remove Programs + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SplatTool" "DisplayName" "SplatTool" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SplatTool" "UninstallString" "$INSTDIR\Uninstall.exe" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SplatTool" "DisplayIcon" "$INSTDIR\icon.ico" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SplatTool" "DisplayVersion" "${VERSION}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SplatTool" "Publisher" "CrafterPika" + +SectionEnd + +Section "Uninstall" + + Delete "$INSTDIR\SplatTool.exe" + Delete "$INSTDIR\icon.ico" + + RMDir /r "$INSTDIR" + + Delete "$DESKTOP\SplatTool.lnk" + Delete "$SMPROGRAMS\SplatTool\SplatTool.lnk" + RMDir "$SMPROGRAMS\SplatTool" + + ; Remove registry keys on uninstall + DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SplatTool" + +SectionEnd diff --git a/TelemetryWebpost.cs b/TelemetryWebpost.cs new file mode 100644 index 0000000..98fa40d --- /dev/null +++ b/TelemetryWebpost.cs @@ -0,0 +1,137 @@ +using ModMenu; +using Func; +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Text.Encodings.Web; +using System.Text.RegularExpressions; +using System.Security.Cryptography; +using System.Text.Json.Serialization; +using System.Security.Cryptography.X509Certificates; + +namespace TelemetryWebpost { + public class SplatTelemetry { + private static readonly HttpClient client = new HttpClient(); + public static uint LastStartNetworkTime = 0; + + public static string Telemetry2Json(byte[] rawData) { + if (rawData == null || rawData.Length == 0) + return "{}"; + + int actualStart = Array.IndexOf(rawData, (byte)0x2D); + if (actualStart == -1) return "{}"; + + string content = Encoding.UTF8.GetString(rawData, actualStart, rawData.Length - actualStart); + + string[] lines = content.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + if (lines.Length == 0 || string.IsNullOrWhiteSpace(lines[0])) + return "{}"; + + string boundary = lines[0].Trim(); + + string[] parts = content.Split(new[] { boundary }, StringSplitOptions.RemoveEmptyEntries); + var result = new Dictionary(); + + foreach (var part in parts) { + if (string.IsNullOrWhiteSpace(part) || + part.Trim() == "--" || + part.Contains("name=\"FaceImg\"") || // Kit Kat said he doesnt need faceimg.tga so i'll skip it :innocent:, makes my life easier. + !part.Contains("Content-Disposition")) + { + continue; + } + + var nameMatch = Regex.Match(part, "name=\"(?[^\"]+)\""); + if (!nameMatch.Success) continue; + string key = nameMatch.Groups["name"].Value; + + int valueStart = part.IndexOf("\r\n\r\n"); + int offset = 4; + + if (valueStart == -1) { + valueStart = part.IndexOf("\n\n"); + offset = 2; + } + + if (valueStart == -1) continue; + + string value = part.Substring(valueStart + offset).Trim(new[] { '\r', '\n', '-', ' ' }); + + result[key] = value; + } + + var options = new JsonSerializerOptions { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + return JsonSerializer.Serialize(result, options); + } + + public static async Task SendTelemetryHttp(string targetUrl, string uniqueId, string TelemetryPayload, byte[] faceImgData) { + var TelemetryPayloadDict = JsonSerializer.Deserialize>(TelemetryPayload); + TelemetryPayloadDict["ServerEnv"] = "L1"; + if (LastStartNetworkTime == Convert.ToUInt32(TelemetryPayloadDict["StartNetworkTime"])) { + Console.WriteLine("Data sent already..."); + return; + } + LastStartNetworkTime = Convert.ToUInt32(TelemetryPayloadDict["StartNetworkTime"]); + try { + using (var content = new MultipartFormDataContent()) { + foreach (var kvp in TelemetryPayloadDict) { + content.Add(new StringContent(kvp.Value), kvp.Key); + } + + var imageContent = new ByteArrayContent(faceImgData); + imageContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + content.Add(imageContent, "FaceImg", "face.bin"); + + byte[] requestBody = await content.ReadAsByteArrayAsync(); + + string sha1Hash; + using (SHA1 sha1 = SHA1.Create()) { + byte[] hashBytes = sha1.ComputeHash(requestBody); + sha1Hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); + } + + var request = new HttpRequestMessage(HttpMethod.Post, targetUrl); + request.Content = content; + + request.Headers.Add("X-BOSS-Digest", sha1Hash); + request.Headers.Add("X-BOSS-UniqueId", uniqueId); + + Console.WriteLine($"Sending telemetry for {TelemetryPayloadDict["MiiName"]}..."); + HttpResponseMessage response = await client.SendAsync(request); + + if (response.IsSuccessStatusCode) { + Console.WriteLine("SUCCESS: Splatoon result saved."); + } else { + string errorText = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"FAILED: {(int)response.StatusCode} - {errorText}"); + } + } + } catch (Exception ex) { + Console.WriteLine($"Connection Error: {ex.Message}"); + } + } + + public static void SendTelemetry() { + if (Program.readUInt32(0x101E4FF8) == 0) { + var p = Program.readUInt32(0x101DCDB0) + 0x150; + var data = Program.readBytes(p, 0x1800); + + byte[] faceimg_pattern = new byte[]{ 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x80, 0x00 }; + int faceimg_offset = ExtFunc.FindPattern(data, faceimg_pattern); + if (faceimg_offset == -1) { + return; + } + var faceimg = Program.readBytes((uint)(p + faceimg_offset), 65580); + var TelemetryPayload = Telemetry2Json(data); + // Console.WriteLine(TelemetryPayload); + + SendTelemetryHttp(Program.config["TelemetryUrl"], "0162b", TelemetryPayload, faceimg).GetAwaiter().GetResult(); + } + } + } +} \ No newline at end of file diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 0000000..944880f --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +3.2.0 diff --git a/assets/Info.plist b/assets/Info.plist new file mode 100644 index 0000000..a98b3de --- /dev/null +++ b/assets/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleExecutable + SplatTool + CFBundleIconFile + icon.icns + CFBundleIdentifier + cc.crafterpika.splattool + CFBundleName + SplatTool + CFBundlePackageType + APPL + CFBundleShortVersionString + 3.2.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.7 + NSHighResolutionCapable + + + \ No newline at end of file diff --git a/assets/SplatTool@1024x1024-rounded.png b/assets/SplatTool@1024x1024-rounded.png new file mode 100644 index 0000000..e52f62d Binary files /dev/null and b/assets/SplatTool@1024x1024-rounded.png differ diff --git a/assets/SplatTool@1024x1024.png b/assets/SplatTool@1024x1024.png new file mode 100644 index 0000000..48d167d Binary files /dev/null and b/assets/SplatTool@1024x1024.png differ diff --git a/assets/config.json b/assets/config.json new file mode 100644 index 0000000..42c29ec --- /dev/null +++ b/assets/config.json @@ -0,0 +1,3 @@ +{ + "TelemetryUrl": "https://splatpost.spbr.net/post" +} \ No newline at end of file diff --git a/assets/demo.png b/assets/demo.png new file mode 100644 index 0000000..aa953c2 Binary files /dev/null and b/assets/demo.png differ diff --git a/assets/entitlements.plist b/assets/entitlements.plist new file mode 100644 index 0000000..f5a9869 --- /dev/null +++ b/assets/entitlements.plist @@ -0,0 +1,17 @@ + + + + + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.cs.debugger + + + + com.apple.security.cs.allow-jit + + + \ No newline at end of file diff --git a/assets/icon.icns b/assets/icon.icns new file mode 100644 index 0000000..da39077 Binary files /dev/null and b/assets/icon.icns differ diff --git a/assets/root_helper/build.sh b/assets/root_helper/build.sh new file mode 100755 index 0000000..98a4edf --- /dev/null +++ b/assets/root_helper/build.sh @@ -0,0 +1,7 @@ +#!/bin/zsh +clang -target x86_64-apple-macos12.7 -o root_helper_x86 root_helper.c +clang -target arm64-apple-macos12.7 -o root_helper_arm64 root_helper.c + +lipo -create root_helper_x86 root_helper_arm64 -output root_helper +rm -rf root_helper_x86 +rm -rf root_helper_arm64 diff --git a/assets/root_helper/root_helper b/assets/root_helper/root_helper new file mode 100755 index 0000000..f65f125 Binary files /dev/null and b/assets/root_helper/root_helper differ diff --git a/assets/root_helper/root_helper.c b/assets/root_helper/root_helper.c new file mode 100644 index 0000000..b33947a --- /dev/null +++ b/assets/root_helper/root_helper.c @@ -0,0 +1,36 @@ +#include +#include +#include +#include +#include +#include + +int main(int argc, char *argv[]) { + char path[PATH_MAX]; + char dir[PATH_MAX]; + char script_path[PATH_MAX]; + char target_app[PATH_MAX]; + char command[PATH_MAX * 3]; + + realpath(argv[0], path); + char *parent_dir = dirname(path); + strncpy(dir, parent_dir, sizeof(dir) - 1); + + if (geteuid() != 0) { + snprintf(command, sizeof(command), "osascript -e \"do shell script \\\"%s\\\" with prompt \\\"SplatTool needs root for reading Cemu's memory.\\\" with administrator privileges\"", path); + + int status = system(command); + return WEXITSTATUS(status); + } + + snprintf(target_app, sizeof(target_app), "%s/SplatTool", dir); + chdir(dir); + + extern char **environ; + char *new_argv[] = { target_app, NULL }; + + execve(target_app, new_argv, environ); + + perror("execve failed"); + return 1; +} \ No newline at end of file diff --git a/build-osx-app.sh b/build-osx-app.sh new file mode 100755 index 0000000..7049420 --- /dev/null +++ b/build-osx-app.sh @@ -0,0 +1,46 @@ +#!/bin/zsh +rm -rf dist +rm -rf bin +rm -rf obj +rm -rf *.dmg + +dotnet publish -r osx-x64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true +dotnet publish -r osx-arm64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true + +INTEL_PUBLISH="bin/Release/net8.0/osx-x64/publish" +ARM64_PUBLISH="bin/Release/net8.0/osx-arm64/publish" +APP_ROOT="dist/SplatTool.app" + +mkdir -p "$APP_ROOT/Contents/MacOS" +mkdir -p "$APP_ROOT/Contents/Resources" +mkdir -p "$APP_ROOT/Contents/Frameworks" + +SHORT_SHA="$(git rev-parse --short HEAD)" +VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" assets/Info.plist) + +cp assets/Info.plist "$APP_ROOT/Contents/" +cp assets/icon.icns "$APP_ROOT/Contents/Resources/" +cp assets/config.json "$APP_ROOT/Contents/Resources" +cp -r modmenu "$APP_ROOT/Contents/Resources/" +cp icon.ico "$APP_ROOT/Contents/Resources/" +cp assets/root_helper/root_helper "$APP_ROOT/Contents/MacOS" +cp "$ARM64_PUBLISH/Photino.Native.dylib" "$APP_ROOT/Contents/Frameworks" +lipo -create -output "$APP_ROOT/Contents/MacOS/SplatTool" "$INTEL_PUBLISH/SplatTool" "$ARM64_PUBLISH/SplatTool" +lipo -create -output "$APP_ROOT/Contents/Frameworks/libnfd.dylib" "$INTEL_PUBLISH/libnfd.dylib" "$ARM64_PUBLISH/libnfd.dylib" + +install_name_tool -add_rpath "@loader_path/../Frameworks" "$APP_ROOT/Contents/MacOS/SplatTool" +install_name_tool -id "@rpath/libnfd.dylib" "$APP_ROOT/Contents/Frameworks/libnfd.dylib" +install_name_tool -id "@rpath/Photino.Native.dylib" "$APP_ROOT/Contents/Frameworks/Photino.Native.dylib" + +xattr -cr "$APP_ROOT" + +chmod +x "$APP_ROOT/Contents/MacOS/SplatTool" +chmod +x "$APP_ROOT/Contents/MacOS/root_helper" + +codesign --remove-signature "$APP_ROOT" +codesign --sign "-" --entitlements assets/entitlements.plist --force --deep --preserve-metadata=entitlements,requirements,flags,runtime --timestamp "$APP_ROOT" +codesign -d --entitlements - "$APP_ROOT/Contents/MacOS/SplatTool" +codesign -vvv --deep --strict "$APP_ROOT" + +ln -sf "/Applications" "dist/Applications" +hdiutil create -format UDZO -fs HFS+ -ov -volname "SplatTool" -srcfolder dist/ SplatTool-$VERSION-$SHORT_SHA-Universal-OSX.dmg \ No newline at end of file diff --git a/httpMenu.cs b/httpMenu.cs new file mode 100644 index 0000000..2502468 --- /dev/null +++ b/httpMenu.cs @@ -0,0 +1,150 @@ +// modules +using ModMenu; +using Func; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using FreezeThread; +using TelemetryWebpost; +using SixLabors.ImageSharp.Processing.Processors.Normalization; +using SixLabors.ImageSharp.Processing; + +namespace ModMenu { + public class ModMenuRequest { + public string? action { get; set; } + public string[]? settings { get; set; } + } + + public class httpMenu { + public static uint sessionId; + public static List GearQueue = new List(); + public static uint Gender; + public static uint Level; + public static uint Cash; + public static uint SeaSnails; + public static SessionData session = new SessionData(); + static WorkThread sessiondIdThread = new WorkThread(() => { + Codes.forceSessiondId(sessionId); + }); + static WorkThread WriteGear = new WorkThread(() => { + SaveEditor.WriteGearQueue(); + }); + static WorkThread ChangeGender = new WorkThread(() => { + SaveEditor.ChangeGender(Gender); + }); + static WorkThread ChangeLevel = new WorkThread(() => { + SaveEditor.ChangeLevel(Level); + }); + static WorkThread ChangeCash = new WorkThread(() => { + SaveEditor.ChangeCash(Cash); + }); + static WorkThread ChangeSeaSnails = new WorkThread(() => { + SaveEditor.ChangeSeaSnails(SeaSnails); + }); + static WorkThread TelemetryThread = new WorkThread(() => { + Thread.Sleep(31000); + SplatTelemetry.SendTelemetry(); + }); + public static string RunCode(ModMenuRequest data) { + if (data?.action == null) return "0"; + + switch (data.action) { + case "cbase": + return $"Cemu Base: 0x{Program.cbase:X} (0x{Program.cbaseCorrected:X})

Total Wins: {Codes.TotalWins()}
Total Losses: {Codes.TotalLoss()}

Thread Status:
- Write Gear: {WriteGear.Status.ToString()}
- Telemetry: {TelemetryThread.Status.ToString()}
- Change Gender: {ChangeGender.Status.ToString()}
- Change Level: {ChangeLevel.Status.ToString()}
- Change Cash: {ChangeCash.Status.ToString()}
- Change Sea Snails: {ChangeSeaSnails.Status.ToString()}"; + + case "sessiondata": + return Codes.GetSessionData(); + + case "namechange": + if (data.settings?.Length > 0) Codes.NameChanger(data.settings[0]); + return "1"; + + case "sessionid": + if (data.settings?.Length > 0) { + sessionId = (uint)Int32.Parse(data.settings[0]); + HandleThread(sessiondIdThread); + } + return "1"; + + case "faceimgtga": + Codes.faceimgtga(Int32.Parse(data.settings[0])); + return "1"; + + case "OSType": + return Program.OsType.ToString(); + + case "FetchGear": + return SaveEditor.FetchAllGear(); + + case "WriteToGearQueue": + SaveEditor.QueueGearSlot(UInt32.Parse(data.settings[0]), data.settings[1], UInt32.Parse(data.settings[2]), UInt32.Parse(data.settings[3]), UInt32.Parse(data.settings[4]), UInt32.Parse(data.settings[5])); + return "1"; + + case "WriteGearQueue": + if (WriteGear.Status == WorkThread.ThreadStatus.Stopped) { + WriteGear.Start(); + } else if (WriteGear.Status == WorkThread.ThreadStatus.Running) { + WriteGear.Stop(); + GearQueue = new List(); + GearQueue.Clear(); + } + return "1"; + + case "ChangeGender": + Gender = UInt32.Parse(data.settings[0]); + HandleThread(ChangeGender); + return "1"; + + case "ChangeLevel": + try { + Level = UInt32.Parse(data.settings[0]); + } catch { + Level = 0; + } + HandleThread(ChangeLevel); + return "1"; + + case "ChangeCash": + try { + Cash = UInt32.Parse(data.settings[0]); + } catch { + Cash = 0; + } + HandleThread(ChangeCash); + return "1"; + + case "ChangeSeaSnails": + try { + SeaSnails = UInt32.Parse(data.settings[0]); + } catch { + SeaSnails = 0; + } + HandleThread(ChangeSeaSnails); + return "1"; + + case "DisconnectLobby": + Codes.DisconnectFromLobby(); + return "1"; + + case "Webpost": + HandleThread(TelemetryThread); + return "1"; + + default: + return "Not Found"; + } + } + + private static void HandleThread(WorkThread thread) { + if (thread.Status == WorkThread.ThreadStatus.Stopped) { + thread.Start(); + } else if (thread.Status == WorkThread.ThreadStatus.Running) { + thread.Pause(); + } else if (thread.Status == WorkThread.ThreadStatus.Paused) { + thread.Resume(); + } else { + thread.Pause(); + } + } + } +} diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..705c5c8 Binary files /dev/null and b/icon.ico differ diff --git a/modmenu/ids.json b/modmenu/ids.json new file mode 100644 index 0000000..637cbe9 --- /dev/null +++ b/modmenu/ids.json @@ -0,0 +1,359 @@ +{ + "headgear": { + "0": "ARINOMAMA (ID=0)", + "1": "White Headband (ID=1)", + "1000": "Urchins Cap (ID=1000)", + "1001": "Lightweight Cap (ID=1001)", + "1002": "Takoroka Mesh (ID=1002)", + "1003": "Fashion Cap (ID=1003)", + "1004": "Squid-Stitch Cap (ID=1004)", + "1005": "Squidvader Cap (ID=1005)", + "1006": "Camo Mesh Cap (ID=1006)", + "1007": "5-Panel Cap (ID=1007)", + "1008": "Zekko Mesh (ID=1008)", + "1009": "Backwards Cap (ID=1009)", + "1010": "2-Stripe Mesh Cap (ID=1010)", + "1011": "Jet Cap (ID=1011)", + "1012": "Cycling Cap (ID=1012)", + "1013": "SQUID GIRL Hat (ID=1013)", + "1014": "Cycle King Cap (ID=1014)", + "1015": "Legendary Cap (ID=1015)", + "1016": "CoroCoro Cap (ID=1016)", + "2000": "Bobble Hat (ID=2000)", + "2001": "Short Beanie (ID=2001)", + "2002": "Striped Beanie (ID=2002)", + "2003": "Sporty Bobble Hat (ID=2003)", + "2004": "Special Forces Beret (ID=2004)", + "2005": "Squid Nordic (ID=2005)", + "3000": "Retro Specs (ID=3000)", + "3001": "Splash Googles (ID=3001)", + "3002": "Pilot Goggles (ID=3002)", + "3003": "Colored Shadows (ID=3003)", + "3004": "Black Arrowbands (ID=3004)", + "3005": "Snorkel (ID=3005)", + "3006": "White Arrowbands (ID=3006)", + "3007": "Fake Contacts (ID=3007)", + "3008": "18K Aviators (ID=3008)", + "3009": "Full Moon Glasses (ID=3009)", + "3010": "Octoglasses (ID=3010)", + "4000": "Jungle Hat (ID=4000)", + "4001": "Safari Hat (ID=4001)", + "4002": "Camping Hat (ID=4002)", + "4003": "Fugu Bell Hat (ID=4003)", + "4004": "Bambo Hat (ID=4004)", + "4005": "Straw Boater (ID=4005)", + "4006": "Treasure Hunter (ID=4006)", + "5000": "Studio Headphones (ID=5000)", + "5001": "Colorful Headphones (ID=5001)", + "5002": "Noise Cancellers (ID=5002)", + "6000": "Golf Visor (ID=6000)", + "6001": "FishFry Visor (ID=6001)", + "6002": "Sun Visor (ID=6002)", + "7000": "Cycle Helmet (ID=7000)", + "7002": "Stealth Goggles (ID=7002)", + "7003": "Tentacles Helmet (ID=7003)", + "7004": "Skate Helmet (ID=7004)", + "7005": "Visor Skate Helmet (ID=7005)", + "8000": "Gas Mask (ID=8000)", + "8001": "Paintball Mask (ID=8001)", + "8002": "Paisley Bandana (ID=8002)", + "8003": "Skull Bandana (ID=8003)", + "9001": "B-Ball Headband (ID=9001)", + "9002": "Squash Headband (ID=9002)", + "9003": "Tennis Headband (ID=9003)", + "9004": "Joggling Headband (ID=9004)", + "9005": "Soccer Headband (ID=9005)", + "9006": "Traditional Headband (ID=9006)", + "25000": "Squid Hairclip (ID=25000)", + "25001": "Samurai Helmet (ID=25001)", + "25002": "Power Mask (ID=25002)", + "27000": "Hero Headset Replica (ID=27000)", + "27001": "?MSN001 (ID=27001)", + "27002": "?MSN002 (ID=27002)", + "27003": "?MSN003 (ID=27003)", + "27004": "?MSN004 (ID=27004)", + "28000": "Octoling Scope (ID=28000)", + "28001": "ヘルメットライバル用強 (ID=28001)", + "29003": "?SubInk_Save (ID=29003)", + "29004": "?InkRecovery_Up (ID=29004)", + "29005": "?HumanMove_Up (ID=29005)", + "29006": "?SquidMove_Up (ID=29006)", + "29007": "?SpecialIncrease_Up (ID=29007)", + "29008": "?SpecialTime_Up (ID=29008)", + "29009": "?RespawnTime_Save (ID=29009)", + "29010": "?RespawnSpecialGauge_Save (ID=29010)", + "29011": "?JumpTime_Save (ID=29011)", + "29012": "?BombDistance_Up (ID=29012)", + "29013": "?StartAllUp (ID=29013)", + "29014": "?EndAllUp (ID=29014)", + "29015": "?MinorityUp (ID=29015)", + "29016": "?ComeBack (ID=29016)", + "29500": "?SUP000 (ID=29500)", + "29501": "?SUP001 (ID=29501)" + }, + "clothes": { + "0": "?NoClothes (ID=0)", + "1": "Basic Tee (ID=1)", + "1000": "White Tee (ID=1000)", + "1001": "Black Squideye (ID=1001)", + "1003": "Sky Blue Squideye (ID=1003)", + "1004": "RockenBerg White (ID=1004)", + "1005": "RockenBerg Black (ID=1005)", + "1006": "Black Tee (ID=1006)", + "1007": "Sunny Day Tee (ID=1007)", + "1008": "Rainy Day Tee (ID=1008)", + "1009": "Reggae Tee (ID=1009)", + "1010": "Fugu Tee (ID=1010)", + "1011": "Mint Tee (ID=1011)", + "1012": "Grape Tee (ID=1012)", + "1013": "Red Vector Tee (ID=1013)", + "1014": "Grey Vector Tee (ID=1014)", + "1015": "Blue Peaks Tee (ID=1015)", + "1016": "Ivory Peaks Tee (ID=1016)", + "1017": "Squid-Stitch Tee (ID=1017)", + "1018": "Pirate Stripes Tee (ID=1018)", + "1019": "Sailor Stripes Tee (ID=1019)", + "1020": "White 8-Bit FishFry (ID=1020)", + "1021": "Black 8-Bit FishFry (ID=1021)", + "1022": "White Anchor Tee (ID=1022)", + "1023": "Black Anchor Tee (ID=1023)", + "1024": "White Line Tee (ID=1024)", + "1025": "Black Pipe Tee (ID=1025)", + "1026": "Carnivore Tee (ID=1026)", + "1027": "Pearl Tee (ID=1027)", + "1028": "Octo Tee (ID=1028)", + "1029": "Herbivore Tee (ID=1029)", + "2000": "White Striped LS (ID=2000)", + "2001": "Black LS (ID=2001)", + "2002": "Purple Camo LS (ID=2002)", + "2003": "Navy Striped LS (ID=2003)", + "2004": "Zekko Baseball LS (ID=2004)", + "2005": "Varsity Baseball (ID=2005)", + "2006": "Black Baseball LS (ID=2006)", + "2007": "White Baseball LS (ID=2007)", + "2008": "White LS (ID=2008)", + "2009": "Green Striped LS (ID=2009)", + "2010": "Squidmark LS (ID=2010)", + "2011": "Zink LS (ID=2011)", + "2012": "Striped Peaks LS (ID=2012)", + "3000": "White Layered LS (ID=3000)", + "3001": "Yellow Layered LS (ID=3001)", + "3002": "Layered Camo LS (ID=3002)", + "3003": "Black Layered LS (ID=3003)", + "3004": "Zink Layered LS (ID=3004)", + "3005": "Layered Anchor LS (ID=3005)", + "3006": "Choco Layered LS (ID=3006)", + "3007": "Part-Time Pirate (ID=3007)", + "3008": "Layered Vector LS (ID=3008)", + "3009": "Green Tee (ID=3009)", + "4000": "Pink Shrimp Polo (ID=4000)", + "4001": "Striped Rugby (ID=4001)", + "4002": "Tricolor Rugby (ID=4002)", + "4003": "Sage Green Polo (ID=4003)", + "4004": "Black Polo (ID=4004)", + "4005": "Cycling Shirt (ID=4005)", + "4006": "Cycle King Jersey (ID=4006)", + "4007": "Slipstream United (ID=4007)", + "4008": "FC Albacore (ID=4008)", + "5000": "Olive Ski Jacket (ID=5000)", + "5002": "Berry Ski Jacket (ID=5002)", + "5003": "Varsity Jacket (ID=5003)", + "5004": "School Jersey (ID=5004)", + "5005": "Green Cardigan (ID=5005)", + "5006": "Black Inky Rider (ID=5006)", + "5007": "White Inky Rider (ID=5007)", + "5008": "Retro Gamer Jersey (ID=5008)", + "5009": "Orange Cardigan (ID=5009)", + "5010": "Forge Inkling Perka (ID=5010)", + "5011": "Forge Octarian Jacket (ID=5011)", + "5012": "Blue Sailor Suit (ID=5012)", + "5013": "White Sailor Suit (ID=5013)", + "5014": "Squid Satin Jacket (ID=5014)", + "5015": "Zapfish Satin Jacket (ID=5015)", + "5016": "Krak-On 528 (ID=5016)", + "6000": "B-Ball Vest (Home) (ID=6000)", + "6001": "B-Ball Vest (Away) (ID=6001)", + "6002": "SQUID GIRL Tunic (ID=6002)", + "7000": "Grey College Sweat (ID=7000)", + "7001": "Squidmark Sweat (ID=7001)", + "7002": "Retro Sweat (ID=7002)", + "7003": "Firefin Sweat Navy (ID=7003)", + "7004": "Navy Collage Sweat (ID=7004)", + "7005": "Reel Sweat (ID=7005)", + "7006": "Anchor Sweat (ID=7006)", + "8000": "Lumberjack Shirt (ID=8000)", + "8001": "Rodeo Shirt (ID=8001)", + "8002": "Green Check Shirt (ID=8002)", + "8003": "White Shirt (ID=8003)", + "8004": "Urchins Jersey (ID=8004)", + "8005": "Aloha Shirt (ID=8005)", + "8006": "Red Check Shirt (ID=8006)", + "8007": "Baby Jelly Shirt (ID=8007)", + "8008": "Baseball Jersey (ID=8008)", + "8009": "Grey Mixed Shirt (ID=8009)", + "8010": "Vintage Check (ID=8010)", + "8011": "Round Collar Shirt (ID=8011)", + "8012": "Logo Aloha Shirt (ID=8012)", + "8013": "Striped Shirt (ID=8013)", + "8014": "Linen Shirt (ID=8014)", + "8015": "Shirt and Tie (ID=8015)", + "8016": "Traditional Apron (ID=8016)", + "9000": "Mountain Gilet (ID=9000)", + "9001": "Forest Gilet (ID=9001)", + "9002": "Dark Urban Gilet (ID=9002)", + "9003": "Yellow Urban Gilet (ID=9003)", + "9004": "Squid Pattern Waistcoat (ID=9004)", + "9005": "Squidstar Waistcoat (ID=9005)", + "10000": "Camo Zip Hoodie (ID=10000)", + "10001": "Green Zip Hoodie (ID=10001)", + "10002": "Zekko Hoodie (ID=10002)", + "10003": "CoroCoro Hoodie (ID=10003)", + "25000": "School Uniform (ID=25000)", + "25001": "Samurai Jacket (ID=25001)", + "25002": "Power Armour (ID=25002)", + "27000": "?MSN000 (ID=27000)", + "27001": "?MSN001 (ID=27001)", + "27002": "?MSN002 (ID=27002)", + "27003": "?MSN003 (ID=27003)", + "27004": "Armor Jacket Replica (ID=27004)", + "28000": "Octoling Armor (ID=28000)", + "26000": "Splatfest Tee (ID=26000)", + "29000": "Attack_Up (ID=29000)", + "29001": "Defense_Up (ID=29001)", + "29002": "MainInk_Save (ID=29002)", + "29003": "SubInk_Save (ID=29003)", + "29004": "InkRecovery_Up (ID=29004)", + "29005": "HumanMove_Up (ID=29005)", + "29006": "SquidMove_Up (ID=29006)", + "29007": "SpecialIncrease_Up (ID=29007)", + "29008": "SpecialTime_Up (ID=29008)", + "29009": "RespawnTime_Save (ID=29009)", + "29010": "RespawnSpecialGauge_Save (ID=29010)", + "29011": "JumpTime_Save (ID=29011)", + "29012": "BombDistance_Up (ID=29012)", + "29013": "MarkingCancel (ID=29013)", + "29014": "SquidMoveSpatter_Reduction (ID=29014)", + "29015": "DeathMarking (ID=29015)", + "29016": "RespawnRadar (ID=29016)", + "29500": "?SUP000 (ID=29500)", + "29501": "?SUP001 (ID=29501)" + }, + "shoes": { + "0": "ありのまま (ID=0)", + "1": "Cream Basics (ID=1)", + "1000": "Blue Lo-Tops (ID=1000)", + "1001": "Banana Basics (ID=1001)", + "1002": "LE Lo-Tops (ID=1002)", + "1003": "White Seahorses (ID=1003)", + "1004": "Orange Lo-Tops (ID=1004)", + "1005": "Black Seahorses (ID=1005)", + "1006": "Clownfish Basics (ID=1006)", + "1007": "Yellow Seahorses (ID=1007)", + "1008": "Strapping Whites (ID=1008)", + "1009": "Strapping Reds (ID=1009)", + "1010": "Soccer Cleats (ID=1010)", + "1011": "LE Soccer Cleats (ID=1011)", + "2000": "Red Hi-Horses (ID=2000)", + "2001": "Zombie Hi-Horses (ID=2001)", + "2002": "Cream Hi-Tops (ID=2002)", + "2003": "Purple Hi-Horses (ID=2003)", + "2004": "Hunter Hi-Tops (ID=2004)", + "2005": "Red Hi-Tops (ID=2005)", + "2006": "Gold Hi_Horses (ID=2006)", + "2007": "SQUID GIRL Shoes (ID=2007)", + "2008": "Shark Mawcasins (ID=2008)", + "2009": "Mawcasins (ID=2009)", + "3000": "Pink Trainers (ID=3000)", + "3001": "Orange Arrows (ID=3001)", + "3002": "Neon Sea Slugs (ID=3002)", + "3003": "White Arrows (ID=3003)", + "3004": "Cyan Trainers (ID=3004)", + "3005": "Purple Sea Slugs (ID=3005)", + "3006": "Red Sea Slugs (ID=3006)", + "3007": "Blue Sea Slugs (ID=3007)", + "3008": "Crazy Arrows (ID=3008)", + "3009": "Black Trainers (ID=3009)", + "4000": "Oyster Clogs (ID=4000)", + "4001": "Choco Clogs (ID=4001)", + "4002": "Blueberry Casuals (ID=4002)", + "4003": "Plum Casuals (ID=4003)", + "4006": "Traditional Sandals (ID=4006)", + "5000": "Trail Boots (ID=5000)", + "5001": "Custom Trail Boots (ID=5001)", + "5002": "Pro Trail Boots (ID=5002)", + "6000": "Moto Boots (ID=6000)", + "6001": "Tan Work Boots (ID=6001)", + "6002": "Red Work Boots (ID=6002)", + "6003": "Blue Moto Boots (ID=6003)", + "6004": "Green Rain Boots (ID=6004)", + "6005": "Acerola Rain Boots (ID=6005)", + "6006": "Punk Whites (ID=6006)", + "6007": "Punk Cherries (ID=6007)", + "6008": "Punk Yellows (ID=6008)", + "6009": "Bubble Rain Boots (ID=6009)", + "6010": "Snowy Down Boots (ID=6010)", + "6011": "Icy Down Boots (ID=6011)", + "7000": "Blue Slip-Ons (ID=7000)", + "7001": "Red Slip-Ons (ID=7001)", + "7002": "Squid-Stitch Slip-Ons (ID=7002)", + "8000": "White Kicks (ID=8000)", + "8001": "Cherry Kicks (ID=8001)", + "8002": "Turquoise Kicks (ID=8002)", + "8003": "Squink Wingtips (ID=8003)", + "8004": "Roasted Brogues (ID=8004)", + "25000": "School Shoes (ID=25000)", + "25001": "Samurai Shoes (ID=25001)", + "25002": "Power Boots (ID=25002)", + "27000": "Hero Runner Replica (ID=27000)", + "27001": "?MSN001 (ID=27001)", + "27002": "?MSN002 (ID=27002)", + "27003": "?MSN003 (ID=27003)", + "27004": "Armor Boots Replica (ID=27004)", + "28000": "Octoling Boots (ID=28000)", + "29000": "?Attack_Up (ID=29000)", + "29001": "?Defense_Up (ID=29001)", + "29002": "?MainInk_Save (ID=29002)", + "29003": "?SubInk_Save (ID=29003)", + "29004": "?Inkrecovery_Up (ID=29004)", + "29005": "?HumanMove_Up (ID=29005)", + "29006": "?SquidMove_Up (ID=29006)", + "29007": "?SpecialIncrease_Up (ID=29007)", + "29008": "?SpecialTime_Up (ID=29008)", + "29009": "?RespawnTime_Save (ID=29009)", + "29010": "?RespawnSpecialGauge_Save (ID=29010)", + "29011": "?JumpTime_Save (ID=29011)", + "29012": "?BombDistance_Up (ID=29012)", + "29013": "?TrapDetect (ID=29013)", + "29014": "?EnemyInkEffect_Reduction (ID=29014)", + "29015": "?SuperJumpSign_Hide (ID=29015)", + "29500": "?SUP000 (ID=29500)", + "29501": "?SUP001 (ID=29501)" + }, + "abilities": { + "0": "Damage Up", + "1": "Defense Up", + "2": "Ink Saver (Main)", + "3": "Ink Saver (Sub)", + "4": "Ink Recovery Up", + "5": "Run Speed Up", + "6": "Swim Speed Up", + "7": "Special Charge Up", + "8": "Special Duration Up", + "9": "Quick Respawn", + "10": "Special Saver", + "11": "Quick Super Jump", + "12": "Bomb Range Up", + "100": "Opening Gambit", + "101": "Last Ditch Effort", + "102": "Tenacity", + "103": "Comeback", + "104": "Cold Blooded", + "105": "Ninja Squid", + "106": "Haunt", + "107": "Recon", + "108": "Bomb Sniffer", + "109": "Ink Resistance Up", + "110": "Stealth Jump", + "111": "S_Abillity_Locked" + } +} diff --git a/modmenu/index.html b/modmenu/index.html new file mode 100644 index 0000000..56a433a --- /dev/null +++ b/modmenu/index.html @@ -0,0 +1,189 @@ + + + + + + SplatTool + + + + +

SplatTool Mod Menu

+ +
+
+ + + + + + +
+ +
+
+
+
+ Name Changer: + + +
+
+
+ Disconnect From Lobby: +
+
+
+
+
+
+ +
+
+

Gear Editor

+
+ Category: + +
+ +
+

Select a category to load gear...

+
+ +
+ +
+
+ Item to Write: + +
+
+ Slot Index: + +
+
+ Sub 1: + +
+
+ Sub 2: + +
+
+ Sub 3: + +
+
+ + +
+
+

+

Player & Other

+
+
+ Change Gender: +
+
+ + +
+
+
+
+ Change Level: + + +
+
+
+
+ Change Cash: + + +
+
+
+
+ Change Sea Snails: + + +
+
+
+
+ +
+
+
+
+ Force Session ID: + + +
+
+
+
+ Dump faceimg.tga: + Disable Anti-Telemetry. +
+
+ + +
+
+
+
+ +
+
+
+ Session Control: + +
+
+

No data fetched yet.

+
+
+
+ +
+
+

Stats:

+
Waiting for fetch...
+

+
+
+ Webpost: + Only works with SPFN. +
+
+
+
+
+
+
+ +
+
+

SplatTool v3.2.0

+
Written by CrafterPika
+
Special Thanks: Winterberry, Javi.ig, Tombuntu, KittenTM, apoplexy
+
+
+
+ + + + diff --git a/modmenu/script.js b/modmenu/script.js new file mode 100644 index 0000000..a7ff6dd --- /dev/null +++ b/modmenu/script.js @@ -0,0 +1,210 @@ +let gearData = null; +let gearThreadActive = false; + +document.addEventListener('DOMContentLoaded', () => { + loadGearData(); + sendToCSharp("OSType"); +}); + +async function loadGearData() { + try { + const response = await fetch('ids.json'); + gearData = await response.json(); + populateAbilities(); + syncGearDropdown(); + } catch (e) { + console.error("Gear IDs failed to load. Check if ids.json exists.", e); + } +} + +function onCategoryChange() { + syncGearDropdown(); + const grid = document.getElementById('gear-selection-grid'); + grid.innerHTML = '

Fetching Gear...

'; + execute('FetchGear'); +} + +function syncGearDropdown() { + if (!gearData) return; + const cat = document.getElementById('gear-category').value; + + const key = (cat === "clothing") ? "clothes" : cat; + const select = document.getElementById('gear-item'); + + if (!select) return; + select.innerHTML = ''; + + if (gearData[key]) { + for (const [id, name] of Object.entries(gearData[key])) { + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = name; + select.appendChild(opt); + } + } +} + +function populateAbilities() { + const selectors = ['ability-sub1', 'ability-sub2', 'ability-sub3']; + selectors.forEach(id => { + const select = document.getElementById(id); + if (!select || !gearData.abilities) return; + select.innerHTML = ''; + for (const [val, name] of Object.entries(gearData.abilities)) { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = name; + select.appendChild(opt); + } + }); +} + +window.external.receiveMessage(message => { + // console.log(message) + if (message.startsWith("Cemu Base:")) { + document.getElementById("cbase").innerHTML = message; + } + + if (message === "2") { + alert("MacOS Build is Untested. Expect Issues!"); + } + + try { + const data = JSON.parse(message); + //console.log("Parsed Data:", data); + const container = document.getElementById("pid-display"); + + if (data.Players) { + container.innerHTML = `

Fetching data....

`; + let html = `
SESSION: ${data.SessionID}
`; + data.Players.forEach((p, i) => { + html += `
P${i}: ${p.Name}
PID: ${p.PID}
NNID: ${p.NNID}
`; + }); + container.innerHTML = html || '

No players found.

'; + } + + else if (data.headgear || data.clothing || data.shoes || data.clothes) { + renderGearGrid(data); + } + } catch (e) { + console.log(e.message); + } +}); + +function renderGearGrid(fullData) { + const category = document.getElementById('gear-category').value; + const grid = document.getElementById('gear-selection-grid'); + grid.innerHTML = ""; + + let items = fullData[category]; + if (!items && category === "clothing") items = fullData["clothes"]; + + if (!items || items.length === 0) { + grid.innerHTML = '

No gear found in this category.

'; + return; + } + + const idLookupKey = (category === "clothing") ? "clothes" : category; + + items.forEach((item, index) => { + const isEmpty = (item.GearID === 4294967295 || item.GearID === -1); + + let gearName = "Unknown Item"; + if (isEmpty) { + gearName = "Empty Slot (+)"; + } else if (gearData && gearData[idLookupKey] && gearData[idLookupKey][item.GearID]) { + gearName = gearData[idLookupKey][item.GearID]; + } else { + gearName = `ID: ${item.GearID}`; + } + + const card = document.createElement('div'); + card.className = 'pid-card'; + card.style.cursor = 'pointer'; + + if (isEmpty) { + card.style.borderLeft = '4px dashed #444'; + card.style.opacity = '0.7'; + } + + card.innerHTML = `${gearName}
Slot: ${index}`; + + card.onclick = () => { + document.querySelectorAll('.pid-card').forEach(c => { + c.style.borderColor = 'var(--border)'; + c.style.background = 'transparent'; + }); + card.style.borderColor = 'var(--highlight)'; + card.style.background = 'rgba(3, 218, 198, 0.05)'; + + syncGearDropdown(); + document.getElementById('gear-slot-idx').value = index; + + if (!isEmpty) { + document.getElementById('gear-item').value = item.GearID; + + document.getElementById('ability-sub1').value = (item.Slot1 === 4294967295) ? 0 : item.Slot1; + document.getElementById('ability-sub2').value = (item.Slot2 === 4294967295) ? 0 : item.Slot2; + document.getElementById('ability-sub3').value = (item.Slot3 === 4294967295) ? 0 : item.Slot3; + } else { + document.getElementById('gear-item').selectedIndex = 0; + document.getElementById('ability-sub1').value = 0; + document.getElementById('ability-sub2').value = 0; + document.getElementById('ability-sub3').value = 0; + } + }; + + grid.appendChild(card); + }); +} + +function applyGearChange() { + const category = document.getElementById('gear-category').value; + const slot = document.getElementById('gear-slot-idx').value; + const id = document.getElementById('gear-item').value; + const s1 = document.getElementById('ability-sub1').value; + const s2 = document.getElementById('ability-sub2').value; + const s3 = document.getElementById('ability-sub3').value; + + sendToCSharp("WriteToGearQueue", [slot, category, id, s1, s2, s3]); +} + +function toggleGearThread() { + gearThreadActive = !gearThreadActive; + const btn = document.getElementById('gear-thread-btn'); + btn.textContent = gearThreadActive ? "Stop Queue" : "Write Queue"; + btn.style.background = gearThreadActive ? "var(--highlight)" : "#333"; + sendToCSharp("WriteGearQueue"); +} + +function sendToCSharp(action, settings = []) { + if (window.external && window.external.sendMessage) { + window.external.sendMessage(JSON.stringify({ action: action, settings: settings })); + } +} + +function openTab(evt, tabName) { + const content = document.getElementsByClassName("tab-content"); + for (let i = 0; i < content.length; i++) { + content[i].classList.remove("active"); + content[i].style.display = "none"; + } + const links = document.getElementsByClassName("tab-link"); + for (let i = 0; i < links.length; i++) links[i].classList.remove("active"); + + document.getElementById(tabName).style.display = "block"; + document.getElementById(tabName).classList.add("active"); + evt.currentTarget.classList.add("active"); + if (tabName === 'Tab6') fetchCemuBase(); +} + +function namechange() { sendToCSharp("namechange", [document.getElementById("name-input").value]); } +function sessionid() { sendToCSharp("sessionid", [document.getElementById("session-input").value]); } +function faceimgtga() { sendToCSharp("faceimgtga", [document.getElementById("format-select").value]); } +function GenderChange() { sendToCSharp("ChangeGender", [document.getElementById("gender-select").value]); } +function Level() { sendToCSharp("ChangeLevel", [document.getElementById("level-input").value]); } +function Cash() { sendToCSharp("ChangeCash", [document.getElementById("cash-input").value]); } +function SeaSnails() { sendToCSharp("ChangeSeaSnails", [document.getElementById("snails-input").value]); } +function execute(cheat) { sendToCSharp(cheat); } +function fetchCemuBase() { sendToCSharp("cbase"); } +function fetchPids() { sendToCSharp("sessiondata"); } diff --git a/modmenu/style.css b/modmenu/style.css new file mode 100644 index 0000000..168fbd0 --- /dev/null +++ b/modmenu/style.css @@ -0,0 +1,264 @@ +:root { + --bg-color: #0f0f0f; + --card-bg: #181818; + --text-color: #d1d1d1; + --accent: #bb86fc; + --highlight: #03dac6; + --border: #2c2c2c; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background-color: var(--bg-color); + color: var(--text-color); + display: flex; + justify-content: center; + padding-top: 40px; + flex-direction: column; + align-items: center; +} + +.tabs-container { + width: 90%; + max-width: 700px; + background: var(--card-bg); + border-radius: 8px; + border: 1px solid var(--border); + overflow: hidden; +} + +.tab-menu { + display: flex; + background: #121212; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.tab-link { + padding: 10px 5px; + cursor: pointer; + border: none; + background: none; + color: #777; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + transition: 0.2s; + flex: 1; + min-width: 80px; +} + +.tab-link:hover { color: #fff; background: #1a1a1a; } + +.tab-link.active { + color: var(--accent); + border-bottom: 2px solid var(--accent); +} + +.tab-content { display: none; padding: 20px; animation: fadeIn 0.3s; } +.tab-content.active { display: block; } + + +#cbase { + font-family: monospace; + background: #000; + padding: 10px; + border-radius: 4px; + color: var(--highlight); + border: 1px solid #333; +} + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.profile-card { + background: #161616; + padding: 15px; + border-radius: 4px; + border: 1px solid var(--border); +} + +input[type="text"] { + background: #000; + border: 1px solid #333; + color: #fff; + padding: 8px 12px; + border-radius: 3px; + flex: 1; + font-size: 13px; + outline: none; +} + +input[type="text"]:focus { + border-color: var(--accent); +} + +.action-btn { + background: var(--accent); + color: #fff; + border: none; + padding: 8px 15px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + transition: all 0.2s ease-in-out; +} + +.action-btn:hover { + background: var(--highlight); + color: #000; + box-shadow: 0 0 12px var(--highlight); + transform: translateY(-1px); +} + +.action-btn:active { + transform: translateY(1px); + box-shadow: 0 0 4px var(--highlight); +} + +.input-label { + font-size: 13px; + font-weight: 600; + color: #bbb; + white-space: nowrap; + text-transform: none; +} + +.input-group { + display: flex; + align-items: center; + gap: 12px; +} + +.input-row { + display: flex; + align-items: center; + margin-bottom: 15px; +} + +.input-row:last-child { + margin-bottom: 0; +} + +.input-label { + width: 140px; + font-size: 13px; + font-weight: 600; + color: #bbb; +} + +.input-group { + display: flex; + gap: 8px; + flex: 1; +} + +.spacer { + flex: 1; +} + +input[type="color"] { + -webkit-appearance: none; + border: 1px solid var(--border); + background: none; + width: 40px; + height: 40px; + cursor: pointer; + padding: 0; + border-radius: 4px; + overflow: hidden; +} + +input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +input[type="color"]::-webkit-color-swatch { + border: none; +} + +.hex-display { + font-family: monospace; + color: #888; + font-size: 13px; + align-self: center; + background: #000; + padding: 5px 10px; + border-radius: 3px; + border: 1px solid var(--border); +} + +.dropdown { + -webkit-appearance: none; /* Disables native GTK styling */ + -moz-appearance: none; + appearance: none; + background: #000; + border: 1px solid var(--border); + color: #fff; + padding: 8px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + outline: none; + transition: border-color 0.2s; +} + +.dropdown:focus { + border-color: var(--accent); +} + +.dropdown option { + background: #1a1a1a; + color: #fff; +} + +.label-stack { + display: flex; + flex-direction: column; + width: 140px; +} + +.input-note { + font-size: 10px; + color: var(--accent); + opacity: 0.8; + margin-top: 2px; + font-weight: 400; +} + +.main-title { + font-size: 36px; + font-weight: 800; + color: #fff; + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: 25px; + text-shadow: 0 0 15px rgba(187, 134, 252, 0.4); +} + +.pid-list { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr 1fr; /* Two columns */ + gap: 10px; + height: 180px; + overflow-y: auto; + padding-right: 5px; +} + +.pid-card { + background: #0a0a0a; + padding: 10px; + border-radius: 4px; + border-left: 3px solid var(--accent); + height: 75px; + box-sizing: border-box; +} + +.pid-card b { + font-size: 0.9em; +} + +.pid-list::-webkit-scrollbar { width: 4px; } +.pid-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }