Skip to content

Commit 9fc1825

Browse files
authored
Merge pull request #46 from MHFU-ModKit/hud/69
feat(hud): the live HUD on the shared packages
2 parents 382dcfb + 2148c47 commit 9fc1825

40 files changed

Lines changed: 4875 additions & 31 deletions

‎apps/hud/README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# mhfu-hud
2+
3+
A live HUD for MHFU next to PPSSPP: vitals, the quest map with the monsters on it, each
4+
monster's AI cells and its species row, drawn in a pygame window. It needs a running PPSSPP
5+
with the remote debugger on (Settings > Tools > Developer Tools > Allow remote debugger).
6+
7+
```bash
8+
uv run hud # finds the PPSSPP on this machine and waits for it
9+
uv run hud --read-only # never writes game memory
10+
uv run hud --shot out/ # one PNG per tab, no window, then exit
11+
```
12+
13+
`--host`, `--port` (default: read from the PPSSPP process), `--poll-hz` (default 3),
14+
`--fullscreen`, `--assets DIR`. Keep the poll rate low: PPSSPP stops the game for every
15+
read, and a high rate stalls the emulator.
16+
17+
## What writes
18+
19+
Only the QUEST_PREP tab writes game memory: the size, species and HP edits staged there are
20+
written to each matching monster once it spawns, by one writer thread. LIVE and AI_MOD only
21+
read. A badge in the status row shows while edits are staged and on; `--read-only` starts no
22+
writer at all.
23+
24+
## Artwork
25+
26+
None is included. The HUD reads a folder (`--assets DIR`, else `$MHFU_HUD_ASSETS`, else
27+
`${XDG_DATA_HOME:-~/.local/share}/mhfu-hud/assets`) and draws placeholders for anything missing:
28+
29+
```
30+
monsters/<slug>.png monster icons, slug from the name ("tigrex", "giadrome")
31+
items/<slug>.png item icons ("paintball")
32+
maps/<slug>.png map images ("snowy_mountains")
33+
backgrounds/village.png the village background (.jpg works too)
34+
```
35+
36+
A `manifest.json` in `monsters/`, `items/` or `maps/` maps a slug to another file name. Map
37+
calibration you save lands in `${XDG_CONFIG_HOME:-~/.config}/mhfu-hud/calibration.json`.

‎apps/hud/pyproject.toml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[project]
2+
name = "mhfu-hud"
3+
version = "0.1.0"
4+
description = "A live HUD next to PPSSPP: vitals, the quest map, the monsters and their AI cells."
5+
readme = "README.md"
6+
requires-python = ">=3.11"
7+
license = "MIT"
8+
dependencies = ["ppsspp-debug", "mhfu", "pygame-ce>=2.5"]
9+
classifiers = ["Typing :: Typed"]
10+
11+
[project.scripts]
12+
hud = "mhfu_hud.cli:main"
13+
14+
[build-system]
15+
requires = ["uv_build>=0.11,<0.12"]
16+
build-backend = "uv_build"

‎apps/hud/src/mhfu_hud/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""A live MHFU HUD next to PPSSPP: it attaches over the debugger, polls game memory on a thread
2+
and draws it with pygame. QUEST_PREP's staged edits write game memory; the rest only reads."""
3+
4+
__version__ = "0.1.0"

‎apps/hud/src/mhfu_hud/__main__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
3+
from .cli import main
4+
5+
sys.exit(main())

‎apps/hud/src/mhfu_hud/app.py‎

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
"""The pygame window: main loop, tab and layout switching, letterboxed scaling.
2+
3+
Layouts draw on a fixed body (CANVAS_W x CANVAS_H) under a status row; the window scales both to
4+
fit and never shrinks below them.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from pathlib import Path
10+
11+
import pygame
12+
13+
from . import panels
14+
from . import widgets as W
15+
from .assets import AssetLibrary
16+
from .calibration import Calibration
17+
from .layouts import AIModLayout, QuestLayout, QuestPrepLayout, VillageLayout
18+
from .layouts.base import Layout, Reader, Writer
19+
from .reader import QUEST_MAP
20+
from .state import Context, GameSnapshot
21+
from .theme import CANVAS_H, CANVAS_W, STATUS_H, C, font
22+
23+
WINDOW_TITLE = "MHFU Live HUD"
24+
GAMEPLAY = {Context.VILLAGE, Context.QUEST}
25+
26+
# LIVE follows the game context, QUEST_PREP stages spawn edits, AI_MOD inspects the AI cells
27+
TAB_LIVE = "live"
28+
TAB_QUEST_PREP = "quest_prep"
29+
TAB_AI_MOD = "ai_mod"
30+
TABS = (TAB_LIVE, TAB_QUEST_PREP, TAB_AI_MOD)
31+
32+
_HELP = [
33+
("F1", "toggle this help"),
34+
("F2 / F3 / F4", "layout: auto / force village / force quest"),
35+
("F11", "toggle fullscreen"),
36+
("ESC", "close overlay, or quit"),
37+
("TAB", "cycle tabs LIVE -> QUEST-PREP -> AI-MOD"),
38+
("", ""),
39+
("B", "toggle bag panel (quest)"),
40+
("] / [", "select next / previous monster (quest)"),
41+
("ENTER", "open / close monster detail page"),
42+
("TAB", "in monster detail: cycle STATS -> AI"),
43+
("M", "cycle map image (quest)"),
44+
("", ""),
45+
("arrows", "navigate quest-prep panels + rows"),
46+
("+ / -", "adjust selected staged edit"),
47+
("T", "staged edit kind: size / species / HP"),
48+
("ENTER", "stage / toggle edit"),
49+
("X / DEL", "remove staged edit"),
50+
("M", "master switch for writing the edits"),
51+
("", ""),
52+
("] / [", "AI-MOD: next / previous monster"),
53+
("up / down", "AI-MOD: scroll the cells"),
54+
("", ""),
55+
("C", "toggle calibration mode"),
56+
("+ / -", "calib: map scale"),
57+
(", / .", "calib: map rotation"),
58+
("T", "calib: player-centered / fixed mode"),
59+
("arrows", "calib: nudge map origin or village marker"),
60+
("S", "calib: save calibration.json"),
61+
]
62+
63+
64+
class HUDApp:
65+
def __init__(
66+
self,
67+
reader: Reader,
68+
calib: Calibration | None = None,
69+
*,
70+
writer: Writer | None = None,
71+
assets: Path | None = None,
72+
fullscreen: bool = False,
73+
) -> None:
74+
"""`writer` None is `--read-only`: QUEST_PREP's edits are off."""
75+
self.reader = reader
76+
self.writer = writer
77+
# not pygame.init(): its joystick subsystem races PPSSPP for the gamepad on macOS
78+
pygame.display.init()
79+
pygame.font.init()
80+
pygame.display.set_caption(WINDOW_TITLE)
81+
self._windowed_size = (CANVAS_W, STATUS_H + CANVAS_H)
82+
self.fullscreen = fullscreen
83+
self.screen = self._make_window(self._windowed_size, fullscreen)
84+
self.canvas = pygame.Surface((CANVAS_W, STATUS_H + CANVAS_H)).convert()
85+
self.body = self.canvas.subsurface((0, STATUS_H, CANVAS_W, CANVAS_H))
86+
"""What the layouts draw on."""
87+
self.clock = pygame.time.Clock()
88+
self.assets = AssetLibrary(assets)
89+
self.calib = calib or Calibration()
90+
self.layouts: dict[Context, Layout] = {
91+
Context.VILLAGE: VillageLayout(self.assets, self.calib),
92+
Context.QUEST: QuestLayout(self.assets, self.calib, reader=reader),
93+
}
94+
self.quest_prep = QuestPrepLayout(self.assets, self.calib, writer=writer)
95+
self.ai_mod = AIModLayout(self.assets, self.calib)
96+
self.forced: Context | None = None
97+
self.tab = TAB_LIVE
98+
self._tab_user_pinned = False
99+
self._last_quest_prep_eligible = False
100+
self.show_help = False
101+
self.running = True
102+
self._blit_off = (0, 0)
103+
self._blit_scale = 1.0
104+
105+
# --- window ---
106+
107+
def _make_window(self, size: tuple[int, int], fullscreen: bool) -> pygame.Surface:
108+
if fullscreen:
109+
return pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
110+
w, h = max(CANVAS_W, size[0]), max(STATUS_H + CANVAS_H, size[1])
111+
return pygame.display.set_mode((w, h), pygame.RESIZABLE)
112+
113+
def _toggle_fullscreen(self) -> None:
114+
self.fullscreen = not self.fullscreen
115+
self.screen = self._make_window(self._windowed_size, self.fullscreen)
116+
117+
# --- loop ---
118+
119+
def run(self) -> None:
120+
try:
121+
while self.running:
122+
snap = self.reader.snapshot
123+
self._absorb(snap)
124+
self._handle_events(snap)
125+
self._render(snap)
126+
self.clock.tick(60)
127+
finally:
128+
pygame.quit()
129+
130+
def shoot(self, out: Path) -> list[Path]:
131+
"""Render every tab once to `out/<tab>.png`."""
132+
out.mkdir(parents=True, exist_ok=True)
133+
snap = self.reader.snapshot
134+
paths = []
135+
for tab in TABS:
136+
self.tab = tab
137+
self._render(snap)
138+
path = out / f"{tab}.png"
139+
pygame.image.save(self.canvas, str(path))
140+
paths.append(path)
141+
return paths
142+
143+
def _absorb(self, snap: GameSnapshot) -> None:
144+
"""Keep the area_index pairs the reader learnt in the user's calibration."""
145+
if snap.learnt_sections and self.calib.learn(QUEST_MAP, snap.learnt_sections):
146+
self.calib.save()
147+
148+
def _context(self, snap: GameSnapshot) -> Context:
149+
return self.forced if self.forced is not None else snap.context
150+
151+
def _handle_events(self, snap: GameSnapshot) -> None:
152+
self._maybe_auto_switch_tab(snap)
153+
layout = self._active_layout(snap)
154+
for ev in pygame.event.get():
155+
if ev.type == pygame.QUIT:
156+
self.running = False
157+
elif ev.type == pygame.VIDEORESIZE and not self.fullscreen:
158+
self._windowed_size = (ev.w, ev.h)
159+
self.screen = self._make_window(self._windowed_size, False)
160+
elif ev.type == pygame.MOUSEBUTTONDOWN and ev.button == 1:
161+
if layout is not None:
162+
layout.handle_click(self._to_canvas(ev.pos), snap)
163+
elif ev.type == pygame.MOUSEMOTION:
164+
if layout is not None:
165+
layout.handle_motion(self._to_canvas(ev.pos), snap)
166+
elif ev.type == pygame.KEYDOWN:
167+
self._handle_key(ev.key, snap, layout)
168+
169+
def _quest_prep_eligible(self, snap: GameSnapshot) -> bool:
170+
"""Outside a quest with an area index set: the engine prefetches the next quest's."""
171+
return snap.context != Context.QUEST and snap.area_index not in (0, -1)
172+
173+
def _maybe_auto_switch_tab(self, snap: GameSnapshot) -> None:
174+
"""Flip to QUEST_PREP when a quest is queued and back when it lapses, unless the user
175+
picked a tab; entering the quest clears that pick."""
176+
eligible = self._quest_prep_eligible(snap)
177+
if snap.context == Context.QUEST:
178+
self._tab_user_pinned = False
179+
if not self._tab_user_pinned:
180+
if eligible and not self._last_quest_prep_eligible:
181+
self.tab = TAB_QUEST_PREP
182+
elif not eligible and self._last_quest_prep_eligible:
183+
self.tab = TAB_LIVE
184+
self._last_quest_prep_eligible = eligible
185+
186+
def _active_layout(self, snap: GameSnapshot) -> Layout | None:
187+
"""The layout that takes keys and clicks: the tab's, or LIVE's for the context."""
188+
if self.tab == TAB_AI_MOD:
189+
return self.ai_mod
190+
if self.tab == TAB_QUEST_PREP:
191+
return self.quest_prep
192+
return self.layouts.get(self._context(snap))
193+
194+
def _handle_key(self, key: int, snap: GameSnapshot, layout: Layout | None) -> None:
195+
if key == pygame.K_F1:
196+
self.show_help = not self.show_help
197+
elif key == pygame.K_F2:
198+
self.forced = None
199+
elif key == pygame.K_F3:
200+
self.forced = Context.VILLAGE
201+
elif key == pygame.K_F4:
202+
self.forced = Context.QUEST
203+
elif key == pygame.K_F11:
204+
self._toggle_fullscreen()
205+
elif self.show_help and key == pygame.K_ESCAPE:
206+
self.show_help = False
207+
# the layout sees TAB first: the quest detail page cycles its sub-pages with it
208+
elif layout is not None and layout.handle_key(key, snap):
209+
return
210+
elif key == pygame.K_TAB:
211+
i = TABS.index(self.tab) if self.tab in TABS else 0
212+
self.tab = TABS[(i + 1) % len(TABS)]
213+
self._tab_user_pinned = True
214+
elif key == pygame.K_ESCAPE:
215+
self.running = False
216+
217+
# --- render ---
218+
219+
def _render(self, snap: GameSnapshot) -> None:
220+
ctx = self._context(snap)
221+
if self.tab == TAB_AI_MOD:
222+
self.ai_mod.render(self.body, snap)
223+
elif self.tab == TAB_QUEST_PREP:
224+
self.quest_prep.render(self.body, snap)
225+
else:
226+
layout = self.layouts.get(ctx)
227+
if layout is not None and (
228+
self.forced is not None or (snap.connected and ctx in GAMEPLAY)
229+
):
230+
layout.render(self.body, snap)
231+
else:
232+
panels.draw_status_screen(self.body, snap)
233+
self._status_row(snap, ctx)
234+
if self.show_help:
235+
self._draw_help()
236+
self._present()
237+
238+
def _status_row(self, snap: GameSnapshot, ctx: Context) -> None:
239+
"""Connection, context, tab and rates; a badge while edits would be written."""
240+
self.canvas.fill(C.BG, (0, 0, CANVAS_W, STATUS_H))
241+
mid = STATUS_H // 2
242+
pygame.draw.circle(self.canvas, C.OK if snap.connected else C.ERR, (14, mid), 5)
243+
tab = {TAB_AI_MOD: "[AI-MOD]", TAB_QUEST_PREP: "[PREP]"}.get(self.tab, "[LIVE]")
244+
parts = [ctx.value.upper(), tab]
245+
if self.forced is not None:
246+
parts.append("[FORCED]")
247+
parts += [f"{self.clock.get_fps():.0f}fps", f"poll {snap.poll_latency_ms:.0f}ms"]
248+
W.text(self.canvas, " · ".join(parts), (26, mid - 8), size=12, color=C.TEXT_DIM)
249+
help_at = (CANVAS_W - 10, mid - 8)
250+
right = W.text(self.canvas, "F1 help", help_at, 12, C.TEXT_DIM, align="right")
251+
status = self.writer.status if self.writer else None
252+
if status is not None and status.active:
253+
n = sum(1 for st in status.staged if st.edit.enabled)
254+
label = f"WRITES ON · {n} edit{'s' * (n != 1)}"
255+
w, h = font(12, True).size(label)
256+
badge = pygame.Rect(0, 1, w + 12, STATUS_H - 2)
257+
badge.right = right.x - 14
258+
pygame.draw.rect(self.canvas, C.WARN, badge, border_radius=4)
259+
at = (badge.centerx, badge.y + (badge.h - h) // 2)
260+
W.text(self.canvas, label, at, 12, C.BG, bold=True, align="center", shadow=False)
261+
262+
def _draw_help(self) -> None:
263+
veil = pygame.Surface((CANVAS_W, CANVAS_H), pygame.SRCALPHA)
264+
veil.fill((0, 0, 0, 200))
265+
self.body.blit(veil, (0, 0))
266+
box = pygame.Rect(CANVAS_W // 2 - 300, 40, 600, 464)
267+
W.panel(self.body, box, fill=C.PANEL_HI, border=C.ACCENT)
268+
W.text(
269+
self.body,
270+
"MHFU LIVE HUD — CONTROLS",
271+
(box.x + 20, box.y + 16),
272+
size=18,
273+
color=C.ACCENT,
274+
bold=True,
275+
)
276+
y = box.y + 48
277+
for keys, desc in _HELP:
278+
if keys:
279+
W.text(self.body, keys, (box.x + 24, y), size=13, color=C.SELECT, bold=True)
280+
W.text(self.body, desc, (box.x + 180, y), size=13, color=C.TEXT)
281+
y += 14 if keys else 6
282+
W.text(
283+
self.body,
284+
"only QUEST-PREP's staged edits write game memory (none with --read-only)",
285+
(box.x + 20, box.bottom - 24),
286+
size=11,
287+
color=C.TEXT_FAINT,
288+
)
289+
290+
def _present(self) -> None:
291+
ww, wh = self.screen.get_size()
292+
cw, ch = self.canvas.get_size()
293+
self._blit_scale = min(ww / cw, wh / ch)
294+
sw, sh = int(cw * self._blit_scale), int(ch * self._blit_scale)
295+
self._blit_off = ((ww - sw) // 2, (wh - sh) // 2)
296+
self.screen.fill((0, 0, 0))
297+
self.screen.blit(pygame.transform.smoothscale(self.canvas, (sw, sh)), self._blit_off)
298+
pygame.display.flip()
299+
300+
def _to_canvas(self, pos: tuple[int, int]) -> tuple[float, float]:
301+
"""A window position in the layouts' coordinates."""
302+
ox, oy = self._blit_off
303+
s = self._blit_scale or 1.0
304+
return (pos[0] - ox) / s, (pos[1] - oy) / s - STATUS_H

0 commit comments

Comments
 (0)