Skip to content

Commit b63e982

Browse files
authored
Merge pull request #42 from MHFU-ModKit/ux/68
studio: Send to game, and what made the window hard to use
2 parents 1d9e418 + 9e45a60 commit b63e982

139 files changed

Lines changed: 9402 additions & 3178 deletions

File tree

Some content is hidden

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

‎apps/studio/src/mhfu_studio/map/adding.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def add(self) -> Selection | None:
112112
if sc is None or sess is None or g is None:
113113
return None
114114
if sess.base_dir is None:
115-
self.message = "save the document first: the shape is written into its assets/"
115+
self.message = "save the document first (File > Save): the shape goes into its assets/"
116116
return None
117117
kind, at = self.kind, tuple(self.placement())
118118
try:
@@ -151,7 +151,7 @@ def add(self) -> Selection | None:
151151
self.message = f"refused: {e}"
152152
(sess.base_dir / name).unlink(missing_ok=True)
153153
return None
154-
self.message = f"added {Path(name).name} ({len(t)} triangles) into {g.label} as {name}"
154+
self.message = f"added {Path(name).name} ({len(t)} triangles) into {g.label}"
155155
warnings = sess.warnings()
156156
if warnings:
157157
self.message += " [!] " + warnings[0]

‎apps/studio/src/mhfu_studio/map/commands.py‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,14 @@ def register(groups: Groups) -> None:
7272

7373

7474
def _game(args: argparse.Namespace) -> Extracted:
75-
from mhfu.files import Extracted
75+
from mhfu_studio.shell import places
7676

77-
return Extracted.find(args.data)
77+
return places.extracted(given=args.data)
7878

7979

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

8584
from mhfu_studio.stage import live
8685
from mhfu_studio.stage import ops as O
@@ -118,7 +117,7 @@ def inject(args: argparse.Namespace) -> int:
118117
pushes.append((p, catch or 0.0))
119118
if args.dry:
120119
return 0
121-
with Client.connect(port=args.port) as client:
120+
with live.connect(args.port) as client:
122121
mem = Live(client)
123122
for p, catch in pushes:
124123
label = p.stage.label
@@ -127,7 +126,7 @@ def inject(args: argparse.Namespace) -> int:
127126
elif args.restore:
128127
live.restore(mem, p.stage)
129128
else:
130-
undo = base / ".inject" / f"{label}_collision_undo.json"
129+
undo = live.undo_path(p.stage.number)
131130
live.run(client, mem, p, catch_for=catch, hold_for=args.hold, undo_file=undo)
132131
return 0
133132

‎apps/studio/src/mhfu_studio/map/core/atlas.py‎

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
# SPDX-FileCopyrightText: 2026 sp00ktober
33
"""The map table as rows of sections, entry area first; the village is row 0.
44
5-
Names come only from the game: three rows and the snowy bank are labelled, everything else
6-
is `row N` / `stNNN`. Day/night twins share their collision bytes, paired by hash, not by an
7-
offset that holds for one map only.
5+
On screen a row is a "map" and a section an "area" (`row_name`, `stage_title`). Names come
6+
only from the game: three maps and the snowy areas are named, everything else is `Map N` /
7+
`Area stNNN`. Day/night twins share their collision bytes, paired by hash, not by an offset
8+
that holds for one map only.
89
"""
910

1011
from __future__ import annotations
@@ -30,7 +31,7 @@
3031

3132
STAGE_NAMES: dict[int, str] = {
3233
139: "Pokke village",
33-
66: "Pokke village (twin of st139)",
34+
66: "Pokke village twin",
3435
98: "Snowy base camp",
3536
99: "Snowy area 1",
3637
95: "Snowy area 2",
@@ -41,28 +42,40 @@
4142
96: "Snowy area 7",
4243
97: "Snowy area 8",
4344
6: "Snowy secret area",
44-
107: "Snowy base camp (night)",
45-
108: "Snowy area 1 (night)",
46-
104: "Snowy area 2 (night)",
47-
103: "Snowy area 3 (night)",
48-
101: "Snowy area 4 (night)",
49-
102: "Snowy area 5 (night)",
50-
109: "Snowy area 6 (night)",
51-
105: "Snowy area 7 (night)",
52-
106: "Snowy area 8 (night)",
53-
15: "Snowy secret area (night)",
45+
107: "Snowy base camp at night",
46+
108: "Snowy area 1 at night",
47+
104: "Snowy area 2 at night",
48+
103: "Snowy area 3 at night",
49+
101: "Snowy area 4 at night",
50+
102: "Snowy area 5 at night",
51+
109: "Snowy area 6 at night",
52+
105: "Snowy area 7 at night",
53+
106: "Snowy area 8 at night",
54+
15: "Snowy secret area at night",
5455
}
5556

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

5960

61+
def stage_id(stage: int) -> str:
62+
return f"st{stage:03d}"
63+
64+
6065
def stage_name(stage: int) -> str:
61-
return STAGE_NAMES.get(stage, f"st{stage:03d}")
66+
"""`Pokke village`, or `Area st140` for a stage the game gives no name."""
67+
return STAGE_NAMES.get(stage, f"Area {stage_id(stage)}")
68+
69+
70+
def stage_title(stage: int) -> str:
71+
"""`Pokke village (st139)`; `Area st140`, its id once, for a stage with no name."""
72+
name = STAGE_NAMES.get(stage)
73+
return f"{name} ({stage_id(stage)})" if name else stage_name(stage)
6274

6375

6476
def row_name(row: int) -> str:
65-
return ROW_NAMES.get(row, f"row {row}")
77+
"""A row as the map it is: `Snowy Mountains (day)`, or `Map 7`."""
78+
return ROW_NAMES.get(row, f"Map {row}")
6679

6780

6881
@dataclass
@@ -192,5 +205,5 @@ def describe(self, row: int | None = None) -> str:
192205
for s in r.sections:
193206
entry = "entry " if s.is_entry else " "
194207
stub = "" if s.present else "(placeholder)"
195-
lines.append(f" [{s.slot:2d}] st{s.stage:03d} {s.name:<26} {entry}{stub}")
208+
lines.append(f" [{s.slot:2d}] {stage_title(s.stage):<33} {entry}{stub}")
196209
return "\n".join(lines)

‎apps/studio/src/mhfu_studio/map/core/edit.py‎

Lines changed: 74 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from mhfu_studio.stage import collision, mesh, textures
2525
from mhfu_studio.stage import ops as O
2626

27-
from .scene import CLIMB_MAX_UP, Array, Key, MapScene, Point
27+
from .scene import CLASS_NAMES, CLIMB_MATERIALS, Array, Key, MapScene, Point, group_name
2828

2929
GROUP, OBJECT, FACE, COLLISION = "group", "object", "face", "collision"
3030
KINDS = (GROUP, OBJECT, FACE, COLLISION)
@@ -34,6 +34,8 @@
3434
SHORT_WALL = 100.0
3535
"""A climbable triangle shorter than this is worth a warning."""
3636
KEEPS = ("did-not-fit",)
37+
#: a collision chunk in words: 1 holds the floor, 0 the walls and ceilings
38+
CHUNK_WORDS = {0: "wall", 1: "floor"}
3739
"""Error codes a commit keeps (a partial pack still draws), shown as warnings."""
3840

3941

@@ -241,15 +243,16 @@ def refresh(self, scene: MapScene) -> Selection:
241243
out.parts[k] = [int(f) for f in np.nonzero(np.isin(g.triangles, ids).all(1))[0]]
242244
return out
243245

244-
def describe(self, scene: MapScene) -> str:
246+
def describe(self) -> str:
247+
"""In words: "2 objects in group 8", "group 8", "3 triangles in 2 groups"."""
245248
if self.empty:
246249
return "nothing selected"
247-
bits = []
248-
for k, ids in self.vertices.items():
249-
n = len(self.parts.get(k, []))
250-
what = {GROUP: "the whole group", OBJECT: f"{n} object(s)", FACE: f"{n} face(s)"}
251-
bits.append(f"{scene.group(*k).label}: {what[self.kind]}, {len(ids)} vertices")
252-
return "; ".join(bits)
250+
ks = list(self.vertices)
251+
where = group_name(ks[0]) if len(ks) == 1 else count(len(ks), "group")
252+
if self.kind == GROUP:
253+
return where
254+
n = sum(len(self.parts.get(k, [])) for k in ks)
255+
return f"{count(n, 'object' if self.kind == OBJECT else 'triangle')} in {where}"
253256

254257

255258
@dataclass
@@ -293,10 +296,26 @@ def centroid(self, scene: MapScene) -> Array:
293296
return out
294297

295298
def describe(self, scene: MapScene) -> str:
299+
"""In words, with the kinds: "3 collision triangles: 2 wall, 1 climbable"."""
296300
if self.empty:
297301
return "no collision triangle selected"
298-
by = sorted(self.by_chunk().items())
299-
return "collision: " + ", ".join(f"chunk {c} x{len(t)}" for c, t in by)
302+
kinds: dict[str, int] = {}
303+
for c, t in self.tris:
304+
k = CLASS_NAMES[str(scene.chunk(c).klass[t])]
305+
kinds[k] = kinds.get(k, 0) + 1
306+
what = ", ".join(f"{n} {k}" for k, n in kinds.items())
307+
if len(kinds) == 1:
308+
what = next(iter(kinds))
309+
return f"{count(len(self), 'collision triangle')}: {what}"
310+
311+
312+
def count(n: int, noun: str) -> str:
313+
return f"{n} {noun}{'' if n == 1 else 's'}"
314+
315+
316+
def triangle_name(chunk: int, tri: int) -> str:
317+
"""`wall triangle 12`: a collision triangle as a person points at it."""
318+
return f"{CHUNK_WORDS.get(chunk, f'chunk {chunk}')} triangle {tri}"
300319

301320

302321
def by_chunk(tris: Iterable[Pair]) -> dict[int, list[int]]:
@@ -340,55 +359,68 @@ def op_key(op: Mapping[str, Any]) -> Key | None:
340359

341360

342361
def describe_op(op: Mapping[str, Any]) -> str:
343-
"""One line per op, in the user's terms."""
362+
"""One line per op, in plain words: "group 8: moved by 0, 100, 0"."""
344363
k = str(op.get("op"))
345364
key = op_key(op)
346-
where = f"sub{key[0]}.g{key[1]}" if key else "collision"
347-
if k == "transform":
348-
by = [round(v) for v in op.get("by", (0, 0, 0))]
349-
rot = [round(v) for v in op.get("rotate", (0, 0, 0))]
350-
sc = [round(v, 2) for v in op.get("scale", (1, 1, 1))]
351-
n = len(op.get("vertices", []))
352-
return f"{k:<9} {where:<9} {n:4d} v by {by} rot {rot} scale {sc}"
353-
if k == "move":
354-
return f"{k:<9} {where:<9} by {op.get('by')}"
365+
where = group_name(key) if key else "collision"
366+
if k in ("transform", "move"):
367+
by, rot = op.get("by", (0, 0, 0)), op.get("rotate", (0, 0, 0))
368+
sc = op.get("scale", (1, 1, 1))
369+
bits = [f"moved by {_xyz(by)}"] if any(round(v) for v in by) else []
370+
if any(round(v) for v in rot):
371+
bits.append(f"turned {_xyz(rot)}\N{DEGREE SIGN}")
372+
if any(round(v, 2) != 1 for v in sc):
373+
bits.append(f"scaled \N{MULTIPLICATION SIGN}{_xyz(sc, 2)}")
374+
return f"{where}: {'; '.join(bits) or 'no change'}"
355375
if k == "pack":
356-
solid = " +collider" if op.get("solid") else ""
357-
n = len(op.get("prims") or [])
358-
return f"{k:<9} {where:<9} {Path(str(op.get('obj', '?'))).name} into {n} prims{solid}"
376+
name = Path(str(op.get("obj", "?"))).name
377+
return f"added {name} into {where}" + (", solid" if op.get("solid") else "")
359378
if k == "clear":
360-
what = f"{len(op['prims'])} prims" if op.get("prims") else "whole group"
361-
return f"{k:<9} {where:<9} {what}"
379+
n = len(op.get("prims") or [])
380+
return f"removed from {where} ({count(n, 'drawing slot')})" if n else f"removed {where}"
362381
if k == "material":
363-
bits = [f"texture {op['texture']}"] if "texture" in op else []
382+
bits = [f"texture slot {op['texture']}"] if "texture" in op else []
364383
if "rgba" in op:
365-
bits.append(f"rgba {tuple(op['rgba'])}")
366-
return f"{k:<9} {where:<9} {', '.join(bits) or '?'}"
384+
bits.append(f"colour {_xyz(op['rgba'])}")
385+
return f"{where}: {', '.join(bits) or 'material'}"
367386
if k == "texture":
368387
if "png" in op:
369-
src = str(op["png"])
388+
src = Path(str(op["png"])).name
370389
elif "from" in op:
371-
src = f"st{op['from']['stage']:03d} slot {op['from']['slot']}"
390+
src = f"slot {op['from']['slot']} of st{op['from']['stage']:03d}"
372391
else:
373-
src = f"rgb {tuple(op.get('rgb', ()))}"
374-
return f"{k:<9} slot {op.get('slot')!s:<4} <- {src}"
392+
src = f"flat colour {_xyz(op.get('rgb', ()))}"
393+
return f"texture slot {op.get('slot')}: {src}"
375394
if k == "collision":
376395
if "solid_box" in op:
377-
return "collision box collider"
396+
return "added a box collider"
378397
if op.get("add"):
379-
chunk = "auto" if op.get("chunk") is None else op["chunk"]
380-
return f"collision chunk {chunk} +{len(op['add'])} triangle(s){_flags_text(op)}"
398+
return f"added {count(len(op['add']), 'collision triangle')}{_flags_text(op)}"
381399
n = ("tri" in op) + len(op.get("tris", []))
382-
what = "delete" if op.get("delete") else ("move" if "verts" in op else "flags")
383-
return f"collision chunk {op.get('chunk', 1)} {what} x{n}{_flags_text(op)}"
384-
return f"{k:<9} {where}"
400+
tris = count(n, f"{CHUNK_WORDS.get(op.get('chunk', 1), 'collision')} triangle")
401+
if op.get("delete"):
402+
return f"deleted {tris}"
403+
if "verts" in op:
404+
return f"moved {tris}"
405+
return f"changed {tris}{_flags_text(op)}"
406+
return f"{k} {where}"
407+
408+
409+
def _xyz(v: Sequence[float], digits: int = 0) -> str:
410+
return ", ".join(f"{round(float(x), digits):g}" for x in v)
385411

386412

387413
def _flags_text(op: Mapping[str, Any]) -> str:
414+
"""The flags an op sets, climbing named."""
388415
fl = op.get("flags") or {}
389416
if not isinstance(fl, Mapping) or not fl:
390417
return ""
391-
return " " + " ".join(f"{k}={v}" for k, v in fl.items())
418+
if set(fl) == {"material"}:
419+
if fl["material"] in CLIMB_MATERIALS:
420+
return ": climbable"
421+
if fl["material"] == 0:
422+
return ": not climbable"
423+
return ": " + ", ".join(f"{k} {v}" for k, v in fl.items())
392424

393425

394426
class EditSession:
@@ -817,17 +849,13 @@ def climb_warnings(self, tris: Iterable[Pair]) -> list[str]:
817849
for c, t in tris:
818850
ch = self.scene.chunk(c)
819851
if not ch.vertical(t):
820-
ny = abs(float(ch.normals[t][1]))
821-
out.append(
822-
f"chunk {c} tri {t} is not near-vertical (|n.y| = {ny:.2f} >="
823-
f" {CLIMB_MAX_UP:.2f}): the material will not make it climbable"
824-
)
852+
out.append(f"{triangle_name(c, t)} is too flat to climb")
825853
h = float(ch.verts[t][:, 1].max() - ch.verts[t][:, 1].min())
826854
if h < SHORT_WALL:
827-
out.append(f"chunk {c} tri {t} is only {h:.0f} units tall")
855+
out.append(f"{triangle_name(c, t)} is only {h:.0f} units tall: too short to climb")
828856
mats.add(int(ch.material[t]))
829857
if len(mats) > 1:
830-
out.append(f"the patch mixes materials {sorted(mats)}")
858+
out.append(f"the selected triangles mix materials {sorted(mats)}")
831859
return out
832860

833861
# checks

‎apps/studio/src/mhfu_studio/map/core/scene.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from mhfu_studio.stage.file import MESHES, TEXTURES, StageFile
2727
from mhfu_studio.stage.textures import UNTEXTURED
2828

29-
from .atlas import stage_name
29+
from .atlas import stage_name, stage_title
3030

3131
Array = npt.NDArray[Any]
3232
Point = Sequence[float] | Array
@@ -49,12 +49,20 @@
4949

5050
FLOOR, WALL, CLIMB, SINK, WADE = "floor", "wall", "climb", "sink", "wade"
5151
CLASSES = (FLOOR, WALL, CLIMB, SINK, WADE)
52+
#: a class as the panels name it
53+
CLASS_NAMES = {FLOOR: "floor", WALL: "wall", CLIMB: "climbable", SINK: "sinking", WADE: "wading"}
5254

5355

5456
class SceneError(RuntimeError):
5557
"""The stage cannot be made into a scene at all."""
5658

5759

60+
def group_name(key: Key) -> str:
61+
"""`group 8`; one of the props model (sub 2) is `group 8 (second model)`."""
62+
sub, g = key
63+
return f"group {g}" + (" (second model)" if sub else "")
64+
65+
5866
@dataclass
5967
class TextureImage:
6068
"""One slot of the bank; `rgba` is (h, w, 4) uint8, row 0 the top."""
@@ -111,7 +119,7 @@ def key(self) -> Key:
111119

112120
@property
113121
def label(self) -> str:
114-
return f"sub{self.sub}.g{self.vg_rec}"
122+
return group_name(self.key)
115123

116124
@property
117125
def budget(self) -> Budget:
@@ -354,7 +362,7 @@ def _resolve_textures(self, groups: Iterable[MeshGroup]) -> None:
354362

355363
def summary(self) -> str:
356364
lines = [
357-
f"st{self.stage:03d} {self.name}",
365+
stage_title(self.stage),
358366
f" fog {self.fog_rgba} subs {self.sub_sizes}",
359367
]
360368
for sub, label in ((0, "terrain"), (2, "props")):
@@ -371,7 +379,7 @@ def summary(self) -> str:
371379
lines.append(f" collision chunk {c.index}: {c.n_triangles} triangles {kinds}")
372380
lines.append(f" textures: {len(self.textures)}")
373381
for e in self.exits:
374-
lines.append(f" exit {e.index} -> st{e.target:03d} {e.target_name}")
382+
lines.append(f" exit {e.index} -> {stage_title(e.target)}")
375383
lines += [f" note: {n}" for n in self.notes]
376384
return "\n".join(lines)
377385

0 commit comments

Comments
 (0)