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.

Files changed (291) hide show
  1. examples/multi_step/configs/README_verilog_rl.md +77 -0
  2. examples/multi_step/configs/VERILOG_REWARDS.md +90 -0
  3. examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +183 -0
  4. examples/multi_step/configs/crafter_eval_synth_qwen4b.toml +35 -0
  5. examples/multi_step/configs/crafter_eval_text_only_groq_qwen32b.toml +36 -0
  6. examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +17 -5
  7. examples/multi_step/configs/crafter_synth_backend.md +40 -0
  8. examples/multi_step/configs/verilog_eval_groq_qwen32b.toml +31 -0
  9. examples/multi_step/configs/verilog_eval_synth_qwen8b.toml +33 -0
  10. examples/multi_step/configs/verilog_rl_lora.toml +190 -0
  11. examples/multi_step/judges/crafter_backend_judge.py +220 -0
  12. examples/multi_step/judges/verilog_backend_judge.py +234 -0
  13. examples/multi_step/readme.md +48 -0
  14. examples/multi_step/verilog_rl_lora.md +218 -0
  15. examples/qwen_coder/configs/coder_lora_30b.toml +1 -1
  16. examples/sft/evaluate.py +2 -0
  17. examples/sft/generate_traces.py +2 -0
  18. examples/swe/task_app/grpo_swe_mini.py +56 -26
  19. examples/swe/task_app/hosted/rollout.py +42 -0
  20. examples/swe/task_app/hosted/test_service.py +5 -6
  21. examples/task_apps/IMAGE_ONLY_EVAL_QUICKSTART.md +258 -0
  22. examples/task_apps/TESTING.md +275 -0
  23. examples/task_apps/__init__.py +0 -0
  24. examples/task_apps/crafter/CREATE_SFT_DATASET.md +273 -0
  25. examples/task_apps/crafter/EVAL_IMAGE_ONLY_RESULTS.md +152 -0
  26. examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +174 -0
  27. examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +268 -0
  28. examples/task_apps/crafter/QUERY_EXAMPLES.md +203 -0
  29. examples/task_apps/crafter/README_IMAGE_ONLY_EVAL.md +316 -0
  30. examples/task_apps/crafter/__init__.py +0 -0
  31. examples/task_apps/crafter/eval_image_only_gpt4o.toml +28 -0
  32. examples/task_apps/crafter/eval_text_only_groq_llama.toml +36 -0
  33. examples/task_apps/crafter/filter_sft_dataset.toml +16 -0
  34. examples/task_apps/crafter/task_app/__init__.py +5 -0
  35. examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter.py +324 -21
  36. examples/{warming_up_to_rl → task_apps/crafter}/task_app/grpo_crafter_task_app.py +1 -1
  37. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/environment.py +10 -0
  38. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/policy.py +76 -7
  39. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/react_agent.py +17 -2
  40. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/openai_client.py +25 -3
  41. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/policy_routes.py +77 -4
  42. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/rollout.py +117 -9
  43. examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_service.py +5 -6
  44. examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +218 -0
  45. examples/task_apps/dev/pokemon_emerald/__init__.py +2 -0
  46. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/README.md +811 -0
  47. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/__init__.py +120 -0
  48. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/action.py +160 -0
  49. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/memory.py +155 -0
  50. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/perception.py +69 -0
  51. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/planning.py +96 -0
  52. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/simple.py +1502 -0
  53. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/agent/system_prompt.py +4 -0
  54. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/grab_map.py +68 -0
  55. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/manual.py +216 -0
  56. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/__init__.py +35 -0
  57. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emerald_utils.py +631 -0
  58. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/emulator.py +1544 -0
  59. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/enums.py +1428 -0
  60. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/memory_reader.py +4848 -0
  61. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/types.py +41 -0
  62. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pokemon_env/utils.py +298 -0
  63. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/pyproject.toml +95 -0
  64. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/run.py +204 -0
  65. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/__init__.py +0 -0
  66. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/app.py +2152 -0
  67. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/client.py +429 -0
  68. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/server/frame_server.py +155 -0
  69. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/README.md +78 -0
  70. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/__init__.py +0 -0
  71. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/run_tests.py +122 -0
  72. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_direct.py +76 -0
  73. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_agent_prompts.py +413 -0
  74. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_battle_state_formatting.py +204 -0
  75. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection.py +133 -0
  76. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_dialogue_detection_comprehensive.py +229 -0
  77. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_direct_agent_emulator.py +300 -0
  78. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_fps_adjustment_pytest.py +205 -0
  79. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_direct.py +200 -0
  80. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_house_to_outside_transition.py +284 -0
  81. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_map_ground_truth_comparison.py +468 -0
  82. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_memory_map.py +575 -0
  83. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_server_map_validation.py +311 -0
  84. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/tests/test_torchic_state.py +259 -0
  85. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/__init__.py +0 -0
  86. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/anticheat.py +372 -0
  87. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/checkpoint.py +296 -0
  88. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/error_handler.py +275 -0
  89. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/get_local_ip.py +22 -0
  90. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/helpers.py +44 -0
  91. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/llm_logger.py +514 -0
  92. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_formatter.py +415 -0
  93. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher.py +1763 -0
  94. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_stitcher_singleton.py +33 -0
  95. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_trimmer.py +106 -0
  96. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/map_visualizer.py +334 -0
  97. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/ocr_dialogue.py +1020 -0
  98. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/recording.py +188 -0
  99. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/state_formatter.py +1481 -0
  100. examples/task_apps/dev/pokemon_emerald/external/pokeagent-speedrun/utils/vlm.py +862 -0
  101. examples/task_apps/dev/pokemon_emerald/modal_app.py +114 -0
  102. examples/task_apps/dev/pokemon_emerald/task_app/README.md +81 -0
  103. examples/task_apps/dev/pokemon_emerald/task_app/__init__.py +6 -0
  104. examples/task_apps/dev/pokemon_emerald/task_app/pokemon_emerald.py +685 -0
  105. examples/task_apps/enron/__init__.py +1 -0
  106. examples/task_apps/enron/eval_groq_qwen32.toml +16 -0
  107. examples/task_apps/enron/filter_sft.toml +5 -0
  108. examples/task_apps/enron/task_app/README.md +14 -0
  109. examples/task_apps/enron/task_app/__init__.py +1 -0
  110. examples/task_apps/enron/task_app/grpo_enron.py +906 -0
  111. examples/task_apps/enron/task_app/grpo_enron_task_app.py +146 -0
  112. examples/task_apps/enron/tests/__init__.py +4 -0
  113. examples/task_apps/enron/tests/conftest.py +115 -0
  114. examples/task_apps/enron/tests/integration/__init__.py +4 -0
  115. examples/task_apps/enron/tests/integration/test_enron_eval.py +179 -0
  116. examples/task_apps/enron/tests/integration/test_enron_rollout.py +135 -0
  117. examples/task_apps/enron/tests/unit/__init__.py +4 -0
  118. examples/task_apps/enron/tests/unit/test_enron_environment.py +126 -0
  119. examples/task_apps/math/__init__.py +0 -0
  120. examples/{rl/task_app → task_apps/math}/math_single_step.py +19 -10
  121. examples/task_apps/pokemon_battle/__init__.py +2 -0
  122. examples/task_apps/pokemon_battle/modal_app.py +104 -0
  123. examples/task_apps/pokemon_battle/task_app/README.md +68 -0
  124. examples/task_apps/pokemon_battle/task_app/__init__.py +6 -0
  125. examples/task_apps/pokemon_battle/task_app/pokemon_showdown.py +932 -0
  126. examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_COMPLETE.md +283 -0
  127. examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_STATUS.md +155 -0
  128. examples/task_apps/pokemon_red/README.md +357 -0
  129. examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +415 -0
  130. examples/task_apps/pokemon_red/__init__.py +3 -0
  131. examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +29 -0
  132. examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +225 -0
  133. examples/task_apps/pokemon_red/pallet_town_rl_config.toml +75 -0
  134. examples/task_apps/pokemon_red/task_app.py +799 -0
  135. examples/task_apps/pokemon_red/test_pallet_town_rewards.py +193 -0
  136. examples/task_apps/sokoban/README.md +307 -0
  137. examples/task_apps/sokoban/__init__.py +3 -0
  138. examples/task_apps/sokoban/eval_groq_qwen32.toml +16 -0
  139. examples/task_apps/sokoban/eval_openai_gpt5.toml +16 -0
  140. examples/task_apps/sokoban/filter_sft.toml +5 -0
  141. examples/task_apps/sokoban/task_app.py +1058 -0
  142. examples/task_apps/sokoban/tests/__init__.py +4 -0
  143. examples/task_apps/sokoban/tests/conftest.py +113 -0
  144. examples/task_apps/sokoban/tests/integration/__init__.py +4 -0
  145. examples/task_apps/sokoban/tests/integration/test_sokoban_eval.py +57 -0
  146. examples/task_apps/sokoban/tests/integration/test_sokoban_rollout.py +198 -0
  147. examples/task_apps/sokoban/tests/unit/__init__.py +4 -0
  148. examples/task_apps/sokoban/tests/unit/test_sokoban_environment.py +114 -0
  149. examples/task_apps/verilog/__init__.py +1 -0
  150. examples/task_apps/verilog/eval_groq_qwen32b.toml +24 -0
  151. examples/task_apps/verilog/filter_sft.toml +5 -0
  152. examples/task_apps/verilog/task_app/README.md +12 -0
  153. examples/task_apps/verilog/task_app/__init__.py +1 -0
  154. examples/task_apps/verilog/task_app/grpo_verilog.py +1166 -0
  155. examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +145 -0
  156. examples/task_apps/verilog/tests/__init__.py +4 -0
  157. examples/task_apps/verilog/tests/conftest.py +115 -0
  158. examples/task_apps/verilog/tests/integration/__init__.py +4 -0
  159. examples/task_apps/verilog/tests/integration/test_verilog_eval.py +181 -0
  160. examples/task_apps/verilog/tests/integration/test_verilog_rollout.py +55 -0
  161. examples/task_apps/verilog/tests/unit/__init__.py +4 -0
  162. examples/task_apps/verilog/tests/unit/test_verilog_scoring.py +118 -0
  163. examples/vlm/crafter_openai_vlm_agent.py +4 -4
  164. examples/vlm/run_crafter_vlm_benchmark.py +4 -4
  165. examples/warming_up_to_rl/groq_test.py +2 -0
  166. examples/warming_up_to_rl/run_local_rollout.py +2 -0
  167. examples/warming_up_to_rl/run_local_rollout_modal.py +2 -0
  168. examples/warming_up_to_rl/run_local_rollout_parallel.py +2 -0
  169. examples/warming_up_to_rl/run_local_rollout_traced.py +2 -0
  170. examples/warming_up_to_rl/run_rollout_remote.py +2 -0
  171. examples/workflows/__init__.py +0 -0
  172. examples/workflows/math_rl/__init__.py +0 -0
  173. examples/workflows/math_rl/download_dataset.py +80 -0
  174. synth_ai/__init__.py +2 -2
  175. synth_ai/api/models/supported.py +1 -0
  176. synth_ai/api/train/builders.py +25 -11
  177. synth_ai/api/train/cli.py +12 -6
  178. synth_ai/api/train/configs/__init__.py +10 -10
  179. synth_ai/api/train/configs/rl.py +5 -4
  180. synth_ai/api/train/configs/sft.py +4 -3
  181. synth_ai/api/train/env_resolver.py +5 -2
  182. synth_ai/api/train/supported_algos.py +10 -5
  183. synth_ai/api/train/utils.py +7 -4
  184. synth_ai/cli/__init__.py +48 -59
  185. synth_ai/cli/_modal_wrapper.py +3 -2
  186. synth_ai/cli/_storage.py +4 -3
  187. synth_ai/cli/_validate_task_app.py +11 -0
  188. synth_ai/cli/balance.py +4 -3
  189. synth_ai/cli/calc.py +2 -2
  190. synth_ai/cli/demo.py +14 -7
  191. synth_ai/cli/legacy_root_backup.py +1 -1
  192. synth_ai/cli/recent.py +1 -1
  193. synth_ai/cli/rl_demo.py +8 -7
  194. synth_ai/cli/root.py +0 -97
  195. synth_ai/cli/status.py +1 -1
  196. synth_ai/cli/task_apps.py +1922 -190
  197. synth_ai/cli/traces.py +1 -1
  198. synth_ai/cli/tui.py +57 -0
  199. synth_ai/cli/turso.py +1 -1
  200. synth_ai/cli/watch.py +1 -1
  201. synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +29 -17
  202. synth_ai/environments/examples/crafter_classic/environment.py +1 -1
  203. synth_ai/environments/examples/enron/engine.py +7 -2
  204. synth_ai/environments/examples/enron/environment.py +68 -0
  205. synth_ai/environments/examples/red/engine.py +27 -0
  206. synth_ai/environments/examples/red/engine_helpers/memory_map.py +7 -0
  207. synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_progression.py +477 -0
  208. synth_ai/environments/examples/red/engine_helpers/state_extraction.py +32 -0
  209. synth_ai/environments/examples/red/environment.py +60 -0
  210. synth_ai/environments/examples/sokoban/taskset.py +116 -0
  211. synth_ai/environments/examples/verilog/engine.py +104 -12
  212. synth_ai/evals/client.py +58 -61
  213. synth_ai/jobs/client.py +16 -4
  214. synth_ai/judge_schemas.py +9 -9
  215. synth_ai/py.typed +0 -0
  216. synth_ai/task/__init__.py +24 -5
  217. synth_ai/task/apps/__init__.py +1 -0
  218. synth_ai/task/config.py +257 -0
  219. synth_ai/task/contracts.py +138 -39
  220. synth_ai/task/proxy.py +48 -56
  221. synth_ai/task/rubrics/__init__.py +56 -0
  222. synth_ai/task/rubrics/loaders.py +152 -0
  223. synth_ai/task/rubrics/models.py +57 -0
  224. synth_ai/task/rubrics/scoring.py +116 -0
  225. synth_ai/{rubrics/validators.py → task/rubrics/strict.py} +53 -30
  226. synth_ai/task/server.py +8 -7
  227. synth_ai/task/trace_correlation_helpers.py +315 -0
  228. synth_ai/task/validators.py +413 -6
  229. synth_ai/tracing_v3/abstractions.py +3 -3
  230. synth_ai/tracing_v3/decorators.py +7 -3
  231. synth_ai/tracing_v3/llm_call_record_helpers.py +5 -5
  232. synth_ai/tracing_v3/replica_sync.py +4 -4
  233. synth_ai/tracing_v3/serialization.py +5 -5
  234. synth_ai/tracing_v3/session_tracer.py +16 -6
  235. synth_ai/tracing_v3/storage/base.py +29 -29
  236. synth_ai/tracing_v3/storage/config.py +3 -3
  237. synth_ai/tracing_v3/trace_utils.py +317 -0
  238. synth_ai/tracing_v3/turso/daemon.py +8 -7
  239. synth_ai/tracing_v3/turso/native_manager.py +66 -43
  240. synth_ai/tracing_v3/utils.py +3 -3
  241. synth_ai/tui/__init__.py +5 -0
  242. synth_ai/tui/__main__.py +13 -0
  243. synth_ai/tui/cli/__init__.py +1 -0
  244. synth_ai/tui/cli/query_experiments.py +164 -0
  245. synth_ai/tui/cli/query_experiments_v3.py +164 -0
  246. synth_ai/tui/dashboard.py +906 -0
  247. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/METADATA +4 -1
  248. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/RECORD +278 -126
  249. examples/agora_ex/README_MoE.md +0 -224
  250. examples/agora_ex/__init__.py +0 -7
  251. examples/agora_ex/agora_ex.py +0 -65
  252. examples/agora_ex/agora_ex_task_app.py +0 -590
  253. examples/agora_ex/configs/rl_lora_qwen3_moe_2xh200.toml +0 -121
  254. examples/agora_ex/reward_fn_grpo-human.py +0 -129
  255. examples/agora_ex/system_prompt_CURRENT.md +0 -63
  256. examples/agora_ex/task_app/agora_ex_task_app.py +0 -590
  257. examples/agora_ex/task_app/reward_fn_grpo-human.py +0 -129
  258. examples/agora_ex/task_app/system_prompt_CURRENT.md +0 -63
  259. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +0 -62
  260. synth_ai/rubrics/__init__.py +0 -22
  261. synth_ai/task/rubrics.py +0 -219
  262. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/README.md +0 -0
  263. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/README.md +0 -0
  264. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/__init__.py +0 -0
  265. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/branching.py +0 -0
  266. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/environment_routes.py +0 -0
  267. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/__init__.py +0 -0
  268. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/__init__.py +0 -0
  269. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/app.py +0 -0
  270. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/shared.py +0 -0
  271. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/envs/crafter/tools.py +0 -0
  272. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/hosted_app.py +0 -0
  273. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/inference/__init__.py +0 -0
  274. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/main.py +0 -0
  275. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/registry.py +0 -0
  276. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/__init__.py +0 -0
  277. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/storage/volume.py +0 -0
  278. /examples/{warming_up_to_rl → task_apps/crafter}/task_app/synth_envs_hosted/test_agents.py +0 -0
  279. /examples/{rl/task_app → task_apps/math}/README.md +0 -0
  280. /examples/{rl/task_app → task_apps/math}/math_task_app.py +0 -0
  281. /examples/{rl → workflows/math_rl}/configs/eval_base_qwen.toml +0 -0
  282. /examples/{rl → workflows/math_rl}/configs/eval_rl_qwen.toml +0 -0
  283. /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen.toml +0 -0
  284. /examples/{rl → workflows/math_rl}/configs/rl_from_base_qwen17.toml +0 -0
  285. /examples/{rl → workflows/math_rl}/configs/rl_from_ft_qwen.toml +0 -0
  286. /examples/{rl → workflows/math_rl}/run_eval.py +0 -0
  287. /examples/{rl → workflows/math_rl}/run_rl_and_save.py +0 -0
  288. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/WHEEL +0 -0
  289. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/entry_points.txt +0 -0
  290. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/licenses/LICENSE +0 -0
  291. {synth_ai-0.2.13.dev1.dist-info → synth_ai-0.2.14.dist-info}/top_level.txt +0 -0
@@ -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")