|
| 1 | +# SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +# SPDX-FileCopyrightText: 2026 sp00ktober |
| 3 | +"""Building a port from its manifest: the donor's model, rig and moveset on the host's PAC. |
| 4 | +
|
| 5 | +`build` runs the steps below in order; each is a function of the ones before, so a tool or a |
| 6 | +test can stop at any of them. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +from collections.abc import Mapping, Sequence |
| 12 | +from dataclasses import dataclass |
| 13 | +from typing import Literal |
| 14 | + |
| 15 | +from mhp_formats import fu, p3rd, pmo |
| 16 | +from mhp_formats.anim import Clip |
| 17 | +from mhp_formats.pac import Pac |
| 18 | +from mhp_formats.skeleton import Skeleton |
| 19 | +from mhp_formats.tmh import Tmh |
| 20 | + |
| 21 | +from . import constraints, mesh, motion, records, retarget |
| 22 | +from . import rig as rigs |
| 23 | +from . import skin as skins |
| 24 | +from .data import Data |
| 25 | +from .manifest import Build, Manifest, Skin |
| 26 | +from .mesh import Part, Skinned |
| 27 | +from .rig import Rig |
| 28 | + |
| 29 | +SKELETON, MODEL, TEXTURES, ANIMATION = range(4) |
| 30 | +"""The host PAC's entries a port replaces; the others and the tail ride along.""" |
| 31 | + |
| 32 | +Mode = Literal["source_skeleton", "retarget"] |
| 33 | + |
| 34 | + |
| 35 | +@dataclass(frozen=True) |
| 36 | +class Donor: |
| 37 | + """The MHP3rd monster.""" |
| 38 | + |
| 39 | + model: p3rd.Pmo |
| 40 | + skeleton: Skeleton |
| 41 | + textures: bytes | None |
| 42 | + """Its TMH as stored; None keeps the host's.""" |
| 43 | + clips: list[Clip | None] |
| 44 | + """Its moveset by slot.""" |
| 45 | + em: int | None |
| 46 | + """Its species, which picks the record map; None builds unmapped.""" |
| 47 | + |
| 48 | + |
| 49 | +@dataclass(frozen=True) |
| 50 | +class Host: |
| 51 | + """The MHFU model PAC the port rides.""" |
| 52 | + |
| 53 | + pac: Pac |
| 54 | + skeleton: Skeleton |
| 55 | + model: pmo.Pmo | None |
| 56 | + """None where the PAC carries no PMO, which rules out a transferred skin.""" |
| 57 | + anim: fu.Anim |
| 58 | + |
| 59 | + |
| 60 | +@dataclass(frozen=True) |
| 61 | +class Binding: |
| 62 | + """The rig the port ships and how the donor's bones land on it.""" |
| 63 | + |
| 64 | + mode: Mode |
| 65 | + rig: Rig |
| 66 | + bone_of: dict[int, int | None] |
| 67 | + """Output joint -> the donor bone whose motion it plays; None or missing rests.""" |
| 68 | + joint_of: dict[int, int] |
| 69 | + """Donor bone -> the output joint its skin rides; a bone missing here loses its weight.""" |
| 70 | + dead: frozenset[int] |
| 71 | + """Animated joints nothing drives, which no guessed skin may ride.""" |
| 72 | + |
| 73 | + |
| 74 | +@dataclass(frozen=True) |
| 75 | +class Summary: |
| 76 | + mode: Mode |
| 77 | + skin: Skin |
| 78 | + """The skin built, after the upgrade and the fallbacks.""" |
| 79 | + em: int | None |
| 80 | + lead_pad: int |
| 81 | + streams: tuple[int, ...] |
| 82 | + groups: int |
| 83 | + vertices: int |
| 84 | + clips: int |
| 85 | + """The donor's distinct clips.""" |
| 86 | + size: int |
| 87 | + host_size: int |
| 88 | + |
| 89 | + def text(self) -> str: |
| 90 | + em = f"em {self.em}" if self.em is not None else "unmapped (default record offset)" |
| 91 | + return "\n".join( |
| 92 | + ( |
| 93 | + f"{self.mode}, skin {self.skin}, {em}", |
| 94 | + f"streams {'+'.join(map(str, self.streams))}, lead pad {self.lead_pad}", |
| 95 | + f"{self.groups} groups, {self.vertices} vertices, {self.clips} clips", |
| 96 | + f"{self.size} bytes, host {self.host_size} ({self.size - self.host_size:+d})", |
| 97 | + ) |
| 98 | + ) |
| 99 | + |
| 100 | + |
| 101 | +@dataclass(frozen=True) |
| 102 | +class Built: |
| 103 | + pac: bytes |
| 104 | + summary: Summary |
| 105 | + |
| 106 | + |
| 107 | +# 1. the inputs |
| 108 | + |
| 109 | + |
| 110 | +def em_of(m: Manifest) -> int | None: |
| 111 | + """The manifest's em id, else the model file's; a negative one opts out of the map.""" |
| 112 | + em = m.source.em_id if m.source.em_id is not None else records.em_for_model(m.source.model) |
| 113 | + return em if em is not None and em >= 0 else None |
| 114 | + |
| 115 | + |
| 116 | +def donor(m: Manifest, data: Data) -> Donor: |
| 117 | + """The donor's model PAC, geometry and moveset.""" |
| 118 | + raw = data.p3rd.read(m.source.model) |
| 119 | + entries = Pac.from_bytes(raw).entries |
| 120 | + skeleton = next((Skeleton.from_bytes(e) for e in entries if Skeleton.sniff(e)), None) |
| 121 | + if skeleton is None: |
| 122 | + raise ValueError(f"MHP3rd file {m.source.model} carries no skeleton") |
| 123 | + return Donor( |
| 124 | + model=mesh.donor(raw, data.p3rd.read(m.source.geo)), |
| 125 | + skeleton=skeleton, |
| 126 | + textures=next((e for e in entries if Tmh.sniff(e)), None), |
| 127 | + clips=motion.moveset(data.p3rd.read(m.source.anim)), |
| 128 | + em=em_of(m), |
| 129 | + ) |
| 130 | + |
| 131 | + |
| 132 | +def host(m: Manifest, data: Data) -> Host: |
| 133 | + """The host species' model PAC.""" |
| 134 | + pac = Pac.from_bytes(data.fu.read(m.port.host_frame)) |
| 135 | + e = pac.entries |
| 136 | + return Host( |
| 137 | + pac=pac, |
| 138 | + skeleton=Skeleton.from_bytes(e[SKELETON]), |
| 139 | + model=pmo.Pmo.from_bytes(e[MODEL]) if pmo.Pmo.sniff(e[MODEL]) else None, |
| 140 | + anim=fu.Anim.from_bytes(e[ANIMATION]), |
| 141 | + ) |
| 142 | + |
| 143 | + |
| 144 | +# 2. the record map |
| 145 | + |
| 146 | + |
| 147 | +def record_map(d: Donor, b: Build) -> dict[int, int]: |
| 148 | + """`{donor bone: record}`; the manifest's `bone_offset` and `skip_bones` override the |
| 149 | + species' table rows.""" |
| 150 | + counts = {len(c.tracks) for c in d.clips if c is not None} |
| 151 | + if len(counts) != 1: |
| 152 | + raise ValueError(f"the moveset's clips disagree on their record count: {sorted(counts)}") |
| 153 | + n = counts.pop() |
| 154 | + return records.for_monster(d.em, n, len(d.skeleton.bones), b.bone_offset, b.skip_bones) |
| 155 | + |
| 156 | + |
| 157 | +def animated(b: Build, record_of: Mapping[int, int], bones: int) -> int: |
| 158 | + """The donor bones the port animates: the manifest's count, else up to the last driven |
| 159 | + bone, else (or out of range) the whole rig.""" |
| 160 | + n = b.animated if b.animated is not None else max(record_of, default=bones - 1) + 1 |
| 161 | + return n if 0 < n <= bones else bones |
| 162 | + |
| 163 | + |
| 164 | +# 3. the rig |
| 165 | + |
| 166 | + |
| 167 | +def binding(b: Build, d: Donor, h: Host, animated: int) -> Binding: |
| 168 | + """The donor's own rig with `animated` bones (`source_skeleton`), or the host's with each |
| 169 | + host joint matched to a donor bone and the animated joints left unmatched dead.""" |
| 170 | + if b.source_skeleton: |
| 171 | + rig = rigs.from_donor(d.skeleton, h.skeleton, animated) |
| 172 | + bone_of: dict[int, int | None] = {j: bone for bone, j in rig.joint_of.items()} |
| 173 | + return Binding("source_skeleton", rig, bone_of, dict(rig.joint_of), frozenset()) |
| 174 | + rig = rigs.from_host(h.skeleton) |
| 175 | + match = retarget.match(d.skeleton, h.skeleton) |
| 176 | + joint_of: dict[int, int] = {} |
| 177 | + for j in sorted(match): |
| 178 | + bone = match[j] |
| 179 | + if bone is not None: |
| 180 | + joint_of.setdefault(bone, j) |
| 181 | + dead = frozenset(j for j in range(rig.animated) if match.get(j) is None) |
| 182 | + return Binding("retarget", rig, dict(match), joint_of, dead) |
| 183 | + |
| 184 | + |
| 185 | +# 4. the geometry |
| 186 | + |
| 187 | + |
| 188 | +def parts(d: Donor, b: Build) -> list[Part]: |
| 189 | + """The donor's draw groups, less those riding a `drop_joints` bone.""" |
| 190 | + out = mesh.parts(d.model) |
| 191 | + return mesh.drop(out, b.drop_joints) if b.drop_joints else out |
| 192 | + |
| 193 | + |
| 194 | +def skin(parts: Sequence[Part], b: Build, bind: Binding, h: Host) -> tuple[list[Skinned], Skin]: |
| 195 | + """The parts bound to the rig, and the skin used: `auto` means `source` on the donor's own |
| 196 | + rig when the donor carries weights; `source` without weights and `transfer` without a host |
| 197 | + PMO fall back to `auto`.""" |
| 198 | + weighted = any(v for p in parts for v in p.influences) |
| 199 | + mode = b.skin |
| 200 | + if mode == "auto" and bind.mode == "source_skeleton" and weighted: |
| 201 | + mode = "source" |
| 202 | + if mode == "source" and weighted: |
| 203 | + return skins.source(parts, bind.joint_of), "source" |
| 204 | + if mode == "transfer" and h.model is not None: |
| 205 | + return skins.transfer(parts, h.model, bind.rig.parents, dead=bind.dead), "transfer" |
| 206 | + rig = bind.rig |
| 207 | + out = skins.auto(parts, rig.bind, rig.parents, exclude=bind.dead, nb=b.nb) |
| 208 | + skins.weld(out, rig.bind) |
| 209 | + return out, "auto" |
| 210 | + |
| 211 | + |
| 212 | +# 5. the motion |
| 213 | + |
| 214 | + |
| 215 | +def animation( |
| 216 | + d: Donor, h: Host, bind: Binding, record_of: Mapping[int, int], lift: float = 0.0 |
| 217 | +) -> fu.Anim: |
| 218 | + """The donor's moveset on the rig's joints, its pelvis raised by `lift` world units.""" |
| 219 | + clips: Sequence[Clip | None] = d.clips |
| 220 | + if lift: |
| 221 | + bone_of_record = {r: bone for bone, r in record_of.items()} |
| 222 | + parents = [bone.parent for bone in d.skeleton.bones] |
| 223 | + pelvis = motion.pelvis(clips, bone_of_record, parents) |
| 224 | + if pelvis is not None: |
| 225 | + clips = motion.lift(clips, lift, pelvis) |
| 226 | + # records are not positional in either mode: joint -> donor bone -> record |
| 227 | + track_of = {j: record_of.get(bone) for j, bone in bind.bone_of.items() if bone is not None} |
| 228 | + return motion.build(clips, h.anim, bind.rig.streams, track_of) |
| 229 | + |
| 230 | + |
| 231 | +# 6. the PAC |
| 232 | + |
| 233 | + |
| 234 | +def pac(h: Host, rig: Rig, model: pmo.Pmo, textures: bytes | None, anim: fu.Anim) -> bytes: |
| 235 | + """The host's PAC with the port's skeleton, PMO, textures and animation in their entries. |
| 236 | +
|
| 237 | + Raises `constraints.ConstraintError` where they break an engine rule.""" |
| 238 | + constraints.check(model, rig.skeleton, anim) |
| 239 | + entries = list(h.pac.entries) |
| 240 | + entries[SKELETON] = rig.skeleton.to_bytes() |
| 241 | + entries[MODEL] = model.to_bytes() |
| 242 | + if textures is not None: |
| 243 | + entries[TEXTURES] = textures |
| 244 | + entries[ANIMATION] = anim.to_bytes() |
| 245 | + return Pac(entries, h.pac.align, h.pac.tail).to_bytes() |
| 246 | + |
| 247 | + |
| 248 | +def build(m: Manifest, data: Data) -> Built: |
| 249 | + """The port's model PAC.""" |
| 250 | + d, h = donor(m, data), host(m, data) |
| 251 | + record_of = record_map(d, m.build) |
| 252 | + bind = binding(m.build, d, h, animated(m.build, record_of, len(d.skeleton.bones))) |
| 253 | + kept = parts(d, m.build) |
| 254 | + skinned, skin_used = skin(kept, m.build, bind, h) |
| 255 | + model = mesh.build(skinned, d.model.scale) |
| 256 | + anim = animation(d, h, bind, record_of, m.build.ground_lift) |
| 257 | + out = pac(h, bind.rig, model, d.textures, anim) |
| 258 | + summary = Summary( |
| 259 | + mode=bind.mode, |
| 260 | + skin=skin_used, |
| 261 | + em=d.em, |
| 262 | + lead_pad=bind.rig.lead_pad, |
| 263 | + streams=tuple(bind.rig.streams), |
| 264 | + groups=len(kept), |
| 265 | + vertices=sum(len(p.positions) for p in kept), |
| 266 | + clips=len({id(c) for c in d.clips if c is not None}), |
| 267 | + size=len(out), |
| 268 | + host_size=len(h.pac.to_bytes()), |
| 269 | + ) |
| 270 | + return Built(out, summary) |
0 commit comments