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.
- splatlogger/__init__.py +0 -0
- splatlogger/__main__.py +4 -0
- splatlogger/cemu_gecko.py +85 -0
- splatlogger/cli.py +76 -0
- splatlogger/enums.py +33 -0
- splatlogger/match_logger.py +252 -0
- splatlogger/resources/names.json +725 -0
- splatlogger/splatlogger.py +308 -0
- splatlogger/tcpgecko.py +154 -0
- splatlogger/tuples.py +48 -0
- splatlogger/utility_functions.py +174 -0
- splatlogger-1.0.0.dist-info/METADATA +175 -0
- splatlogger-1.0.0.dist-info/RECORD +17 -0
- splatlogger-1.0.0.dist-info/WHEEL +5 -0
- splatlogger-1.0.0.dist-info/entry_points.txt +2 -0
- splatlogger-1.0.0.dist-info/licenses/LICENSE +19 -0
- splatlogger-1.0.0.dist-info/top_level.txt +1 -0
splatlogger/__init__.py
ADDED
|
File without changes
|
splatlogger/__main__.py
ADDED
|
@@ -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
|