PyMemoryEditor 3.0.2__tar.gz → 3.2.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.
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/CONTRIBUTING.md +21 -6
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/Makefile +42 -8
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PKG-INFO +3 -1
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/__init__.py +1 -1
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/_widgets.py +106 -27
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/application.py +72 -12
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/cheat_table.py +95 -23
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/log_console_dialog.py +2 -2
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/main_window.py +79 -24
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/memory_map_dialog.py +4 -14
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/memory_viewer_dialog.py +14 -15
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/modules_dialog.py +2 -3
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/open_process_dialog.py +2 -14
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/pointer_chain_dialog.py +18 -16
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/pointer_scan_dialog.py +24 -25
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/results_view.py +89 -15
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/scan_worker.py +2 -1
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/functions.py +177 -10
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/process.py +100 -12
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/functions.py +416 -166
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/libsystem.py +15 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/process.py +8 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/types.py +52 -1
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/__init__.py +4 -2
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/config.py +58 -34
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/policy.py +108 -16
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/server.py +48 -22
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/session.py +227 -58
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/toolset.py +435 -233
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/abstract.py +136 -33
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/pointer_scan.py +130 -19
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/region.py +1 -4
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/remote_pointer.py +11 -3
- pymemoryeditor-3.2.0/PyMemoryEditor/process/scanning.py +586 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/thread_info.py +6 -4
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/convert.py +51 -50
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/pattern.py +38 -6
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/scan.py +211 -80
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/scan_numpy.py +50 -26
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/functions.py +280 -87
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/process.py +70 -12
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/types.py +23 -16
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/memory-region.md +4 -3
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/openprocess.md +8 -3
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/pointer-path.md +8 -1
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/thread-info.md +10 -7
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/utilities.md +9 -5
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/app.md +3 -2
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/conf.py +1 -1
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/allocate-free.md +13 -10
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/memory-regions.md +3 -2
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/modules-threads.md +5 -2
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/pattern-scan.md +18 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/pointer-scan.md +7 -2
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/searching.md +42 -14
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/installation.md +4 -3
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/mcp.md +47 -20
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/platform-notes.md +50 -6
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/troubleshooting.md +26 -3
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/pyproject.toml +20 -4
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_cheat_entry.py +98 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_pointer_parsing.py +10 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_smoke.py +129 -1
- pymemoryeditor-3.2.0/tests/app/test_app_widgets.py +248 -0
- pymemoryeditor-3.2.0/tests/app/test_main_window_lifecycle.py +200 -0
- pymemoryeditor-3.2.0/tests/app/test_results_model.py +164 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/conftest.py +12 -2
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_argument_fuzz.py +3 -2
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_fake_fidelity.py +29 -1
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_live.py +14 -7
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_parsing.py +142 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_policy.py +108 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_server.py +142 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_toolset.py +816 -5
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_allocate_free.py +5 -4
- pymemoryeditor-3.2.0/tests/memory/test_search_argument_errors.py +69 -0
- pymemoryeditor-3.2.0/tests/memory/test_unaligned_scan.py +81 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_bitness_fallback_warns.py +1 -1
- pymemoryeditor-3.2.0/tests/platforms/test_linux_error_hints.py +110 -0
- pymemoryeditor-3.2.0/tests/platforms/test_linux_pid_reuse.py +214 -0
- pymemoryeditor-3.2.0/tests/platforms/test_linux_snapshot_passthrough.py +60 -0
- pymemoryeditor-3.2.0/tests/platforms/test_macos_load_commands.py +185 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_macos_protect.py +160 -0
- pymemoryeditor-3.2.0/tests/platforms/test_macos_region_walk.py +118 -0
- pymemoryeditor-3.2.0/tests/platforms/test_macos_snapshot.py +69 -0
- pymemoryeditor-3.2.0/tests/platforms/test_macos_threads.py +171 -0
- pymemoryeditor-3.2.0/tests/platforms/test_macos_types.py +97 -0
- pymemoryeditor-3.2.0/tests/platforms/test_win32_backend_guards.py +266 -0
- pymemoryeditor-3.2.0/tests/platforms/test_win32_region_walk.py +159 -0
- pymemoryeditor-3.2.0/tests/platforms/test_win32_toolhelp.py +124 -0
- pymemoryeditor-3.2.0/tests/platforms/test_win32_wow64.py +137 -0
- pymemoryeditor-3.2.0/tests/pointer/test_pointer_review_fixes.py +224 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/pointer/test_pointer_scan.py +148 -0
- pymemoryeditor-3.2.0/tests/process/test_foreign_process.py +95 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_chunking_integration.py +71 -17
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_scan.py +210 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_scan_properties.py +99 -11
- pymemoryeditor-3.2.0/tests/scan/test_scan_review_fixes.py +443 -0
- pymemoryeditor-3.0.2/PyMemoryEditor/process/scanning.py +0 -467
- pymemoryeditor-3.0.2/tests/app/test_app_widgets.py +0 -87
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/.flake8 +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/.gitignore +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/.readthedocs.yaml +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/CODE_OF_CONDUCT.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/LICENSE +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/__main__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/_auto_refresh_dialog.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/_icon.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/assets/icon.svg +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/cheat_entry.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/cheat_poll_worker.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/scan_types.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/scanner_panel.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/threads_dialog.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/value_types.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/enums.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/libc.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/types.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/__main__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/errors.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/info.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/module_info.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/util.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/py.typed +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/memory_allocation_states.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/memory_protections.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/memory_types.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/process_operations.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/standard_access_rights.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/README.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/SECURITY.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/assets/screenshots/app.png +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/assets/social-preview.html +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/assets/social-preview.png +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/_static/.gitkeep +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/_static/custom.css +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/_templates/layout.html +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/enums.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/errors.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/index.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/module-info.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/remote-pointer.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/contributing.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/funding.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/glossary.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/logging.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/opening-process.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/pointers.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/read-write.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/index.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/license.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/quickstart.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/requirements.txt +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/why.md +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_scan_request.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_value_types.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_auto_refresh_dialog.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_cheat_poll_worker.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_open_process_dialog.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_refine_scan_worker.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/conftest.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/core/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/core/test_errors.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/core/test_logger.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_registry.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_session.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_bufflength_inference.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_editor.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_narrow_width_consistency.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_partial_io.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_read_into.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_str_boundary.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_str_decode_consistency.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_typed_accessors.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_write_safety.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_write_span.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_write_str_bytes_width.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_bitness.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_linux_types.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_macos_transient_reads.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_win32_permissions.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/pointer/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/pointer/test_pointer_chain.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/pointer/test_remote_pointer.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_memory_region.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_module_enumeration.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_partial_name_match.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_process_enumeration.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_process_lookup.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_thread_enumeration.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/__init__.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_pattern_compile.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_pattern_scan.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_region_snapshot.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_scan_numpy.py +0 -0
- {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_scanning_helper.py +0 -0
|
@@ -22,9 +22,15 @@ The tests read and write the memory of the test process itself; they should run
|
|
|
22
22
|
on any supported platform without elevated privileges.
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
make test
|
|
25
|
+
make test-fast # pytest tests -m "not slow" (~20 s: unit, property and GUI tests)
|
|
26
|
+
make test # pytest tests -v (everything, incl. live whole-process scans)
|
|
27
|
+
make test-mcp # pytest tests/mcp_server (the MCP server suite, ~10 s)
|
|
26
28
|
```
|
|
27
29
|
|
|
30
|
+
`test-fast` is the loop to iterate on; the `slow` and `scan_heavy` markers
|
|
31
|
+
(defined in `pyproject.toml`) hold the tests that scan the test process's whole
|
|
32
|
+
address space, which takes minutes on CI runners.
|
|
33
|
+
|
|
28
34
|
## Linting
|
|
29
35
|
|
|
30
36
|
```bash
|
|
@@ -46,11 +52,13 @@ make pre-commit # lint + type-check + test
|
|
|
46
52
|
```
|
|
47
53
|
|
|
48
54
|
The CI pipeline runs the same lint, mypy and tests, and blocks merges on failure.
|
|
49
|
-
The
|
|
50
|
-
all three platform backends are exercised on every
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
The library matrix runs Python 3.10–3.15 on Ubuntu and Windows plus a single
|
|
56
|
+
Python 3.12 cell on macOS, so all three platform backends are exercised on every
|
|
57
|
+
push. Dedicated jobs also run the suite with the `speed` extra (NumPy) to keep
|
|
58
|
+
the vectorized scan path covered, and the MCP server suite once per OS. Even so,
|
|
59
|
+
contributors touching a specific backend are encouraged to run `pytest tests`
|
|
60
|
+
locally on that platform before submitting. `make ci` runs the same lint,
|
|
61
|
+
type-check and coverage-gated test invocations the workflow does.
|
|
54
62
|
|
|
55
63
|
## Project layout
|
|
56
64
|
|
|
@@ -63,6 +71,7 @@ PyMemoryEditor/
|
|
|
63
71
|
├── win32/ # Windows implementation (kernel32, user32)
|
|
64
72
|
├── linux/ # Linux implementation (process_vm_readv/writev, /proc/<pid>/maps)
|
|
65
73
|
├── macos/ # macOS implementation (task_for_pid, mach_vm_*)
|
|
74
|
+
├── mcp/ # Model Context Protocol server exposed as `pymemoryeditor-mcp`
|
|
66
75
|
└── app/ # PySide6 (Qt) demo app exposed as `pymemoryeditor` CLI
|
|
67
76
|
```
|
|
68
77
|
|
|
@@ -84,6 +93,12 @@ The public alias `OpenProcess` is chosen at import time in `__init__.py` based o
|
|
|
84
93
|
2. Branch from `main`. Keep commits focused.
|
|
85
94
|
3. Run `make pre-commit` (lint + type-check + tests) locally before pushing.
|
|
86
95
|
4. Open a PR describing the change and how it was tested.
|
|
96
|
+
5. Give the PR a [Conventional Commits](https://www.conventionalcommits.org/) title,
|
|
97
|
+
e.g. `fix: reject empty search targets` or `docs(mcp): document --allow-write`.
|
|
98
|
+
A required check lints it: the type must be one of `feat`, `fix`, `perf`,
|
|
99
|
+
`refactor`, `revert`, `docs`, `ci`, `build`, `chore`, `test`, `style`, and
|
|
100
|
+
the subject must start lowercase and not end with a period. PRs are
|
|
101
|
+
squash-merged, so the title becomes the commit message on `main`.
|
|
87
102
|
|
|
88
103
|
## Reporting bugs
|
|
89
104
|
|
|
@@ -30,7 +30,9 @@ help:
|
|
|
30
30
|
@echo " $(YELLOW)install-app$(NC) - Install dependencies to run the PyMemoryEditor App"
|
|
31
31
|
@echo " $(YELLOW)run-app$(NC) - Run the PyMemoryEditor App"
|
|
32
32
|
@echo " $(YELLOW)install-dev$(NC) - Install development dependencies"
|
|
33
|
-
@echo " $(YELLOW)test$(NC) - Run
|
|
33
|
+
@echo " $(YELLOW)test$(NC) - Run the full test suite (incl. live whole-process scans)"
|
|
34
|
+
@echo " $(YELLOW)test-fast$(NC) - Run the fast lane only (-m \"not slow\", ~20 s)"
|
|
35
|
+
@echo " $(YELLOW)test-mcp$(NC) - Run the MCP server suite"
|
|
34
36
|
@echo " $(YELLOW)test-verbose$(NC) - Run tests with verbose output"
|
|
35
37
|
@echo " $(YELLOW)test-coverage$(NC) - Run tests with coverage report"
|
|
36
38
|
@echo " $(YELLOW)lint$(NC) - Run linter (flake8)"
|
|
@@ -40,8 +42,9 @@ help:
|
|
|
40
42
|
@echo " $(YELLOW)build-wheel$(NC) - Build wheel package"
|
|
41
43
|
@echo " $(YELLOW)build-sdist$(NC) - Build source distribution"
|
|
42
44
|
@echo " $(YELLOW)validate$(NC) - Validate package"
|
|
43
|
-
@echo " $(YELLOW)publish$(NC) - Publish to PyPI"
|
|
45
|
+
@echo " $(YELLOW)publish$(NC) - Publish to PyPI by token (emergency path; releases go through GitHub Releases)"
|
|
44
46
|
@echo " $(YELLOW)publish-test$(NC) - Publish to Test PyPI"
|
|
47
|
+
@echo " $(YELLOW)ci$(NC) - Mirror the CI workflow: lint, type-check, both coverage-gated test runs"
|
|
45
48
|
@echo " $(YELLOW)version$(NC) - Show current version"
|
|
46
49
|
@echo " $(YELLOW)check-deps$(NC) - Check for outdated dependencies"
|
|
47
50
|
@echo " $(YELLOW)update-deps$(NC) - Update dependencies"
|
|
@@ -103,13 +106,30 @@ install:
|
|
|
103
106
|
$(PIP) install -e .
|
|
104
107
|
@echo "$(GREEN)Package installed successfully!$(NC)"
|
|
105
108
|
|
|
106
|
-
# Run tests
|
|
109
|
+
# Run tests (everything, including the live scans marked `slow`/`scan_heavy`)
|
|
107
110
|
.PHONY: test
|
|
108
111
|
test:
|
|
109
112
|
@echo "$(GREEN)Running tests...$(NC)"
|
|
110
113
|
$(PYTHON) -m pytest $(TEST_DIR) -v
|
|
111
114
|
@echo "$(GREEN)Tests completed!$(NC)"
|
|
112
115
|
|
|
116
|
+
# Fast lane: unit, property and GUI tests only (~20 s). The markers are
|
|
117
|
+
# defined in pyproject.toml.
|
|
118
|
+
.PHONY: test-fast
|
|
119
|
+
test-fast:
|
|
120
|
+
@echo "$(GREEN)Running the fast test lane (-m \"not slow\")...$(NC)"
|
|
121
|
+
$(PYTHON) -m pytest $(TEST_DIR) -m "not slow"
|
|
122
|
+
@echo "$(GREEN)Fast tests completed!$(NC)"
|
|
123
|
+
|
|
124
|
+
# The MCP server suite. CI runs it as its own job (`build-mcp`), with the
|
|
125
|
+
# pytest-qt plugin disabled because it imports Qt at startup and no test here
|
|
126
|
+
# needs it.
|
|
127
|
+
.PHONY: test-mcp
|
|
128
|
+
test-mcp:
|
|
129
|
+
@echo "$(GREEN)Running the MCP server tests...$(NC)"
|
|
130
|
+
$(PYTHON) -m pytest $(TEST_DIR)/mcp_server -p no:pytest-qt
|
|
131
|
+
@echo "$(GREEN)MCP tests completed!$(NC)"
|
|
132
|
+
|
|
113
133
|
# Run tests with verbose output
|
|
114
134
|
.PHONY: test-verbose
|
|
115
135
|
test-verbose:
|
|
@@ -185,7 +205,10 @@ validate: build
|
|
|
185
205
|
$(PYTHON) -m twine check $(DIST_DIR)/*
|
|
186
206
|
@echo "$(GREEN)Package validation completed!$(NC)"
|
|
187
207
|
|
|
188
|
-
# Publish to PyPI
|
|
208
|
+
# Publish to PyPI by API token. This is the emergency path only: releases are
|
|
209
|
+
# cut by publishing a GitHub Release, which .github/workflows/publish.yml
|
|
210
|
+
# uploads to PyPI via trusted publishing (OIDC, no token) and then lists on
|
|
211
|
+
# the MCP Registry. Use this target only if that workflow is unavailable.
|
|
189
212
|
.PHONY: publish
|
|
190
213
|
publish: validate
|
|
191
214
|
@echo "$(YELLOW)Are you sure you want to publish to PyPI? [y/N]$(NC)" && read ans && [ $${ans:-N} = y ]
|
|
@@ -238,11 +261,12 @@ install-docs:
|
|
|
238
261
|
$(PIP) install -e .
|
|
239
262
|
@echo "$(GREEN)Docs dependencies installed!$(NC)"
|
|
240
263
|
|
|
241
|
-
# Build the HTML documentation. Output lands in $(DOCS_BUILD_DIR).
|
|
264
|
+
# Build the HTML documentation. Output lands in $(DOCS_BUILD_DIR). Warnings
|
|
265
|
+
# are errors, the same as the `docs` job in python-package.yml.
|
|
242
266
|
.PHONY: docs
|
|
243
267
|
docs:
|
|
244
268
|
@echo "$(GREEN)Building HTML documentation...$(NC)"
|
|
245
|
-
$(PYTHON) -m sphinx -b html $(DOCS_DIR) $(DOCS_BUILD_DIR)
|
|
269
|
+
$(PYTHON) -m sphinx -W --keep-going -b html $(DOCS_DIR) $(DOCS_BUILD_DIR)
|
|
246
270
|
@echo "$(GREEN)Documentation generated at $(DOCS_BUILD_DIR)/index.html$(NC)"
|
|
247
271
|
|
|
248
272
|
# Live-reload docs server — rebuilds on every save and reloads the browser.
|
|
@@ -281,9 +305,19 @@ pre-commit: lint type-check test
|
|
|
281
305
|
pre-publish: all security
|
|
282
306
|
@echo "$(GREEN)Pre-publish checks completed!$(NC)"
|
|
283
307
|
|
|
284
|
-
# CI/CD targets
|
|
308
|
+
# CI/CD targets. Mirrors .github/workflows/python-package.yml: the library
|
|
309
|
+
# and app tests are gated on the library's coverage (.coveragerc-lib), the
|
|
310
|
+
# MCP suite on its own (.coveragerc-mcp), with the same floors as the
|
|
311
|
+
# workflow. Keep the two in sync when either changes.
|
|
285
312
|
.PHONY: ci
|
|
286
|
-
ci: install-dev install lint type-check
|
|
313
|
+
ci: install-dev install lint type-check
|
|
314
|
+
@echo "$(GREEN)Running the library/app suite with the CI coverage gate...$(NC)"
|
|
315
|
+
QT_QPA_PLATFORM=offscreen $(PYTHON) -m pytest $(TEST_DIR) --ignore=$(TEST_DIR)/mcp_server --maxfail=10 \
|
|
316
|
+
--cov=$(PACKAGE_NAME) --cov-config=.coveragerc-lib --cov-report=term --cov-fail-under=62
|
|
317
|
+
@echo "$(GREEN)Running the MCP suite with the CI coverage gate...$(NC)"
|
|
318
|
+
$(PYTHON) -m pytest $(TEST_DIR)/mcp_server -p no:pytest-qt --maxfail=10 \
|
|
319
|
+
--cov=$(PACKAGE_NAME)/mcp --cov-config=.coveragerc-mcp --cov-report=term --cov-fail-under=85
|
|
320
|
+
$(MAKE) build validate
|
|
287
321
|
@echo "$(GREEN)CI pipeline completed!$(NC)"
|
|
288
322
|
|
|
289
323
|
# Show package info
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: PyMemoryEditor
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Read, write and scan process memory in a few lines of Python — Cheat Engine-style scans, pointer chains and AOB search on Windows, Linux and macOS.
|
|
5
5
|
Project-URL: Homepage, https://github.com/JeanExtreme002/PyMemoryEditor
|
|
6
6
|
Project-URL: Documentation, https://pymemoryeditor.readthedocs.io
|
|
@@ -26,6 +26,8 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
26
26
|
Classifier: Programming Language :: Python :: 3.11
|
|
27
27
|
Classifier: Programming Language :: Python :: 3.12
|
|
28
28
|
Classifier: Programming Language :: Python :: 3.13
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
29
31
|
Classifier: Topic :: Games/Entertainment
|
|
30
32
|
Classifier: Topic :: Scientific/Engineering
|
|
31
33
|
Classifier: Topic :: Security
|
|
@@ -6,9 +6,10 @@ Centralises tiny helpers (numeric sort items, hex address parsing) that
|
|
|
6
6
|
previously appeared duplicated across several dialog modules.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import threading
|
|
9
10
|
from typing import Any, Callable, Iterable, List, Optional, Tuple
|
|
10
11
|
|
|
11
|
-
from PySide6.QtCore import QObject, Qt, QThread
|
|
12
|
+
from PySide6.QtCore import QCoreApplication, QObject, Qt, QThread, QTimer
|
|
12
13
|
from PySide6.QtGui import QStandardItem
|
|
13
14
|
|
|
14
15
|
|
|
@@ -84,6 +85,56 @@ def _reap_detached_worker(worker: QThread) -> None:
|
|
|
84
85
|
worker.deleteLater()
|
|
85
86
|
|
|
86
87
|
|
|
88
|
+
def detached_workers_running() -> bool:
|
|
89
|
+
"""Whether any worker that outlived its owner is still running."""
|
|
90
|
+
return any(worker.isRunning() for worker in _DETACHED_WORKERS)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_after_detached_workers(callback: Callable[[], None]) -> None:
|
|
94
|
+
"""Run ``callback`` once every currently detached worker has finished.
|
|
95
|
+
|
|
96
|
+
A detached worker can still be inside a backend syscall on the process it
|
|
97
|
+
was given, so releasing that process (``close()``) must wait for it: on
|
|
98
|
+
Windows the handle value can be recycled for an unrelated object the moment
|
|
99
|
+
it is closed, and a late write from the cheat poller would land there.
|
|
100
|
+
With no worker still running, ``callback`` runs immediately.
|
|
101
|
+
"""
|
|
102
|
+
pending = [worker for worker in _DETACHED_WORKERS if worker.isRunning()]
|
|
103
|
+
if not pending:
|
|
104
|
+
callback()
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
remaining = set(pending)
|
|
108
|
+
done = False
|
|
109
|
+
# `finished` can be delivered from the worker's own thread while the
|
|
110
|
+
# isFinished() sweep below runs on this one: guard the bookkeeping so the
|
|
111
|
+
# callback (a handle close) runs exactly once.
|
|
112
|
+
guard = threading.Lock()
|
|
113
|
+
# Whichever thread notices the last worker finishing, the callback is
|
|
114
|
+
# posted to the GUI thread (the application object lives there), so it
|
|
115
|
+
# never runs concurrently with the GUI setting up the next process.
|
|
116
|
+
gui = QCoreApplication.instance()
|
|
117
|
+
|
|
118
|
+
def _on_finished(worker: QThread) -> None:
|
|
119
|
+
nonlocal done
|
|
120
|
+
with guard:
|
|
121
|
+
remaining.discard(worker)
|
|
122
|
+
if remaining or done:
|
|
123
|
+
return
|
|
124
|
+
done = True
|
|
125
|
+
if gui is None:
|
|
126
|
+
callback()
|
|
127
|
+
else:
|
|
128
|
+
QTimer.singleShot(0, gui, callback)
|
|
129
|
+
|
|
130
|
+
for worker in pending:
|
|
131
|
+
worker.finished.connect(lambda w=worker: _on_finished(w))
|
|
132
|
+
# Same race as detach_worker: one may have finished before its connect.
|
|
133
|
+
for worker in pending:
|
|
134
|
+
if worker.isFinished():
|
|
135
|
+
_on_finished(worker)
|
|
136
|
+
|
|
137
|
+
|
|
87
138
|
class TearsDownOnClose:
|
|
88
139
|
"""Mixin that runs a dialog's teardown on *every* way out, not just close.
|
|
89
140
|
|
|
@@ -166,45 +217,76 @@ class NumericItem(QStandardItem):
|
|
|
166
217
|
return self.text() < other.text()
|
|
167
218
|
|
|
168
219
|
|
|
220
|
+
_HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_hex_digits(text: str) -> Optional[int]:
|
|
224
|
+
"""``text`` as an unsigned hex number, or None. Accepts a ``0x`` prefix."""
|
|
225
|
+
cleaned = text.strip()
|
|
226
|
+
if cleaned[:2].lower() == "0x":
|
|
227
|
+
cleaned = cleaned[2:]
|
|
228
|
+
# Explicit digit check rather than relying on int(): int(x, 16) also takes
|
|
229
|
+
# "1_0" (underscore separators) and leading sign/whitespace, none of which
|
|
230
|
+
# is a hex address a user meant to type.
|
|
231
|
+
if not cleaned or not _HEX_DIGITS.issuperset(cleaned):
|
|
232
|
+
return None
|
|
233
|
+
return int(cleaned, 16)
|
|
234
|
+
|
|
235
|
+
|
|
169
236
|
def parse_hex_address(text: str) -> Optional[int]:
|
|
170
237
|
"""Parse a hex address string (with or without 0x prefix) into an int.
|
|
171
238
|
|
|
172
|
-
|
|
239
|
+
Addresses are unsigned: a leading sign makes the string invalid. Returns
|
|
240
|
+
None on any parse error. Surrounding whitespace is tolerated.
|
|
173
241
|
"""
|
|
174
242
|
if not text:
|
|
175
243
|
return None
|
|
176
|
-
|
|
177
|
-
|
|
244
|
+
return _parse_hex_digits(text)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def parse_hex_offset(text: str) -> Optional[int]:
|
|
248
|
+
"""Parse a hex pointer-chain offset, which unlike an address may be negative.
|
|
249
|
+
|
|
250
|
+
``"-0x10"`` → -16, ``"10"`` → 16. Returns None on any parse error.
|
|
251
|
+
"""
|
|
252
|
+
if not text:
|
|
178
253
|
return None
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
254
|
+
cleaned = text.strip()
|
|
255
|
+
negative = cleaned.startswith("-")
|
|
256
|
+
if negative or cleaned.startswith("+"):
|
|
257
|
+
cleaned = cleaned[1:]
|
|
258
|
+
parsed = _parse_hex_digits(cleaned)
|
|
259
|
+
if parsed is None:
|
|
184
260
|
return None
|
|
261
|
+
return -parsed if negative else parsed
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def format_size(size: int) -> str:
|
|
265
|
+
"""Human-readable byte count (``1536`` → ``"1.5 KB"``), binary units."""
|
|
266
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
267
|
+
s = float(size)
|
|
268
|
+
for unit in units:
|
|
269
|
+
if s < 1024 or unit == units[-1]:
|
|
270
|
+
return f"{s:,.1f} {unit}" if unit != "B" else f"{int(s):,} B"
|
|
271
|
+
s /= 1024
|
|
272
|
+
return f"{size:,} B"
|
|
185
273
|
|
|
186
274
|
|
|
187
275
|
def parse_offsets(texts: Iterable[str]) -> Optional[List[int]]:
|
|
188
276
|
"""Parse pointer-chain offset tokens (in order) from raw field strings.
|
|
189
277
|
|
|
190
278
|
Empty tokens are skipped; every remaining token is read as hex (with or
|
|
191
|
-
without ``0x
|
|
192
|
-
the caller treats that as "invalid chain, do
|
|
193
|
-
can be unit-tested directly.
|
|
279
|
+
without ``0x``, optionally negative). Returns ``None`` if any non-empty
|
|
280
|
+
token can't be parsed — the caller treats that as "invalid chain, do
|
|
281
|
+
nothing". Pure (no Qt) so it can be unit-tested directly.
|
|
194
282
|
"""
|
|
195
283
|
offsets: List[int] = []
|
|
196
284
|
for text in texts:
|
|
197
285
|
if not text:
|
|
198
286
|
continue
|
|
199
|
-
parsed =
|
|
287
|
+
parsed = parse_hex_offset(text)
|
|
200
288
|
if parsed is None:
|
|
201
|
-
|
|
202
|
-
# int for ambiguous tokens like "10" (the dialog treats offsets as
|
|
203
|
-
# hex throughout).
|
|
204
|
-
try:
|
|
205
|
-
parsed = int(text, 16)
|
|
206
|
-
except ValueError:
|
|
207
|
-
return None
|
|
289
|
+
return None
|
|
208
290
|
offsets.append(parsed)
|
|
209
291
|
return offsets
|
|
210
292
|
|
|
@@ -230,13 +312,10 @@ def resolve_base_address(
|
|
|
230
312
|
module_name = name_part.strip().strip('"').strip("'").strip()
|
|
231
313
|
offset = parse_hex_address(offset_part)
|
|
232
314
|
if offset is None:
|
|
233
|
-
|
|
234
|
-
offset
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
"The offset after '+' must be hex "
|
|
238
|
-
'(e.g. "game.exe"+0x10F4F4).'
|
|
239
|
-
)
|
|
315
|
+
return None, (
|
|
316
|
+
"The offset after '+' must be hex "
|
|
317
|
+
'(e.g. "game.exe"+0x10F4F4).'
|
|
318
|
+
)
|
|
240
319
|
module_base = module_lookup(module_name)
|
|
241
320
|
if module_base is None:
|
|
242
321
|
return None, (
|
|
@@ -5,6 +5,7 @@ Entry point for the PyMemoryEditor App.
|
|
|
5
5
|
A Cheat-Engine-inspired memory scanner built on PySide6 (Qt for Python),
|
|
6
6
|
working on Windows, Linux and macOS.
|
|
7
7
|
"""
|
|
8
|
+
import logging
|
|
8
9
|
import sys
|
|
9
10
|
import signal
|
|
10
11
|
import contextlib
|
|
@@ -266,11 +267,6 @@ def apply_theme(app, theme_id: str = DEFAULT_THEME_ID) -> None:
|
|
|
266
267
|
)
|
|
267
268
|
|
|
268
269
|
|
|
269
|
-
def apply_dark_theme(app) -> None:
|
|
270
|
-
"""Backward-compatible shim that applies the default dark theme."""
|
|
271
|
-
apply_theme(app, DEFAULT_THEME_ID)
|
|
272
|
-
|
|
273
|
-
|
|
274
270
|
STYLE_SHEET = """
|
|
275
271
|
QToolTip {
|
|
276
272
|
color: %(text)s;
|
|
@@ -484,10 +480,66 @@ def _scoped_signal_handler(signalnum, handler):
|
|
|
484
480
|
signal.signal(signalnum, previous_handler)
|
|
485
481
|
|
|
486
482
|
|
|
483
|
+
_LOG = logging.getLogger(__name__)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _qt_excepthook(exc_type, exc_value, exc_tb) -> None:
|
|
487
|
+
"""``sys.excepthook`` for the duration of the Qt event loop.
|
|
488
|
+
|
|
489
|
+
PySide6 catches an exception raised inside a slot, hands it to
|
|
490
|
+
``sys.excepthook`` and carries on — with the default hook that is a
|
|
491
|
+
traceback on stderr the user never sees, and the action they clicked
|
|
492
|
+
silently does nothing. Log it (so the Log Console gets it) and show it.
|
|
493
|
+
KeyboardInterrupt is left to the interpreter's hook: it is the host's
|
|
494
|
+
signal, not an app bug.
|
|
495
|
+
"""
|
|
496
|
+
if issubclass(exc_type, KeyboardInterrupt):
|
|
497
|
+
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
|
498
|
+
return
|
|
499
|
+
_LOG.error(
|
|
500
|
+
"Unhandled exception in the UI", exc_info=(exc_type, exc_value, exc_tb)
|
|
501
|
+
)
|
|
502
|
+
try:
|
|
503
|
+
from PySide6.QtCore import QThread
|
|
504
|
+
from PySide6.QtWidgets import QApplication, QMessageBox
|
|
505
|
+
|
|
506
|
+
app = QApplication.instance()
|
|
507
|
+
# Widgets may only be created on the GUI thread. PySide6 routes an
|
|
508
|
+
# exception escaping a QThread.run() through this same hook, from
|
|
509
|
+
# that worker thread, so a dialog there would be undefined behaviour.
|
|
510
|
+
# The log line above is the whole report in that case.
|
|
511
|
+
if app is None or QThread.currentThread() != app.thread():
|
|
512
|
+
return
|
|
513
|
+
QMessageBox.critical(
|
|
514
|
+
None,
|
|
515
|
+
"Unexpected error",
|
|
516
|
+
"%s: %s\n\nThe full traceback is in Tools → Log Console."
|
|
517
|
+
% (exc_type.__name__, exc_value),
|
|
518
|
+
)
|
|
519
|
+
except Exception: # noqa: BLE001 — a hook must never raise
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@contextlib.contextmanager
|
|
524
|
+
def _scoped_excepthook(hook):
|
|
525
|
+
"""Install ``hook`` as ``sys.excepthook`` for the block, then restore."""
|
|
526
|
+
previous = sys.excepthook
|
|
527
|
+
sys.excepthook = hook
|
|
528
|
+
try:
|
|
529
|
+
yield
|
|
530
|
+
finally:
|
|
531
|
+
sys.excepthook = previous
|
|
532
|
+
|
|
533
|
+
|
|
487
534
|
def main(argv=None):
|
|
488
535
|
"""
|
|
489
536
|
Run the app. Safe to call in-process: it installs no signal handler.
|
|
490
537
|
|
|
538
|
+
While the event loop runs, ``sys.excepthook`` is swapped for one that logs
|
|
539
|
+
and shows slot exceptions (PySide6 routes them there and continues), and
|
|
540
|
+
restored on the way out — the loop owns the thread for that time, so an
|
|
541
|
+
embedder's own hook is not observable from inside it anyway.
|
|
542
|
+
|
|
491
543
|
``argv`` defaults to ``sys.argv`` so packaging tools (which call
|
|
492
544
|
``main()`` with no arguments) keep working. Tests and embedders can pass
|
|
493
545
|
an explicit list — previously a positional ``*args`` was accepted but
|
|
@@ -509,6 +561,7 @@ def main(argv=None):
|
|
|
509
561
|
from PySide6.QtCore import QSettings
|
|
510
562
|
from PySide6.QtWidgets import QApplication
|
|
511
563
|
|
|
564
|
+
from ._widgets import detached_workers_running
|
|
512
565
|
from .main_window import MainWindow
|
|
513
566
|
from .open_process_dialog import OpenProcessDialog
|
|
514
567
|
|
|
@@ -536,14 +589,21 @@ def main(argv=None):
|
|
|
536
589
|
window = MainWindow(process)
|
|
537
590
|
window.show()
|
|
538
591
|
try:
|
|
539
|
-
|
|
592
|
+
with _scoped_excepthook(_qt_excepthook):
|
|
593
|
+
app.exec()
|
|
540
594
|
finally:
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
595
|
+
# A worker that blew its join on close is detached, not stopped, and
|
|
596
|
+
# may still be inside a syscall on this handle; closing it now could
|
|
597
|
+
# hand its value to an unrelated object mid-write. The process is
|
|
598
|
+
# exiting, so leave it to the OS, which releases every handle then.
|
|
599
|
+
if not detached_workers_running():
|
|
600
|
+
try:
|
|
601
|
+
# `process` is only the handle the picker opened; File →
|
|
602
|
+
# Change Process… released that one and attached the window
|
|
603
|
+
# to another.
|
|
604
|
+
window.process.close()
|
|
605
|
+
except Exception:
|
|
606
|
+
pass
|
|
547
607
|
|
|
548
608
|
|
|
549
609
|
def main_cli(argv=None):
|
|
@@ -56,11 +56,6 @@ from .value_types import (
|
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
# Re-exported for backward compatibility with callers that imported the
|
|
60
|
-
# poll-interval constant from this module before the split.
|
|
61
|
-
_TICK_INTERVAL_MS = TICK_INTERVAL_MS
|
|
62
|
-
|
|
63
|
-
|
|
64
59
|
class CheatTable(QWidget):
|
|
65
60
|
"""Bottom pane: saved addresses, freezing, manual edits."""
|
|
66
61
|
|
|
@@ -80,6 +75,14 @@ class CheatTable(QWidget):
|
|
|
80
75
|
self._suspend_signals = False
|
|
81
76
|
# Before _build_ui(), which connects cellChanged — a path that reads it.
|
|
82
77
|
self._poller_stopped = False
|
|
78
|
+
# Set while the owner knows the target is gone (see set_polling_paused):
|
|
79
|
+
# the poller idles on an empty snapshot and edits are refused instead
|
|
80
|
+
# of being queued against a dead handle.
|
|
81
|
+
self._paused = False
|
|
82
|
+
# Write failures reported by the poller since the last flush. They are
|
|
83
|
+
# shown in one dialog per event-loop burst, so a bulk edit whose every
|
|
84
|
+
# row fails (target exited) produces one message, not one per row.
|
|
85
|
+
self._pending_write_failures: List[Tuple[int, str]] = []
|
|
83
86
|
|
|
84
87
|
self._build_ui()
|
|
85
88
|
|
|
@@ -130,6 +133,31 @@ class CheatTable(QWidget):
|
|
|
130
133
|
self.shutdown()
|
|
131
134
|
super().closeEvent(event)
|
|
132
135
|
|
|
136
|
+
def set_polling_paused(self, paused: bool) -> None:
|
|
137
|
+
"""Idle the poll loop while the target is known to be gone.
|
|
138
|
+
|
|
139
|
+
The owning window's heartbeat calls this when the process exits. The
|
|
140
|
+
publish timer stops and the worker is handed an empty snapshot, so it
|
|
141
|
+
sleeps through its ticks instead of hitting a dead handle ten times a
|
|
142
|
+
second (and, on a recycled PID, a stranger's memory). Entries are kept:
|
|
143
|
+
the table still shows the last values read. Resuming re-publishes.
|
|
144
|
+
"""
|
|
145
|
+
if self._poller_stopped or paused == self._paused:
|
|
146
|
+
return
|
|
147
|
+
self._paused = paused
|
|
148
|
+
if paused:
|
|
149
|
+
self._publish_timer.stop()
|
|
150
|
+
self._poller.update_snapshot([])
|
|
151
|
+
else:
|
|
152
|
+
self._publish_snapshot_to_worker()
|
|
153
|
+
self._publish_timer.start()
|
|
154
|
+
|
|
155
|
+
def is_polling_paused(self) -> bool:
|
|
156
|
+
return self._paused
|
|
157
|
+
|
|
158
|
+
def entry_count(self) -> int:
|
|
159
|
+
return len(self._entries)
|
|
160
|
+
|
|
133
161
|
def _build_ui(self) -> None:
|
|
134
162
|
layout = QVBoxLayout(self)
|
|
135
163
|
# Small top inset so the toolbar buttons don't sit flush against the
|
|
@@ -208,8 +236,17 @@ class CheatTable(QWidget):
|
|
|
208
236
|
# spotted from the table, so the floor is enforced once, at the door,
|
|
209
237
|
# rather than at each of those call sites. (The AOB pattern spec is the
|
|
210
238
|
# one whose declared length is 0 — the scanner derives its real width
|
|
211
|
-
# from the pattern.)
|
|
212
|
-
|
|
239
|
+
# from the pattern.) The ceiling is the same one every other width
|
|
240
|
+
# field enforces (MAX_ENTRY_LENGTH); only a JSON import can carry more,
|
|
241
|
+
# and silently resizing its entry would alter what the user exports
|
|
242
|
+
# next, so that is refused -- the import loop reports and skips it.
|
|
243
|
+
length = int(entry.length)
|
|
244
|
+
if length > MAX_ENTRY_LENGTH:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
"entry at 0x%X has length %d, over the %d-byte maximum"
|
|
247
|
+
% (entry.address, length, MAX_ENTRY_LENGTH)
|
|
248
|
+
)
|
|
249
|
+
entry.length = max(1, length)
|
|
213
250
|
|
|
214
251
|
# If the address already exists, just refresh its description/type.
|
|
215
252
|
for existing in self._entries:
|
|
@@ -236,7 +273,16 @@ class CheatTable(QWidget):
|
|
|
236
273
|
length: int,
|
|
237
274
|
description: str = "",
|
|
238
275
|
) -> None:
|
|
239
|
-
"""Convenience used by the scanner panel to bulk-promote rows.
|
|
276
|
+
"""Convenience used by the scanner panel to bulk-promote rows.
|
|
277
|
+
|
|
278
|
+
:raises ValueError: if ``length`` is over ``MAX_ENTRY_LENGTH`` -- checked
|
|
279
|
+
once up front so a refusal adds none of the rows rather than some.
|
|
280
|
+
"""
|
|
281
|
+
if int(length) > MAX_ENTRY_LENGTH:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
"length %d is over the %d-byte maximum for a cheat-table entry"
|
|
284
|
+
% (int(length), MAX_ENTRY_LENGTH)
|
|
285
|
+
)
|
|
240
286
|
for addr in addresses:
|
|
241
287
|
self.add_entry(
|
|
242
288
|
CheatEntry(
|
|
@@ -356,8 +402,9 @@ class CheatTable(QWidget):
|
|
|
356
402
|
# next poll tick reads the value back and corrects the cell if the
|
|
357
403
|
# write didn't take, and an outright failure returns via
|
|
358
404
|
# write_failed → _on_write_failed.
|
|
359
|
-
# Nothing to route it to once shutdown() handed the worker over
|
|
360
|
-
|
|
405
|
+
# Nothing to route it to once shutdown() handed the worker over,
|
|
406
|
+
# and nothing to write to while the target is known to be gone.
|
|
407
|
+
if self._poller_stopped or self._paused:
|
|
361
408
|
return
|
|
362
409
|
self._poller.request_write(
|
|
363
410
|
entry.address, entry.spec.pytype, entry.length, value
|
|
@@ -469,13 +516,33 @@ class CheatTable(QWidget):
|
|
|
469
516
|
def _on_write_failed(self, failure) -> None:
|
|
470
517
|
"""Surface a manual value write that failed on the worker thread.
|
|
471
518
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
519
|
+
Writes are async (queued onto the poll worker), so a failure arrives
|
|
520
|
+
here via the ``write_failed`` signal rather than inline. The optimistic
|
|
521
|
+
cell update made when the edit was queued is corrected by the next poll
|
|
522
|
+
tick, which reads back the unchanged value.
|
|
523
|
+
|
|
524
|
+
Failures are collected and shown by ``_flush_write_failures`` on the
|
|
525
|
+
next event-loop pass: the worker drains a bulk edit's queue in one
|
|
526
|
+
tick, so its failures land here back to back and read as one report.
|
|
476
527
|
"""
|
|
477
|
-
|
|
478
|
-
|
|
528
|
+
address, _pytype, _length, message = failure
|
|
529
|
+
if not self._pending_write_failures:
|
|
530
|
+
QTimer.singleShot(0, self._flush_write_failures)
|
|
531
|
+
self._pending_write_failures.append((address, message))
|
|
532
|
+
|
|
533
|
+
def _flush_write_failures(self) -> None:
|
|
534
|
+
failures = self._pending_write_failures
|
|
535
|
+
self._pending_write_failures = []
|
|
536
|
+
if not failures:
|
|
537
|
+
return
|
|
538
|
+
if len(failures) == 1:
|
|
539
|
+
QMessageBox.critical(self, "Write Failed", failures[0][1])
|
|
540
|
+
return
|
|
541
|
+
preview = "\n".join(f"0x{addr:X}: {msg}" for addr, msg in failures[:10])
|
|
542
|
+
extra = "" if len(failures) <= 10 else f"\n…and {len(failures) - 10} more."
|
|
543
|
+
QMessageBox.critical(
|
|
544
|
+
self, "Write Failed", f"{len(failures)} write(s) failed:\n\n{preview}{extra}"
|
|
545
|
+
)
|
|
479
546
|
|
|
480
547
|
def _editing_row(self) -> int:
|
|
481
548
|
"""Return the row currently being edited, or -1 if none."""
|
|
@@ -603,15 +670,20 @@ class CheatTable(QWidget):
|
|
|
603
670
|
if spec.accepts_length_override:
|
|
604
671
|
entry.length = effective_length
|
|
605
672
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
673
|
+
# Same path as an inline edit: the syscall runs on the poll
|
|
674
|
+
# worker, never on the UI thread (a slow target or a page
|
|
675
|
+
# fault would freeze the interface for every row). A write
|
|
676
|
+
# that fails comes back via write_failed → one coalesced
|
|
677
|
+
# dialog; the optimistic update below is corrected by the
|
|
678
|
+
# next tick's read-back.
|
|
679
|
+
if self._poller_stopped or self._paused:
|
|
611
680
|
failures.append(
|
|
612
|
-
(entry.address,
|
|
681
|
+
(entry.address, "the target process is no longer available")
|
|
613
682
|
)
|
|
614
683
|
continue
|
|
684
|
+
self._poller.request_write(
|
|
685
|
+
entry.address, spec.pytype, entry.length, value
|
|
686
|
+
)
|
|
615
687
|
|
|
616
688
|
entry.last_value = value
|
|
617
689
|
if entry.frozen:
|
|
@@ -873,7 +945,7 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]:
|
|
|
873
945
|
"Buffer length (bytes):",
|
|
874
946
|
value=spec.length or 4,
|
|
875
947
|
minValue=1,
|
|
876
|
-
maxValue=
|
|
948
|
+
maxValue=MAX_ENTRY_LENGTH,
|
|
877
949
|
)
|
|
878
950
|
if not ok:
|
|
879
951
|
return None
|