ff9mapkit 1.0.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/hub.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""World-Hub generator (overworld's lane): a ``journeys.toml`` registry -> a hub ``field.toml``.
|
|
2
|
+
|
|
3
|
+
A *World Hub* is the New-Game-landing **journey selector** (memory: ``project-ff9-world-hub``): you walk as
|
|
4
|
+
a Moogle, talk to a narrator NPC, pick a **journey** (a complete arc = one or more chained campaign slices)
|
|
5
|
+
from a dialogue menu, and warp into its entry field -- optionally seeding the story beat first. The hub is
|
|
6
|
+
THIN: per journey it needs only ``{display name, entry field id, seed}``; HOW a journey plays internally is
|
|
7
|
+
the journey's own business (story_flags' campaign lane). This module is the "hardcoded MVP -> generator"
|
|
8
|
+
step the world-hub MVP left as a follow-up: it turns a small ``journeys.toml`` into a complete hub
|
|
9
|
+
``field.toml``, built on the in-game-proven primitives -- the choice ``warp`` action
|
|
10
|
+
(:func:`ff9mapkit.content.event.warp`) + ``[player] model=`` (the Moogle PC).
|
|
11
|
+
|
|
12
|
+
It **emits a field.toml** (the existing build/deploy path then compiles it -- no new build path), mirroring
|
|
13
|
+
:func:`ff9mapkit.campaign.render_campaign_toml`'s emit-then-build pattern. The generated hub is a normal
|
|
14
|
+
synthesized BG-borrow field: a camera ``borrow`` + a Moogle player + a narrator NPC + one ``[[choice]]``
|
|
15
|
+
whose options ``warp`` to each journey's entry, plus a trailing no-warp "stay" row (the cancel target).
|
|
16
|
+
|
|
17
|
+
``journeys.toml`` shape::
|
|
18
|
+
|
|
19
|
+
[hub]
|
|
20
|
+
name = "WORLD_HUB" # -> EVT_<name>.eb / FBG_N<area>_<name>
|
|
21
|
+
id = 4500 # the hub field id (>= 4000)
|
|
22
|
+
area = 21 # >= 10 (the BG-borrow loader reads 2 digits; single digits black-screen)
|
|
23
|
+
borrow_bg = "GRGR_MAP420_GR_CEN_0" # BG-borrow a real room for the backdrop (the MVP art path)
|
|
24
|
+
camera = "camera_hub.bgx" # that room's camera (you extract it from your own install; gitignored)
|
|
25
|
+
text_block = 8 # a real MesDB id NOT shadowed by a higher folder (1073 IS -> wrong menu)
|
|
26
|
+
prompt = "Kupo! Which journey will you take?"
|
|
27
|
+
stay_text = "Stay here, kupo..." # the trailing no-warp (cancel) row label
|
|
28
|
+
player_model = 220 # the Moogle PC (220 = GEO_NPC_F0_MOG, the iconic save moogle)
|
|
29
|
+
player_spawn = [404, 127]
|
|
30
|
+
narrator = "Stiltzkin"
|
|
31
|
+
narrator_model = 220 # default = player_model
|
|
32
|
+
narrator_pos = [480, 127]
|
|
33
|
+
|
|
34
|
+
# Set-dressing (author-customizable) -- dress the hub without hand-editing the generated field.toml.
|
|
35
|
+
[[hub.props]] # static set-dressing (the proven [[prop]] path; non-interactive)
|
|
36
|
+
prop = "save_point" # a prop archetype by name (save_point/chest/tent/barrel/...) OR
|
|
37
|
+
pos = [300, 100] # model = <GEO id> + pose = "<anim>" for a bare standing figure
|
|
38
|
+
[[hub.ambient_npcs]] # a flavor character (talk -> its dialogue line, if any)
|
|
39
|
+
archetype = "moogle" # archetype name OR model = <GEO id>
|
|
40
|
+
pos = [260, 150]
|
|
41
|
+
dialogue = "Kupo! Safe travels!" # optional; omit for a silent standing NPC
|
|
42
|
+
|
|
43
|
+
[[journey]]
|
|
44
|
+
id = "black_mage_village" # stable slug (hub-choice key + seed namespace; docs/JOURNEYS.md)
|
|
45
|
+
name = "The Black Mage Village" # the menu row label (default: humanized id)
|
|
46
|
+
entry = 4501 # the journey's entry field id (the warp target)
|
|
47
|
+
set_scenario = 2600 # optional: seed the beat hub-side before the warp
|
|
48
|
+
|
|
49
|
+
Regenerate the hub after editing the registry (``ff9mapkit gen-hub journeys.toml``); the emitted
|
|
50
|
+
``hub.field.toml`` is a build artifact -- don't hand-edit it.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import os
|
|
56
|
+
import re
|
|
57
|
+
import tomllib
|
|
58
|
+
from dataclasses import dataclass, field
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
# The iconic save Moogle (GEO_NPC_F0_MOG). NOT 199 (GEO_NPC_F5, a bat-winged variant that surprised the
|
|
62
|
+
# world-hub playtest -- memory project-ff9-world-hub). Both share the MOG movement clips via catalog.npc_anims.
|
|
63
|
+
MOOGLE_MODEL = 220
|
|
64
|
+
DEFAULT_AREA = 21
|
|
65
|
+
DEFAULT_PROMPT = "Kupo! Which journey will you take?"
|
|
66
|
+
DEFAULT_STAY = "Stay here, kupo..."
|
|
67
|
+
DEFAULT_NARRATOR = "Stiltzkin"
|
|
68
|
+
DEFAULT_CAMERA = "camera_hub.bgx"
|
|
69
|
+
# Hold the screen black this many frames before the reveal fade so the engine's smooth-camera follower
|
|
70
|
+
# settles UNSEEN (else warping into the hub visibly eases the camera to rest -- the borrowed room's camera
|
|
71
|
+
# vs the warp-in delta). ~1.5s @ 30fps; engine-independent. Tune/disable (0) via [hub] entry_settle.
|
|
72
|
+
DEFAULT_ENTRY_SETTLE = 45
|
|
73
|
+
DEFAULT_TEXT_BLOCK = 1073
|
|
74
|
+
SHADOWED_TEXT_BLOCK = 1073 # the block the higher-priority FF9CustomMap folder defines (shadows yours)
|
|
75
|
+
PAGING_SOFT_MAX = 8 # journeys + the stay row beyond ~this need menu scrolling -- verify in-game
|
|
76
|
+
SCENARIO_MAX = 32767
|
|
77
|
+
|
|
78
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$") # a field/journey name -> EVT_/FBG_ token + comment/subdir-safe
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def name_token(s, *, fallback="HUB") -> str:
|
|
82
|
+
"""Coerce a hub display name to an EVT_/FBG_ TOKEN: the ``[hub]`` ``name`` becomes ``EVT_<name>.eb`` +
|
|
83
|
+
``FBG_N<area>_<name>`` (validated by :data:`_NAME_RE`), so spaces/punctuation must collapse to underscores
|
|
84
|
+
(``World Hub`` -> ``World_Hub``). Already-valid tokens pass through unchanged; empty -> ``fallback``."""
|
|
85
|
+
t = re.sub(r"[^A-Za-z0-9_]+", "_", str(s)).strip("_")
|
|
86
|
+
return t or fallback
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class HubError(ValueError):
|
|
90
|
+
"""A journeys.toml / hub-generation problem (caught + printed by the CLI)."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Journey:
|
|
95
|
+
"""One row of the journey menu, in the ``docs/JOURNEYS.md`` schema: a stable ``id`` slug (the hub-choice
|
|
96
|
+
key + seed namespace), a pretty ``name`` (the menu label + the GUI breadcrumb), the ``entry`` field it
|
|
97
|
+
warps into, and an optional ``set_scenario`` seed applied hub-side before the warp. (A *multi-campaign*
|
|
98
|
+
journey -- ``campaigns`` / ``entry = {campaign, field}`` / ``[journey.seed]`` / ``[[journey.link]]`` --
|
|
99
|
+
is the future journey ASSEMBLER's job; gen-hub builds the single-entry form, ``entry = <field id>``.)"""
|
|
100
|
+
id: str
|
|
101
|
+
name: str
|
|
102
|
+
entry: int
|
|
103
|
+
set_scenario: "int | None" = None
|
|
104
|
+
entrance: "int | None" = None # arrival entrance into the entry field (D8:2) -- frames the entry camera
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class HubSpec:
|
|
109
|
+
"""The whole hub registry: the hub field's identity + backdrop + Moogle/narrator rig + the journeys."""
|
|
110
|
+
name: str
|
|
111
|
+
id: int
|
|
112
|
+
area: int = DEFAULT_AREA
|
|
113
|
+
borrow_bg: str = ""
|
|
114
|
+
borrow_field: "int | None" = None # the real field id whose camera `gen-hub --extract-camera` pulls
|
|
115
|
+
camera: str = DEFAULT_CAMERA
|
|
116
|
+
entry_settle: int = DEFAULT_ENTRY_SETTLE # frames the hub holds black on entry so the camera settles
|
|
117
|
+
text_block: int = DEFAULT_TEXT_BLOCK
|
|
118
|
+
prompt: str = DEFAULT_PROMPT
|
|
119
|
+
stay_text: str = DEFAULT_STAY
|
|
120
|
+
player_model: "int | str" = MOOGLE_MODEL
|
|
121
|
+
player_spawn: "list | None" = None
|
|
122
|
+
narrator: str = DEFAULT_NARRATOR
|
|
123
|
+
narrator_model: "int | str | None" = None # None -> inherit player_model
|
|
124
|
+
narrator_pos: "list | None" = None
|
|
125
|
+
# Set-dressing (author-customizable): static [[prop]]s (save point / lamp / a bare standing moogle) +
|
|
126
|
+
# ambient [[npc]]s (a talkable flavor character). All BG-borrow + the proven prop/npc build path.
|
|
127
|
+
props: "list" = field(default_factory=list) # each: {prop=<archetype>|model=N, pos, pose?, face?}
|
|
128
|
+
ambient_npcs: "list" = field(default_factory=list) # each: {archetype=<name>|model=N, pos, dialogue?}
|
|
129
|
+
journeys: "list[Journey]" = field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def narrator_model_resolved(self):
|
|
133
|
+
return self.narrator_model if self.narrator_model is not None else self.player_model
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _humanize(name: str) -> str:
|
|
137
|
+
"""``black_mage_village`` -> ``Black Mage Village`` -- a default menu label from a journey's token name."""
|
|
138
|
+
return " ".join(w.capitalize() for w in str(name).replace("_", " ").split()) or str(name)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def hubspec_from_table(h: dict, journeys: "list[Journey]") -> HubSpec:
|
|
142
|
+
"""Build a :class:`HubSpec` from a parsed ``[hub]`` table + a resolved journey list. The single source
|
|
143
|
+
of truth for the ``[hub]`` presentation schema -- both :func:`load_journeys` (gen-hub's single-entry
|
|
144
|
+
rows) and the multi-campaign journey assembler (:mod:`ff9mapkit.journey`, which resolves campaign entries
|
|
145
|
+
to global ids before calling here) construct their HubSpec through this. Raises :class:`HubError` on a
|
|
146
|
+
missing required key; semantic checks stay in :func:`validate_hub`."""
|
|
147
|
+
if "name" not in h:
|
|
148
|
+
raise HubError("[hub] missing required key 'name' (becomes EVT_<name>.eb / FBG_N<area>_<name>)")
|
|
149
|
+
if "id" not in h:
|
|
150
|
+
raise HubError("[hub] missing required key 'id' (the hub field id, >= 4000)")
|
|
151
|
+
return HubSpec(
|
|
152
|
+
name=str(h["name"]),
|
|
153
|
+
id=int(h["id"]),
|
|
154
|
+
area=int(h.get("area", DEFAULT_AREA)),
|
|
155
|
+
borrow_bg=str(h.get("borrow_bg", "")),
|
|
156
|
+
borrow_field=int(h["borrow_field"]) if h.get("borrow_field") is not None else None,
|
|
157
|
+
camera=str(h.get("camera", DEFAULT_CAMERA)),
|
|
158
|
+
entry_settle=int(h.get("entry_settle", DEFAULT_ENTRY_SETTLE)),
|
|
159
|
+
text_block=int(h.get("text_block", DEFAULT_TEXT_BLOCK)),
|
|
160
|
+
prompt=str(h.get("prompt", DEFAULT_PROMPT)),
|
|
161
|
+
stay_text=str(h.get("stay_text", DEFAULT_STAY)),
|
|
162
|
+
player_model=h.get("player_model", MOOGLE_MODEL),
|
|
163
|
+
player_spawn=list(h["player_spawn"]) if "player_spawn" in h else None,
|
|
164
|
+
narrator=str(h.get("narrator", DEFAULT_NARRATOR)),
|
|
165
|
+
narrator_model=h.get("narrator_model"),
|
|
166
|
+
narrator_pos=list(h["narrator_pos"]) if "narrator_pos" in h else None,
|
|
167
|
+
props=[dict(p) for p in h.get("props", [])],
|
|
168
|
+
ambient_npcs=[dict(n) for n in h.get("ambient_npcs", [])],
|
|
169
|
+
journeys=journeys,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_journeys(path) -> HubSpec:
|
|
174
|
+
"""Parse a ``journeys.toml`` into a :class:`HubSpec`. Raises :class:`HubError` on a STRUCTURAL problem
|
|
175
|
+
(no ``[hub]`` table, a ``[[journey]]`` missing ``id``/``entry``, or a multi-campaign journey that needs the
|
|
176
|
+
assembler); semantic checks (id band, dup ids, missing ``borrow_bg`` ...) are :func:`validate_hub`'s job so
|
|
177
|
+
the CLI can print them all at once."""
|
|
178
|
+
p = Path(path)
|
|
179
|
+
with open(p, "rb") as fh:
|
|
180
|
+
data = tomllib.load(fh)
|
|
181
|
+
if "hub" not in data:
|
|
182
|
+
raise HubError(f"{p}: not a journeys manifest (no [hub] table)")
|
|
183
|
+
|
|
184
|
+
journeys = []
|
|
185
|
+
for i, j in enumerate(data.get("journey", [])):
|
|
186
|
+
if "campaigns" in j or isinstance(j.get("entry"), dict):
|
|
187
|
+
raise HubError(f"[[journey]] #{i}: a multi-campaign journey (campaigns / entry = {{campaign, "
|
|
188
|
+
f"field}}) needs the journey ASSEMBLER (`ff9mapkit assemble-journey`, "
|
|
189
|
+
f"docs/JOURNEYS.md), not gen-hub. gen-hub builds the single-entry form: "
|
|
190
|
+
f"entry = <field id>.")
|
|
191
|
+
if "id" not in j:
|
|
192
|
+
raise HubError(f"[[journey]] #{i}: missing required key 'id' (the stable slug; docs/JOURNEYS.md)")
|
|
193
|
+
jid = str(j["id"])
|
|
194
|
+
if "entry" not in j:
|
|
195
|
+
raise HubError(f"[[journey]] {jid!r}: missing required key 'entry' (the journey's entry field id)")
|
|
196
|
+
sc = j.get("set_scenario")
|
|
197
|
+
ent = j.get("entrance")
|
|
198
|
+
journeys.append(Journey(
|
|
199
|
+
id=jid,
|
|
200
|
+
name=str(j.get("name") or _humanize(jid)),
|
|
201
|
+
entry=int(j["entry"]),
|
|
202
|
+
set_scenario=int(sc) if sc is not None else None,
|
|
203
|
+
entrance=int(ent) if ent is not None else None,
|
|
204
|
+
))
|
|
205
|
+
|
|
206
|
+
return hubspec_from_table(data["hub"], journeys)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def validate_hub(spec: HubSpec) -> "tuple[list, list]":
|
|
210
|
+
"""Validate a :class:`HubSpec`. Returns ``(errors, warnings)`` -- errors abort generation, warnings are
|
|
211
|
+
advisory (like :func:`ff9mapkit.campaign.lint_campaign`). The emitted field.toml then runs the kit's own
|
|
212
|
+
:func:`ff9mapkit.build.validate` at build time; this catches the hub-spec-level problems early + clearly."""
|
|
213
|
+
errors, warnings = [], []
|
|
214
|
+
|
|
215
|
+
if not spec.name or not _NAME_RE.match(spec.name):
|
|
216
|
+
errors.append(f"[hub] name {spec.name!r} must be a non-empty token (A-Z, 0-9, _) -- it becomes "
|
|
217
|
+
f"EVT_<name> / FBG_N<area>_<name>")
|
|
218
|
+
if not (4000 <= spec.id <= 32767):
|
|
219
|
+
errors.append(f"[hub] id {spec.id} out of range -- custom field ids are 4000-32767 (the live "
|
|
220
|
+
f"fldMapNo is Int16, so a higher id registers but is unreachable)")
|
|
221
|
+
if spec.area < 10:
|
|
222
|
+
errors.append(f"[hub] area {spec.area} must be >= 10 -- the BG-borrow loader builds 'FBG_N<area>' "
|
|
223
|
+
f"and reads 2 digits, so single-digit areas black-screen")
|
|
224
|
+
if not spec.borrow_bg:
|
|
225
|
+
errors.append("[hub] borrow_bg is required -- BG-borrow a real room (area >= 10) for the hub "
|
|
226
|
+
"backdrop. Authoring novel hub art is out of the generator's scope (paint layers + a "
|
|
227
|
+
"custom [camera]/[walkmesh] by hand, then hand-author the field.toml).")
|
|
228
|
+
if not spec.camera:
|
|
229
|
+
errors.append("[hub] camera is required -- the borrowed room's .bgx (extract it from your install; "
|
|
230
|
+
"it's gitignored, you supply the bytes). Or set borrow_field = <id> and run "
|
|
231
|
+
"`gen-hub --extract-camera` to cache it automatically.")
|
|
232
|
+
if spec.borrow_field is not None and not (isinstance(spec.borrow_field, int) and spec.borrow_field > 0):
|
|
233
|
+
errors.append(f"[hub] borrow_field {spec.borrow_field!r} must be a positive real field id (the room "
|
|
234
|
+
f"whose camera --extract-camera pulls into the cache)")
|
|
235
|
+
if not spec.journeys:
|
|
236
|
+
errors.append("a hub needs at least one [[journey]] -- nothing to select")
|
|
237
|
+
|
|
238
|
+
seen: set = set()
|
|
239
|
+
for i, j in enumerate(spec.journeys):
|
|
240
|
+
if not j.id or not _NAME_RE.match(j.id):
|
|
241
|
+
errors.append(f"[[journey]] #{i}: id {j.id!r} must be a token (A-Z, 0-9, _) -- the stable slug")
|
|
242
|
+
elif j.id in seen:
|
|
243
|
+
errors.append(f"[[journey]] id {j.id!r} is duplicated -- journey ids must be unique")
|
|
244
|
+
seen.add(j.id)
|
|
245
|
+
if not (isinstance(j.entry, int) and j.entry > 0):
|
|
246
|
+
errors.append(f"[[journey]] {j.id!r}: entry {j.entry!r} must be a positive field id "
|
|
247
|
+
f"(the warp destination)")
|
|
248
|
+
elif j.entry == spec.id:
|
|
249
|
+
warnings.append(f"[[journey]] {j.id!r}: entry {j.entry} is the hub itself -- picking it warps "
|
|
250
|
+
f"the hub onto itself")
|
|
251
|
+
if j.set_scenario is not None and not (0 <= j.set_scenario <= SCENARIO_MAX):
|
|
252
|
+
errors.append(f"[[journey]] {j.id!r}: set_scenario {j.set_scenario} out of range "
|
|
253
|
+
f"(0-{SCENARIO_MAX})")
|
|
254
|
+
|
|
255
|
+
spawn = spec.player_spawn or [0, 0] # the narrator defaults to the player spawn -> they'd overlap
|
|
256
|
+
npos = spec.narrator_pos if spec.narrator_pos is not None else spawn
|
|
257
|
+
if list(npos) == list(spawn):
|
|
258
|
+
warnings.append(f"[hub] narrator_pos {list(npos)} == player_spawn (or unset) -- the player spawns "
|
|
259
|
+
f"INSIDE the narrator. Set distinct player_spawn / narrator_pos a few units apart on "
|
|
260
|
+
f"the walkmesh (e.g. player [404,127], narrator [480,127] for a Gargan Roo backdrop).")
|
|
261
|
+
|
|
262
|
+
if spec.text_block == SHADOWED_TEXT_BLOCK:
|
|
263
|
+
warnings.append(f"[hub] text_block {SHADOWED_TEXT_BLOCK} is SHADOWED by the FF9CustomMap folder in a "
|
|
264
|
+
f"stacked setup -- the menu shows that folder's text, not yours. Pick a distinct real "
|
|
265
|
+
f"MesDB id; deploy_field's shadow check suggests free ones.")
|
|
266
|
+
rows = len(spec.journeys) + 1 # + the trailing stay row
|
|
267
|
+
if rows > PAGING_SOFT_MAX:
|
|
268
|
+
warnings.append(f"{rows} menu rows (journeys + stay) -- FF9 choice menus show ~4 at a time and "
|
|
269
|
+
f"scroll; verify the long list reads well in-game (paging / sub-hubs are a future "
|
|
270
|
+
f"enhancement).")
|
|
271
|
+
|
|
272
|
+
# set-dressing structural checks (unknown prop/archetype NAMES are caught by build.validate on the
|
|
273
|
+
# emitted field.toml -- here we just ensure each row is well-formed so the emit produces valid TOML).
|
|
274
|
+
def _check_pos(label, row):
|
|
275
|
+
if not (isinstance(row.get("pos"), (list, tuple)) and len(row["pos"]) == 2):
|
|
276
|
+
errors.append(f"{label}: needs pos = [x, z] (a point on the hub walkmesh)")
|
|
277
|
+
for k, p in enumerate(spec.props):
|
|
278
|
+
if p.get("prop") is None and p.get("model") is None:
|
|
279
|
+
errors.append(f"[[hub.props]] #{k}: needs a 'prop' (archetype name, e.g. \"save_point\") or "
|
|
280
|
+
f"'model' (a GEO id) + optional 'pose'")
|
|
281
|
+
_check_pos(f"[[hub.props]] #{k}", p)
|
|
282
|
+
for k, n in enumerate(spec.ambient_npcs):
|
|
283
|
+
if n.get("archetype") is None and n.get("model") is None:
|
|
284
|
+
errors.append(f"[[hub.ambient_npcs]] #{k}: needs an 'archetype' (name, e.g. \"moogle\") or "
|
|
285
|
+
f"'model' (a GEO id)")
|
|
286
|
+
_check_pos(f"[[hub.ambient_npcs]] #{k}", n)
|
|
287
|
+
return errors, warnings
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _q(s) -> str:
|
|
291
|
+
"""A TOML-safe basic-string value (escape backslash + double-quote)."""
|
|
292
|
+
return str(s).replace("\\", "\\\\").replace('"', '\\"')
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _model_toml(v) -> str:
|
|
296
|
+
"""Emit a model value: a numeric id (or digit string) bare, a GEO name quoted."""
|
|
297
|
+
if isinstance(v, int):
|
|
298
|
+
return str(v)
|
|
299
|
+
s = str(v)
|
|
300
|
+
return s if s.isdigit() else f'"{_q(s)}"'
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _prop_block(p: dict) -> list:
|
|
304
|
+
"""A ``[[prop]]`` toml block from a ``[[hub.props]]`` row -- static set-dressing (a prop archetype by
|
|
305
|
+
name, e.g. ``save_point``/``lamp``, or a raw ``model`` + ``pose`` for a bare standing figure)."""
|
|
306
|
+
out = ["[[prop]]"]
|
|
307
|
+
if p.get("prop") is not None:
|
|
308
|
+
out.append(f'prop = "{_q(p["prop"])}"')
|
|
309
|
+
elif p.get("model") is not None:
|
|
310
|
+
out.append(f"model = {_model_toml(p['model'])}")
|
|
311
|
+
pos = p.get("pos") or [0, 0]
|
|
312
|
+
out.append(f"pos = [{int(pos[0])}, {int(pos[1])}]")
|
|
313
|
+
if p.get("pose") is not None:
|
|
314
|
+
out.append(f"pose = {_model_toml(p['pose'])}")
|
|
315
|
+
if p.get("face") is not None:
|
|
316
|
+
out.append(f"face = {int(p['face'])}")
|
|
317
|
+
out.append("")
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _ambient_npc_block(n: dict, idx: int) -> list:
|
|
322
|
+
"""A non-narrator ``[[npc]]`` block from a ``[[hub.ambient_npcs]]`` row -- a flavor character (talk -> its
|
|
323
|
+
``dialogue`` line, if any). Resolved by ``archetype`` name or raw ``model``."""
|
|
324
|
+
out = ["[[npc]]", f'name = "{_q(n.get("name") or f"Ambient_{idx}")}"']
|
|
325
|
+
if n.get("archetype") is not None:
|
|
326
|
+
out.append(f'archetype = "{_q(n["archetype"])}"')
|
|
327
|
+
elif n.get("model") is not None:
|
|
328
|
+
out.append(f"model = {_model_toml(n['model'])}")
|
|
329
|
+
pos = n.get("pos") or [0, 0]
|
|
330
|
+
out.append(f"pos = [{int(pos[0])}, {int(pos[1])}]")
|
|
331
|
+
if n.get("dialogue"):
|
|
332
|
+
out.append(f'dialogue = "{_q(n["dialogue"])}"')
|
|
333
|
+
out.append("")
|
|
334
|
+
return out
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _area_title_lines(spec) -> list:
|
|
338
|
+
"""Emit the area-title autohide when the hub BG-borrows a real AREA-TITLE room (Ice Cavern, Mognet
|
|
339
|
+
Central, ...): that room's localized title overlay is Active by default and the synthesized hub has no
|
|
340
|
+
donor ``.eb`` to retire it, so it would sit there statically claiming to be that place. Resolve the
|
|
341
|
+
overlay range OFFLINE (areatitle manifest) so the build needn't re-read resources.assets. Best-effort:
|
|
342
|
+
degrade to nothing if the manifest is unreachable (a non-area-title borrow returns nothing anyway)."""
|
|
343
|
+
try:
|
|
344
|
+
from . import areatitle
|
|
345
|
+
rng = areatitle.title_range(f"FBG_N{int(spec.area):02d}_{spec.borrow_bg}")
|
|
346
|
+
except Exception:
|
|
347
|
+
rng = None
|
|
348
|
+
if not rng:
|
|
349
|
+
return []
|
|
350
|
+
return [f"hide_area_title = true # borrows an area-title room but isn't that place --",
|
|
351
|
+
f"area_title_overlays = [{rng[0]}, {rng[1]}] # hide its title card (mapLocalizeAreaTitle.txt)"]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def render_hub_field_toml(spec: HubSpec, *, source: "str | None" = None) -> str:
|
|
355
|
+
"""The hub ``field.toml`` text -- valid TOML the existing build/deploy path compiles. Mirrors the proven
|
|
356
|
+
hand-authored ``examples/world_hub/hub.field.toml`` shape (BG-borrow + Moogle PC + narrator + the journey
|
|
357
|
+
``[[choice]]``). ``source`` (the journeys.toml name) is noted in the header comment."""
|
|
358
|
+
src = f" from {source}" if source else ""
|
|
359
|
+
cancel = len(spec.journeys) # the trailing stay row's 0-based index = number of journeys
|
|
360
|
+
spawn = spec.player_spawn or [0, 0]
|
|
361
|
+
npos = spec.narrator_pos or list(spawn)
|
|
362
|
+
L = [
|
|
363
|
+
"# ============================================================================",
|
|
364
|
+
f"# WORLD HUB -- generated by `ff9mapkit gen-hub`{src}.",
|
|
365
|
+
"# A journey selector: walk as the Moogle, talk to the narrator -> a menu of journeys ->",
|
|
366
|
+
"# each row warps you into that journey's entry field (the in-game-proven choice `warp`",
|
|
367
|
+
"# action + `[player] model=`). REGENERATE after editing the journeys.toml -- this file is a",
|
|
368
|
+
"# build artifact, hand edits are overwritten. (memory: project-ff9-world-hub)",
|
|
369
|
+
"# ============================================================================",
|
|
370
|
+
"",
|
|
371
|
+
"[field]",
|
|
372
|
+
f"id = {spec.id}",
|
|
373
|
+
f'name = "{_q(spec.name)}"',
|
|
374
|
+
f'borrow_bg = "{_q(spec.borrow_bg)}" # a real room as the backdrop (area >= 10)',
|
|
375
|
+
f"area = {spec.area}",
|
|
376
|
+
f"text_block = {spec.text_block} # a real MesDB id NOT shadowed by a higher mod folder",
|
|
377
|
+
*_area_title_lines(spec),
|
|
378
|
+
"",
|
|
379
|
+
"[camera]",
|
|
380
|
+
f'borrow = "{_q(spec.camera)}" # the borrowed room\'s camera (gitignored; extract from your install)',
|
|
381
|
+
*([f"entry_settle = {spec.entry_settle} # hold black on entry so the camera settles unseen "
|
|
382
|
+
f"(no warp-in ease); 0 = off"] if spec.entry_settle else []),
|
|
383
|
+
"",
|
|
384
|
+
"[player]",
|
|
385
|
+
f"spawn = [{spawn[0]}, {spawn[1]}]",
|
|
386
|
+
f"model = {_model_toml(spec.player_model)} # walk the hub as the Moogle ([player] model=)",
|
|
387
|
+
"",
|
|
388
|
+
"[[npc]]",
|
|
389
|
+
f'name = "{_q(spec.narrator)}" # the narrator -- talk to open the journey menu',
|
|
390
|
+
f"pos = [{npos[0]}, {npos[1]}]",
|
|
391
|
+
f"model = {_model_toml(spec.narrator_model_resolved)}",
|
|
392
|
+
"",
|
|
393
|
+
]
|
|
394
|
+
if spec.props or spec.ambient_npcs:
|
|
395
|
+
L.append("# Set-dressing (author-customizable via [[hub.props]] / [[hub.ambient_npcs]]): static props")
|
|
396
|
+
L.append("# + ambient flavor NPCs. All BG-borrow; the proven prop/npc build path compiles them.")
|
|
397
|
+
L.append("")
|
|
398
|
+
for p in spec.props:
|
|
399
|
+
L += _prop_block(p)
|
|
400
|
+
for k, n in enumerate(spec.ambient_npcs):
|
|
401
|
+
L += _ambient_npc_block(n, k)
|
|
402
|
+
L += [
|
|
403
|
+
"# The journey menu: each option warps to a journey's entry field; set_scenario (optional) seeds",
|
|
404
|
+
"# the beat hub-side before the warp. The trailing row has no warp -- it just closes the menu.",
|
|
405
|
+
"[[choice]]",
|
|
406
|
+
f'npc = "{_q(spec.narrator)}"',
|
|
407
|
+
f'prompt = "{_q(spec.prompt)}"',
|
|
408
|
+
f"cancel = {cancel} # B / cancel -> the last row (no warp)",
|
|
409
|
+
"instant = true # pop the menu fully drawn ([IMME]) -- a selector, like FF9 shop menus",
|
|
410
|
+
"",
|
|
411
|
+
]
|
|
412
|
+
for j in spec.journeys:
|
|
413
|
+
L.append("[[choice.options]]")
|
|
414
|
+
L.append(f'text = "{_q(j.name)}"')
|
|
415
|
+
L.append(f"warp = {j.entry}")
|
|
416
|
+
if j.set_scenario is not None:
|
|
417
|
+
L.append(f"set_scenario = {j.set_scenario}")
|
|
418
|
+
if j.entrance is not None:
|
|
419
|
+
L.append(f"entrance = {j.entrance} # arrival entrance -> frames the entry camera (no static frame)")
|
|
420
|
+
L.append("")
|
|
421
|
+
L.append("[[choice.options]]")
|
|
422
|
+
L.append(f'text = "{_q(spec.stay_text)}" # no warp -- closes the menu')
|
|
423
|
+
L.append("")
|
|
424
|
+
return "\n".join(L)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _relpath(target, start_dir) -> str:
|
|
428
|
+
"""A forward-slash path from ``start_dir`` to ``target`` -- repo-relative (portable across clones/OSes),
|
|
429
|
+
falling back to absolute only across Windows drives."""
|
|
430
|
+
target, start_dir = Path(target).resolve(), Path(start_dir).resolve()
|
|
431
|
+
try:
|
|
432
|
+
return Path(os.path.relpath(target, start_dir)).as_posix()
|
|
433
|
+
except ValueError: # different drives on Windows -> can't relativize
|
|
434
|
+
return target.as_posix()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def extract_camera_into_spec(spec: HubSpec, out_dir, *, game=None, force=False) -> dict:
|
|
438
|
+
"""Pull the ``[hub] borrow_field`` room's camera into the gitignored workspace cache and point
|
|
439
|
+
``spec.camera`` at that ONE central copy (a repo-relative path from ``out_dir``). Returns the
|
|
440
|
+
``extract.cache_field`` result. Shared by :func:`generate` (gen-hub) and the journey assembler's hub emit
|
|
441
|
+
(:func:`ff9mapkit.journey.generate_hub`) so both auto-provision the borrowed camera identically. Needs the
|
|
442
|
+
install + UnityPy."""
|
|
443
|
+
if not spec.borrow_field:
|
|
444
|
+
raise HubError("camera extraction needs [hub] borrow_field = <real field id> (the room whose camera "
|
|
445
|
+
"to extract; e.g. borrow_field = 950). Or supply the [hub] camera .bgx yourself.")
|
|
446
|
+
from . import extract as _extract
|
|
447
|
+
extracted = _extract.cache_field(spec.borrow_field, game=game, force=force)
|
|
448
|
+
spec.camera = _relpath(extracted["camera"], Path(out_dir)) # point at the ONE central cache copy
|
|
449
|
+
return extracted
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def generate(journeys_path, out_path=None, *, extract_camera=False, game=None, force=False) -> dict:
|
|
453
|
+
"""Load a ``journeys.toml``, validate it, and emit the hub ``field.toml``. Returns a summary
|
|
454
|
+
``{path, spec, warnings, journeys, extracted}``. Raises :class:`HubError` on a validation error.
|
|
455
|
+
``out_path`` defaults to ``hub.field.toml`` beside the registry; a directory ``out_path`` writes
|
|
456
|
+
``hub.field.toml`` inside it.
|
|
457
|
+
|
|
458
|
+
``extract_camera`` (needs the install + UnityPy): pull the borrowed room's camera (``[hub]
|
|
459
|
+
borrow_field``) into the gitignored workspace cache once and point the emitted ``[camera] borrow`` at
|
|
460
|
+
that single central copy -- so ``gen-hub`` then build/deploy "just works", no manual extract step."""
|
|
461
|
+
journeys_path = Path(journeys_path)
|
|
462
|
+
spec = load_journeys(journeys_path)
|
|
463
|
+
errors, warnings = validate_hub(spec)
|
|
464
|
+
if errors:
|
|
465
|
+
raise HubError("journeys.toml validation failed:\n - " + "\n - ".join(errors))
|
|
466
|
+
out_path = Path(out_path) if out_path else (journeys_path.parent / "hub.field.toml")
|
|
467
|
+
if out_path.is_dir():
|
|
468
|
+
out_path = out_path / "hub.field.toml"
|
|
469
|
+
|
|
470
|
+
extracted = None
|
|
471
|
+
if extract_camera:
|
|
472
|
+
extracted = extract_camera_into_spec(spec, out_path.parent, game=game, force=force)
|
|
473
|
+
|
|
474
|
+
text = render_hub_field_toml(spec, source=journeys_path.name)
|
|
475
|
+
out_path.write_text(text, encoding="utf-8", newline="\n")
|
|
476
|
+
return {"path": out_path, "spec": spec, "warnings": warnings, "journeys": len(spec.journeys),
|
|
477
|
+
"extracted": extracted}
|
ff9mapkit/idgated.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Engine behaviors keyed on a field's real ``fldMapNo``/``fldLocNo`` that a FORK loses on a custom id --
|
|
2
|
+
the **lost-on-a-mint** axis of the fork-fidelity taxonomy (``docs/FORK_FIDELITY.md``), made per-field
|
|
3
|
+
queryable so ``fork-report`` can preview it.
|
|
4
|
+
|
|
5
|
+
When you fork a field it runs at a new custom id (>= 4000), so every engine special-case gated on the real id
|
|
6
|
+
silently stops firing. Most are internal (camera/position fixups); the USER-VISIBLE ones a fork loses are:
|
|
7
|
+
|
|
8
|
+
* **Walkmesh hotfix** -- a load-time/dynamic ``BGI_triSetActive`` (catalogued + sometimes auto-reproduced in
|
|
9
|
+
:mod:`ff9mapkit.walkmesh_hotfixes`). Referenced here so the lost-on-mint list is one place.
|
|
10
|
+
* **Narrow-map letterbox** -- the engine letterboxes a field narrower than widescreen (NarrowMapList, a
|
|
11
|
+
per-field width table); a fork defaults to width 500 (widescreen), so the side masking is lost and off-screen
|
|
12
|
+
party can draw over where the bars were. Widths baked in :mod:`ff9mapkit._narrowmap_data`.
|
|
13
|
+
* **Chocobo dig HUD** -- the live Hot&Cold timer/HUD is gated on ``fldMapNo`` 2950-2952 (``EventHUD.cs``).
|
|
14
|
+
* **Intro FMV** -- the field-70 opening movie is id-bound.
|
|
15
|
+
* **ATE achievement** -- a field's ATEs count toward the *ATE80* trophy via ``EMinigame.MappingATEID``, which
|
|
16
|
+
keys on ``fldLocNo`` (the field's LOCATION). The engine sets ``fldLocNo = eventIDToMESID[fldMapNo]``
|
|
17
|
+
(``HonoluluFieldMain.cs:19``) -- i.e. the field's registered MES/text-block id -- so we resolve it from the
|
|
18
|
+
baked :data:`ff9mapkit._fieldtext.EVENT_ID_TO_MES`. A mint runs at a custom id with a different text-block,
|
|
19
|
+
so its ATEs don't map to the trophy. The ATE itself still PLAYS; only the achievement bookkeeping is lost.
|
|
20
|
+
|
|
21
|
+
This is pure baked data (no install needed) -- safe to call from the install-free analysis path.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from . import walkmesh_hotfixes as _wh
|
|
26
|
+
from ._fieldtext import EVENT_ID_TO_MES as _EVENT_TO_MES
|
|
27
|
+
from ._narrowmap_data import FORK_DEFAULT_WIDTH, WIDTHS as _WIDTHS
|
|
28
|
+
|
|
29
|
+
# fldLocNo == the field's MES id (HonoluluFieldMain.cs:19). These LOCATIONS have ATE-seen trophy mappings
|
|
30
|
+
# (EMinigame.MappingATEID, lines 532-669 -- all `fldLocNo == N` cases; Memoria source, provenance-clean).
|
|
31
|
+
ATE_ACHIEVEMENT_LOCS = frozenset({4, 8, 32, 37, 40, 44, 47, 52, 53, 70, 88, 90,
|
|
32
|
+
276, 289, 344, 358, 359, 485, 525, 595, 741, 943})
|
|
33
|
+
|
|
34
|
+
# ~16:9 of the 240px PSX height: a field narrower than this is letterboxed in-game, but a fork (width 500)
|
|
35
|
+
# renders widescreen, so the side letterbox masking is lost (the project-ff9-narrow-map-fork-letterbox bug).
|
|
36
|
+
WIDESCREEN_WIDTH = 426
|
|
37
|
+
CHOCOBO_HUD_FIELDS = frozenset({2950, 2951, 2952}) # EventHUD.cs: the live Chocobo Hot&Cold dig HUD
|
|
38
|
+
FMV_INTRO_FIELDS = frozenset({70}) # field-70 opening movie (Cinematic ops + MBG)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _as_id(field):
|
|
42
|
+
try:
|
|
43
|
+
return int(field)
|
|
44
|
+
except (TypeError, ValueError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def narrow_map_width(field) -> int:
|
|
49
|
+
"""The field's real PSX screen width (NarrowMapList), or the fork default (500) for an unlisted id."""
|
|
50
|
+
f = _as_id(field)
|
|
51
|
+
return _WIDTHS.get(f, FORK_DEFAULT_WIDTH) if f is not None else FORK_DEFAULT_WIDTH
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def loses_letterbox(field) -> bool:
|
|
55
|
+
"""True if the real field is narrower than widescreen, so a fork (default width 500) loses its letterbox."""
|
|
56
|
+
f = _as_id(field)
|
|
57
|
+
return f is not None and f in _WIDTHS and _WIDTHS[f] < WIDESCREEN_WIDTH
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def field_loc_no(field):
|
|
61
|
+
"""The field's ``fldLocNo`` (== its registered MES/text-block id, ``eventIDToMESID[fldMapNo]``), or None."""
|
|
62
|
+
f = _as_id(field)
|
|
63
|
+
return _EVENT_TO_MES.get(f) if f is not None else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def has_ate_achievement(field) -> bool:
|
|
67
|
+
"""True if the field's location has an ATE-seen trophy mapping (lost on a mint -- a different fldLocNo)."""
|
|
68
|
+
loc = field_loc_no(field)
|
|
69
|
+
return loc is not None and loc in ATE_ACHIEVEMENT_LOCS
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def lost_on_mint(field) -> list:
|
|
73
|
+
"""``[(label, detail), ...]`` for every USER-VISIBLE id-gated engine behavior a fork of ``field`` loses on
|
|
74
|
+
its custom id. Empty for most fields. The walkmesh entry notes whether the kit auto-reproduces it; the rest
|
|
75
|
+
steer to *fork in-place on the real id* (or accept the loss). Used by ``fork-report``."""
|
|
76
|
+
f = _as_id(field)
|
|
77
|
+
if f is None:
|
|
78
|
+
return []
|
|
79
|
+
out = []
|
|
80
|
+
h = _wh.info(f)
|
|
81
|
+
if h is not None:
|
|
82
|
+
if h.engine_remapped:
|
|
83
|
+
repro = "reproduced by the engine fork-donor remap"
|
|
84
|
+
elif h.auto:
|
|
85
|
+
repro = "auto-reproduced on fork"
|
|
86
|
+
else:
|
|
87
|
+
repro = "fork-in-place"
|
|
88
|
+
out.append(("walkmesh hotfix", f"{h.name} ({repro})"))
|
|
89
|
+
if loses_letterbox(f):
|
|
90
|
+
out.append(("narrow-map letterbox",
|
|
91
|
+
f"real width {_WIDTHS[f]} < widescreen; a fork renders widescreen "
|
|
92
|
+
f"(width {FORK_DEFAULT_WIDTH}) so the letterbox masking is lost"))
|
|
93
|
+
if f in CHOCOBO_HUD_FIELDS:
|
|
94
|
+
out.append(("Chocobo dig HUD", "the live Hot&Cold HUD is gated on fldMapNo 2950-2952 -> fork in-place"))
|
|
95
|
+
if f in FMV_INTRO_FIELDS:
|
|
96
|
+
out.append(("intro FMV", "the field-70 opening movie is id-bound -> retarget the stock field-70 override"))
|
|
97
|
+
if has_ate_achievement(f):
|
|
98
|
+
out.append(("ATE achievement",
|
|
99
|
+
f"this location (fldLocNo {field_loc_no(f)}) has an ATE-seen trophy (EMinigame.MappingATEID); "
|
|
100
|
+
f"a mint's different fldLocNo loses the ATE80 bookkeeping (the ATE still plays) -> fork in-place"))
|
|
101
|
+
return out
|