Skip to content

Commit f6752e1

Browse files
sp00ktoberclaude
andauthored
feat(mhfu): live views, the game automation, go-on-quest and the debug shell (#7)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d833200 commit f6752e1

42 files changed

Lines changed: 6211 additions & 11 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/mhfu/README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,33 @@ and constants; the commands below are built on it. They read an extracted game f
6565
| `mhfu stage ids\|maps\|exits\|surfaces` | Stage files, the map table, area exits |
6666
| `mhfu inject FILE_ID FILE` | Places a finished file for the framework's live injection |
6767

68+
## The running game
69+
70+
`mhfu.live` drives the game in PPSSPP through its debugger (`ppsspp-debug`): boot, walk, talk,
71+
take a quest. Every step waits on what memory says, never on a count of button presses, since
72+
the game's menus drop presses.
73+
74+
```python
75+
from mhfu.live import boot, quests
76+
from mhfu.live.session import Session
77+
78+
with Session.launch(cold=True) as s: # PPSSPP here, or in the modkit container
79+
boot.to_village(s, "english")
80+
quests.take(s, 1, "Giadrome") # rank 2, the first quest naming the Giadrome
81+
s.game.player.hp
82+
```
83+
84+
| Command | What it does |
85+
|---|---|
86+
| `mhfu go-on-quest --rank R --quest NAME` | Cold boot to standing in the quest |
87+
| `mhfu start`, `mhfu stop` | The game with its debugger open, or stopped |
88+
| `mhfu shell` | A debug shell on the running game: player, monsters, animations, hit volumes |
89+
90+
PPSSPP must open its debugger at startup (`RemoteDebuggerOnStartup = True` in `ppsspp.ini`).
91+
`MHFU_ISO` names the game image, `MHFU_PPSSPP` a PPSSPP binary other than the usual install,
92+
and `MHFU_LAUNCHER=docker` (with `MHFU_CONTAINER`) uses the container from `ppsspp/`. The
93+
automation boots the first save slot; it is tested in German and English, not yet in the other three.
94+
6895
## Licence
6996

7097
MIT.

‎packages/mhfu/src/mhfu/addresses.toml‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@
1919
[address]
2020
# --- screens and areas ---
2121
SCENE_OBJECT = { eu = 0x08A8C6E0, type = "ptr", doc = "scene-object pointer; tells sub-screens apart where SCREEN_STATE is shared (the real screen oracle)" }
22-
SCREEN_STATE = { eu = 0x08A8CA48, type = "u8", doc = "screen-state id: 1 menu or zone load, 4 title, 6 cutscene, 17 in-area; 1 and 4 are the JIT-cold window" }
22+
SCREEN_STATE = { eu = 0x08A8CA48, type = "u8", doc = "screen-state id: 1 menu or zone load, 4 title, 6 cutscene, 17 in-area, 113 village with a quest under contract; 1 and 4 are the JIT-cold window" }
2323
MAP_SUBSECTION = { eu = 0x08A8DE4C, type = "u8", doc = "sub-area byte; collides across sections and is scratch in the hall, so use AREA_INDEX" }
2424
SCREEN_STATE_MIRROR = { eu = 0x08ACCA48, type = "u8", doc = "mirror of SCREEN_STATE (+0x40000), lags it by 50-100 ms" }
2525
MAP_SUBSECTION_MIRROR = { eu = 0x08ACDE4C, type = "u8", doc = "mirror of MAP_SUBSECTION (+0x40000)" }
2626
AREA_INDEX = { eu = 0x08B0C7DC, type = "u16", doc = "visible map-section index, equal to the stage number; ENTITY.SECTION uses the same encoding" }
2727

2828
# --- menus and dialogue ---
29-
MAIN_MENU_HIGHLIGHT = { eu = 0x08A8CADE, type = "u16", doc = "main-menu highlight bar Y (68 + 32*row); draw-pool memory, valid only on the main menu" }
30-
BOX_MENU_HIGHLIGHT = { eu = 0x08A8CC76, type = "u16", doc = "house item-box menu highlight Y (32 + 16*row); draw-pool memory, re-check for any other menu" }
29+
MAIN_MENU_HIGHLIGHT = { eu = 0x08A8CADE, type = "u16", doc = "main-menu highlight bar Y in the German UI (68 + 32*row); draw memory that moves with the language (English: 100 + 32*row elsewhere)" }
30+
BOX_MENU_HIGHLIGHT = { eu = 0x08A8CC76, type = "u16", doc = "house item-box menu highlight Y in the German UI (32 + 16*row); draw memory that moves with the language and the menu" }
3131
LANGUAGE_CURSOR = { eu = 0x08B0C7EC, type = "u8", doc = "boot language-menu cursor, 0 = English, wraps" }
3232
QUEST_CARD_NAME_MIRROR = { eu = 0x0999A088, type = "bytes", doc = "copy of the browsed quest card's name text" }
3333
QUEST_BROWSE_HISTORY = { eu = 0x09A01B62, type = "bytes", doc = "ring buffer of recently browsed quest names (stride 0x88); not the accepted quest" }
@@ -68,7 +68,8 @@ COLLISION_WORLD_PTR = { eu = 0x09C18FD0, type = "ptr", doc = "pointer to the col
6868

6969
# --- entity vtables ---
7070
PLAYER_STRUCT_VTABLE = { eu = 0x089BAB08, type = "vtable", doc = "vtable of PLAYER_STRUCT and its six same-class character slots" }
71-
PLAYER_ENTITY_VTABLE = { eu = 0x089BAF3C, type = "vtable", doc = "vtable of the player combat entity PLAYER_ENTITY" }
71+
PLAYER_ENTITY_VTABLE = { eu = 0x089BAF3C, type = "vtable", doc = "vtable of the player combat entity PLAYER_ENTITY in the village" }
72+
PLAYER_QUEST_VTABLE = { eu = 0x089BB2AC, type = "vtable", doc = "vtable of PLAYER_ENTITY during a quest" }
7273
NPC_VTABLE = { eu = 0x089BB034, type = "vtable", doc = "village NPC vtable; there is no NPC registry, so scan the heap for it" }
7374
TIGREX_VTABLE = { eu = 0x089BB69C, type = "vtable", doc = "em75 (Tigrex) entity vtable; writable, slots point into the em75 overlay, valid only while loaded" }
7475
ANTEKA_VTABLE = { eu = 0x089BC074, type = "vtable", doc = "Anteka entity vtable" }
@@ -150,6 +151,51 @@ EM_FACTORY = { eu = 0x09AB15D8, type = "fn", doc = "big-monster entity factory f
150151
ATTACK_SPAWNERS = { eu = 0x09B63000, type = "code", doc = "start of game_task's attack-node constructors, one per species (TIGREX_ATTACK_SPAWN among them), (?, entity, attack id)" }
151152
ATTACK_SPAWNERS_END = { eu = 0x09B66B00, type = "code", doc = "end of the ATTACK_SPAWNERS run" }
152153

154+
# --- scenes ---
155+
# Scene objects: the values SCENE_OBJECT holds. Menus and prompts sit in a pool, so a prompt
156+
# zone's value moves between visits and the menu values move with the UI language (these are the
157+
# German UI's); free-roam, elder and quest-board values are the same in English.
158+
SCENE_ROAM = { eu = 0x08A8C518, type = "bytes", doc = "scene: free roam, village or quest; alternates with the area's own free-roam scene" }
159+
SCENE_VILLAGE = { eu = 0x08A8C940, type = "bytes", doc = "scene: village free roam" }
160+
SCENE_VILLAGE_CONTRACT = { eu = 0x08A8C9AC, type = "bytes", doc = "scene: village free roam with a quest under contract" }
161+
SCENE_QUEST = { eu = 0x08A8DA4C, type = "bytes", doc = "scene: free roam inside a quest area" }
162+
SCENE_QUEST_ALT = { eu = 0x08A8DA58, type = "bytes", doc = "scene: free roam inside a quest area, alternating with SCENE_QUEST" }
163+
SCENE_PROMPT = { eu = 0x08A8CADC, type = "bytes", doc = "scene: some square-button prompt zone (village exit, hall table, item box); still free roam" }
164+
SCENE_PROMPT_ALT = { eu = 0x08A8CA70, type = "bytes", doc = "scene: the other half of SCENE_PROMPT's alternating pair" }
165+
SCENE_LANGUAGE_MENU = { eu = 0x08A8C858, type = "bytes", doc = "scene: boot language menu; waits for input forever, SCREEN_STATE reads 0 as for the logos" }
166+
SCENE_MAIN_MENU = { eu = 0x08A8CB78, type = "bytes", doc = "scene: main menu" }
167+
SCENE_CHAR_SELECT = { eu = 0x08A8DC74, type = "bytes", doc = "scene: character-slot list" }
168+
SCENE_DIALOG = { eu = 0x08A8CBD8, type = "bytes", doc = "scene: NPC speech box" }
169+
SCENE_MESSAGE_BOX = { eu = 0x08A8CFB0, type = "bytes", doc = "scene: acknowledgement box closed with cross" }
170+
SCENE_YES_NO = { eu = 0x08A8DD14, type = "bytes", doc = "scene: yes/no confirmation" }
171+
SCENE_SAVE_CONFIRM = { eu = 0x08A8CCFC, type = "bytes", doc = "scene: save confirmation defaulting to yes; cross overwrites the save, circle aborts" }
172+
SCENE_PAUSE_MENU = { eu = 0x08A8D328, type = "bytes", doc = "scene: START menu" }
173+
SCENE_NPC_MENU = { eu = 0x08A8CAC0, type = "bytes", doc = "scene: felyne recruiter's option list; each NPC's list has its own scene" }
174+
SCENE_SHOP_MENU = { eu = 0x08A8CBC8, type = "bytes", doc = "scene: item shop's and smith's option list" }
175+
SCENE_FELYNE_MENU = { eu = 0x08A8CAF0, type = "bytes", doc = "scene: smith's felyne option list" }
176+
SCENE_SHOP_SELL = { eu = 0x08A8D700, type = "bytes", doc = "scene: item shop's sell list" }
177+
SCENE_FARM_UPGRADES = { eu = 0x08A8D7A8, type = "bytes", doc = "scene: Pokke farm upgrade list, its own widget outside NPC_MENU_STATE" }
178+
SCENE_QUEST_RANK = { eu = 0x08A8CC0C, type = "bytes", doc = "scene: quest-rank menu of the elder and the hall counters" }
179+
SCENE_QUEST_LIST = { eu = 0x08A8D640, type = "bytes", doc = "scene: quest browser, alternating with SCENE_QUEST_LIST_ALT" }
180+
SCENE_QUEST_LIST_ALT = { eu = 0x08A8D5D4, type = "bytes", doc = "scene: quest browser, alternating with SCENE_QUEST_LIST" }
181+
SCENE_TRAVEL_MENU = { eu = 0x08A8CE88, type = "bytes", doc = "scene: fast-travel destination list" }
182+
SCENE_ITEM_BOX_MENU = { eu = 0x08A8CEAC, type = "bytes", doc = "scene: item-box options in the player's house" }
183+
SCENE_ITEM_BOX_MENU_HALL = { eu = 0x08A8CD84, type = "bytes", doc = "scene: the same item-box options in the gathering hall" }
184+
185+
# --- village and hall ---
186+
# NPC objects are per-area slots: an area reuses a slot for someone else, so check the room first.
187+
CAM_VIEW_EYE = { eu = 0x09998D50, type = "vec3", doc = "camera eye in village and quest; the stick walks along atan2(CAM_LOOK_AT - eye) in x/z" }
188+
SCENE_SUPPLY_BOX = { eu = 0x08A8E2B4, type = "bytes", doc = "scene: base-camp supply box item grid" }
189+
NPC_HEAP = { eu = 0x09000000, type = "bytes", doc = "start of the 2 MB span holding every NPC object (struct ENTITY); scan it for NPC_VTABLE" }
190+
NPC_ELDER = { eu = 0x090BDBA0, type = "bytes", doc = "NPC slot: the village elder at her fire; the hall's receptionist in the hall" }
191+
NPC_HALL_COUNTER_LOW = { eu = 0x090C1470, type = "bytes", doc = "NPC slot: the hall's low-rank quest counter" }
192+
NPC_HALL_COUNTER_HIGH = { eu = 0x090C4D40, type = "bytes", doc = "NPC slot: the hall's high-rank quest counter" }
193+
NPC_HALL_COUNTER_G = { eu = 0x090C8610, type = "bytes", doc = "NPC slot: the hall's G-rank quest counter" }
194+
NPC_HALL_TRESHI = { eu = 0x090CBEE0, type = "bytes", doc = "NPC slot: Treshi, the hall's treasure-hunt quests" }
195+
NPC_HALL_SHOP = { eu = 0x090CFC90, type = "bytes", doc = "NPC slot: the hall's item shop" }
196+
NPC_HALL_HUNTER = { eu = 0x090D3560, type = "bytes", doc = "NPC slot: the hunter by the hall's fire pit" }
197+
NPC_HALL_FELYNE = { eu = 0x090D6E30, type = "bytes", doc = "NPC slot: the felyne waiting in the hall" }
198+
153199
# --- framework regions ---
154200
AI_PUBLISH = { eu = 0x08AE2000, type = "bytes", doc = "framework-owned scratch the ai_publish mod writes and the HUD reads: 0x18 header + 21 x 0x14" }
155201
CLI_BRIDGE_BLOCK = { eu = 0x0A7F0000, type = "bytes", doc = "framework-owned command block (struct CLI_BRIDGE) the debug CLI writes; needs memory=64" }
@@ -162,11 +208,13 @@ DRAW_NODE = { offset = 0x008, type = "ptr", doc = "engine output: shared draw no
162208
ROTATION = { offset = 0x010, type = "f32[12]", doc = "3x4 orientation rows, rebuilt every frame from YAW: read only" }
163209
SIZE_SCALE = { offset = 0x024, type = "f32", doc = "size multiplier; re-derived every frame, so resize through RENDER_SCALE and SIZE_RADIUS" }
164210
TRANSLATION = { offset = 0x040, type = "vec3", doc = "transform translation row (w = 1.0 at +0x4C), copied from POSITION each frame" }
211+
TRANSLATION_W = { offset = 0x04C, type = "f32", doc = "w of the translation row, 1.0 on a live transform" }
165212
FRAME_COUNTER = { offset = 0x092, type = "u8", doc = "per-entity frame counter; stops while the AI tick is frozen" }
166213
CLIP = { offset = 0x0B8, type = "ptr", doc = "slot-0 clip node of the playing animation (clip blocks at +0x80, 0x40 per slot)" }
167214
CLIP_FLAGS = { offset = 0x0BC, type = "u16", doc = "slot-0 clip flags: bit 0 playing (cleared when the clip ends), bit 1 loop" }
168215
JOINTS = { offset = 0x190, type = "ptr", doc = "live joint array the engine poses from (struct JOINT, stride 0x250)" }
169216
SLOT_COUNT = { offset = 0x1A2, type = "u16", doc = "AI slot count on the small-monster path; big monsters ignore it" }
217+
ACTION_TABLE = { offset = 0x1AC, type = "ptr", doc = "per-species table the vt[8] resolver maps an animation input through: probabilities for small monsters, clip pointers for big ones" }
170218
SLOT_INPUTS = { offset = 0x1B8, type = "u16[3]", doc = "per-slot input cursors fed to the vt[8] resolver; zeroed by act_set" }
171219
NEXT_OBJ = { offset = 0x1C4, type = "ptr", doc = "next entity on the engine's update list; splice clones in here" }
172220
PREV_OBJ = { offset = 0x1C8, type = "ptr", doc = "previous entity on the engine's update list (not a pursue target)" }
@@ -188,6 +236,7 @@ TALKABLE = { offset = 0x2FE, type = "u8", doc = "village NPCs: 1 while this NPC
188236
STIMULUS_TAG = { offset = 0x322, type = "u16", doc = "stimulus type written by the damage handler; picks alert or flee responses" }
189237
ANIM_INPUT = { offset = 0x324, type = "u16[3]", doc = "per-body-part animation input = executor action id + 0x3E8 + slot*0xC8" }
190238
ANIM_MODE = { offset = 0x32C, type = "u16[3]", doc = "per-body-part animation mode" }
239+
ANIM_SPEED = { offset = 0x334, type = "u16[3]", doc = "per-body-part playback speed each action installs with its clip; it tracks the action but is not an AI state" }
191240
COMBAT_ENGAGED = { offset = 0x33D, type = "u8", doc = "set to 1 by the combat registrar together with COMBAT_NODE" }
192241
COMBATANT_IDS = { offset = 0x33E, type = "u16[16]", doc = "player only: combatant ids registered against the player" }
193242
COMBATANT_COUNT = { offset = 0x398, type = "u8", doc = "player only: number of ids in COMBATANT_IDS (at most 16)" }
@@ -207,6 +256,7 @@ AI_TIMER_A = { offset = 0x624, type = "u16", doc = "AI countdown timer read by t
207256
AI_TIMER_B = { offset = 0x626, type = "u16", doc = "second AI countdown timer read by the stress-response ladder" }
208257
STRESS = { offset = 0x62E, type = "u16", doc = "stress counter clamped to 75..450" }
209258
FLAGS = { offset = 0x638, type = "u32", doc = "flags: 0x8000 visibility gate B, 0x20 enraged, bit 0 swaps in ACTION_LIST_REMAP" }
259+
ACTION_LIST = { offset = 0x640, type = "ptr", doc = "action-descriptor list the dispatcher walks; a big monster's is its overlay's table of 8-byte rows, byte 0 the action id" }
210260
FLEE = { offset = 0x6D8, type = "u8", doc = "small monsters: 1 while fleeing; engine output, read only" }
211261
HERD_RALLY = { offset = 0x1214, type = "vec3", doc = "small monsters: herd rally position set when the player attacks the herd" }
212262
HERD_MEMBERS = { offset = 0x1220, type = "ptr[20]", doc = "small monsters: other entities in the same herd" }

‎packages/mhfu/src/mhfu/cli/live.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""`mhfu start` and `mhfu stop`: the game in PPSSPP with its debugger open."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import dataclasses
7+
import sys
8+
from typing import TYPE_CHECKING
9+
10+
from ..live.session import Launcher, Session
11+
12+
if TYPE_CHECKING:
13+
from . import Subparsers
14+
15+
16+
def register(sub: Subparsers) -> None:
17+
p = sub.add_parser("start", help="start the game with its debugger open and print the port")
18+
launcher_args(p)
19+
p.add_argument(
20+
"--state",
21+
help="savestate to load at launch, as the emulator sees it; skips the cold boot, "
22+
"so plugins do not load",
23+
)
24+
p.add_argument("--cold", action="store_true", help="restart an emulator already running")
25+
p.add_argument("--timeout", type=float, default=60.0, help="seconds to wait for the game")
26+
p.set_defaults(run=start)
27+
28+
p = sub.add_parser("stop", help="stop the emulator")
29+
launcher_args(p, paths=False)
30+
p.set_defaults(run=stop)
31+
32+
33+
def launcher_args(p: argparse.ArgumentParser, paths: bool = True) -> None:
34+
"""Where the emulator runs: the options `launcher` reads back."""
35+
where = p.add_mutually_exclusive_group()
36+
where.add_argument(
37+
"--docker",
38+
dest="docker",
39+
action="store_true",
40+
default=None,
41+
help="PPSSPP in the modkit container (MHFU_LAUNCHER=docker)",
42+
)
43+
where.add_argument(
44+
"--local", dest="docker", action="store_false", help="PPSSPP on this machine (default)"
45+
)
46+
p.add_argument("--container", help="container name (MHFU_CONTAINER, default ppsspp)")
47+
if paths:
48+
p.add_argument("--ppsspp", help="local PPSSPP binary (MHFU_PPSSPP)")
49+
p.add_argument("--iso", help="game image as the emulator sees it (MHFU_ISO)")
50+
51+
52+
def launcher(args: argparse.Namespace) -> Launcher:
53+
given = {
54+
"docker": args.docker,
55+
"container": args.container,
56+
"ppsspp": getattr(args, "ppsspp", None),
57+
"iso": getattr(args, "iso", None),
58+
}
59+
return dataclasses.replace(
60+
Launcher.from_env(), **{k: v for k, v in given.items() if v is not None}
61+
)
62+
63+
64+
def start(args: argparse.Namespace) -> int:
65+
try:
66+
session = Session.launch(
67+
launcher(args),
68+
state=args.state,
69+
cold=args.cold,
70+
timeout=args.timeout,
71+
stop_on_exit=False,
72+
)
73+
except (ConnectionError, RuntimeError, TimeoutError) as e:
74+
print(f"mhfu start: {e}", file=sys.stderr)
75+
return 1
76+
with session:
77+
print(session.port)
78+
return 0
79+
80+
81+
def stop(args: argparse.Namespace) -> int:
82+
print(f"stopped {launcher(args).stop()} emulator process(es)")
83+
return 0

0 commit comments

Comments
 (0)