Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 186 additions & 1 deletion apps/studio/src/mhfu_studio/map/commands.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,196 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2026 sp00ktober
"""The `studio` commands of this area."""
"""`studio map inject` (a map document into the running game) and `studio render map`."""

from __future__ import annotations

import argparse
from pathlib import Path
from typing import TYPE_CHECKING

from mhfu_studio.cli import Groups
from mhfu_studio.harness import flags

if TYPE_CHECKING:
from mhfu.files import Extracted

from .core.scene import MapScene

VIEWS = ("iso", "top", "front", "back", "side", "low", "hunter")
HALVES = ("mesh", "collision", "textures")


def register(groups: Groups) -> None:
"""Adds this area's commands; nothing heavy may be imported at module level."""
c = groups.map.add_parser(
"inject", help="push a map document's edits into the running game (PPSSPP's debugger)"
)
c.add_argument("document", type=Path, help="the document's folder, or its map.toml")
c.add_argument("--stage", type=int, help="one stage of the document (default: every one)")
c.add_argument("--data", type=Path, help="extracted game (default: $MHFU_DATA)")
c.add_argument("--port", type=int, help="debugger port (default: PPSSPP's own)")
for half in HALVES:
c.add_argument(f"--{half}", action="store_true", help=f"push the {half} (default: all)")
c.add_argument("--restore", action="store_true", help="put the file's bytes back instead")
c.add_argument(
"--catch",
type=float,
help="seconds to watch for the area reload and re-apply the mesh on it (default: none"
" in the village, 120 in a quest area); the game runs slower meanwhile",
)
c.add_argument(
"--hold", type=float, default=0.0, help="seconds to re-apply what an area load undoes"
)
c.add_argument("--scratch", type=lambda s: int(s, 0), help="free RAM for added collision")
c.add_argument("--dry", action="store_true", help="plan it offline; touch no emulator")
c.set_defaults(run=inject)

r = groups.render.add_parser(
"map", help="draw a stage, or a map row one tile per section, from fixed cameras"
)
where = r.add_mutually_exclusive_group(required=True)
where.add_argument("--stage", type=int, help="the stage (section) to draw")
where.add_argument("--row", type=int, help="every section of a map row, one tile each")
r.add_argument(
"--view", action="append", choices=VIEWS, help="repeatable (default: iso); --row takes one"
)
r.add_argument("--doc", type=Path, help="a map document whose edits the stage shows")
r.add_argument("--data", type=Path, help="extracted game (default: $MHFU_DATA)")
r.add_argument("--contact", type=Path, metavar="PNG", help="the tiles as one contact sheet")
r.add_argument("--collision", action="store_true", help="draw the collision by class")
r.add_argument("--lattice", action="store_true", help="draw the broadphase lattice")
r.add_argument("--no-backdrop", action="store_true", help="hide the sky and far terrain")
r.add_argument(
"--mode",
type=int,
default=0,
choices=range(5),
help="0 textured, 1 vertex colour, 2 texture only, 3 by group, 4 flat",
)
flags.add_flags(r)
r.set_defaults(run=render)


def _game(args: argparse.Namespace) -> Extracted:
from mhfu.files import Extracted

return Extracted.find(args.data)


def inject(args: argparse.Namespace) -> int:
from mhfu import addresses as a
from mhfu.memory import Live
from ppsspp_debug import Client

from mhfu_studio.stage import live
from mhfu_studio.stage import ops as O
from mhfu_studio.stage.file import StageFile

from .document import MapDocument

doc = MapDocument.load(args.document)
base = doc.directory or Path(".")
entries = doc.stages
if args.stage is not None:
entry = doc.stage(args.stage)
if entry is None:
raise ValueError(f"the document has no stage st{args.stage:03d}")
entries = [entry]
halves = {h: getattr(args, h) for h in HALVES}
if not any(halves.values()):
halves = dict.fromkeys(halves, True)
game = _game(args)
pushes = []
for s in entries:
print(f"{s.label}: {len(s.ops)} op(s) in {s.ops_file}")
found = O.check(s.ops, base_dir=base, where=s.label)
for f in found:
print(f)
if any(f.level == "error" for f in found):
raise ValueError(f"{s.ops_file} has errors; nothing pushed")
sf = StageFile.read(game, s.number)
p = live.prepare(sf, s.ops, base, scratch=args.scratch or a.STAGE_SCRATCH, **halves)
for line in [*p.log, *map(str, p.findings), *live.describe(p)]:
print(line)
catch = args.catch
if catch is None and not args.restore:
catch = live.default_catch(p, print)
pushes.append((p, catch or 0.0))
if args.dry:
return 0
with Client.connect(port=args.port) as client:
mem = Live(client)
for p, catch in pushes:
label = p.stage.label
if len(pushes) > 1 and live.pac_address(mem, p.stage.number) is None:
print(f"{label}: not resident, skipped (stand in the area and push it alone)")
elif args.restore:
live.restore(mem, p.stage)
else:
undo = base / ".inject" / f"{label}_collision_undo.json"
live.run(client, mem, p, catch_for=catch, hold_for=args.hold, undo_file=undo)
return 0


def render(args: argparse.Namespace) -> int:
from .core.atlas import Atlas
from .core.scene import open_stage
from .render import headless

if args.contact is None or args.out is not None or args.golden is not None:
flags.check_args(args)
game = _game(args)
atlas = Atlas(game)
views = args.view or ["iso"]
layers = headless.Layers(args.collision, args.lattice, not args.no_backdrop, args.mode)
if args.stage is not None:
scene = _edited(open_stage(game, args.stage), args.doc)
shots = headless.views(
scene,
views,
arrivals=atlas.arrivals(args.stage),
layers=layers,
size=args.size,
samples=args.samples,
label=lambda v: f"st{scene.stage:03d}_{v}",
)
else:
if len(views) > 1:
raise ValueError("a row draws one view: give --view once")
row = atlas.row(args.row)
scenes = [
(_edited(open_stage(game, s.stage), args.doc), atlas.arrivals(s.stage))
for s in row.sections
if s.present
]
if not scenes:
raise ValueError(f"row {args.row} has no section")
shots = headless.tiles(
scenes, views[0], layers=layers, size=args.size, samples=args.samples
)
if args.contact is not None:
from mhfu_studio.harness.render import contact_sheet
from mhfu_studio.shell.target import write_png

images = shots.images
sheet = contact_sheet(list(images.values()), list(images))
print(f"wrote {write_png(flags.check_out(args.contact), sheet)}")
if args.out is None and args.golden is None:
return 0
return flags.finish(args, shots)


def _edited(scene: MapScene, doc_dir: Path | None) -> MapScene:
"""The scene with the document's list for its stage replayed, when there is one."""
if doc_dir is None:
return scene
from .core.edit import EditSession
from .document import MapDocument

doc = MapDocument.load(doc_dir)
entry = doc.stage(scene.stage)
if entry is not None and entry.ops:
session = EditSession(scene, entry.ops, doc.directory)
for f in session.findings:
print(f)
return scene
3 changes: 3 additions & 0 deletions apps/studio/src/mhfu_studio/map/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2026 sp00ktober
"""The stage as a scene, the map table and the edit model: numpy, no GL."""
196 changes: 196 additions & 0 deletions apps/studio/src/mhfu_studio/map/core/atlas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2026 sp00ktober
"""The map table as rows of sections, entry area first; the village is row 0.

Names come only from the game: three rows and the snowy bank are labelled, everything else
is `row N` / `stNNN`. Day/night twins share their collision bytes, paired by hash, not by an
offset that holds for one map only.
"""

from __future__ import annotations

import hashlib
from collections.abc import Sequence
from dataclasses import dataclass, field

from mhfu import files
from mhfu import stage as S
from mhfu.files import Extracted

from mhfu_studio.stage.file import COLLISION, StageFile

PLACEHOLDER = 2048
"""A stub stage is this many bytes or fewer."""

ROW_NAMES: dict[int, str] = {
0: "Pokke village & town",
11: "Snowy Mountains (day)",
21: "Snowy Mountains (night)",
}

STAGE_NAMES: dict[int, str] = {
139: "Pokke village",
66: "Pokke village (twin of st139)",
98: "Snowy base camp",
99: "Snowy area 1",
95: "Snowy area 2",
94: "Snowy area 3",
92: "Snowy area 4",
93: "Snowy area 5",
100: "Snowy area 6",
96: "Snowy area 7",
97: "Snowy area 8",
6: "Snowy secret area",
107: "Snowy base camp (night)",
108: "Snowy area 1 (night)",
104: "Snowy area 2 (night)",
103: "Snowy area 3 (night)",
101: "Snowy area 4 (night)",
102: "Snowy area 5 (night)",
109: "Snowy area 6 (night)",
105: "Snowy area 7 (night)",
106: "Snowy area 8 (night)",
15: "Snowy secret area (night)",
}

ORPHAN_STAGES = (126, 198, 199, 200)
"""Complete maps no row references: a cut section and three unconnected arenas."""


def stage_name(stage: int) -> str:
return STAGE_NAMES.get(stage, f"st{stage:03d}")


def row_name(row: int) -> str:
return ROW_NAMES.get(row, f"row {row}")


@dataclass
class Section:
"""A stage in a row; `slot` is its index there (0 the entry area)."""

stage: int
slot: int
row: int
name: str
is_entry: bool
present: bool = True
"""The file is a real map, not a placeholder."""
size: int = 0


@dataclass
class MapRow:
index: int
name: str
sections: list[Section] = field(default_factory=list)

@property
def entry(self) -> Section | None:
return self.sections[0] if self.sections else None

@property
def stages(self) -> list[int]:
return [s.stage for s in self.sections]

def slot_of(self, stage: int) -> int | None:
return next((s.slot for s in self.sections if s.stage == stage), None)


class Atlas:
"""The rows of `game_sub.ovl` (or `table`), read once; overlays are read on demand."""

def __init__(self, game: Extracted, table: Sequence[Sequence[int]] | None = None) -> None:
self.game = game
self.rows: list[MapRow] = []
self._by_stage: dict[int, list[tuple[int, int]]] = {}
self._overlays: dict[int, S.StageOverlay | None] = {}
for i, stages in enumerate(S.read_map_table(game) if table is None else table):
row = MapRow(i, row_name(i))
for slot, n in enumerate(stages):
size = self.size(n)
row.sections.append(
Section(n, slot, i, stage_name(n), slot == 0, size > PLACEHOLDER, size)
)
self._by_stage.setdefault(n, []).append((i, slot))
self.rows.append(row)

def size(self, stage: int) -> int:
try:
return self.game.path(files.stage_pac(stage)).stat().st_size
except (OSError, ValueError):
return 0

def row(self, index: int) -> MapRow:
return self.rows[index]

@property
def village(self) -> MapRow:
return self.rows[0]

def live_rows(self) -> list[MapRow]:
"""Rows with at least one section."""
return [r for r in self.rows if r.sections]

def rows_of(self, stage: int) -> list[tuple[int, int]]:
"""(row, slot) of every row naming the stage."""
return list(self._by_stage.get(stage, ()))

def section(self, stage: int, row: int | None = None) -> Section | None:
hits = [h for h in self.rows_of(stage) if row is None or h[0] == row]
if not hits:
return None
r, slot = hits[0]
return self.rows[r].sections[slot]

def overlay(self, stage: int) -> S.StageOverlay | None:
if stage not in self._overlays:
try:
self._overlays[stage] = S.StageOverlay.read(self.game, stage)
except (OSError, ValueError):
self._overlays[stage] = None
return self._overlays[stage]

def arrivals(self, stage: int) -> list[tuple[int, S.Exit]]:
"""(from stage, exit) of every exit in the stage's rows that lands in it: an exit's
`dest` is in the target's frame, so only these can be drawn here."""
out = []
seen: set[int] = set()
for r, _ in self.rows_of(stage):
for s in self.rows[r].sections:
if s.stage == stage or s.stage in seen or not s.present:
continue
seen.add(s.stage)
ov = self.overlay(s.stage)
out += [(s.stage, e) for e in (ov.exits() if ov else []) if e.target == stage]
return out

def unreferenced(self) -> list[int]:
"""Stage files that exist and no row names."""
return [n for n in files.STAGES[1:] if n not in self._by_stage and self.size(n) > 0]

def twins(self) -> dict[int, list[int]]:
"""Per stage, the others sharing its collision bytes."""
groups: dict[str, list[int]] = {}
for r in self.rows:
for s in r.sections:
if not s.present:
continue
try:
data = StageFile.read(self.game, s.stage).entry(COLLISION)
except (OSError, ValueError):
continue
members = groups.setdefault(hashlib.sha1(data).hexdigest(), [])
if s.stage not in members:
members.append(s.stage)
return {m: [n for n in ms if n != m] for ms in groups.values() for m in ms}

def describe(self, row: int | None = None) -> str:
lines = []
for r in [self.rows[row]] if row is not None else self.live_rows():
lines.append(f"row {r.index:2d} {r.name:<28} {len(r.sections)} sections")
for s in r.sections:
entry = "entry " if s.is_entry else " "
stub = "" if s.present else "(placeholder)"
lines.append(f" [{s.slot:2d}] st{s.stage:03d} {s.name:<26} {entry}{stub}")
return "\n".join(lines)
Loading
Loading