initial commit

This commit is contained in:
CrafterPika 2026-04-23 17:08:47 +02:00
commit 3ab62216aa
No known key found for this signature in database
GPG key ID: 68B3920DBA3C6C31
31 changed files with 2790 additions and 1 deletions

View file

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

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
bin/*
obj/*
obj/Debug/*
obj/Release/*
.DS_Store
dist/*
*.dmg
*.sln

12
.vscode/settings.json vendored Normal file
View file

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

86
FreezeThreads.cs Normal file
View file

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

204
Func.cs Normal file
View file

@ -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<Player> Players { get; set; } = new List<Player>();
}
public class ExtFunc {
public static async Task<string> 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...";
}
}
}
}

420
ProcessWrapper.cs Normal file
View file

@ -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<vm_region_basic_info_64>());
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<vm_region_basic_info_64>(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;
}
}
}

253
Program.cs Normal file
View file

@ -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<string, string> 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("<html></html>")
.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<Dictionary<string, string>>(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<ModMenuRequest>(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("<html></html>")
.WaitForClose();
return;
}
}
}
}

View file

@ -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)<br>
This version has been scrubbed of anything abusive.
![SplatTool Showoff Image](assets/demo.png)<br>
<small>SplatTool running on macOS 26.4.1</small>
## Running
## Linux
Since Linux actually demands root priviliages to read another applications memory, you have to run it as root.<br>
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.<br>
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.

178
SaveEditor.cs Normal file
View file

@ -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<string, List<Gear>>();
int structSize = Marshal.SizeOf<Gear>();
var categories = new (string Name, uint Offset)[] {
("headgear", 0x61A0),
("clothing", 0x31A0),
("shoes", 0x1A0)
};
foreach (var cat in categories) {
var gearList = new List<Gear>();
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<Gear>(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<T>(byte[] bytes) where T : struct {
GCHandle handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try {
return Marshal.PtrToStructure<T>(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;
}
}
}

27
SplatTool.csproj Normal file
View file

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<ApplicationIcon>icon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NativeFileDialogSharp" Version="0.6.0-alpha" />
<PackageReference Include="Photino.NET" Version="4.0.16" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<None Update="modmenu\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="assets/config.json">
<Link>config.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

3
SplatTool.slnx Normal file
View file

@ -0,0 +1,3 @@
<Solution>
<Project Path="SplatTool.csproj" />
</Solution>

55
SplatTool_Installer.nsi Normal file
View file

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

137
TelemetryWebpost.cs Normal file
View file

@ -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<string, string>();
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=\"(?<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<Dictionary<string, string>>(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();
}
}
}
}

1
VERSION.txt Normal file
View file

@ -0,0 +1 @@
3.2.0

24
assets/Info.plist Normal file
View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>SplatTool</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleIdentifier</key>
<string>cc.crafterpika.splattool</string>
<key>CFBundleName</key>
<string>SplatTool</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>3.2.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.7</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

3
assets/config.json Normal file
View file

@ -0,0 +1,3 @@
{
"TelemetryUrl": "https://splatpost.spbr.net/post"
}

BIN
assets/demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

17
assets/entitlements.plist Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- MacOS will whine about the DLLs -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- allow task_for_pid() to function -->
<key>com.apple.security.cs.debugger</key>
<true/>
<!-- C# Needs JIT for CIL -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

BIN
assets/icon.icns Normal file

Binary file not shown.

7
assets/root_helper/build.sh Executable file
View file

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

BIN
assets/root_helper/root_helper Executable file

Binary file not shown.

View file

@ -0,0 +1,36 @@
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <limits.h>
#include <libgen.h>
#include <stdlib.h>
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;
}

46
build-osx-app.sh Executable file
View file

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

150
httpMenu.cs Normal file
View file

@ -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<GearQueueSlot> GearQueue = new List<GearQueueSlot>();
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})<br><br>Total Wins: {Codes.TotalWins()}<br>Total Losses: {Codes.TotalLoss()}<br><br>Thread Status:<br>- Write Gear: {WriteGear.Status.ToString()}<br>- Telemetry: {TelemetryThread.Status.ToString()}<br>- Change Gender: {ChangeGender.Status.ToString()}<br>- Change Level: {ChangeLevel.Status.ToString()}<br>- Change Cash: {ChangeCash.Status.ToString()}<br>- 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<GearQueueSlot>();
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();
}
}
}
}

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

359
modmenu/ids.json Normal file
View file

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

189
modmenu/index.html Normal file
View file

@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SplatTool</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1 class="main-title">SplatTool Mod Menu</h1>
<div class="tabs-container">
<div class="tab-menu">
<button class="tab-link active" onclick="openTab(event, 'Tab1')">Player & Lobby</button>
<button class="tab-link" onclick="openTab(event, 'Tab8')">Save Editor</button>
<button class="tab-link" onclick="openTab(event, 'Tab4')">Utilities</button>
<button class="tab-link" onclick="openTab(event, 'Tab5')">PID Grabber</button>
<button class="tab-link" onclick="openTab(event, 'Tab6')">Stats</button>
<button class="tab-link" onclick="openTab(event, 'Tab7')">About</button>
</div>
<div id="Tab1" class="tab-content active">
<div class="profile-card">
<div class="input-row">
<div class="input-group">
<span class="input-label">Name Changer:</span>
<input type="text" id="name-input" placeholder="Enter name..." autocomplete="off">
<button class="action-btn" onclick="namechange()">Apply</button>
</div>
</div>
<div class="input-row">
<span class="input-label">Disconnect From Lobby:</span>
<div class="input-group">
<div class="spacer"></div> <button class="action-btn" onclick="execute('DisconnectLobby')">Apply</button>
</div>
</div>
</div>
</div>
<div id="Tab8" class="tab-content">
<div class="profile-card">
<h3 style="margin-top: 0; font-size: 14px; color: var(--accent);">Gear Editor</h3>
<div class="input-row">
<span class="input-label">Category:</span>
<select id="gear-category" class="dropdown" style="flex: 1;" onchange="onCategoryChange()">
<option value="headgear">Headgear</option>
<option value="clothing">Clothing</option>
<option value="shoes">Shoes</option>
</select>
</div>
<div id="gear-selection-grid" class="pid-list" style="margin-bottom: 15px; border: 1px solid var(--border); padding: 5px;">
<p style="text-align: center; opacity: 0.5; width: 100%;">Select a category to load gear...</p>
</div>
<hr style="border: 0; border-top: 1px solid var(--border); margin: 15px 0;">
<div id="editor-fields">
<div class="input-row">
<span class="input-label">Item to Write:</span>
<select id="gear-item" class="dropdown" style="flex: 1;"></select>
</div>
<div class="input-row">
<span class="input-label">Slot Index:</span>
<input type="number" id="gear-slot-idx" value="0" readonly class="dropdown" style="width: 60px; opacity: 0.7;">
</div>
<div class="input-row">
<span class="input-label">Sub 1:</span>
<select id="ability-sub1" class="dropdown" style="flex: 1;"></select>
</div>
<div class="input-row">
<span class="input-label">Sub 2:</span>
<select id="ability-sub2" class="dropdown" style="flex: 1;"></select>
</div>
<div class="input-row">
<span class="input-label">Sub 3:</span>
<select id="ability-sub3" class="dropdown" style="flex: 1;"></select>
</div>
<div class="input-row">
<button id="gear-thread-btn" class="action-btn" style="flex: 1; background: #333;" onclick="toggleGearThread()">Write Queue</button>
<button class="action-btn" style="flex: 1;" onclick="applyGearChange()">Write to Queue</button>
</div>
</div>
<br><br>
<h3 style="margin-top: 0; font-size: 14px; color: var(--accent);">Player & Other</h3>
<div class="input-row">
<div class="label-stack">
<span class="input-label">Change Gender:</span>
</div>
<div class="input-group">
<select id="gender-select" class="dropdown" style="flex: 1;">
<option value="0">Girl</option>
<option value="1">Boy</option>
<option value="2">Octoling (Rival)</option>
</select>
<button class="action-btn" onclick="GenderChange()">Toggle Thread</button>
</div>
</div>
<div class="input-row">
<div class="input-group">
<span class="input-label">Change Level:</span>
<input type="text" id="level-input" placeholder="50..." autocomplete="off">
<button class="action-btn" onclick="Level()">Toggle Thread</button>
</div>
</div>
<div class="input-row">
<div class="input-group">
<span class="input-label">Change Cash:</span>
<input type="text" id="cash-input" placeholder="9999..." autocomplete="off">
<button class="action-btn" onclick="Cash()">Toggle Thread</button>
</div>
</div>
<div class="input-row">
<div class="input-group">
<span class="input-label">Change Sea Snails:</span>
<input type="text" id="snails-input" placeholder="999..." autocomplete="off">
<button class="action-btn" onclick="SeaSnails()">Toggle Thread</button>
</div>
</div>
</div>
</div>
<div id="Tab4" class="tab-content">
<div class="profile-card">
<div class="input-row">
<div class="input-group">
<span class="input-label">Force Session ID:</span>
<input type="text" id="session-input" placeholder="Enter Session ID..." autocomplete="off">
<button class="action-btn" onclick="sessionid()">Toggle Thread</button>
</div>
</div>
<div class="input-row">
<div class="label-stack">
<span class="input-label">Dump faceimg.tga:</span>
<span class="input-note">Disable Anti-Telemetry.</span>
</div>
<div class="input-group">
<select id="format-select" class="dropdown" style="flex: 1;">
<option value="0">TGA</option>
<option value="1">PNG</option>
</select>
<button class="action-btn" onclick="faceimgtga();alert('faceimg.tga should be dumped.')">Dump</button>
</div>
</div>
</div>
</div>
<div id="Tab5" class="tab-content">
<div class="profile-card">
<div class="input-row">
<span class="input-label">Session Control:</span>
<button class="action-btn" style="flex: 1;" onclick="fetchPids()">Fetch Current PIDs</button>
</div>
<div id="pid-display" class="pid-list">
<p style="text-align: center; opacity: 0.5; margin-top: 20px;">No data fetched yet.</p>
</div>
</div>
</div>
<div id="Tab6" class="tab-content">
<div class="profile-card">
<h3 style="margin-top: 0;">Stats:</h3>
<div id="cbase">Waiting for fetch...</div>
<br><br>
<div class="input-row">
<div class="label-stack">
<span class="input-label">Webpost:</span>
<span class="input-note">Only works with SPFN.</span>
</div>
<div class="input-group">
<div class="spacer"></div> <button class="action-btn" onclick="execute('Webpost')">Toggle Thread</button>
</div>
</div>
</div>
</div>
<div id="Tab7" class="tab-content">
<div class="profile-card">
<h3 style="margin-top: 0;">SplatTool v3.2.0</h3>
<div>Written by CrafterPika</div>
<div>Special Thanks: Winterberry, Javi.ig, Tombuntu, KittenTM, apoplexy</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

210
modmenu/script.js Normal file
View file

@ -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 = '<p style="text-align: center; width: 100%; padding: 20px;">Fetching Gear...</p>';
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 = `<p style="text-align: center; opacity: 0.5; margin-top: 20px;">Fetching data....</p>`;
let html = `<div style="grid-column: span 2; color: var(--highlight); font-family: monospace; font-size: 14px; margin-bottom: 5px; border-bottom: 1px solid #222; padding-bottom: 5px;">SESSION: ${data.SessionID}</div>`;
data.Players.forEach((p, i) => {
html += `<div class="pid-card"><b>P${i}: ${p.Name}</b><br><small>PID: ${p.PID}</small><br><small>NNID: ${p.NNID}</small></div>`;
});
container.innerHTML = html || '<p style="text-align: center;">No players found.</p>';
}
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 = '<p style="text-align: center; width: 100%; padding: 20px;">No gear found in this category.</p>';
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 = `<b>${gearName}</b><br><small>Slot: ${index}</small>`;
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"); }

264
modmenu/style.css Normal file
View file

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