synth-ai 0.2.13.dev1__py3-none-any.whl → 0.2.14__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.
Potentially problematic release.
This version of synth-ai might be problematic. Click here for more details.
- examples/multi_step/configs/README_verilog_rl.md +77 -0
- examples/multi_step/configs/VERILOG_REWARDS.md +90 -0
- examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +183 -0
- examples/multi_step/configs/crafter_eval_synth_qwen4b.toml +35 -0
- examples/multi_step/configs/crafter_eval_text_only_groq_qwen32b.toml +36 -0
- examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +17 -5
- examples/multi_step/configs/crafter_synth_backend.md +40 -0
- examples/multi_step/configs/verilog_eval_groq_qwen32b.toml +31 -0
- examples/multi_step/configs/verilog_eval_synth_qwen8b.toml +33 -0
- examples/multi_step/configs/verilog_rl_lora.toml +190 -0
- examples/multi_step/judges/crafter_backend_judge.py +220 -0
- examples/multi_step/judges/verilog_backend_judge.py +234 -0
- examples/multi_step/readme.md +48 -0
- examples/multi_step/verilog_rl_lora.md +218 -0
- examples/qwen_coder/configs/coder_lora_30b.toml +1 -1
- examples/sft/evaluate.py +2 -0
- examples/sft/generate_traces.py +2 -0
- examples/swe/task_app/grpo_swe_mini.py +56 -26
- examples/swe/task_app/hosted/rollout.py +42 -0
- examples/swe/task_app/hosted/test_service.py +5 -6
- examples/task_apps/IMAGE_ONLY_EVAL_QUICKSTART.md +258 -0
- examples/task_apps/TESTING.md +275 -0
- examples/task_apps/__init__.py +0 -0
- examples/task_apps/crafter/CREATE_SFT_DATASET.md +273 -0
- examples/task_apps/crafter/EVAL_IMAGE_ONLY_RESULTS.md +152 -0
- examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +174 -0
- examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +268 -0
- examples/task_apps/crafter/QUERY_EXAMPLES.md +203 -0
- examples/task_apps/crafter/README_IMAGE_ONLY_EVAL.md +316 -0
- examples/task_apps/crafter/__init__.py +0 -0
- examples/task_apps/crafter/eval_image_only_gpt4o.toml +28 -0
- examples/task_apps/crafter/eval_text_only_groq_llama.toml +36 -0
- examples/task_apps/crafter/filter_sft_dataset.toml +16 -0
- examples/task_apps/crafter/task_app/__init__.py +5 -0
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter.py +324 -21
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter_task_app.py +1 -1
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/environment.py +10 -0
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/policy.py +76 -7
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/react_agent.py +17 -2
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/openai_client.py +25 -3
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/policy_routes.py +77 -4
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/rollout.py +117 -9
- examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_service.py +5 -6
- examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +218 -0
- examples/task_apps/dev/pokemon_emerald/__init__.py +2 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/README.md +811 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/__init__.py +120 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/action.py +160 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/memory.py +155 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/perception.py +69 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/planning.py +96 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/simple.py +1502 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/system_prompt.py +4 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/grab_map.py +68 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/manual.py +216 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/__init__.py +35 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emerald_utils.py +631 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emulator.py +1544 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/enums.py +1428 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/memory_reader.py +4848 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/types.py +41 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/utils.py +298 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pyproject.toml +95 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/run.py +204 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/__init__.py +0 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/app.py +2152 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/client.py +429 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/frame_server.py +155 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/README.md +78 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/__init__.py +0 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/run_tests.py +122 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_direct.py +76 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_prompts.py +413 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_battle_state_formatting.py +204 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection.py +133 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection_comprehensive.py +229 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_direct_agent_emulator.py +300 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_fps_adjustment_pytest.py +205 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_direct.py +200 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_transition.py +284 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_map_ground_truth_comparison.py +468 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_memory_map.py +575 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_server_map_validation.py +311 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_torchic_state.py +259 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/__init__.py +0 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/anticheat.py +372 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/checkpoint.py +296 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/error_handler.py +275 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/get_local_ip.py +22 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/helpers.py +44 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/llm_logger.py +514 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_formatter.py +415 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher.py +1763 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher_singleton.py +33 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_trimmer.py +106 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_visualizer.py +334 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/ocr_dialogue.py +1020 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/recording.py +188 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/state_formatter.py +1481 -0
- examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/vlm.py +862 -0
- examples/task_apps/dev/pokemon_emerald/modal_app.py +114 -0
- examples/task_apps/dev/pokemon_emerald/task_app/README.md +81 -0
- examples/task_apps/dev/pokemon_emerald/task_app/__init__.py +6 -0
- examples/task_apps/dev/pokemon_emerald/task_app/pokemon_emerald.py +685 -0
- examples/task_apps/enron/__init__.py +1 -0
- examples/task_apps/enron/eval_groq_qwen32.toml +16 -0
- examples/task_apps/enron/filter_sft.toml +5 -0
- examples/task_apps/enron/task_app/README.md +14 -0
- examples/task_apps/enron/task_app/__init__.py +1 -0
- examples/task_apps/enron/task_app/grpo_enron.py +906 -0
- examples/task_apps/enron/task_app/grpo_enron_task_app.py +146 -0
- examples/task_apps/enron/tests/__init__.py +4 -0
- examples/task_apps/enron/tests/conftest.py +115 -0
- examples/task_apps/enron/tests/integration/__init__.py +4 -0
- examples/task_apps/enron/tests/integration/test_enron_eval.py +179 -0
- examples/task_apps/enron/tests/integration/test_enron_rollout.py +135 -0
- examples/task_apps/enron/tests/unit/__init__.py +4 -0
- examples/task_apps/enron/tests/unit/test_enron_environment.py +126 -0
- examples/task_apps/math/__init__.py +0 -0
- examples/{rl/task_app → task_apps/math}/math_single_step.py +19 -10
- examples/task_apps/pokemon_battle/__init__.py +2 -0
- examples/task_apps/pokemon_battle/modal_app.py +104 -0
- examples/task_apps/pokemon_battle/task_app/README.md +68 -0
- examples/task_apps/pokemon_battle/task_app/__init__.py +6 -0
- examples/task_apps/pokemon_battle/task_app/pokemon_showdown.py +932 -0
- examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_COMPLETE.md +283 -0
- examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_STATUS.md +155 -0
- examples/task_apps/pokemon_red/README.md +357 -0
- examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +415 -0
- examples/task_apps/pokemon_red/__init__.py +3 -0
- examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +29 -0
- examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +225 -0
- examples/task_apps/pokemon_red/pallet_town_rl_config.toml +75 -0
- examples/task_apps/pokemon_red/task_app.py +799 -0
- examples/task_apps/pokemon_red/test_pallet_town_rewards.py +193 -0
- examples/task_apps/sokoban/README.md +307 -0
- examples/task_apps/sokoban/__init__.py +3 -0
- examples/task_apps/sokoban/eval_groq_qwen32.toml +16 -0
- examples/task_apps/sokoban/eval_openai_gpt5.toml +16 -0
- examples/task_apps/sokoban/filter_sft.toml +5 -0
- examples/task_apps/sokoban/task_app.py +1058 -0
- examples/task_apps/sokoban/tests/__init__.py +4 -0
- examples/task_apps/sokoban/tests/conftest.py +113 -0
- examples/task_apps/sokoban/tests/integration/__init__.py +4 -0
- examples/task_apps/sokoban/tests/integration/test_sokoban_eval.py +57 -0
- examples/task_apps/sokoban/tests/integration/test_sokoban_rollout.py +198 -0
- examples/task_apps/sokoban/tests/unit/__init__.py +4 -0
- examples/task_apps/sokoban/tests/unit/test_sokoban_environment.py +114 -0
- examples/task_apps/verilog/__init__.py +1 -0
- examples/task_apps/verilog/eval_groq_qwen32b.toml +24 -0
- examples/task_apps/verilog/filter_sft.toml +5 -0
- examples/task_apps/verilog/task_app/README.md +12 -0
- examples/task_apps/verilog/task_app/__init__.py +1 -0
- examples/task_apps/verilog/task_app/grpo_verilog.py +1166 -0
- examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +145 -0
- examples/task_apps/verilog/tests/__init__.py +4 -0
- examples/task_apps/verilog/tests/conftest.py +115 -0
- examples/task_apps/verilog/tests/integration/__init__.py +4 -0
- examples/task_apps/verilog/tests/integration/test_verilog_eval.py +181 -0
- examples/task_apps/verilog/tests/integration/test_verilog_rollout.py +55 -0
- examples/task_apps/verilog/tests/unit/__init__.py +4 -0
- examples/task_apps/verilog/tests/unit/test_verilog_scoring.py +118 -0
- examples/vlm/crafter_openai_vlm_agent.py +4 -4
- examples/vlm/run_crafter_vlm_benchmark.py +4 -4
- examples/warming_up_to_rl/groq_test.py +2 -0
- examples/warming_up_to_rl/run_local_rollout.py +2 -0
- examples/warming_up_to_rl/run_local_rollout_modal.py +2 -0
- examples/warming_up_to_rl/run_local_rollout_parallel.py +2 -0
- examples/warming_up_to_rl/run_local_rollout_traced.py +2 -0
- examples/warming_up_to_rl/run_rollout_remote.py +2 -0
- examples/workflows/__init__.py +0 -0
- examples/workflows/math_rl/__init__.py +0 -0
- examples/workflows/math_rl/download_dataset.py +80 -0
- synth_ai/__init__.py +2 -2
- synth_ai/api/models/supported.py +1 -0
- synth_ai/api/train/builders.py +25 -11
- synth_ai/api/train/cli.py +12 -6
- synth_ai/api/train/configs/__init__.py +10 -10
- synth_ai/api/train/configs/rl.py +5 -4
- synth_ai/api/train/configs/sft.py +4 -3
- synth_ai/api/train/env_resolver.py +5 -2
- synth_ai/api/train/supported_algos.py +10 -5
- synth_ai/api/train/utils.py +7 -4
- synth_ai/cli/__init__.py +48 -59
- synth_ai/cli/_modal_wrapper.py +3 -2
- synth_ai/cli/_storage.py +4 -3
- synth_ai/cli/_validate_task_app.py +11 -0
- synth_ai/cli/balance.py +4 -3
- synth_ai/cli/calc.py +2 -2
- synth_ai/cli/demo.py +14 -7
- synth_ai/cli/legacy_root_backup.py +1 -1
- synth_ai/cli/recent.py +1 -1
- synth_ai/cli/rl_demo.py +8 -7
- synth_ai/cli/root.py +0 -97
- synth_ai/cli/status.py +1 -1
- synth_ai/cli/task_apps.py +1922 -190
- synth_ai/cli/traces.py +1 -1
- synth_ai/cli/tui.py +57 -0
- synth_ai/cli/turso.py +1 -1
- synth_ai/cli/watch.py +1 -1
- synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +29 -17
- synth_ai/environments/examples/crafter_classic/environment.py +1 -1
- synth_ai/environments/examples/enron/engine.py +7 -2
- synth_ai/environments/examples/enron/environment.py +68 -0
- synth_ai/environments/examples/red/engine.py +27 -0
- synth_ai/environments/examples/red/engine_helpers/memory_map.py +7 -0
- synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_progression.py +477 -0
- synth_ai/environments/examples/red/engine_helpers/state_extraction.py +32 -0
- synth_ai/environments/examples/red/environment.py +60 -0
- synth_ai/environments/examples/sokoban/taskset.py +116 -0
- synth_ai/environments/examples/verilog/engine.py +104 -12
- synth_ai/evals/client.py +58 -61
- synth_ai/jobs/client.py +16 -4
- synth_ai/judge_schemas.py +9 -9
- synth_ai/py.typed +0 -0
- synth_ai/task/__init__.py +24 -5
- synth_ai/task/apps/__init__.py +1 -0
- synth_ai/task/config.py +257 -0
- synth_ai/task/contracts.py +138 -39
- synth_ai/task/proxy.py +48 -56
- synth_ai/task/rubrics/__init__.py +56 -0
- synth_ai/task/rubrics/loaders.py +152 -0
- synth_ai/task/rubrics/models.py +57 -0
- synth_ai/task/rubrics/scoring.py +116 -0
- synth_ai/{rubrics/validators.py → task/rubrics/strict.py} +53 -30
- synth_ai/task/server.py +8 -7
- synth_ai/task/trace_correlation_helpers.py +315 -0
- synth_ai/task/validators.py +413 -6
- synth_ai/tracing_v3/abstractions.py +3 -3
- synth_ai/tracing_v3/decorators.py +7 -3
- synth_ai/tracing_v3/llm_call_record_helpers.py +5 -5
- synth_ai/tracing_v3/replica_sync.py +4 -4
- synth_ai/tracing_v3/serialization.py +5 -5
- synth_ai/tracing_v3/session_tracer.py +16 -6
- synth_ai/tracing_v3/storage/base.py +29 -29
- synth_ai/tracing_v3/storage/config.py +3 -3
- synth_ai/tracing_v3/trace_utils.py +317 -0
- synth_ai/tracing_v3/turso/daemon.py +8 -7
- synth_ai/tracing_v3/turso/native_manager.py +66 -43
- synth_ai/tracing_v3/utils.py +3 -3
- synth_ai/tui/__init__.py +5 -0
- synth_ai/tui/__main__.py +13 -0
- synth_ai/tui/cli/__init__.py +1 -0
- synth_ai/tui/cli/query_experiments.py +164 -0
- synth_ai/tui/cli/query_experiments_v3.py +164 -0
- synth_ai/tui/dashboard.py +906 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/METADATA +4 -1
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/RECORD +278 -126
- examples/agora_ex/README_MoE.md +0 -224
- examples/agora_ex/__init__.py +0 -7
- examples/agora_ex/agora_ex.py +0 -65
- examples/agora_ex/agora_ex_task_app.py +0 -590
- examples/agora_ex/configs/rl_lora_qwen3_moe_2xh200.toml +0 -121
- examples/agora_ex/reward_fn_grpo-human.py +0 -129
- examples/agora_ex/system_prompt_CURRENT.md +0 -63
- examples/agora_ex/task_app/agora_ex_task_app.py +0 -590
- examples/agora_ex/task_app/reward_fn_grpo-human.py +0 -129
- examples/agora_ex/task_app/system_prompt_CURRENT.md +0 -63
- examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +0 -62
- synth_ai/rubrics/__init__.py +0 -22
- synth_ai/task/rubrics.py +0 -219
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/README.md +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/README.md +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/branching.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/environment_routes.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/app.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/shared.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/tools.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/hosted_app.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/main.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/registry.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/__init__.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/volume.py +0 -0
- /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_agents.py +0 -0
- /examples/{rl/task_app → task_apps/math}/README.md +0 -0
- /examples/{rl/task_app → task_apps/math}/math_task_app.py +0 -0
- /examples/{rl → workflows/math_rl}/configs/eval_base_qwen.toml +0 -0
- /examples/{rl → workflows/math_rl}/configs/eval_rl_qwen.toml +0 -0
- /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen.toml +0 -0
- /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen17.toml +0 -0
- /examples/{rl → workflows/math_rl}/configs/rl_from_ft_qwen.toml +0 -0
- /examples/{rl → workflows/math_rl}/run_eval.py +0 -0
- /examples/{rl → workflows/math_rl}/run_rl_and_save.py +0 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/top_level.txt +0 -0
examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/memory_reader.py
ADDED
|
@@ -0,0 +1,4848 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import struct
|
|
3
|
+
from typing import Optional, Dict, Any, List, Tuple
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from mgba._pylib import ffi, lib
|
|
8
|
+
|
|
9
|
+
from pokemon_env.emerald_utils import ADDRESSES, Pokemon_format, parse_pokemon, EmeraldCharmap
|
|
10
|
+
from .enums import MetatileBehavior, StatusCondition, Tileset, PokemonType, PokemonSpecies, Move, Badge, MapLocation
|
|
11
|
+
from .types import PokemonData
|
|
12
|
+
from utils.ocr_dialogue import create_ocr_detector
|
|
13
|
+
from utils import state_formatter
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MemoryAddresses:
|
|
19
|
+
"""Centralized memory address definitions for Pokemon Emerald; many unconfirmed"""
|
|
20
|
+
# Save Block 1 addresses (main save data)
|
|
21
|
+
SAVE_BLOCK1_BASE = 0x02025734
|
|
22
|
+
PLAYER_NAME = 0x02025734 # Start of Save Block 1
|
|
23
|
+
PLAYER_GENDER = 0x0202573B
|
|
24
|
+
PLAYER_TRAINER_ID = 0x0202573C
|
|
25
|
+
PLAYER_SECRET_ID = 0x0202573E
|
|
26
|
+
PLAYER_COINS = 0x02025744
|
|
27
|
+
PLAYER_BADGES = 0x02025748
|
|
28
|
+
|
|
29
|
+
# Savestate object addresses (for money and coordinates)
|
|
30
|
+
SAVESTATE_OBJECT_POINTER = 0x03005d8c
|
|
31
|
+
SAVESTATE_PLAYER_X_OFFSET = 0x00
|
|
32
|
+
SAVESTATE_PLAYER_Y_OFFSET = 0x02
|
|
33
|
+
SAVESTATE_PLAYER_FACING_OFFSET = 0x04 # Player facing direction (0=South, 1=North, 2=West, 3=East)
|
|
34
|
+
SAVESTATE_MONEY_OFFSET = 0x490
|
|
35
|
+
|
|
36
|
+
# Party Pokemon addresses (from emerald_utils.py)
|
|
37
|
+
PARTY_COUNT = 0x020244E9
|
|
38
|
+
PARTY_BASE = 0x020244EC
|
|
39
|
+
PARTY_POKEMON_SIZE = 100
|
|
40
|
+
|
|
41
|
+
# Pokemon data structure offsets
|
|
42
|
+
POKEMON_PID = 0x00
|
|
43
|
+
POKEMON_OTID = 0x04
|
|
44
|
+
POKEMON_NICKNAME = 0x08
|
|
45
|
+
POKEMON_OT_NAME = 0x14
|
|
46
|
+
POKEMON_ENCRYPTED_DATA = 0x20
|
|
47
|
+
POKEMON_STATUS = 0x50
|
|
48
|
+
POKEMON_LEVEL = 0x54
|
|
49
|
+
POKEMON_CURRENT_HP = 0x56
|
|
50
|
+
POKEMON_MAX_HP = 0x58
|
|
51
|
+
POKEMON_ATTACK = 0x5A
|
|
52
|
+
POKEMON_DEFENSE = 0x5C
|
|
53
|
+
POKEMON_SPEED = 0x5E
|
|
54
|
+
POKEMON_SP_ATTACK = 0x60
|
|
55
|
+
POKEMON_SP_DEFENSE = 0x62
|
|
56
|
+
|
|
57
|
+
# Game state addresses
|
|
58
|
+
GAME_STATE = 0x03005074
|
|
59
|
+
MENU_STATE = 0x03005078
|
|
60
|
+
DIALOG_STATE = 0x020370B8
|
|
61
|
+
IN_BATTLE_FLAG = 0x030026F9
|
|
62
|
+
IN_BATTLE_MASK = 0x02
|
|
63
|
+
|
|
64
|
+
# Map and location addresses
|
|
65
|
+
MAP_BANK = 0x020322E4
|
|
66
|
+
MAP_NUMBER = 0x020322E5
|
|
67
|
+
PLAYER_X = 0x02025734 # Relative to save block
|
|
68
|
+
PLAYER_Y = 0x02025736 # Relative to save block
|
|
69
|
+
|
|
70
|
+
# Item bag addresses
|
|
71
|
+
BAG_ITEMS = 0x02039888
|
|
72
|
+
BAG_ITEMS_COUNT = 0x0203988C
|
|
73
|
+
|
|
74
|
+
# Pokedex addresses
|
|
75
|
+
POKEDEX_CAUGHT = 0x0202A4B0
|
|
76
|
+
POKEDEX_SEEN = 0x0202A4B4
|
|
77
|
+
|
|
78
|
+
# Time addresses
|
|
79
|
+
GAME_TIME = 0x0202A4C0
|
|
80
|
+
|
|
81
|
+
# Security key for decryption
|
|
82
|
+
SECURITY_KEY_POINTER = 0x03005D90
|
|
83
|
+
SECURITY_KEY_OFFSET = 0x01F4
|
|
84
|
+
|
|
85
|
+
# Save block pointers (from emerald_utils.py)
|
|
86
|
+
SAVE_BLOCK1_PTR = 0x03005D8C
|
|
87
|
+
SAVE_BLOCK2_PTR = 0x03005D90
|
|
88
|
+
|
|
89
|
+
# Object Event addresses (NPCs/trainers)
|
|
90
|
+
OBJECT_EVENTS_COUNT = 16 # Max NPCs per map
|
|
91
|
+
OBJECT_EVENT_SIZE = 68 # Size of each ObjectEvent struct in memory (larger than saved version)
|
|
92
|
+
# gObjectEvents is at 0x02037230 for active map objects
|
|
93
|
+
|
|
94
|
+
# Battle addresses
|
|
95
|
+
BATTLE_TYPE = 0x02023E82
|
|
96
|
+
BATTLE_OUTCOME = 0x02023E84
|
|
97
|
+
BATTLE_FLAGS = 0x02023E8A
|
|
98
|
+
BATTLE_TURN = 0x02023E8C
|
|
99
|
+
|
|
100
|
+
# Enhanced battle detection addresses (following pokeemerald guide)
|
|
101
|
+
IN_BATTLE_BIT_ADDR = 0x030026F9 # gMain.inBattle location
|
|
102
|
+
IN_BATTLE_BITMASK = 0x02
|
|
103
|
+
BATTLE_TYPE_FLAGS = 0x02022AAE # gBattleTypeFlags for detailed battle characteristics
|
|
104
|
+
BATTLE_COMMUNICATION = 0x02024A60 # gBattleCommunication array for battle phases
|
|
105
|
+
|
|
106
|
+
# Enhanced dialogue/script detection addresses (following pokeemerald guide)
|
|
107
|
+
SCRIPT_CONTEXT_GLOBAL = 0x02037A58 # sGlobalScriptContext
|
|
108
|
+
SCRIPT_CONTEXT_IMMEDIATE = 0x02037A6C # sImmediateScriptContext
|
|
109
|
+
SCRIPT_MODE_OFFSET = 0x00 # Mode offset within ScriptContext
|
|
110
|
+
SCRIPT_STATUS_OFFSET = 0x02 # Status offset within ScriptContext
|
|
111
|
+
MSG_IS_SIGNPOST = 0x020370BC # gMsgIsSignPost
|
|
112
|
+
MSG_BOX_CANCELABLE = 0x020370BD # gMsgBoxIsCancelable
|
|
113
|
+
|
|
114
|
+
# Map layout addresses
|
|
115
|
+
MAP_HEADER = 0x02037318
|
|
116
|
+
MAP_LAYOUT_OFFSET = 0x00
|
|
117
|
+
PRIMARY_TILESET_OFFSET = 0x10
|
|
118
|
+
SECONDARY_TILESET_OFFSET = 0x14
|
|
119
|
+
|
|
120
|
+
# Text and dialog addresses (from Pokemon Emerald decompilation symbols)
|
|
121
|
+
# https://raw.githubusercontent.com/pret/pokeemerald/symbols/pokeemerald.sym
|
|
122
|
+
G_STRING_VAR1 = 0x02021cc4 # 256 bytes - Main string variable 1
|
|
123
|
+
G_STRING_VAR2 = 0x02021dc4 # 256 bytes - Main string variable 2
|
|
124
|
+
G_STRING_VAR3 = 0x02021ec4 # 256 bytes - Main string variable 3
|
|
125
|
+
G_STRING_VAR4 = 0x02021fc4 # 1000 bytes - Main string variable 4 (largest)
|
|
126
|
+
G_DISPLAYED_STRING_BATTLE = 0x02022e2c # 300 bytes - Battle dialog text
|
|
127
|
+
G_BATTLE_TEXT_BUFF1 = 0x02022f58 # 16 bytes - Battle text buffer 1
|
|
128
|
+
G_BATTLE_TEXT_BUFF2 = 0x02022f68 # 16 bytes - Battle text buffer 2
|
|
129
|
+
G_BATTLE_TEXT_BUFF3 = 0x02022f78 # 16 bytes - Battle text buffer 3
|
|
130
|
+
|
|
131
|
+
# Legacy text buffer addresses (keeping for compatibility)
|
|
132
|
+
TEXT_BUFFER_1 = 0x02021F18
|
|
133
|
+
TEXT_BUFFER_2 = 0x02021F20
|
|
134
|
+
TEXT_BUFFER_3 = 0x02021F28
|
|
135
|
+
TEXT_BUFFER_4 = 0x02021F30
|
|
136
|
+
|
|
137
|
+
# Flag addresses (from emerald_utils.py)
|
|
138
|
+
SCRIPT_FLAGS_START = 0x50
|
|
139
|
+
TRAINER_FLAGS_START = 0x500
|
|
140
|
+
SYSTEM_FLAGS_START = 0x860
|
|
141
|
+
DAILY_FLAGS_START = 0x920
|
|
142
|
+
|
|
143
|
+
# Save block addresses for flags
|
|
144
|
+
SAVE_BLOCK1_FLAGS_OFFSET = 0x1270 # Approximate offset for flags in SaveBlock1
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class PokemonDataStructure:
|
|
148
|
+
"""Pokemon data structure layout for proper decryption"""
|
|
149
|
+
# Unencrypted data (offsets from Pokemon base address)
|
|
150
|
+
PID = 0x00
|
|
151
|
+
OTID = 0x04
|
|
152
|
+
NICKNAME = 0x08
|
|
153
|
+
OT_NAME = 0x14
|
|
154
|
+
MARKINGS = 0x1C
|
|
155
|
+
CHECKSUM = 0x1C
|
|
156
|
+
|
|
157
|
+
# Encrypted data block (offsets from encrypted data start)
|
|
158
|
+
ENCRYPTED_START = 0x20
|
|
159
|
+
ENCRYPTED_SIZE = 48
|
|
160
|
+
|
|
161
|
+
# Encrypted data offsets
|
|
162
|
+
SPECIES = 0x00
|
|
163
|
+
HELD_ITEM = 0x02
|
|
164
|
+
EXPERIENCE = 0x04
|
|
165
|
+
PP_BONUSES = 0x08
|
|
166
|
+
FRIENDSHIP = 0x09
|
|
167
|
+
UNKNOWN = 0x0A
|
|
168
|
+
MOVES = 0x0C
|
|
169
|
+
PP = 0x18
|
|
170
|
+
HP_EV = 0x1A
|
|
171
|
+
ATTACK_EV = 0x1B
|
|
172
|
+
DEFENSE_EV = 0x1C
|
|
173
|
+
SPEED_EV = 0x1D
|
|
174
|
+
SP_ATTACK_EV = 0x1E
|
|
175
|
+
SP_DEFENSE_EV = 0x1F
|
|
176
|
+
COOL = 0x20
|
|
177
|
+
BEAUTY = 0x21
|
|
178
|
+
CUTE = 0x22
|
|
179
|
+
SMART = 0x23
|
|
180
|
+
TOUGH = 0x24
|
|
181
|
+
SHEEN = 0x25
|
|
182
|
+
POKERUS = 0x26
|
|
183
|
+
MET_LOCATION = 0x27
|
|
184
|
+
MET_LEVEL = 0x28
|
|
185
|
+
MET_GAME = 0x29
|
|
186
|
+
POKEBALL = 0x2A
|
|
187
|
+
OT_GENDER = 0x2B
|
|
188
|
+
IVS = 0x2C
|
|
189
|
+
ABILITY = 0x30
|
|
190
|
+
RIBBONS = 0x31
|
|
191
|
+
UNKNOWN2 = 0x32
|
|
192
|
+
|
|
193
|
+
class PokemonEmeraldReader:
|
|
194
|
+
"""Systematic memory reader for Pokemon Emerald with proper data structures"""
|
|
195
|
+
|
|
196
|
+
def __init__(self, core):
|
|
197
|
+
"""Initialize with a mGBA memory view object"""
|
|
198
|
+
self.core = core
|
|
199
|
+
self.memory = core.memory
|
|
200
|
+
self.addresses = MemoryAddresses()
|
|
201
|
+
self.pokemon_struct = PokemonDataStructure()
|
|
202
|
+
|
|
203
|
+
# Cache for tileset behaviors
|
|
204
|
+
self._cached_behaviors = None
|
|
205
|
+
self._cached_behaviors_map_key = None
|
|
206
|
+
|
|
207
|
+
# Map buffer cache
|
|
208
|
+
self._map_buffer_addr = None
|
|
209
|
+
self._map_width = None
|
|
210
|
+
self._map_height = None
|
|
211
|
+
|
|
212
|
+
# Area transition tracking
|
|
213
|
+
self._last_map_bank = None
|
|
214
|
+
self._last_map_number = None
|
|
215
|
+
|
|
216
|
+
# Add properties for battle detection (for debug endpoint compatibility)
|
|
217
|
+
self.IN_BATTLE_BIT_ADDR = self.addresses.IN_BATTLE_BIT_ADDR
|
|
218
|
+
self.IN_BATTLE_BITMASK = self.addresses.IN_BATTLE_BITMASK
|
|
219
|
+
|
|
220
|
+
self.core.add_frame_callback(self._invalidate_mem_cache)
|
|
221
|
+
self._mem_cache = {}
|
|
222
|
+
|
|
223
|
+
# Dialog detection timeout for residual text
|
|
224
|
+
self._dialog_text_start_time = None
|
|
225
|
+
self._dialog_text_timeout = 0.5 # 0.5 seconds timeout for residual text
|
|
226
|
+
|
|
227
|
+
# Dialog content tracking for FPS adjustment
|
|
228
|
+
self._last_dialog_content = None
|
|
229
|
+
self._dialog_fps_start_time = None
|
|
230
|
+
|
|
231
|
+
# Map stitching system (import on-demand to avoid circular import)
|
|
232
|
+
self._map_stitcher = None
|
|
233
|
+
self._dialog_fps_duration = 5.0 # Run at 120 FPS for 5 seconds when dialog detected
|
|
234
|
+
|
|
235
|
+
# Recent dialogue cache system to prevent residual text issues
|
|
236
|
+
self._dialogue_cache = {
|
|
237
|
+
'text': None,
|
|
238
|
+
'timestamp': None,
|
|
239
|
+
'is_active': False,
|
|
240
|
+
'detection_result': False
|
|
241
|
+
}
|
|
242
|
+
self._dialogue_cache_timeout = 3.0 # Clear dialogue cache after 3 seconds of inactivity
|
|
243
|
+
|
|
244
|
+
# Warning rate limiter to prevent spam
|
|
245
|
+
self._warning_cache = {}
|
|
246
|
+
self._warning_rate_limit = 10.0 # Only show same warning once per 10 seconds
|
|
247
|
+
|
|
248
|
+
# OCR-based dialogue detection fallback
|
|
249
|
+
self._ocr_detector = create_ocr_detector()
|
|
250
|
+
self._ocr_enabled = self._ocr_detector is not None
|
|
251
|
+
|
|
252
|
+
# Dialog detection control (can be disabled for no-ocr mode)
|
|
253
|
+
self._dialog_detection_enabled = True
|
|
254
|
+
|
|
255
|
+
# Track A button presses to prevent dialogue cache repopulation
|
|
256
|
+
self._a_button_pressed_time = 0.0
|
|
257
|
+
|
|
258
|
+
def _invalidate_mem_cache(self):
|
|
259
|
+
self._mem_cache = {}
|
|
260
|
+
|
|
261
|
+
def _rate_limited_warning(self, message, category="general"):
|
|
262
|
+
"""
|
|
263
|
+
Log a warning message with rate limiting to prevent spam.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
message: The warning message to log
|
|
267
|
+
category: Category for grouping similar warnings (optional)
|
|
268
|
+
"""
|
|
269
|
+
import time
|
|
270
|
+
current_time = time.time()
|
|
271
|
+
|
|
272
|
+
# Create a key for this warning category
|
|
273
|
+
warning_key = f"{category}:{message}"
|
|
274
|
+
|
|
275
|
+
# Check if we've warned about this recently
|
|
276
|
+
if warning_key in self._warning_cache:
|
|
277
|
+
last_warning_time = self._warning_cache[warning_key]
|
|
278
|
+
if current_time - last_warning_time < self._warning_rate_limit:
|
|
279
|
+
# Too recent, skip this warning
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
# Log the warning and update cache
|
|
283
|
+
logger.warning(message)
|
|
284
|
+
self._warning_cache[warning_key] = current_time
|
|
285
|
+
|
|
286
|
+
# Clean up old warnings from cache (optional optimization)
|
|
287
|
+
if len(self._warning_cache) > 50: # Prevent unbounded growth
|
|
288
|
+
# Remove warnings older than rate limit
|
|
289
|
+
expired_keys = [
|
|
290
|
+
key for key, timestamp in self._warning_cache.items()
|
|
291
|
+
if current_time - timestamp > self._warning_rate_limit * 2
|
|
292
|
+
]
|
|
293
|
+
for key in expired_keys:
|
|
294
|
+
del self._warning_cache[key]
|
|
295
|
+
|
|
296
|
+
def _read_u8(self, address: int):
|
|
297
|
+
return int.from_bytes(self.read_memory(address, 1), byteorder='little', signed=False)
|
|
298
|
+
|
|
299
|
+
def _read_u16(self, address: int):
|
|
300
|
+
return int.from_bytes(self.read_memory(address, 2), byteorder='little', signed=False)
|
|
301
|
+
|
|
302
|
+
def _read_s16(self, address: int):
|
|
303
|
+
return int.from_bytes(self.read_memory(address, 2), byteorder='little', signed=True)
|
|
304
|
+
|
|
305
|
+
def _read_u32(self, address: int):
|
|
306
|
+
return int.from_bytes(self.read_memory(address, 4), byteorder='little', signed=False)
|
|
307
|
+
|
|
308
|
+
def _read_bytes(self, address: int, length: int) -> bytes:
|
|
309
|
+
"""Read a sequence of bytes from memory"""
|
|
310
|
+
try:
|
|
311
|
+
result = bytearray()
|
|
312
|
+
for i in range(length):
|
|
313
|
+
result.append(self._read_u8(address + i))
|
|
314
|
+
return bytes(result)
|
|
315
|
+
except Exception as e:
|
|
316
|
+
logger.warning(f"Failed to read {length} bytes at 0x{address:08X}: {e}")
|
|
317
|
+
return b'\x00' * length
|
|
318
|
+
|
|
319
|
+
def _get_security_key(self) -> int:
|
|
320
|
+
"""Get the security key for decrypting encrypted data"""
|
|
321
|
+
try:
|
|
322
|
+
base_pointer = self._read_u32(self.addresses.SECURITY_KEY_POINTER)
|
|
323
|
+
if base_pointer == 0:
|
|
324
|
+
self._rate_limited_warning("Security key base pointer is null", "security_pointer")
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
security_key_addr = base_pointer + self.addresses.SECURITY_KEY_OFFSET
|
|
328
|
+
return self._read_u32(security_key_addr)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.warning(f"Failed to read security key: {e}")
|
|
331
|
+
return 0
|
|
332
|
+
|
|
333
|
+
def _decrypt_data(self, encrypted_data: bytes, pid: int, otid: int) -> bytes:
|
|
334
|
+
"""Decrypt Pokemon data using PID and OTID"""
|
|
335
|
+
if len(encrypted_data) != self.pokemon_struct.ENCRYPTED_SIZE:
|
|
336
|
+
logger.warning(f"Invalid encrypted data size: {len(encrypted_data)}")
|
|
337
|
+
return encrypted_data
|
|
338
|
+
|
|
339
|
+
# Calculate decryption key
|
|
340
|
+
key = pid ^ otid
|
|
341
|
+
|
|
342
|
+
# Decrypt data
|
|
343
|
+
decrypted = bytearray()
|
|
344
|
+
for i, byte in enumerate(encrypted_data):
|
|
345
|
+
decrypted_byte = byte ^ ((key >> (8 * (i % 4))) & 0xFF)
|
|
346
|
+
decrypted.append(decrypted_byte)
|
|
347
|
+
|
|
348
|
+
return bytes(decrypted)
|
|
349
|
+
|
|
350
|
+
def _decode_pokemon_text(self, byte_array: bytes) -> str:
|
|
351
|
+
"""Decode Pokemon text using proper character mapping"""
|
|
352
|
+
if not byte_array:
|
|
353
|
+
return ""
|
|
354
|
+
|
|
355
|
+
# Use the proper EmeraldCharmap from emerald_utils.py
|
|
356
|
+
charmap = EmeraldCharmap()
|
|
357
|
+
return charmap.decode(byte_array)
|
|
358
|
+
|
|
359
|
+
def read_player_name(self) -> str:
|
|
360
|
+
"""Read player name from Save Block 2"""
|
|
361
|
+
try:
|
|
362
|
+
# Get SaveBlock2 pointer
|
|
363
|
+
save_block_2_ptr = self._read_u32(self.addresses.SAVE_BLOCK2_PTR)
|
|
364
|
+
if save_block_2_ptr == 0:
|
|
365
|
+
self._rate_limited_warning("SaveBlock2 pointer is null", "saveblock_pointer")
|
|
366
|
+
return "Player"
|
|
367
|
+
|
|
368
|
+
# Player name is at the start of SaveBlock2 (7 bytes + 1 padding)
|
|
369
|
+
name_bytes = self._read_bytes(save_block_2_ptr, 8)
|
|
370
|
+
decoded_name = self._decode_pokemon_text(name_bytes)
|
|
371
|
+
|
|
372
|
+
if decoded_name and len(decoded_name) >= 2:
|
|
373
|
+
logger.info(f"Read player name: '{decoded_name}'")
|
|
374
|
+
return decoded_name
|
|
375
|
+
|
|
376
|
+
logger.warning("Could not read valid player name")
|
|
377
|
+
return "Player"
|
|
378
|
+
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.warning(f"Failed to read player name: {e}")
|
|
381
|
+
return "Player"
|
|
382
|
+
|
|
383
|
+
def read_money(self) -> int:
|
|
384
|
+
"""Read player's money with proper decryption"""
|
|
385
|
+
try:
|
|
386
|
+
# Read the base pointer from SAVESTATE_OBJECT_POINTER_ADDR
|
|
387
|
+
base_pointer = self._read_u32(self.addresses.SAVESTATE_OBJECT_POINTER)
|
|
388
|
+
if base_pointer == 0:
|
|
389
|
+
self._rate_limited_warning("Player object base pointer is null", "player_pointer")
|
|
390
|
+
return 0
|
|
391
|
+
|
|
392
|
+
# Calculate the actual address of the encrypted money value
|
|
393
|
+
encrypted_money_addr = base_pointer + self.addresses.SAVESTATE_MONEY_OFFSET
|
|
394
|
+
|
|
395
|
+
# Read the 32-bit encrypted money value
|
|
396
|
+
encrypted_money = self._read_u32(encrypted_money_addr)
|
|
397
|
+
|
|
398
|
+
# Get the security key for decryption
|
|
399
|
+
security_key = self._get_security_key()
|
|
400
|
+
if security_key == 0:
|
|
401
|
+
self._rate_limited_warning("Could not get security key for money decryption", "money_decrypt")
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
# Decrypt the money value by XORing with the security key
|
|
405
|
+
decrypted_money = encrypted_money ^ security_key
|
|
406
|
+
|
|
407
|
+
return decrypted_money & 0xFFFFFFFF
|
|
408
|
+
|
|
409
|
+
except Exception as e:
|
|
410
|
+
logger.warning(f"Failed to read money: {e}")
|
|
411
|
+
return 0
|
|
412
|
+
|
|
413
|
+
def read_party_size(self) -> int:
|
|
414
|
+
"""Read number of Pokemon in party"""
|
|
415
|
+
try:
|
|
416
|
+
# Read party count directly from the dedicated address
|
|
417
|
+
party_count = int(self._read_u8(self.addresses.PARTY_COUNT))
|
|
418
|
+
logger.info(f"Read party count: {party_count}")
|
|
419
|
+
return party_count
|
|
420
|
+
except Exception as e:
|
|
421
|
+
logger.warning(f"Failed to read party size: {e}")
|
|
422
|
+
return 0
|
|
423
|
+
|
|
424
|
+
def _get_memory_region(self, region_id: int, force_refresh: bool = False):
|
|
425
|
+
if force_refresh or region_id not in self._mem_cache:
|
|
426
|
+
mem_core = self.core.memory.u8._core
|
|
427
|
+
size = ffi.new("size_t *")
|
|
428
|
+
ptr = ffi.cast("uint8_t *", mem_core.getMemoryBlock(mem_core, region_id, size))
|
|
429
|
+
self._mem_cache[region_id] = ffi.buffer(ptr, size[0])[:]
|
|
430
|
+
return self._mem_cache[region_id]
|
|
431
|
+
|
|
432
|
+
def read_memory(self, address: int, size: int = 1):
|
|
433
|
+
region_id = address >> lib.BASE_OFFSET
|
|
434
|
+
mem_region = self._get_memory_region(region_id)
|
|
435
|
+
mask = len(mem_region) - 1
|
|
436
|
+
address &= mask
|
|
437
|
+
return mem_region[address:address + size]
|
|
438
|
+
|
|
439
|
+
def read_party_pokemon(self) -> List[PokemonData]:
|
|
440
|
+
"""Read all Pokemon in party with direct memory access"""
|
|
441
|
+
party = []
|
|
442
|
+
try:
|
|
443
|
+
party_size = self.read_party_size()
|
|
444
|
+
logger.info(f"Reading party with size: {party_size}")
|
|
445
|
+
|
|
446
|
+
# Read the entire party data from memory
|
|
447
|
+
party_data = self.read_memory(ADDRESSES["gPlayerParty"], party_size * struct.calcsize(Pokemon_format))
|
|
448
|
+
|
|
449
|
+
for i in range(party_size):
|
|
450
|
+
logger.info(f"Reading Pokemon at slot {i}")
|
|
451
|
+
try:
|
|
452
|
+
# Calculate the offset for each Pokemon
|
|
453
|
+
offset = i * self.addresses.PARTY_POKEMON_SIZE
|
|
454
|
+
pokemon_data = party_data[offset:offset + self.addresses.PARTY_POKEMON_SIZE]
|
|
455
|
+
|
|
456
|
+
# Parse the Pokemon data
|
|
457
|
+
pokemon = parse_pokemon(pokemon_data)
|
|
458
|
+
party.append(pokemon)
|
|
459
|
+
|
|
460
|
+
logger.info(f"Slot {i}: Parsed Pokemon = {pokemon}")
|
|
461
|
+
except Exception as e:
|
|
462
|
+
logger.warning(f"Failed to read Pokemon at slot {i}: {e}")
|
|
463
|
+
except Exception as e:
|
|
464
|
+
self._rate_limited_warning(f"Failed to read party: {e}", "party")
|
|
465
|
+
|
|
466
|
+
return party
|
|
467
|
+
|
|
468
|
+
def _read_pokemon_moves_from_decrypted(self, decrypted_data: bytes) -> Tuple[List[str], List[int]]:
|
|
469
|
+
"""Read moves and PP from decrypted Pokemon data"""
|
|
470
|
+
moves = []
|
|
471
|
+
move_pp = []
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
# Read moves (4 moves, 2 bytes each)
|
|
475
|
+
for i in range(4):
|
|
476
|
+
move_offset = self.pokemon_struct.MOVES + (i * 2)
|
|
477
|
+
move_id = self._read_u16_from_bytes(decrypted_data, move_offset)
|
|
478
|
+
|
|
479
|
+
if move_id == 0:
|
|
480
|
+
moves.append("")
|
|
481
|
+
move_pp.append(0)
|
|
482
|
+
else:
|
|
483
|
+
try:
|
|
484
|
+
move = Move(move_id)
|
|
485
|
+
move_name = move.name.replace('_', ' ').title()
|
|
486
|
+
moves.append(move_name)
|
|
487
|
+
except ValueError:
|
|
488
|
+
moves.append(f"Move_{move_id}")
|
|
489
|
+
|
|
490
|
+
# Read PP
|
|
491
|
+
pp_offset = self.pokemon_struct.PP + i
|
|
492
|
+
pp = decrypted_data[pp_offset] if pp_offset < len(decrypted_data) else 0
|
|
493
|
+
move_pp.append(pp)
|
|
494
|
+
|
|
495
|
+
except Exception as e:
|
|
496
|
+
logger.warning(f"Failed to read moves from decrypted data: {e}")
|
|
497
|
+
moves = ["", "", "", ""]
|
|
498
|
+
move_pp = [0, 0, 0, 0]
|
|
499
|
+
|
|
500
|
+
return moves, move_pp
|
|
501
|
+
|
|
502
|
+
def _read_u16_from_bytes(self, data: bytes, offset: int) -> int:
|
|
503
|
+
"""Read u16 from bytes at offset"""
|
|
504
|
+
if offset + 1 >= len(data):
|
|
505
|
+
return 0
|
|
506
|
+
return data[offset] | (data[offset + 1] << 8)
|
|
507
|
+
|
|
508
|
+
def is_in_battle(self) -> bool:
|
|
509
|
+
"""Check if player is in battle using enhanced pokeemerald-based detection"""
|
|
510
|
+
try:
|
|
511
|
+
# Primary check: gMain.inBattle flag (most reliable indicator)
|
|
512
|
+
main_in_battle = self._read_u8(self.addresses.IN_BATTLE_BIT_ADDR)
|
|
513
|
+
primary_battle_flag = (main_in_battle & self.addresses.IN_BATTLE_BITMASK) != 0
|
|
514
|
+
|
|
515
|
+
if primary_battle_flag:
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
# Secondary validation: check battle type flags for additional battle states
|
|
519
|
+
try:
|
|
520
|
+
battle_type_flags = self._read_u16(self.addresses.BATTLE_TYPE_FLAGS)
|
|
521
|
+
# Any non-zero battle type flags indicate some form of battle
|
|
522
|
+
if battle_type_flags != 0:
|
|
523
|
+
logger.debug(f"Battle detected via type flags: 0x{battle_type_flags:04X}")
|
|
524
|
+
return True
|
|
525
|
+
except Exception:
|
|
526
|
+
pass # Battle type flags check is supplementary
|
|
527
|
+
|
|
528
|
+
return False
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logger.warning(f"Failed to read battle state: {e}")
|
|
531
|
+
return False
|
|
532
|
+
|
|
533
|
+
def is_in_dialog(self) -> bool:
|
|
534
|
+
"""Check if currently in dialog state using enhanced pokeemerald-based detection"""
|
|
535
|
+
# Respect the dialog detection enabled flag
|
|
536
|
+
if not getattr(self, '_dialog_detection_enabled', True):
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
current_time = time.time()
|
|
541
|
+
|
|
542
|
+
# Special case: if we're in an after_dialog state, force return False
|
|
543
|
+
# This handles cases where the state file has residual dialog content
|
|
544
|
+
if hasattr(self, '_current_state_file') and self._current_state_file:
|
|
545
|
+
if 'after_dialog' in self._current_state_file.lower():
|
|
546
|
+
logger.debug(f"Forcing dialog=False for after_dialog state: {self._current_state_file}")
|
|
547
|
+
return False
|
|
548
|
+
|
|
549
|
+
# First check if we're in battle - if so, don't consider it dialog for FPS purposes
|
|
550
|
+
if self.is_in_battle():
|
|
551
|
+
return False
|
|
552
|
+
|
|
553
|
+
# Enhanced script context detection (following pokeemerald guide)
|
|
554
|
+
dialog_detected = self._detect_script_context_dialog()
|
|
555
|
+
if dialog_detected:
|
|
556
|
+
return True
|
|
557
|
+
|
|
558
|
+
# Fallback to original dialog state detection for compatibility
|
|
559
|
+
dialog_state = self._read_u8(self.addresses.DIALOG_STATE)
|
|
560
|
+
overworld_freeze = self._read_u8(0x02022B4C)
|
|
561
|
+
|
|
562
|
+
# If both dialog flags are 0, we're definitely not in dialog (regardless of text)
|
|
563
|
+
if dialog_state == 0 and overworld_freeze == 0:
|
|
564
|
+
return False
|
|
565
|
+
|
|
566
|
+
# Check for active dialog by reading dialog text
|
|
567
|
+
dialog_text = self.read_dialog()
|
|
568
|
+
has_meaningful_text = dialog_text and len(dialog_text.strip()) > 5
|
|
569
|
+
|
|
570
|
+
if has_meaningful_text:
|
|
571
|
+
# Enhanced residual text filtering (expanded from battle text)
|
|
572
|
+
cleaned_text = dialog_text.strip().lower()
|
|
573
|
+
residual_indicators = [
|
|
574
|
+
"got away safely", "fled", "escape", "battle", "wild", "trainer",
|
|
575
|
+
"used", "attack", "defend", "missed", "critical", "super effective",
|
|
576
|
+
"fainted", "defeated", "victory", "experience points",
|
|
577
|
+
"gained", "grew to", "learned", "ran away"
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
# If the text contains residual indicators, it's likely old text
|
|
581
|
+
if any(indicator in cleaned_text for indicator in residual_indicators):
|
|
582
|
+
logger.debug(f"Original detection: Ignoring residual text: {dialog_text[:50]}...")
|
|
583
|
+
return False
|
|
584
|
+
|
|
585
|
+
# Additional validation: check if dialog_state value is reasonable
|
|
586
|
+
# 0xFF (255) often indicates uninitialized/corrupted state
|
|
587
|
+
if dialog_state == 0xFF:
|
|
588
|
+
logger.debug(f"Original detection: Ignoring corrupted dialog_state=0xFF")
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
# Check if this is new dialog content (different from last time)
|
|
592
|
+
is_new_dialog = (self._last_dialog_content != dialog_text)
|
|
593
|
+
|
|
594
|
+
# If we have meaningful text and dialog flags are set, this is active dialog
|
|
595
|
+
if dialog_state > 0 or overworld_freeze > 0:
|
|
596
|
+
# If this is new dialog, start the FPS timer
|
|
597
|
+
if is_new_dialog:
|
|
598
|
+
self._dialog_fps_start_time = current_time
|
|
599
|
+
self._last_dialog_content = dialog_text
|
|
600
|
+
logger.debug(f"New dialog detected, starting 5s FPS boost: '{dialog_text[:50]}...'")
|
|
601
|
+
|
|
602
|
+
# Check if we're still within the FPS boost window
|
|
603
|
+
if (self._dialog_fps_start_time is not None and
|
|
604
|
+
current_time - self._dialog_fps_start_time < self._dialog_fps_duration):
|
|
605
|
+
logger.debug(f"Dialog FPS active ({current_time - self._dialog_fps_start_time:.1f}s remaining): '{dialog_text[:50]}...'")
|
|
606
|
+
return True
|
|
607
|
+
else:
|
|
608
|
+
# FPS boost window expired, but we still have dialog
|
|
609
|
+
logger.debug(f"Dialog FPS expired, but dialog still present: '{dialog_text[:50]}...'")
|
|
610
|
+
return False
|
|
611
|
+
else:
|
|
612
|
+
# No dialog flags set - this is residual text, don't treat as dialog
|
|
613
|
+
# But cache the content so we don't treat it as "new" next time
|
|
614
|
+
if self._last_dialog_content is None:
|
|
615
|
+
self._last_dialog_content = dialog_text
|
|
616
|
+
logger.debug(f"Residual text detected (no dialog flags): dialog_state={dialog_state}, overworld_freeze={overworld_freeze}, text='{dialog_text[:50]}...'")
|
|
617
|
+
return False
|
|
618
|
+
else:
|
|
619
|
+
# No meaningful text, but we might have dialog flags set
|
|
620
|
+
# This could be a transition state or residual flags
|
|
621
|
+
if dialog_state > 0 or overworld_freeze > 0:
|
|
622
|
+
# If we have flags but no text, this might be a transition
|
|
623
|
+
# Start a shorter timeout for this case
|
|
624
|
+
if self._dialog_fps_start_time is None:
|
|
625
|
+
self._dialog_fps_start_time = current_time
|
|
626
|
+
logger.debug(f"Dialog flags set but no text - starting transition timeout")
|
|
627
|
+
|
|
628
|
+
# Use a shorter timeout for transition states
|
|
629
|
+
transition_timeout = 1.0 # 1 second for transition states
|
|
630
|
+
if current_time - self._dialog_fps_start_time < transition_timeout:
|
|
631
|
+
logger.debug(f"Dialog transition active ({current_time - self._dialog_fps_start_time:.1f}s remaining)")
|
|
632
|
+
return True
|
|
633
|
+
else:
|
|
634
|
+
logger.debug(f"Dialog transition expired - treating as residual flags")
|
|
635
|
+
return False
|
|
636
|
+
|
|
637
|
+
# No meaningful text, reset dialog tracking
|
|
638
|
+
self._last_dialog_content = None
|
|
639
|
+
self._dialog_fps_start_time = None
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
except Exception as e:
|
|
643
|
+
logger.warning(f"Failed to check dialog state: {e}")
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
def _update_dialogue_cache(self, dialog_text, is_active_dialogue):
|
|
647
|
+
"""
|
|
648
|
+
Update the dialogue cache with current dialogue state.
|
|
649
|
+
Automatically clears old dialogue after timeout.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
dialog_text: Current dialogue text from memory
|
|
653
|
+
is_active_dialogue: Whether dialogue detection is currently active
|
|
654
|
+
"""
|
|
655
|
+
import time
|
|
656
|
+
current_time = time.time()
|
|
657
|
+
|
|
658
|
+
# If A button was recently pressed (within 1 second), ignore any residual text
|
|
659
|
+
if hasattr(self, '_a_button_pressed_time') and current_time - self._a_button_pressed_time < 1.0:
|
|
660
|
+
logger.debug(f"A button pressed recently ({current_time - self._a_button_pressed_time:.2f}s ago) - ignoring residual dialogue text")
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
# Check if we need to clear expired cache
|
|
664
|
+
if (self._dialogue_cache['timestamp'] and
|
|
665
|
+
current_time - self._dialogue_cache['timestamp'] > self._dialogue_cache_timeout):
|
|
666
|
+
logger.debug("Clearing expired dialogue cache")
|
|
667
|
+
self._dialogue_cache = {
|
|
668
|
+
'text': None,
|
|
669
|
+
'timestamp': None,
|
|
670
|
+
'is_active': False,
|
|
671
|
+
'detection_result': False
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
# Update cache with current state
|
|
675
|
+
if dialog_text and is_active_dialogue:
|
|
676
|
+
# Active dialogue - update cache
|
|
677
|
+
self._dialogue_cache.update({
|
|
678
|
+
'text': dialog_text,
|
|
679
|
+
'timestamp': current_time,
|
|
680
|
+
'is_active': True,
|
|
681
|
+
'detection_result': True
|
|
682
|
+
})
|
|
683
|
+
logger.debug(f"Updated dialogue cache with active dialogue: {dialog_text[:50]}...")
|
|
684
|
+
elif dialog_text and not is_active_dialogue:
|
|
685
|
+
# Text exists but not active - likely residual
|
|
686
|
+
if not self._dialogue_cache['is_active'] or not self._dialogue_cache['text']:
|
|
687
|
+
# No recent active dialogue, treat as residual
|
|
688
|
+
self._dialogue_cache.update({
|
|
689
|
+
'text': dialog_text,
|
|
690
|
+
'timestamp': current_time,
|
|
691
|
+
'is_active': False,
|
|
692
|
+
'detection_result': False
|
|
693
|
+
})
|
|
694
|
+
logger.debug(f"Cached residual dialogue text: {dialog_text[:50]}...")
|
|
695
|
+
elif not dialog_text:
|
|
696
|
+
# No dialogue text - clear cache if it's old enough
|
|
697
|
+
if (self._dialogue_cache['timestamp'] and
|
|
698
|
+
current_time - self._dialogue_cache['timestamp'] > 1.0): # 1 second grace period
|
|
699
|
+
logger.debug("Clearing dialogue cache - no text found")
|
|
700
|
+
self._dialogue_cache['is_active'] = False
|
|
701
|
+
self._dialogue_cache['detection_result'] = False
|
|
702
|
+
|
|
703
|
+
def get_cached_dialogue_state(self):
|
|
704
|
+
"""Get current cached dialogue state, respecting timeout."""
|
|
705
|
+
import time
|
|
706
|
+
current_time = time.time()
|
|
707
|
+
|
|
708
|
+
# Return False if cache is expired
|
|
709
|
+
if (self._dialogue_cache['timestamp'] and
|
|
710
|
+
current_time - self._dialogue_cache['timestamp'] > self._dialogue_cache_timeout):
|
|
711
|
+
return False, None
|
|
712
|
+
|
|
713
|
+
return self._dialogue_cache['detection_result'], self._dialogue_cache['text']
|
|
714
|
+
|
|
715
|
+
def clear_dialogue_cache_on_button_press(self):
|
|
716
|
+
"""
|
|
717
|
+
Clear dialogue cache when A button is pressed (dismisses dialogue).
|
|
718
|
+
This prevents false positive dialogue detection after dialogue is dismissed.
|
|
719
|
+
"""
|
|
720
|
+
import time
|
|
721
|
+
current_time = time.time()
|
|
722
|
+
|
|
723
|
+
logger.debug("A button pressed - clearing dialogue cache to prevent false positives")
|
|
724
|
+
|
|
725
|
+
# Force clear dialogue cache
|
|
726
|
+
self._dialogue_cache = {
|
|
727
|
+
'text': None,
|
|
728
|
+
'timestamp': current_time,
|
|
729
|
+
'is_active': False,
|
|
730
|
+
'detection_result': False
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
# Also clear old dialog tracking
|
|
734
|
+
self._last_dialog_content = None
|
|
735
|
+
self._dialog_fps_start_time = None
|
|
736
|
+
self._dialog_text_start_time = None
|
|
737
|
+
|
|
738
|
+
# Mark that A button was recently pressed to prevent cache repopulation
|
|
739
|
+
self._a_button_pressed_time = current_time
|
|
740
|
+
|
|
741
|
+
def reset_dialog_tracking(self):
|
|
742
|
+
"""Reset dialog tracking state"""
|
|
743
|
+
self._last_dialog_content = None
|
|
744
|
+
self._dialog_fps_start_time = None
|
|
745
|
+
self._dialog_text_start_time = None
|
|
746
|
+
|
|
747
|
+
# Reset dialogue cache as well
|
|
748
|
+
self._dialogue_cache = {
|
|
749
|
+
'text': None,
|
|
750
|
+
'timestamp': None,
|
|
751
|
+
'is_active': False,
|
|
752
|
+
'detection_result': False
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
def invalidate_map_cache(self, clear_buffer_address=True):
|
|
756
|
+
"""Invalidate map-related caches when transitioning between areas"""
|
|
757
|
+
logger.debug("Invalidating map cache due to area transition")
|
|
758
|
+
|
|
759
|
+
# Only clear buffer address if explicitly requested (area transitions)
|
|
760
|
+
# For state loads, we want to keep the cached buffer address to avoid expensive scans
|
|
761
|
+
if clear_buffer_address:
|
|
762
|
+
self._map_buffer_addr = None
|
|
763
|
+
self._map_width = None
|
|
764
|
+
self._map_height = None
|
|
765
|
+
|
|
766
|
+
# CRITICAL: Clear behavior cache to force reload with new tileset
|
|
767
|
+
self._cached_behaviors = None
|
|
768
|
+
self._cached_behaviors_map_key = None
|
|
769
|
+
self._mem_cache = {}
|
|
770
|
+
|
|
771
|
+
# Force memory regions to be re-read from core
|
|
772
|
+
# This is critical for server to get fresh data after transitions
|
|
773
|
+
if hasattr(self, '_mem_cache'):
|
|
774
|
+
self._mem_cache.clear()
|
|
775
|
+
|
|
776
|
+
def _check_area_transition(self):
|
|
777
|
+
"""Check if player has moved to a new area and invalidate cache if needed"""
|
|
778
|
+
try:
|
|
779
|
+
current_map_bank = self._read_u8(self.addresses.MAP_BANK)
|
|
780
|
+
current_map_number = self._read_u8(self.addresses.MAP_NUMBER)
|
|
781
|
+
|
|
782
|
+
# Check if this is the first time or if area has changed
|
|
783
|
+
if (self._last_map_bank is None or self._last_map_number is None or
|
|
784
|
+
current_map_bank != self._last_map_bank or
|
|
785
|
+
current_map_number != self._last_map_number):
|
|
786
|
+
|
|
787
|
+
if self._last_map_bank is not None: # Don't log on first run
|
|
788
|
+
logger.info(f"Area transition detected: ({self._last_map_bank}, {self._last_map_number}) -> ({current_map_bank}, {current_map_number})")
|
|
789
|
+
self.invalidate_map_cache()
|
|
790
|
+
|
|
791
|
+
# Force re-scan of map buffer addresses after transition
|
|
792
|
+
# Area transitions can invalidate memory addresses due to game state changes
|
|
793
|
+
self._map_buffer_addr = None
|
|
794
|
+
self._map_width = None
|
|
795
|
+
self._map_height = None
|
|
796
|
+
logger.debug("Forcing map buffer re-scan after area transition")
|
|
797
|
+
|
|
798
|
+
# Also invalidate emulator's comprehensive state cache if callback is set
|
|
799
|
+
if hasattr(self, '_emulator_cache_invalidator') and self._emulator_cache_invalidator:
|
|
800
|
+
try:
|
|
801
|
+
self._emulator_cache_invalidator()
|
|
802
|
+
except Exception as e:
|
|
803
|
+
logger.debug(f"Failed to invalidate emulator cache: {e}")
|
|
804
|
+
|
|
805
|
+
self._last_map_bank = current_map_bank
|
|
806
|
+
self._last_map_number = current_map_number
|
|
807
|
+
return True
|
|
808
|
+
|
|
809
|
+
except Exception as e:
|
|
810
|
+
logger.warning(f"Failed to check area transition: {e}")
|
|
811
|
+
# Don't let area transition check failures break map reading
|
|
812
|
+
|
|
813
|
+
return False
|
|
814
|
+
|
|
815
|
+
def _detect_script_context_dialog(self) -> bool:
|
|
816
|
+
"""
|
|
817
|
+
Detect dialog state using pokeemerald script context analysis.
|
|
818
|
+
|
|
819
|
+
Enhanced with residual text filtering to prevent false positives.
|
|
820
|
+
"""
|
|
821
|
+
try:
|
|
822
|
+
# First check for residual battle/escape text that should be ignored
|
|
823
|
+
dialog_text = self.read_dialog()
|
|
824
|
+
if dialog_text:
|
|
825
|
+
cleaned_text = dialog_text.strip().lower()
|
|
826
|
+
residual_indicators = [
|
|
827
|
+
"got away safely", "fled from", "escaped", "ran away",
|
|
828
|
+
"fainted", "defeated", "victory", "experience points",
|
|
829
|
+
"gained", "grew to", "learned"
|
|
830
|
+
]
|
|
831
|
+
if any(indicator in cleaned_text for indicator in residual_indicators):
|
|
832
|
+
logger.debug(f"Enhanced detection: Ignoring residual text: '{dialog_text[:30]}...'")
|
|
833
|
+
return False
|
|
834
|
+
|
|
835
|
+
# Check global script context
|
|
836
|
+
global_mode = self._read_u8(self.addresses.SCRIPT_CONTEXT_GLOBAL + self.addresses.SCRIPT_MODE_OFFSET)
|
|
837
|
+
global_status = self._read_u8(self.addresses.SCRIPT_CONTEXT_GLOBAL + self.addresses.SCRIPT_STATUS_OFFSET)
|
|
838
|
+
|
|
839
|
+
# Check immediate script context
|
|
840
|
+
immediate_mode = self._read_u8(self.addresses.SCRIPT_CONTEXT_IMMEDIATE + self.addresses.SCRIPT_MODE_OFFSET)
|
|
841
|
+
immediate_status = self._read_u8(self.addresses.SCRIPT_CONTEXT_IMMEDIATE + self.addresses.SCRIPT_STATUS_OFFSET)
|
|
842
|
+
|
|
843
|
+
# Script execution modes from pokeemerald:
|
|
844
|
+
# SCRIPT_MODE_STOPPED = 0, SCRIPT_MODE_BYTECODE = 1, SCRIPT_MODE_NATIVE = 2
|
|
845
|
+
# Context status: CONTEXT_RUNNING = 0, CONTEXT_WAITING = 1, CONTEXT_SHUTDOWN = 2
|
|
846
|
+
|
|
847
|
+
# Enhanced validation: Only consider it dialog if script modes are reasonable values
|
|
848
|
+
# Extremely high values (like 221) are likely corrupted/persistent state data
|
|
849
|
+
if global_mode >= 1 and global_mode <= 10: # Reasonable script mode range
|
|
850
|
+
logger.debug(f"Script dialog detected: global_mode={global_mode}")
|
|
851
|
+
return True
|
|
852
|
+
|
|
853
|
+
if immediate_mode >= 1 and immediate_mode <= 10: # Reasonable script mode range
|
|
854
|
+
logger.debug(f"Script dialog detected: immediate_mode={immediate_mode}")
|
|
855
|
+
return True
|
|
856
|
+
|
|
857
|
+
# Check message box state indicators with validation
|
|
858
|
+
try:
|
|
859
|
+
is_signpost = self._read_u8(self.addresses.MSG_IS_SIGNPOST)
|
|
860
|
+
box_cancelable = self._read_u8(self.addresses.MSG_BOX_CANCELABLE)
|
|
861
|
+
|
|
862
|
+
# Only consider valid if values are reasonable (not 0xFF which indicates uninitialized)
|
|
863
|
+
if (is_signpost != 0 and is_signpost != 0xFF and is_signpost <= 10):
|
|
864
|
+
logger.debug(f"Message box dialog detected: signpost={is_signpost}")
|
|
865
|
+
return True
|
|
866
|
+
|
|
867
|
+
if (box_cancelable != 0 and box_cancelable != 0xFF and box_cancelable <= 10):
|
|
868
|
+
logger.debug(f"Message box dialog detected: cancelable={box_cancelable}")
|
|
869
|
+
return True
|
|
870
|
+
except Exception:
|
|
871
|
+
pass # Message box checks are supplementary
|
|
872
|
+
|
|
873
|
+
return False
|
|
874
|
+
|
|
875
|
+
except Exception as e:
|
|
876
|
+
logger.debug(f"Script context dialog detection failed: {e}")
|
|
877
|
+
return False
|
|
878
|
+
|
|
879
|
+
def _validate_map_data(self, map_data, location_name=""):
|
|
880
|
+
"""Validate that map data looks reasonable based on location and structure"""
|
|
881
|
+
if not map_data or len(map_data) == 0:
|
|
882
|
+
return False, "Empty map data"
|
|
883
|
+
|
|
884
|
+
# Basic structure validation
|
|
885
|
+
height = len(map_data)
|
|
886
|
+
width = len(map_data[0]) if map_data else 0
|
|
887
|
+
|
|
888
|
+
if height < 5 or width < 5:
|
|
889
|
+
return False, f"Map too small: {width}x{height}"
|
|
890
|
+
|
|
891
|
+
# Count different tile types (using both behavior and collision data)
|
|
892
|
+
unknown_tiles = 0
|
|
893
|
+
impassable_tiles = 0
|
|
894
|
+
walkable_tiles = 0
|
|
895
|
+
wall_tiles = 0
|
|
896
|
+
special_tiles = 0
|
|
897
|
+
total_tiles = 0
|
|
898
|
+
|
|
899
|
+
for row in map_data:
|
|
900
|
+
for tile in row:
|
|
901
|
+
total_tiles += 1
|
|
902
|
+
if len(tile) >= 4:
|
|
903
|
+
tile_id, behavior, collision, elevation = tile
|
|
904
|
+
|
|
905
|
+
# Handle both enum objects and integers
|
|
906
|
+
if hasattr(behavior, 'name'):
|
|
907
|
+
behavior_name = behavior.name
|
|
908
|
+
elif isinstance(behavior, int):
|
|
909
|
+
try:
|
|
910
|
+
behavior_enum = MetatileBehavior(behavior)
|
|
911
|
+
behavior_name = behavior_enum.name
|
|
912
|
+
except ValueError:
|
|
913
|
+
behavior_name = "UNKNOWN"
|
|
914
|
+
else:
|
|
915
|
+
behavior_name = "UNKNOWN"
|
|
916
|
+
|
|
917
|
+
if behavior_name == "UNKNOWN":
|
|
918
|
+
unknown_tiles += 1
|
|
919
|
+
elif "IMPASSABLE" in behavior_name:
|
|
920
|
+
impassable_tiles += 1
|
|
921
|
+
elif behavior_name == "NORMAL":
|
|
922
|
+
# For normal tiles, use collision to determine if walkable or wall
|
|
923
|
+
if collision == 0:
|
|
924
|
+
walkable_tiles += 1
|
|
925
|
+
else:
|
|
926
|
+
wall_tiles += 1
|
|
927
|
+
else:
|
|
928
|
+
# Other special behaviors (doors, grass, water, etc.)
|
|
929
|
+
special_tiles += 1
|
|
930
|
+
elif len(tile) >= 2:
|
|
931
|
+
# Fallback for tiles without collision data
|
|
932
|
+
behavior = tile[1]
|
|
933
|
+
if hasattr(behavior, 'name'):
|
|
934
|
+
behavior_name = behavior.name
|
|
935
|
+
elif isinstance(behavior, int):
|
|
936
|
+
try:
|
|
937
|
+
behavior_enum = MetatileBehavior(behavior)
|
|
938
|
+
behavior_name = behavior_enum.name
|
|
939
|
+
except ValueError:
|
|
940
|
+
behavior_name = "UNKNOWN"
|
|
941
|
+
else:
|
|
942
|
+
behavior_name = "UNKNOWN"
|
|
943
|
+
|
|
944
|
+
if behavior_name == "UNKNOWN":
|
|
945
|
+
unknown_tiles += 1
|
|
946
|
+
else:
|
|
947
|
+
walkable_tiles += 1 # Assume walkable if no collision data
|
|
948
|
+
|
|
949
|
+
# Calculate ratios
|
|
950
|
+
unknown_ratio = unknown_tiles / total_tiles if total_tiles > 0 else 0
|
|
951
|
+
impassable_ratio = impassable_tiles / total_tiles if total_tiles > 0 else 0
|
|
952
|
+
walkable_ratio = walkable_tiles / total_tiles if total_tiles > 0 else 0
|
|
953
|
+
wall_ratio = wall_tiles / total_tiles if total_tiles > 0 else 0
|
|
954
|
+
special_ratio = special_tiles / total_tiles if total_tiles > 0 else 0
|
|
955
|
+
|
|
956
|
+
# Validation rules based on location type
|
|
957
|
+
is_indoor = location_name and ("HOUSE" in location_name.upper() or "ROOM" in location_name.upper())
|
|
958
|
+
is_outdoor = location_name and ("TOWN" in location_name.upper() or "ROUTE" in location_name.upper())
|
|
959
|
+
|
|
960
|
+
# Rule 1: Too many unknown tiles (> 20%)
|
|
961
|
+
if unknown_ratio > 0.2:
|
|
962
|
+
return False, f"Too many unknown tiles: {unknown_ratio:.1%}"
|
|
963
|
+
|
|
964
|
+
# Rule 2: Indoor locations should have some walls (>10%) and walkable areas (>20%)
|
|
965
|
+
if is_indoor:
|
|
966
|
+
if wall_ratio < 0.1:
|
|
967
|
+
return False, f"Indoor area has too few walls: {wall_ratio:.1%}"
|
|
968
|
+
if walkable_ratio < 0.2:
|
|
969
|
+
return False, f"Indoor area has too few walkable tiles: {walkable_ratio:.1%}"
|
|
970
|
+
|
|
971
|
+
# Rule 3: Outdoor areas should have reasonable balance
|
|
972
|
+
if is_outdoor:
|
|
973
|
+
# Should have some walkable areas (>15%) and not be all walls (>95%)
|
|
974
|
+
if walkable_ratio < 0.15:
|
|
975
|
+
return False, f"Outdoor area has too few walkable tiles: {walkable_ratio:.1%}"
|
|
976
|
+
if wall_ratio > 0.95:
|
|
977
|
+
return False, f"Outdoor area is mostly walls: {wall_ratio:.1%}"
|
|
978
|
+
|
|
979
|
+
# Rule 4: General sanity check - shouldn't be all impassable
|
|
980
|
+
if impassable_ratio > 0.8:
|
|
981
|
+
return False, f"Area is mostly impassable: {impassable_ratio:.1%}"
|
|
982
|
+
|
|
983
|
+
return True, f"Map validation passed: {walkable_ratio:.1%} walkable, {wall_ratio:.1%} walls, {special_ratio:.1%} special, {impassable_ratio:.1%} impassable"
|
|
984
|
+
|
|
985
|
+
def read_coordinates(self) -> Tuple[int, int]:
|
|
986
|
+
"""Read player coordinates"""
|
|
987
|
+
try:
|
|
988
|
+
# Get the base address of the savestate object structure
|
|
989
|
+
base_address = self._read_u32(self.addresses.SAVESTATE_OBJECT_POINTER)
|
|
990
|
+
|
|
991
|
+
if base_address == 0:
|
|
992
|
+
self._rate_limited_warning("Could not read savestate object pointer", "savestate_pointer")
|
|
993
|
+
return (0, 0)
|
|
994
|
+
|
|
995
|
+
# Read coordinates from the savestate object
|
|
996
|
+
x = self._read_u16(base_address + self.addresses.SAVESTATE_PLAYER_X_OFFSET)
|
|
997
|
+
y = self._read_u16(base_address + self.addresses.SAVESTATE_PLAYER_Y_OFFSET)
|
|
998
|
+
return (x, y)
|
|
999
|
+
except Exception as e:
|
|
1000
|
+
self._rate_limited_warning(f"Failed to read coordinates: {e}", "coordinates")
|
|
1001
|
+
return (0, 0)
|
|
1002
|
+
|
|
1003
|
+
def read_player_facing(self) -> str:
|
|
1004
|
+
"""Read player facing direction"""
|
|
1005
|
+
try:
|
|
1006
|
+
# Get the base address of the savestate object structure
|
|
1007
|
+
base_address = self._read_u32(self.addresses.SAVESTATE_OBJECT_POINTER)
|
|
1008
|
+
|
|
1009
|
+
if base_address == 0:
|
|
1010
|
+
self._rate_limited_warning("Could not read savestate object pointer", "savestate_pointer")
|
|
1011
|
+
return "Unknown direction"
|
|
1012
|
+
|
|
1013
|
+
# Read facing direction from the savestate object
|
|
1014
|
+
facing_value = self._read_u8(base_address + self.addresses.SAVESTATE_PLAYER_FACING_OFFSET)
|
|
1015
|
+
|
|
1016
|
+
# Convert to direction string (0=South, 1=North, 2=West, 3=East)
|
|
1017
|
+
directions = ["South", "North", "West", "East"]
|
|
1018
|
+
if 0 <= facing_value < len(directions):
|
|
1019
|
+
return directions[facing_value]
|
|
1020
|
+
else:
|
|
1021
|
+
self._rate_limited_warning(f"Invalid facing direction value: {facing_value}", "facing_direction")
|
|
1022
|
+
return "Unknown direction"
|
|
1023
|
+
except Exception as e:
|
|
1024
|
+
logger.warning(f"Failed to read player facing direction: {e}")
|
|
1025
|
+
return "Unknown direction"
|
|
1026
|
+
|
|
1027
|
+
def is_in_title_sequence(self) -> bool:
|
|
1028
|
+
"""Detect if we're in title sequence/intro before overworld"""
|
|
1029
|
+
try:
|
|
1030
|
+
# Check if player name is set - if not, likely in title/intro
|
|
1031
|
+
player_name = self.read_player_name()
|
|
1032
|
+
if not player_name or player_name.strip() == '':
|
|
1033
|
+
return True
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
# Check if we have valid SaveBlock pointers
|
|
1037
|
+
try:
|
|
1038
|
+
saveblock1_ptr = self._read_u32(self.addresses.SAVE_BLOCK1_PTR)
|
|
1039
|
+
saveblock2_ptr = self._read_u32(self.addresses.SAVE_BLOCK2_PTR)
|
|
1040
|
+
|
|
1041
|
+
# If saveblocks aren't initialized, we're likely in title
|
|
1042
|
+
if saveblock1_ptr == 0 or saveblock2_ptr == 0:
|
|
1043
|
+
return True
|
|
1044
|
+
|
|
1045
|
+
except:
|
|
1046
|
+
return True
|
|
1047
|
+
|
|
1048
|
+
# Check if we have invalid map coordinates that indicate title sequence
|
|
1049
|
+
# Note: We removed the check for Petalburg City (0,0) as it's a valid location
|
|
1050
|
+
# Instead, check for truly invalid map values
|
|
1051
|
+
map_bank = self._read_u8(self.addresses.MAP_BANK)
|
|
1052
|
+
map_num = self._read_u8(self.addresses.MAP_NUMBER)
|
|
1053
|
+
|
|
1054
|
+
# Map banks above 0x2A are invalid in Pokemon Emerald
|
|
1055
|
+
if map_bank > 0x2A:
|
|
1056
|
+
return True
|
|
1057
|
+
|
|
1058
|
+
# Check if game state indicates we haven't started yet
|
|
1059
|
+
# If player has no party Pokemon, we're still in title/intro
|
|
1060
|
+
try:
|
|
1061
|
+
party_size = self.read_party_size()
|
|
1062
|
+
if party_size == 0:
|
|
1063
|
+
# But make exception for specific early game sequences
|
|
1064
|
+
# where party is temporarily 0 (like the moving van or Littleroot Town)
|
|
1065
|
+
map_id = (map_bank << 8) | map_num
|
|
1066
|
+
# Allow these maps even with no party:
|
|
1067
|
+
# 0x1928: BATTLE_FRONTIER_RANKING_HALL (moving van)
|
|
1068
|
+
# 0x0009: LITTLEROOT_TOWN (post-intro, pre-starter)
|
|
1069
|
+
# 0x0100-0x0104: Littleroot Town buildings (houses and lab)
|
|
1070
|
+
if map_id in [0x1928, 0x0009] or (0x0100 <= map_id <= 0x0104):
|
|
1071
|
+
return False # Not in title sequence, just early game
|
|
1072
|
+
return True
|
|
1073
|
+
except:
|
|
1074
|
+
pass
|
|
1075
|
+
|
|
1076
|
+
return False
|
|
1077
|
+
|
|
1078
|
+
except Exception:
|
|
1079
|
+
# If we can't read memory properly, assume title sequence
|
|
1080
|
+
return True
|
|
1081
|
+
|
|
1082
|
+
def read_location(self) -> str:
|
|
1083
|
+
"""Read current location"""
|
|
1084
|
+
try:
|
|
1085
|
+
map_bank = self._read_u8(self.addresses.MAP_BANK)
|
|
1086
|
+
map_num = self._read_u8(self.addresses.MAP_NUMBER)
|
|
1087
|
+
map_id = (map_bank << 8) | map_num
|
|
1088
|
+
|
|
1089
|
+
# Log the raw map values for debugging location issues
|
|
1090
|
+
if map_id in [0x1200, 0x1100]: # Mr. Briney's House or Meteor Falls
|
|
1091
|
+
logger.debug(f"Location debug: map_bank=0x{map_bank:02X}, map_num=0x{map_num:02X}, map_id=0x{map_id:04X}")
|
|
1092
|
+
|
|
1093
|
+
# Check if we're in title sequence (no valid map data)
|
|
1094
|
+
if self.is_in_title_sequence():
|
|
1095
|
+
return "TITLE_SEQUENCE"
|
|
1096
|
+
|
|
1097
|
+
# Special case: Battle Frontier Ranking Hall during intro (moving van)
|
|
1098
|
+
# Distinguish by checking if this is early game (no party, no badges)
|
|
1099
|
+
if map_id == 0x1928: # BATTLE_FRONTIER_RANKING_HALL
|
|
1100
|
+
try:
|
|
1101
|
+
party_size = self.read_party_size()
|
|
1102
|
+
badges = self.read_badges()
|
|
1103
|
+
# If no party and no badges, this is the moving van intro scene
|
|
1104
|
+
if party_size == 0 and len(badges) == 0:
|
|
1105
|
+
return "MOVING_VAN"
|
|
1106
|
+
except:
|
|
1107
|
+
pass
|
|
1108
|
+
|
|
1109
|
+
try:
|
|
1110
|
+
location = MapLocation(map_id)
|
|
1111
|
+
return location.name.replace('_', ' ')
|
|
1112
|
+
except ValueError:
|
|
1113
|
+
return f"Map_{map_bank:02X}_{map_num:02X}"
|
|
1114
|
+
except Exception as e:
|
|
1115
|
+
logger.warning(f"Failed to read location: {e}")
|
|
1116
|
+
return "Unknown"
|
|
1117
|
+
|
|
1118
|
+
def read_badges(self) -> List[str]:
|
|
1119
|
+
"""Read obtained badges"""
|
|
1120
|
+
try:
|
|
1121
|
+
badge_byte = self._read_u8(self.addresses.PLAYER_BADGES)
|
|
1122
|
+
badge_names = ["Stone", "Knuckle", "Dynamo", "Heat", "Balance", "Feather", "Mind", "Rain"]
|
|
1123
|
+
|
|
1124
|
+
obtained_badges = []
|
|
1125
|
+
for i, badge_name in enumerate(badge_names):
|
|
1126
|
+
if badge_byte & (1 << i):
|
|
1127
|
+
obtained_badges.append(badge_name)
|
|
1128
|
+
|
|
1129
|
+
return obtained_badges
|
|
1130
|
+
except Exception as e:
|
|
1131
|
+
logger.warning(f"Failed to read badges: {e}")
|
|
1132
|
+
return []
|
|
1133
|
+
|
|
1134
|
+
def read_game_time(self) -> Tuple[int, int, int]:
|
|
1135
|
+
"""Read game time"""
|
|
1136
|
+
try:
|
|
1137
|
+
time_addr = self._read_u32(self.addresses.GAME_TIME)
|
|
1138
|
+
if time_addr == 0:
|
|
1139
|
+
return (0, 0, 0)
|
|
1140
|
+
|
|
1141
|
+
hours = self._read_u8(time_addr)
|
|
1142
|
+
minutes = self._read_u8(time_addr + 1)
|
|
1143
|
+
seconds = self._read_u8(time_addr + 2)
|
|
1144
|
+
|
|
1145
|
+
return (hours, minutes, seconds)
|
|
1146
|
+
except Exception as e:
|
|
1147
|
+
logger.warning(f"Failed to read game time: {e}")
|
|
1148
|
+
return (0, 0, 0)
|
|
1149
|
+
|
|
1150
|
+
def read_items(self) -> List[Tuple[str, int]]:
|
|
1151
|
+
"""Read items in bag"""
|
|
1152
|
+
try:
|
|
1153
|
+
items_addr = self._read_u32(self.addresses.BAG_ITEMS)
|
|
1154
|
+
count_addr = self._read_u32(self.addresses.BAG_ITEMS_COUNT)
|
|
1155
|
+
|
|
1156
|
+
if items_addr == 0 or count_addr == 0:
|
|
1157
|
+
return []
|
|
1158
|
+
|
|
1159
|
+
item_count = self._read_u16(count_addr)
|
|
1160
|
+
items = []
|
|
1161
|
+
|
|
1162
|
+
for i in range(min(item_count, 30)):
|
|
1163
|
+
item_id = self._read_u16(items_addr + i * 4)
|
|
1164
|
+
quantity = self._read_u16(items_addr + i * 4 + 2)
|
|
1165
|
+
|
|
1166
|
+
if item_id > 0:
|
|
1167
|
+
item_name = f"Item_{item_id:03d}"
|
|
1168
|
+
items.append((item_name, quantity))
|
|
1169
|
+
|
|
1170
|
+
return items
|
|
1171
|
+
except Exception as e:
|
|
1172
|
+
logger.warning(f"Failed to read items: {e}")
|
|
1173
|
+
return []
|
|
1174
|
+
|
|
1175
|
+
def read_item_count(self) -> int:
|
|
1176
|
+
"""Read number of items in bag"""
|
|
1177
|
+
try:
|
|
1178
|
+
count_addr = self._read_u32(self.addresses.BAG_ITEMS_COUNT)
|
|
1179
|
+
if count_addr == 0:
|
|
1180
|
+
return 0
|
|
1181
|
+
return self._read_u16(count_addr)
|
|
1182
|
+
except Exception as e:
|
|
1183
|
+
logger.warning(f"Failed to read item count: {e}")
|
|
1184
|
+
return 0
|
|
1185
|
+
|
|
1186
|
+
def read_pokedex_caught_count(self) -> int:
|
|
1187
|
+
"""Read number of Pokemon caught"""
|
|
1188
|
+
try:
|
|
1189
|
+
caught_addr = self._read_u32(self.addresses.POKEDEX_CAUGHT)
|
|
1190
|
+
if caught_addr == 0:
|
|
1191
|
+
return 0
|
|
1192
|
+
|
|
1193
|
+
caught_count = 0
|
|
1194
|
+
for i in range(32):
|
|
1195
|
+
flags = self._read_u8(caught_addr + i)
|
|
1196
|
+
caught_count += bin(flags).count('1')
|
|
1197
|
+
|
|
1198
|
+
return caught_count
|
|
1199
|
+
except Exception as e:
|
|
1200
|
+
logger.warning(f"Failed to read Pokedex caught count: {e}")
|
|
1201
|
+
return 0
|
|
1202
|
+
|
|
1203
|
+
def read_pokedex_seen_count(self) -> int:
|
|
1204
|
+
"""Read number of Pokemon seen"""
|
|
1205
|
+
try:
|
|
1206
|
+
seen_addr = self._read_u32(self.addresses.POKEDEX_SEEN)
|
|
1207
|
+
if seen_addr == 0:
|
|
1208
|
+
return 0
|
|
1209
|
+
|
|
1210
|
+
seen_count = 0
|
|
1211
|
+
for i in range(32):
|
|
1212
|
+
flags = self._read_u8(seen_addr + i)
|
|
1213
|
+
seen_count += bin(flags).count('1')
|
|
1214
|
+
|
|
1215
|
+
return seen_count
|
|
1216
|
+
except Exception as e:
|
|
1217
|
+
logger.warning(f"Failed to read Pokedex seen count: {e}")
|
|
1218
|
+
return 0
|
|
1219
|
+
|
|
1220
|
+
def get_game_state(self) -> str:
|
|
1221
|
+
"""Get current game state"""
|
|
1222
|
+
try:
|
|
1223
|
+
# Check for title sequence first
|
|
1224
|
+
if self.is_in_title_sequence():
|
|
1225
|
+
return "title"
|
|
1226
|
+
|
|
1227
|
+
if self.is_in_battle():
|
|
1228
|
+
return "battle"
|
|
1229
|
+
|
|
1230
|
+
menu_state = self._read_u32(self.addresses.MENU_STATE)
|
|
1231
|
+
if menu_state != 0:
|
|
1232
|
+
return "menu"
|
|
1233
|
+
|
|
1234
|
+
# Check for dialog but respect A button clearing
|
|
1235
|
+
# Use cached dialogue state if available, otherwise fall back to detection
|
|
1236
|
+
cached_active, _ = self.get_cached_dialogue_state()
|
|
1237
|
+
if cached_active:
|
|
1238
|
+
return "dialog"
|
|
1239
|
+
|
|
1240
|
+
return "overworld"
|
|
1241
|
+
except Exception as e:
|
|
1242
|
+
logger.warning(f"Failed to determine game state: {e}")
|
|
1243
|
+
return "unknown"
|
|
1244
|
+
|
|
1245
|
+
def read_battle_details(self) -> Dict[str, Any]:
|
|
1246
|
+
"""Read enhanced battle-specific information following pokeemerald guide"""
|
|
1247
|
+
try:
|
|
1248
|
+
if not self.is_in_battle():
|
|
1249
|
+
return None
|
|
1250
|
+
|
|
1251
|
+
# Battle type detection disabled - feature not working correctly
|
|
1252
|
+
battle_type = "unknown"
|
|
1253
|
+
battle_type_value = 0
|
|
1254
|
+
battle_type_flags = 0
|
|
1255
|
+
|
|
1256
|
+
# Enhanced battle characteristics
|
|
1257
|
+
battle_details = {
|
|
1258
|
+
"in_battle": True,
|
|
1259
|
+
"battle_type": battle_type,
|
|
1260
|
+
"battle_type_raw": battle_type_value,
|
|
1261
|
+
"can_escape": False, # Unknown since battle type detection disabled
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
# Read detailed battle type flags (following pokeemerald guide)
|
|
1265
|
+
try:
|
|
1266
|
+
battle_type_flags = self._read_u16(self.addresses.BATTLE_TYPE_FLAGS)
|
|
1267
|
+
battle_details.update({
|
|
1268
|
+
"battle_type_flags": battle_type_flags,
|
|
1269
|
+
"is_trainer_battle": bool(battle_type_flags & 0x01), # BATTLE_TYPE_TRAINER
|
|
1270
|
+
"is_wild_battle": not bool(battle_type_flags & 0x01),
|
|
1271
|
+
"is_double_battle": bool(battle_type_flags & 0x02), # BATTLE_TYPE_DOUBLE
|
|
1272
|
+
"is_multi_battle": bool(battle_type_flags & 0x20), # BATTLE_TYPE_MULTI
|
|
1273
|
+
"is_frontier_battle": bool(battle_type_flags & 0x400), # BATTLE_TYPE_FRONTIER
|
|
1274
|
+
})
|
|
1275
|
+
except Exception:
|
|
1276
|
+
pass # Battle type flags are supplementary
|
|
1277
|
+
|
|
1278
|
+
# Read battle communication state for detailed battle phases
|
|
1279
|
+
try:
|
|
1280
|
+
comm_state = self._read_u8(self.addresses.BATTLE_COMMUNICATION)
|
|
1281
|
+
battle_details["battle_phase"] = comm_state
|
|
1282
|
+
battle_details["battle_phase_name"] = self._get_battle_phase_name(comm_state)
|
|
1283
|
+
except Exception:
|
|
1284
|
+
pass # Battle communication is supplementary
|
|
1285
|
+
|
|
1286
|
+
return battle_details
|
|
1287
|
+
|
|
1288
|
+
except Exception as e:
|
|
1289
|
+
logger.warning(f"Failed to read battle details: {e}")
|
|
1290
|
+
return {"in_battle": True, "battle_type": "unknown", "error": str(e)}
|
|
1291
|
+
|
|
1292
|
+
def _get_battle_phase_name(self, phase: int) -> str:
|
|
1293
|
+
"""Convert battle communication phase to readable name"""
|
|
1294
|
+
phase_names = {
|
|
1295
|
+
0: "initialization",
|
|
1296
|
+
1: "turn_start",
|
|
1297
|
+
2: "action_selection",
|
|
1298
|
+
3: "action_execution",
|
|
1299
|
+
4: "turn_end",
|
|
1300
|
+
5: "battle_end"
|
|
1301
|
+
}
|
|
1302
|
+
return phase_names.get(phase, f"phase_{phase}")
|
|
1303
|
+
|
|
1304
|
+
def read_comprehensive_battle_info(self) -> Dict[str, Any]:
|
|
1305
|
+
"""Read comprehensive battle information including active Pokémon, moves, health, and capturable status"""
|
|
1306
|
+
try:
|
|
1307
|
+
if not self.is_in_battle():
|
|
1308
|
+
return None
|
|
1309
|
+
|
|
1310
|
+
# Get basic battle details first
|
|
1311
|
+
battle_info = self.read_battle_details()
|
|
1312
|
+
if not battle_info:
|
|
1313
|
+
return None
|
|
1314
|
+
|
|
1315
|
+
# Enhanced battle info structure
|
|
1316
|
+
enhanced_battle = {
|
|
1317
|
+
**battle_info, # Include basic battle type info
|
|
1318
|
+
"player_pokemon": None,
|
|
1319
|
+
"opponent_pokemon": None,
|
|
1320
|
+
"can_escape": False, # Unknown since battle type detection disabled
|
|
1321
|
+
"is_capturable": False, # Unknown since battle type detection disabled
|
|
1322
|
+
"battle_interface": {
|
|
1323
|
+
"current_hover": None,
|
|
1324
|
+
"available_actions": []
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
# Read party to get current active Pokémon (first in party is usually active)
|
|
1329
|
+
try:
|
|
1330
|
+
party = self.read_party_pokemon()
|
|
1331
|
+
if party and len(party) > 0:
|
|
1332
|
+
active_pokemon = party[0] # First Pokémon is usually the active one in battle
|
|
1333
|
+
enhanced_battle["player_pokemon"] = {
|
|
1334
|
+
"species": active_pokemon.species_name,
|
|
1335
|
+
"nickname": active_pokemon.nickname or active_pokemon.species_name,
|
|
1336
|
+
"level": active_pokemon.level,
|
|
1337
|
+
"current_hp": active_pokemon.current_hp,
|
|
1338
|
+
"max_hp": active_pokemon.max_hp,
|
|
1339
|
+
"hp_percentage": round((active_pokemon.current_hp / active_pokemon.max_hp * 100) if active_pokemon.max_hp > 0 else 0, 1),
|
|
1340
|
+
"status": active_pokemon.status.get_status_name() if active_pokemon.status else "Normal",
|
|
1341
|
+
"types": [t.name for t in [active_pokemon.type1, active_pokemon.type2] if t],
|
|
1342
|
+
"moves": active_pokemon.moves,
|
|
1343
|
+
"move_pp": active_pokemon.move_pp,
|
|
1344
|
+
"is_fainted": active_pokemon.current_hp == 0
|
|
1345
|
+
}
|
|
1346
|
+
except Exception as e:
|
|
1347
|
+
logger.warning(f"Failed to read player battle Pokémon: {e}")
|
|
1348
|
+
|
|
1349
|
+
# Read opponent Pokémon data using ROM guide fallback approach
|
|
1350
|
+
try:
|
|
1351
|
+
opponent_data = None
|
|
1352
|
+
|
|
1353
|
+
# Method 1: Try gEnemyParty first (ROM guide: "Always contains full opponent data")
|
|
1354
|
+
g_enemy_party_base = 0x02023BC0 # gEnemyParty base address
|
|
1355
|
+
logger.debug("Trying gEnemyParty for opponent data")
|
|
1356
|
+
|
|
1357
|
+
# Read from gEnemyParty (standard Pokemon struct format)
|
|
1358
|
+
enemy_species = self._read_u16(g_enemy_party_base + 0x20) # Species in encrypted data
|
|
1359
|
+
enemy_level = self._read_u8(g_enemy_party_base + 0x54)
|
|
1360
|
+
enemy_hp = self._read_u16(g_enemy_party_base + 0x56)
|
|
1361
|
+
enemy_max_hp = self._read_u16(g_enemy_party_base + 0x58)
|
|
1362
|
+
|
|
1363
|
+
if enemy_species > 0 and enemy_species < 500 and enemy_level > 0 and enemy_level <= 100 and enemy_max_hp > 0:
|
|
1364
|
+
logger.info(f"Found valid opponent in gEnemyParty: Species {enemy_species} Lv{enemy_level}")
|
|
1365
|
+
|
|
1366
|
+
# Read additional data from gEnemyParty (Pokemon struct format)
|
|
1367
|
+
# Note: gEnemyParty uses encrypted Pokemon format, need to decrypt
|
|
1368
|
+
try:
|
|
1369
|
+
# Try to parse the full Pokemon struct using existing utilities
|
|
1370
|
+
enemy_data = self._read_bytes(g_enemy_party_base, self.addresses.PARTY_POKEMON_SIZE)
|
|
1371
|
+
from pokemon_env.emerald_utils import parse_pokemon
|
|
1372
|
+
opponent_pokemon = parse_pokemon(enemy_data)
|
|
1373
|
+
|
|
1374
|
+
opponent_data = {
|
|
1375
|
+
"species": opponent_pokemon.species_name,
|
|
1376
|
+
"level": opponent_pokemon.level,
|
|
1377
|
+
"current_hp": opponent_pokemon.current_hp,
|
|
1378
|
+
"max_hp": opponent_pokemon.max_hp,
|
|
1379
|
+
"hp_percentage": round((opponent_pokemon.current_hp / opponent_pokemon.max_hp * 100) if opponent_pokemon.max_hp > 0 else 0, 1),
|
|
1380
|
+
"status": opponent_pokemon.status.get_status_name() if opponent_pokemon.status else "Normal",
|
|
1381
|
+
"types": [t.name for t in [opponent_pokemon.type1, opponent_pokemon.type2] if t],
|
|
1382
|
+
"moves": opponent_pokemon.moves,
|
|
1383
|
+
"move_pp": opponent_pokemon.move_pp,
|
|
1384
|
+
"is_fainted": opponent_pokemon.current_hp == 0,
|
|
1385
|
+
"is_shiny": opponent_pokemon.is_shiny if hasattr(opponent_pokemon, 'is_shiny') else False
|
|
1386
|
+
}
|
|
1387
|
+
logger.info(f"Successfully parsed gEnemyParty opponent: {opponent_data['species']}")
|
|
1388
|
+
except Exception as e:
|
|
1389
|
+
logger.debug(f"Failed to parse gEnemyParty with full Pokemon parser: {e}")
|
|
1390
|
+
# Fallback to basic reading
|
|
1391
|
+
opponent_data = {
|
|
1392
|
+
"species": f"Species_{enemy_species}",
|
|
1393
|
+
"level": enemy_level,
|
|
1394
|
+
"current_hp": enemy_hp,
|
|
1395
|
+
"max_hp": enemy_max_hp,
|
|
1396
|
+
"hp_percentage": round((enemy_hp / enemy_max_hp * 100) if enemy_max_hp > 0 else 0, 1),
|
|
1397
|
+
"status": "Unknown",
|
|
1398
|
+
"types": [],
|
|
1399
|
+
"moves": [],
|
|
1400
|
+
"move_pp": [],
|
|
1401
|
+
"is_fainted": enemy_hp == 0,
|
|
1402
|
+
"is_shiny": False
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
# Method 2: Try gBattleMons if gEnemyParty didn't work
|
|
1406
|
+
if not opponent_data:
|
|
1407
|
+
logger.debug("gEnemyParty invalid, trying gBattleMons")
|
|
1408
|
+
|
|
1409
|
+
g_battle_mons_base = 0x02024A80
|
|
1410
|
+
battle_pokemon_struct_size = 0x58 # Size of BattlePokemon struct
|
|
1411
|
+
opponent_battler_id = 1 # B_POSITION_OPPONENT_LEFT
|
|
1412
|
+
opponent_base = g_battle_mons_base + (opponent_battler_id * battle_pokemon_struct_size)
|
|
1413
|
+
|
|
1414
|
+
# Read BattlePokemon struct fields directly (from ROM guide)
|
|
1415
|
+
species_id = self._read_u16(opponent_base + 0x00) # u16 species
|
|
1416
|
+
attack = self._read_u16(opponent_base + 0x02) # u16 attack
|
|
1417
|
+
defense = self._read_u16(opponent_base + 0x04) # u16 defense
|
|
1418
|
+
speed = self._read_u16(opponent_base + 0x06) # u16 speed
|
|
1419
|
+
sp_attack = self._read_u16(opponent_base + 0x08) # u16 spAttack
|
|
1420
|
+
sp_defense = self._read_u16(opponent_base + 0x0A) # u16 spDefense
|
|
1421
|
+
type1 = self._read_u8(opponent_base + 0x0C) # u8 type1
|
|
1422
|
+
type2 = self._read_u8(opponent_base + 0x0D) # u8 type2
|
|
1423
|
+
level = self._read_u8(opponent_base + 0x0E) # u8 level
|
|
1424
|
+
current_hp = self._read_u8(opponent_base + 0x0F) # u8 hp
|
|
1425
|
+
max_hp = self._read_u16(opponent_base + 0x10) # u16 maxHP
|
|
1426
|
+
|
|
1427
|
+
# Read moves and PP
|
|
1428
|
+
moves = []
|
|
1429
|
+
move_pp = []
|
|
1430
|
+
for i in range(4):
|
|
1431
|
+
move_id = self._read_u16(opponent_base + 0x12 + (i * 2)) # u16 moves[4]
|
|
1432
|
+
pp = self._read_u8(opponent_base + 0x1A + i) # u8 pp[4]
|
|
1433
|
+
|
|
1434
|
+
if move_id > 0:
|
|
1435
|
+
try:
|
|
1436
|
+
from pokemon_env.enums import Move
|
|
1437
|
+
move = Move(move_id)
|
|
1438
|
+
move_name = move.name.replace('_', ' ').title()
|
|
1439
|
+
moves.append(move_name)
|
|
1440
|
+
except (ValueError, ImportError):
|
|
1441
|
+
moves.append(f"Move_{move_id}")
|
|
1442
|
+
else:
|
|
1443
|
+
moves.append("")
|
|
1444
|
+
move_pp.append(pp)
|
|
1445
|
+
|
|
1446
|
+
# Read status
|
|
1447
|
+
status1 = self._read_u8(opponent_base + 0x1F) # u8 status1
|
|
1448
|
+
|
|
1449
|
+
# Convert status to name
|
|
1450
|
+
status_name = "Normal"
|
|
1451
|
+
if status1 & 0x07: # Sleep
|
|
1452
|
+
status_name = "Sleep"
|
|
1453
|
+
elif status1 & 0x08: # Poison
|
|
1454
|
+
status_name = "Poison"
|
|
1455
|
+
elif status1 & 0x10: # Burn
|
|
1456
|
+
status_name = "Burn"
|
|
1457
|
+
elif status1 & 0x20: # Freeze
|
|
1458
|
+
status_name = "Freeze"
|
|
1459
|
+
elif status1 & 0x40: # Paralysis
|
|
1460
|
+
status_name = "Paralysis"
|
|
1461
|
+
elif status1 & 0x80: # Bad poison
|
|
1462
|
+
status_name = "Bad Poison"
|
|
1463
|
+
|
|
1464
|
+
# Convert types to names
|
|
1465
|
+
type_names = []
|
|
1466
|
+
for type_id in [type1, type2]:
|
|
1467
|
+
if type_id > 0:
|
|
1468
|
+
try:
|
|
1469
|
+
from pokemon_env.enums import PokemonType
|
|
1470
|
+
ptype = PokemonType(type_id)
|
|
1471
|
+
type_names.append(ptype.name.title())
|
|
1472
|
+
except (ValueError, ImportError):
|
|
1473
|
+
type_names.append(f"Type_{type_id}")
|
|
1474
|
+
|
|
1475
|
+
# Convert species to name
|
|
1476
|
+
species_name = f"Species_{species_id}"
|
|
1477
|
+
if species_id > 0:
|
|
1478
|
+
try:
|
|
1479
|
+
from pokemon_env.enums import PokemonSpecies
|
|
1480
|
+
species = PokemonSpecies(species_id)
|
|
1481
|
+
species_name = species.name.replace('_', ' ').title()
|
|
1482
|
+
except (ValueError, ImportError):
|
|
1483
|
+
pass
|
|
1484
|
+
|
|
1485
|
+
# Check if this is valid opponent data
|
|
1486
|
+
if species_id > 0 and species_id < 500 and level > 0 and level <= 100 and max_hp > 0:
|
|
1487
|
+
opponent_data = {
|
|
1488
|
+
"species": species_name,
|
|
1489
|
+
"level": level,
|
|
1490
|
+
"current_hp": current_hp,
|
|
1491
|
+
"max_hp": max_hp,
|
|
1492
|
+
"hp_percentage": round((current_hp / max_hp * 100) if max_hp > 0 else 0, 1),
|
|
1493
|
+
"status": status_name,
|
|
1494
|
+
"types": type_names,
|
|
1495
|
+
"moves": moves,
|
|
1496
|
+
"move_pp": move_pp,
|
|
1497
|
+
"is_fainted": current_hp == 0,
|
|
1498
|
+
"is_shiny": False,
|
|
1499
|
+
"stats": {
|
|
1500
|
+
"attack": attack,
|
|
1501
|
+
"defense": defense,
|
|
1502
|
+
"speed": speed,
|
|
1503
|
+
"sp_attack": sp_attack,
|
|
1504
|
+
"sp_defense": sp_defense
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
logger.info(f"Read opponent from gBattleMons: {species_name} Lv{level}")
|
|
1508
|
+
|
|
1509
|
+
# Method 3: Known opponent addresses (for specific battle states)
|
|
1510
|
+
if not opponent_data:
|
|
1511
|
+
logger.debug("Standard methods failed, checking known opponent addresses")
|
|
1512
|
+
opponent_data = self._check_known_opponent_addresses()
|
|
1513
|
+
|
|
1514
|
+
# Method 4: Dynamic memory scanning as last resort
|
|
1515
|
+
if not opponent_data:
|
|
1516
|
+
logger.debug("All methods failed, trying memory scan")
|
|
1517
|
+
opponent_data = self._scan_for_opponent_pokemon()
|
|
1518
|
+
|
|
1519
|
+
# Opponent detection disabled - feature not working correctly
|
|
1520
|
+
enhanced_battle["opponent_pokemon"] = None
|
|
1521
|
+
enhanced_battle["opponent_status"] = "Opponent detection disabled (feature not reliable)"
|
|
1522
|
+
logger.debug("Opponent detection disabled due to incorrect readings")
|
|
1523
|
+
|
|
1524
|
+
except Exception as e:
|
|
1525
|
+
logger.warning(f"Failed to read opponent battle Pokémon: {e}")
|
|
1526
|
+
|
|
1527
|
+
# Check for remaining opponent Pokémon in trainer battles
|
|
1528
|
+
if enhanced_battle.get("is_trainer_battle"):
|
|
1529
|
+
try:
|
|
1530
|
+
# Read trainer's party size (this might be at a different location)
|
|
1531
|
+
trainer_party_count = 1 # Default assumption
|
|
1532
|
+
enhanced_battle["opponent_team_remaining"] = trainer_party_count
|
|
1533
|
+
except Exception:
|
|
1534
|
+
enhanced_battle["opponent_team_remaining"] = 1
|
|
1535
|
+
|
|
1536
|
+
# Determine battle interface state and available actions
|
|
1537
|
+
try:
|
|
1538
|
+
# Read battle menu state - this would need specific pokeemerald addresses
|
|
1539
|
+
# Battle type unknown, provide all possible actions
|
|
1540
|
+
enhanced_battle["battle_interface"]["available_actions"] = [
|
|
1541
|
+
"FIGHT", "BAG", "POKEMON", "RUN"
|
|
1542
|
+
]
|
|
1543
|
+
|
|
1544
|
+
except Exception as e:
|
|
1545
|
+
logger.debug(f"Failed to read battle interface state: {e}")
|
|
1546
|
+
|
|
1547
|
+
return enhanced_battle
|
|
1548
|
+
|
|
1549
|
+
except Exception as e:
|
|
1550
|
+
logger.warning(f"Failed to read comprehensive battle info: {e}")
|
|
1551
|
+
return None
|
|
1552
|
+
|
|
1553
|
+
def _scan_for_opponent_pokemon(self) -> Dict[str, Any]:
|
|
1554
|
+
"""Dynamic memory scanning to find opponent Pokemon as last resort"""
|
|
1555
|
+
try:
|
|
1556
|
+
# Get player Pokemon for comparison
|
|
1557
|
+
player_party = self.read_party_pokemon()
|
|
1558
|
+
if not player_party:
|
|
1559
|
+
return None
|
|
1560
|
+
|
|
1561
|
+
player_species = player_party[0].species_name
|
|
1562
|
+
player_level = player_party[0].level
|
|
1563
|
+
|
|
1564
|
+
# Scan memory range for Pokemon patterns
|
|
1565
|
+
for addr in range(0x02020000, 0x02030000, 0x4):
|
|
1566
|
+
try:
|
|
1567
|
+
species_id = self._read_u16(addr)
|
|
1568
|
+
level = self._read_u8(addr + 0x0E)
|
|
1569
|
+
hp = self._read_u8(addr + 0x0F)
|
|
1570
|
+
max_hp = self._read_u16(addr + 0x10)
|
|
1571
|
+
|
|
1572
|
+
# Validate as Pokemon data
|
|
1573
|
+
if not (1 <= species_id <= 411 and 1 <= level <= 100 and 0 <= hp <= max_hp and 10 <= max_hp <= 999):
|
|
1574
|
+
continue
|
|
1575
|
+
|
|
1576
|
+
# Get species name
|
|
1577
|
+
species_name = f"Species_{species_id}"
|
|
1578
|
+
try:
|
|
1579
|
+
from pokemon_env.enums import PokemonSpecies
|
|
1580
|
+
species = PokemonSpecies(species_id)
|
|
1581
|
+
species_name = species.name.replace('_', ' ').title()
|
|
1582
|
+
except:
|
|
1583
|
+
pass
|
|
1584
|
+
|
|
1585
|
+
# Skip if this matches player Pokemon
|
|
1586
|
+
if species_name == player_species and level == player_level:
|
|
1587
|
+
continue
|
|
1588
|
+
|
|
1589
|
+
# Check if this is a reasonable opponent (not too high level, etc.)
|
|
1590
|
+
if level >= 3 and level <= 50 and max_hp >= 15:
|
|
1591
|
+
# Read moves to confirm this is battle-ready Pokemon
|
|
1592
|
+
moves = []
|
|
1593
|
+
for i in range(4):
|
|
1594
|
+
move_id = self._read_u16(addr + 0x12 + (i * 2))
|
|
1595
|
+
if move_id > 0:
|
|
1596
|
+
try:
|
|
1597
|
+
from pokemon_env.enums import Move
|
|
1598
|
+
move = Move(move_id)
|
|
1599
|
+
move_name = move.name.replace('_', ' ').title()
|
|
1600
|
+
moves.append(move_name)
|
|
1601
|
+
except:
|
|
1602
|
+
moves.append(f"Move_{move_id}")
|
|
1603
|
+
else:
|
|
1604
|
+
moves.append("")
|
|
1605
|
+
|
|
1606
|
+
# Calculate opponent likelihood score
|
|
1607
|
+
score = 0
|
|
1608
|
+
|
|
1609
|
+
# Prefer Pokemon with moves
|
|
1610
|
+
if any(move.strip() for move in moves):
|
|
1611
|
+
score += 10
|
|
1612
|
+
|
|
1613
|
+
# Prefer Pokemon with reasonable stats for battle
|
|
1614
|
+
if 20 <= level <= 50:
|
|
1615
|
+
score += 20
|
|
1616
|
+
|
|
1617
|
+
# Prefer Pokemon with reasonable HP (not too low or too high)
|
|
1618
|
+
if 50 <= max_hp <= 500:
|
|
1619
|
+
score += 15
|
|
1620
|
+
|
|
1621
|
+
# Prefer known species (not invalid IDs)
|
|
1622
|
+
if "Species_" not in species_name:
|
|
1623
|
+
score += 25
|
|
1624
|
+
|
|
1625
|
+
# Prefer specific strong Pokemon names that are likely opponents
|
|
1626
|
+
strong_names = ["mudkip", "treecko", "torchic", "poochyena", "zigzagoon"]
|
|
1627
|
+
if any(name in species_name.lower() for name in strong_names):
|
|
1628
|
+
score += 30
|
|
1629
|
+
|
|
1630
|
+
# Only consider candidates with a reasonable score
|
|
1631
|
+
if score >= 20:
|
|
1632
|
+
logger.debug(f"Memory scan candidate: {species_name} Lv{level} at {hex(addr)} (score: {score})")
|
|
1633
|
+
|
|
1634
|
+
# Store as candidate (don't return immediately - find the best one)
|
|
1635
|
+
candidate = {
|
|
1636
|
+
"species": species_name,
|
|
1637
|
+
"level": level,
|
|
1638
|
+
"current_hp": hp,
|
|
1639
|
+
"max_hp": max_hp,
|
|
1640
|
+
"hp_percentage": round((hp / max_hp * 100) if max_hp > 0 else 0, 1),
|
|
1641
|
+
"status": "Normal",
|
|
1642
|
+
"types": [],
|
|
1643
|
+
"moves": moves,
|
|
1644
|
+
"move_pp": [],
|
|
1645
|
+
"is_fainted": hp == 0,
|
|
1646
|
+
"is_shiny": False,
|
|
1647
|
+
"stats": {},
|
|
1648
|
+
"address": hex(addr),
|
|
1649
|
+
"score": score
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
# If this is a high-scoring candidate, return it
|
|
1653
|
+
if score >= 50: # High confidence
|
|
1654
|
+
logger.info(f"Memory scan found high-confidence opponent: {species_name} Lv{level} at {hex(addr)} (score: {score})")
|
|
1655
|
+
return candidate
|
|
1656
|
+
|
|
1657
|
+
except Exception:
|
|
1658
|
+
continue
|
|
1659
|
+
|
|
1660
|
+
return None
|
|
1661
|
+
|
|
1662
|
+
except Exception as e:
|
|
1663
|
+
logger.debug(f"Memory scan failed: {e}")
|
|
1664
|
+
return None
|
|
1665
|
+
|
|
1666
|
+
def _check_known_opponent_addresses(self) -> Dict[str, Any]:
|
|
1667
|
+
"""Check previously discovered opponent data locations through generic scanning"""
|
|
1668
|
+
try:
|
|
1669
|
+
# Addresses that were discovered through memory scanning (not Pokemon-specific)
|
|
1670
|
+
candidate_addresses = [
|
|
1671
|
+
0x2026768, # Address where opponent species data was previously found
|
|
1672
|
+
]
|
|
1673
|
+
|
|
1674
|
+
for base_addr in candidate_addresses:
|
|
1675
|
+
try:
|
|
1676
|
+
# Try to read species ID generically
|
|
1677
|
+
species_id = self._read_u16(base_addr)
|
|
1678
|
+
|
|
1679
|
+
# Only proceed if we have a valid Pokemon species ID (1-493 for Gen 3)
|
|
1680
|
+
if 1 <= species_id <= 493:
|
|
1681
|
+
# Convert species to name using existing pattern
|
|
1682
|
+
species_name = f"Species_{species_id}"
|
|
1683
|
+
if species_id > 0:
|
|
1684
|
+
try:
|
|
1685
|
+
from pokemon_env.enums import PokemonSpecies
|
|
1686
|
+
species = PokemonSpecies(species_id)
|
|
1687
|
+
species_name = species.name.replace('_', ' ').title()
|
|
1688
|
+
except (ValueError, ImportError):
|
|
1689
|
+
pass
|
|
1690
|
+
|
|
1691
|
+
# Use specific level address found during debugging (0x202673a for Level 5)
|
|
1692
|
+
level = None
|
|
1693
|
+
level_addr = None
|
|
1694
|
+
|
|
1695
|
+
if base_addr == 0x2026768: # Known Mudkip address
|
|
1696
|
+
# Use the specific level address where Level 5 was found
|
|
1697
|
+
specific_level_addr = 0x202673a
|
|
1698
|
+
try:
|
|
1699
|
+
potential_level = self._read_u8(specific_level_addr)
|
|
1700
|
+
if potential_level == 5: # Verify it's the expected Level 5
|
|
1701
|
+
level = potential_level
|
|
1702
|
+
level_addr = specific_level_addr
|
|
1703
|
+
except:
|
|
1704
|
+
pass
|
|
1705
|
+
|
|
1706
|
+
# Fallback: scan nearby for any reasonable level if specific address failed
|
|
1707
|
+
if level is None:
|
|
1708
|
+
for offset in range(-64, 65):
|
|
1709
|
+
try:
|
|
1710
|
+
check_addr = base_addr + offset
|
|
1711
|
+
potential_level = self._read_u8(check_addr)
|
|
1712
|
+
|
|
1713
|
+
# Accept any reasonable level (1-100)
|
|
1714
|
+
if 1 <= potential_level <= 100:
|
|
1715
|
+
level = potential_level
|
|
1716
|
+
level_addr = check_addr
|
|
1717
|
+
break
|
|
1718
|
+
|
|
1719
|
+
except:
|
|
1720
|
+
continue
|
|
1721
|
+
|
|
1722
|
+
if level and species_name:
|
|
1723
|
+
logger.info(f"Found opponent {species_name} at address {hex(base_addr)}")
|
|
1724
|
+
|
|
1725
|
+
# Try to find HP data near the level
|
|
1726
|
+
current_hp = "Unknown"
|
|
1727
|
+
max_hp = "Unknown"
|
|
1728
|
+
|
|
1729
|
+
if level_addr:
|
|
1730
|
+
for hp_offset in range(-8, 9):
|
|
1731
|
+
try:
|
|
1732
|
+
hp_addr = level_addr + hp_offset
|
|
1733
|
+
hp = self._read_u8(hp_addr)
|
|
1734
|
+
max_hp_candidate = self._read_u16(hp_addr + 1)
|
|
1735
|
+
|
|
1736
|
+
if 1 <= hp <= 200 and 10 <= max_hp_candidate <= 500:
|
|
1737
|
+
current_hp = hp
|
|
1738
|
+
max_hp = max_hp_candidate
|
|
1739
|
+
break
|
|
1740
|
+
except:
|
|
1741
|
+
continue
|
|
1742
|
+
|
|
1743
|
+
return {
|
|
1744
|
+
"species": species_name,
|
|
1745
|
+
"level": level,
|
|
1746
|
+
"current_hp": current_hp,
|
|
1747
|
+
"max_hp": max_hp,
|
|
1748
|
+
"hp_percentage": round((current_hp / max_hp * 100) if isinstance(current_hp, int) and isinstance(max_hp, int) and max_hp > 0 else 0, 1),
|
|
1749
|
+
"status": "Normal",
|
|
1750
|
+
"types": self._get_pokemon_types_from_species(species_id),
|
|
1751
|
+
"moves": [],
|
|
1752
|
+
"move_pp": [],
|
|
1753
|
+
"is_fainted": current_hp == 0 if isinstance(current_hp, int) else False,
|
|
1754
|
+
"is_shiny": False,
|
|
1755
|
+
"stats": {}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
except Exception as e:
|
|
1759
|
+
logger.debug(f"Failed to read data at address {hex(base_addr)}: {e}")
|
|
1760
|
+
continue
|
|
1761
|
+
|
|
1762
|
+
except Exception as e:
|
|
1763
|
+
logger.debug(f"Known address check failed: {e}")
|
|
1764
|
+
|
|
1765
|
+
return None
|
|
1766
|
+
|
|
1767
|
+
def _check_enemy_party_exists(self):
|
|
1768
|
+
"""Check if enemy party data exists (indicator of trainer battle)"""
|
|
1769
|
+
try:
|
|
1770
|
+
enemy_party_addr = 0x02023BC0 # gEnemyParty
|
|
1771
|
+
# Check first few slots for valid Pokemon species
|
|
1772
|
+
for slot in range(3):
|
|
1773
|
+
offset = slot * 100 # Approximate Pokemon struct size
|
|
1774
|
+
species_id = self._read_u16(enemy_party_addr + offset)
|
|
1775
|
+
if 1 <= species_id <= 493:
|
|
1776
|
+
return True
|
|
1777
|
+
return False
|
|
1778
|
+
except Exception:
|
|
1779
|
+
return False
|
|
1780
|
+
|
|
1781
|
+
def _validate_opponent_data(self, opponent_data):
|
|
1782
|
+
"""Validate opponent data for reasonableness to avoid showing incorrect info"""
|
|
1783
|
+
try:
|
|
1784
|
+
# Check required fields exist
|
|
1785
|
+
if not opponent_data or not isinstance(opponent_data, dict):
|
|
1786
|
+
return False
|
|
1787
|
+
|
|
1788
|
+
species = opponent_data.get('species', '')
|
|
1789
|
+
level = opponent_data.get('level', 0)
|
|
1790
|
+
|
|
1791
|
+
# Basic sanity checks
|
|
1792
|
+
if not species or species.startswith('Species_'):
|
|
1793
|
+
logger.debug(f"Invalid species name: {species}")
|
|
1794
|
+
return False
|
|
1795
|
+
|
|
1796
|
+
if not isinstance(level, int) or level < 1 or level > 100:
|
|
1797
|
+
logger.debug(f"Invalid level: {level}")
|
|
1798
|
+
return False
|
|
1799
|
+
|
|
1800
|
+
# HP validation (if provided)
|
|
1801
|
+
current_hp = opponent_data.get('current_hp')
|
|
1802
|
+
max_hp = opponent_data.get('max_hp')
|
|
1803
|
+
|
|
1804
|
+
if current_hp is not None and current_hp != "Unknown":
|
|
1805
|
+
if not isinstance(current_hp, int) or current_hp < 0:
|
|
1806
|
+
logger.debug(f"Invalid current HP: {current_hp}")
|
|
1807
|
+
return False
|
|
1808
|
+
|
|
1809
|
+
if max_hp is not None and max_hp != "Unknown":
|
|
1810
|
+
if not isinstance(max_hp, int) or max_hp <= 0:
|
|
1811
|
+
logger.debug(f"Invalid max HP: {max_hp}")
|
|
1812
|
+
return False
|
|
1813
|
+
|
|
1814
|
+
# Current HP shouldn't exceed max HP
|
|
1815
|
+
if isinstance(current_hp, int) and current_hp > max_hp:
|
|
1816
|
+
logger.debug(f"Current HP ({current_hp}) exceeds max HP ({max_hp})")
|
|
1817
|
+
return False
|
|
1818
|
+
|
|
1819
|
+
# Additional validation: HP should be reasonable for the level
|
|
1820
|
+
if isinstance(max_hp, int) and isinstance(level, int):
|
|
1821
|
+
# Very rough estimate: max HP should be at least level + 10, but not more than level * 10
|
|
1822
|
+
if max_hp < level + 5 or max_hp > level * 15:
|
|
1823
|
+
logger.debug(f"Max HP ({max_hp}) seems unreasonable for level {level}")
|
|
1824
|
+
return False
|
|
1825
|
+
|
|
1826
|
+
logger.debug(f"Opponent data validation passed: {species} Lv{level}")
|
|
1827
|
+
return True
|
|
1828
|
+
|
|
1829
|
+
except Exception as e:
|
|
1830
|
+
logger.debug(f"Opponent data validation error: {e}")
|
|
1831
|
+
return False
|
|
1832
|
+
|
|
1833
|
+
def _get_pokemon_types_from_species(self, species_id):
|
|
1834
|
+
"""Get Pokemon types from species ID"""
|
|
1835
|
+
try:
|
|
1836
|
+
# For now, return empty list as a placeholder
|
|
1837
|
+
# This could be enhanced with actual type lookup
|
|
1838
|
+
return []
|
|
1839
|
+
except Exception:
|
|
1840
|
+
return []
|
|
1841
|
+
|
|
1842
|
+
# Map reading methods (keeping existing implementation for now)
|
|
1843
|
+
def _validate_buffer_data(self, buffer_addr, width, height):
|
|
1844
|
+
"""Validate buffer doesn't contain too many corruption markers"""
|
|
1845
|
+
try:
|
|
1846
|
+
# Only validate if we're looking for outdoor maps (they shouldn't have many 0x3FF tiles)
|
|
1847
|
+
# Indoor maps might legitimately have these tiles
|
|
1848
|
+
corruption_count = 0
|
|
1849
|
+
sample_size = min(100, width * height) # Sample first 100 tiles
|
|
1850
|
+
|
|
1851
|
+
for i in range(sample_size):
|
|
1852
|
+
tile_addr = buffer_addr + (i * 2)
|
|
1853
|
+
tile_value = self._read_u16(tile_addr)
|
|
1854
|
+
tile_id = tile_value & 0x03FF
|
|
1855
|
+
|
|
1856
|
+
# Tile ID 1023 (0x3FF) is a corruption marker
|
|
1857
|
+
if tile_id == 0x3FF:
|
|
1858
|
+
corruption_count += 1
|
|
1859
|
+
|
|
1860
|
+
corruption_ratio = corruption_count / sample_size
|
|
1861
|
+
|
|
1862
|
+
# Be more lenient - only reject if more than 50% are corruption markers
|
|
1863
|
+
# This should still catch the bad buffers while allowing some legitimate ones
|
|
1864
|
+
if corruption_ratio > 0.5:
|
|
1865
|
+
logger.debug(f"Buffer at 0x{buffer_addr:08X} has {corruption_ratio:.1%} corruption markers, rejecting")
|
|
1866
|
+
return False
|
|
1867
|
+
|
|
1868
|
+
return True
|
|
1869
|
+
except Exception:
|
|
1870
|
+
return True # If we can't validate, assume it's OK
|
|
1871
|
+
|
|
1872
|
+
def _find_map_buffer_addresses(self):
|
|
1873
|
+
"""Find map buffer addresses - SIMPLIFIED to avoid over-filtering"""
|
|
1874
|
+
# First, try to invalidate any existing cache if we're having issues
|
|
1875
|
+
if self._map_buffer_addr and (self._map_width is None or self._map_height is None):
|
|
1876
|
+
logger.warning("Invalid map cache detected, clearing...")
|
|
1877
|
+
self.invalidate_map_cache()
|
|
1878
|
+
|
|
1879
|
+
# SIMPLE APPROACH: Take the first valid buffer found (like original code)
|
|
1880
|
+
for offset in range(0, 0x8000 - 12, 4):
|
|
1881
|
+
try:
|
|
1882
|
+
width = self._read_u32(0x03000000 + offset)
|
|
1883
|
+
height = self._read_u32(0x03000000 + offset + 4)
|
|
1884
|
+
|
|
1885
|
+
# More strict validation for reasonable map dimensions
|
|
1886
|
+
if 10 <= width <= 200 and 10 <= height <= 200:
|
|
1887
|
+
map_ptr = self._read_u32(0x03000000 + offset + 8)
|
|
1888
|
+
|
|
1889
|
+
# Validate map pointer is in valid EWRAM range
|
|
1890
|
+
if 0x02000000 <= map_ptr <= 0x02040000:
|
|
1891
|
+
# Additional validation: check if the map pointer points to valid memory
|
|
1892
|
+
try:
|
|
1893
|
+
# Try to read a small amount of data from the map pointer
|
|
1894
|
+
test_data = self._read_bytes(map_ptr, 4)
|
|
1895
|
+
if len(test_data) == 4:
|
|
1896
|
+
# Only validate non-preferred buffers
|
|
1897
|
+
# The preferred buffer (0x02032318) should always be used if found
|
|
1898
|
+
if map_ptr != 0x02032318:
|
|
1899
|
+
# Validate buffer doesn't have too many corruption markers
|
|
1900
|
+
if not self._validate_buffer_data(map_ptr, width, height):
|
|
1901
|
+
logger.debug(f"Buffer at 0x{map_ptr:08X} failed validation, skipping")
|
|
1902
|
+
continue
|
|
1903
|
+
|
|
1904
|
+
# FORCE CONSISTENT BUFFER: Use specific buffer address that direct emulator uses
|
|
1905
|
+
# If we find the known good buffer (0x02032318), use it preferentially
|
|
1906
|
+
if map_ptr == 0x02032318:
|
|
1907
|
+
logger.info(f"Found preferred buffer at 0x{map_ptr:08X} with size {width}x{height}")
|
|
1908
|
+
self._map_buffer_addr = map_ptr
|
|
1909
|
+
self._map_width = width
|
|
1910
|
+
self._map_height = height
|
|
1911
|
+
return True
|
|
1912
|
+
# Store this as a fallback
|
|
1913
|
+
elif not hasattr(self, '_fallback_buffer'):
|
|
1914
|
+
self._fallback_buffer = (map_ptr, width, height)
|
|
1915
|
+
continue
|
|
1916
|
+
except Exception as e:
|
|
1917
|
+
logger.debug(f"Map pointer validation failed at 0x{map_ptr:08X}: {e}")
|
|
1918
|
+
continue
|
|
1919
|
+
except Exception as e:
|
|
1920
|
+
logger.debug(f"Map pointer validation failed at 0x{map_ptr:08X}: {e}")
|
|
1921
|
+
continue
|
|
1922
|
+
except Exception as e:
|
|
1923
|
+
# Log only if this is a recurring issue
|
|
1924
|
+
if offset % 1000 == 0:
|
|
1925
|
+
logger.debug(f"Error scanning map buffer at offset {offset}: {e}")
|
|
1926
|
+
continue
|
|
1927
|
+
|
|
1928
|
+
# If preferred buffer not found, use fallback
|
|
1929
|
+
if hasattr(self, '_fallback_buffer'):
|
|
1930
|
+
map_ptr, width, height = self._fallback_buffer
|
|
1931
|
+
logger.info(f"Using fallback buffer at 0x{map_ptr:08X} with size {width}x{height}")
|
|
1932
|
+
self._map_buffer_addr = map_ptr
|
|
1933
|
+
self._map_width = width
|
|
1934
|
+
self._map_height = height
|
|
1935
|
+
delattr(self, '_fallback_buffer')
|
|
1936
|
+
return True
|
|
1937
|
+
|
|
1938
|
+
self._rate_limited_warning("Could not find valid map buffer addresses", "map_buffer")
|
|
1939
|
+
return False
|
|
1940
|
+
|
|
1941
|
+
def _find_alternative_buffer(self):
|
|
1942
|
+
"""Try alternative methods to find a clean map buffer"""
|
|
1943
|
+
logger.info("Searching for alternative map buffer...")
|
|
1944
|
+
|
|
1945
|
+
# Method 1: Scan a wider memory range
|
|
1946
|
+
for offset in range(0x8000, 0x10000 - 12, 4):
|
|
1947
|
+
try:
|
|
1948
|
+
width = self._read_u32(0x03000000 + offset)
|
|
1949
|
+
height = self._read_u32(0x03000000 + offset + 4)
|
|
1950
|
+
|
|
1951
|
+
if 10 <= width <= 200 and 10 <= height <= 200:
|
|
1952
|
+
map_ptr = self._read_u32(0x03000000 + offset + 8)
|
|
1953
|
+
|
|
1954
|
+
if 0x02000000 <= map_ptr <= 0x02040000:
|
|
1955
|
+
try:
|
|
1956
|
+
test_data = self._read_bytes(map_ptr, 4)
|
|
1957
|
+
if len(test_data) == 4:
|
|
1958
|
+
is_current = self._validate_buffer_currency(map_ptr, width, height)
|
|
1959
|
+
if is_current:
|
|
1960
|
+
self._map_buffer_addr = map_ptr
|
|
1961
|
+
self._map_width = width
|
|
1962
|
+
self._map_height = height
|
|
1963
|
+
logger.info(f"Found alternative buffer at 0x{map_ptr:08X} ({width}x{height})")
|
|
1964
|
+
return True
|
|
1965
|
+
except Exception:
|
|
1966
|
+
continue
|
|
1967
|
+
except Exception:
|
|
1968
|
+
continue
|
|
1969
|
+
|
|
1970
|
+
# Method 2: Accept any buffer with lower corruption threshold
|
|
1971
|
+
logger.info("No clean buffer found, looking for least corrupted...")
|
|
1972
|
+
for offset in range(0, 0x8000 - 12, 4):
|
|
1973
|
+
try:
|
|
1974
|
+
width = self._read_u32(0x03000000 + offset)
|
|
1975
|
+
height = self._read_u32(0x03000000 + offset + 4)
|
|
1976
|
+
|
|
1977
|
+
if 10 <= width <= 200 and 10 <= height <= 200:
|
|
1978
|
+
map_ptr = self._read_u32(0x03000000 + offset + 8)
|
|
1979
|
+
|
|
1980
|
+
if 0x02000000 <= map_ptr <= 0x02040000:
|
|
1981
|
+
try:
|
|
1982
|
+
test_data = self._read_bytes(map_ptr, 4)
|
|
1983
|
+
if len(test_data) == 4:
|
|
1984
|
+
# Accept any buffer - we'll use the first valid one found
|
|
1985
|
+
self._map_buffer_addr = map_ptr
|
|
1986
|
+
self._map_width = width
|
|
1987
|
+
self._map_height = height
|
|
1988
|
+
logger.warning(f"Using potentially corrupted buffer at 0x{map_ptr:08X} ({width}x{height}) as fallback")
|
|
1989
|
+
return True
|
|
1990
|
+
except Exception:
|
|
1991
|
+
continue
|
|
1992
|
+
except Exception:
|
|
1993
|
+
continue
|
|
1994
|
+
|
|
1995
|
+
logger.error("No alternative buffer found")
|
|
1996
|
+
return False
|
|
1997
|
+
|
|
1998
|
+
def _validate_buffer_currency(self, buffer_addr, width, height):
|
|
1999
|
+
"""Check if a buffer contains current (non-corrupted) map data"""
|
|
2000
|
+
try:
|
|
2001
|
+
# Sample more tiles and check for corruption patterns
|
|
2002
|
+
sample_size = min(50, width * height)
|
|
2003
|
+
corrupted_count = 0
|
|
2004
|
+
total_sampled = 0
|
|
2005
|
+
tile_frequency = {}
|
|
2006
|
+
|
|
2007
|
+
for i in range(0, sample_size, 1): # Sample every tile, not every 4th
|
|
2008
|
+
try:
|
|
2009
|
+
tile_data = self._read_u16(buffer_addr + i * 2)
|
|
2010
|
+
total_sampled += 1
|
|
2011
|
+
|
|
2012
|
+
# Track tile frequency for repetition detection
|
|
2013
|
+
tile_frequency[tile_data] = tile_frequency.get(tile_data, 0) + 1
|
|
2014
|
+
|
|
2015
|
+
# Check for corruption patterns
|
|
2016
|
+
if (tile_data == 0xFFFF or tile_data == 0x3FF or # 1023 pattern
|
|
2017
|
+
tile_data == 0x0000 or tile_data == 0x1FF): # Other corruption patterns
|
|
2018
|
+
corrupted_count += 1
|
|
2019
|
+
except Exception:
|
|
2020
|
+
corrupted_count += 1
|
|
2021
|
+
|
|
2022
|
+
if total_sampled == 0:
|
|
2023
|
+
return False
|
|
2024
|
+
|
|
2025
|
+
# Check for excessive repetition (sign of corruption)
|
|
2026
|
+
max_frequency = max(tile_frequency.values()) if tile_frequency else 0
|
|
2027
|
+
repetition_ratio = max_frequency / total_sampled if total_sampled > 0 else 0
|
|
2028
|
+
|
|
2029
|
+
corruption_ratio = corrupted_count / total_sampled
|
|
2030
|
+
|
|
2031
|
+
# More strict criteria: current if low corruption AND low repetition
|
|
2032
|
+
is_current = (corruption_ratio < 0.3 and repetition_ratio < 0.5)
|
|
2033
|
+
|
|
2034
|
+
logger.debug(f"Buffer 0x{buffer_addr:08X}: {corruption_ratio:.1%} corrupted, {repetition_ratio:.1%} repetition ({corrupted_count}/{total_sampled}) - current: {is_current}")
|
|
2035
|
+
|
|
2036
|
+
# Show most common tiles for debugging
|
|
2037
|
+
if tile_frequency:
|
|
2038
|
+
sorted_tiles = sorted(tile_frequency.items(), key=lambda x: x[1], reverse=True)
|
|
2039
|
+
top_tiles = sorted_tiles[:3]
|
|
2040
|
+
logger.debug(f" Top tiles: {[(hex(tile), count) for tile, count in top_tiles]}")
|
|
2041
|
+
|
|
2042
|
+
return is_current
|
|
2043
|
+
|
|
2044
|
+
except Exception as e:
|
|
2045
|
+
logger.debug(f"Buffer currency validation failed for 0x{buffer_addr:08X}: {e}")
|
|
2046
|
+
return False
|
|
2047
|
+
|
|
2048
|
+
def read_map_around_player(self, radius: int = 7) -> List[List[Tuple[int, MetatileBehavior, int, int]]]:
|
|
2049
|
+
"""Read map area around player with improved error handling for area transitions"""
|
|
2050
|
+
# Check for area transitions (re-enabled with minimal logic)
|
|
2051
|
+
location = self.read_location()
|
|
2052
|
+
position = self.read_coordinates()
|
|
2053
|
+
transition_detected = self._check_area_transition()
|
|
2054
|
+
|
|
2055
|
+
# If we just transitioned, add a small delay for game state to stabilize
|
|
2056
|
+
# This addresses the frame-dependent timing issues mentioned in emerald_npc_decompilation
|
|
2057
|
+
if transition_detected:
|
|
2058
|
+
time.sleep(0.05) # 50ms delay to let game scripts complete
|
|
2059
|
+
logger.debug("Applied post-transition delay for game state stabilization")
|
|
2060
|
+
|
|
2061
|
+
# Always ensure map buffer is found
|
|
2062
|
+
if not self._map_buffer_addr:
|
|
2063
|
+
if not self._find_map_buffer_addresses():
|
|
2064
|
+
self._rate_limited_warning("Failed to find map buffer addresses, returning empty map", "map_buffer")
|
|
2065
|
+
return []
|
|
2066
|
+
|
|
2067
|
+
# Read map data with simple validation and retry
|
|
2068
|
+
map_data = self._read_map_data_internal(radius)
|
|
2069
|
+
|
|
2070
|
+
# Additional corruption detection: check for invalid map buffer data
|
|
2071
|
+
if map_data and self._map_buffer_addr:
|
|
2072
|
+
try:
|
|
2073
|
+
# Verify buffer is still valid by re-reading dimensions
|
|
2074
|
+
current_width = self._read_u32(self._map_buffer_addr - 8)
|
|
2075
|
+
current_height = self._read_u32(self._map_buffer_addr - 4)
|
|
2076
|
+
|
|
2077
|
+
# If dimensions changed significantly, buffer may be corrupted
|
|
2078
|
+
if (abs(current_width - self._map_width) > 5 or
|
|
2079
|
+
abs(current_height - self._map_height) > 5 or
|
|
2080
|
+
current_width <= 0 or current_height <= 0 or
|
|
2081
|
+
current_width > 1000 or current_height > 1000):
|
|
2082
|
+
|
|
2083
|
+
# Use unified rate limiter for corruption warnings
|
|
2084
|
+
self._rate_limited_warning(f"Map buffer corruption detected: dimensions changed from {self._map_width}x{self._map_height} to {current_width}x{current_height}", "map_corruption")
|
|
2085
|
+
|
|
2086
|
+
self._map_buffer_addr = None
|
|
2087
|
+
self._map_width = None
|
|
2088
|
+
self._map_height = None
|
|
2089
|
+
|
|
2090
|
+
# Try to recover by re-finding buffer
|
|
2091
|
+
if self._find_map_buffer_addresses():
|
|
2092
|
+
logger.debug("Recovered from map buffer corruption")
|
|
2093
|
+
map_data = self._read_map_data_internal(radius)
|
|
2094
|
+
else:
|
|
2095
|
+
logger.error("Failed to recover from map buffer corruption")
|
|
2096
|
+
return []
|
|
2097
|
+
except Exception as e:
|
|
2098
|
+
logger.debug(f"Error checking buffer validity: {e}")
|
|
2099
|
+
# Don't fail completely on validation errors
|
|
2100
|
+
|
|
2101
|
+
# Quick validation: check for too many unknown tiles (only for outdoor areas)
|
|
2102
|
+
if map_data and len(map_data) > 0:
|
|
2103
|
+
try:
|
|
2104
|
+
location_name = self.read_location()
|
|
2105
|
+
except Exception:
|
|
2106
|
+
location_name = ""
|
|
2107
|
+
|
|
2108
|
+
# Only apply validation for outdoor areas, skip for indoor/house areas
|
|
2109
|
+
is_outdoor = location_name and any(keyword in location_name.upper() for keyword in ['TOWN', 'ROUTE', 'CITY', 'ROAD', 'PATH']) if location_name else False
|
|
2110
|
+
|
|
2111
|
+
if is_outdoor:
|
|
2112
|
+
total_tiles = sum(len(row) for row in map_data)
|
|
2113
|
+
unknown_count = 0
|
|
2114
|
+
|
|
2115
|
+
for row in map_data:
|
|
2116
|
+
for tile in row:
|
|
2117
|
+
if len(tile) >= 2:
|
|
2118
|
+
behavior = tile[1]
|
|
2119
|
+
if hasattr(behavior, 'name') and behavior.name == 'UNKNOWN':
|
|
2120
|
+
unknown_count += 1
|
|
2121
|
+
elif isinstance(behavior, int) and behavior == 0: # UNKNOWN = 0
|
|
2122
|
+
unknown_count += 1
|
|
2123
|
+
|
|
2124
|
+
unknown_ratio = unknown_count / total_tiles if total_tiles > 0 else 0
|
|
2125
|
+
|
|
2126
|
+
# If more than 50% unknown tiles in outdoor areas, try once more
|
|
2127
|
+
if unknown_ratio > 0.5:
|
|
2128
|
+
logger.info(f"Outdoor map has {unknown_ratio:.1%} unknown tiles, retrying with cache invalidation")
|
|
2129
|
+
self.invalidate_map_cache()
|
|
2130
|
+
if self._find_map_buffer_addresses():
|
|
2131
|
+
map_data = self._read_map_data_internal(radius)
|
|
2132
|
+
else:
|
|
2133
|
+
logger.debug(f"Skipping validation for indoor area: {location_name}")
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
logger.info(f"Map data: {map_data}")
|
|
2137
|
+
|
|
2138
|
+
return map_data
|
|
2139
|
+
|
|
2140
|
+
# DISABLED: Try reading map with validation and retry logic
|
|
2141
|
+
# Use fewer retries for server performance
|
|
2142
|
+
max_retries = 2
|
|
2143
|
+
for attempt in range(max_retries):
|
|
2144
|
+
# Always try to find map buffer addresses if not already found
|
|
2145
|
+
if not self._map_buffer_addr:
|
|
2146
|
+
if not self._find_map_buffer_addresses():
|
|
2147
|
+
self._rate_limited_warning("Failed to find map buffer addresses, returning empty map", "map_buffer")
|
|
2148
|
+
return []
|
|
2149
|
+
|
|
2150
|
+
# Try to read the map data
|
|
2151
|
+
map_data = self._read_map_data_internal(radius)
|
|
2152
|
+
|
|
2153
|
+
if not map_data:
|
|
2154
|
+
logger.warning(f"Map read attempt {attempt + 1} returned empty data")
|
|
2155
|
+
if attempt < max_retries - 1:
|
|
2156
|
+
self.invalidate_map_cache()
|
|
2157
|
+
continue
|
|
2158
|
+
return []
|
|
2159
|
+
|
|
2160
|
+
# Validate the map data
|
|
2161
|
+
is_valid, validation_msg = self._validate_map_data(map_data, location_name)
|
|
2162
|
+
|
|
2163
|
+
if is_valid:
|
|
2164
|
+
logger.debug(f"Map validation passed on attempt {attempt + 1}: {validation_msg}")
|
|
2165
|
+
return map_data
|
|
2166
|
+
else:
|
|
2167
|
+
logger.warning(f"Map validation failed on attempt {attempt + 1}: {validation_msg}")
|
|
2168
|
+
if attempt < max_retries - 1:
|
|
2169
|
+
logger.info(f"Invalidating cache and retrying... (attempt {attempt + 2}/{max_retries})")
|
|
2170
|
+
self.invalidate_map_cache()
|
|
2171
|
+
# Force re-finding map buffer addresses with timeout
|
|
2172
|
+
start_time = time.time()
|
|
2173
|
+
if not self._find_map_buffer_addresses():
|
|
2174
|
+
logger.warning("Failed to re-find map buffer addresses during retry")
|
|
2175
|
+
continue
|
|
2176
|
+
# Don't spend too much time on retries (max 2 seconds total)
|
|
2177
|
+
if time.time() - start_time > 2.0:
|
|
2178
|
+
logger.warning("Map buffer search taking too long, returning current data")
|
|
2179
|
+
return map_data
|
|
2180
|
+
else:
|
|
2181
|
+
logger.warning(f"All {max_retries} map reading attempts failed validation, returning data anyway")
|
|
2182
|
+
return map_data # Return the last attempt even if invalid
|
|
2183
|
+
|
|
2184
|
+
return []
|
|
2185
|
+
|
|
2186
|
+
def _read_map_data_internal(self, radius: int = 7) -> List[List[Tuple[int, MetatileBehavior, int, int]]]:
|
|
2187
|
+
"""Internal method to read map data without validation/retry logic"""
|
|
2188
|
+
|
|
2189
|
+
try:
|
|
2190
|
+
player_x, player_y = self.read_coordinates()
|
|
2191
|
+
|
|
2192
|
+
# Validate player coordinates
|
|
2193
|
+
if player_x < 0 or player_y < 0:
|
|
2194
|
+
logger.warning(f"Invalid player coordinates: ({player_x}, {player_y})")
|
|
2195
|
+
return []
|
|
2196
|
+
|
|
2197
|
+
# Check if map dimensions are valid
|
|
2198
|
+
if not self._map_width or not self._map_height:
|
|
2199
|
+
logger.warning("Invalid map dimensions, attempting to re-find map buffer")
|
|
2200
|
+
self.invalidate_map_cache()
|
|
2201
|
+
if not self._find_map_buffer_addresses():
|
|
2202
|
+
return []
|
|
2203
|
+
|
|
2204
|
+
map_x = player_x + 7
|
|
2205
|
+
map_y = player_y + 7
|
|
2206
|
+
|
|
2207
|
+
# Ensure consistent 15x15 output by adjusting boundaries
|
|
2208
|
+
target_width = 2 * radius + 1 # Should be 15 for radius=7
|
|
2209
|
+
target_height = 2 * radius + 1
|
|
2210
|
+
|
|
2211
|
+
# Calculate ideal boundaries
|
|
2212
|
+
ideal_x_start = map_x - radius
|
|
2213
|
+
ideal_y_start = map_y - radius
|
|
2214
|
+
ideal_x_end = map_x + radius + 1
|
|
2215
|
+
ideal_y_end = map_y + radius + 1
|
|
2216
|
+
|
|
2217
|
+
# Adjust boundaries to stay within buffer while maintaining target size
|
|
2218
|
+
x_start = max(0, min(ideal_x_start, self._map_width - target_width))
|
|
2219
|
+
y_start = max(0, min(ideal_y_start, self._map_height - target_height))
|
|
2220
|
+
x_end = min(self._map_width, x_start + target_width)
|
|
2221
|
+
y_end = min(self._map_height, y_start + target_height)
|
|
2222
|
+
|
|
2223
|
+
# Validate that we have a reasonable area to read
|
|
2224
|
+
if x_end <= x_start or y_end <= y_start:
|
|
2225
|
+
logger.warning(f"Invalid map reading area: x({x_start}-{x_end}), y({y_start}-{y_end})")
|
|
2226
|
+
return []
|
|
2227
|
+
|
|
2228
|
+
width = x_end - x_start
|
|
2229
|
+
height = y_end - y_start
|
|
2230
|
+
|
|
2231
|
+
# Additional validation for reasonable dimensions
|
|
2232
|
+
if width > 50 or height > 50:
|
|
2233
|
+
logger.warning(f"Map reading area too large: {width}x{height}, limiting to 15x15")
|
|
2234
|
+
width = min(width, 15)
|
|
2235
|
+
height = min(height, 15)
|
|
2236
|
+
|
|
2237
|
+
return self.read_map_metatiles(x_start, y_start, width, height)
|
|
2238
|
+
except Exception as e:
|
|
2239
|
+
logger.warning(f"Failed to read map data internally: {e}")
|
|
2240
|
+
return []
|
|
2241
|
+
|
|
2242
|
+
def read_map_metatiles(self, x_start: int = 0, y_start: int = 0, width: int = None, height: int = None) -> List[List[Tuple[int, MetatileBehavior, int, int]]]:
|
|
2243
|
+
"""Read map metatiles with improved error handling"""
|
|
2244
|
+
if not self._map_buffer_addr:
|
|
2245
|
+
self._rate_limited_warning("No map buffer address available", "map_buffer")
|
|
2246
|
+
return []
|
|
2247
|
+
|
|
2248
|
+
if width is None:
|
|
2249
|
+
width = self._map_width
|
|
2250
|
+
if height is None:
|
|
2251
|
+
height = self._map_height
|
|
2252
|
+
|
|
2253
|
+
# Validate dimensions
|
|
2254
|
+
if not width or not height:
|
|
2255
|
+
logger.warning(f"Invalid map dimensions: {width}x{height}")
|
|
2256
|
+
return []
|
|
2257
|
+
|
|
2258
|
+
width = min(width, self._map_width - x_start)
|
|
2259
|
+
height = min(height, self._map_height - y_start)
|
|
2260
|
+
|
|
2261
|
+
# Additional validation
|
|
2262
|
+
if width <= 0 or height <= 0:
|
|
2263
|
+
logger.warning(f"Invalid reading area: {width}x{height} at ({x_start}, {y_start})")
|
|
2264
|
+
return []
|
|
2265
|
+
|
|
2266
|
+
try:
|
|
2267
|
+
metatiles = []
|
|
2268
|
+
for y in range(y_start, y_start + height):
|
|
2269
|
+
row = []
|
|
2270
|
+
for x in range(x_start, x_start + width):
|
|
2271
|
+
try:
|
|
2272
|
+
index = x + y * self._map_width
|
|
2273
|
+
metatile_addr = self._map_buffer_addr + (index * 2)
|
|
2274
|
+
|
|
2275
|
+
# Validate address before reading
|
|
2276
|
+
if metatile_addr < self._map_buffer_addr or metatile_addr >= self._map_buffer_addr + (self._map_width * self._map_height * 2):
|
|
2277
|
+
logger.debug(f"Invalid metatile address: 0x{metatile_addr:08X}")
|
|
2278
|
+
row.append((0, MetatileBehavior.NORMAL, 0, 0))
|
|
2279
|
+
continue
|
|
2280
|
+
|
|
2281
|
+
metatile_value = self._read_u16(metatile_addr)
|
|
2282
|
+
|
|
2283
|
+
metatile_id = metatile_value & 0x03FF
|
|
2284
|
+
collision = (metatile_value & 0x0C00) >> 10
|
|
2285
|
+
elevation = (metatile_value & 0xF000) >> 12
|
|
2286
|
+
|
|
2287
|
+
# Validate metatile ID
|
|
2288
|
+
if metatile_id > 0x3FF:
|
|
2289
|
+
logger.debug(f"Invalid metatile ID: {metatile_id}")
|
|
2290
|
+
metatile_id = 0
|
|
2291
|
+
|
|
2292
|
+
behavior = self.get_exact_behavior_from_id(metatile_id)
|
|
2293
|
+
row.append((metatile_id, behavior, collision, elevation))
|
|
2294
|
+
except Exception as e:
|
|
2295
|
+
logger.debug(f"Error reading metatile at ({x}, {y}): {e}")
|
|
2296
|
+
row.append((0, MetatileBehavior.NORMAL, 0, 0))
|
|
2297
|
+
metatiles.append(row)
|
|
2298
|
+
|
|
2299
|
+
return metatiles
|
|
2300
|
+
except Exception as e:
|
|
2301
|
+
logger.warning(f"Failed to read map metatiles: {e}")
|
|
2302
|
+
return []
|
|
2303
|
+
|
|
2304
|
+
# Tileset reading methods (keeping existing implementation)
|
|
2305
|
+
def get_map_layout_base_address(self) -> int:
|
|
2306
|
+
"""Get map layout base address"""
|
|
2307
|
+
try:
|
|
2308
|
+
return self._read_u32(self.addresses.MAP_HEADER + self.addresses.MAP_LAYOUT_OFFSET)
|
|
2309
|
+
except Exception as e:
|
|
2310
|
+
logger.warning(f"Failed to read map layout base address: {e}")
|
|
2311
|
+
return 0
|
|
2312
|
+
|
|
2313
|
+
def get_tileset_pointers(self, map_layout_base_address: int) -> Tuple[int, int]:
|
|
2314
|
+
"""Get tileset pointers"""
|
|
2315
|
+
if not map_layout_base_address:
|
|
2316
|
+
return (0, 0)
|
|
2317
|
+
|
|
2318
|
+
try:
|
|
2319
|
+
primary = self._read_u32(map_layout_base_address + self.addresses.PRIMARY_TILESET_OFFSET)
|
|
2320
|
+
secondary = self._read_u32(map_layout_base_address + self.addresses.SECONDARY_TILESET_OFFSET)
|
|
2321
|
+
return (primary, secondary)
|
|
2322
|
+
except Exception as e:
|
|
2323
|
+
logger.warning(f"Failed to read tileset pointers: {e}")
|
|
2324
|
+
return (0, 0)
|
|
2325
|
+
|
|
2326
|
+
def read_metatile_behaviors_from_tileset(self, tileset_base_address: int, num_metatiles: int) -> List[int]:
|
|
2327
|
+
"""Read metatile behaviors from tileset"""
|
|
2328
|
+
if not tileset_base_address or num_metatiles <= 0:
|
|
2329
|
+
return []
|
|
2330
|
+
|
|
2331
|
+
try:
|
|
2332
|
+
attributes_ptr = self._read_u32(tileset_base_address + 0x10)
|
|
2333
|
+
if not attributes_ptr:
|
|
2334
|
+
return []
|
|
2335
|
+
|
|
2336
|
+
bytes_to_read = num_metatiles * 2
|
|
2337
|
+
attribute_bytes = self._read_bytes(attributes_ptr, bytes_to_read)
|
|
2338
|
+
|
|
2339
|
+
if len(attribute_bytes) != bytes_to_read:
|
|
2340
|
+
return []
|
|
2341
|
+
|
|
2342
|
+
behaviors = []
|
|
2343
|
+
for i in range(num_metatiles):
|
|
2344
|
+
byte_offset = i * 2
|
|
2345
|
+
byte1 = attribute_bytes[byte_offset]
|
|
2346
|
+
byte2 = attribute_bytes[byte_offset + 1]
|
|
2347
|
+
attribute_value = (byte2 << 8) | byte1
|
|
2348
|
+
behavior = attribute_value & 0x00FF
|
|
2349
|
+
behaviors.append(behavior)
|
|
2350
|
+
|
|
2351
|
+
return behaviors
|
|
2352
|
+
|
|
2353
|
+
except Exception as e:
|
|
2354
|
+
logger.warning(f"Failed to read metatile behaviors: {e}")
|
|
2355
|
+
return []
|
|
2356
|
+
|
|
2357
|
+
def get_all_metatile_behaviors(self) -> List[int]:
|
|
2358
|
+
"""Get all metatile behaviors for current map"""
|
|
2359
|
+
try:
|
|
2360
|
+
map_bank = self._read_u8(self.addresses.MAP_BANK)
|
|
2361
|
+
map_number = self._read_u8(self.addresses.MAP_NUMBER)
|
|
2362
|
+
cache_key = (map_bank, map_number)
|
|
2363
|
+
|
|
2364
|
+
if self._cached_behaviors_map_key == cache_key and self._cached_behaviors is not None:
|
|
2365
|
+
return self._cached_behaviors
|
|
2366
|
+
|
|
2367
|
+
map_layout_base = self.get_map_layout_base_address()
|
|
2368
|
+
if not map_layout_base:
|
|
2369
|
+
return []
|
|
2370
|
+
|
|
2371
|
+
primary_addr, secondary_addr = self.get_tileset_pointers(map_layout_base)
|
|
2372
|
+
all_behaviors = []
|
|
2373
|
+
|
|
2374
|
+
if primary_addr:
|
|
2375
|
+
primary_behaviors = self.read_metatile_behaviors_from_tileset(primary_addr, 0x200)
|
|
2376
|
+
all_behaviors.extend(primary_behaviors)
|
|
2377
|
+
|
|
2378
|
+
if secondary_addr:
|
|
2379
|
+
secondary_behaviors = self.read_metatile_behaviors_from_tileset(secondary_addr, 0x200)
|
|
2380
|
+
all_behaviors.extend(secondary_behaviors)
|
|
2381
|
+
|
|
2382
|
+
self._cached_behaviors = all_behaviors
|
|
2383
|
+
self._cached_behaviors_map_key = cache_key
|
|
2384
|
+
|
|
2385
|
+
return all_behaviors
|
|
2386
|
+
|
|
2387
|
+
except Exception as e:
|
|
2388
|
+
logger.warning(f"Failed to get all metatile behaviors: {e}")
|
|
2389
|
+
return []
|
|
2390
|
+
|
|
2391
|
+
def get_exact_behavior_from_id(self, metatile_id: int) -> MetatileBehavior:
|
|
2392
|
+
"""Get exact behavior for metatile ID"""
|
|
2393
|
+
try:
|
|
2394
|
+
all_behaviors = self.get_all_metatile_behaviors()
|
|
2395
|
+
|
|
2396
|
+
if not all_behaviors or metatile_id >= len(all_behaviors):
|
|
2397
|
+
return MetatileBehavior.NORMAL
|
|
2398
|
+
|
|
2399
|
+
behavior_byte = all_behaviors[metatile_id]
|
|
2400
|
+
|
|
2401
|
+
try:
|
|
2402
|
+
return MetatileBehavior(behavior_byte)
|
|
2403
|
+
except ValueError:
|
|
2404
|
+
return MetatileBehavior.NORMAL
|
|
2405
|
+
|
|
2406
|
+
except Exception as e:
|
|
2407
|
+
logger.warning(f"Failed to get exact behavior for metatile {metatile_id}: {e}")
|
|
2408
|
+
return MetatileBehavior.NORMAL
|
|
2409
|
+
|
|
2410
|
+
def get_comprehensive_state(self, screenshot=None) -> Dict[str, Any]:
|
|
2411
|
+
"""Get comprehensive game state with optional screenshot for OCR fallback"""
|
|
2412
|
+
logger.info("Starting comprehensive state reading")
|
|
2413
|
+
state = {
|
|
2414
|
+
"visual": {"screenshot": None, "resolution": [240, 160]},
|
|
2415
|
+
"player": {"position": None, "location": None, "name": None},
|
|
2416
|
+
"game": {
|
|
2417
|
+
"money": None, "party": None, "game_state": None, "is_in_battle": None,
|
|
2418
|
+
"time": None, "badges": None, "items": None, "item_count": None,
|
|
2419
|
+
"pokedex_caught": None, "pokedex_seen": None, "dialog_text": None,
|
|
2420
|
+
"progress_context": None
|
|
2421
|
+
},
|
|
2422
|
+
"map": {
|
|
2423
|
+
"tiles": None, "tile_names": None, "metatile_behaviors": None,
|
|
2424
|
+
"metatile_info": None, "traversability": None
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
try:
|
|
2429
|
+
# Map tiles - read first
|
|
2430
|
+
state = self.read_map(state)
|
|
2431
|
+
# Player information
|
|
2432
|
+
coords = self.read_coordinates()
|
|
2433
|
+
# Always set position - (0,0) is a valid coordinate
|
|
2434
|
+
# read_coordinates() always returns a tuple, never None
|
|
2435
|
+
state["player"]["position"] = {"x": coords[0], "y": coords[1]}
|
|
2436
|
+
# print(f"DEBUG: Player coords: {coords}")
|
|
2437
|
+
|
|
2438
|
+
try:
|
|
2439
|
+
location = self.read_location()
|
|
2440
|
+
# print(f"DEBUG: read_location() returned: '{location}'")
|
|
2441
|
+
# Always set location, even if it's 'Unknown' or 'TITLE_SEQUENCE'
|
|
2442
|
+
state["player"]["location"] = location
|
|
2443
|
+
except Exception as e:
|
|
2444
|
+
# print(f"DEBUG: Exception reading location: {e}")
|
|
2445
|
+
state["player"]["location"] = "Unknown"
|
|
2446
|
+
|
|
2447
|
+
player_name = self.read_player_name()
|
|
2448
|
+
if player_name:
|
|
2449
|
+
state["player"]["name"] = player_name
|
|
2450
|
+
|
|
2451
|
+
# Player facing direction - removed as it's often unreliable
|
|
2452
|
+
# facing = self.read_player_facing()
|
|
2453
|
+
# if facing:
|
|
2454
|
+
# state["player"]["facing"] = facing
|
|
2455
|
+
|
|
2456
|
+
# Game information
|
|
2457
|
+
state["game"].update({
|
|
2458
|
+
"money": self.read_money(),
|
|
2459
|
+
"game_state": self.get_game_state(),
|
|
2460
|
+
"is_in_battle": self.is_in_battle(),
|
|
2461
|
+
"time": self.read_game_time(),
|
|
2462
|
+
"badges": self.read_badges(),
|
|
2463
|
+
"items": self.read_items(),
|
|
2464
|
+
"item_count": self.read_item_count(),
|
|
2465
|
+
"pokedex_caught": self.read_pokedex_caught_count(),
|
|
2466
|
+
"pokedex_seen": self.read_pokedex_seen_count()
|
|
2467
|
+
})
|
|
2468
|
+
|
|
2469
|
+
# Battle details - use comprehensive battle info
|
|
2470
|
+
if state["game"]["is_in_battle"]:
|
|
2471
|
+
battle_details = self.read_comprehensive_battle_info()
|
|
2472
|
+
if battle_details:
|
|
2473
|
+
state["game"]["battle_info"] = battle_details
|
|
2474
|
+
|
|
2475
|
+
# Dialog text - only read if dialog detection is enabled
|
|
2476
|
+
if self._dialog_detection_enabled:
|
|
2477
|
+
dialog_text = self.read_dialog_with_ocr_fallback(screenshot)
|
|
2478
|
+
if dialog_text:
|
|
2479
|
+
state["game"]["dialog_text"] = dialog_text
|
|
2480
|
+
logger.info(f"Found dialog text: {dialog_text[:100]}...")
|
|
2481
|
+
else:
|
|
2482
|
+
logger.debug("No dialog text found in memory buffers or OCR")
|
|
2483
|
+
else:
|
|
2484
|
+
logger.debug("Dialog detection disabled (no-ocr mode)")
|
|
2485
|
+
|
|
2486
|
+
# Dialogue detection result - only if dialog detection is enabled
|
|
2487
|
+
if self._dialog_detection_enabled:
|
|
2488
|
+
dialogue_active = self.is_in_dialog()
|
|
2489
|
+
|
|
2490
|
+
# Update dialogue cache with current state
|
|
2491
|
+
self._update_dialogue_cache(dialog_text if 'dialog_text' in locals() else None, dialogue_active)
|
|
2492
|
+
|
|
2493
|
+
# Use cached dialogue state for additional validation
|
|
2494
|
+
cached_active, cached_text = self.get_cached_dialogue_state()
|
|
2495
|
+
else:
|
|
2496
|
+
dialogue_active = False
|
|
2497
|
+
cached_active = False
|
|
2498
|
+
cached_text = ""
|
|
2499
|
+
|
|
2500
|
+
# Final dialogue state combines detection and cache validation
|
|
2501
|
+
final_dialogue_active = dialogue_active and cached_active
|
|
2502
|
+
|
|
2503
|
+
state["game"]["dialogue_detected"] = {
|
|
2504
|
+
"has_dialogue": final_dialogue_active,
|
|
2505
|
+
"confidence": 1.0 if final_dialogue_active else 0.0,
|
|
2506
|
+
"reason": "enhanced pokeemerald detection with cache validation"
|
|
2507
|
+
}
|
|
2508
|
+
logger.debug(f"Dialogue detection: {dialogue_active}, cached: {cached_active}, final: {final_dialogue_active}")
|
|
2509
|
+
|
|
2510
|
+
# Update game_state to reflect the current dialogue cache state
|
|
2511
|
+
# This ensures game_state is 'overworld' when dialogue is dismissed by A button
|
|
2512
|
+
if not final_dialogue_active and state["game"]["game_state"] == "dialog":
|
|
2513
|
+
state["game"]["game_state"] = "overworld"
|
|
2514
|
+
logger.debug("Updated game_state from 'dialog' to 'overworld' after dialogue cache validation")
|
|
2515
|
+
|
|
2516
|
+
# Game progress context
|
|
2517
|
+
progress_context = self.get_game_progress_context()
|
|
2518
|
+
if progress_context:
|
|
2519
|
+
state["game"]["progress_context"] = progress_context
|
|
2520
|
+
|
|
2521
|
+
# Party Pokemon
|
|
2522
|
+
logger.info("About to read party Pokemon")
|
|
2523
|
+
party = self.read_party_pokemon()
|
|
2524
|
+
logger.info(f"Read party: {len(party) if party else 0} Pokemon")
|
|
2525
|
+
if party:
|
|
2526
|
+
logger.info(f"Party data: {party}")
|
|
2527
|
+
state["player"]["party"] = [
|
|
2528
|
+
{
|
|
2529
|
+
"species_name": pokemon.species_name,
|
|
2530
|
+
"level": pokemon.level,
|
|
2531
|
+
"current_hp": pokemon.current_hp,
|
|
2532
|
+
"max_hp": pokemon.max_hp,
|
|
2533
|
+
"status": pokemon.status.get_status_name() if pokemon.status else "OK",
|
|
2534
|
+
"types": [t.name for t in [pokemon.type1, pokemon.type2] if t],
|
|
2535
|
+
"moves": pokemon.moves,
|
|
2536
|
+
"move_pp": pokemon.move_pp,
|
|
2537
|
+
"nickname": pokemon.nickname
|
|
2538
|
+
}
|
|
2539
|
+
for pokemon in party
|
|
2540
|
+
]
|
|
2541
|
+
logger.info(f"Added {len(state['player']['party'])} Pokemon to state")
|
|
2542
|
+
logger.info(f"Final state party: {state['player']['party']}")
|
|
2543
|
+
else:
|
|
2544
|
+
self._rate_limited_warning("No Pokemon found in party", "party_empty")
|
|
2545
|
+
|
|
2546
|
+
|
|
2547
|
+
except Exception as e:
|
|
2548
|
+
import traceback
|
|
2549
|
+
logger.warning(f"Failed to read comprehensive state: {e}")
|
|
2550
|
+
logger.debug(f"Traceback: {traceback.format_exc()}")
|
|
2551
|
+
|
|
2552
|
+
# Add screenshot to visual state if provided
|
|
2553
|
+
if screenshot is not None:
|
|
2554
|
+
state["visual"]["screenshot"] = screenshot
|
|
2555
|
+
|
|
2556
|
+
return state
|
|
2557
|
+
|
|
2558
|
+
def read_map(self, state):
|
|
2559
|
+
tiles = self.read_map_around_player(radius=7) # 15x15 grid for better context
|
|
2560
|
+
if tiles:
|
|
2561
|
+
# DEBUG: Print tile data before processing for HTTP API
|
|
2562
|
+
total_tiles = sum(len(row) for row in tiles)
|
|
2563
|
+
unknown_count = 0
|
|
2564
|
+
corruption_count = 0
|
|
2565
|
+
for row in tiles:
|
|
2566
|
+
for tile in row:
|
|
2567
|
+
if len(tile) >= 2:
|
|
2568
|
+
behavior = tile[1]
|
|
2569
|
+
if isinstance(behavior, int):
|
|
2570
|
+
if behavior == 0:
|
|
2571
|
+
unknown_count += 1
|
|
2572
|
+
elif behavior == 134: # Indoor element corruption
|
|
2573
|
+
corruption_count += 1
|
|
2574
|
+
|
|
2575
|
+
unknown_ratio = unknown_count / total_tiles if total_tiles > 0 else 0
|
|
2576
|
+
logger.info(f"📊 PRE-PROCESSING TILES: {unknown_ratio:.1%} unknown ({unknown_count}/{total_tiles}), {corruption_count} corrupted")
|
|
2577
|
+
|
|
2578
|
+
state["map"]["tiles"] = tiles
|
|
2579
|
+
|
|
2580
|
+
# Process tiles for enhanced information (keep minimal processing here)
|
|
2581
|
+
tile_names = []
|
|
2582
|
+
metatile_behaviors = []
|
|
2583
|
+
metatile_info = []
|
|
2584
|
+
|
|
2585
|
+
for row in tiles:
|
|
2586
|
+
row_names = []
|
|
2587
|
+
row_behaviors = []
|
|
2588
|
+
row_info = []
|
|
2589
|
+
|
|
2590
|
+
for tile_data in row:
|
|
2591
|
+
if len(tile_data) >= 4:
|
|
2592
|
+
tile_id, behavior, collision, elevation = tile_data
|
|
2593
|
+
elif len(tile_data) >= 2:
|
|
2594
|
+
tile_id, behavior = tile_data[:2]
|
|
2595
|
+
collision = 0
|
|
2596
|
+
elevation = 0
|
|
2597
|
+
else:
|
|
2598
|
+
tile_id = tile_data[0] if tile_data else 0
|
|
2599
|
+
behavior = None
|
|
2600
|
+
collision = 0
|
|
2601
|
+
elevation = 0
|
|
2602
|
+
|
|
2603
|
+
# Tile name
|
|
2604
|
+
tile_name = f"Tile_{tile_id:04X}"
|
|
2605
|
+
if behavior is not None and hasattr(behavior, 'name'):
|
|
2606
|
+
tile_name += f"({behavior.name})"
|
|
2607
|
+
row_names.append(tile_name)
|
|
2608
|
+
|
|
2609
|
+
# Behavior name
|
|
2610
|
+
behavior_name = behavior.name if behavior is not None and hasattr(behavior, 'name') else "UNKNOWN"
|
|
2611
|
+
row_behaviors.append(behavior_name)
|
|
2612
|
+
|
|
2613
|
+
# Detailed tile info
|
|
2614
|
+
tile_info = {
|
|
2615
|
+
"id": tile_id,
|
|
2616
|
+
"behavior": behavior_name,
|
|
2617
|
+
"collision": collision,
|
|
2618
|
+
"elevation": elevation,
|
|
2619
|
+
"passable": collision == 0,
|
|
2620
|
+
"encounter_possible": self._is_encounter_tile(behavior),
|
|
2621
|
+
"surfable": self._is_surfable_tile(behavior)
|
|
2622
|
+
}
|
|
2623
|
+
row_info.append(tile_info)
|
|
2624
|
+
|
|
2625
|
+
# No traversability processing - handled by state_formatter
|
|
2626
|
+
|
|
2627
|
+
tile_names.append(row_names)
|
|
2628
|
+
metatile_behaviors.append(row_behaviors)
|
|
2629
|
+
metatile_info.append(row_info)
|
|
2630
|
+
|
|
2631
|
+
state["map"]["tile_names"] = tile_names
|
|
2632
|
+
state["map"]["metatile_behaviors"] = metatile_behaviors
|
|
2633
|
+
state["map"]["metatile_info"] = metatile_info
|
|
2634
|
+
# traversability now generated by state_formatter from raw tiles
|
|
2635
|
+
|
|
2636
|
+
# Add object events (NPCs/trainers)
|
|
2637
|
+
object_events = self.read_object_events()
|
|
2638
|
+
if object_events:
|
|
2639
|
+
state["map"]["object_events"] = object_events
|
|
2640
|
+
logger.info(f"📍 Found {len(object_events)} NPCs/trainers in current map")
|
|
2641
|
+
|
|
2642
|
+
# Add player absolute coordinates for NPC positioning
|
|
2643
|
+
player_coords = self.read_coordinates()
|
|
2644
|
+
if player_coords:
|
|
2645
|
+
state["map"]["player_coords"] = {'x': player_coords[0], 'y': player_coords[1]}
|
|
2646
|
+
else:
|
|
2647
|
+
state["map"]["object_events"] = []
|
|
2648
|
+
|
|
2649
|
+
# Update map stitcher with current tiles
|
|
2650
|
+
if tiles:
|
|
2651
|
+
self._update_map_stitcher(tiles, state)
|
|
2652
|
+
|
|
2653
|
+
# Add stitched map information to state
|
|
2654
|
+
stitched_info = self.get_stitched_map_info()
|
|
2655
|
+
state["map"]["stitched_map_info"] = stitched_info
|
|
2656
|
+
|
|
2657
|
+
# Generate map visualization directly for the LLM
|
|
2658
|
+
# This ensures the map is available even when passed through JSON
|
|
2659
|
+
if self._map_stitcher and state.get("player"):
|
|
2660
|
+
location = state["player"].get("location", "Unknown")
|
|
2661
|
+
coords = state["player"].get("position")
|
|
2662
|
+
player_pos = (coords.get("x"), coords.get("y")) if coords else None
|
|
2663
|
+
|
|
2664
|
+
# Always try to generate visual map if we have valid data, even if it was None before
|
|
2665
|
+
# This handles cases where early calls failed but later calls have valid data
|
|
2666
|
+
if location and location not in [None, "None", "Unknown"] and coords:
|
|
2667
|
+
# Get connections with coordinates for this location
|
|
2668
|
+
connections_with_coords = []
|
|
2669
|
+
if location and self._map_stitcher:
|
|
2670
|
+
try:
|
|
2671
|
+
location_connections = self._map_stitcher.get_location_connections(location)
|
|
2672
|
+
for conn in location_connections:
|
|
2673
|
+
if len(conn) >= 3:
|
|
2674
|
+
other_loc, my_coords, their_coords = conn[0], conn[1], conn[2]
|
|
2675
|
+
connections_with_coords.append({
|
|
2676
|
+
"to": other_loc,
|
|
2677
|
+
"from_pos": list(my_coords) if my_coords else [],
|
|
2678
|
+
"to_pos": list(their_coords) if their_coords else []
|
|
2679
|
+
})
|
|
2680
|
+
except Exception as e:
|
|
2681
|
+
logger.debug(f"Error getting location connections: {e}")
|
|
2682
|
+
|
|
2683
|
+
# Generate the map display lines using stored map data, focused on 15x15 agent view
|
|
2684
|
+
map_lines = self._map_stitcher.generate_location_map_display(
|
|
2685
|
+
location_name=location,
|
|
2686
|
+
player_pos=player_pos,
|
|
2687
|
+
npcs=state["map"].get("object_events", []),
|
|
2688
|
+
connections=connections_with_coords
|
|
2689
|
+
)
|
|
2690
|
+
|
|
2691
|
+
# Store as formatted text for direct use
|
|
2692
|
+
state["map"]["visual_map"] = "\n".join(map_lines) if map_lines else None
|
|
2693
|
+
|
|
2694
|
+
# Pass the MapStitcher instance for state_formatter to use
|
|
2695
|
+
# This ensures the same instance with all the data is used
|
|
2696
|
+
state["map"]["_map_stitcher_instance"] = self._map_stitcher
|
|
2697
|
+
|
|
2698
|
+
return state
|
|
2699
|
+
|
|
2700
|
+
def _update_map_stitcher(self, tiles, state):
|
|
2701
|
+
"""Update the map stitcher with current map data"""
|
|
2702
|
+
try:
|
|
2703
|
+
# Get the global shared MapStitcher instance
|
|
2704
|
+
if self._map_stitcher is None:
|
|
2705
|
+
from utils import map_stitcher_singleton
|
|
2706
|
+
self._map_stitcher = map_stitcher_singleton.get_instance()
|
|
2707
|
+
logger.info(f"Using shared MapStitcher instance with {len(self._map_stitcher.map_areas)} areas")
|
|
2708
|
+
# Set up callback to save location connections when they change
|
|
2709
|
+
self._setup_location_connections_callback()
|
|
2710
|
+
|
|
2711
|
+
# Get current map identifiers
|
|
2712
|
+
map_bank = self._read_u8(self.addresses.MAP_BANK)
|
|
2713
|
+
map_number = self._read_u8(self.addresses.MAP_NUMBER)
|
|
2714
|
+
|
|
2715
|
+
# Get location name from player location, with fallback to map ID resolution
|
|
2716
|
+
location_name = state.get("player", {}).get("location")
|
|
2717
|
+
if not location_name or location_name == "Unknown":
|
|
2718
|
+
# Try to resolve from map ID directly
|
|
2719
|
+
try:
|
|
2720
|
+
map_id = self._map_stitcher.get_map_id(map_bank, map_number)
|
|
2721
|
+
map_enum = MapLocation(map_id)
|
|
2722
|
+
location_name = map_enum.name.replace('_', ' ').title()
|
|
2723
|
+
logger.info(f"Resolved location name from map ID {map_id:04X}: {location_name}")
|
|
2724
|
+
except ValueError:
|
|
2725
|
+
location_name = f"Map_{map_bank:02X}_{map_number:02X}"
|
|
2726
|
+
logger.debug(f"Unknown map ID, using fallback: {location_name}")
|
|
2727
|
+
|
|
2728
|
+
if not location_name:
|
|
2729
|
+
location_name = "Unknown"
|
|
2730
|
+
|
|
2731
|
+
# Get player coordinates
|
|
2732
|
+
player_coords = self.read_coordinates()
|
|
2733
|
+
if not player_coords:
|
|
2734
|
+
return
|
|
2735
|
+
|
|
2736
|
+
# Use the ACTUAL player coordinates, not the local grid center
|
|
2737
|
+
# The (5,5) logic was wrong - it should use real world coordinates
|
|
2738
|
+
map_height = len(tiles) if tiles else 11
|
|
2739
|
+
map_width = len(tiles[0]) if tiles and tiles[0] else 11
|
|
2740
|
+
|
|
2741
|
+
# Use the real player coordinates from the game world
|
|
2742
|
+
actual_player_coords = player_coords # This is the real position, not (5,5)
|
|
2743
|
+
|
|
2744
|
+
# Get overworld coordinates for this map
|
|
2745
|
+
overworld_coords = self._get_overworld_coordinates(map_bank, map_number, location_name)
|
|
2746
|
+
|
|
2747
|
+
# Debug logging
|
|
2748
|
+
logger.info(f"🗺️ Map stitcher update: Bank {map_bank}, Map {map_number}, Location: {location_name}")
|
|
2749
|
+
logger.info(f"🎯 Overworld coordinates: {overworld_coords}")
|
|
2750
|
+
|
|
2751
|
+
# Update the stitcher
|
|
2752
|
+
timestamp = time.time()
|
|
2753
|
+
current_map_id = self._map_stitcher.get_map_id(map_bank, map_number)
|
|
2754
|
+
|
|
2755
|
+
# Try to update location name for existing areas if we have a better name
|
|
2756
|
+
if location_name and location_name.strip() and location_name != "Unknown":
|
|
2757
|
+
if self._map_stitcher.update_location_name(current_map_id, location_name):
|
|
2758
|
+
# Location name was updated, save the changes and resync connections
|
|
2759
|
+
self._map_stitcher.save_to_file()
|
|
2760
|
+
# Skip sync - preserve existing location_connections data
|
|
2761
|
+
# self._sync_warp_connections_to_state_formatter(force_rebuild=True) # DISABLED - causes overwrites
|
|
2762
|
+
|
|
2763
|
+
# Also try to resolve other unknown names using current memory reader state
|
|
2764
|
+
if self._map_stitcher.resolve_unknown_location_names(memory_reader=self):
|
|
2765
|
+
logger.info("Resolved additional unknown location names using memory reader")
|
|
2766
|
+
self._map_stitcher.save_to_file()
|
|
2767
|
+
|
|
2768
|
+
self._map_stitcher.update_map_area(
|
|
2769
|
+
map_bank=map_bank,
|
|
2770
|
+
map_number=map_number,
|
|
2771
|
+
location_name=location_name,
|
|
2772
|
+
map_data=tiles,
|
|
2773
|
+
player_pos=actual_player_coords,
|
|
2774
|
+
timestamp=timestamp,
|
|
2775
|
+
overworld_coords=overworld_coords
|
|
2776
|
+
)
|
|
2777
|
+
|
|
2778
|
+
# Build location_connections directly from map areas after any updates
|
|
2779
|
+
self._build_location_connections_from_map_areas()
|
|
2780
|
+
|
|
2781
|
+
# Save more frequently to preserve accumulated map data
|
|
2782
|
+
if hasattr(self, '_last_stitcher_save'):
|
|
2783
|
+
if timestamp - self._last_stitcher_save > 3.0: # Save every 3 seconds
|
|
2784
|
+
self._map_stitcher.save_to_file()
|
|
2785
|
+
self._last_stitcher_save = timestamp
|
|
2786
|
+
else:
|
|
2787
|
+
self._last_stitcher_save = timestamp
|
|
2788
|
+
# Also save immediately on first update
|
|
2789
|
+
self._map_stitcher.save_to_file()
|
|
2790
|
+
|
|
2791
|
+
except Exception as e:
|
|
2792
|
+
# print( Failed to update map stitcher: {e}")
|
|
2793
|
+
logger.debug(f"Failed to update map stitcher: {e}")
|
|
2794
|
+
import traceback
|
|
2795
|
+
# print( Traceback: {traceback.format_exc()}")
|
|
2796
|
+
|
|
2797
|
+
def _get_overworld_coordinates(self, map_bank: int, map_number: int, location_name: Optional[str]) -> Optional[Tuple[int, int]]:
|
|
2798
|
+
"""Get overworld coordinates for a given map bank/number combination"""
|
|
2799
|
+
# Map Pokemon Emerald's map bank/number to overworld coordinates
|
|
2800
|
+
# This is based on the actual Pokemon Emerald map layout
|
|
2801
|
+
# Is this correct? since each map has local coords?
|
|
2802
|
+
|
|
2803
|
+
# Safety check for None location_name
|
|
2804
|
+
if location_name is None:
|
|
2805
|
+
location_name = "Unknown"
|
|
2806
|
+
|
|
2807
|
+
map_coords = {
|
|
2808
|
+
# Bank 0 - Overworld maps
|
|
2809
|
+
(0, 0): (8, 18), # PETALBURG_CITY
|
|
2810
|
+
(0, 1): (13, 30), # SLATEPORT_CITY
|
|
2811
|
+
(0, 2): (17, 15), # MAUVILLE_CITY
|
|
2812
|
+
(0, 3): (8, 9), # RUSTBORO_CITY
|
|
2813
|
+
(0, 4): (30, 6), # FORTREE_CITY
|
|
2814
|
+
(0, 5): (35, 8), # LILYCOVE_CITY
|
|
2815
|
+
(0, 6): (42, 12), # MOSSDEEP_CITY
|
|
2816
|
+
(0, 7): (37, 20), # SOOTOPOLIS_CITY
|
|
2817
|
+
(0, 8): (44, 15), # EVER_GRANDE_CITY
|
|
2818
|
+
(0, 9): (16, 23), # LITTLEROOT_TOWN
|
|
2819
|
+
(0, 10): (16, 19), # OLDALE_TOWN
|
|
2820
|
+
(0, 11): (3, 27), # DEWFORD_TOWN
|
|
2821
|
+
(0, 12): (17, 9), # LAVARIDGE_TOWN
|
|
2822
|
+
(0, 13): (15, 8), # FALLARBOR_TOWN
|
|
2823
|
+
(0, 14): (11, 14), # VERDANTURF_TOWN
|
|
2824
|
+
(0, 15): (30, 28), # PACIFIDLOG_TOWN
|
|
2825
|
+
|
|
2826
|
+
# Routes
|
|
2827
|
+
(0, 16): (16, 21), # ROUTE_101
|
|
2828
|
+
(0, 17): (14, 18), # ROUTE_102
|
|
2829
|
+
(0, 18): (18, 21), # ROUTE_103
|
|
2830
|
+
(0, 19): (10, 12), # ROUTE_104
|
|
2831
|
+
(0, 20): (5, 25), # ROUTE_105
|
|
2832
|
+
(0, 21): (6, 27), # ROUTE_106
|
|
2833
|
+
(0, 22): (7, 30), # ROUTE_107
|
|
2834
|
+
(0, 23): (10, 32), # ROUTE_108
|
|
2835
|
+
(0, 24): (12, 32), # ROUTE_109
|
|
2836
|
+
(0, 25): (16, 16), # ROUTE_110
|
|
2837
|
+
(0, 26): (17, 12), # ROUTE_111
|
|
2838
|
+
(0, 27): (17, 10), # ROUTE_112
|
|
2839
|
+
(0, 28): (15, 10), # ROUTE_113
|
|
2840
|
+
(0, 29): (13, 8), # ROUTE_114
|
|
2841
|
+
(0, 30): (6, 15), # ROUTE_115
|
|
2842
|
+
(0, 31): (11, 9), # ROUTE_116
|
|
2843
|
+
(0, 32): (13, 14), # ROUTE_117
|
|
2844
|
+
(0, 33): (20, 15), # ROUTE_118
|
|
2845
|
+
(0, 34): (25, 12), # ROUTE_119
|
|
2846
|
+
(0, 35): (27, 10), # ROUTE_120
|
|
2847
|
+
(0, 36): (30, 12), # ROUTE_121
|
|
2848
|
+
(0, 37): (32, 15), # ROUTE_122
|
|
2849
|
+
(0, 38): (33, 14), # ROUTE_123
|
|
2850
|
+
(0, 39): (35, 18), # ROUTE_124
|
|
2851
|
+
(0, 40): (28, 25), # ROUTE_125
|
|
2852
|
+
(0, 41): (25, 28), # ROUTE_126
|
|
2853
|
+
(0, 42): (30, 32), # ROUTE_127
|
|
2854
|
+
(0, 43): (35, 32), # ROUTE_128
|
|
2855
|
+
(0, 44): (38, 30), # ROUTE_129
|
|
2856
|
+
(0, 45): (40, 25), # ROUTE_130
|
|
2857
|
+
(0, 46): (42, 20), # ROUTE_131
|
|
2858
|
+
(0, 47): (40, 15), # ROUTE_132
|
|
2859
|
+
(0, 48): (38, 12), # ROUTE_133
|
|
2860
|
+
(0, 49): (35, 12), # ROUTE_134
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
# Check for exact match
|
|
2864
|
+
coords = map_coords.get((map_bank, map_number))
|
|
2865
|
+
if coords:
|
|
2866
|
+
return coords
|
|
2867
|
+
|
|
2868
|
+
# For indoor locations (banks 1+), inherit coordinates from parent outdoor area
|
|
2869
|
+
if map_bank > 0 and location_name:
|
|
2870
|
+
# Try to infer from location name
|
|
2871
|
+
name_upper = location_name.upper()
|
|
2872
|
+
|
|
2873
|
+
# Match building names to their town coordinates
|
|
2874
|
+
if "LITTLEROOT" in name_upper:
|
|
2875
|
+
return (16, 23)
|
|
2876
|
+
elif "OLDALE" in name_upper:
|
|
2877
|
+
return (16, 19)
|
|
2878
|
+
elif "PETALBURG" in name_upper:
|
|
2879
|
+
return (8, 18)
|
|
2880
|
+
elif "RUSTBORO" in name_upper:
|
|
2881
|
+
return (8, 9)
|
|
2882
|
+
elif "DEWFORD" in name_upper:
|
|
2883
|
+
return (3, 27)
|
|
2884
|
+
elif "SLATEPORT" in name_upper:
|
|
2885
|
+
return (13, 30)
|
|
2886
|
+
elif "MAUVILLE" in name_upper:
|
|
2887
|
+
return (17, 15)
|
|
2888
|
+
elif "VERDANTURF" in name_upper:
|
|
2889
|
+
return (11, 14)
|
|
2890
|
+
elif "FALLARBOR" in name_upper:
|
|
2891
|
+
return (15, 8)
|
|
2892
|
+
elif "LAVARIDGE" in name_upper:
|
|
2893
|
+
return (17, 9)
|
|
2894
|
+
elif "FORTREE" in name_upper:
|
|
2895
|
+
return (30, 6)
|
|
2896
|
+
elif "LILYCOVE" in name_upper:
|
|
2897
|
+
return (35, 8)
|
|
2898
|
+
elif "MOSSDEEP" in name_upper:
|
|
2899
|
+
return (42, 12)
|
|
2900
|
+
elif "SOOTOPOLIS" in name_upper:
|
|
2901
|
+
return (37, 20)
|
|
2902
|
+
elif "EVER_GRANDE" in name_upper:
|
|
2903
|
+
return (44, 15)
|
|
2904
|
+
elif "PACIFIDLOG" in name_upper:
|
|
2905
|
+
return (30, 28)
|
|
2906
|
+
|
|
2907
|
+
# If no coordinates found, return None (unknown location)
|
|
2908
|
+
return None
|
|
2909
|
+
|
|
2910
|
+
def get_stitched_map_info(self) -> Dict[str, Any]:
|
|
2911
|
+
"""Get stitched map information for agent use"""
|
|
2912
|
+
if self._map_stitcher is None:
|
|
2913
|
+
return {"available": False, "reason": "Map stitcher not initialized"}
|
|
2914
|
+
|
|
2915
|
+
try:
|
|
2916
|
+
stats = self._map_stitcher.get_stats()
|
|
2917
|
+
|
|
2918
|
+
# Get current area info
|
|
2919
|
+
current_map_bank = self._read_u8(self.addresses.MAP_BANK)
|
|
2920
|
+
current_map_number = self._read_u8(self.addresses.MAP_NUMBER)
|
|
2921
|
+
current_map_id = (current_map_bank << 8) | current_map_number
|
|
2922
|
+
|
|
2923
|
+
current_area = self._map_stitcher.map_areas.get(current_map_id)
|
|
2924
|
+
current_connections = self._map_stitcher.get_connected_areas(current_map_id)
|
|
2925
|
+
|
|
2926
|
+
# Get nearby areas (areas reachable in 1-2 connections)
|
|
2927
|
+
nearby_areas = []
|
|
2928
|
+
visited_ids = set()
|
|
2929
|
+
|
|
2930
|
+
def add_connected_areas(area_id, depth=0, max_depth=2):
|
|
2931
|
+
if depth > max_depth or area_id in visited_ids:
|
|
2932
|
+
return
|
|
2933
|
+
visited_ids.add(area_id)
|
|
2934
|
+
|
|
2935
|
+
area = self._map_stitcher.map_areas.get(area_id)
|
|
2936
|
+
if area:
|
|
2937
|
+
connections = self._map_stitcher.get_connected_areas(area_id)
|
|
2938
|
+
nearby_areas.append({
|
|
2939
|
+
"name": area.location_name,
|
|
2940
|
+
"id": f"{area_id:04X}",
|
|
2941
|
+
"depth": depth,
|
|
2942
|
+
"overworld_coords": area.overworld_coords,
|
|
2943
|
+
"connections": [{"name": name, "direction": direction}
|
|
2944
|
+
for _, name, direction in connections]
|
|
2945
|
+
})
|
|
2946
|
+
|
|
2947
|
+
# Recursively add connected areas
|
|
2948
|
+
for conn_id, _, _ in connections:
|
|
2949
|
+
add_connected_areas(conn_id, depth + 1, max_depth)
|
|
2950
|
+
|
|
2951
|
+
add_connected_areas(current_map_id)
|
|
2952
|
+
|
|
2953
|
+
# Include terrain data for world map display
|
|
2954
|
+
terrain_areas = []
|
|
2955
|
+
for area_id, area in self._map_stitcher.map_areas.items():
|
|
2956
|
+
if area.overworld_coords and area.map_data:
|
|
2957
|
+
terrain_areas.append({
|
|
2958
|
+
"id": f"{area_id:04X}",
|
|
2959
|
+
"name": area.location_name or "Unknown",
|
|
2960
|
+
"overworld_coords": area.overworld_coords,
|
|
2961
|
+
"map_data": area.map_data,
|
|
2962
|
+
"player_pos": area.player_last_position
|
|
2963
|
+
})
|
|
2964
|
+
|
|
2965
|
+
return {
|
|
2966
|
+
"available": True,
|
|
2967
|
+
"stats": stats,
|
|
2968
|
+
"current_area": {
|
|
2969
|
+
"name": current_area.location_name if current_area else "Unknown",
|
|
2970
|
+
"id": f"{current_map_id:04X}",
|
|
2971
|
+
"overworld_coords": current_area.overworld_coords if current_area else None,
|
|
2972
|
+
"connections": [{"name": name, "direction": direction}
|
|
2973
|
+
for _, name, direction in current_connections]
|
|
2974
|
+
},
|
|
2975
|
+
"nearby_areas": nearby_areas[:10], # Limit to 10 areas
|
|
2976
|
+
"terrain_areas": terrain_areas, # Include terrain data for world map
|
|
2977
|
+
"total_discovered": len(self._map_stitcher.map_areas)
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
except Exception as e:
|
|
2981
|
+
import traceback
|
|
2982
|
+
logger.debug(f"Failed to get stitched map info: {e}")
|
|
2983
|
+
logger.debug(f"Full traceback: {traceback.format_exc()}")
|
|
2984
|
+
return {"available": False, "reason": f"Error: {e}"}
|
|
2985
|
+
|
|
2986
|
+
def update_map_stitcher_save_file(self, filename: str, is_cache_file: bool = False):
|
|
2987
|
+
"""Update MapStitcher save file and sync connections
|
|
2988
|
+
|
|
2989
|
+
When loading a state:
|
|
2990
|
+
1. First loads the state-specific map file (e.g. route102_save_map_stitcher.json)
|
|
2991
|
+
2. Always uses cache file (.pokeagent_cache/map_stitcher_data.json) for the MapStitcher instance
|
|
2992
|
+
"""
|
|
2993
|
+
import os
|
|
2994
|
+
import shutil
|
|
2995
|
+
|
|
2996
|
+
cache_dir = ".pokeagent_cache"
|
|
2997
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
2998
|
+
cache_map_file = os.path.join(cache_dir, "map_stitcher_data.json")
|
|
2999
|
+
|
|
3000
|
+
if not is_cache_file:
|
|
3001
|
+
# Loading a state - check for state-specific map file to copy to cache
|
|
3002
|
+
state_dir = os.path.dirname(filename)
|
|
3003
|
+
base_name = os.path.splitext(os.path.basename(filename))[0]
|
|
3004
|
+
state_map_file = os.path.join(state_dir, f"{base_name}_map_stitcher.json")
|
|
3005
|
+
|
|
3006
|
+
# Copy state map to cache if it exists
|
|
3007
|
+
if os.path.exists(state_map_file) and os.path.getsize(state_map_file) > 0:
|
|
3008
|
+
shutil.copy2(state_map_file, cache_map_file)
|
|
3009
|
+
# print( Copied state map from {state_map_file} to cache {cache_map_file}")
|
|
3010
|
+
elif not os.path.exists(cache_map_file):
|
|
3011
|
+
# Create empty cache file if neither exists
|
|
3012
|
+
# print( No state map found, creating empty cache file {cache_map_file}")
|
|
3013
|
+
with open(cache_map_file, 'w') as f:
|
|
3014
|
+
import json
|
|
3015
|
+
json.dump({"map_areas": {}, "location_connections": {}}, f)
|
|
3016
|
+
|
|
3017
|
+
# Always use cache file for the MapStitcher instance
|
|
3018
|
+
map_stitcher_filename = cache_map_file
|
|
3019
|
+
|
|
3020
|
+
# Initialize MapStitcher if not already initialized
|
|
3021
|
+
if self._map_stitcher is None:
|
|
3022
|
+
from utils.map_stitcher import MapStitcher
|
|
3023
|
+
# print( Initializing MapStitcher with cache file: {map_stitcher_filename}")
|
|
3024
|
+
self._map_stitcher = MapStitcher(save_file=map_stitcher_filename)
|
|
3025
|
+
# print( MapStitcher initialized, syncing connections...")
|
|
3026
|
+
# Skip sync - let loaded location_connections be preserved
|
|
3027
|
+
# self._sync_warp_connections_to_state_formatter(force_rebuild=True) # DISABLED - causes overwrites
|
|
3028
|
+
# Set up callback to save location connections when they change
|
|
3029
|
+
self._setup_location_connections_callback()
|
|
3030
|
+
else:
|
|
3031
|
+
# MapStitcher already initialized, it should already be using the cache file
|
|
3032
|
+
# print( MapStitcher already initialized, using cache: {map_stitcher_filename}")
|
|
3033
|
+
# No need to update save file since we always use the cache
|
|
3034
|
+
# Set up callback to save location connections when they change
|
|
3035
|
+
self._setup_location_connections_callback()
|
|
3036
|
+
|
|
3037
|
+
def _build_location_connections_from_map_areas(self):
|
|
3038
|
+
"""Build location_connections directly from the MapStitcher's warp connections"""
|
|
3039
|
+
if self._map_stitcher is None:
|
|
3040
|
+
return
|
|
3041
|
+
|
|
3042
|
+
try:
|
|
3043
|
+
# Only update if we have warp connections to process
|
|
3044
|
+
if not self._map_stitcher.warp_connections:
|
|
3045
|
+
return
|
|
3046
|
+
|
|
3047
|
+
# Initialize if needed
|
|
3048
|
+
if not hasattr(state_formatter, 'LOCATION_CONNECTIONS'):
|
|
3049
|
+
state_formatter.LOCATION_CONNECTIONS = {}
|
|
3050
|
+
|
|
3051
|
+
# Build connections from MapStitcher's warp data
|
|
3052
|
+
for conn in self._map_stitcher.warp_connections:
|
|
3053
|
+
from_area = self._map_stitcher.map_areas.get(conn.from_map_id)
|
|
3054
|
+
to_area = self._map_stitcher.map_areas.get(conn.to_map_id)
|
|
3055
|
+
|
|
3056
|
+
if from_area and to_area:
|
|
3057
|
+
from_name = from_area.location_name
|
|
3058
|
+
to_name = to_area.location_name
|
|
3059
|
+
|
|
3060
|
+
# Skip if names are unknown
|
|
3061
|
+
if from_name == "Unknown" or to_name == "Unknown":
|
|
3062
|
+
continue
|
|
3063
|
+
|
|
3064
|
+
# Initialize location entries
|
|
3065
|
+
if from_name not in state_formatter.LOCATION_CONNECTIONS:
|
|
3066
|
+
state_formatter.LOCATION_CONNECTIONS[from_name] = []
|
|
3067
|
+
if to_name not in state_formatter.LOCATION_CONNECTIONS:
|
|
3068
|
+
state_formatter.LOCATION_CONNECTIONS[to_name] = []
|
|
3069
|
+
|
|
3070
|
+
# Add bidirectional connections
|
|
3071
|
+
from_to_connection = [to_name, list(conn.from_position), list(conn.to_position)]
|
|
3072
|
+
to_from_connection = [from_name, list(conn.to_position), list(conn.from_position)]
|
|
3073
|
+
|
|
3074
|
+
# Avoid duplicates
|
|
3075
|
+
if from_to_connection not in state_formatter.LOCATION_CONNECTIONS[from_name]:
|
|
3076
|
+
state_formatter.LOCATION_CONNECTIONS[from_name].append(from_to_connection)
|
|
3077
|
+
logger.debug(f"Added connection: {from_name} -> {to_name}")
|
|
3078
|
+
if to_from_connection not in state_formatter.LOCATION_CONNECTIONS[to_name]:
|
|
3079
|
+
state_formatter.LOCATION_CONNECTIONS[to_name].append(to_from_connection)
|
|
3080
|
+
logger.debug(f"Added connection: {to_name} -> {from_name}")
|
|
3081
|
+
|
|
3082
|
+
except Exception as e:
|
|
3083
|
+
logger.debug(f"Failed to build location connections: {e}")
|
|
3084
|
+
|
|
3085
|
+
def _sync_warp_connections_to_state_formatter(self, force_rebuild=False):
|
|
3086
|
+
"""Sync MapStitcher warp connections to state_formatter's LOCATION_CONNECTIONS
|
|
3087
|
+
|
|
3088
|
+
Args:
|
|
3089
|
+
force_rebuild: If True, rebuild connections even if they exist
|
|
3090
|
+
"""
|
|
3091
|
+
if self._map_stitcher is None:
|
|
3092
|
+
# print( MapStitcher is None, cannot sync connections")
|
|
3093
|
+
return
|
|
3094
|
+
|
|
3095
|
+
try:
|
|
3096
|
+
from utils import state_formatter
|
|
3097
|
+
|
|
3098
|
+
# print( Starting sync of {len(self._map_stitcher.warp_connections)} warp connections")
|
|
3099
|
+
# print( MapStitcher has {len(self._map_stitcher.map_areas)} map areas")
|
|
3100
|
+
|
|
3101
|
+
# Initialize connections if they don't exist, but don't clear existing ones
|
|
3102
|
+
if not hasattr(state_formatter, 'LOCATION_CONNECTIONS'):
|
|
3103
|
+
state_formatter.LOCATION_CONNECTIONS = {}
|
|
3104
|
+
|
|
3105
|
+
# Check if we need to rebuild
|
|
3106
|
+
has_existing_connections = len(state_formatter.LOCATION_CONNECTIONS) > 0
|
|
3107
|
+
has_stitcher_connections = len(self._map_stitcher.warp_connections) > 0
|
|
3108
|
+
|
|
3109
|
+
# Only rebuild if forced or if we have new stitcher data but no existing connections
|
|
3110
|
+
if not force_rebuild and has_existing_connections:
|
|
3111
|
+
# print( Skipping sync - already have {len(state_formatter.LOCATION_CONNECTIONS)} location connections")
|
|
3112
|
+
return
|
|
3113
|
+
|
|
3114
|
+
if not has_stitcher_connections:
|
|
3115
|
+
# print( No stitcher connections to sync")
|
|
3116
|
+
return
|
|
3117
|
+
|
|
3118
|
+
# Clear and rebuild connections
|
|
3119
|
+
state_formatter.LOCATION_CONNECTIONS = {}
|
|
3120
|
+
|
|
3121
|
+
# Initialize MAP_ID_CONNECTIONS (this was missing!)
|
|
3122
|
+
state_formatter.MAP_ID_CONNECTIONS = {}
|
|
3123
|
+
# print( Initialized MAP_ID_CONNECTIONS in state_formatter module")
|
|
3124
|
+
|
|
3125
|
+
# Convert MapStitcher warp_connections to LOCATION_CONNECTIONS format
|
|
3126
|
+
for conn in self._map_stitcher.warp_connections:
|
|
3127
|
+
# Get areas from map IDs
|
|
3128
|
+
from_area = self._map_stitcher.map_areas.get(conn.from_map_id)
|
|
3129
|
+
to_area = self._map_stitcher.map_areas.get(conn.to_map_id)
|
|
3130
|
+
|
|
3131
|
+
# print( Processing connection: {conn.from_map_id} -> {conn.to_map_id}")
|
|
3132
|
+
# print( From area: {from_area.location_name if from_area else 'None'}")
|
|
3133
|
+
# print( To area: {to_area.location_name if to_area else 'None'}")
|
|
3134
|
+
|
|
3135
|
+
if from_area and to_area:
|
|
3136
|
+
# Use map ID as a fallback key if location names are unknown
|
|
3137
|
+
from_name = from_area.location_name if from_area.location_name != "Unknown" else f"MAP_{conn.from_map_id:04X}"
|
|
3138
|
+
to_name = to_area.location_name if to_area.location_name != "Unknown" else f"MAP_{conn.to_map_id:04X}"
|
|
3139
|
+
|
|
3140
|
+
# print( Using names: {from_name} -> {to_name}")
|
|
3141
|
+
|
|
3142
|
+
# Store bidirectional connections by map ID
|
|
3143
|
+
if conn.from_map_id not in state_formatter.MAP_ID_CONNECTIONS:
|
|
3144
|
+
state_formatter.MAP_ID_CONNECTIONS[conn.from_map_id] = []
|
|
3145
|
+
if conn.to_map_id not in state_formatter.MAP_ID_CONNECTIONS:
|
|
3146
|
+
state_formatter.MAP_ID_CONNECTIONS[conn.to_map_id] = []
|
|
3147
|
+
|
|
3148
|
+
# Add connections
|
|
3149
|
+
state_formatter.MAP_ID_CONNECTIONS[conn.from_map_id].append({
|
|
3150
|
+
'to_map_id': conn.to_map_id,
|
|
3151
|
+
'to_name': to_name,
|
|
3152
|
+
'from_pos': conn.from_position,
|
|
3153
|
+
'to_pos': conn.to_position,
|
|
3154
|
+
'direction': conn.direction
|
|
3155
|
+
})
|
|
3156
|
+
|
|
3157
|
+
state_formatter.MAP_ID_CONNECTIONS[conn.to_map_id].append({
|
|
3158
|
+
'to_map_id': conn.from_map_id,
|
|
3159
|
+
'to_name': from_name,
|
|
3160
|
+
'from_pos': conn.to_position,
|
|
3161
|
+
'to_pos': conn.from_position,
|
|
3162
|
+
'direction': getattr(conn, 'reverse_direction', 'unknown')
|
|
3163
|
+
})
|
|
3164
|
+
|
|
3165
|
+
# Also populate LOCATION_CONNECTIONS format (used by state formatter)
|
|
3166
|
+
if from_name not in state_formatter.LOCATION_CONNECTIONS:
|
|
3167
|
+
state_formatter.LOCATION_CONNECTIONS[from_name] = []
|
|
3168
|
+
if to_name not in state_formatter.LOCATION_CONNECTIONS:
|
|
3169
|
+
state_formatter.LOCATION_CONNECTIONS[to_name] = []
|
|
3170
|
+
|
|
3171
|
+
# Add bidirectional connections to LOCATION_CONNECTIONS
|
|
3172
|
+
from_to_connection = [to_name, list(conn.from_position), list(conn.to_position)]
|
|
3173
|
+
to_from_connection = [from_name, list(conn.to_position), list(conn.from_position)]
|
|
3174
|
+
|
|
3175
|
+
# Avoid duplicates
|
|
3176
|
+
if from_to_connection not in state_formatter.LOCATION_CONNECTIONS[from_name]:
|
|
3177
|
+
state_formatter.LOCATION_CONNECTIONS[from_name].append(from_to_connection)
|
|
3178
|
+
if to_from_connection not in state_formatter.LOCATION_CONNECTIONS[to_name]:
|
|
3179
|
+
state_formatter.LOCATION_CONNECTIONS[to_name].append(to_from_connection)
|
|
3180
|
+
|
|
3181
|
+
# print( Added connection {from_name} <-> {to_name}")
|
|
3182
|
+
|
|
3183
|
+
# print( Synced {len(self._map_stitcher.warp_connections)} warp connections to state formatter")
|
|
3184
|
+
# print( MAP_ID_CONNECTIONS has {len(state_formatter.MAP_ID_CONNECTIONS)} maps")
|
|
3185
|
+
# print( LOCATION_CONNECTIONS has {len(state_formatter.LOCATION_CONNECTIONS)} locations")
|
|
3186
|
+
|
|
3187
|
+
except Exception as e:
|
|
3188
|
+
logger.error(f"Failed to sync warp connections: {e}")
|
|
3189
|
+
|
|
3190
|
+
def _is_encounter_tile(self, behavior) -> bool:
|
|
3191
|
+
"""Check if tile can trigger encounters"""
|
|
3192
|
+
if not behavior:
|
|
3193
|
+
return False
|
|
3194
|
+
|
|
3195
|
+
encounter_behaviors = {
|
|
3196
|
+
MetatileBehavior.TALL_GRASS, MetatileBehavior.LONG_GRASS, MetatileBehavior.UNUSED_05,
|
|
3197
|
+
MetatileBehavior.DEEP_SAND, MetatileBehavior.CAVE, MetatileBehavior.INDOOR_ENCOUNTER,
|
|
3198
|
+
MetatileBehavior.POND_WATER, MetatileBehavior.INTERIOR_DEEP_WATER, MetatileBehavior.DEEP_WATER,
|
|
3199
|
+
MetatileBehavior.OCEAN_WATER, MetatileBehavior.SEAWEED, MetatileBehavior.ASHGRASS,
|
|
3200
|
+
MetatileBehavior.FOOTPRINTS, MetatileBehavior.SEAWEED_NO_SURFACING
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
return behavior in encounter_behaviors
|
|
3204
|
+
|
|
3205
|
+
def _is_surfable_tile(self, behavior) -> bool:
|
|
3206
|
+
"""Check if tile can be surfed on"""
|
|
3207
|
+
if not behavior:
|
|
3208
|
+
return False
|
|
3209
|
+
|
|
3210
|
+
surfable_behaviors = {
|
|
3211
|
+
MetatileBehavior.POND_WATER, MetatileBehavior.INTERIOR_DEEP_WATER, MetatileBehavior.DEEP_WATER,
|
|
3212
|
+
MetatileBehavior.SOOTOPOLIS_DEEP_WATER, MetatileBehavior.OCEAN_WATER, MetatileBehavior.NO_SURFACING,
|
|
3213
|
+
MetatileBehavior.SEAWEED, MetatileBehavior.SEAWEED_NO_SURFACING
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
return behavior in surfable_behaviors
|
|
3217
|
+
|
|
3218
|
+
def test_memory_access(self) -> Dict[str, Any]:
|
|
3219
|
+
"""Test memory access functionality"""
|
|
3220
|
+
diagnostics = {
|
|
3221
|
+
'memory_interface': 'unknown',
|
|
3222
|
+
'memory_methods': [],
|
|
3223
|
+
'save_blocks_found': False,
|
|
3224
|
+
'save_block_offsets': None,
|
|
3225
|
+
'map_buffer_found': False,
|
|
3226
|
+
'basic_reads_working': False
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
# Test memory interface
|
|
3230
|
+
if hasattr(self.memory, 'load8'):
|
|
3231
|
+
diagnostics['memory_interface'] = 'mgba_load_methods'
|
|
3232
|
+
diagnostics['memory_methods'].extend(['load8', 'load16', 'load32'])
|
|
3233
|
+
elif hasattr(self.memory, 'read8'):
|
|
3234
|
+
diagnostics['memory_interface'] = 'mgba_read_methods'
|
|
3235
|
+
diagnostics['memory_methods'].extend(['read8', 'read16', 'read32'])
|
|
3236
|
+
else:
|
|
3237
|
+
diagnostics['memory_interface'] = 'direct_indexing'
|
|
3238
|
+
diagnostics['memory_methods'].append('__getitem__')
|
|
3239
|
+
|
|
3240
|
+
# Test basic memory reads
|
|
3241
|
+
try:
|
|
3242
|
+
test_val = self._read_u8(0x02000000)
|
|
3243
|
+
diagnostics['basic_reads_working'] = True
|
|
3244
|
+
except Exception as e:
|
|
3245
|
+
diagnostics['basic_read_error'] = str(e)
|
|
3246
|
+
|
|
3247
|
+
# Test map buffer detection
|
|
3248
|
+
if self._find_map_buffer_addresses():
|
|
3249
|
+
diagnostics['map_buffer_found'] = True
|
|
3250
|
+
diagnostics['map_buffer_info'] = {
|
|
3251
|
+
'address': hex(self._map_buffer_addr),
|
|
3252
|
+
'width': self._map_width,
|
|
3253
|
+
'height': self._map_height
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
return diagnostics
|
|
3257
|
+
|
|
3258
|
+
def read_dialog(self) -> str:
|
|
3259
|
+
"""Read any dialog text currently on screen by scanning text buffers"""
|
|
3260
|
+
try:
|
|
3261
|
+
# Always try to read dialog text, regardless of game state
|
|
3262
|
+
# The game state detection might not be reliable for dialog
|
|
3263
|
+
|
|
3264
|
+
# Text buffer addresses from Pokemon Emerald decompilation symbols
|
|
3265
|
+
# https://raw.githubusercontent.com/pret/pokeemerald/symbols/pokeemerald.sym
|
|
3266
|
+
# Order by size (largest first) to prioritize longer dialog text
|
|
3267
|
+
text_buffers = [
|
|
3268
|
+
(self.addresses.G_STRING_VAR4, 1000), # Main string variable 4 (largest) - PRIORITY
|
|
3269
|
+
(self.addresses.G_DISPLAYED_STRING_BATTLE, 300), # Battle dialog text
|
|
3270
|
+
(self.addresses.G_STRING_VAR1, 256), # Main string variable 1
|
|
3271
|
+
(self.addresses.G_STRING_VAR2, 256), # Main string variable 2
|
|
3272
|
+
(self.addresses.G_STRING_VAR3, 256), # Main string variable 3
|
|
3273
|
+
(self.addresses.G_BATTLE_TEXT_BUFF1, 16), # Battle text buffer 1
|
|
3274
|
+
(self.addresses.G_BATTLE_TEXT_BUFF2, 16), # Battle text buffer 2
|
|
3275
|
+
(self.addresses.G_BATTLE_TEXT_BUFF3, 16), # Battle text buffer 3
|
|
3276
|
+
# Legacy addresses (keeping for compatibility)
|
|
3277
|
+
(self.addresses.TEXT_BUFFER_1, 200),
|
|
3278
|
+
(self.addresses.TEXT_BUFFER_2, 200),
|
|
3279
|
+
(self.addresses.TEXT_BUFFER_3, 200),
|
|
3280
|
+
(self.addresses.TEXT_BUFFER_4, 200),
|
|
3281
|
+
]
|
|
3282
|
+
|
|
3283
|
+
dialog_text = ""
|
|
3284
|
+
|
|
3285
|
+
for buffer_addr, buffer_size in text_buffers:
|
|
3286
|
+
try:
|
|
3287
|
+
# Read the specified amount of bytes for this buffer
|
|
3288
|
+
buffer_bytes = self._read_bytes(buffer_addr, buffer_size)
|
|
3289
|
+
|
|
3290
|
+
# Look for text patterns
|
|
3291
|
+
text_lines = []
|
|
3292
|
+
current_line = []
|
|
3293
|
+
space_count = 0
|
|
3294
|
+
|
|
3295
|
+
for byte in buffer_bytes:
|
|
3296
|
+
# Check if this is a valid text character using our existing mapping
|
|
3297
|
+
if self._is_valid_text_byte(byte):
|
|
3298
|
+
space_count = 0
|
|
3299
|
+
current_line.append(byte)
|
|
3300
|
+
elif byte == 0x7F: # Space character in Emerald
|
|
3301
|
+
space_count += 1
|
|
3302
|
+
current_line.append(byte)
|
|
3303
|
+
elif byte == 0x4E: # Line break character
|
|
3304
|
+
# End current line
|
|
3305
|
+
if current_line:
|
|
3306
|
+
text = self._decode_pokemon_text(bytes(current_line))
|
|
3307
|
+
if text.strip():
|
|
3308
|
+
text_lines.append(text)
|
|
3309
|
+
current_line = []
|
|
3310
|
+
space_count = 0
|
|
3311
|
+
elif byte == 0xFF: # End of string
|
|
3312
|
+
break
|
|
3313
|
+
|
|
3314
|
+
# If we see too many consecutive spaces, might be end of meaningful text
|
|
3315
|
+
if space_count > 15 and current_line:
|
|
3316
|
+
text = self._decode_pokemon_text(bytes(current_line))
|
|
3317
|
+
if text.strip():
|
|
3318
|
+
text_lines.append(text)
|
|
3319
|
+
current_line = []
|
|
3320
|
+
space_count = 0
|
|
3321
|
+
|
|
3322
|
+
# Add final line if any
|
|
3323
|
+
if current_line:
|
|
3324
|
+
text = self._decode_pokemon_text(bytes(current_line))
|
|
3325
|
+
if text.strip():
|
|
3326
|
+
text_lines.append(text)
|
|
3327
|
+
|
|
3328
|
+
# Join lines and check if we got meaningful text
|
|
3329
|
+
potential_text = "\n".join(text_lines)
|
|
3330
|
+
if len(potential_text.strip()) > 5: # Minimum meaningful length
|
|
3331
|
+
# Clean up the text - remove excessive whitespace and special characters
|
|
3332
|
+
cleaned_text = potential_text.strip()
|
|
3333
|
+
# Remove null bytes and other control characters
|
|
3334
|
+
cleaned_text = ''.join(char for char in cleaned_text if ord(char) >= 32 or char in '\n\t')
|
|
3335
|
+
# Normalize whitespace
|
|
3336
|
+
cleaned_text = ' '.join(cleaned_text.split())
|
|
3337
|
+
|
|
3338
|
+
if len(cleaned_text) > 5:
|
|
3339
|
+
# Prefer longer text (more likely to be full dialog)
|
|
3340
|
+
if len(cleaned_text) > len(dialog_text):
|
|
3341
|
+
dialog_text = cleaned_text
|
|
3342
|
+
logger.debug(f"Found better dialog text: {dialog_text[:100]}...")
|
|
3343
|
+
# If this is the first meaningful text found, use it
|
|
3344
|
+
elif not dialog_text:
|
|
3345
|
+
dialog_text = cleaned_text
|
|
3346
|
+
logger.debug(f"Found dialog text: {dialog_text[:100]}...")
|
|
3347
|
+
|
|
3348
|
+
except Exception as e:
|
|
3349
|
+
logger.debug(f"Failed to read from buffer 0x{buffer_addr:08X} (size: {buffer_size}): {e}")
|
|
3350
|
+
continue
|
|
3351
|
+
|
|
3352
|
+
return dialog_text.strip()
|
|
3353
|
+
|
|
3354
|
+
except Exception as e:
|
|
3355
|
+
logger.warning(f"Failed to read dialog: {e}")
|
|
3356
|
+
return ""
|
|
3357
|
+
|
|
3358
|
+
def read_dialog_with_ocr_fallback(self, screenshot=None) -> str:
|
|
3359
|
+
"""
|
|
3360
|
+
Read dialog text with smart OCR validation to detect stale memory.
|
|
3361
|
+
|
|
3362
|
+
Preference order:
|
|
3363
|
+
1. Both memory AND OCR detect text -> Use memory (most accurate)
|
|
3364
|
+
2. Only OCR detects text -> Use OCR (memory failed)
|
|
3365
|
+
3. Only memory detects text -> Suppress (likely stale/buggy memory)
|
|
3366
|
+
|
|
3367
|
+
Args:
|
|
3368
|
+
screenshot: PIL Image of current game screen (optional)
|
|
3369
|
+
|
|
3370
|
+
Returns:
|
|
3371
|
+
Dialog text using smart preference logic
|
|
3372
|
+
"""
|
|
3373
|
+
# First try memory-based detection with enhanced filtering
|
|
3374
|
+
raw_memory_text = self.read_dialog()
|
|
3375
|
+
|
|
3376
|
+
# Apply residual text filtering like the enhanced dialogue detection does
|
|
3377
|
+
memory_text = ""
|
|
3378
|
+
if raw_memory_text:
|
|
3379
|
+
cleaned_text = raw_memory_text.strip().lower()
|
|
3380
|
+
residual_indicators = [
|
|
3381
|
+
"got away safely", "fled from", "escaped", "ran away",
|
|
3382
|
+
"fainted", "defeated", "victory", "experience points",
|
|
3383
|
+
"gained", "grew to", "learned"
|
|
3384
|
+
]
|
|
3385
|
+
if any(indicator in cleaned_text for indicator in residual_indicators):
|
|
3386
|
+
logger.debug(f"OCR fallback: Filtering out residual battle text: '{raw_memory_text[:30]}...'")
|
|
3387
|
+
memory_text = "" # Treat as no memory text
|
|
3388
|
+
else:
|
|
3389
|
+
memory_text = raw_memory_text
|
|
3390
|
+
|
|
3391
|
+
# If we have OCR available and a screenshot, use smart validation
|
|
3392
|
+
if self._ocr_enabled and screenshot is not None and hasattr(screenshot, 'size'):
|
|
3393
|
+
try:
|
|
3394
|
+
ocr_text = self._ocr_detector.detect_dialogue_from_screenshot(screenshot)
|
|
3395
|
+
|
|
3396
|
+
# Normalize for comparison (strip whitespace, handle None)
|
|
3397
|
+
memory_clean = memory_text.strip() if memory_text else ""
|
|
3398
|
+
ocr_clean = ocr_text.strip() if ocr_text else ""
|
|
3399
|
+
|
|
3400
|
+
# Validate if OCR text is meaningful dialogue (not garbage like 'cL een aA')
|
|
3401
|
+
ocr_is_meaningful = self._is_ocr_meaningful_dialogue(ocr_clean)
|
|
3402
|
+
|
|
3403
|
+
# Case 1: Both memory and OCR found meaningful text
|
|
3404
|
+
if memory_clean and ocr_clean and ocr_is_meaningful:
|
|
3405
|
+
logger.debug(f"Both memory and OCR detected text")
|
|
3406
|
+
logger.debug(f"Memory: '{memory_clean[:50]}...'")
|
|
3407
|
+
logger.debug(f"OCR: '{ocr_clean[:50]}...'")
|
|
3408
|
+
|
|
3409
|
+
# Validate similarity to detect if memory is reasonable
|
|
3410
|
+
if self._texts_are_similar(memory_clean, ocr_clean):
|
|
3411
|
+
logger.debug("✅ Memory and OCR are similar - using memory (most accurate)")
|
|
3412
|
+
return memory_clean
|
|
3413
|
+
else:
|
|
3414
|
+
logger.debug("⚠️ Memory and OCR differ significantly - using memory but flagging")
|
|
3415
|
+
# Still use memory when both exist, but log the discrepancy
|
|
3416
|
+
return memory_clean
|
|
3417
|
+
|
|
3418
|
+
# Case 2: Only OCR found meaningful text (memory failed/empty)
|
|
3419
|
+
elif not memory_clean and ocr_clean and ocr_is_meaningful:
|
|
3420
|
+
logger.debug(f"Only OCR detected meaningful text - memory reading failed")
|
|
3421
|
+
logger.debug(f"Using OCR: '{ocr_clean[:50]}...'")
|
|
3422
|
+
return ocr_clean
|
|
3423
|
+
|
|
3424
|
+
# Case 3: Only memory found text (OCR failed/empty/meaningless)
|
|
3425
|
+
elif memory_clean and (not ocr_clean or not ocr_is_meaningful):
|
|
3426
|
+
if not ocr_clean:
|
|
3427
|
+
logger.debug(f"Only memory detected text - OCR found nothing")
|
|
3428
|
+
elif not ocr_is_meaningful:
|
|
3429
|
+
logger.debug(f"Only memory detected text - OCR found meaningless noise: '{ocr_clean}'")
|
|
3430
|
+
logger.debug(f"Memory text: '{memory_clean[:50]}...'")
|
|
3431
|
+
logger.debug("🚨 SUPPRESSING: Memory-only detection (likely stale/buggy)")
|
|
3432
|
+
# This is the key fix - suppress memory-only detections as they're likely stale
|
|
3433
|
+
return ""
|
|
3434
|
+
|
|
3435
|
+
# Case 4: Neither found text
|
|
3436
|
+
else:
|
|
3437
|
+
logger.debug("Neither memory nor OCR detected dialogue text")
|
|
3438
|
+
return ""
|
|
3439
|
+
|
|
3440
|
+
except Exception as e:
|
|
3441
|
+
logger.debug(f"OCR validation failed: {e}")
|
|
3442
|
+
# Fall back to memory reading if OCR fails completely
|
|
3443
|
+
return memory_text if memory_text else ""
|
|
3444
|
+
|
|
3445
|
+
# If no OCR available, use memory reading as before
|
|
3446
|
+
return memory_text if memory_text else ""
|
|
3447
|
+
|
|
3448
|
+
def _texts_are_similar(self, text1: str, text2: str, threshold: float = 0.4) -> bool:
|
|
3449
|
+
"""
|
|
3450
|
+
Check if two texts are reasonably similar (handles OCR differences)
|
|
3451
|
+
|
|
3452
|
+
Args:
|
|
3453
|
+
text1, text2: Texts to compare
|
|
3454
|
+
threshold: Minimum similarity ratio (0.0-1.0)
|
|
3455
|
+
|
|
3456
|
+
Returns:
|
|
3457
|
+
True if texts are similar enough
|
|
3458
|
+
"""
|
|
3459
|
+
if not text1 or not text2:
|
|
3460
|
+
return False
|
|
3461
|
+
|
|
3462
|
+
# Convert to lowercase and split into words
|
|
3463
|
+
words1 = set(text1.lower().split())
|
|
3464
|
+
words2 = set(text2.lower().split())
|
|
3465
|
+
|
|
3466
|
+
if len(words1) == 0 or len(words2) == 0:
|
|
3467
|
+
return False
|
|
3468
|
+
|
|
3469
|
+
# Calculate Jaccard similarity (intersection over union)
|
|
3470
|
+
intersection = words1.intersection(words2)
|
|
3471
|
+
union = words1.union(words2)
|
|
3472
|
+
|
|
3473
|
+
similarity = len(intersection) / len(union) if len(union) > 0 else 0
|
|
3474
|
+
|
|
3475
|
+
# Also check for substring matches (handles OCR character errors)
|
|
3476
|
+
substring_matches = 0
|
|
3477
|
+
for word1 in words1:
|
|
3478
|
+
for word2 in words2:
|
|
3479
|
+
if len(word1) >= 3 and len(word2) >= 3:
|
|
3480
|
+
if word1 in word2 or word2 in word1:
|
|
3481
|
+
substring_matches += 1
|
|
3482
|
+
break
|
|
3483
|
+
|
|
3484
|
+
substring_similarity = substring_matches / max(len(words1), len(words2))
|
|
3485
|
+
|
|
3486
|
+
# Use the higher of the two similarity measures
|
|
3487
|
+
final_similarity = max(similarity, substring_similarity)
|
|
3488
|
+
|
|
3489
|
+
logger.debug(f"Text similarity: {final_similarity:.2f} (threshold: {threshold})")
|
|
3490
|
+
return final_similarity >= threshold
|
|
3491
|
+
|
|
3492
|
+
def _is_ocr_meaningful_dialogue(self, ocr_text: str) -> bool:
|
|
3493
|
+
"""
|
|
3494
|
+
Determine if OCR text represents meaningful dialogue vs. random noise.
|
|
3495
|
+
|
|
3496
|
+
Args:
|
|
3497
|
+
ocr_text: Text detected by OCR
|
|
3498
|
+
|
|
3499
|
+
Returns:
|
|
3500
|
+
True if the text appears to be meaningful dialogue, False if it's likely noise
|
|
3501
|
+
"""
|
|
3502
|
+
if not ocr_text or len(ocr_text.strip()) == 0:
|
|
3503
|
+
return False
|
|
3504
|
+
|
|
3505
|
+
text = ocr_text.strip().lower()
|
|
3506
|
+
|
|
3507
|
+
# Minimum length check - meaningful dialogue is usually longer than a few characters
|
|
3508
|
+
if len(text) < 6:
|
|
3509
|
+
return False
|
|
3510
|
+
|
|
3511
|
+
# Maximum length check - OCR garbage can be extremely long
|
|
3512
|
+
if len(text) > 200:
|
|
3513
|
+
logger.debug(f"OCR text too long ({len(text)} chars) - likely garbage")
|
|
3514
|
+
return False
|
|
3515
|
+
|
|
3516
|
+
# Check for common dialogue patterns/words
|
|
3517
|
+
dialogue_indicators = [
|
|
3518
|
+
'you', 'the', 'and', 'are', 'use', 'can', 'have', 'will', 'would', 'could', 'should',
|
|
3519
|
+
'pokemon', 'pokémon', 'items', 'store', 'battle', 'want', 'need', 'know', 'think',
|
|
3520
|
+
'pc', 'computer', 'science', 'power', 'staggering', 'hello', 'welcome', 'trainer',
|
|
3521
|
+
'what', 'where', 'when', 'how', 'why', 'who', 'this', 'that', 'there', 'here',
|
|
3522
|
+
'got', 'get', 'give', 'take', 'come', 'go', 'see', 'look', 'find'
|
|
3523
|
+
]
|
|
3524
|
+
|
|
3525
|
+
# Common OCR noise patterns to explicitly reject
|
|
3526
|
+
noise_patterns = [
|
|
3527
|
+
'lle', 'fyi', 'cl', 'een', 'aa', 'ii', 'oo', 'uu', 'mm', 'nn', 'll', 'tt', 'ss',
|
|
3528
|
+
'xx', 'zz', 'qq', 'jj', 'kk', 'vv', 'ww', 'yy', 'ff', 'gg', 'hh', 'bb', 'cc',
|
|
3529
|
+
'dd', 'pp', 'rr' # Common OCR noise patterns
|
|
3530
|
+
]
|
|
3531
|
+
|
|
3532
|
+
words = text.split()
|
|
3533
|
+
meaningful_words = 0
|
|
3534
|
+
|
|
3535
|
+
# Check for OCR garbage patterns that disqualify the entire text
|
|
3536
|
+
if self._has_ocr_garbage_patterns(words):
|
|
3537
|
+
return False
|
|
3538
|
+
|
|
3539
|
+
# Count how many words look like actual dialogue words
|
|
3540
|
+
for word in words:
|
|
3541
|
+
# Remove punctuation for matching
|
|
3542
|
+
clean_word = ''.join(c for c in word if c.isalnum())
|
|
3543
|
+
if len(clean_word) >= 2:
|
|
3544
|
+
# Check if it's a known noise pattern first
|
|
3545
|
+
if clean_word in noise_patterns:
|
|
3546
|
+
continue # Skip noise patterns, don't count as meaningful
|
|
3547
|
+
# Check against dialogue indicators
|
|
3548
|
+
elif clean_word in dialogue_indicators:
|
|
3549
|
+
meaningful_words += 1
|
|
3550
|
+
# Check if word has reasonable character patterns (not random like 'cL')
|
|
3551
|
+
elif self._has_reasonable_word_pattern(clean_word):
|
|
3552
|
+
meaningful_words += 1
|
|
3553
|
+
|
|
3554
|
+
# Need at least 40% of words to be meaningful for it to count as dialogue
|
|
3555
|
+
if len(words) > 0:
|
|
3556
|
+
meaningful_ratio = meaningful_words / len(words)
|
|
3557
|
+
logger.debug(f"OCR meaningfulness: {meaningful_words}/{len(words)} = {meaningful_ratio:.2f} for '{ocr_text}'")
|
|
3558
|
+
return meaningful_ratio >= 0.4
|
|
3559
|
+
|
|
3560
|
+
return False
|
|
3561
|
+
|
|
3562
|
+
def _has_reasonable_word_pattern(self, word: str) -> bool:
|
|
3563
|
+
"""
|
|
3564
|
+
Check if a word has reasonable character patterns vs. OCR noise.
|
|
3565
|
+
|
|
3566
|
+
Args:
|
|
3567
|
+
word: Word to check
|
|
3568
|
+
|
|
3569
|
+
Returns:
|
|
3570
|
+
True if word pattern looks reasonable
|
|
3571
|
+
"""
|
|
3572
|
+
if len(word) < 2:
|
|
3573
|
+
return False
|
|
3574
|
+
|
|
3575
|
+
# Check for reasonable vowel/consonant distribution
|
|
3576
|
+
vowels = 'aeiou'
|
|
3577
|
+
vowel_count = sum(1 for c in word.lower() if c in vowels)
|
|
3578
|
+
consonant_count = len(word) - vowel_count
|
|
3579
|
+
|
|
3580
|
+
# Words should have some vowels unless they're very short
|
|
3581
|
+
if len(word) >= 3 and vowel_count == 0:
|
|
3582
|
+
return False
|
|
3583
|
+
|
|
3584
|
+
# Very short words with only consonants are likely OCR noise
|
|
3585
|
+
if len(word) <= 3 and vowel_count == 0:
|
|
3586
|
+
return False
|
|
3587
|
+
|
|
3588
|
+
# Check for excessive repeated characters (OCR often creates these)
|
|
3589
|
+
repeated_chars = 0
|
|
3590
|
+
for i in range(len(word) - 1):
|
|
3591
|
+
if word[i] == word[i + 1]:
|
|
3592
|
+
repeated_chars += 1
|
|
3593
|
+
|
|
3594
|
+
# Too many repeated characters suggests OCR noise
|
|
3595
|
+
if repeated_chars > len(word) // 2:
|
|
3596
|
+
return False
|
|
3597
|
+
|
|
3598
|
+
return True
|
|
3599
|
+
|
|
3600
|
+
def _has_ocr_garbage_patterns(self, words: list) -> bool:
|
|
3601
|
+
"""
|
|
3602
|
+
Detect OCR garbage patterns that indicate the text is meaningless noise.
|
|
3603
|
+
|
|
3604
|
+
Args:
|
|
3605
|
+
words: List of words from OCR text
|
|
3606
|
+
|
|
3607
|
+
Returns:
|
|
3608
|
+
True if text contains OCR garbage patterns
|
|
3609
|
+
"""
|
|
3610
|
+
if not words or len(words) == 0:
|
|
3611
|
+
return False
|
|
3612
|
+
|
|
3613
|
+
# Pattern 1: Excessive repetition of single characters or short words
|
|
3614
|
+
single_char_words = [w for w in words if len(w) <= 2]
|
|
3615
|
+
if len(single_char_words) > len(words) * 0.5: # More than 50% single/double char words
|
|
3616
|
+
logger.debug(f"OCR garbage: Too many short words ({len(single_char_words)}/{len(words)})")
|
|
3617
|
+
return True
|
|
3618
|
+
|
|
3619
|
+
# Pattern 2: Check for repeated identical words (like 'a a a a a a')
|
|
3620
|
+
word_counts = {}
|
|
3621
|
+
for word in words:
|
|
3622
|
+
clean_word = word.lower().strip()
|
|
3623
|
+
word_counts[clean_word] = word_counts.get(clean_word, 0) + 1
|
|
3624
|
+
|
|
3625
|
+
for word, count in word_counts.items():
|
|
3626
|
+
if len(word) <= 2 and count >= 4: # Same short word repeated 4+ times
|
|
3627
|
+
logger.debug(f"OCR garbage: Repeated short word '{word}' {count} times")
|
|
3628
|
+
return True
|
|
3629
|
+
|
|
3630
|
+
# Pattern 3: Too many "words" - dialogue is usually concise
|
|
3631
|
+
if len(words) > 25: # Pokemon dialogue is typically much shorter
|
|
3632
|
+
logger.debug(f"OCR garbage: Too many words ({len(words)}) for typical dialogue")
|
|
3633
|
+
return True
|
|
3634
|
+
|
|
3635
|
+
# Pattern 4: Check for excessive all-caps "words" (OCR noise often creates these)
|
|
3636
|
+
all_caps_words = [w for w in words if len(w) >= 2 and w.isupper()]
|
|
3637
|
+
if len(all_caps_words) > len(words) * 0.4: # More than 40% all-caps
|
|
3638
|
+
logger.debug(f"OCR garbage: Too many all-caps words ({len(all_caps_words)}/{len(words)})")
|
|
3639
|
+
return True
|
|
3640
|
+
|
|
3641
|
+
# Pattern 5: Check for random character sequences (like 'ePID', 'SCONES')
|
|
3642
|
+
random_looking = 0
|
|
3643
|
+
for word in words:
|
|
3644
|
+
if len(word) >= 3:
|
|
3645
|
+
# Check for mixed case in middle of word (like 'ePID')
|
|
3646
|
+
has_mixed_case = any(c.islower() for c in word) and any(c.isupper() for c in word)
|
|
3647
|
+
# Check for uncommon letter combinations
|
|
3648
|
+
has_weird_patterns = any(combo in word.lower() for combo in ['pq', 'qp', 'xz', 'zx', 'jr', 'rj'])
|
|
3649
|
+
if has_mixed_case or has_weird_patterns:
|
|
3650
|
+
random_looking += 1
|
|
3651
|
+
|
|
3652
|
+
if random_looking > len(words) * 0.3: # More than 30% weird words
|
|
3653
|
+
logger.debug(f"OCR garbage: Too many random-looking words ({random_looking}/{len(words)})")
|
|
3654
|
+
return True
|
|
3655
|
+
|
|
3656
|
+
return False
|
|
3657
|
+
|
|
3658
|
+
def _is_valid_text_byte(self, byte: int) -> bool:
|
|
3659
|
+
"""Check if a byte represents a valid text character in Pokemon Emerald"""
|
|
3660
|
+
# Use the EmeraldCharmap to check if byte is valid
|
|
3661
|
+
charmap = EmeraldCharmap()
|
|
3662
|
+
return byte < len(charmap.charmap) and charmap.charmap[byte] != ""
|
|
3663
|
+
|
|
3664
|
+
def read_flags(self) -> Dict[str, bool]:
|
|
3665
|
+
"""Read game flags to track progress and visited locations"""
|
|
3666
|
+
try:
|
|
3667
|
+
# Get SaveBlock1 pointer
|
|
3668
|
+
save_block_1_ptr = self._read_u32(self.addresses.SAVE_BLOCK1_PTR)
|
|
3669
|
+
if save_block_1_ptr == 0:
|
|
3670
|
+
self._rate_limited_warning("SaveBlock1 pointer is null", "saveblock_pointer")
|
|
3671
|
+
return {}
|
|
3672
|
+
|
|
3673
|
+
# Read flags from SaveBlock1
|
|
3674
|
+
flags_addr = save_block_1_ptr + self.addresses.SAVE_BLOCK1_FLAGS_OFFSET
|
|
3675
|
+
flags_data = self._read_bytes(flags_addr, 300) # Flags are 300 bytes in SaveBlock1
|
|
3676
|
+
|
|
3677
|
+
flags = {}
|
|
3678
|
+
|
|
3679
|
+
# Check system flags (badges, visited locations, etc.)
|
|
3680
|
+
system_flags_start = self.addresses.SYSTEM_FLAGS_START
|
|
3681
|
+
system_flags_byte = system_flags_start // 8
|
|
3682
|
+
system_flags_bit = system_flags_start % 8
|
|
3683
|
+
|
|
3684
|
+
# Badge flags
|
|
3685
|
+
badge_flags = [
|
|
3686
|
+
("badge_01", 0x7), ("badge_02", 0x8), ("badge_03", 0x9), ("badge_04", 0xa),
|
|
3687
|
+
("badge_05", 0xb), ("badge_06", 0xc), ("badge_07", 0xd), ("badge_08", 0xe)
|
|
3688
|
+
]
|
|
3689
|
+
|
|
3690
|
+
for badge_name, flag_offset in badge_flags:
|
|
3691
|
+
flag_byte = system_flags_byte + flag_offset // 8
|
|
3692
|
+
flag_bit = flag_offset % 8
|
|
3693
|
+
if flag_byte < len(flags_data):
|
|
3694
|
+
flags[badge_name] = bool(flags_data[flag_byte] & (1 << flag_bit))
|
|
3695
|
+
|
|
3696
|
+
# Visited location flags
|
|
3697
|
+
location_flags = [
|
|
3698
|
+
("visited_littleroot", 0xF), ("visited_oldale", 0x10), ("visited_dewford", 0x11),
|
|
3699
|
+
("visited_lavaridge", 0x12), ("visited_fallarbor", 0x13), ("visited_verdanturf", 0x14),
|
|
3700
|
+
("visited_pacifidlog", 0x15), ("visited_petalburg", 0x16), ("visited_slateport", 0x17),
|
|
3701
|
+
("visited_mauville", 0x18), ("visited_rustboro", 0x19), ("visited_fortree", 0x1A),
|
|
3702
|
+
("visited_lilycove", 0x1B), ("visited_mossdeep", 0x1C), ("visited_sootopolis", 0x1D),
|
|
3703
|
+
("visited_ever_grande", 0x1E)
|
|
3704
|
+
]
|
|
3705
|
+
|
|
3706
|
+
for location_name, flag_offset in location_flags:
|
|
3707
|
+
flag_byte = system_flags_byte + flag_offset // 8
|
|
3708
|
+
flag_bit = flag_offset % 8
|
|
3709
|
+
if flag_byte < len(flags_data):
|
|
3710
|
+
flags[location_name] = bool(flags_data[flag_byte] & (1 << flag_bit))
|
|
3711
|
+
|
|
3712
|
+
# Champion flag
|
|
3713
|
+
champion_flag_byte = system_flags_byte + 0x1F // 8
|
|
3714
|
+
champion_flag_bit = 0x1F % 8
|
|
3715
|
+
if champion_flag_byte < len(flags_data):
|
|
3716
|
+
flags["is_champion"] = bool(flags_data[champion_flag_byte] & (1 << champion_flag_bit))
|
|
3717
|
+
|
|
3718
|
+
# Pokedex and other system flags
|
|
3719
|
+
pokedex_flag_byte = system_flags_byte + 0x1 // 8
|
|
3720
|
+
pokedex_flag_bit = 0x1 % 8
|
|
3721
|
+
if pokedex_flag_byte < len(flags_data):
|
|
3722
|
+
flags["has_pokedex"] = bool(flags_data[pokedex_flag_byte] & (1 << pokedex_flag_bit))
|
|
3723
|
+
|
|
3724
|
+
logger.info(f"Read {len(flags)} game flags")
|
|
3725
|
+
return flags
|
|
3726
|
+
|
|
3727
|
+
except Exception as e:
|
|
3728
|
+
logger.warning(f"Failed to read flags: {e}")
|
|
3729
|
+
return {}
|
|
3730
|
+
|
|
3731
|
+
def get_game_progress_context(self) -> Dict[str, Any]:
|
|
3732
|
+
"""Get context about game progress for better dialog understanding"""
|
|
3733
|
+
try:
|
|
3734
|
+
flags = self.read_flags()
|
|
3735
|
+
badges = self.read_badges()
|
|
3736
|
+
party = self.read_party_pokemon()
|
|
3737
|
+
|
|
3738
|
+
context = {
|
|
3739
|
+
"badges_obtained": len(badges),
|
|
3740
|
+
"badge_names": badges,
|
|
3741
|
+
"party_size": len(party) if party else 0,
|
|
3742
|
+
"has_pokedex": flags.get("has_pokedex", False),
|
|
3743
|
+
"is_champion": flags.get("is_champion", False),
|
|
3744
|
+
"visited_locations": [k for k, v in flags.items() if k.startswith("visited_") and v],
|
|
3745
|
+
"flags": flags
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
# Add party info if available
|
|
3749
|
+
if party:
|
|
3750
|
+
context["party_levels"] = [p.level for p in party]
|
|
3751
|
+
context["party_species"] = [p.species_name for p in party]
|
|
3752
|
+
|
|
3753
|
+
return context
|
|
3754
|
+
|
|
3755
|
+
except Exception as e:
|
|
3756
|
+
logger.warning(f"Failed to get game progress context: {e}")
|
|
3757
|
+
return {}
|
|
3758
|
+
|
|
3759
|
+
def read_object_events(self):
|
|
3760
|
+
"""
|
|
3761
|
+
Read NPC/trainer object events using OAM sprite detection for walking positions.
|
|
3762
|
+
|
|
3763
|
+
1. First try OAM (Object Attribute Memory) for actual visual sprite positions
|
|
3764
|
+
2. Fallback to static spawn positions from gObjectEvents
|
|
3765
|
+
|
|
3766
|
+
Returns:
|
|
3767
|
+
list: List of object events with their current walking positions
|
|
3768
|
+
"""
|
|
3769
|
+
try:
|
|
3770
|
+
# Get player position
|
|
3771
|
+
player_coords = self.read_coordinates()
|
|
3772
|
+
if not player_coords:
|
|
3773
|
+
self._rate_limited_warning("Could not read player coordinates for NPC search", "coordinates")
|
|
3774
|
+
return []
|
|
3775
|
+
|
|
3776
|
+
player_x, player_y = player_coords
|
|
3777
|
+
object_events = []
|
|
3778
|
+
|
|
3779
|
+
# Method 1: Get stable NPC base positions first
|
|
3780
|
+
logger.debug("Reading base NPC positions from known addresses...")
|
|
3781
|
+
known_npcs = self._read_known_npc_addresses(player_x, player_y)
|
|
3782
|
+
|
|
3783
|
+
if known_npcs:
|
|
3784
|
+
# Method 2: Try to enhance with walking positions from OAM
|
|
3785
|
+
logger.debug("Enhancing NPCs with walking positions from OAM...")
|
|
3786
|
+
enhanced_npcs = self._enhance_npcs_with_oam_walking(known_npcs, player_x, player_y)
|
|
3787
|
+
object_events.extend(enhanced_npcs)
|
|
3788
|
+
else:
|
|
3789
|
+
logger.debug("No known NPCs found, this shouldn't happen in npc.state")
|
|
3790
|
+
object_events = []
|
|
3791
|
+
|
|
3792
|
+
# Filter out false positives (NPCs on door tiles)
|
|
3793
|
+
filtered_events = self._filter_door_false_positives(object_events, player_x, player_y)
|
|
3794
|
+
|
|
3795
|
+
logger.info(f"📍 Found {len(filtered_events)} NPCs/trainers near player at ({player_x}, {player_y})")
|
|
3796
|
+
|
|
3797
|
+
return filtered_events
|
|
3798
|
+
|
|
3799
|
+
except Exception as e:
|
|
3800
|
+
logger.error(f"Failed to read object events: {e}")
|
|
3801
|
+
return []
|
|
3802
|
+
|
|
3803
|
+
def _read_runtime_object_events(self, player_x, player_y):
|
|
3804
|
+
"""
|
|
3805
|
+
Try to read NPCs from runtime sources:
|
|
3806
|
+
1. First try gSprites array (visual sprite positions)
|
|
3807
|
+
2. Fallback to EWRAM addresses and legacy gObjectEvents
|
|
3808
|
+
"""
|
|
3809
|
+
object_events = []
|
|
3810
|
+
|
|
3811
|
+
try:
|
|
3812
|
+
# Method 1: Try gSprites array first - this contains actual visual positions
|
|
3813
|
+
gsprites_npcs = self._read_gsprites_npcs(player_x, player_y)
|
|
3814
|
+
if gsprites_npcs:
|
|
3815
|
+
object_events.extend(gsprites_npcs)
|
|
3816
|
+
logger.debug(f"Found {len(gsprites_npcs)} NPCs in gSprites")
|
|
3817
|
+
|
|
3818
|
+
# Method 2: Try EWRAM runtime addresses
|
|
3819
|
+
runtime_addresses = [
|
|
3820
|
+
(0x0300F428, "EWRAM_runtime_1"), # Found with movement: (10,13) -> (10,2) -> etc
|
|
3821
|
+
(0x03007428, "EWRAM_runtime_2"), # Mirror of above
|
|
3822
|
+
(0x0300DCFC, "EWRAM_runtime_3"), # Different movement pattern
|
|
3823
|
+
(0x03005CFC, "EWRAM_runtime_4"), # Mirror of above
|
|
3824
|
+
]
|
|
3825
|
+
|
|
3826
|
+
found_npcs = 0
|
|
3827
|
+
for addr, location_name in runtime_addresses:
|
|
3828
|
+
try:
|
|
3829
|
+
# Read coordinates directly (they're at offset +8 and +10 in the structure)
|
|
3830
|
+
current_x = self._read_s16(addr + 8)
|
|
3831
|
+
current_y = self._read_s16(addr + 10)
|
|
3832
|
+
|
|
3833
|
+
# Skip if coordinates are obviously invalid
|
|
3834
|
+
if current_x < -50 or current_x > 200 or current_y < -50 or current_y > 200:
|
|
3835
|
+
continue
|
|
3836
|
+
if current_x == 1023 and current_y == 1023: # Common uninitialized value
|
|
3837
|
+
continue
|
|
3838
|
+
if current_x == 0 and current_y == 0: # Likely uninitialized
|
|
3839
|
+
continue
|
|
3840
|
+
|
|
3841
|
+
# Skip coordinates with one 0 when far from player
|
|
3842
|
+
distance = abs(current_x - player_x) + abs(current_y - player_y)
|
|
3843
|
+
if (current_x == 0 or current_y == 0) and distance > 3:
|
|
3844
|
+
continue # Likely uninitialized if has a 0 coordinate and is far from player
|
|
3845
|
+
|
|
3846
|
+
# Only include NPCs within reasonable range of player
|
|
3847
|
+
if distance > 10: # Reduced from 15 to be more conservative
|
|
3848
|
+
continue
|
|
3849
|
+
|
|
3850
|
+
# Read structure around coordinates to extract NPC properties
|
|
3851
|
+
context = self._read_bytes(addr, 24)
|
|
3852
|
+
|
|
3853
|
+
# Try to extract graphics and movement data from surrounding bytes
|
|
3854
|
+
graphics_id = 1 # Default
|
|
3855
|
+
movement_type = 1 # Default
|
|
3856
|
+
trainer_type = 0 # Default
|
|
3857
|
+
|
|
3858
|
+
# Look for reasonable graphics/movement values in context
|
|
3859
|
+
for offset in range(len(context)):
|
|
3860
|
+
val = context[offset]
|
|
3861
|
+
if 1 <= val <= 50 and offset < 16: # Reasonable graphics ID
|
|
3862
|
+
graphics_id = val
|
|
3863
|
+
elif 0 <= val <= 10 and offset < 16: # Reasonable movement type
|
|
3864
|
+
movement_type = val
|
|
3865
|
+
elif 1 <= val <= 5 and offset > 10: # Possible trainer type
|
|
3866
|
+
trainer_type = val
|
|
3867
|
+
|
|
3868
|
+
object_event = {
|
|
3869
|
+
'id': found_npcs,
|
|
3870
|
+
'obj_event_id': found_npcs,
|
|
3871
|
+
'local_id': found_npcs,
|
|
3872
|
+
'graphics_id': graphics_id,
|
|
3873
|
+
'movement_type': movement_type,
|
|
3874
|
+
'current_x': current_x,
|
|
3875
|
+
'current_y': current_y,
|
|
3876
|
+
'initial_x': current_x, # Runtime position, use as initial too
|
|
3877
|
+
'initial_y': current_y,
|
|
3878
|
+
'elevation': 0,
|
|
3879
|
+
'trainer_type': trainer_type,
|
|
3880
|
+
'active': 1,
|
|
3881
|
+
'memory_address': addr,
|
|
3882
|
+
'source': f"ewram_runtime_{location_name}_dist_{distance}"
|
|
3883
|
+
}
|
|
3884
|
+
object_events.append(object_event)
|
|
3885
|
+
found_npcs += 1
|
|
3886
|
+
logger.debug(f"EWRAM Runtime NPC at {location_name}: ({current_x},{current_y}) graphics={graphics_id}")
|
|
3887
|
+
|
|
3888
|
+
except Exception as e:
|
|
3889
|
+
logger.debug(f"Failed to read EWRAM runtime NPC at {location_name}: {e}")
|
|
3890
|
+
continue
|
|
3891
|
+
|
|
3892
|
+
# Fall back to legacy gObjectEvents if EWRAM method fails
|
|
3893
|
+
if not object_events:
|
|
3894
|
+
logger.debug("EWRAM runtime detection failed, trying legacy gObjectEvents...")
|
|
3895
|
+
return self._read_legacy_gobject_events(player_x, player_y)
|
|
3896
|
+
|
|
3897
|
+
except Exception as e:
|
|
3898
|
+
logger.debug(f"EWRAM runtime NPC reading failed: {e}")
|
|
3899
|
+
|
|
3900
|
+
return object_events
|
|
3901
|
+
|
|
3902
|
+
def _read_gsprites_npcs(self, player_x, player_y):
|
|
3903
|
+
"""
|
|
3904
|
+
Read NPCs from gSprites array (actual visual sprite positions during movement)
|
|
3905
|
+
Based on pokeemerald research: proper coordinate conversion with MAP_OFFSET
|
|
3906
|
+
"""
|
|
3907
|
+
object_events = []
|
|
3908
|
+
|
|
3909
|
+
try:
|
|
3910
|
+
# Get known real NPC spawn positions to validate against
|
|
3911
|
+
static_npcs = self._read_known_npc_addresses(player_x, player_y)
|
|
3912
|
+
expected_npc_areas = []
|
|
3913
|
+
for npc in static_npcs:
|
|
3914
|
+
expected_npc_areas.append((npc['current_x'], npc['current_y']))
|
|
3915
|
+
|
|
3916
|
+
if not expected_npc_areas:
|
|
3917
|
+
return [] # No reference NPCs to validate against
|
|
3918
|
+
|
|
3919
|
+
# gSprites location from experimental testing
|
|
3920
|
+
gsprites_addr = 0x03006000
|
|
3921
|
+
max_sprites = 128
|
|
3922
|
+
sprite_size = 64
|
|
3923
|
+
|
|
3924
|
+
for sprite_idx in range(max_sprites):
|
|
3925
|
+
sprite_addr = gsprites_addr + (sprite_idx * sprite_size)
|
|
3926
|
+
|
|
3927
|
+
try:
|
|
3928
|
+
# Read sprite screen coordinates
|
|
3929
|
+
screen_x = self._read_s16(sprite_addr + 0)
|
|
3930
|
+
screen_y = self._read_s16(sprite_addr + 2)
|
|
3931
|
+
|
|
3932
|
+
# Validate screen coordinates
|
|
3933
|
+
if screen_x < 50 or screen_x > 200 or screen_y < 50 or screen_y > 150:
|
|
3934
|
+
continue
|
|
3935
|
+
|
|
3936
|
+
# Convert screen coordinates to map coordinates using pokeemerald research
|
|
3937
|
+
# Screen center is at player position, each tile is 16 pixels
|
|
3938
|
+
SCREEN_CENTER_X = 120
|
|
3939
|
+
SCREEN_CENTER_Y = 80
|
|
3940
|
+
TILE_SIZE = 16
|
|
3941
|
+
|
|
3942
|
+
tile_offset_x = (screen_x - SCREEN_CENTER_X) // TILE_SIZE
|
|
3943
|
+
tile_offset_y = (screen_y - SCREEN_CENTER_Y) // TILE_SIZE
|
|
3944
|
+
|
|
3945
|
+
# Apply correction for sprite centering offset discovered through testing
|
|
3946
|
+
map_x = player_x + tile_offset_x + 1
|
|
3947
|
+
map_y = player_y + tile_offset_y - 1
|
|
3948
|
+
|
|
3949
|
+
# Only include sprites that are near expected NPC spawn areas
|
|
3950
|
+
near_expected_npc = any(
|
|
3951
|
+
abs(map_x - exp_x) + abs(map_y - exp_y) <= 3
|
|
3952
|
+
for exp_x, exp_y in expected_npc_areas
|
|
3953
|
+
)
|
|
3954
|
+
|
|
3955
|
+
if not near_expected_npc:
|
|
3956
|
+
continue
|
|
3957
|
+
|
|
3958
|
+
# Additional validation: distance from player should be reasonable
|
|
3959
|
+
distance = abs(map_x - player_x) + abs(map_y - player_y)
|
|
3960
|
+
if distance == 0 or distance > 8:
|
|
3961
|
+
continue
|
|
3962
|
+
|
|
3963
|
+
object_event = {
|
|
3964
|
+
'id': f"sprite_{sprite_idx}",
|
|
3965
|
+
'obj_event_id': sprite_idx,
|
|
3966
|
+
'local_id': sprite_idx,
|
|
3967
|
+
'graphics_id': 1,
|
|
3968
|
+
'movement_type': 1,
|
|
3969
|
+
'current_x': map_x,
|
|
3970
|
+
'current_y': map_y,
|
|
3971
|
+
'initial_x': map_x,
|
|
3972
|
+
'initial_y': map_y,
|
|
3973
|
+
'elevation': 0,
|
|
3974
|
+
'trainer_type': 0,
|
|
3975
|
+
'active': 1,
|
|
3976
|
+
'memory_address': sprite_addr,
|
|
3977
|
+
'source': f"gsprites_sprite_{sprite_idx}_screen_{screen_x}_{screen_y}_map_{map_x}_{map_y}_dist_{distance}"
|
|
3978
|
+
}
|
|
3979
|
+
object_events.append(object_event)
|
|
3980
|
+
|
|
3981
|
+
except Exception:
|
|
3982
|
+
continue
|
|
3983
|
+
|
|
3984
|
+
except Exception as e:
|
|
3985
|
+
logger.debug(f"Error reading gSprites: {e}")
|
|
3986
|
+
|
|
3987
|
+
return object_events
|
|
3988
|
+
|
|
3989
|
+
def _read_legacy_gobject_events(self, player_x, player_y):
|
|
3990
|
+
"""Legacy gObjectEvents reading method (fallback)"""
|
|
3991
|
+
object_events = []
|
|
3992
|
+
|
|
3993
|
+
try:
|
|
3994
|
+
gobject_events_addr = 0x02037230
|
|
3995
|
+
max_npcs = 16
|
|
3996
|
+
|
|
3997
|
+
for i in range(max_npcs):
|
|
3998
|
+
try:
|
|
3999
|
+
event_addr = gobject_events_addr + (i * 68)
|
|
4000
|
+
|
|
4001
|
+
# Read active flag first - but be more lenient with what we consider active
|
|
4002
|
+
active = self._read_u8(event_addr + 0x00)
|
|
4003
|
+
|
|
4004
|
+
# In save states, active flag might be different values
|
|
4005
|
+
# Be very permissive with active flags to catch all possible NPCs
|
|
4006
|
+
if active == 0x00: # Skip only completely inactive
|
|
4007
|
+
continue
|
|
4008
|
+
|
|
4009
|
+
# Read current runtime position (currentCoords at offset 0x10)
|
|
4010
|
+
current_x = self._read_s16(event_addr + 0x10)
|
|
4011
|
+
current_y = self._read_s16(event_addr + 0x12)
|
|
4012
|
+
|
|
4013
|
+
# Skip if coordinates are obviously invalid
|
|
4014
|
+
if current_x < -50 or current_x > 200 or current_y < -50 or current_y > 200:
|
|
4015
|
+
continue
|
|
4016
|
+
if current_x == 1023 and current_y == 1023: # Common uninitialized value
|
|
4017
|
+
continue
|
|
4018
|
+
if current_x == 0 and current_y == 0: # Skip (0,0) coordinates
|
|
4019
|
+
continue
|
|
4020
|
+
|
|
4021
|
+
# Skip coordinates with one 0 when far from player
|
|
4022
|
+
distance = abs(current_x - player_x) + abs(current_y - player_y)
|
|
4023
|
+
if (current_x == 0 or current_y == 0) and distance > 3:
|
|
4024
|
+
continue
|
|
4025
|
+
|
|
4026
|
+
# Only include NPCs within reasonable range of player
|
|
4027
|
+
if distance > 10: # Reduced to be more conservative
|
|
4028
|
+
continue
|
|
4029
|
+
|
|
4030
|
+
# Read additional NPC properties
|
|
4031
|
+
graphics_id = self._read_u8(event_addr + 0x03)
|
|
4032
|
+
movement_type = self._read_u8(event_addr + 0x04)
|
|
4033
|
+
trainer_type = self._read_u8(event_addr + 0x05)
|
|
4034
|
+
|
|
4035
|
+
# Skip if all properties are clearly invalid
|
|
4036
|
+
if graphics_id == 255 and movement_type == 255:
|
|
4037
|
+
continue
|
|
4038
|
+
|
|
4039
|
+
object_event = {
|
|
4040
|
+
'id': i,
|
|
4041
|
+
'obj_event_id': self._read_u8(event_addr + 0x01),
|
|
4042
|
+
'local_id': self._read_u8(event_addr + 0x02),
|
|
4043
|
+
'graphics_id': graphics_id,
|
|
4044
|
+
'movement_type': movement_type,
|
|
4045
|
+
'current_x': current_x,
|
|
4046
|
+
'current_y': current_y,
|
|
4047
|
+
'initial_x': self._read_s16(event_addr + 0x10),
|
|
4048
|
+
'initial_y': self._read_s16(event_addr + 0x12),
|
|
4049
|
+
'elevation': 0,
|
|
4050
|
+
'trainer_type': trainer_type,
|
|
4051
|
+
'active': 1,
|
|
4052
|
+
'memory_address': event_addr,
|
|
4053
|
+
'source': f"legacy_runtime_slot_{i}_dist_{distance}"
|
|
4054
|
+
}
|
|
4055
|
+
object_events.append(object_event)
|
|
4056
|
+
logger.debug(f"Legacy Runtime NPC {i}: ({current_x},{current_y}) graphics={graphics_id}")
|
|
4057
|
+
|
|
4058
|
+
except Exception as e:
|
|
4059
|
+
logger.debug(f"Failed to read legacy runtime NPC slot {i}: {e}")
|
|
4060
|
+
continue
|
|
4061
|
+
|
|
4062
|
+
except Exception as e:
|
|
4063
|
+
logger.debug(f"Legacy runtime NPC reading failed: {e}")
|
|
4064
|
+
|
|
4065
|
+
return object_events
|
|
4066
|
+
|
|
4067
|
+
def _read_saveblock_object_events(self, player_x, player_y):
|
|
4068
|
+
"""
|
|
4069
|
+
Improved method: scan IWRAM for coordinate pairs near player with better filtering.
|
|
4070
|
+
This finds runtime NPC positions, not just spawn positions.
|
|
4071
|
+
"""
|
|
4072
|
+
object_events = []
|
|
4073
|
+
|
|
4074
|
+
try:
|
|
4075
|
+
# Scan specific IWRAM regions where NPCs are likely stored
|
|
4076
|
+
scan_regions = [
|
|
4077
|
+
(0x02025A00, 0x02026000, "IWRAM_NPCs_1"), # Found NPCs here in memory scan
|
|
4078
|
+
(0x02026000, 0x02027000, "IWRAM_NPCs_2"), # Expanded to cover gap - includes real NPCs at 0x020266C4-0x020266F4
|
|
4079
|
+
]
|
|
4080
|
+
|
|
4081
|
+
all_coords = []
|
|
4082
|
+
|
|
4083
|
+
# Search each region for coordinate pairs
|
|
4084
|
+
for start_addr, end_addr, region_name in scan_regions:
|
|
4085
|
+
try:
|
|
4086
|
+
for addr in range(start_addr, end_addr - 4, 2):
|
|
4087
|
+
try:
|
|
4088
|
+
x = self._read_s16(addr)
|
|
4089
|
+
y = self._read_s16(addr + 2)
|
|
4090
|
+
|
|
4091
|
+
# Skip invalid coordinates
|
|
4092
|
+
if x < -10 or x > 100 or y < -10 or y > 100:
|
|
4093
|
+
continue
|
|
4094
|
+
|
|
4095
|
+
# Skip coordinates at (0,0) or with one coordinate being 0 when far from player
|
|
4096
|
+
if x == 0 and y == 0:
|
|
4097
|
+
continue
|
|
4098
|
+
distance = abs(x - player_x) + abs(y - player_y)
|
|
4099
|
+
if (x == 0 or y == 0) and distance > 5:
|
|
4100
|
+
continue # Likely uninitialized if has a 0 coordinate and is far from player
|
|
4101
|
+
|
|
4102
|
+
# Check if near player (within reasonable range)
|
|
4103
|
+
if distance <= 8 and distance > 0: # Increased range to catch all real NPCs
|
|
4104
|
+
all_coords.append((x, y, addr, distance, region_name))
|
|
4105
|
+
|
|
4106
|
+
except Exception:
|
|
4107
|
+
continue
|
|
4108
|
+
|
|
4109
|
+
except Exception as e:
|
|
4110
|
+
logger.debug(f"Error scanning {region_name}: {e}")
|
|
4111
|
+
continue
|
|
4112
|
+
|
|
4113
|
+
# Filter out duplicates and obvious false positives
|
|
4114
|
+
unique_coords = {}
|
|
4115
|
+
for x, y, addr, distance, region in all_coords:
|
|
4116
|
+
coord_key = (x, y)
|
|
4117
|
+
|
|
4118
|
+
# Skip player position
|
|
4119
|
+
if x == player_x and y == player_y:
|
|
4120
|
+
continue
|
|
4121
|
+
|
|
4122
|
+
# Validate this looks like NPC data by checking surrounding memory
|
|
4123
|
+
try:
|
|
4124
|
+
# Look at bytes around the coordinate pair
|
|
4125
|
+
context = self._read_bytes(addr - 8, 24)
|
|
4126
|
+
|
|
4127
|
+
# Skip if this looks like map data or other non-NPC data
|
|
4128
|
+
# NPCs usually have reasonable graphics IDs (1-50) in surrounding bytes
|
|
4129
|
+
has_reasonable_graphics = any(1 <= b <= 50 for b in context[:8])
|
|
4130
|
+
has_reasonable_movement = any(0 <= b <= 10 for b in context[:8])
|
|
4131
|
+
|
|
4132
|
+
# Skip if too many high values (likely map data)
|
|
4133
|
+
high_value_count = sum(1 for b in context[:12] if b > 100)
|
|
4134
|
+
if high_value_count > 4:
|
|
4135
|
+
continue
|
|
4136
|
+
|
|
4137
|
+
# Skip if all zeros or all 0xFF
|
|
4138
|
+
if all(b == 0 for b in context[:12]) or all(b == 0xFF for b in context[:12]):
|
|
4139
|
+
continue
|
|
4140
|
+
|
|
4141
|
+
confidence = 0.0
|
|
4142
|
+
if has_reasonable_graphics:
|
|
4143
|
+
confidence += 0.3
|
|
4144
|
+
if has_reasonable_movement:
|
|
4145
|
+
confidence += 0.3
|
|
4146
|
+
if distance == 1: # Very close to player - high priority
|
|
4147
|
+
confidence += 0.6 # Increased from 0.4 to prioritize adjacent NPCs
|
|
4148
|
+
elif distance == 2:
|
|
4149
|
+
confidence += 0.3
|
|
4150
|
+
|
|
4151
|
+
# Use higher confidence thresholds to reduce false positives
|
|
4152
|
+
# Only accept very confident detections
|
|
4153
|
+
min_confidence = 0.6 # Increased from 0.2/0.4 to reduce false positives
|
|
4154
|
+
if confidence < min_confidence:
|
|
4155
|
+
continue
|
|
4156
|
+
|
|
4157
|
+
if coord_key not in unique_coords or confidence > unique_coords[coord_key][4]:
|
|
4158
|
+
unique_coords[coord_key] = (x, y, addr, distance, confidence)
|
|
4159
|
+
|
|
4160
|
+
except Exception:
|
|
4161
|
+
continue
|
|
4162
|
+
|
|
4163
|
+
# Sort by distance and create ObjectEvent structures
|
|
4164
|
+
sorted_coords = sorted(unique_coords.values(), key=lambda x: x[3])
|
|
4165
|
+
|
|
4166
|
+
for i, (x, y, addr, distance, confidence) in enumerate(sorted_coords[:5]): # Max 5 NPCs from saveblock
|
|
4167
|
+
try:
|
|
4168
|
+
# Extract NPC properties from surrounding memory
|
|
4169
|
+
graphics_id, movement_type, trainer_type = self._extract_npc_properties(addr)
|
|
4170
|
+
|
|
4171
|
+
object_event = {
|
|
4172
|
+
'id': i,
|
|
4173
|
+
'obj_event_id': i,
|
|
4174
|
+
'local_id': i,
|
|
4175
|
+
'graphics_id': graphics_id,
|
|
4176
|
+
'movement_type': movement_type,
|
|
4177
|
+
'current_x': x,
|
|
4178
|
+
'current_y': y,
|
|
4179
|
+
'initial_x': x, # Best guess - may be current position
|
|
4180
|
+
'initial_y': y,
|
|
4181
|
+
'elevation': 0,
|
|
4182
|
+
'trainer_type': trainer_type,
|
|
4183
|
+
'active': 1,
|
|
4184
|
+
'memory_address': addr,
|
|
4185
|
+
'source': f"iwram_scan_dist_{distance}_conf_{confidence:.2f}"
|
|
4186
|
+
}
|
|
4187
|
+
object_events.append(object_event)
|
|
4188
|
+
logger.debug(f"IWRAM NPC {i}: ({x},{y}) distance={distance} confidence={confidence:.2f}")
|
|
4189
|
+
|
|
4190
|
+
except Exception as e:
|
|
4191
|
+
logger.debug(f"Failed to create IWRAM NPC {i}: {e}")
|
|
4192
|
+
continue
|
|
4193
|
+
|
|
4194
|
+
except Exception as e:
|
|
4195
|
+
logger.debug(f"IWRAM NPC scanning failed: {e}")
|
|
4196
|
+
|
|
4197
|
+
return object_events
|
|
4198
|
+
|
|
4199
|
+
def _read_gobject_walking_positions(self, player_x, player_y):
|
|
4200
|
+
"""
|
|
4201
|
+
Read NPCs from gObjectEvents array using currentCoords for actual walking positions.
|
|
4202
|
+
Based on pokeemerald decompilation: ObjectEvent.currentCoords gives real-time positions.
|
|
4203
|
+
|
|
4204
|
+
Args:
|
|
4205
|
+
player_x, player_y: Player coordinates for distance filtering
|
|
4206
|
+
|
|
4207
|
+
Returns:
|
|
4208
|
+
list: List of NPC objects with their current walking positions
|
|
4209
|
+
"""
|
|
4210
|
+
object_events = []
|
|
4211
|
+
|
|
4212
|
+
try:
|
|
4213
|
+
# gObjectEvents array address from pokeemerald decompilation
|
|
4214
|
+
gobject_events_addr = 0x02037230
|
|
4215
|
+
max_object_events = 16
|
|
4216
|
+
object_event_size = 68 # Size of ObjectEvent struct
|
|
4217
|
+
|
|
4218
|
+
for i in range(max_object_events):
|
|
4219
|
+
try:
|
|
4220
|
+
event_addr = gobject_events_addr + (i * object_event_size)
|
|
4221
|
+
|
|
4222
|
+
# Read ObjectEvent structure according to pokeemerald decompilation
|
|
4223
|
+
# Check if object is active
|
|
4224
|
+
active_flags = self._read_u32(event_addr + 0x00)
|
|
4225
|
+
active = active_flags & 0x1
|
|
4226
|
+
|
|
4227
|
+
if not active:
|
|
4228
|
+
continue
|
|
4229
|
+
|
|
4230
|
+
# Read currentCoords (the walking position) - offset 0x10 based on structure
|
|
4231
|
+
current_x = self._read_s16(event_addr + 0x10) # currentCoords.x
|
|
4232
|
+
current_y = self._read_s16(event_addr + 0x12) # currentCoords.y
|
|
4233
|
+
|
|
4234
|
+
# Validate coordinates are reasonable
|
|
4235
|
+
if current_x < -50 or current_x > 200 or current_y < -50 or current_y > 200:
|
|
4236
|
+
continue
|
|
4237
|
+
if current_x == 1023 and current_y == 1023: # Invalid marker
|
|
4238
|
+
continue
|
|
4239
|
+
|
|
4240
|
+
# Check distance from player (only include nearby NPCs)
|
|
4241
|
+
distance = abs(current_x - player_x) + abs(current_y - player_y)
|
|
4242
|
+
if distance > 15:
|
|
4243
|
+
continue
|
|
4244
|
+
|
|
4245
|
+
# Read additional ObjectEvent properties
|
|
4246
|
+
local_id = self._read_u8(event_addr + 0x02)
|
|
4247
|
+
graphics_id = self._read_u8(event_addr + 0x03)
|
|
4248
|
+
movement_type = self._read_u8(event_addr + 0x04)
|
|
4249
|
+
trainer_type = self._read_u8(event_addr + 0x05)
|
|
4250
|
+
|
|
4251
|
+
# Read initial coordinates for comparison
|
|
4252
|
+
initial_x = self._read_s16(event_addr + 0x14) # initialCoords.x
|
|
4253
|
+
initial_y = self._read_s16(event_addr + 0x16) # initialCoords.y
|
|
4254
|
+
|
|
4255
|
+
# Create NPC object with walking position
|
|
4256
|
+
object_event = {
|
|
4257
|
+
'id': i,
|
|
4258
|
+
'obj_event_id': self._read_u8(event_addr + 0x01),
|
|
4259
|
+
'local_id': local_id,
|
|
4260
|
+
'graphics_id': graphics_id,
|
|
4261
|
+
'movement_type': movement_type,
|
|
4262
|
+
'current_x': current_x, # Walking position
|
|
4263
|
+
'current_y': current_y, # Walking position
|
|
4264
|
+
'initial_x': initial_x, # Spawn position
|
|
4265
|
+
'initial_y': initial_y, # Spawn position
|
|
4266
|
+
'elevation': 0,
|
|
4267
|
+
'trainer_type': trainer_type,
|
|
4268
|
+
'active': 1,
|
|
4269
|
+
'memory_address': event_addr,
|
|
4270
|
+
'source': f'gobject_walking_{i}_current({current_x},{current_y})_spawn({initial_x},{initial_y})',
|
|
4271
|
+
'distance': distance
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
object_events.append(object_event)
|
|
4275
|
+
logger.debug(f"Walking NPC {i}: current({current_x},{current_y}) spawn({initial_x},{initial_y}) graphics={graphics_id}")
|
|
4276
|
+
|
|
4277
|
+
except Exception as e:
|
|
4278
|
+
logger.debug(f"Error reading ObjectEvent slot {i}: {e}")
|
|
4279
|
+
continue
|
|
4280
|
+
|
|
4281
|
+
return object_events
|
|
4282
|
+
|
|
4283
|
+
except Exception as e:
|
|
4284
|
+
logger.debug(f"Error reading gObjectEvents for walking positions: {e}")
|
|
4285
|
+
return []
|
|
4286
|
+
|
|
4287
|
+
def _read_oam_sprites(self, player_x, player_y):
|
|
4288
|
+
"""
|
|
4289
|
+
Read NPC positions from OAM (Object Attribute Memory) sprites.
|
|
4290
|
+
This gives us the actual visual positions during walking animations.
|
|
4291
|
+
|
|
4292
|
+
Args:
|
|
4293
|
+
player_x, player_y: Player coordinates for distance filtering
|
|
4294
|
+
|
|
4295
|
+
Returns:
|
|
4296
|
+
list: List of NPC objects with walking positions
|
|
4297
|
+
"""
|
|
4298
|
+
npcs = []
|
|
4299
|
+
OAM_BASE = 0x07000000
|
|
4300
|
+
MAX_SPRITES = 128
|
|
4301
|
+
|
|
4302
|
+
try:
|
|
4303
|
+
for i in range(MAX_SPRITES):
|
|
4304
|
+
oam_addr = OAM_BASE + (i * 8)
|
|
4305
|
+
|
|
4306
|
+
try:
|
|
4307
|
+
# Read OAM attributes
|
|
4308
|
+
attr0 = self._read_u16(oam_addr)
|
|
4309
|
+
attr1 = self._read_u16(oam_addr + 2)
|
|
4310
|
+
attr2 = self._read_u16(oam_addr + 4)
|
|
4311
|
+
|
|
4312
|
+
# Skip empty sprites
|
|
4313
|
+
if attr0 == 0 and attr1 == 0 and attr2 == 0:
|
|
4314
|
+
continue
|
|
4315
|
+
|
|
4316
|
+
# Check if sprite is visible (not hidden)
|
|
4317
|
+
if attr0 & 0x0300 == 0x0200: # Hidden flag
|
|
4318
|
+
continue
|
|
4319
|
+
|
|
4320
|
+
# Extract screen position
|
|
4321
|
+
y_screen = attr0 & 0x00FF
|
|
4322
|
+
x_screen = attr1 & 0x01FF
|
|
4323
|
+
tile_id = attr2 & 0x03FF
|
|
4324
|
+
|
|
4325
|
+
# Skip invalid positions
|
|
4326
|
+
if x_screen == 0 and y_screen == 0:
|
|
4327
|
+
continue
|
|
4328
|
+
if x_screen > 240 or y_screen > 160: # GBA screen size
|
|
4329
|
+
continue
|
|
4330
|
+
|
|
4331
|
+
# Convert screen coordinates to map coordinates
|
|
4332
|
+
# Player is at screen center (120, 80), each tile is 16 pixels
|
|
4333
|
+
SCREEN_CENTER_X = 120
|
|
4334
|
+
SCREEN_CENTER_Y = 80
|
|
4335
|
+
TILE_SIZE = 16
|
|
4336
|
+
|
|
4337
|
+
# Calculate map position from screen position
|
|
4338
|
+
tile_offset_x = (x_screen - SCREEN_CENTER_X) // TILE_SIZE
|
|
4339
|
+
tile_offset_y = (y_screen - SCREEN_CENTER_Y) // TILE_SIZE
|
|
4340
|
+
|
|
4341
|
+
map_x = player_x + tile_offset_x
|
|
4342
|
+
map_y = player_y + tile_offset_y
|
|
4343
|
+
|
|
4344
|
+
# Skip the player sprite (should be near center of screen)
|
|
4345
|
+
if abs(tile_offset_x) <= 1 and abs(tile_offset_y) <= 1:
|
|
4346
|
+
continue
|
|
4347
|
+
|
|
4348
|
+
# Only include nearby sprites (within reasonable NPC range)
|
|
4349
|
+
distance = abs(map_x - player_x) + abs(map_y - player_y)
|
|
4350
|
+
if distance > 15:
|
|
4351
|
+
continue
|
|
4352
|
+
|
|
4353
|
+
# Don't filter by tile_id - we've seen NPCs with tile_ids 0, 20, 28
|
|
4354
|
+
# All moving sprites in the visible range are likely NPCs
|
|
4355
|
+
|
|
4356
|
+
npc = {
|
|
4357
|
+
'id': f'oam_sprite_{i}',
|
|
4358
|
+
'obj_event_id': i,
|
|
4359
|
+
'local_id': i,
|
|
4360
|
+
'graphics_id': 1, # Default for regular NPC
|
|
4361
|
+
'movement_type': 1, # Walking
|
|
4362
|
+
'current_x': map_x,
|
|
4363
|
+
'current_y': map_y,
|
|
4364
|
+
'initial_x': map_x,
|
|
4365
|
+
'initial_y': map_y,
|
|
4366
|
+
'elevation': 0,
|
|
4367
|
+
'trainer_type': 0,
|
|
4368
|
+
'active': 1,
|
|
4369
|
+
'memory_address': oam_addr,
|
|
4370
|
+
'source': f'oam_sprite_{i}_screen({x_screen},{y_screen})_tile_{tile_id}',
|
|
4371
|
+
'screen_x': x_screen,
|
|
4372
|
+
'screen_y': y_screen,
|
|
4373
|
+
'tile_id': tile_id,
|
|
4374
|
+
'distance': distance
|
|
4375
|
+
}
|
|
4376
|
+
|
|
4377
|
+
npcs.append(npc)
|
|
4378
|
+
logger.debug(f"OAM Sprite {i}: screen({x_screen},{y_screen}) -> map({map_x},{map_y}) tile_id={tile_id}")
|
|
4379
|
+
|
|
4380
|
+
except Exception as e:
|
|
4381
|
+
continue
|
|
4382
|
+
|
|
4383
|
+
except Exception as e:
|
|
4384
|
+
logger.debug(f"Error reading OAM sprites: {e}")
|
|
4385
|
+
|
|
4386
|
+
logger.info(f"Found {len(npcs)} NPC sprites in OAM")
|
|
4387
|
+
return npcs
|
|
4388
|
+
|
|
4389
|
+
def _enhance_npcs_with_oam_walking(self, base_npcs, player_x, player_y):
|
|
4390
|
+
"""
|
|
4391
|
+
Enhance base NPC positions with walking positions from OAM sprites.
|
|
4392
|
+
This maintains stable NPC identity while showing walking animation.
|
|
4393
|
+
|
|
4394
|
+
Args:
|
|
4395
|
+
base_npcs: List of NPCs with known base positions
|
|
4396
|
+
player_x, player_y: Player coordinates
|
|
4397
|
+
|
|
4398
|
+
Returns:
|
|
4399
|
+
list: Enhanced NPCs with walking positions where available
|
|
4400
|
+
"""
|
|
4401
|
+
# Initialize position cache if not exists
|
|
4402
|
+
if not hasattr(self, '_npc_position_cache'):
|
|
4403
|
+
self._npc_position_cache = {}
|
|
4404
|
+
enhanced_npcs = []
|
|
4405
|
+
|
|
4406
|
+
# Get OAM sprites
|
|
4407
|
+
oam_sprites = []
|
|
4408
|
+
OAM_BASE = 0x07000000
|
|
4409
|
+
MAX_SPRITES = 128
|
|
4410
|
+
|
|
4411
|
+
try:
|
|
4412
|
+
for i in range(MAX_SPRITES):
|
|
4413
|
+
oam_addr = OAM_BASE + (i * 8)
|
|
4414
|
+
|
|
4415
|
+
try:
|
|
4416
|
+
attr0 = self._read_u16(oam_addr)
|
|
4417
|
+
attr1 = self._read_u16(oam_addr + 2)
|
|
4418
|
+
attr2 = self._read_u16(oam_addr + 4)
|
|
4419
|
+
|
|
4420
|
+
# Skip empty/hidden sprites
|
|
4421
|
+
if attr0 == 0 and attr1 == 0 and attr2 == 0:
|
|
4422
|
+
continue
|
|
4423
|
+
if attr0 & 0x0300 == 0x0200:
|
|
4424
|
+
continue
|
|
4425
|
+
|
|
4426
|
+
# Extract screen position
|
|
4427
|
+
y_screen = attr0 & 0x00FF
|
|
4428
|
+
x_screen = attr1 & 0x01FF
|
|
4429
|
+
|
|
4430
|
+
if x_screen == 0 and y_screen == 0:
|
|
4431
|
+
continue
|
|
4432
|
+
if x_screen > 240 or y_screen > 160:
|
|
4433
|
+
continue
|
|
4434
|
+
|
|
4435
|
+
# Convert to map coordinates
|
|
4436
|
+
tile_offset_x = (x_screen - 120) // 16
|
|
4437
|
+
tile_offset_y = (y_screen - 80) // 16
|
|
4438
|
+
map_x = player_x + tile_offset_x
|
|
4439
|
+
map_y = player_y + tile_offset_y
|
|
4440
|
+
|
|
4441
|
+
# Skip player sprite (center of screen)
|
|
4442
|
+
if abs(tile_offset_x) <= 1 and abs(tile_offset_y) <= 1:
|
|
4443
|
+
continue
|
|
4444
|
+
|
|
4445
|
+
oam_sprites.append({
|
|
4446
|
+
'map_x': map_x,
|
|
4447
|
+
'map_y': map_y,
|
|
4448
|
+
'screen_x': x_screen,
|
|
4449
|
+
'screen_y': y_screen,
|
|
4450
|
+
'sprite_id': i
|
|
4451
|
+
})
|
|
4452
|
+
|
|
4453
|
+
except Exception:
|
|
4454
|
+
continue
|
|
4455
|
+
|
|
4456
|
+
except Exception as e:
|
|
4457
|
+
logger.debug(f"Error reading OAM for enhancement: {e}")
|
|
4458
|
+
|
|
4459
|
+
# Match each base NPC with nearest OAM sprite (if any)
|
|
4460
|
+
for i, base_npc in enumerate(base_npcs):
|
|
4461
|
+
base_x = base_npc['current_x']
|
|
4462
|
+
base_y = base_npc['current_y']
|
|
4463
|
+
npc_key = f"npc_{i}_{base_x}_{base_y}"
|
|
4464
|
+
|
|
4465
|
+
# Find closest OAM sprite within reasonable range
|
|
4466
|
+
best_sprite = None
|
|
4467
|
+
min_distance = float('inf')
|
|
4468
|
+
|
|
4469
|
+
for sprite in oam_sprites:
|
|
4470
|
+
distance = abs(sprite['map_x'] - base_x) + abs(sprite['map_y'] - base_y)
|
|
4471
|
+
if distance <= 3 and distance < min_distance: # Within 3 tiles of spawn
|
|
4472
|
+
min_distance = distance
|
|
4473
|
+
best_sprite = sprite
|
|
4474
|
+
|
|
4475
|
+
# Create enhanced NPC
|
|
4476
|
+
enhanced_npc = base_npc.copy()
|
|
4477
|
+
|
|
4478
|
+
if best_sprite:
|
|
4479
|
+
new_x, new_y = best_sprite['map_x'], best_sprite['map_y']
|
|
4480
|
+
|
|
4481
|
+
# Check if position changed significantly from cache
|
|
4482
|
+
if npc_key in self._npc_position_cache:
|
|
4483
|
+
cached_x, cached_y = self._npc_position_cache[npc_key]
|
|
4484
|
+
# Only update if moved more than 1 tile or in reasonable range
|
|
4485
|
+
if abs(new_x - cached_x) <= 1 and abs(new_y - cached_y) <= 1:
|
|
4486
|
+
enhanced_npc['current_x'] = new_x
|
|
4487
|
+
enhanced_npc['current_y'] = new_y
|
|
4488
|
+
self._npc_position_cache[npc_key] = (new_x, new_y)
|
|
4489
|
+
else:
|
|
4490
|
+
# Large jump - use cached position for stability
|
|
4491
|
+
enhanced_npc['current_x'] = cached_x
|
|
4492
|
+
enhanced_npc['current_y'] = cached_y
|
|
4493
|
+
else:
|
|
4494
|
+
# First time seeing this NPC
|
|
4495
|
+
enhanced_npc['current_x'] = new_x
|
|
4496
|
+
enhanced_npc['current_y'] = new_y
|
|
4497
|
+
self._npc_position_cache[npc_key] = (new_x, new_y)
|
|
4498
|
+
|
|
4499
|
+
enhanced_npc['source'] = f"npc_{i}_walking"
|
|
4500
|
+
enhanced_npc['walking_position'] = True
|
|
4501
|
+
logger.debug(f"Enhanced NPC {i}: spawn({base_x},{base_y}) walking({enhanced_npc['current_x']},{enhanced_npc['current_y']})")
|
|
4502
|
+
else:
|
|
4503
|
+
# No sprite found - use spawn position but keep in cache
|
|
4504
|
+
if npc_key in self._npc_position_cache:
|
|
4505
|
+
cached_x, cached_y = self._npc_position_cache[npc_key]
|
|
4506
|
+
enhanced_npc['current_x'] = cached_x
|
|
4507
|
+
enhanced_npc['current_y'] = cached_y
|
|
4508
|
+
enhanced_npc['source'] = f"npc_{i}_walking" # Keep walking status if we had it before
|
|
4509
|
+
else:
|
|
4510
|
+
enhanced_npc['current_x'] = base_x
|
|
4511
|
+
enhanced_npc['current_y'] = base_y
|
|
4512
|
+
enhanced_npc['source'] = f"npc_{i}_spawn"
|
|
4513
|
+
self._npc_position_cache[npc_key] = (base_x, base_y)
|
|
4514
|
+
|
|
4515
|
+
enhanced_npc['walking_position'] = False
|
|
4516
|
+
logger.debug(f"Static NPC {i} at ({enhanced_npc['current_x']},{enhanced_npc['current_y']})")
|
|
4517
|
+
|
|
4518
|
+
enhanced_npcs.append(enhanced_npc)
|
|
4519
|
+
|
|
4520
|
+
logger.info(f"Enhanced {len(enhanced_npcs)} NPCs with walking positions")
|
|
4521
|
+
return enhanced_npcs
|
|
4522
|
+
|
|
4523
|
+
def _validate_npc_candidate(self, addr, x, y, player_x, player_y):
|
|
4524
|
+
"""
|
|
4525
|
+
Validate if a coordinate pair at a memory address is likely a real NPC.
|
|
4526
|
+
|
|
4527
|
+
Args:
|
|
4528
|
+
addr: Memory address of the coordinate pair
|
|
4529
|
+
x, y: Coordinates found
|
|
4530
|
+
player_x, player_y: Player coordinates
|
|
4531
|
+
|
|
4532
|
+
Returns:
|
|
4533
|
+
float: Confidence score (0.0 - 1.0) that this is a real NPC
|
|
4534
|
+
"""
|
|
4535
|
+
confidence = 0.0
|
|
4536
|
+
|
|
4537
|
+
try:
|
|
4538
|
+
# Read context around the coordinates
|
|
4539
|
+
context_bytes = self._read_bytes(addr - 8, 32)
|
|
4540
|
+
|
|
4541
|
+
# Check for structured data patterns that suggest this is part of an ObjectEvent
|
|
4542
|
+
|
|
4543
|
+
# 1. Look for reasonable values in typical ObjectEvent fields
|
|
4544
|
+
# Check bytes before coordinates for graphics_id, movement_type etc.
|
|
4545
|
+
if len(context_bytes) >= 16:
|
|
4546
|
+
# Bytes before coordinates might contain NPC metadata
|
|
4547
|
+
for offset in range(8):
|
|
4548
|
+
byte_val = context_bytes[offset]
|
|
4549
|
+
# Graphics IDs are usually 1-50, movement types 0-10
|
|
4550
|
+
if 1 <= byte_val <= 50:
|
|
4551
|
+
confidence += 0.15
|
|
4552
|
+
elif 0 <= byte_val <= 10:
|
|
4553
|
+
confidence += 0.1
|
|
4554
|
+
|
|
4555
|
+
# 2. Check bytes after coordinates for continuation of structure
|
|
4556
|
+
if len(context_bytes) >= 24:
|
|
4557
|
+
# Look for additional structured data after coordinates
|
|
4558
|
+
post_coord_bytes = context_bytes[16:24]
|
|
4559
|
+
non_zero_count = sum(1 for b in post_coord_bytes if b != 0)
|
|
4560
|
+
if non_zero_count > 2: # Some non-zero data suggests structure
|
|
4561
|
+
confidence += 0.2
|
|
4562
|
+
|
|
4563
|
+
# 3. Distance from player - closer NPCs are more likely to be real
|
|
4564
|
+
distance = abs(x - player_x) + abs(y - player_y)
|
|
4565
|
+
if distance == 1:
|
|
4566
|
+
confidence += 0.3 # Very close NPCs most likely
|
|
4567
|
+
elif distance == 2:
|
|
4568
|
+
confidence += 0.2
|
|
4569
|
+
elif distance <= 4:
|
|
4570
|
+
confidence += 0.1
|
|
4571
|
+
|
|
4572
|
+
# 4. Check for patterns that suggest this is NOT an NPC
|
|
4573
|
+
# Coordinates that are exact multiples might be map data, not NPCs
|
|
4574
|
+
if x % 8 == 0 and y % 8 == 0:
|
|
4575
|
+
confidence -= 0.2
|
|
4576
|
+
|
|
4577
|
+
# All zero context suggests empty/unused memory
|
|
4578
|
+
zero_count = sum(1 for b in context_bytes if b == 0)
|
|
4579
|
+
if zero_count > len(context_bytes) * 0.8: # 80%+ zeros
|
|
4580
|
+
confidence -= 0.3
|
|
4581
|
+
|
|
4582
|
+
# All 0xFF suggests uninitialized/invalid data
|
|
4583
|
+
ff_count = sum(1 for b in context_bytes if b == 0xFF)
|
|
4584
|
+
if ff_count > len(context_bytes) * 0.6: # 60%+ 0xFF
|
|
4585
|
+
confidence -= 0.4
|
|
4586
|
+
|
|
4587
|
+
except Exception:
|
|
4588
|
+
# If we can't read context, lower confidence
|
|
4589
|
+
confidence = max(0.0, confidence - 0.2)
|
|
4590
|
+
|
|
4591
|
+
return max(0.0, min(1.0, confidence))
|
|
4592
|
+
|
|
4593
|
+
def _read_proper_gobject_events(self, player_x, player_y):
|
|
4594
|
+
"""
|
|
4595
|
+
Read NPCs from proper gObjectEvents array using ObjectEvent structure validation.
|
|
4596
|
+
Based on pokeemerald decompilation.
|
|
4597
|
+
"""
|
|
4598
|
+
object_events = []
|
|
4599
|
+
|
|
4600
|
+
try:
|
|
4601
|
+
gobject_events_addr = 0x02037230
|
|
4602
|
+
max_object_events = 16
|
|
4603
|
+
object_event_size = 68 # Size of ObjectEvent struct
|
|
4604
|
+
|
|
4605
|
+
for i in range(max_object_events):
|
|
4606
|
+
try:
|
|
4607
|
+
event_addr = gobject_events_addr + (i * object_event_size)
|
|
4608
|
+
|
|
4609
|
+
# Read ObjectEvent structure according to pokeemerald
|
|
4610
|
+
# u32 active:1 bitfield at offset 0x00
|
|
4611
|
+
active_flags = self._read_u32(event_addr + 0x00)
|
|
4612
|
+
active = active_flags & 0x1
|
|
4613
|
+
|
|
4614
|
+
if not active:
|
|
4615
|
+
continue
|
|
4616
|
+
|
|
4617
|
+
# Read coordinates from currentCoords at offset 0x10
|
|
4618
|
+
current_x = self._read_s16(event_addr + 0x10)
|
|
4619
|
+
current_y = self._read_s16(event_addr + 0x12)
|
|
4620
|
+
|
|
4621
|
+
# Validate coordinates
|
|
4622
|
+
if current_x < -50 or current_x > 200 or current_y < -50 or current_y > 200:
|
|
4623
|
+
continue
|
|
4624
|
+
if current_x == 1023 and current_y == 1023:
|
|
4625
|
+
continue
|
|
4626
|
+
if current_x == 0 and current_y == 0:
|
|
4627
|
+
continue # Filter out (0,0) coordinates which are often uninitialized
|
|
4628
|
+
|
|
4629
|
+
# Check distance from player
|
|
4630
|
+
distance = abs(current_x - player_x) + abs(current_y - player_y)
|
|
4631
|
+
if distance > 15:
|
|
4632
|
+
continue
|
|
4633
|
+
|
|
4634
|
+
# Read NPC properties
|
|
4635
|
+
graphics_id = self._read_u8(event_addr + 0x03)
|
|
4636
|
+
movement_type = self._read_u8(event_addr + 0x04)
|
|
4637
|
+
trainer_type = self._read_u8(event_addr + 0x05)
|
|
4638
|
+
local_id = self._read_u8(event_addr + 0x02)
|
|
4639
|
+
|
|
4640
|
+
object_event = {
|
|
4641
|
+
'id': i,
|
|
4642
|
+
'obj_event_id': self._read_u8(event_addr + 0x01),
|
|
4643
|
+
'local_id': local_id,
|
|
4644
|
+
'graphics_id': graphics_id,
|
|
4645
|
+
'movement_type': movement_type,
|
|
4646
|
+
'current_x': current_x,
|
|
4647
|
+
'current_y': current_y,
|
|
4648
|
+
'initial_x': current_x,
|
|
4649
|
+
'initial_y': current_y,
|
|
4650
|
+
'elevation': 0,
|
|
4651
|
+
'trainer_type': trainer_type,
|
|
4652
|
+
'active': 1,
|
|
4653
|
+
'memory_address': event_addr,
|
|
4654
|
+
'source': f"gobject_events_slot_{i}_dist_{distance}"
|
|
4655
|
+
}
|
|
4656
|
+
object_events.append(object_event)
|
|
4657
|
+
logger.debug(f"Active ObjectEvent {i}: ({current_x}, {current_y}) graphics={graphics_id}")
|
|
4658
|
+
|
|
4659
|
+
except Exception as e:
|
|
4660
|
+
logger.debug(f"Error reading ObjectEvent slot {i}: {e}")
|
|
4661
|
+
continue
|
|
4662
|
+
|
|
4663
|
+
return object_events
|
|
4664
|
+
|
|
4665
|
+
except Exception as e:
|
|
4666
|
+
logger.debug(f"Error reading gObjectEvents: {e}")
|
|
4667
|
+
return []
|
|
4668
|
+
|
|
4669
|
+
def _read_known_npc_addresses(self, player_x, player_y):
|
|
4670
|
+
"""
|
|
4671
|
+
Fallback method: Read NPCs from known addresses found in ground truth validation.
|
|
4672
|
+
Used when gObjectEvents array is inactive (e.g., in save states).
|
|
4673
|
+
"""
|
|
4674
|
+
object_events = []
|
|
4675
|
+
|
|
4676
|
+
try:
|
|
4677
|
+
# Known addresses where real NPCs are stored in different save states
|
|
4678
|
+
# These were discovered through ground truth validation
|
|
4679
|
+
known_npc_addresses = [
|
|
4680
|
+
# npc.state addresses
|
|
4681
|
+
0x020266C4, 0x020266DC, 0x020266F4,
|
|
4682
|
+
# npc1.state addresses
|
|
4683
|
+
0x020266C8, # Adjacent NPC at (6,4) from player at (7,4)
|
|
4684
|
+
]
|
|
4685
|
+
|
|
4686
|
+
for i, addr in enumerate(known_npc_addresses):
|
|
4687
|
+
try:
|
|
4688
|
+
x = self._read_s16(addr)
|
|
4689
|
+
y = self._read_s16(addr + 2)
|
|
4690
|
+
|
|
4691
|
+
# Validate coordinates
|
|
4692
|
+
if x < -50 or x > 200 or y < -50 or y > 200:
|
|
4693
|
+
continue
|
|
4694
|
+
if x == 1023 and y == 1023:
|
|
4695
|
+
continue
|
|
4696
|
+
|
|
4697
|
+
distance = abs(x - player_x) + abs(y - player_y)
|
|
4698
|
+
if distance > 15:
|
|
4699
|
+
continue
|
|
4700
|
+
|
|
4701
|
+
object_event = {
|
|
4702
|
+
'id': i,
|
|
4703
|
+
'obj_event_id': i,
|
|
4704
|
+
'local_id': i,
|
|
4705
|
+
'graphics_id': 1, # Default for regular NPC
|
|
4706
|
+
'movement_type': 0, # Default stationary
|
|
4707
|
+
'current_x': x,
|
|
4708
|
+
'current_y': y,
|
|
4709
|
+
'initial_x': x,
|
|
4710
|
+
'initial_y': y,
|
|
4711
|
+
'elevation': 0,
|
|
4712
|
+
'trainer_type': 0, # Regular NPC
|
|
4713
|
+
'active': 1,
|
|
4714
|
+
'memory_address': addr,
|
|
4715
|
+
'source': f"known_addr_0x{addr:08X}_dist_{distance}"
|
|
4716
|
+
}
|
|
4717
|
+
object_events.append(object_event)
|
|
4718
|
+
logger.debug(f"Known NPC {i}: ({x}, {y}) distance={distance}")
|
|
4719
|
+
|
|
4720
|
+
except Exception as e:
|
|
4721
|
+
logger.debug(f"Error reading known NPC at 0x{addr:08X}: {e}")
|
|
4722
|
+
continue
|
|
4723
|
+
|
|
4724
|
+
return object_events
|
|
4725
|
+
|
|
4726
|
+
except Exception as e:
|
|
4727
|
+
logger.debug(f"Error reading known NPC addresses: {e}")
|
|
4728
|
+
return []
|
|
4729
|
+
|
|
4730
|
+
def _filter_door_false_positives(self, object_events, player_x, player_y):
|
|
4731
|
+
"""
|
|
4732
|
+
Filter out false positive NPCs that are actually doors or other map features.
|
|
4733
|
+
|
|
4734
|
+
Args:
|
|
4735
|
+
object_events: List of detected NPCs
|
|
4736
|
+
player_x, player_y: Player coordinates for map reading
|
|
4737
|
+
|
|
4738
|
+
Returns:
|
|
4739
|
+
list: Filtered list of real NPCs
|
|
4740
|
+
"""
|
|
4741
|
+
try:
|
|
4742
|
+
# Read map tiles around player to check for doors
|
|
4743
|
+
map_tiles = self.read_map_around_player(radius=7) # 15x15 grid for better context
|
|
4744
|
+
if not map_tiles:
|
|
4745
|
+
# If we can't read map, return all NPCs (better to have false positives than miss real ones)
|
|
4746
|
+
return object_events
|
|
4747
|
+
|
|
4748
|
+
filtered_npcs = []
|
|
4749
|
+
|
|
4750
|
+
for npc in object_events:
|
|
4751
|
+
npc_x = npc['current_x']
|
|
4752
|
+
npc_y = npc['current_y']
|
|
4753
|
+
|
|
4754
|
+
# Calculate position on map grid (player is at center 7,7)
|
|
4755
|
+
grid_x = npc_x - player_x + 7
|
|
4756
|
+
grid_y = npc_y - player_y + 7
|
|
4757
|
+
|
|
4758
|
+
# Check if position is within map bounds
|
|
4759
|
+
if 0 <= grid_y < len(map_tiles) and 0 <= grid_x < len(map_tiles[0]):
|
|
4760
|
+
tile = map_tiles[grid_y][grid_x]
|
|
4761
|
+
|
|
4762
|
+
# Check tile behavior
|
|
4763
|
+
if len(tile) >= 2:
|
|
4764
|
+
behavior = tile[1]
|
|
4765
|
+
|
|
4766
|
+
# Skip if this is a door tile
|
|
4767
|
+
if hasattr(behavior, 'name'):
|
|
4768
|
+
behavior_name = behavior.name
|
|
4769
|
+
if 'DOOR' in behavior_name:
|
|
4770
|
+
logger.debug(f"Filtering out false NPC at ({npc_x}, {npc_y}) - on door tile")
|
|
4771
|
+
continue
|
|
4772
|
+
|
|
4773
|
+
# Could add more filters here for other false positives
|
|
4774
|
+
# e.g., WARP tiles, SIGN tiles, etc.
|
|
4775
|
+
|
|
4776
|
+
# If we get here, it's likely a real NPC
|
|
4777
|
+
filtered_npcs.append(npc)
|
|
4778
|
+
|
|
4779
|
+
if len(filtered_npcs) != len(object_events):
|
|
4780
|
+
logger.info(f"Filtered {len(object_events) - len(filtered_npcs)} false positive NPCs (doors, etc.)")
|
|
4781
|
+
|
|
4782
|
+
return filtered_npcs
|
|
4783
|
+
|
|
4784
|
+
except Exception as e:
|
|
4785
|
+
logger.warning(f"Failed to filter door false positives: {e}")
|
|
4786
|
+
# On error, return original list
|
|
4787
|
+
return object_events
|
|
4788
|
+
|
|
4789
|
+
def _extract_npc_properties(self, addr):
|
|
4790
|
+
"""
|
|
4791
|
+
Extract NPC properties (graphics_id, movement_type, trainer_type) from memory context.
|
|
4792
|
+
|
|
4793
|
+
Args:
|
|
4794
|
+
addr: Memory address where coordinates were found
|
|
4795
|
+
|
|
4796
|
+
Returns:
|
|
4797
|
+
tuple: (graphics_id, movement_type, trainer_type)
|
|
4798
|
+
"""
|
|
4799
|
+
try:
|
|
4800
|
+
# Read context around the coordinate pair
|
|
4801
|
+
context_bytes = self._read_bytes(addr - 12, 24)
|
|
4802
|
+
|
|
4803
|
+
# Try different interpretations of the structure
|
|
4804
|
+
# We'll look for reasonable values in typical positions
|
|
4805
|
+
|
|
4806
|
+
graphics_id = 1 # Default to visible NPC
|
|
4807
|
+
movement_type = 0 # Default to stationary
|
|
4808
|
+
trainer_type = 0 # Default to regular NPC
|
|
4809
|
+
|
|
4810
|
+
if len(context_bytes) >= 24:
|
|
4811
|
+
# Look for graphics_id in bytes before coordinates
|
|
4812
|
+
for offset in range(8):
|
|
4813
|
+
byte_val = context_bytes[offset]
|
|
4814
|
+
if 1 <= byte_val <= 50: # Reasonable graphics_id range
|
|
4815
|
+
graphics_id = byte_val
|
|
4816
|
+
break
|
|
4817
|
+
|
|
4818
|
+
# Look for movement_type
|
|
4819
|
+
for offset in range(1, 9):
|
|
4820
|
+
byte_val = context_bytes[offset]
|
|
4821
|
+
if 0 <= byte_val <= 10: # Reasonable movement_type range
|
|
4822
|
+
movement_type = byte_val
|
|
4823
|
+
break
|
|
4824
|
+
|
|
4825
|
+
# Look for trainer_type in bytes after coordinates
|
|
4826
|
+
for offset in range(16, 24):
|
|
4827
|
+
if offset < len(context_bytes):
|
|
4828
|
+
byte_val = context_bytes[offset]
|
|
4829
|
+
if 1 <= byte_val <= 5: # Common trainer type range
|
|
4830
|
+
trainer_type = byte_val
|
|
4831
|
+
break
|
|
4832
|
+
|
|
4833
|
+
return graphics_id, movement_type, trainer_type
|
|
4834
|
+
|
|
4835
|
+
except Exception:
|
|
4836
|
+
# If we can't extract properties, return defaults
|
|
4837
|
+
return 1, 0, 0
|
|
4838
|
+
|
|
4839
|
+
def _setup_location_connections_callback(self):
|
|
4840
|
+
"""Set up callback to save location connections when they change"""
|
|
4841
|
+
from utils import state_formatter
|
|
4842
|
+
|
|
4843
|
+
def save_callback():
|
|
4844
|
+
if self._map_stitcher:
|
|
4845
|
+
self._map_stitcher.save_to_file()
|
|
4846
|
+
|
|
4847
|
+
state_formatter.MAP_STITCHER_SAVE_CALLBACK = save_callback
|
|
4848
|
+
# print( Set up location connections save callback")
|