ps5dbg-scanner 0.2.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.
- ps5dbg_scanner/__init__.py +11 -0
- ps5dbg_scanner/__main__.py +9 -0
- ps5dbg_scanner/app.py +362 -0
- ps5dbg_scanner/connection_panel.py +96 -0
- ps5dbg_scanner/freeze_manager.py +125 -0
- ps5dbg_scanner/results_table.py +260 -0
- ps5dbg_scanner/scan_panel.py +92 -0
- ps5dbg_scanner-0.2.0.dist-info/METADATA +153 -0
- ps5dbg_scanner-0.2.0.dist-info/RECORD +13 -0
- ps5dbg_scanner-0.2.0.dist-info/WHEEL +5 -0
- ps5dbg_scanner-0.2.0.dist-info/entry_points.txt +2 -0
- ps5dbg_scanner-0.2.0.dist-info/licenses/LICENSE +22 -0
- ps5dbg_scanner-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""ps5dbg-scanner — cross-platform 'Cheat Engine for PS5' GUI built on ps5dbg.
|
|
2
|
+
|
|
3
|
+
Run with ``ps5dbg-scanner`` (entry point) or ``python -m ps5dbg_scanner``.
|
|
4
|
+
|
|
5
|
+
from ps5dbg_scanner.app import main
|
|
6
|
+
main()
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.2.0"
|
|
11
|
+
__all__ = ["__version__"]
|
ps5dbg_scanner/app.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""ScannerApp — the tkinter root + threading-safe worker dispatch.
|
|
2
|
+
|
|
3
|
+
Owns:
|
|
4
|
+
- the Tk root window and all panels
|
|
5
|
+
- the live ps5dbg.PS5Debug client (None until connected)
|
|
6
|
+
- a worker-thread pool: blocking ps5dbg calls run off the UI thread and post
|
|
7
|
+
results back via ``root.after(0, callback)`` (the only tkinter-safe path)
|
|
8
|
+
- the current scan session state (turbo resident handle, survivor count,
|
|
9
|
+
value type/length for the active session)
|
|
10
|
+
|
|
11
|
+
UI thread never blocks on network I/O.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import tkinter as tk
|
|
19
|
+
from tkinter import messagebox, ttk
|
|
20
|
+
from typing import Callable, Optional
|
|
21
|
+
|
|
22
|
+
from ps5dbg import PS5Debug
|
|
23
|
+
from ps5dbg import turboscan as ts
|
|
24
|
+
from ps5dbg.constants import ScanCompareType, ScanValueType
|
|
25
|
+
|
|
26
|
+
from .freeze_manager import FreezeManager
|
|
27
|
+
from .results_table import ResultsTable
|
|
28
|
+
from .scan_panel import ScanPanel
|
|
29
|
+
from .connection_panel import ConnectionPanel
|
|
30
|
+
|
|
31
|
+
# value type -> (ps5dbg ScanValueType, fixed byte width). Bytes/string excluded
|
|
32
|
+
# from the core v0.2 value scan (they use the AOB pane in v0.3).
|
|
33
|
+
_VALUE_TYPES: dict[str, tuple[ScanValueType, int]] = {
|
|
34
|
+
"u8": (ScanValueType.UINT8, 1),
|
|
35
|
+
"u16": (ScanValueType.UINT16, 2),
|
|
36
|
+
"u32": (ScanValueType.UINT32, 4),
|
|
37
|
+
"u64": (ScanValueType.UINT64, 8),
|
|
38
|
+
"i8": (ScanValueType.INT8, 1),
|
|
39
|
+
"i16": (ScanValueType.INT16, 2),
|
|
40
|
+
"i32": (ScanValueType.INT32, 4),
|
|
41
|
+
"i64": (ScanValueType.INT64, 8),
|
|
42
|
+
"f32": (ScanValueType.FLOAT, 4),
|
|
43
|
+
"f64": (ScanValueType.DOUBLE, 8),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# scan type -> (ps5dbg ScanCompareType, needs_value)
|
|
47
|
+
_SCAN_TYPES: dict[str, tuple[ScanCompareType, bool]] = {
|
|
48
|
+
"Exact value": (ScanCompareType.EXACT_VALUE, True),
|
|
49
|
+
"Bigger than": (ScanCompareType.BIGGER_THAN, True),
|
|
50
|
+
"Smaller than": (ScanCompareType.SMALLER_THAN, True),
|
|
51
|
+
"Increased value": (ScanCompareType.INCREASED_VALUE, False),
|
|
52
|
+
"Decreased value": (ScanCompareType.DECREASED_VALUE, False),
|
|
53
|
+
"Changed value": (ScanCompareType.CHANGED_VALUE, False),
|
|
54
|
+
"Unchanged value": (ScanCompareType.UNCHANGED_VALUE, False),
|
|
55
|
+
"Unknown initial value": (ScanCompareType.UNKNOWN_INITIAL_VALUE, False),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ScannerApp:
|
|
60
|
+
"""The scanner application. Construct, then call run()."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self.root = tk.Tk()
|
|
64
|
+
self.root.title("ps5dbg-scanner — PS5 memory scanner")
|
|
65
|
+
self.root.geometry("900x600")
|
|
66
|
+
self._client: Optional[PS5Debug] = None
|
|
67
|
+
self._pid: Optional[int] = None
|
|
68
|
+
|
|
69
|
+
# scan session state
|
|
70
|
+
self._session_active = False
|
|
71
|
+
self._survivor_count = 0
|
|
72
|
+
self._value_type_name = "u32"
|
|
73
|
+
self._value_length = 4
|
|
74
|
+
|
|
75
|
+
# freeze manager (started after connect)
|
|
76
|
+
self._freeze: Optional[FreezeManager] = None
|
|
77
|
+
|
|
78
|
+
# status bar
|
|
79
|
+
self._status = tk.StringVar(value="disconnected")
|
|
80
|
+
|
|
81
|
+
# build UI
|
|
82
|
+
self._conn = ConnectionPanel(self.root, self)
|
|
83
|
+
self._scan = ScanPanel(self.root, self,
|
|
84
|
+
value_types=list(_VALUE_TYPES),
|
|
85
|
+
scan_types=list(_SCAN_TYPES))
|
|
86
|
+
self._table = ResultsTable(self.root, self)
|
|
87
|
+
|
|
88
|
+
self._layout()
|
|
89
|
+
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
90
|
+
|
|
91
|
+
# -- layout --------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _layout(self) -> None:
|
|
94
|
+
self.root.grid_rowconfigure(2, weight=1)
|
|
95
|
+
self.root.grid_columnconfigure(0, weight=1)
|
|
96
|
+
self._conn.frame.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4))
|
|
97
|
+
self._scan.frame.grid(row=1, column=0, sticky="ew", padx=8, pady=4)
|
|
98
|
+
self._table.frame.grid(row=2, column=0, sticky="nsew", padx=8, pady=4)
|
|
99
|
+
ttk.Label(self.root, textvariable=self._status,
|
|
100
|
+
relief="sunken", anchor="w").grid(
|
|
101
|
+
row=3, column=0, sticky="ew", padx=8, pady=(0, 4))
|
|
102
|
+
|
|
103
|
+
# -- threading-safe worker dispatch -------------------------------------
|
|
104
|
+
|
|
105
|
+
def run_async(
|
|
106
|
+
self,
|
|
107
|
+
work: Callable[[], object],
|
|
108
|
+
on_done: Optional[Callable[[object], None]] = None,
|
|
109
|
+
on_error: Optional[Callable[[BaseException], None]] = None,
|
|
110
|
+
) -> threading.Thread:
|
|
111
|
+
"""Run ``work`` on a worker thread; marshal result back to the UI thread.
|
|
112
|
+
|
|
113
|
+
on_done/on_error run on the UI thread via root.after(0, ...).
|
|
114
|
+
"""
|
|
115
|
+
def runner() -> None:
|
|
116
|
+
try:
|
|
117
|
+
result = work()
|
|
118
|
+
except BaseException as e: # noqa: BLE001
|
|
119
|
+
if on_error:
|
|
120
|
+
self.root.after(0, lambda: on_error(e))
|
|
121
|
+
else:
|
|
122
|
+
self.root.after(0, lambda: self._fail(e))
|
|
123
|
+
return
|
|
124
|
+
if on_done:
|
|
125
|
+
self.root.after(0, lambda: on_done(result))
|
|
126
|
+
|
|
127
|
+
t = threading.Thread(target=runner, daemon=True)
|
|
128
|
+
t.start()
|
|
129
|
+
return t
|
|
130
|
+
|
|
131
|
+
# -- status / errors (UI thread) ----------------------------------------
|
|
132
|
+
|
|
133
|
+
def set_status(self, text: str) -> None:
|
|
134
|
+
self._status.set(text)
|
|
135
|
+
|
|
136
|
+
def _fail(self, e: BaseException) -> None:
|
|
137
|
+
self.set_status(f"error: {e}")
|
|
138
|
+
messagebox.showerror("ps5dbg-scanner", str(e))
|
|
139
|
+
|
|
140
|
+
# -- connection (called by ConnectionPanel) -----------------------------
|
|
141
|
+
|
|
142
|
+
def connect(self, host: str, port: int) -> None:
|
|
143
|
+
def work() -> str:
|
|
144
|
+
c = PS5Debug(host=host, port=port, timeout=10.0)
|
|
145
|
+
c.connect()
|
|
146
|
+
self._client = c
|
|
147
|
+
ver = c.version()
|
|
148
|
+
fw = c.fw_version()
|
|
149
|
+
# start the freeze supervisor against this client
|
|
150
|
+
self._freeze = FreezeManager(
|
|
151
|
+
writer=lambda a, d: c.write(self._pid or 0, a, d),
|
|
152
|
+
rate_hz=10.0,
|
|
153
|
+
on_error=lambda slot, exc: self.root.after(
|
|
154
|
+
0, lambda: self.set_status(f"freeze failed @ {slot.address:#x}: {exc}")),
|
|
155
|
+
)
|
|
156
|
+
self._freeze.start()
|
|
157
|
+
return f"{ver} (FW {fw // 100}.{fw % 100:02d})"
|
|
158
|
+
|
|
159
|
+
self.run_async(
|
|
160
|
+
work,
|
|
161
|
+
on_done=lambda label: (
|
|
162
|
+
self.set_status(f"connected: ps5debug-NG {label}"),
|
|
163
|
+
self._conn.on_connected(),
|
|
164
|
+
self._refresh_targets(),
|
|
165
|
+
),
|
|
166
|
+
on_error=lambda e: self._fail(e),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def client(self) -> Optional[PS5Debug]:
|
|
171
|
+
return self._client
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def pid(self) -> Optional[int]:
|
|
175
|
+
return self._pid
|
|
176
|
+
|
|
177
|
+
@pid.setter
|
|
178
|
+
def pid(self, value: Optional[int]) -> None:
|
|
179
|
+
self._pid = value
|
|
180
|
+
if self._freeze and self._client:
|
|
181
|
+
# update the freeze writer's target pid
|
|
182
|
+
self._freeze._writer = lambda a, d: self._client.write(value or 0, a, d) # type: ignore[union-attr]
|
|
183
|
+
self.set_status(f"target pid = {value}")
|
|
184
|
+
|
|
185
|
+
def _refresh_targets(self) -> None:
|
|
186
|
+
def work():
|
|
187
|
+
assert self._client is not None
|
|
188
|
+
fg = self._client.foreground_app()
|
|
189
|
+
procs = self._client.procs()
|
|
190
|
+
return fg, procs
|
|
191
|
+
self.run_async(work, on_done=lambda r: self._conn.populate_targets(*r))
|
|
192
|
+
|
|
193
|
+
# -- scanning (called by ScanPanel) -------------------------------------
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def value_type_name(self) -> str:
|
|
197
|
+
return self._value_type_name
|
|
198
|
+
|
|
199
|
+
def first_scan(self, value_type_name: str, scan_type_name: str, value_text: str) -> None:
|
|
200
|
+
if self._client is None or self._pid is None:
|
|
201
|
+
self._fail(RuntimeError("not connected / no target"))
|
|
202
|
+
return
|
|
203
|
+
vt, width = _VALUE_TYPES[value_type_name]
|
|
204
|
+
ct, needs_value = _SCAN_TYPES[scan_type_name]
|
|
205
|
+
# validate/encode in the UI thread so a bad value surfaces cleanly
|
|
206
|
+
# instead of crashing the Tk callback
|
|
207
|
+
try:
|
|
208
|
+
value = self._encode_value(value_type_name, value_text) if needs_value else 0
|
|
209
|
+
except ValueError as e:
|
|
210
|
+
self._fail(e)
|
|
211
|
+
return
|
|
212
|
+
self._value_type_name = value_type_name
|
|
213
|
+
self._value_length = width
|
|
214
|
+
self.set_status("first scan…")
|
|
215
|
+
|
|
216
|
+
def work():
|
|
217
|
+
assert self._client is not None
|
|
218
|
+
self._client.authenticate(flags=2)
|
|
219
|
+
# default to scanning the whole address space: pass pid's low page
|
|
220
|
+
# up; the resident scan wants a contiguous range. For v0.2 we scan
|
|
221
|
+
# each writable region and let the server keep one resident set.
|
|
222
|
+
# Simpler: scan the first RW region (where game state usually lives).
|
|
223
|
+
# (A multi-region variant is a v0.3 refinement.)
|
|
224
|
+
maps = self._client.maps(self._pid)
|
|
225
|
+
rw = [m for m in maps if "w" in m.perms and "x" not in m.perms]
|
|
226
|
+
if not rw:
|
|
227
|
+
raise RuntimeError("no writable data region found in target")
|
|
228
|
+
# pick the largest RW region — usually the main heap
|
|
229
|
+
region = max(rw, key=lambda m: m.end - m.start)
|
|
230
|
+
stored, count = ts.turboscan_start_resident(
|
|
231
|
+
self._client.connection, self._pid,
|
|
232
|
+
region.start, region.end - region.start,
|
|
233
|
+
vt, ct, width, value, ts.TS_SERVER_RESIDENT,
|
|
234
|
+
)
|
|
235
|
+
if not stored:
|
|
236
|
+
raise RuntimeError("server declined resident scan (too many hits?)")
|
|
237
|
+
self._session_active = True
|
|
238
|
+
self._survivor_count = count
|
|
239
|
+
self._table.set_session(vt, width, count)
|
|
240
|
+
return count
|
|
241
|
+
|
|
242
|
+
def on_done(count: int) -> None:
|
|
243
|
+
self.set_status(f"first scan: {count} survivors")
|
|
244
|
+
self._scan.on_scan_done(count)
|
|
245
|
+
self._table.refresh_window()
|
|
246
|
+
|
|
247
|
+
self.run_async(work, on_done=on_done, on_error=lambda e: self._fail(e))
|
|
248
|
+
|
|
249
|
+
def next_scan(self, scan_type_name: str, value_text: str) -> None:
|
|
250
|
+
if not self._session_active or self._client is None:
|
|
251
|
+
self._fail(RuntimeError("no active scan session — run First Scan first"))
|
|
252
|
+
return
|
|
253
|
+
vt, _ = _VALUE_TYPES[self._value_type_name]
|
|
254
|
+
ct, needs_value = _SCAN_TYPES[scan_type_name]
|
|
255
|
+
try:
|
|
256
|
+
value = self._encode_value(self._value_type_name, value_text) if needs_value else 0
|
|
257
|
+
except ValueError as e:
|
|
258
|
+
self._fail(e)
|
|
259
|
+
return
|
|
260
|
+
self.set_status("next scan…")
|
|
261
|
+
|
|
262
|
+
def work():
|
|
263
|
+
assert self._client is not None
|
|
264
|
+
count = ts.turboscan_count_resident(
|
|
265
|
+
self._client.connection, vt, ct, value,
|
|
266
|
+
)
|
|
267
|
+
self._survivor_count = count
|
|
268
|
+
return count
|
|
269
|
+
|
|
270
|
+
def on_done(count: int) -> None:
|
|
271
|
+
self.set_status(f"narrowed to {count} survivors")
|
|
272
|
+
self._scan.on_scan_done(count)
|
|
273
|
+
self._table.set_count(count)
|
|
274
|
+
self._table.refresh_window()
|
|
275
|
+
|
|
276
|
+
self.run_async(work, on_done=on_done, on_error=lambda e: self._fail(e))
|
|
277
|
+
|
|
278
|
+
def new_scan(self) -> None:
|
|
279
|
+
if self._session_active and self._client is not None:
|
|
280
|
+
def work():
|
|
281
|
+
ts.turboscan_end(self._client.connection) # type: ignore[union-attr]
|
|
282
|
+
self.run_async(work, on_done=lambda _: self._reset_session())
|
|
283
|
+
else:
|
|
284
|
+
self._reset_session()
|
|
285
|
+
|
|
286
|
+
def _reset_session(self) -> None:
|
|
287
|
+
self._session_active = False
|
|
288
|
+
self._survivor_count = 0
|
|
289
|
+
self._table.clear()
|
|
290
|
+
self.set_status("new scan")
|
|
291
|
+
self._scan.on_new_scan()
|
|
292
|
+
|
|
293
|
+
def fetch_survivors(self, start: int, count: int):
|
|
294
|
+
"""Called by ResultsTable on the UI thread; returns synchronously.
|
|
295
|
+
|
|
296
|
+
Used inside a worker thread spawned by the table itself.
|
|
297
|
+
"""
|
|
298
|
+
assert self._client is not None
|
|
299
|
+
return ts.turboscan_get_resident(
|
|
300
|
+
self._client.connection, start, count, self._value_length,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# -- freeze (called by ResultsTable) ------------------------------------
|
|
304
|
+
|
|
305
|
+
def freeze_address(self, address: int, value_bytes: bytes) -> None:
|
|
306
|
+
if self._freeze:
|
|
307
|
+
self._freeze.freeze(address, value_bytes)
|
|
308
|
+
self.set_status(f"froze {address:#x}")
|
|
309
|
+
|
|
310
|
+
def unfreeze_address(self, address: int) -> None:
|
|
311
|
+
if self._freeze:
|
|
312
|
+
self._freeze.unfreeze(address)
|
|
313
|
+
self.set_status(f"unfroze {address:#x}")
|
|
314
|
+
|
|
315
|
+
def is_frozen(self, address: int) -> bool:
|
|
316
|
+
return bool(self._freeze and self._freeze.is_frozen(address))
|
|
317
|
+
|
|
318
|
+
# -- value encoding ------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def _encode_value(value_type_name: str, text: str):
|
|
322
|
+
"""Parse a user-entered value string into the right Python type.
|
|
323
|
+
|
|
324
|
+
Float types accept decimal notation; integer types accept decimal or
|
|
325
|
+
0x hex. Raises ValueError with a usable message on bad input (the
|
|
326
|
+
caller surfaces it via _fail rather than crashing the callback).
|
|
327
|
+
"""
|
|
328
|
+
s = text.strip()
|
|
329
|
+
if not s:
|
|
330
|
+
raise ValueError("enter a value first (the scan type needs one)")
|
|
331
|
+
if value_type_name.startswith("f"):
|
|
332
|
+
return float(s) # raises ValueError on bad input, which we want
|
|
333
|
+
# integer types: allow 0x hex or decimal; int(s, 0) handles both
|
|
334
|
+
return int(s, 0)
|
|
335
|
+
|
|
336
|
+
# -- lifecycle -----------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def _on_close(self) -> None:
|
|
339
|
+
try:
|
|
340
|
+
if self._freeze:
|
|
341
|
+
self._freeze.stop()
|
|
342
|
+
if self._session_active and self._client is not None:
|
|
343
|
+
try:
|
|
344
|
+
ts.turboscan_end(self._client.connection)
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
if self._client is not None:
|
|
348
|
+
self._client.close()
|
|
349
|
+
finally:
|
|
350
|
+
self.root.destroy()
|
|
351
|
+
|
|
352
|
+
def run(self) -> None:
|
|
353
|
+
self.root.mainloop()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def main() -> int:
|
|
357
|
+
ScannerApp().run()
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Connection panel — host/port, connect, target picker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .app import ScannerApp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConnectionPanel:
|
|
14
|
+
"""Top bar: host/port entries, Connect button, target dropdown."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, parent: tk.Widget, app: "ScannerApp") -> None:
|
|
17
|
+
self.app = app
|
|
18
|
+
self.frame = ttk.LabelFrame(parent, text="Connection")
|
|
19
|
+
self.frame.columnconfigure(4, weight=1)
|
|
20
|
+
|
|
21
|
+
ttk.Label(self.frame, text="Host:").grid(row=0, column=0, padx=(8, 2), pady=6)
|
|
22
|
+
default_host = os.environ.get("PS5_HOST", "")
|
|
23
|
+
self.host_var = tk.StringVar(value=default_host)
|
|
24
|
+
self.host_entry = ttk.Entry(self.frame, textvariable=self.host_var, width=18)
|
|
25
|
+
self.host_entry.grid(row=0, column=1, padx=2, pady=6)
|
|
26
|
+
|
|
27
|
+
ttk.Label(self.frame, text="Port:").grid(row=0, column=2, padx=(6, 2), pady=6)
|
|
28
|
+
self.port_var = tk.StringVar(value=os.environ.get("PS5_PORT", "744"))
|
|
29
|
+
self.port_entry = ttk.Entry(self.frame, textvariable=self.port_var, width=6)
|
|
30
|
+
self.port_entry.grid(row=0, column=3, padx=2, pady=6)
|
|
31
|
+
|
|
32
|
+
self.connect_btn = ttk.Button(self.frame, text="Connect", command=self._on_connect)
|
|
33
|
+
self.connect_btn.grid(row=0, column=4, padx=8, pady=6, sticky="w")
|
|
34
|
+
|
|
35
|
+
ttk.Label(self.frame, text="Target:").grid(row=0, column=5, padx=(12, 2), pady=6)
|
|
36
|
+
self.target_var = tk.StringVar()
|
|
37
|
+
self.target_combo = ttk.Combobox(
|
|
38
|
+
self.frame, textvariable=self.target_var, state="readonly", width=44
|
|
39
|
+
)
|
|
40
|
+
self.target_combo.grid(row=0, column=6, padx=2, pady=6)
|
|
41
|
+
self.target_combo.bind("<<ComboboxSelected>>", self._on_target_selected)
|
|
42
|
+
self._targets: list[tuple[int, str, str]] = [] # (pid, name, titleid)
|
|
43
|
+
|
|
44
|
+
# -- callbacks -----------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def _on_connect(self) -> None:
|
|
47
|
+
host = self.host_var.get().strip()
|
|
48
|
+
if not host:
|
|
49
|
+
self.app.set_status("enter a host first")
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
port = int(self.port_var.get().strip())
|
|
53
|
+
except ValueError:
|
|
54
|
+
self.app.set_status("invalid port")
|
|
55
|
+
return
|
|
56
|
+
self.connect_btn.config(state="disabled")
|
|
57
|
+
self.app.set_status(f"connecting to {host}:{port}…")
|
|
58
|
+
self.app.connect(host, port)
|
|
59
|
+
|
|
60
|
+
def on_connected(self) -> None:
|
|
61
|
+
"""Called by app (UI thread) after a successful connect."""
|
|
62
|
+
self.connect_btn.config(text="Disconnect", state="normal",
|
|
63
|
+
command=self._on_disconnect)
|
|
64
|
+
|
|
65
|
+
def _on_disconnect(self) -> None:
|
|
66
|
+
# simple: tell user to restart for now (full disconnect/reconnect is v0.3 polish)
|
|
67
|
+
self.app.set_status("disconnect: close & reopen the app (v0.3 will add live reconnect)")
|
|
68
|
+
|
|
69
|
+
# -- targets -------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def populate_targets(self, fg, procs) -> None:
|
|
72
|
+
"""Populate the target dropdown. Runs on UI thread."""
|
|
73
|
+
self._targets = []
|
|
74
|
+
labels = []
|
|
75
|
+
# foreground app first (if any)
|
|
76
|
+
if fg.pid != 0:
|
|
77
|
+
self._targets.append((fg.pid, fg.name, fg.titleid))
|
|
78
|
+
labels.append(f"▶ {fg.name} (pid {fg.pid}, {fg.titleid}) [foreground]")
|
|
79
|
+
# then all procs, sorted, excluding kernel pid 0
|
|
80
|
+
for p in sorted(procs, key=lambda x: x.name.lower()):
|
|
81
|
+
if p.pid == 0:
|
|
82
|
+
continue
|
|
83
|
+
self._targets.append((p.pid, p.name, ""))
|
|
84
|
+
labels.append(f" {p.name} (pid {p.pid})")
|
|
85
|
+
self.target_combo["values"] = labels
|
|
86
|
+
if labels:
|
|
87
|
+
self.target_combo.current(0 if fg.pid != 0 else 0)
|
|
88
|
+
self._select_index(0)
|
|
89
|
+
|
|
90
|
+
def _on_target_selected(self, _evt) -> None:
|
|
91
|
+
self._select_index(self.target_combo.current())
|
|
92
|
+
|
|
93
|
+
def _select_index(self, idx: int) -> None:
|
|
94
|
+
if 0 <= idx < len(self._targets):
|
|
95
|
+
pid, name, _titleid = self._targets[idx]
|
|
96
|
+
self.app.pid = pid
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Freeze manager — keeps a set of addresses pinned to fixed values.
|
|
2
|
+
|
|
3
|
+
A single supervisor thread wakes at a configurable interval (default 10 Hz) and
|
|
4
|
+
re-writes every frozen address via the ps5dbg client. This is the classic
|
|
5
|
+
"freeze health/ammo at 999" feature.
|
|
6
|
+
|
|
7
|
+
The manager is pure logic + one background thread; it takes a write callable so
|
|
8
|
+
it's unit-testable with a fake. All ps5dbg calls happen on the supervisor
|
|
9
|
+
thread, never on the tkinter UI thread.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Callable, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FrozenSlot:
|
|
21
|
+
"""One frozen address: where, what value, and the encoded bytes to write."""
|
|
22
|
+
|
|
23
|
+
address: int
|
|
24
|
+
value_bytes: bytes
|
|
25
|
+
label: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FreezeManager:
|
|
29
|
+
"""Supervisor that periodically re-writes frozen addresses.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
writer: callable(address:int, data:bytes) -> None. Typically
|
|
33
|
+
``lambda a, d: client.write(pid, a, d)``. Raised exceptions are
|
|
34
|
+
swallowed and reported via ``on_error`` (a freeze failing should
|
|
35
|
+
not crash the supervisor or the UI).
|
|
36
|
+
rate_hz: how often per second to re-write. Default 10.
|
|
37
|
+
on_error: optional callback(slot, exc) invoked when a write raises.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
writer: Callable[[int, bytes], None],
|
|
43
|
+
rate_hz: float = 10.0,
|
|
44
|
+
on_error: Optional[Callable[[FrozenSlot, BaseException], None]] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._writer = writer
|
|
47
|
+
self._period = 1.0 / rate_hz if rate_hz > 0 else 0.1
|
|
48
|
+
self._on_error = on_error
|
|
49
|
+
self._slots: dict[int, FrozenSlot] = {}
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
self._stop = threading.Event()
|
|
52
|
+
self._thread: Optional[threading.Thread] = None
|
|
53
|
+
|
|
54
|
+
# -- slot management (UI thread calls these) ----------------------------
|
|
55
|
+
|
|
56
|
+
def freeze(self, address: int, value_bytes: bytes, label: str = "") -> None:
|
|
57
|
+
"""Add or replace a frozen slot. Idempotent on address."""
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._slots[address] = FrozenSlot(
|
|
60
|
+
address=address, value_bytes=value_bytes, label=label
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def unfreeze(self, address: int) -> None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
self._slots.pop(address, None)
|
|
66
|
+
|
|
67
|
+
def is_frozen(self, address: int) -> bool:
|
|
68
|
+
with self._lock:
|
|
69
|
+
return address in self._slots
|
|
70
|
+
|
|
71
|
+
def frozen_addresses(self) -> list[int]:
|
|
72
|
+
with self._lock:
|
|
73
|
+
return list(self._slots.keys())
|
|
74
|
+
|
|
75
|
+
def set_value(self, address: int, value_bytes: bytes) -> None:
|
|
76
|
+
"""Update the frozen value without toggling freeze state."""
|
|
77
|
+
with self._lock:
|
|
78
|
+
slot = self._slots.get(address)
|
|
79
|
+
if slot is not None:
|
|
80
|
+
self._slots[address] = FrozenSlot(
|
|
81
|
+
address=address, value_bytes=value_bytes, label=slot.label
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# -- supervisor thread ---------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def start(self) -> None:
|
|
87
|
+
if self._thread and self._thread.is_alive():
|
|
88
|
+
return
|
|
89
|
+
self._stop.clear()
|
|
90
|
+
self._thread = threading.Thread(
|
|
91
|
+
target=self._run, name="ps5dbg-scanner-freeze", daemon=True
|
|
92
|
+
)
|
|
93
|
+
self._thread.start()
|
|
94
|
+
|
|
95
|
+
def stop(self) -> None:
|
|
96
|
+
self._stop.set()
|
|
97
|
+
t = self._thread
|
|
98
|
+
self._thread = None
|
|
99
|
+
if t and t.is_alive():
|
|
100
|
+
t.join(timeout=1.0)
|
|
101
|
+
|
|
102
|
+
def _run(self) -> None:
|
|
103
|
+
while not self._stop.is_set():
|
|
104
|
+
with self._lock:
|
|
105
|
+
slots = list(self._slots.values())
|
|
106
|
+
for slot in slots:
|
|
107
|
+
if self._stop.is_set():
|
|
108
|
+
break
|
|
109
|
+
try:
|
|
110
|
+
self._writer(slot.address, slot.value_bytes)
|
|
111
|
+
except BaseException as e: # noqa: BLE001 — supervisor must survive
|
|
112
|
+
if self._on_error:
|
|
113
|
+
try:
|
|
114
|
+
self._on_error(slot, e)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
# precise-ish sleep, wake early on stop
|
|
118
|
+
self._stop.wait(self._period)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def rate_hz(self) -> float:
|
|
122
|
+
return 1.0 / self._period if self._period > 0 else 0.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["FreezeManager", "FrozenSlot"]
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Results table — virtualized ttk.Treeview over the server-resident survivor set.
|
|
2
|
+
|
|
3
|
+
The naive "insert every survivor as a row" approach freezes tkinter past ~50k
|
|
4
|
+
rows. This widget instead keeps a sliding window of rows (PAGE_SIZE) and
|
|
5
|
+
re-fills them as the user scrolls, fetching survivors from the PS5 on demand
|
|
6
|
+
via ``app.fetch_survivors(start, count)`` in a worker thread.
|
|
7
|
+
|
|
8
|
+
Columns: Address | Current | Previous | Frozen.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import tkinter as tk
|
|
14
|
+
from tkinter import ttk
|
|
15
|
+
from typing import TYPE_CHECKING, Optional
|
|
16
|
+
|
|
17
|
+
from ps5dbg.constants import ScanValueType
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .app import ScannerApp
|
|
21
|
+
|
|
22
|
+
PAGE_SIZE = 500 # rows materialized in the Treeview at once
|
|
23
|
+
MAX_ROWS = 5000 # cap to keep the scrollbar honest; full count shown in status
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResultsTable:
|
|
27
|
+
"""Virtualized survivor table. Fetches windows from the PS5."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, parent: tk.Widget, app: "ScannerApp") -> None:
|
|
30
|
+
self.app = app
|
|
31
|
+
self.frame = ttk.LabelFrame(parent, text="Results")
|
|
32
|
+
self.frame.columnconfigure(0, weight=1)
|
|
33
|
+
self.frame.rowconfigure(0, weight=1)
|
|
34
|
+
|
|
35
|
+
columns = ("address", "current", "previous", "frozen")
|
|
36
|
+
self.tree = ttk.Treeview(self.frame, columns=columns, show="headings",
|
|
37
|
+
selectmode="browse")
|
|
38
|
+
for col, text, width in (
|
|
39
|
+
("address", "Address", 160),
|
|
40
|
+
("current", "Current", 120),
|
|
41
|
+
("previous", "Previous", 120),
|
|
42
|
+
("frozen", "Frozen", 60),
|
|
43
|
+
):
|
|
44
|
+
self.tree.heading(col, text=text)
|
|
45
|
+
self.tree.column(col, width=width, anchor="w")
|
|
46
|
+
self.tree.grid(row=0, column=0, sticky="nsew")
|
|
47
|
+
|
|
48
|
+
vsb = ttk.Scrollbar(self.frame, orient="vertical", command=self.tree.yview)
|
|
49
|
+
vsb.grid(row=0, column=1, sticky="ns")
|
|
50
|
+
self.tree.configure(yscrollcommand=self._on_scroll)
|
|
51
|
+
self._vsb = vsb
|
|
52
|
+
|
|
53
|
+
# right-click context menu
|
|
54
|
+
self.menu = tk.Menu(self.tree, tearoff=0)
|
|
55
|
+
self.menu.add_command(label="Freeze / Unfreeze", command=self._toggle_freeze)
|
|
56
|
+
self.menu.add_command(label="Edit value…", command=self._edit_value)
|
|
57
|
+
self.menu.add_command(label="Copy address", command=self._copy_address)
|
|
58
|
+
self.tree.bind("<Button-3>", self._on_right_click)
|
|
59
|
+
|
|
60
|
+
# session state
|
|
61
|
+
self._value_type: ScanValueType = ScanValueType.UINT32
|
|
62
|
+
self._value_length: int = 4
|
|
63
|
+
self._count: int = 0
|
|
64
|
+
self._fetch_lock = threading.Lock()
|
|
65
|
+
self._last_fetch_start: int = -1
|
|
66
|
+
|
|
67
|
+
# -- session wiring (called by app on UI thread) ------------------------
|
|
68
|
+
|
|
69
|
+
def set_session(self, value_type: ScanValueType, value_length: int, count: int) -> None:
|
|
70
|
+
self._value_type = value_type
|
|
71
|
+
self._value_length = value_length
|
|
72
|
+
self._count = count
|
|
73
|
+
self.clear()
|
|
74
|
+
|
|
75
|
+
def set_count(self, count: int) -> None:
|
|
76
|
+
self._count = count
|
|
77
|
+
self.clear()
|
|
78
|
+
|
|
79
|
+
def clear(self) -> None:
|
|
80
|
+
self.tree.delete(*self.tree.get_children())
|
|
81
|
+
self._last_fetch_start = -1
|
|
82
|
+
|
|
83
|
+
# -- virtualized window fetch -------------------------------------------
|
|
84
|
+
|
|
85
|
+
def refresh_window(self, start: int = 0) -> None:
|
|
86
|
+
"""Fetch the [start, start+PAGE_SIZE) survivor window and re-fill rows.
|
|
87
|
+
|
|
88
|
+
Runs the fetch on a worker thread; re-fills the Treeview on the UI thread.
|
|
89
|
+
"""
|
|
90
|
+
if self._count == 0 or self.app.client is None:
|
|
91
|
+
return
|
|
92
|
+
start = max(0, min(start, max(0, self._count - 1)))
|
|
93
|
+
fetch_count = min(PAGE_SIZE, self._count - start)
|
|
94
|
+
if start == self._last_fetch_start:
|
|
95
|
+
return # already showing this window
|
|
96
|
+
|
|
97
|
+
def work():
|
|
98
|
+
return self.app.fetch_survivors(start, fetch_count)
|
|
99
|
+
|
|
100
|
+
def on_done(rows):
|
|
101
|
+
self._fill_rows(start, rows)
|
|
102
|
+
self._last_fetch_start = start
|
|
103
|
+
|
|
104
|
+
self.app.run_async(work, on_done=on_done)
|
|
105
|
+
|
|
106
|
+
def _fill_rows(self, start: int, rows) -> None:
|
|
107
|
+
"""Replace the Treeview contents with ``rows`` starting at ``start``."""
|
|
108
|
+
self.tree.delete(*self.tree.get_children())
|
|
109
|
+
for i, (addr, cur, prev, _first) in enumerate(rows):
|
|
110
|
+
idx = start + i
|
|
111
|
+
frozen = "🔒" if self.app.is_frozen(addr) else ""
|
|
112
|
+
self.tree.insert(
|
|
113
|
+
"", "end", iid=str(idx),
|
|
114
|
+
values=(f"{addr:#018x}", self._fmt(cur), self._fmt(prev), frozen),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _fmt(self, raw: bytes) -> str:
|
|
118
|
+
"""Format a raw value bytes according to the session's value type."""
|
|
119
|
+
try:
|
|
120
|
+
if self._value_type in (ScanValueType.UINT8, ScanValueType.UINT16,
|
|
121
|
+
ScanValueType.UINT32, ScanValueType.UINT64):
|
|
122
|
+
return str(int.from_bytes(raw, "little", signed=False))
|
|
123
|
+
if self._value_type in (ScanValueType.INT8, ScanValueType.INT16,
|
|
124
|
+
ScanValueType.INT32, ScanValueType.INT64):
|
|
125
|
+
return str(int.from_bytes(raw, "little", signed=True))
|
|
126
|
+
if self._value_type is ScanValueType.FLOAT:
|
|
127
|
+
import struct
|
|
128
|
+
return f"{struct.unpack('<f', raw)[0]:.4g}"
|
|
129
|
+
if self._value_type is ScanValueType.DOUBLE:
|
|
130
|
+
import struct
|
|
131
|
+
return f"{struct.unpack('<d', raw)[0]:.4g}"
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
return raw.hex()
|
|
135
|
+
|
|
136
|
+
# -- scroll handling -----------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _on_scroll(self, *args) -> None:
|
|
139
|
+
"""Treeview yview callback — detect when the user nears a window edge."""
|
|
140
|
+
self._vsb.set(*args)
|
|
141
|
+
if not args:
|
|
142
|
+
return
|
|
143
|
+
# args = (first, last); if last close to 1.0, page forward; first close to 0, back
|
|
144
|
+
first = float(args[0])
|
|
145
|
+
# figure out which survivor index is at the top of the visible area
|
|
146
|
+
children = self.tree.get_children()
|
|
147
|
+
if not children:
|
|
148
|
+
return
|
|
149
|
+
top_idx = int(children[0])
|
|
150
|
+
visible = len(children)
|
|
151
|
+
# if the user has scrolled to near the bottom of the current window, fetch next
|
|
152
|
+
if first > 0.7 and top_idx + visible < self._count:
|
|
153
|
+
self.refresh_window(top_idx + visible - 20)
|
|
154
|
+
elif first < 0.2 and top_idx > 0:
|
|
155
|
+
self.refresh_window(max(0, top_idx - PAGE_SIZE + 20))
|
|
156
|
+
|
|
157
|
+
# -- context menu actions -----------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _on_right_click(self, event) -> None:
|
|
160
|
+
row = self.tree.identify_row(event.y)
|
|
161
|
+
if row:
|
|
162
|
+
self.tree.selection_set(row)
|
|
163
|
+
self.menu.tk_popup(event.x_root, event.y_root)
|
|
164
|
+
|
|
165
|
+
def _selected_address(self) -> Optional[int]:
|
|
166
|
+
sel = self.tree.selection()
|
|
167
|
+
if not sel:
|
|
168
|
+
return None
|
|
169
|
+
idx = int(sel[0])
|
|
170
|
+
# we need the actual address; re-fetch this single survivor
|
|
171
|
+
if self.app.client is None:
|
|
172
|
+
return None
|
|
173
|
+
rows = self.app.fetch_survivors(idx, 1)
|
|
174
|
+
if not rows:
|
|
175
|
+
return None
|
|
176
|
+
return rows[0][0]
|
|
177
|
+
|
|
178
|
+
def _toggle_freeze(self) -> None:
|
|
179
|
+
addr = self._selected_address()
|
|
180
|
+
if addr is None:
|
|
181
|
+
return
|
|
182
|
+
if self.app.is_frozen(addr):
|
|
183
|
+
self.app.unfreeze_address(addr)
|
|
184
|
+
else:
|
|
185
|
+
# freeze at the current value
|
|
186
|
+
rows = self.app.fetch_survivors(int(self.tree.selection()[0]), 1)
|
|
187
|
+
if rows:
|
|
188
|
+
_, cur, _prev, _first = rows[0]
|
|
189
|
+
self.app.freeze_address(addr, cur)
|
|
190
|
+
# refresh the visible window to update the lock icon
|
|
191
|
+
self.refresh_window(self._last_fetch_start)
|
|
192
|
+
|
|
193
|
+
def _edit_value(self) -> None:
|
|
194
|
+
sel = self.tree.selection()
|
|
195
|
+
if not sel or self.app.client is None or self.app.pid is None:
|
|
196
|
+
return
|
|
197
|
+
idx = int(sel[0])
|
|
198
|
+
rows = self.app.fetch_survivors(idx, 1)
|
|
199
|
+
if not rows:
|
|
200
|
+
return
|
|
201
|
+
addr, cur, _prev, _first = rows[0]
|
|
202
|
+
new = self._prompt("New value", "Edit value", self._fmt(cur))
|
|
203
|
+
if new is None:
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
data = self._encode(new)
|
|
207
|
+
except ValueError:
|
|
208
|
+
from tkinter import messagebox
|
|
209
|
+
messagebox.showerror("ps5dbg-scanner", f"invalid value for {self._value_type.name}")
|
|
210
|
+
return
|
|
211
|
+
# write synchronously off the UI thread
|
|
212
|
+
def work():
|
|
213
|
+
self.app.client.write(self.app.pid, addr, data) # type: ignore[union-attr]
|
|
214
|
+
self.app.run_async(work, on_done=lambda _: (
|
|
215
|
+
self.app.set_status(f"wrote {data.hex()} -> {addr:#x}"),
|
|
216
|
+
self.refresh_window(self._last_fetch_start),
|
|
217
|
+
))
|
|
218
|
+
|
|
219
|
+
def _copy_address(self) -> None:
|
|
220
|
+
addr = self._selected_address()
|
|
221
|
+
if addr is None:
|
|
222
|
+
return
|
|
223
|
+
self.frame.clipboard_clear()
|
|
224
|
+
self.frame.clipboard_append(f"{addr:#x}")
|
|
225
|
+
self.app.set_status(f"copied {addr:#x}")
|
|
226
|
+
|
|
227
|
+
# -- helpers -------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _prompt(self, title, prompt, initial="") -> Optional[str]:
|
|
230
|
+
from tkinter import simpledialog
|
|
231
|
+
return simpledialog.askstring(title, prompt, initialvalue=initial,
|
|
232
|
+
parent=self.frame)
|
|
233
|
+
|
|
234
|
+
def _encode(self, text: str) -> bytes:
|
|
235
|
+
import struct
|
|
236
|
+
vt = self._value_type
|
|
237
|
+
n = int(text, 0) if not text.lstrip("-").replace(".", "", 1).isdigit() else (
|
|
238
|
+
float(text) if "." in text else int(text, 0)
|
|
239
|
+
)
|
|
240
|
+
if vt in (ScanValueType.UINT8,):
|
|
241
|
+
return struct.pack("<B", int(n) & 0xFF)
|
|
242
|
+
if vt in (ScanValueType.INT8,):
|
|
243
|
+
return struct.pack("<b", int(n))
|
|
244
|
+
if vt in (ScanValueType.UINT16,):
|
|
245
|
+
return struct.pack("<H", int(n) & 0xFFFF)
|
|
246
|
+
if vt in (ScanValueType.INT16,):
|
|
247
|
+
return struct.pack("<h", int(n))
|
|
248
|
+
if vt in (ScanValueType.UINT32,):
|
|
249
|
+
return struct.pack("<I", int(n) & 0xFFFFFFFF)
|
|
250
|
+
if vt in (ScanValueType.INT32,):
|
|
251
|
+
return struct.pack("<i", int(n))
|
|
252
|
+
if vt in (ScanValueType.UINT64,):
|
|
253
|
+
return struct.pack("<Q", int(n) & 0xFFFFFFFFFFFFFFFF)
|
|
254
|
+
if vt in (ScanValueType.INT64,):
|
|
255
|
+
return struct.pack("<q", int(n))
|
|
256
|
+
if vt is ScanValueType.FLOAT:
|
|
257
|
+
return struct.pack("<f", float(n))
|
|
258
|
+
if vt is ScanValueType.DOUBLE:
|
|
259
|
+
return struct.pack("<d", float(n))
|
|
260
|
+
raise ValueError(f"unsupported value type {vt}")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Scan panel — value type, scan type, value entry, scan buttons."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import tkinter as tk
|
|
5
|
+
from tkinter import ttk
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .app import ScannerApp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ScanPanel:
|
|
13
|
+
"""Middle bar: value-type / scan-type dropdowns, value entry, scan buttons."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
parent: tk.Widget,
|
|
18
|
+
app: "ScannerApp",
|
|
19
|
+
value_types: list[str],
|
|
20
|
+
scan_types: list[str],
|
|
21
|
+
) -> None:
|
|
22
|
+
self.app = app
|
|
23
|
+
self.frame = ttk.LabelFrame(parent, text="Scan")
|
|
24
|
+
self.frame.columnconfigure(3, weight=1)
|
|
25
|
+
|
|
26
|
+
ttk.Label(self.frame, text="Value type:").grid(row=0, column=0, padx=(8, 2), pady=8)
|
|
27
|
+
self.value_type_var = tk.StringVar(value="u32")
|
|
28
|
+
self.value_type_combo = ttk.Combobox(
|
|
29
|
+
self.frame, textvariable=self.value_type_var, state="readonly",
|
|
30
|
+
width=6, values=value_types,
|
|
31
|
+
)
|
|
32
|
+
self.value_type_combo.grid(row=0, column=1, padx=2, pady=8)
|
|
33
|
+
|
|
34
|
+
ttk.Label(self.frame, text="Scan type:").grid(row=0, column=2, padx=(10, 2), pady=8)
|
|
35
|
+
self.scan_type_var = tk.StringVar(value="Exact value")
|
|
36
|
+
self.scan_type_combo = ttk.Combobox(
|
|
37
|
+
self.frame, textvariable=self.scan_type_var, state="readonly",
|
|
38
|
+
width=22, values=scan_types,
|
|
39
|
+
)
|
|
40
|
+
self.scan_type_combo.grid(row=0, column=3, sticky="w", padx=2, pady=8)
|
|
41
|
+
self.scan_type_combo.bind("<<ComboboxSelected>>", self._on_scan_type_changed)
|
|
42
|
+
|
|
43
|
+
ttk.Label(self.frame, text="Value:").grid(row=0, column=4, padx=(10, 2), pady=8)
|
|
44
|
+
self.value_var = tk.StringVar()
|
|
45
|
+
self.value_entry = ttk.Entry(self.frame, textvariable=self.value_var, width=14)
|
|
46
|
+
self.value_entry.grid(row=0, column=5, padx=2, pady=8)
|
|
47
|
+
|
|
48
|
+
self.first_btn = ttk.Button(self.frame, text="First Scan", command=self._on_first)
|
|
49
|
+
self.first_btn.grid(row=0, column=6, padx=(10, 2), pady=8)
|
|
50
|
+
self.next_btn = ttk.Button(self.frame, text="Next Scan", command=self._on_next, state="disabled")
|
|
51
|
+
self.next_btn.grid(row=0, column=7, padx=2, pady=8)
|
|
52
|
+
self.new_btn = ttk.Button(self.frame, text="New Scan", command=self._on_new, state="disabled")
|
|
53
|
+
self.new_btn.grid(row=0, column=8, padx=(2, 8), pady=8)
|
|
54
|
+
|
|
55
|
+
self._set_buttons(post_first=False)
|
|
56
|
+
|
|
57
|
+
# -- callbacks -----------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _on_scan_type_changed(self, _evt) -> None:
|
|
60
|
+
# valueless scan types (Increased/Decreased/Changed/Unknown) disable the entry
|
|
61
|
+
valueless = {"Increased value", "Decreased value", "Changed value",
|
|
62
|
+
"Unchanged value", "Unknown initial value"}
|
|
63
|
+
state = "disabled" if self.scan_type_var.get() in valueless else "normal"
|
|
64
|
+
self.value_entry.config(state=state)
|
|
65
|
+
|
|
66
|
+
def _on_first(self) -> None:
|
|
67
|
+
self.app.first_scan(self.value_type_var.get(), self.scan_type_var.get(),
|
|
68
|
+
self.value_var.get())
|
|
69
|
+
|
|
70
|
+
def _on_next(self) -> None:
|
|
71
|
+
self.app.next_scan(self.scan_type_var.get(), self.value_var.get())
|
|
72
|
+
|
|
73
|
+
def _on_new(self) -> None:
|
|
74
|
+
self.app.new_scan()
|
|
75
|
+
|
|
76
|
+
# -- state transitions (called by app on UI thread) ---------------------
|
|
77
|
+
|
|
78
|
+
def on_scan_done(self, survivor_count: int) -> None:
|
|
79
|
+
self._set_buttons(post_first=True)
|
|
80
|
+
|
|
81
|
+
def on_new_scan(self) -> None:
|
|
82
|
+
self._set_buttons(post_first=False)
|
|
83
|
+
|
|
84
|
+
def _set_buttons(self, post_first: bool) -> None:
|
|
85
|
+
if post_first:
|
|
86
|
+
self.first_btn.config(state="disabled")
|
|
87
|
+
self.next_btn.config(state="normal")
|
|
88
|
+
self.new_btn.config(state="normal")
|
|
89
|
+
else:
|
|
90
|
+
self.first_btn.config(state="normal")
|
|
91
|
+
self.next_btn.config(state="disabled")
|
|
92
|
+
self.new_btn.config(state="disabled")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ps5dbg-scanner
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Cross-platform 'Cheat Engine for PS5' GUI built on the ps5dbg library.
|
|
5
|
+
Author: Darkatek7
|
|
6
|
+
License: GPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/Darkatek7/ps5dbg-scanner
|
|
8
|
+
Project-URL: Repository, https://github.com/Darkatek7/ps5dbg-scanner
|
|
9
|
+
Project-URL: Issues, https://github.com/Darkatek7/ps5dbg-scanner/issues
|
|
10
|
+
Keywords: ps5,playstation5,ps5debug,memory scanner,cheat engine,homebrew,jailbreak
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Games/Entertainment
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: ps5dbg>=0.1.1
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Requires-Dist: ps5dbg[dev]>=0.1.1; extra == "dev"
|
|
30
|
+
Provides-Extra: build
|
|
31
|
+
Requires-Dist: build; extra == "build"
|
|
32
|
+
Requires-Dist: twine; extra == "build"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# ps5dbg-scanner
|
|
36
|
+
|
|
37
|
+
> A cross-platform **"Cheat Engine for PS5"** built on the [`ps5dbg`](https://github.com/Darkatek7/ps5dbg) library. Scan a game's memory, narrow down to the values you care about, then freeze or edit them — from any OS, no Windows required.
|
|
38
|
+
|
|
39
|
+
[](https://www.python.org/)
|
|
40
|
+
[](./LICENSE)
|
|
41
|
+
[](#roadmap)
|
|
42
|
+
|
|
43
|
+
`ps5dbg-scanner` is a desktop GUI for the classic memory-editor loop:
|
|
44
|
+
|
|
45
|
+
1. **Pick the foreground app** (or any process) on your jailbroken PS5.
|
|
46
|
+
2. **First scan** — find all addresses holding a value (e.g. your current gold = 1000).
|
|
47
|
+
3. **Next scan** — change the value in-game, then narrow the survivor list (now it's 1050 → only addresses that went from 1000 → 1050).
|
|
48
|
+
4. Repeat until you have a handful of candidates, then **freeze** or **edit** the value.
|
|
49
|
+
|
|
50
|
+
It uses ps5debug-NG's **server-resident turbo scan**, so survivors live on the PS5 and narrow scans are fast even when the first scan returns hundreds of thousands of hits.
|
|
51
|
+
|
|
52
|
+
Runs anywhere Python 3.10+ runs (Linux, macOS, Windows) with **no extra runtime dependencies** beyond `ps5dbg` (tkinter ships with Python).
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Why it exists
|
|
57
|
+
|
|
58
|
+
Every existing PS5 memory scanner is **Windows-only** (`PS_Trainer_Viewer`, the PS4-era `PS4CheaterNeo`). Mac and Linux users have nothing. `ps5dbg-scanner` is the cross-platform answer — built on the [`ps5dbg`](https://pypi.org/project/ps5dbg/) library, which implements the full ps5debug-NG wire protocol.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install ps5dbg-scanner
|
|
66
|
+
# or
|
|
67
|
+
pipx install ps5dbg-scanner
|
|
68
|
+
|
|
69
|
+
# then run
|
|
70
|
+
export PS5_HOST=192.168.1.10 # your PS5's LAN IP
|
|
71
|
+
ps5dbg-scanner
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Requires a PS5 running [ps5debug-NG](https://github.com/OpenSourcereR-dev/ps5debug-NG) reachable on your LAN.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Quick start
|
|
79
|
+
|
|
80
|
+
1. Launch the app: `ps5dbg-scanner`
|
|
81
|
+
2. Enter your PS5's IP, click **Connect**.
|
|
82
|
+
3. Pick the target from the dropdown (the foreground app is marked `▶`).
|
|
83
|
+
4. Choose a **value type** (u32 is the common case for game state) and **scan type**.
|
|
84
|
+
5. Enter the current value, click **First Scan**.
|
|
85
|
+
6. Change the value in-game, enter the new value, click **Next Scan**.
|
|
86
|
+
7. Repeat until the survivor count is small.
|
|
87
|
+
8. Right-click a row → **Freeze** (lock it) or **Edit value**.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Features
|
|
92
|
+
|
|
93
|
+
- **Value scan** across u8/u16/u32/u64, i8/i16/i32/i64, float, double.
|
|
94
|
+
- **Scan types**: exact, bigger/smaller, increased/decreased, changed/unchanged, unknown-initial-value.
|
|
95
|
+
- **Server-resident narrow loop** — survivors stay on the PS5; no re-upload per scan.
|
|
96
|
+
- **Virtualized results table** — scrolls smoothly through millions of hits.
|
|
97
|
+
- **Freeze** values at a fixed number (the classic "infinite health" — re-written 10×/sec).
|
|
98
|
+
- **Edit** any survivor's value directly.
|
|
99
|
+
- **Foreground-app detection** — pick the running game with one click.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## How it works
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
┌─────────────────────────────────────────────┐
|
|
107
|
+
│ tkinter GUI (UI thread only) │
|
|
108
|
+
│ Connection panel Scan panel Results │
|
|
109
|
+
└──────────────────┬──────────────────────────┘
|
|
110
|
+
│ worker threads (blocking I/O off the UI thread)
|
|
111
|
+
▼
|
|
112
|
+
┌─────────────────┐
|
|
113
|
+
│ ps5dbg library │ ← PyPI: pip install ps5dbg
|
|
114
|
+
└────────┬────────┘
|
|
115
|
+
│ TCP wire protocol (port 744)
|
|
116
|
+
▼
|
|
117
|
+
┌─────────────────┐
|
|
118
|
+
│ PS5 (jailed) │
|
|
119
|
+
│ ps5debug-NG │
|
|
120
|
+
└─────────────────┘
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
All blocking network calls run off the UI thread; results are marshaled back via `root.after`. The scan engine uses the turbo-resident path so narrow scans stay fast at scale.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Requirements
|
|
128
|
+
|
|
129
|
+
- **Python** 3.10+ (tkinter ships with standard CPython).
|
|
130
|
+
- **ps5dbg** ≥ 0.1.0 (auto-installed as a dependency).
|
|
131
|
+
- **Console** any PS5 running ps5debug-NG with the turbo-scan family advertised.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Roadmap
|
|
136
|
+
|
|
137
|
+
- **v0.2** — core scanner: value scan → narrow → freeze/edit (this release, alpha)
|
|
138
|
+
- **v0.3** — hex viewer pane, AOB pattern scanner, bookmark/address-list manager
|
|
139
|
+
- **v0.4** — debugger UI (breakpoints/registers) on top of ps5dbg's `DebugSession`
|
|
140
|
+
|
|
141
|
+
Contributions welcome — see [`AGENTS.md`](./AGENTS.md) for architecture and conventions.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Acknowledgments
|
|
146
|
+
|
|
147
|
+
- **[ps5dbg](https://github.com/Darkatek7/ps5dbg)** — the library this is built on.
|
|
148
|
+
- **[OpenSourcereR-dev/ps5debug-NG](https://github.com/OpenSourcereR-dev/ps5debug-NG)** — the debugger payload and wire protocol.
|
|
149
|
+
- **[etaHEN](https://github.com/etaHEN/etaHEN)** — the HEN chain that loads it.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
[GPL-3.0-only](./LICENSE) — matching ps5dbg and ps5debug-NG.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
ps5dbg_scanner/__init__.py,sha256=Wy7pBDj2AhqyAVWXGNwdGDfgNjQfQEjSkXgAQiDqYig,308
|
|
2
|
+
ps5dbg_scanner/__main__.py,sha256=M7YujKbHQTdwcSIdRUsKTd5p-71mCUlqOaEneHVVbHg,161
|
|
3
|
+
ps5dbg_scanner/app.py,sha256=RjICsrjPQS4CeE1Usezssueu0XxjZNCRUG3UiU3Zfek,13760
|
|
4
|
+
ps5dbg_scanner/connection_panel.py,sha256=-u4S3FxIFGC3_UdziKTJsLPCG_J8PKZpcKaZMAZlavo,4053
|
|
5
|
+
ps5dbg_scanner/freeze_manager.py,sha256=2d8wf4z_VvRsu38jnvjysfzDpNAnANMxK4sx8LXPZsE,4307
|
|
6
|
+
ps5dbg_scanner/results_table.py,sha256=zbsVVCTkEaWHrKJAdYP0WkgQQySgcbODFHwyI8ERc6g,10384
|
|
7
|
+
ps5dbg_scanner/scan_panel.py,sha256=wVktXI9U88YMkzc1gjCFbOte-TXznOsxail0MvIIZ8w,3805
|
|
8
|
+
ps5dbg_scanner-0.2.0.dist-info/licenses/LICENSE,sha256=94ywdt7ckRTD9PK0pT2rDm65LFNFo7q-1G1KZGlW_wc,958
|
|
9
|
+
ps5dbg_scanner-0.2.0.dist-info/METADATA,sha256=9AG1SSY_c_8al3x-buXjXPdJDX05j5bCTRr741PcO8Q,6738
|
|
10
|
+
ps5dbg_scanner-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
ps5dbg_scanner-0.2.0.dist-info/entry_points.txt,sha256=YDbYUMdtl84N-izWmctBa1xNYmytXnFyavE53jtjgu0,59
|
|
12
|
+
ps5dbg_scanner-0.2.0.dist-info/top_level.txt,sha256=cbgQHV0pCsDiruadhmauLKi8-IPhMAyRqZboX-SjQPA,15
|
|
13
|
+
ps5dbg_scanner-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
GNU GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 29 June 2007
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
5
|
+
Everyone is permitted to copy and distribute verbatim copies
|
|
6
|
+
of this license document, but changing it is not allowed.
|
|
7
|
+
|
|
8
|
+
ps5dbg-scanner is licensed under GPL-3.0-only, matching the ps5dbg library
|
|
9
|
+
and ps5debug-NG it builds upon.
|
|
10
|
+
|
|
11
|
+
The full text of the GPL-3.0 is available at:
|
|
12
|
+
https://www.gnu.org/licenses/gpl-3.0.txt
|
|
13
|
+
|
|
14
|
+
This program is free software: you can redistribute it and/or modify
|
|
15
|
+
it under the terms of the GNU General Public License as published by
|
|
16
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
17
|
+
(at your option) any later version.
|
|
18
|
+
|
|
19
|
+
This program is distributed in the hope that it will be useful,
|
|
20
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
21
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
22
|
+
GNU General Public License for more details.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ps5dbg_scanner
|