Splatlogger 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,4 @@
1
+ from .cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,85 @@
1
+ # Splatlogger (c) 2025 Shadow Doggo.
2
+
3
+ import struct
4
+
5
+ import psutil
6
+ from PyMemoryEditor import OpenProcess, ProcessIDNotExistsError # type: ignore[import-untyped]
7
+
8
+
9
+ class CemuGecko:
10
+ """Wrapper around PyMemoryEditor to provide Cemu memory access.
11
+ Compatible with existing code written for TCPGecko.
12
+ """
13
+
14
+ def __init__(self, pid: int):
15
+ cemu_pid = self._find_cemu_process() if pid == -1 else pid
16
+ if cemu_pid is None:
17
+ raise CemuGeckoError("Cemu process not found.")
18
+
19
+ try:
20
+ self._process = OpenProcess(pid=cemu_pid)
21
+ except ProcessIDNotExistsError as e:
22
+ raise CemuGeckoError("Invalid process ID.") from e
23
+
24
+ self._base_address = self._get_base_address()
25
+ if self._base_address == 0:
26
+ raise CemuGeckoError("Could not find Cemu's Wii U memory space.")
27
+
28
+ def close(self):
29
+ """Close the process handle."""
30
+ self._process.close()
31
+
32
+ def peek_raw(self, address: int, length: int) -> bytes:
33
+ """Read raw memory starting at address and ending at address + length.
34
+ Returns a bytes object.
35
+ """
36
+ if length <= 0:
37
+ raise ValueError("Reading memory requires a positive length (# of bytes).")
38
+
39
+ return self._process.read_process_memory(self._base_address + address, bytes, length)
40
+
41
+ def peek8(self, address: int, *, signed: bool = False) -> int:
42
+ """Get an 8-bit integer value stored at the specified address."""
43
+ return int.from_bytes(self.peek_raw(address, length=1), byteorder="big", signed=signed)
44
+
45
+ def peek16(self, address: int, *, signed: bool = False) -> int:
46
+ """Get a 16-bit integer value stored at the specified address."""
47
+ return int.from_bytes(self.peek_raw(address, length=2), byteorder="big", signed=signed)
48
+
49
+ def peek32(self, address: int, *, signed: bool = False) -> int:
50
+ """Get a 32-bit integer value stored at the specified address."""
51
+ return int.from_bytes(self.peek_raw(address, length=4), byteorder="big", signed=signed)
52
+
53
+ def peek_float(self, address: int) -> float:
54
+ """Get a floating point value stored at the specified address."""
55
+ return struct.unpack(">f", self.peek_raw(address, length=4))[0]
56
+
57
+ def read_string(self, address: int, length: int = 1, encoding: str = "utf-8") -> str:
58
+ return self.peek_raw(address, length).decode(encoding)
59
+
60
+ def _get_base_address(self) -> int:
61
+ # Find Cemu's Wii U memory space and verify the pattern of bytes at the start of the game's memory.
62
+ base_address = 0
63
+ for memory_region in self._process.get_memory_regions():
64
+ address = memory_region.address
65
+ if memory_region.size == 0x4E000000:
66
+ pattern = bytes.fromhex("02D4E7")
67
+ if self._process.read_process_memory(address + 0xE000000, bytes, bufflength=20).find(pattern) != -1:
68
+ base_address = address - 0x2000000
69
+ break
70
+
71
+ return base_address
72
+
73
+ @staticmethod
74
+ def _find_cemu_process() -> int | None:
75
+ for process in psutil.process_iter():
76
+ try:
77
+ if process.name().lower() in ("cemu", "cemu.exe", "xapfish", "xapfish.exe"):
78
+ return process.pid
79
+ except (psutil.AccessDenied, psutil.NoSuchProcess):
80
+ continue
81
+
82
+ return None
83
+
84
+
85
+ class CemuGeckoError(Exception): pass
splatlogger/cli.py ADDED
@@ -0,0 +1,76 @@
1
+ # Splatlogger (c) 2025 Shadow Doggo.
2
+
3
+ import argparse
4
+ import platform
5
+ import sys
6
+ import webbrowser
7
+ from pathlib import Path
8
+
9
+ import userpaths
10
+ from colorama import Fore, Style, just_fix_windows_console
11
+
12
+ from . import utility_functions as uf
13
+ from .splatlogger import VERSION, main
14
+ from .tuples import Options
15
+
16
+
17
+ def cli():
18
+ if platform.system() == "Windows":
19
+ just_fix_windows_console()
20
+
21
+ print(f"Splatlogger v{VERSION} by Shadow Doggo\n")
22
+
23
+ main(parse_args())
24
+
25
+
26
+ def parse_args() -> Options:
27
+ parser = argparse.ArgumentParser(description="PID (Principal ID)/Network ID grabber and match logger for Splatoon")
28
+ add_parser_arguments(parser)
29
+
30
+ if len(sys.argv) == 1 and (args_path := Path(userpaths.get_my_documents()) / "Splatlogger/options.txt").is_file():
31
+ with open(args_path, encoding="utf-8") as args_f:
32
+ args = parser.parse_args(args_f.read().split())
33
+ elif len(sys.argv) == 1:
34
+ print(f'{Fore.YELLOW}No arguments were provided. Run "splatlogger -h" for help.{Style.RESET_ALL}'
35
+ "\nWould you like to open the README? (y/n)")
36
+ if input().lower() in ("y", "yes"):
37
+ webbrowser.open("https://codeberg.org/ShadowDoggo/Splatlogger/src/branch/main/README.md")
38
+ sys.exit()
39
+ else:
40
+ args = parser.parse_args()
41
+
42
+ stack_addr: int | None = None
43
+ if args.stack and args.cemu:
44
+ try:
45
+ stack_addr = int(args.stack, 16)
46
+ except ValueError:
47
+ uf.print_error("Invalid stack address. Please provide a valid hex value.")
48
+ sys.exit()
49
+
50
+ if args.log_level == "none" and args.silent:
51
+ uf.print_warning("You have disabled printing logs to the console as well as creating a log file. "
52
+ "Are you sure this wasn't a mistake?\n")
53
+
54
+ return Options(ip=args.ip, log_level=args.log_level, auto_logging=args.auto in ("all", "latest"),
55
+ log_latest=args.auto == "latest", cemu=args.cemu is not None, cemu_pid=args.cemu,
56
+ ptr_offset=0x503000 if args.cemu else 0, spfn=args.service == "spfn", no_fetch=args.no_fetch,
57
+ silent_logging=args.silent, stack=stack_addr)
58
+
59
+
60
+ def add_parser_arguments(parser: argparse.ArgumentParser) -> None:
61
+ parser.add_argument("--ip", help="Your Wii U's LAN IP address.")
62
+ parser.add_argument("-c", "--cemu",
63
+ help="Switch to Cemu mode (optional: PID of the Cemu process).", metavar="PID",
64
+ nargs="?", const=-1)
65
+ parser.add_argument("--log-level", help="Set how much data should be logged (see README).",
66
+ choices=["none", "standard", "extended", "stats"], default="standard")
67
+ parser.add_argument("--service", help="Choose which network service you're using (Pretendo by default).",
68
+ choices=["pretendo", "spfn"], default="pretendo")
69
+ parser.add_argument("-a", "--auto",
70
+ help="Enable auto logging (see README).",
71
+ choices=["all", "latest"], nargs="?", const="all")
72
+ parser.add_argument("--stack",
73
+ help="Beginning address of the stack space for Default Core 1 (Cemu only, see README).",
74
+ metavar="Address (in hex)")
75
+ parser.add_argument("-n", "--no-fetch", help="Disable fetching network IDs and Mii names.", action="store_true")
76
+ parser.add_argument("-s", "--silent", help="Disable printing logs to the console.", action="store_true")
splatlogger/enums.py ADDED
@@ -0,0 +1,33 @@
1
+ # Splatlogger (c) 2025 Shadow Doggo.
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ class Pointers(IntEnum):
7
+ SESSION = 0x106EB980
8
+ STATIC_MEM = 0x106E0330
9
+ MAIN_MGR_BASE = 0x106E5814
10
+ MAIN_MGR_VS_GAME = 0x106E7FF8
11
+
12
+
13
+ class Addresses(IntEnum):
14
+ # A bunch of data including player stats from the latest match somewhere on the stack.
15
+ # I'm not exactly sure what this is, but it works.
16
+ WIN_TEAM = 0x107AF914
17
+ STATS = 0x107AF944
18
+
19
+
20
+ class Offsets(IntEnum):
21
+ SESSION_ID_IDX = 0xBD
22
+ SESSION_ID = 0xCC
23
+ MAINMGR_PLAYER_MGR = 0x268
24
+ MAINMGR_SCENE_INFO = 0x2A4
25
+ PLAYERMGR_PLAYER_COUNT = 0x320
26
+ SCENE_INFO_RESULTS_SHOWN = 0x330
27
+ STATICMEM_PLAYER_INFO_ARY = 0x10
28
+ STATICMEM_STAGE = 0x28
29
+ STATICMEM_MATCH_HOUR = 0x234
30
+ STATICMEM_VERSUS_MODE = 0x238
31
+ STATICMEM_VERSUS_RULE = 0x23C
32
+ CEMU_WIN_TEAM = 0xF556C
33
+ CEMU_STATS = 0xF559C
@@ -0,0 +1,252 @@
1
+ # Splatlogger (c) 2025 Shadow Doggo.
2
+
3
+ import json
4
+ import platform
5
+ # import sys
6
+ import time
7
+ from datetime import datetime
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import userpaths # type: ignore[import-untyped]
13
+
14
+ from . import utility_functions as uf
15
+ from .cemu_gecko import CemuGecko
16
+ from .enums import Addresses, Offsets
17
+ from .tcpgecko import TCPGecko
18
+ from .tuples import AccountInfo, CommonAddresses, Options, PlayerInfo
19
+
20
+
21
+ class MatchLogger:
22
+ """Provides the match logging functionality of Splatlogger."""
23
+
24
+ def __init__(self, options: Options, gecko: TCPGecko | CemuGecko, static_mem_adr: int) -> None:
25
+ self._date = datetime.now()
26
+ self._log_path: Path = (Path(userpaths.get_my_documents()) /
27
+ f"Splatlogger/logs/{self._date.strftime('%Y-%m-%d')}")
28
+ self._options = options
29
+ self._gecko = gecko
30
+ self._static_mem_adr = static_mem_adr
31
+ self._stats_offset: int | None = None
32
+ self._align = 2 if options.auto_logging else 0
33
+ names_path = resources.files(__package__).joinpath("resources", "names.json")
34
+ # names_path = Path(sys._MEIPASS) / "resources/names.json"
35
+ try:
36
+ with names_path.open("r", encoding="utf-8") as names_f:
37
+ self._names: dict[str, Any] = json.load(names_f)
38
+ except FileNotFoundError:
39
+ uf.exit_with_close("Couldn't find 'names.json'. The package may have been installed incorrectly.")
40
+
41
+ def create_new_log(self) -> None:
42
+ """Initialize a new log file."""
43
+ if not self._log_path.exists():
44
+ self._log_path.mkdir(parents=True)
45
+
46
+ self._write_log(log=f"Splatlogger log from {self._date.strftime('%Y-%m-%d %H:%M:%S')} "
47
+ f"(UTC {uf.get_utc_offset(self._date):+})\n", mode="w")
48
+
49
+ def get_log_path(self) -> Path:
50
+ """Return the path to the current log file."""
51
+ if self._options.log_latest:
52
+ return self._log_path / f"{self._date.strftime('%Y-%m-%d')} log.txt"
53
+
54
+ return self._log_path / f"{self._date.strftime('%Y-%m-%d %H-%M-%S')} log.txt"
55
+
56
+ def log_match(self, date: datetime, session_id: int, match_count: int, player_info_list: list[PlayerInfo],
57
+ adrs: CommonAddresses) -> None:
58
+ """Write player and match data to the log file.
59
+ A log file must be initialized before calling this method."""
60
+ disconnect = False
61
+
62
+ match_info = self._get_match_info()
63
+ versus_rule = match_info[3]
64
+ if self._options.log_level == "standard":
65
+ match_log = (f"\nMatch {match_count}\n\n{uf.player_table_header(self._options.spfn)}\n"
66
+ if self._options.auto_logging else f"\n{uf.player_table_header(self._options.spfn)}\n")
67
+ else:
68
+ match_log = self._format_match_info(match_count, date, session_id, match_info)
69
+
70
+ for player_info in player_info_list:
71
+ match_log += self._format_match_log(player_info)
72
+
73
+ if self._options.log_level == "stats" and not disconnect:
74
+ if stats := self._get_player_stats(player_info, versus_rule, adrs):
75
+ match_log += stats
76
+ else:
77
+ disconnect = True
78
+
79
+ if self._options.log_level == "standard":
80
+ match_log += (
81
+ f"\nSession ID: {session_id if session_id != -1 else 'None'}\n"
82
+ f"Fetched at: {date.strftime('%Y-%m-%d %H:%M:%S')} (UTC {uf.get_utc_offset(date):+})\n"
83
+ if self._options.auto_logging else ""
84
+ )
85
+
86
+ self._write_log(log=match_log, mode="a")
87
+
88
+ def _get_name(self, name: str, value: int | str) -> str:
89
+ names = self._names.get(f"{name}_name", {})
90
+ if isinstance(names, dict):
91
+ return names.get(str(value), "Unknown")
92
+ return "Unknown"
93
+
94
+ def _write_log(self, log: str, mode: str) -> None:
95
+ with open(self.get_log_path(), mode, encoding="utf-8") as log_f:
96
+ log_f.write(log)
97
+
98
+ def _get_match_info(self) -> tuple[str, int, int, int]:
99
+ return (
100
+ self._gecko.read_string(self._static_mem_adr + Offsets.STATICMEM_STAGE, length=32).split("\u0000")[0],
101
+ self._gecko.peek8(self._static_mem_adr + Offsets.STATICMEM_MATCH_HOUR + 0x3), # Read last byte as uint8
102
+ self._gecko.peek8(self._static_mem_adr + Offsets.STATICMEM_VERSUS_MODE + 0x3),
103
+ self._gecko.peek8(self._static_mem_adr + Offsets.STATICMEM_VERSUS_RULE + 0x3)
104
+ )
105
+
106
+ def _format_match_info(self, match_count: int, date: datetime, session_id: int,
107
+ match_info: tuple[str, int, int, int]) -> str:
108
+ stage, match_hour, versus_mode, versus_rule = match_info
109
+
110
+ header = (
111
+ f"\n[Match {match_count}]\n"
112
+ f"{' ' * self._align}Time: {date.strftime('%Y-%m-%d %H:%M:%S')} (UTC {uf.get_utc_offset(date):+})\n"
113
+ if self._options.auto_logging and self._options.log_level != "standard" else "\n"
114
+ )
115
+
116
+ return (
117
+ f"{header}"
118
+ f"{' ' * self._align}Session ID: {session_id if session_id != -1 else 'None'}\n"
119
+ f"{' ' * self._align}Lobby type: {self._get_name('versus_mode', versus_mode)} "
120
+ f"({versus_mode})\n"
121
+ f"{' ' * self._align}Game mode: {self._get_name('versus_rule', versus_rule)} "
122
+ f"({versus_rule})\n"
123
+ f"{' ' * self._align}Stage: {self._get_name('stage', stage)} ({stage})\n"
124
+ f"{' ' * self._align}Match hour: {self._get_name('match_hour', match_hour)} ({match_hour})\n"
125
+ )
126
+
127
+ def _format_match_log(self, player_info: PlayerInfo) -> str:
128
+ network_id_type = "SFID" if self._options.spfn else "PNID"
129
+ indent = " " * (self._align + 2)
130
+
131
+ return (
132
+ uf.player_table_entry(player_info, AccountInfo(player_info.network_id, player_info.mii_name, "")) + "\n"
133
+ ) if self._options.log_level == "standard" else (
134
+ f"\n{' ' * self._align}[Player {player_info.idx + 1}]\n"
135
+ f"{indent}Name: {player_info.name}"
136
+ f"{f' (Mii name: {player_info.mii_name})' if not uf.compare_names(player_info.name, player_info.mii_name) else ''}\n"
137
+ f"{indent}PID: {player_info.pid}\n"
138
+ f"{indent}{network_id_type}: {player_info.network_id}\n"
139
+ f"{indent}Team: {self._get_name('team', player_info.team)} "
140
+ f"({player_info.team})\n"
141
+ f"{indent}Appearance: Gender: {self._get_name('gender', player_info.gender)} "
142
+ f"({player_info.gender}) | "
143
+ f"Skin tone: {self._get_name('skin_tone', player_info.skin_tone)} ({player_info.skin_tone}) | "
144
+ f"Eye color: {self._get_name('eye_color', player_info.eye_color)} ({player_info.eye_color})\n"
145
+ f"{indent}Gear: Headgear: {self._get_name('headgear', player_info.headgear)} "
146
+ f"({player_info.headgear}) | "
147
+ f"Clothes: {self._get_name('clothes', player_info.clothes)} ({player_info.clothes}) | "
148
+ f"Shoes: {self._get_name('shoes', player_info.shoes)} ({player_info.shoes})\n"
149
+ f"{indent}Weapons: Main: {self._get_name('weapon', player_info.weapon)} "
150
+ f"({player_info.weapon}) | "
151
+ f"Sub: {self._get_name('sub_weapon', player_info.sub_weapon)} ({player_info.sub_weapon}) | "
152
+ f"Special: {self._get_name('special_weapon', player_info.special_weapon)} "
153
+ f"({player_info.special_weapon})\n"
154
+ f"{indent}Level: {player_info.level}\n"
155
+ f"{indent}Rank: {self._get_name('rank', player_info.rank)} "
156
+ f"({player_info.rank})\n"
157
+ )
158
+
159
+ # If the player stats stuff looks overly complicated, that's because it is.
160
+ # Getting stuff from the stack reliably is a PITA.
161
+ def _get_player_stats(self, player_info: PlayerInfo, versus_rule: int, adrs: CommonAddresses) -> str | None:
162
+ stats_adr, win_team_adr = self._get_stats_addresses()
163
+
164
+ if player_info.idx == 0 and not self._wait_for_results(adrs):
165
+ return None
166
+
167
+ if not self._options.cemu:
168
+ # If on tiramisu/aroma, get stats offset after the first match is complete.
169
+ if self._stats_offset is None:
170
+ if (stats_offset := self._find_stats_offset(adrs.player_info_ary_adr)) is None:
171
+ return None
172
+ self._stats_offset = stats_offset
173
+
174
+ stats_adr += self._stats_offset
175
+ win_team_adr += self._stats_offset
176
+
177
+ winning_team = self._gecko.peek32(win_team_adr)
178
+
179
+ return self._format_stats_log(player_info, winning_team, versus_rule,
180
+ self._read_player_stats(stats_adr, player_info.idx))
181
+
182
+ def _get_stats_addresses(self) -> tuple[int, int]:
183
+ stats_adr: int = Addresses.STATS
184
+ win_team_adr: int = Addresses.WIN_TEAM
185
+
186
+ if self._options.cemu and self._options.stack:
187
+ stats_adr = self._options.stack + Offsets.CEMU_STATS
188
+ win_team_adr = self._options.stack + Offsets.CEMU_WIN_TEAM
189
+ elif self._options.cemu:
190
+ # Assume default values if stack is not provided. These seem consistent across various systems.
191
+ stack_adr = 0xE101440 if platform.system() == "Windows" else 0xE175900
192
+ stats_adr = stack_adr + Offsets.CEMU_STATS
193
+ win_team_adr = stack_adr + Offsets.CEMU_WIN_TEAM
194
+
195
+ return stats_adr, win_team_adr
196
+
197
+ def _wait_for_results(self, adrs: CommonAddresses) -> bool:
198
+ for _ in range(200):
199
+ if not uf.is_player_in_vs_match():
200
+ return False
201
+
202
+ scene_info_adr = self._gecko.peek32(adrs.main_mgr_base_adr + Offsets.MAINMGR_SCENE_INFO)
203
+ if self._gecko.peek32(scene_info_adr + Offsets.SCENE_INFO_RESULTS_SHOWN) == 0: # Some sort of timer?
204
+ return True
205
+
206
+ time.sleep(3)
207
+
208
+ return False
209
+
210
+ def _read_player_stats(self, stats_adr: int, player_idx: int) -> tuple[int, int, int]:
211
+ stats = self._gecko.peek_raw(stats_adr, length=0x124)
212
+ offset = player_idx * 0x20
213
+
214
+ points = uf.read_int(stats, offset + 0x3A, offset + 0x3C)
215
+ kills = uf.read_int(stats, offset + 0x3E, offset + 0x40)
216
+ deaths = uf.read_int(stats, offset + 0x42, offset + 0x44)
217
+
218
+ return points, kills, deaths
219
+
220
+ def _format_stats_log(self, player_info: PlayerInfo, winning_team: int, versus_rule: int,
221
+ stats: tuple[int, int, int]) -> str:
222
+ indent = " " * (self._align + 2)
223
+ player_won = player_info.team == winning_team
224
+ points, kills, deaths = stats
225
+
226
+ points_log = ""
227
+ if versus_rule == 0: # Only log points in turf war cause in ranked they're the same for all players.
228
+ if player_won:
229
+ points_log = f"{indent}Points: {points + 1000}p ({points}p w/o win bonus)\n"
230
+ else:
231
+ points_log = f"{indent}Points: {points}p\n"
232
+
233
+ return (
234
+ f"{points_log}"
235
+ f"{indent}K/D: {kills}/{deaths}\n"
236
+ f"{indent}Result: {'Win' if player_won else 'Lose'}\n"
237
+ )
238
+
239
+ def _find_stats_offset(self, player_info_ary_adr: int) -> int | None:
240
+ # Find pointer to the first player's PlayerInfo within the stats and calculate offset.
241
+ first_player_info_adr = self._gecko.peek32(player_info_ary_adr)
242
+
243
+ # Check default offsets of 0 and -0x38 first. The pointer is at 0x30.
244
+ if self._gecko.peek32(Addresses.STATS + 0x30) == first_player_info_adr:
245
+ return 0
246
+ if self._gecko.peek32(Addresses.STATS - 0x8) == first_player_info_adr:
247
+ return -0x38
248
+
249
+ search_space = self._gecko.peek_raw(Addresses.STATS - 0x100, length=0x200)
250
+ stats_offset = search_space.find(first_player_info_adr.to_bytes(length=4, byteorder="big"))
251
+
252
+ return None if stats_offset == -1 else stats_offset - 0x130