Splatlogger 1.2.3__tar.gz → 1.3.0__tar.gz

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.
Files changed (24) hide show
  1. {splatlogger-1.2.3/src/Splatlogger.egg-info → splatlogger-1.3.0}/PKG-INFO +8 -11
  2. {splatlogger-1.2.3 → splatlogger-1.3.0}/README.md +7 -10
  3. {splatlogger-1.2.3 → splatlogger-1.3.0}/pyproject.toml +1 -1
  4. {splatlogger-1.2.3 → splatlogger-1.3.0/src/Splatlogger.egg-info}/PKG-INFO +8 -11
  5. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/__init__.py +1 -1
  6. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/__main__.py +0 -1
  7. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/cli.py +17 -26
  8. splatlogger-1.3.0/src/splatlogger/enums.py +137 -0
  9. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/match_logger.py +164 -114
  10. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/resources/names.json +3 -4
  11. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/splatlogger.py +57 -39
  12. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/tuples.py +19 -1
  13. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/utility_functions.py +7 -10
  14. splatlogger-1.2.3/src/splatlogger/enums.py +0 -81
  15. {splatlogger-1.2.3 → splatlogger-1.3.0}/LICENSE +0 -0
  16. {splatlogger-1.2.3 → splatlogger-1.3.0}/setup.cfg +0 -0
  17. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/Splatlogger.egg-info/SOURCES.txt +0 -0
  18. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/Splatlogger.egg-info/dependency_links.txt +0 -0
  19. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/Splatlogger.egg-info/entry_points.txt +0 -0
  20. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/Splatlogger.egg-info/requires.txt +0 -0
  21. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/Splatlogger.egg-info/top_level.txt +0 -0
  22. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/cemu_gecko.py +0 -0
  23. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/errors.py +0 -0
  24. {splatlogger-1.2.3 → splatlogger-1.3.0}/src/splatlogger/tcpgecko.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Splatlogger
3
- Version: 1.2.3
3
+ Version: 1.3.0
4
4
  Summary: PID Grabber and player/match information logging tool for Splatoon
5
5
  Author-email: Shadow Doggo <shadowdoggo@protonmail.com>
6
6
  License-Expression: MIT
@@ -74,6 +74,7 @@ To enable Cemu support, install with the `cemu` extra:
74
74
  ```
75
75
  pip install "Splatlogger[cemu]"
76
76
  ```
77
+ (use `pip install --upgrade` to update from an older version)
77
78
 
78
79
  Then run with:
79
80
  ```
@@ -102,7 +103,7 @@ python -m splatlogger --ip IP [options] / --cemu [PID] [options]
102
103
  - `none` - Don't create a log file.
103
104
  - `standard` - Log only basic player information, the same as what's printed to the console (default).
104
105
  - `extended` - Log all player information and additional match information.
105
- - `stats` - Same as above with the addition of player stats (points, K/D, win/lose) and disconnect detection.
106
+ - `full` - Same as above with the addition of player stats (points, K/D, win/lose), disconnect detection, and match results.
106
107
  Requires the match to end to finish logging; if it ends abruptly, the stats won't be logged.
107
108
 
108
109
  - `--service` - Choose which network service you're using.
@@ -117,11 +118,6 @@ Also allows you to view information about the lobby/gathering while on the match
117
118
 
118
119
  (These options don't affect anything if the log level is set to `none`)
119
120
 
120
- - `--stack Address (in hex)` - (Cemu only, optional) Beginning address of the stack space for Default Core 1 (Debug > View PPC threads).
121
- Only required for logging stats. If the default values don't work, you'll need to specify this manually.
122
-
123
- <img src="assets/stack.png" width="600" alt="(example)">
124
-
125
121
  - `-sl`, `--single-log` - Enable single log mode. When enabled, only one log file will be created for each day and logging will resume in that file
126
122
  instead of creating a new one for each logging session (default behavior for `--auto latest`).
127
123
 
@@ -149,7 +145,7 @@ ip = "192.168.1.50"
149
145
  # Target network service: "pretendo", "altendo", or "none"
150
146
  service = "pretendo"
151
147
 
152
- # Log level: "standard", "extended", "stats", or "none"
148
+ # Log level: "standard", "extended", "full", or "none"
153
149
  log_level = "extended"
154
150
 
155
151
  # Auto logging: "all", "latest" (omit for manual logging)
@@ -168,9 +164,6 @@ single_log = true
168
164
  # Cemu process ID (set to -1 to find automatically)
169
165
  # Uncomment this option to enable Cemu mode
170
166
  # cemu = -1
171
-
172
- # Beginning address of the stack space for Default Core 1 (set only if stat logging doesn't work by default)
173
- # stack = "0xE101440"
174
167
  ```
175
168
 
176
169
  ### Troubleshooting
@@ -207,6 +200,10 @@ python scripts/build.py [--upx-dir path/to/upx]
207
200
  ```
208
201
  use `--upx-dir` if you're using UPX to compress the executable.
209
202
 
203
+ You can also use `build.bat` on Windows and `build.sh` on Linux to handle everything automatically.
204
+
205
+ (No builds or build script are provided for macOS as I don't have a Mac to test, and my attempts at virtualizing macOS have been unsuccessful.)
206
+
210
207
  ## Acknowledgments
211
208
  - NWPlayer123 for the reference implementation of a TCPGecko library.
212
209
 
@@ -49,6 +49,7 @@ To enable Cemu support, install with the `cemu` extra:
49
49
  ```
50
50
  pip install "Splatlogger[cemu]"
51
51
  ```
52
+ (use `pip install --upgrade` to update from an older version)
52
53
 
53
54
  Then run with:
54
55
  ```
@@ -77,7 +78,7 @@ python -m splatlogger --ip IP [options] / --cemu [PID] [options]
77
78
  - `none` - Don't create a log file.
78
79
  - `standard` - Log only basic player information, the same as what's printed to the console (default).
79
80
  - `extended` - Log all player information and additional match information.
80
- - `stats` - Same as above with the addition of player stats (points, K/D, win/lose) and disconnect detection.
81
+ - `full` - Same as above with the addition of player stats (points, K/D, win/lose), disconnect detection, and match results.
81
82
  Requires the match to end to finish logging; if it ends abruptly, the stats won't be logged.
82
83
 
83
84
  - `--service` - Choose which network service you're using.
@@ -92,11 +93,6 @@ Also allows you to view information about the lobby/gathering while on the match
92
93
 
93
94
  (These options don't affect anything if the log level is set to `none`)
94
95
 
95
- - `--stack Address (in hex)` - (Cemu only, optional) Beginning address of the stack space for Default Core 1 (Debug > View PPC threads).
96
- Only required for logging stats. If the default values don't work, you'll need to specify this manually.
97
-
98
- <img src="assets/stack.png" width="600" alt="(example)">
99
-
100
96
  - `-sl`, `--single-log` - Enable single log mode. When enabled, only one log file will be created for each day and logging will resume in that file
101
97
  instead of creating a new one for each logging session (default behavior for `--auto latest`).
102
98
 
@@ -124,7 +120,7 @@ ip = "192.168.1.50"
124
120
  # Target network service: "pretendo", "altendo", or "none"
125
121
  service = "pretendo"
126
122
 
127
- # Log level: "standard", "extended", "stats", or "none"
123
+ # Log level: "standard", "extended", "full", or "none"
128
124
  log_level = "extended"
129
125
 
130
126
  # Auto logging: "all", "latest" (omit for manual logging)
@@ -143,9 +139,6 @@ single_log = true
143
139
  # Cemu process ID (set to -1 to find automatically)
144
140
  # Uncomment this option to enable Cemu mode
145
141
  # cemu = -1
146
-
147
- # Beginning address of the stack space for Default Core 1 (set only if stat logging doesn't work by default)
148
- # stack = "0xE101440"
149
142
  ```
150
143
 
151
144
  ### Troubleshooting
@@ -182,6 +175,10 @@ python scripts/build.py [--upx-dir path/to/upx]
182
175
  ```
183
176
  use `--upx-dir` if you're using UPX to compress the executable.
184
177
 
178
+ You can also use `build.bat` on Windows and `build.sh` on Linux to handle everything automatically.
179
+
180
+ (No builds or build script are provided for macOS as I don't have a Mac to test, and my attempts at virtualizing macOS have been unsuccessful.)
181
+
185
182
  ## Acknowledgments
186
183
  - NWPlayer123 for the reference implementation of a TCPGecko library.
187
184
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "Splatlogger"
7
- version = "1.2.3"
7
+ version = "1.3.0"
8
8
  authors = [
9
9
  { name="Shadow Doggo", email="shadowdoggo@protonmail.com" }
10
10
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Splatlogger
3
- Version: 1.2.3
3
+ Version: 1.3.0
4
4
  Summary: PID Grabber and player/match information logging tool for Splatoon
5
5
  Author-email: Shadow Doggo <shadowdoggo@protonmail.com>
6
6
  License-Expression: MIT
@@ -74,6 +74,7 @@ To enable Cemu support, install with the `cemu` extra:
74
74
  ```
75
75
  pip install "Splatlogger[cemu]"
76
76
  ```
77
+ (use `pip install --upgrade` to update from an older version)
77
78
 
78
79
  Then run with:
79
80
  ```
@@ -102,7 +103,7 @@ python -m splatlogger --ip IP [options] / --cemu [PID] [options]
102
103
  - `none` - Don't create a log file.
103
104
  - `standard` - Log only basic player information, the same as what's printed to the console (default).
104
105
  - `extended` - Log all player information and additional match information.
105
- - `stats` - Same as above with the addition of player stats (points, K/D, win/lose) and disconnect detection.
106
+ - `full` - Same as above with the addition of player stats (points, K/D, win/lose), disconnect detection, and match results.
106
107
  Requires the match to end to finish logging; if it ends abruptly, the stats won't be logged.
107
108
 
108
109
  - `--service` - Choose which network service you're using.
@@ -117,11 +118,6 @@ Also allows you to view information about the lobby/gathering while on the match
117
118
 
118
119
  (These options don't affect anything if the log level is set to `none`)
119
120
 
120
- - `--stack Address (in hex)` - (Cemu only, optional) Beginning address of the stack space for Default Core 1 (Debug > View PPC threads).
121
- Only required for logging stats. If the default values don't work, you'll need to specify this manually.
122
-
123
- <img src="assets/stack.png" width="600" alt="(example)">
124
-
125
121
  - `-sl`, `--single-log` - Enable single log mode. When enabled, only one log file will be created for each day and logging will resume in that file
126
122
  instead of creating a new one for each logging session (default behavior for `--auto latest`).
127
123
 
@@ -149,7 +145,7 @@ ip = "192.168.1.50"
149
145
  # Target network service: "pretendo", "altendo", or "none"
150
146
  service = "pretendo"
151
147
 
152
- # Log level: "standard", "extended", "stats", or "none"
148
+ # Log level: "standard", "extended", "full", or "none"
153
149
  log_level = "extended"
154
150
 
155
151
  # Auto logging: "all", "latest" (omit for manual logging)
@@ -168,9 +164,6 @@ single_log = true
168
164
  # Cemu process ID (set to -1 to find automatically)
169
165
  # Uncomment this option to enable Cemu mode
170
166
  # cemu = -1
171
-
172
- # Beginning address of the stack space for Default Core 1 (set only if stat logging doesn't work by default)
173
- # stack = "0xE101440"
174
167
  ```
175
168
 
176
169
  ### Troubleshooting
@@ -207,6 +200,10 @@ python scripts/build.py [--upx-dir path/to/upx]
207
200
  ```
208
201
  use `--upx-dir` if you're using UPX to compress the executable.
209
202
 
203
+ You can also use `build.bat` on Windows and `build.sh` on Linux to handle everything automatically.
204
+
205
+ (No builds or build script are provided for macOS as I don't have a Mac to test, and my attempts at virtualizing macOS have been unsuccessful.)
206
+
210
207
  ## Acknowledgments
211
208
  - NWPlayer123 for the reference implementation of a TCPGecko library.
212
209
 
@@ -1,2 +1,2 @@
1
- __version__ = "1.2.3"
1
+ __version__ = "1.3.0"
2
2
  __author__ = "Shadow Doggo"
@@ -3,6 +3,5 @@ try:
3
3
  except ImportError:
4
4
  from splatlogger.cli import cli
5
5
 
6
-
7
6
  if __name__ == "__main__":
8
7
  cli()
@@ -7,7 +7,7 @@ import webbrowser
7
7
  from pathlib import Path
8
8
 
9
9
  import userpaths
10
- from colorama import Fore, Style, just_fix_windows_console
10
+ from colorama import just_fix_windows_console
11
11
  from prompt_toolkit import PromptSession
12
12
  from prompt_toolkit.patch_stdout import patch_stdout
13
13
 
@@ -70,7 +70,7 @@ def command_listener(ctx: AppContext) -> None:
70
70
 
71
71
 
72
72
  def parse_args() -> Options:
73
- parser = argparse.ArgumentParser(description="PID (Principal ID)/Network ID grabber and match logger for Splatoon")
73
+ parser = argparse.ArgumentParser(description="PID Grabber and player/match information logging tool for Splatoon")
74
74
  add_parser_arguments(parser)
75
75
 
76
76
  config_path = Path(userpaths.get_my_documents()) / "Splatlogger/config.toml"
@@ -83,28 +83,25 @@ def parse_args() -> Options:
83
83
  args = parser.parse_args()
84
84
 
85
85
  if not args.ip and not args.cemu:
86
- print(
87
- f'{Fore.YELLOW}No target (IP address or Cemu mode) was provided. Run "splatlogger -h" for help.{Style.RESET_ALL}'
88
- "\nWould you like to open the README? (y/n)")
86
+ uf.print_warning("No target (IP address or Cemu mode) was provided. Run \"splatlogger -h\" for help.\n")
87
+ print("Would you like to open the README? (y/n)")
89
88
  if input().lower() in ("y", "yes"):
90
89
  webbrowser.open("https://codeberg.org/ShadowDoggo/Splatlogger/src/branch/main/README.md")
91
90
  sys.exit()
92
91
 
93
- stack_addr: int | None = None
94
- if args.stack and args.cemu:
95
- try:
96
- stack_addr = int(args.stack, 16)
97
- except ValueError:
98
- uf.print_error("Invalid stack address. Please provide a valid hex value.")
99
- sys.exit()
100
-
101
92
  if args.log_level == "none" and args.silent:
102
- uf.print_warning("You have disabled printing logs to the console as well as creating a log file. "
103
- "Are you sure this wasn't a mistake?\n")
93
+ uf.print_warning("Silent mode is enabled and no log level is set. No information will be logged.\n")
104
94
 
105
- return Options(ip=args.ip, log_level=args.log_level, auto_logging=args.auto in ("all", "latest"),
106
- log_latest=args.auto == "latest", platform="cemu" if args.cemu else "wiiu", cemu_pid=args.cemu,
107
- service=args.service, silent_logging=args.silent, single_log=args.single_log, stack=stack_addr)
95
+ return Options(
96
+ ip=args.ip,
97
+ log_level=args.log_level,
98
+ auto_logging=args.auto in ("all", "latest"),
99
+ log_latest=args.auto == "latest",
100
+ platform="cemu" if args.cemu else "wiiu", cemu_pid=args.cemu,
101
+ service=args.service,
102
+ silent_logging=args.silent,
103
+ single_log=args.single_log
104
+ )
108
105
 
109
106
 
110
107
  def add_parser_arguments(parser: argparse.ArgumentParser) -> None:
@@ -113,15 +110,12 @@ def add_parser_arguments(parser: argparse.ArgumentParser) -> None:
113
110
  help="Switch to Cemu mode (optional: PID of the Cemu process).", metavar="PID",
114
111
  nargs="?", const=-1)
115
112
  parser.add_argument("--log-level", help="Set how much data should be logged (see README).",
116
- choices=["none", "standard", "extended", "stats"], default="standard")
113
+ choices=["none", "standard", "extended", "full"], default="standard")
117
114
  parser.add_argument("--service", help="Choose which network service you're using (Pretendo by default).",
118
115
  choices=["none", "pretendo", "altendo"], default="pretendo")
119
116
  parser.add_argument("-a", "--auto",
120
117
  help="Enable auto logging (see README).",
121
118
  choices=["all", "latest"], nargs="?", const="all")
122
- parser.add_argument("--stack",
123
- help="Beginning address of the stack space for Default Core 1 (Cemu only, see README).",
124
- metavar="Address (in hex)")
125
119
  parser.add_argument("-sl", "--single-log", help="Enable single log file mode.", action="store_true")
126
120
  parser.add_argument("-s", "--silent", help="Disable printing logs to the console.", action="store_true")
127
121
 
@@ -136,7 +130,7 @@ CONFIG_TEMPLATE = """# Splatlogger Configuration
136
130
  # Target network service: "pretendo", "altendo", or "none"
137
131
  # service = "pretendo"
138
132
 
139
- # Log level: "standard", "extended", "stats", or "none"
133
+ # Log level: "standard", "extended", "full", or "none"
140
134
  # log_level = "standard"
141
135
 
142
136
  # Auto logging: "all", "latest" (omit for manual logging)
@@ -155,9 +149,6 @@ CONFIG_TEMPLATE = """# Splatlogger Configuration
155
149
  # Cemu process ID (set to -1 to find automatically)
156
150
  # Uncomment this option to enable Cemu mode
157
151
  # cemu = -1
158
-
159
- # Beginning address of the stack space for Default Core 1 (set only if stat logging doesn't work by default)
160
- # stack = "0xE101440"
161
152
  """
162
153
 
163
154
 
@@ -0,0 +1,137 @@
1
+ # Splatlogger (c) 2025 Shadow Doggo.
2
+
3
+ from enum import Enum, IntEnum
4
+
5
+
6
+ class Pointers(IntEnum):
7
+ SESSION = 0x101E8980
8
+ STATIC_MEM = 0x101DD330
9
+ MAIN_MGR_BASE = 0x101E2814
10
+ MAIN_MGR_VS_GAME = 0x101E4FF8
11
+ FRAMEWORK = 0x101E80A8
12
+
13
+ def convert(self, platform: str) -> int:
14
+ return self.value + Offsets.WIIU_BSS_OFFSET if platform == "wiiu" else self.value
15
+
16
+
17
+ class Offsets(IntEnum):
18
+ WIIU_BSS_OFFSET = 0x503000
19
+ SESSION_ID_IDX = 0xBD
20
+ SESSION_ID = 0xCC
21
+ SESSION_ACTIVE_STATION_LIST_SENTINEL = 0xD8
22
+ SESSION_ACTIVE_STATION_LIST_FIRST_NODE_PTR = 0xDC
23
+ SESSION_ACTIVE_STATION_LIST_COUNT = 0xE0
24
+ SESSION_STATION_NODE_CAPACITY = 0xFC
25
+ STATION_LIST_NODE_PREV_PTR = 0x0
26
+ STATION_LIST_NODE_NEXT_PTR = 0x4
27
+ STATION_LIST_NODE_STATION_ID = 0x8
28
+ STATION_LIST_NODE_SIZE = 0x10
29
+ MAINMGR_PLAYER_MGR_PTR = 0x268
30
+ PLAYERMGR_PLAYER_COUNT = 0x320
31
+ MAINMGR_VS_GAME_SEQ_MGR_VS_GAME_PTR = 0x2A4
32
+ MAINMGR_VS_GAME_PAGE_HANDLER_PTR = 0x2D8
33
+ SEQ_MGR_VS_GAME_FINAL_RESULT_PTR = 0xAA8
34
+ SEQ_MGR_VS_GAME_FINAL_RESULT_SCORE_PTR = 0x294
35
+ SCORE_OBJECT_TEAM_RESULTS_ARY_PTR = 0x10
36
+ TEAM_RESULTS_ARY_SIZE_REGULAR = 0x18
37
+ TEAM_RESULTS_ARY_SIZE_RANKED = 0x2C
38
+ TEAM_RESULTS_PAINT_PC = 0x4
39
+ TEAM_RESULTS_WON = 0xC
40
+ TEAM_RESULTS_RANKED_OBJ_COUNT = 0x10
41
+ TEAM_RESULTS_POINTS = 0x14
42
+ PAGE_HANDLER_RESULTS_PAGE_PTR = 0x118
43
+ PAGE_HANDLER_LEADERBOARD_PAGE_PTR = 0x11C
44
+ RESULTS_PAGE_PHASE = 0x1E8
45
+ LEADERBOARD_PAGE_SLOTS = 0x214
46
+ LEADERBOARD_PAGE_ROWS_PTR = 0x218
47
+ ROWS_PLAYER_STRIDE = 0x20
48
+ ROWS_PLAYER_IDX = 0x0
49
+ ROWS_PLAYER_PLAYERINFO_PTR = 0x4
50
+ ROWS_PLAYER_TEAM = 0x8
51
+ ROWS_PLAYER_POINTS = 0xC
52
+ ROWS_PLAYER_KILLS = 0x10
53
+ ROWS_PLAYER_DEATHS = 0x14
54
+ ROWS_PLAYER_LOCAL_PLAYER_FLAG = 0x1C
55
+ FRAMEWORK_PEER_MGR_PTR = 0x14
56
+ PEER_MGR_ID_MGR_PTR = 0x84
57
+ ID_MGR_SLOT_ARY_PTR = 0x8
58
+ SLOT_SIZE = 0x14
59
+ SLOT_STATE = 0x0
60
+ SLOT_PID = 0x4
61
+ SLOT_PLAYER_ID = 0x12
62
+ STATICMEM_PLAYER_INFO_ARY_PTR = 0x10
63
+ STATICMEM_STAGE = 0x28
64
+ STATICMEM_MATCH_HOUR = 0x234
65
+ STATICMEM_VERSUS_MODE = 0x238
66
+ STATICMEM_VERSUS_RULE = 0x23C
67
+ PLAYER_INFO_NAME = 0x6
68
+ PLAYER_INFO_NAME_LENGTH = 0x20
69
+ PLAYER_INFO_REGION = 0x2C
70
+ PLAYER_INFO_TEAM = 0x33
71
+ PLAYER_INFO_GENDER = 0x37
72
+ PLAYER_INFO_SKIN_TONE = 0x3B
73
+ PLAYER_INFO_EYE_COLOR = 0x3F
74
+ PLAYER_INFO_WEAPON_SET = 0x42
75
+ PLAYER_INFO_MAIN_WEAPON = 0x46
76
+ PLAYER_INFO_SUB_WEAPON = 0x4A
77
+ PLAYER_INFO_SPECIAL_WEAPON = 0x4F
78
+ PLAYER_INFO_SHOES = 0x54
79
+ PLAYER_INFO_CLOTHES = 0x70
80
+ PLAYER_INFO_HEADGEAR = 0x8C
81
+ PLAYER_INFO_INK_TANK = 0xAA
82
+ PLAYER_INFO_LEVEL = 0xAF
83
+ PLAYER_INFO_RANK = 0xB3
84
+ PLAYER_INFO_FEST_TEAM = 0xB7
85
+ PLAYER_INFO_FEST_ID = 0xBA
86
+ PLAYER_INFO_FEST_RANK = 0xBF
87
+ PLAYER_INFO_PID = 0xD0
88
+ PLAYER_INFO_FEST_POWER = 0xD6
89
+ PLAYER_INFO_SIZE = 0xD8
90
+
91
+
92
+ class OffsetChains(Enum):
93
+ ID_MANAGER = (
94
+ Offsets.FRAMEWORK_PEER_MGR_PTR,
95
+ Offsets.PEER_MGR_ID_MGR_PTR,
96
+ 0
97
+ )
98
+ ID_MANAGER_SLOT_ARRAY = (
99
+ Offsets.ID_MGR_SLOT_ARY_PTR,
100
+ 0
101
+ )
102
+ RESULTS_PHASE_ADDRESS = (
103
+ Offsets.MAINMGR_VS_GAME_PAGE_HANDLER_PTR,
104
+ Offsets.PAGE_HANDLER_RESULTS_PAGE_PTR,
105
+ Offsets.RESULTS_PAGE_PHASE
106
+ )
107
+ LEADERBOARD_PAGE = (
108
+ Offsets.MAINMGR_VS_GAME_PAGE_HANDLER_PTR,
109
+ Offsets.PAGE_HANDLER_LEADERBOARD_PAGE_PTR,
110
+ 0
111
+ )
112
+ TEAM_RESULTS_ARRAY = (
113
+ Offsets.MAINMGR_VS_GAME_SEQ_MGR_VS_GAME_PTR,
114
+ Offsets.SEQ_MGR_VS_GAME_FINAL_RESULT_PTR,
115
+ Offsets.SEQ_MGR_VS_GAME_FINAL_RESULT_SCORE_PTR,
116
+ Offsets.SCORE_OBJECT_TEAM_RESULTS_ARY_PTR,
117
+ 0
118
+ )
119
+
120
+ @property
121
+ def offsets(self) -> list[int]:
122
+ return list(self.value)
123
+
124
+
125
+ class VSMode(IntEnum):
126
+ REGULAR = 0
127
+ GACHI = 1
128
+ FEST = 2
129
+ PRIVATE = 3
130
+ PARTY = 4
131
+
132
+
133
+ class VSRule(IntEnum):
134
+ TURF = 0
135
+ RAINMAKER = 1
136
+ ZONES = 2
137
+ TOWER = 3
@@ -1,7 +1,6 @@
1
1
  # Splatlogger (c) 2025 Shadow Doggo.
2
2
 
3
3
  import json
4
- import platform
5
4
  import sys
6
5
  import time
7
6
  from datetime import datetime
@@ -12,8 +11,8 @@ from typing import Any
12
11
  import userpaths # type: ignore[import-untyped]
13
12
 
14
13
  from . import utility_functions as uf
15
- from .enums import Addresses, Offsets, Pointers, VSMode, VSRule
16
- from .tuples import AccountInfo, AppContext, CommonAddresses, PlayerInfo
14
+ from .enums import OffsetChains, Offsets, Pointers, VSMode, VSRule
15
+ from .tuples import AccountInfo, AppContext, CommonAddresses, PlayerInfo, PlayerStats, MatchResults
17
16
 
18
17
 
19
18
  def get_resource_path(relative_path: str | Path) -> Path:
@@ -76,27 +75,38 @@ class MatchLogger:
76
75
  match_info = self._get_match_info()
77
76
  versus_mode, versus_rule = match_info[2], match_info[3]
78
77
  if self._ctx.options.log_level == "standard":
79
- match_log = (f"\nMatch {match_count}\n\n{uf.player_table_header(self._ctx.options.service == 'altendo')}\n"
80
- if self._ctx.options.auto_logging or self._ctx.options.single_log else
81
- f"\n{uf.player_table_header(self._ctx.options.service == 'altendo')}\n")
78
+ match_log = (
79
+ f"\nMatch {match_count}\n\n{uf.player_table_header(self._ctx.options.service == 'altendo')}\n"
80
+ if self._ctx.options.auto_logging or self._ctx.options.single_log
81
+ else f"\n{uf.player_table_header(self._ctx.options.service == 'altendo')}\n"
82
+ )
82
83
  else:
83
84
  match_log = self._format_match_info(match_count, date, session_id, match_info)
84
85
 
85
- dcd_players: list[int] = []
86
- if self._ctx.options.log_level == "stats":
87
- if self._wait_for_results(adrs):
88
- dcd_players = self._check_for_dcs(session_id)
86
+ dcd_players: set[int] = set()
87
+ stats: list[PlayerStats] = []
88
+ match_results: MatchResults | None = None
89
+ if self._ctx.options.log_level == "full":
90
+ if self._wait_for_results(adrs, dcd_players):
91
+ stats = self._get_player_stats()
92
+ match_results = self._get_match_results(versus_rule)
89
93
  else:
90
94
  disconnect = True
91
95
 
96
+ stats_by_idx = {row.idx: row for row in stats}
97
+
92
98
  for player_info in player_info_list:
93
99
  match_log += self._format_match_log(player_info, versus_mode)
94
100
 
95
- if self._ctx.options.log_level == "stats" and not disconnect:
96
- if stats := self._get_player_stats(player_info, versus_rule, adrs, dcd_players):
97
- match_log += stats
98
- else:
99
- disconnect = True
101
+ if self._ctx.options.log_level == "full" and not disconnect:
102
+ if (player_stats := stats_by_idx.get(player_info.idx)) is not None:
103
+ match_log += self._format_stats_log(
104
+ player_info, match_results.winning_team if match_results else None,
105
+ versus_rule, player_stats, dcd_players
106
+ )
107
+
108
+ if match_results:
109
+ match_log += self._format_match_results(match_results, versus_rule)
100
110
 
101
111
  if self._ctx.options.log_level == "standard":
102
112
  match_log += (
@@ -120,12 +130,12 @@ class MatchLogger:
120
130
 
121
131
  def _get_match_info(self) -> tuple[str, int, int, int]:
122
132
  return (
123
- self._ctx.gecko.read_string(self._ctx.static_mem_adr + Offsets.STATICMEM_STAGE, length=32).split("\u0000")[
124
- 0],
125
- self._ctx.gecko.peek8(self._ctx.static_mem_adr + Offsets.STATICMEM_MATCH_HOUR + 0x3),
126
- # Read last byte as uint8
127
- self._ctx.gecko.peek8(self._ctx.static_mem_adr + Offsets.STATICMEM_VERSUS_MODE + 0x3),
128
- self._ctx.gecko.peek8(self._ctx.static_mem_adr + Offsets.STATICMEM_VERSUS_RULE + 0x3)
133
+ self._ctx.gecko.read_string(self._ctx.static_mem_adr + Offsets.STATICMEM_STAGE, length=32)
134
+ .split("\u0000")[0],
135
+ # Read last byte as sint8
136
+ self._ctx.gecko.peek8(self._ctx.static_mem_adr + Offsets.STATICMEM_MATCH_HOUR + 0x3, signed=True),
137
+ self._ctx.gecko.peek8(self._ctx.static_mem_adr + Offsets.STATICMEM_VERSUS_MODE + 0x3, signed=True),
138
+ self._ctx.gecko.peek8(self._ctx.static_mem_adr + Offsets.STATICMEM_VERSUS_RULE + 0x3, signed=True)
129
139
  )
130
140
 
131
141
  def _format_match_info(self, match_count: int, date: datetime, session_id: int,
@@ -160,7 +170,7 @@ class MatchLogger:
160
170
  )
161
171
 
162
172
  return (
163
- uf.player_table_entry(player_info, AccountInfo(player_info.network_id, player_info.mii_name, "")) + "\n"
173
+ uf.player_table_entry(player_info, AccountInfo(player_info.network_id, player_info.mii_name, "")) + "\n"
164
174
  ) if self._ctx.options.log_level == "standard" else (
165
175
  f"\n{' ' * self._align}[Player {player_info.idx + 1}]\n"
166
176
  f"{indent}Name: {player_info.name}"
@@ -191,83 +201,144 @@ class MatchLogger:
191
201
  f"{fest_info if vs_mode == VSMode.FEST else ''}"
192
202
  )
193
203
 
194
- # If the player stats stuff looks overly complicated, that's because it is.
195
- # Getting stuff from the stack reliably is a PITA.
196
- def _get_player_stats(self, player_info: PlayerInfo, versus_rule: int, adrs: CommonAddresses,
197
- dcd_players: list[int]) -> str | None:
198
- stats_adr, win_team_adr = self._get_stats_addresses()
199
-
200
- if self._ctx.options.platform == "wiiu":
201
- # If on console, get stats offset after the first match is complete.
202
- if self._stats_offset is None:
203
- if (stats_offset := self._find_stats_offset(adrs.player_info_ary_adr)) is None:
204
- return None
205
- self._stats_offset = stats_offset
206
-
207
- stats_adr += self._stats_offset
208
- win_team_adr += self._stats_offset
209
-
210
- winning_team = self._ctx.gecko.peek32(win_team_adr)
211
-
212
- return self._format_stats_log(player_info, winning_team, versus_rule,
213
- self._read_player_stats(stats_adr, player_info.idx),
214
- dcd_players)
215
-
216
- def _get_stats_addresses(self) -> tuple[int, int]:
217
- stats_adr: int = Addresses.STATS
218
- win_team_adr: int = Addresses.WIN_TEAM
219
-
220
- if self._ctx.options.platform == "cemu" and self._ctx.options.stack:
221
- stats_adr = self._ctx.options.stack + Offsets.CEMU_STATS
222
- win_team_adr = self._ctx.options.stack + Offsets.CEMU_WIN_TEAM
223
- elif self._ctx.options.platform == "cemu":
224
- # Assume default values if stack is not provided. These seem consistent across various systems.
225
- stack_adr = Addresses.CEMU_STACK_DEFAULT_WINDOWS \
226
- if platform.system() == "Windows" else Addresses.CEMU_STACK_DEFAULT_LINUX
227
- stats_adr = stack_adr + Offsets.CEMU_STATS
228
- win_team_adr = stack_adr + Offsets.CEMU_WIN_TEAM
229
-
230
- return stats_adr, win_team_adr
231
-
232
- def _wait_for_results(self, adrs: CommonAddresses) -> bool:
233
- for _ in range(200):
204
+ def _format_match_results(self, match_results: MatchResults, versus_rule: int) -> str:
205
+ indent = ' ' * (self._align + 2)
206
+
207
+ if versus_rule in (VSRule.RAINMAKER, VSRule.ZONES, VSRule.TOWER):
208
+ return (
209
+ f"\n{' ' * self._align}[Match Results]\n"
210
+ f"{indent}Winner: {'Bravo' if match_results.winning_team == 1 else 'Alpha'}\n"
211
+ f"{indent}Alpha points: {match_results.alpha_points}\n"
212
+ f"{indent}Bravo points: {match_results.bravo_points}\n"
213
+ f"{indent}Alpha score: {100 - match_results.alpha_count}\n"
214
+ f"{indent}Bravo score: {100 - match_results.bravo_count}\n"
215
+ )
216
+ else:
217
+ return (
218
+ f"\n{' ' * self._align}[Match Results]\n"
219
+ f"{indent}Winner: {'Bravo' if match_results.winning_team == 1 else 'Alpha'}\n"
220
+ f"{indent}Alpha painted: {match_results.alpha_paint_pc / 10:.1f}%\n"
221
+ f"{indent}Bravo painted: {match_results.bravo_paint_pc / 10:.1f}%\n"
222
+ f"{indent}Alpha points: {match_results.alpha_points}\n"
223
+ f"{indent}Bravo points: {match_results.bravo_points}\n"
224
+ )
225
+
226
+ def _wait_for_results(self, adrs: CommonAddresses, dcd_players: set[int]) -> bool:
227
+ id_mgr_adr = self._ctx.gecko.resolve_pointer_chain(
228
+ Pointers.FRAMEWORK.convert(self._ctx.options.platform),
229
+ OffsetChains.ID_MANAGER.offsets
230
+ )
231
+ slots_count = 0
232
+ slot_ary_adr = 0
233
+ if id_mgr_adr != 0:
234
+ slots_count = self._ctx.gecko.peek32(id_mgr_adr)
235
+ slot_ary_adr = self._ctx.gecko.peek32(id_mgr_adr + Offsets.ID_MGR_SLOT_ARY_PTR)
236
+
237
+ for _ in range(300):
234
238
  if not self._is_player_in_vs_match():
235
239
  return False
236
240
 
237
- scene_info_adr = self._ctx.gecko.peek32(adrs.main_mgr_base_adr + Offsets.MAINMGR_SCENE_INFO)
238
- if self._ctx.gecko.peek32(scene_info_adr + Offsets.SCENE_INFO_RESULTS_SHOWN) == 0: # Some sort of timer?
239
- return True
241
+ phase_adr = self._ctx.gecko.resolve_pointer_chain(
242
+ Pointers.MAIN_MGR_VS_GAME.convert(self._ctx.options.platform),
243
+ OffsetChains.RESULTS_PHASE_ADDRESS.offsets
244
+ )
240
245
 
241
- time.sleep(3)
246
+ if phase_adr != 0:
247
+ phase = self._ctx.gecko.peek8(phase_adr)
248
+ if 2 <= phase <= 14: # Judging/results states post fade-in.
249
+ return True
250
+
251
+ dcd_players.update(self._check_for_dcs(slots_count, slot_ary_adr))
252
+
253
+ time.sleep(2)
242
254
 
243
255
  return False
244
256
 
245
- def _read_player_stats(self, stats_adr: int, player_idx: int) -> tuple[int, int, int]:
246
- stats = self._ctx.gecko.peek_raw(stats_adr, length=Offsets.STATS_BUFFER_LENGTH)
247
- offset = player_idx * Offsets.STATS_PLAYER_STRIDE
257
+ def _get_player_stats(self) -> list[PlayerStats]:
258
+ leaderboard_page_adr = self._ctx.gecko.resolve_pointer_chain(
259
+ Pointers.MAIN_MGR_VS_GAME.convert(self._ctx.options.platform),
260
+ OffsetChains.LEADERBOARD_PAGE.offsets
261
+ )
262
+ if leaderboard_page_adr == 0:
263
+ return []
264
+
265
+ leaderboard_slots_num = self._ctx.gecko.peek32(leaderboard_page_adr + Offsets.LEADERBOARD_PAGE_SLOTS)
266
+ leaderboard_rows_adr = self._ctx.gecko.peek32(leaderboard_page_adr + Offsets.LEADERBOARD_PAGE_ROWS_PTR)
248
267
 
249
- points = uf.read_int(stats, offset + Offsets.STATS_POINTS, 2)
250
- kills = uf.read_int(stats, offset + Offsets.STATS_KILLS, 2)
251
- deaths = uf.read_int(stats, offset + Offsets.STATS_DEATHS, 2)
268
+ if not 1 <= leaderboard_slots_num <= 8 or leaderboard_rows_adr == 0:
269
+ return []
252
270
 
253
- return points, kills, deaths
271
+ size = leaderboard_slots_num * Offsets.ROWS_PLAYER_STRIDE
272
+ stats = self._ctx.gecko.peek_raw(leaderboard_rows_adr, length=size)
273
+ slots = []
274
+ for slot in range(leaderboard_slots_num):
275
+ offset = slot * Offsets.ROWS_PLAYER_STRIDE
276
+ idx = uf.read_int(stats, offset + Offsets.ROWS_PLAYER_IDX, 4, signed=True)
277
+ team = uf.read_int(stats, offset + Offsets.ROWS_PLAYER_TEAM, 4, signed=True)
278
+ if not 0 <= idx < 8 or team not in (0, 1): continue
279
+ points = uf.read_int(stats, offset + Offsets.ROWS_PLAYER_POINTS, 4)
280
+ kills = uf.read_int(stats, offset + Offsets.ROWS_PLAYER_KILLS, 4)
281
+ deaths = uf.read_int(stats, offset + Offsets.ROWS_PLAYER_DEATHS, 4)
282
+ local_player = uf.read_int(stats, offset + Offsets.ROWS_PLAYER_LOCAL_PLAYER_FLAG, 1) == 1
283
+
284
+ slots.append(PlayerStats(idx, team, points, kills, deaths, local_player))
285
+
286
+ return sorted(slots, key=lambda x: x.idx)
287
+
288
+ def _get_match_results(self, versus_rule: int) -> MatchResults | None:
289
+ team_results_ary_adr = self._ctx.gecko.resolve_pointer_chain(
290
+ Pointers.MAIN_MGR_VS_GAME.convert(self._ctx.options.platform),
291
+ OffsetChains.TEAM_RESULTS_ARRAY.offsets
292
+ )
293
+ if team_results_ary_adr == 0:
294
+ return None
295
+
296
+ if versus_rule in (VSRule.RAINMAKER, VSRule.ZONES, VSRule.TOWER):
297
+ alpha_results = self._ctx.gecko.peek_raw(team_results_ary_adr, Offsets.TEAM_RESULTS_ARY_SIZE_RANKED)
298
+ bravo_results = self._ctx.gecko.peek_raw(team_results_ary_adr + Offsets.TEAM_RESULTS_ARY_SIZE_RANKED,
299
+ Offsets.TEAM_RESULTS_ARY_SIZE_RANKED)
300
+
301
+ return MatchResults(
302
+ winning_team=uf.read_int(bravo_results, Offsets.TEAM_RESULTS_WON, 1),
303
+ alpha_paint_pc=uf.read_int(alpha_results, Offsets.TEAM_RESULTS_PAINT_PC, 4),
304
+ bravo_paint_pc=uf.read_int(bravo_results, Offsets.TEAM_RESULTS_PAINT_PC, 4),
305
+ alpha_points=uf.read_int(alpha_results, Offsets.TEAM_RESULTS_POINTS, 4),
306
+ bravo_points=uf.read_int(bravo_results, Offsets.TEAM_RESULTS_POINTS, 4),
307
+ alpha_count=uf.read_int(alpha_results, Offsets.TEAM_RESULTS_RANKED_OBJ_COUNT, 4),
308
+ bravo_count=uf.read_int(bravo_results, Offsets.TEAM_RESULTS_RANKED_OBJ_COUNT, 4)
309
+ )
310
+
311
+ alpha_results = self._ctx.gecko.peek_raw(team_results_ary_adr, Offsets.TEAM_RESULTS_ARY_SIZE_REGULAR)
312
+ bravo_results = self._ctx.gecko.peek_raw(team_results_ary_adr + Offsets.TEAM_RESULTS_ARY_SIZE_REGULAR,
313
+ Offsets.TEAM_RESULTS_ARY_SIZE_REGULAR)
314
+
315
+ return MatchResults(
316
+ winning_team=uf.read_int(bravo_results, Offsets.TEAM_RESULTS_WON, 1),
317
+ alpha_paint_pc=uf.read_int(alpha_results, Offsets.TEAM_RESULTS_PAINT_PC, 4),
318
+ bravo_paint_pc=uf.read_int(bravo_results, Offsets.TEAM_RESULTS_PAINT_PC, 4),
319
+ alpha_points=uf.read_int(alpha_results, Offsets.TEAM_RESULTS_POINTS, 4),
320
+ bravo_points=uf.read_int(bravo_results, Offsets.TEAM_RESULTS_POINTS, 4),
321
+ alpha_count=0,
322
+ bravo_count=0
323
+ )
254
324
 
255
- def _format_stats_log(self, player_info: PlayerInfo, winning_team: int, versus_rule: int,
256
- stats: tuple[int, int, int], dcd_players: list) -> str:
325
+ def _format_stats_log(self, player_info: PlayerInfo, winning_team: int | None, versus_rule: int,
326
+ stats: PlayerStats, dcd_players: set[int]) -> str:
257
327
  indent = " " * (self._align + 2)
258
- player_won = player_info.team == winning_team
328
+ player_won = player_info.team == winning_team if winning_team is not None else None
259
329
  player_dcd = player_info.pid in dcd_players
260
- points, kills, deaths = stats
261
330
 
262
331
  points_log = ""
263
332
  if versus_rule == VSRule.TURF: # Only log points in turf war cause in ranked they're the same for all players.
264
333
  if player_won:
265
- points_log = f"{indent}Points: {points + 1000}p ({points}p w/o win bonus)\n"
334
+ points_log = f"{indent}Points: {stats.points}p ({stats.points - 1000}p w/o win bonus)\n"
266
335
  else:
267
- points_log = f"{indent}Points: {points}p\n"
336
+ points_log = f"{indent}Points: {stats.points}p\n"
268
337
 
269
338
  if player_dcd:
270
339
  result = "Disconnected"
340
+ elif player_won is None:
341
+ result = "Unknown"
271
342
  elif player_won:
272
343
  result = "Win"
273
344
  else:
@@ -275,47 +346,26 @@ class MatchLogger:
275
346
 
276
347
  return (
277
348
  f"{points_log}"
278
- f"{indent}K/D: {kills}/{deaths}\n"
349
+ f"{indent}K/D: {stats.kills}/{stats.deaths}\n"
279
350
  f"{indent}Result: {result}\n"
280
351
  )
281
352
 
282
- def _find_stats_offset(self, player_info_ary_adr: int) -> int | None:
283
- # Find pointer to the first player's PlayerInfo within the stats and calculate offset.
284
- first_player_info_adr = self._ctx.gecko.peek32(player_info_ary_adr)
285
-
286
- # Check default offsets of 0 and -0x38 first. The pointer is at 0x30.
287
- if self._ctx.gecko.peek32(Addresses.STATS + 0x30) == first_player_info_adr:
288
- return 0
289
- if self._ctx.gecko.peek32(Addresses.STATS - 0x8) == first_player_info_adr:
290
- return -0x38
291
-
292
- search_space = self._ctx.gecko.peek_raw(Addresses.STATS - 0x100, length=0x200)
293
- stats_offset = search_space.find(first_player_info_adr.to_bytes(length=4, byteorder="big"))
294
-
295
- return None if stats_offset == -1 else stats_offset - 0x130
296
-
297
- def _check_for_dcs(self, session_id: int) -> list[int]:
298
- # Magic numbers here cause I have no idea what class/struct this belongs to,
299
- # I just happened to find a pointer chain to it.
300
- lobby_history_adr = self._ctx.gecko.resolve_pointer_chain(0x106EB0A0, [0xC, 0x78, 0x10])
301
- if lobby_history_adr == 0:
302
- return []
303
-
304
- lobby_history = self._ctx.gecko.peek_raw(lobby_history_adr, length=0x1D0)
353
+ def _check_for_dcs(self, slots_count: int, slot_ary_adr: int) -> set[int]:
354
+ dcd_players: set[int] = set()
305
355
 
306
- latest_player_status: dict[int, int] = {}
356
+ for offset in range(0, slots_count * 4, 4):
357
+ slot_adr = self._ctx.gecko.peek32(slot_ary_adr + offset)
358
+ if slot_adr == 0: continue
359
+ slot = self._ctx.gecko.peek_raw(slot_adr, Offsets.SLOT_SIZE)
307
360
 
308
- offset = 0
309
- while uf.read_int(lobby_history, offset, 4) not in (0, 0x6D): # 0x6D acts as a separator
310
- if uf.read_int(lobby_history, 0x8 + offset, 4) == session_id:
311
- status = uf.read_int(lobby_history, 0x4 + offset, 4)
312
- player_id = uf.read_int(lobby_history, 0xC + offset, 4)
313
- latest_player_status[player_id] = status
361
+ state = uf.read_int(slot, Offsets.SLOT_STATE, 4)
362
+ pid = uf.read_int(slot, Offsets.SLOT_PID, 4)
363
+ player_id = uf.read_int(slot, Offsets.SLOT_PLAYER_ID, 1)
314
364
 
315
- offset += 0x10
365
+ if state == 3 and player_id != 0xFF:
366
+ dcd_players.add(pid)
316
367
 
317
- # I'm p sure 0x8 means the player left or got kicked and 0x7 means they abruptly disconnected from the mesh.
318
- return [player_id for player_id, status in latest_player_status.items() if status == 0x7 or status == 0x8]
368
+ return dcd_players
319
369
 
320
370
  def _is_player_in_vs_match(self) -> bool:
321
- return self._ctx.gecko.peek32(uf.convert_ptr(Pointers.MAIN_MGR_VS_GAME, self._ctx.options.platform)) != 0
371
+ return self._ctx.gecko.peek32(Pointers.MAIN_MGR_VS_GAME.convert(self._ctx.options.platform)) != 0
@@ -5,12 +5,12 @@
5
5
  },
6
6
 
7
7
  "versus_mode_name": {
8
+ "-1": "?cNone",
8
9
  "0": "Regular Battle",
9
10
  "1": "Ranked Battle",
10
11
  "2": "Splatfest Battle",
11
12
  "3": "Private Battle",
12
- "4": "Squad Battle",
13
- "255": "None"
13
+ "4": "Squad Battle"
14
14
  },
15
15
 
16
16
  "versus_rule_name": {
@@ -18,8 +18,7 @@
18
18
  "0": "Turf War",
19
19
  "1": "Rainmaker",
20
20
  "2": "Splat Zones",
21
- "3": "Tower Control",
22
- "255": "None"
21
+ "3": "Tower Control"
23
22
  },
24
23
 
25
24
  "team_name": {
@@ -17,10 +17,10 @@ import userpaths # type: ignore[import-untyped]
17
17
 
18
18
  from . import utility_functions as uf
19
19
  from .enums import Offsets, Pointers
20
+ from .errors import SplatloggerError
20
21
  from .match_logger import MatchLogger
21
22
  from .tcpgecko import GeckoConnectionFailedError, TCPGecko
22
23
  from .tuples import AccountInfo, AppContext, CommonAddresses, Options, PlayerInfo
23
- from .errors import SplatloggerError
24
24
 
25
25
  if TYPE_CHECKING:
26
26
  from .cemu_gecko import CemuGecko
@@ -90,7 +90,8 @@ def command_lobby_info(ctx: AppContext) -> bool:
90
90
 
91
91
  # Not reading through names.json just for this.
92
92
  vs_modes = (
93
- "Regular Battle", "Ranked Battle", "Splatfest Battle", "Private Battle", "Squad Battle")
93
+ "Regular Battle", "Ranked Battle", "Splatfest Battle", "Private Battle", "Squad Battle"
94
+ )
94
95
  vs_mode = ctx.gecko.peek8(ctx.static_mem_adr + Offsets.STATICMEM_VERSUS_MODE + 0x3)
95
96
  print(f"Lobby type: {vs_modes[vs_mode] if 0 <= vs_mode < len(vs_modes) else 'Unknown'}")
96
97
 
@@ -101,7 +102,7 @@ def command_lobby_info(ctx: AppContext) -> bool:
101
102
  f"\n{'PID':<10} | {'ANID' if ctx.options.service == 'altendo' else 'PNID':<16}\n{'-' * 30}"
102
103
  )
103
104
  for player in players:
104
- print(f"{player[0]} | {player[1]}")
105
+ print(f"{player[0]:<10} | {player[1]}")
105
106
  else:
106
107
  print("\nNo active session.")
107
108
  finally:
@@ -156,7 +157,7 @@ def create_tcpgecko_instance(options: Options) -> TCPGecko | CemuGecko:
156
157
 
157
158
  @uf.handle_gecko_errors
158
159
  def get_static_mem_adr(gecko: TCPGecko | CemuGecko, options: Options) -> int:
159
- static_mem_adr = gecko.peek32(uf.convert_ptr(Pointers.STATIC_MEM, options.platform))
160
+ static_mem_adr = gecko.peek32(Pointers.STATIC_MEM.convert(options.platform))
160
161
  if static_mem_adr == 0:
161
162
  msg = (
162
163
  "Successfully opened Cemu process but could not read StaticMem address. "
@@ -229,13 +230,13 @@ def splatlog(ctx: AppContext, match_logger: MatchLogger | None, match_count: int
229
230
 
230
231
  @uf.handle_gecko_errors
231
232
  def get_common_addresses(ctx: AppContext) -> CommonAddresses:
232
- return CommonAddresses(ctx.gecko.peek32(uf.convert_ptr(Pointers.MAIN_MGR_BASE, ctx.options.platform)),
233
- ctx.gecko.peek32(ctx.static_mem_adr + Offsets.STATICMEM_PLAYER_INFO_ARY))
233
+ return CommonAddresses(ctx.gecko.peek32(Pointers.MAIN_MGR_BASE.convert(ctx.options.platform)),
234
+ ctx.gecko.peek32(ctx.static_mem_adr + Offsets.STATICMEM_PLAYER_INFO_ARY_PTR))
234
235
 
235
236
 
236
237
  @uf.handle_gecko_errors
237
238
  def get_session_id(ctx: AppContext) -> int:
238
- if (session_adr := ctx.gecko.peek32(uf.convert_ptr(Pointers.SESSION, ctx.options.platform))) != 0:
239
+ if (session_adr := ctx.gecko.peek32(Pointers.SESSION.convert(ctx.options.platform))) != 0:
239
240
  session_id_idx = int.from_bytes(ctx.gecko.peek_raw(session_adr + Offsets.SESSION_ID_IDX,
240
241
  length=1), byteorder="big")
241
242
  return ctx.gecko.peek32(session_adr + session_id_idx * 4 + Offsets.SESSION_ID)
@@ -246,10 +247,14 @@ def get_session_id(ctx: AppContext) -> int:
246
247
  @uf.handle_gecko_errors
247
248
  def get_player_count(ctx: AppContext, main_mgr_base_adr: int) -> int:
248
249
  if is_player_in_vs_match(ctx) or ctx.options.auto_logging:
249
- player_count = ctx.gecko.resolve_pointer_chain(main_mgr_base_adr,
250
- [Offsets.MAINMGR_PLAYER_MGR, Offsets.PLAYERMGR_PLAYER_COUNT])
251
- if player_count != 0:
252
- return player_count
250
+ player_count_adr = ctx.gecko.resolve_pointer_chain(
251
+ main_mgr_base_adr + Offsets.MAINMGR_PLAYER_MGR_PTR,
252
+ [Offsets.PLAYERMGR_PLAYER_COUNT]
253
+ )
254
+ if player_count_adr != 0:
255
+ player_count = ctx.gecko.peek32(player_count_adr)
256
+ if 1 <= player_count <= 8:
257
+ return player_count
253
258
  else:
254
259
  uf.print_warning("You are currently not in a match. Logging previous match data "
255
260
  "(Player 1 data will be your own, Session ID will be the current one).\n")
@@ -259,19 +264,19 @@ def get_player_count(ctx: AppContext, main_mgr_base_adr: int) -> int:
259
264
 
260
265
  @uf.handle_gecko_errors
261
266
  def is_player_in_vs_match(ctx: AppContext) -> bool:
262
- return ctx.gecko.peek32(uf.convert_ptr(Pointers.MAIN_MGR_VS_GAME, ctx.options.platform)) != 0
267
+ return ctx.gecko.peek32(Pointers.MAIN_MGR_VS_GAME.convert(ctx.options.platform)) != 0
263
268
 
264
269
 
265
- def collect_player_info(ctx: AppContext, player_count: int, player_info_ary_adr: int) -> Iterator[
266
- tuple[PlayerInfo, AccountInfo]]:
270
+ def collect_player_info(ctx: AppContext, player_count: int,
271
+ player_info_ary_adr: int) -> Iterator[tuple[PlayerInfo, AccountInfo]]:
267
272
  for player_idx in range(player_count):
268
273
  if player_info_tuple := get_player_info(ctx, player_idx, player_info_ary_adr):
269
274
  yield player_info_tuple
270
275
 
271
276
 
272
277
  @uf.handle_gecko_errors
273
- def get_player_info(ctx: AppContext, player_idx: int, player_info_ary_adr: int) -> tuple[
274
- PlayerInfo, AccountInfo] | None:
278
+ def get_player_info(ctx: AppContext, player_idx: int,
279
+ player_info_ary_adr: int) -> tuple[PlayerInfo, AccountInfo] | None:
275
280
  player_info_adr = ctx.gecko.peek32(player_info_ary_adr + player_idx * 0x4)
276
281
  if player_info_adr == 0:
277
282
  return None
@@ -290,17 +295,22 @@ def get_player_info(ctx: AppContext, player_idx: int, player_info_ary_adr: int)
290
295
 
291
296
 
292
297
  def decode_player_name(player_info_raw: bytes) -> str:
293
- return clean(player_info_raw[Offsets.PLAYER_INFO_NAME:Offsets.PLAYER_INFO_NAME + Offsets.PLAYER_INFO_NAME_LENGTH]
294
- .decode("utf-16-be", errors="replace")
295
- .split("\u0000")[0],
296
- to_ascii=False, lower=False, no_line_breaks=True)
298
+ return clean(
299
+ player_info_raw[Offsets.PLAYER_INFO_NAME:Offsets.PLAYER_INFO_NAME + Offsets.PLAYER_INFO_NAME_LENGTH]
300
+ .decode("utf-16-be", errors="replace")
301
+ .split("\u0000")[0],
302
+ to_ascii=False,
303
+ lower=False,
304
+ no_line_breaks=True
305
+ )
297
306
 
298
307
 
299
308
  def get_player_account_info(ctx: AppContext, player_pid: int, player_name: str) -> AccountInfo:
300
309
  if ctx.options.service == "none":
301
310
  return AccountInfo("", player_name, "")
302
311
 
303
- act_info = get_altendo_account_info(player_pid) if ctx.options.service == "altendo" else get_pn_account_info(player_pid)
312
+ act_info = get_altendo_account_info(player_pid) if ctx.options.service == "altendo" else get_pn_account_info(
313
+ player_pid)
304
314
  if act_info.request_error:
305
315
  return AccountInfo("", player_name, act_info.request_error)
306
316
 
@@ -316,7 +326,7 @@ def build_player_info(player_idx: int, player_pid: int, player_name: str, act_in
316
326
  name=player_name,
317
327
  mii_name=act_info.mii_name,
318
328
  region=uf.read_int(player_info_raw, Offsets.PLAYER_INFO_REGION, 4),
319
- team=uf.read_int(player_info_raw, Offsets.PLAYER_INFO_TEAM, 1),
329
+ team=uf.read_int(player_info_raw, Offsets.PLAYER_INFO_TEAM, 1, signed=True),
320
330
  gender=uf.read_int(player_info_raw, Offsets.PLAYER_INFO_GENDER, 1),
321
331
  skin_tone=uf.read_int(player_info_raw, Offsets.PLAYER_INFO_SKIN_TONE, 1),
322
332
  eye_color=uf.read_int(player_info_raw, Offsets.PLAYER_INFO_EYE_COLOR, 1),
@@ -339,32 +349,40 @@ def build_player_info(player_idx: int, player_pid: int, player_name: str, act_in
339
349
 
340
350
  @uf.handle_gecko_errors
341
351
  def get_lobby_players(ctx: AppContext) -> list[tuple[int, str]] | None:
342
- # The game stores the active players in the lobby in a doubly linked list with a sentinel node on each end.
343
- head_node_adr = ctx.gecko.resolve_pointer_chain(uf.convert_ptr(Pointers.SESSION, ctx.options.platform),
344
- [Offsets.SESSION_LOBBY, Offsets.LOBBY_PLAYER_LIST_HEAD])
345
- if head_node_adr == 0:
352
+ session_adr = ctx.gecko.peek32(Pointers.SESSION.convert(ctx.options.platform))
353
+ if session_adr == 0:
346
354
  return None
347
355
 
348
- # The 'prev' pointer of the head node points to the sentinel node.
349
- sentinel_node_adr = ctx.gecko.peek32(head_node_adr)
356
+ # Circular doubly linked list with a sentinel embedded in Session.
357
+ sentinel_adr = session_adr + Offsets.SESSION_ACTIVE_STATION_LIST_SENTINEL
358
+ header = ctx.gecko.peek_raw(sentinel_adr, 0xC)
359
+ current_node = uf.read_int(header, Offsets.STATION_LIST_NODE_NEXT_PTR, 4)
360
+ count = uf.read_int(header,
361
+ Offsets.SESSION_ACTIVE_STATION_LIST_COUNT - Offsets.SESSION_ACTIVE_STATION_LIST_SENTINEL, 4)
362
+ capacity = ctx.gecko.peek32(session_adr + Offsets.SESSION_STATION_NODE_CAPACITY)
363
+
364
+ if not 0 <= count <= capacity <= 10:
365
+ return None
350
366
 
351
- players = []
367
+ player_ids = []
368
+ previous_node = sentinel_adr
352
369
 
353
- current_node = head_node_adr
354
- count = 0
355
- while current_node != sentinel_node_adr and current_node != 0 and count < 8:
356
- node_data = ctx.gecko.peek_raw(current_node + 0x4, 8)
370
+ for _ in range(count):
371
+ if current_node == 0 or current_node == sentinel_adr:
372
+ return None
357
373
 
358
- next_node = uf.read_int(node_data, 0x0, 4)
359
- player_id = uf.read_int(node_data, 0x4, 4)
374
+ node = ctx.gecko.peek_raw(current_node, Offsets.STATION_LIST_NODE_SIZE)
375
+ if uf.read_int(node, Offsets.STATION_LIST_NODE_PREV_PTR, 4) != previous_node:
376
+ return None
360
377
 
378
+ player_id = uf.read_int(node, Offsets.STATION_LIST_NODE_STATION_ID, 4)
361
379
  if player_id != 0:
362
- players.append((player_id, get_player_account_info(ctx, player_id, "").network_id))
380
+ player_ids.append(player_id)
363
381
 
364
- current_node = next_node
365
- count += 1
382
+ previous_node = current_node
383
+ current_node = uf.read_int(node, Offsets.STATION_LIST_NODE_NEXT_PTR, 4)
366
384
 
367
- return players
385
+ return [(pid, get_player_account_info(ctx, pid, "").network_id) for pid in player_ids]
368
386
 
369
387
 
370
388
  def get_pn_account_info(player_pid: int) -> AccountInfo:
@@ -22,7 +22,6 @@ class Options(NamedTuple):
22
22
  service: str
23
23
  single_log: bool
24
24
  silent_logging: bool
25
- stack: int | None
26
25
 
27
26
 
28
27
  @dataclass
@@ -72,3 +71,22 @@ class PlayerInfo(NamedTuple):
72
71
  fest_id: int
73
72
  fest_rank: int
74
73
  fest_power: int
74
+
75
+
76
+ class PlayerStats(NamedTuple):
77
+ idx: int
78
+ team: int
79
+ points: int
80
+ kills: int
81
+ deaths: int
82
+ local_player: bool
83
+
84
+
85
+ class MatchResults(NamedTuple):
86
+ winning_team: int
87
+ alpha_paint_pc: int
88
+ bravo_paint_pc: int
89
+ alpha_points: int
90
+ bravo_points: int
91
+ alpha_count: int
92
+ bravo_count: int
@@ -13,7 +13,6 @@ from xml.etree import ElementTree
13
13
  import requests
14
14
  from colorama import Fore, Style
15
15
 
16
- from .enums import Offsets
17
16
  from .errors import SplatloggerError
18
17
  from .tcpgecko import GeckoConnectionClosedError, GeckoReadMemError, TCPGeckoError
19
18
  from .tuples import AccountInfo, PlayerInfo
@@ -32,21 +31,19 @@ def player_table_header(altendo: bool) -> str:
32
31
 
33
32
 
34
33
  def player_table_entry(player_info: PlayerInfo, act_info: AccountInfo) -> str:
35
- return (f"Player {player_info.idx + 1} | "
36
- f"{player_info.pid:<10} | "
37
- f"{act_info.network_id:<16} | "
38
- f"{player_info.name}"
39
- f"{f' ({act_info.mii_name})' if not compare_names(player_info.name, act_info.mii_name) else ''}")
34
+ return (
35
+ f"Player {player_info.idx + 1} | "
36
+ f"{player_info.pid:<10} | "
37
+ f"{act_info.network_id:<16} | "
38
+ f"{player_info.name}"
39
+ f"{f' ({act_info.mii_name})' if not compare_names(player_info.name, act_info.mii_name) else ''}"
40
+ )
40
41
 
41
42
 
42
43
  def read_int(data: bytes, offset: int, length: int, *, signed: bool = False) -> int:
43
44
  return int.from_bytes(data[offset:offset + length], byteorder="big", signed=signed)
44
45
 
45
46
 
46
- def convert_ptr(address: int, platform: str):
47
- return address + Offsets.WIIU_BSS_OFFSET if platform == "wiiu" else address
48
-
49
-
50
47
  def handle_gecko_errors(func: Callable[..., Any]) -> Any:
51
48
  @wraps(func)
52
49
  def wrapper(*args: Any, **kwargs: Any):
@@ -1,81 +0,0 @@
1
- # Splatlogger (c) 2025 Shadow Doggo.
2
-
3
- from enum import IntEnum
4
-
5
-
6
- class Pointers(IntEnum):
7
- SESSION = 0x101E8980
8
- STATIC_MEM = 0x101DD330
9
- MAIN_MGR_BASE = 0x101E2814
10
- MAIN_MGR_VS_GAME = 0x101E4FF8
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
- CEMU_STACK_DEFAULT_WINDOWS = 0xE101440
19
- CEMU_STACK_DEFAULT_LINUX = 0xE175900
20
-
21
-
22
- class Offsets(IntEnum):
23
- WIIU_BSS_OFFSET = 0x503000
24
- SESSION_LOBBY = 0xB0
25
- SESSION_ID_IDX = 0xBD
26
- SESSION_ID = 0xCC
27
- LOBBY_PLAYER_LIST_HEAD = 0xA4
28
- MAINMGR_PLAYER_MGR = 0x268
29
- MAINMGR_SCENE_INFO = 0x2A4
30
- PLAYERMGR_PLAYER_COUNT = 0x320
31
- SCENE_INFO_RESULTS_SHOWN = 0x330
32
- STATICMEM_PLAYER_INFO_ARY = 0x10
33
- STATICMEM_STAGE = 0x28
34
- STATICMEM_MATCH_HOUR = 0x234
35
- STATICMEM_VERSUS_MODE = 0x238
36
- STATICMEM_VERSUS_RULE = 0x23C
37
- CEMU_WIN_TEAM = 0xF556C
38
- CEMU_STATS = 0xF559C
39
- PLAYER_INFO_NAME = 0x6
40
- PLAYER_INFO_NAME_LENGTH = 0x20
41
- PLAYER_INFO_REGION = 0x2C
42
- PLAYER_INFO_TEAM = 0x33
43
- PLAYER_INFO_GENDER = 0x37
44
- PLAYER_INFO_SKIN_TONE = 0x3B
45
- PLAYER_INFO_EYE_COLOR = 0x3F
46
- PLAYER_INFO_WEAPON_SET = 0x42
47
- PLAYER_INFO_MAIN_WEAPON = 0x46
48
- PLAYER_INFO_SUB_WEAPON = 0x4A
49
- PLAYER_INFO_SPECIAL_WEAPON = 0x4F
50
- PLAYER_INFO_SHOES = 0x54
51
- PLAYER_INFO_CLOTHES = 0x70
52
- PLAYER_INFO_HEADGEAR = 0x8C
53
- PLAYER_INFO_INK_TANK = 0xAA
54
- PLAYER_INFO_LEVEL = 0xAF
55
- PLAYER_INFO_RANK = 0xB3
56
- PLAYER_INFO_FEST_TEAM = 0xB7
57
- PLAYER_INFO_FEST_ID = 0xBA
58
- PLAYER_INFO_FEST_RANK = 0xBF
59
- PLAYER_INFO_PID = 0xD0
60
- PLAYER_INFO_FEST_POWER = 0xD6
61
- PLAYER_INFO_SIZE = 0xD8
62
- STATS_BUFFER_LENGTH = 0x124
63
- STATS_PLAYER_STRIDE = 0x20
64
- STATS_POINTS = 0x3A
65
- STATS_KILLS = 0x3E
66
- STATS_DEATHS = 0x42
67
-
68
-
69
- class VSMode(IntEnum):
70
- REGULAR = 0
71
- GACHI = 1
72
- FEST = 2
73
- PRIVATE = 3
74
- PARTY = 4
75
-
76
-
77
- class VSRule(IntEnum):
78
- TURF = 0
79
- RAINMAKER = 1
80
- ZONES = 2
81
- TOWER = 3
File without changes
File without changes