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/config.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Path resolution + the FF9/Memoria mod-folder layout.
|
|
2
|
+
|
|
3
|
+
This module is the single place that knows where things live on disk, replacing the
|
|
4
|
+
absolute paths hardcoded into a dozen of the original bespoke scripts. Game-path
|
|
5
|
+
resolution is, in priority order:
|
|
6
|
+
|
|
7
|
+
1. an explicit path passed in code / via the CLI ``--game`` flag
|
|
8
|
+
2. the ``FF9_GAME_PATH`` environment variable
|
|
9
|
+
3. a ``game_path`` key in the user config file ``~/.ff9mapkit.toml``
|
|
10
|
+
4. a small list of common Steam install locations (best-effort fallback)
|
|
11
|
+
|
|
12
|
+
Nothing here touches the game install; these are pure path computations. A ``ModLayout``
|
|
13
|
+
wraps a *mod root* (e.g. ``<game>/FF9CustomMap``) and yields the canonical sub-paths the
|
|
14
|
+
builder writes to. The same layout works whether the mod root is the live game folder or
|
|
15
|
+
a scratch/staging directory — that decoupling is what lets the builder be validated
|
|
16
|
+
offline (build into a temp dir, diff against the deployed assets).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import tomllib # Python 3.11+
|
|
27
|
+
except ModuleNotFoundError: # pragma: no cover - we require 3.11 but fail soft
|
|
28
|
+
tomllib = None # type: ignore[assignment]
|
|
29
|
+
|
|
30
|
+
# The seven shipped language folders. Field event scripts (.eb) and dialogue (.mes) are
|
|
31
|
+
# stored per-language; in practice the bytecode is identical across all seven and only the
|
|
32
|
+
# text differs, but we always write all seven so no locale loses the field.
|
|
33
|
+
LANGS: tuple[str, ...] = ("us", "uk", "fr", "gr", "it", "es", "jp")
|
|
34
|
+
|
|
35
|
+
# The mod folder Memoria reads first (highest override priority) on this project's install.
|
|
36
|
+
DEFAULT_MOD_FOLDER = "FF9CustomMap"
|
|
37
|
+
|
|
38
|
+
# User config file (TOML) — optional convenience so users set their game path once.
|
|
39
|
+
USER_CONFIG = Path.home() / ".ff9mapkit.toml"
|
|
40
|
+
|
|
41
|
+
# Best-effort fallbacks for a Steam install (Windows). Only used if nothing else resolves.
|
|
42
|
+
_COMMON_STEAM_PATHS = (
|
|
43
|
+
r"C:\Program Files (x86)\Steam\steamapps\common\FINAL FANTASY IX",
|
|
44
|
+
r"C:\Program Files\Steam\steamapps\common\FINAL FANTASY IX",
|
|
45
|
+
r"D:\SteamLibrary\steamapps\common\FINAL FANTASY IX",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConfigError(RuntimeError):
|
|
50
|
+
"""Raised when a required path cannot be resolved or does not exist."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_user_config() -> dict:
|
|
54
|
+
if tomllib is None or not USER_CONFIG.is_file():
|
|
55
|
+
return {}
|
|
56
|
+
try:
|
|
57
|
+
with USER_CONFIG.open("rb") as fh:
|
|
58
|
+
return tomllib.load(fh)
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_game_path(explicit: str | os.PathLike | None = None) -> Path:
|
|
64
|
+
"""Resolve the Final Fantasy IX install folder.
|
|
65
|
+
|
|
66
|
+
Order: explicit arg > $FF9_GAME_PATH > ~/.ff9mapkit.toml(game_path) > common Steam dirs.
|
|
67
|
+
Raises ConfigError with actionable guidance if none of them point at a real folder.
|
|
68
|
+
"""
|
|
69
|
+
candidates: list[Path] = []
|
|
70
|
+
if explicit:
|
|
71
|
+
candidates.append(Path(explicit))
|
|
72
|
+
env = os.environ.get("FF9_GAME_PATH")
|
|
73
|
+
if env:
|
|
74
|
+
candidates.append(Path(env))
|
|
75
|
+
cfg = _read_user_config().get("game_path")
|
|
76
|
+
if cfg:
|
|
77
|
+
candidates.append(Path(cfg))
|
|
78
|
+
candidates.extend(Path(p) for p in _COMMON_STEAM_PATHS)
|
|
79
|
+
|
|
80
|
+
for c in candidates:
|
|
81
|
+
if c.is_dir():
|
|
82
|
+
return c.resolve()
|
|
83
|
+
|
|
84
|
+
raise ConfigError(
|
|
85
|
+
"Could not locate the Final Fantasy IX install folder.\n"
|
|
86
|
+
"Set it one of these ways:\n"
|
|
87
|
+
" - pass --game \"<path>\" on the command line\n"
|
|
88
|
+
" - export FF9_GAME_PATH=\"<path>\"\n"
|
|
89
|
+
f" - add game_path = \"<path>\" to {USER_CONFIG}\n"
|
|
90
|
+
"The folder should contain FF9_Launcher.exe and a StreamingAssets directory."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def find_mod_root(game_path: Path, mod_folder: str = DEFAULT_MOD_FOLDER) -> Path:
|
|
95
|
+
"""The mod root inside the game install (created by the builder if absent)."""
|
|
96
|
+
return (game_path / mod_folder).resolve()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ModLayout:
|
|
101
|
+
"""Canonical sub-paths within a Memoria mod root.
|
|
102
|
+
|
|
103
|
+
`root` may be the live ``<game>/FF9CustomMap`` or any staging directory. All methods are
|
|
104
|
+
pure path joins (they neither read nor create anything) except ``ensure_dirs``.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
root: Path
|
|
108
|
+
|
|
109
|
+
# --- top-level registration files Memoria reads from the mod root ---
|
|
110
|
+
@property
|
|
111
|
+
def dictionary_patch(self) -> Path:
|
|
112
|
+
return self.root / "DictionaryPatch.txt"
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def battle_patch(self) -> Path:
|
|
116
|
+
return self.root / "BattlePatch.txt"
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def text_patch(self) -> Path:
|
|
120
|
+
"""Item/ability/card NAME + DESCRIPTION overrides (``TextPatch.txt``, a ``>DATABASE`` find/replace
|
|
121
|
+
patch -- ``Memoria.TextPatcher``). A per-mod-folder drop-in like the dictionary/battle patches,
|
|
122
|
+
read once at ``DataPatchers.Initialize`` -> a text change needs a RELAUNCH (not F6 Reload)."""
|
|
123
|
+
return self.root / "TextPatch.txt"
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def mod_description(self) -> Path:
|
|
127
|
+
return self.root / "ModDescription.xml"
|
|
128
|
+
|
|
129
|
+
# --- field background scene (camera/overlays + walkmesh + PNGs) ---
|
|
130
|
+
@property
|
|
131
|
+
def fieldmaps_dir(self) -> Path:
|
|
132
|
+
return self.root / "StreamingAssets" / "assets" / "resources" / "FieldMaps"
|
|
133
|
+
|
|
134
|
+
def fieldmap_dir(self, fbg_name: str) -> Path:
|
|
135
|
+
"""Folder holding ``<fbg>.bgx``, ``<fbg>.bgi.bytes`` and the overlay PNGs."""
|
|
136
|
+
return self.fieldmaps_dir / fbg_name
|
|
137
|
+
|
|
138
|
+
# --- battle background (BBG): a loose FBX + image#.png the engine loads instead of the bundle ---
|
|
139
|
+
@property
|
|
140
|
+
def battlemap_all_dir(self) -> Path:
|
|
141
|
+
# NOTE: the capitalized "Assets/Resources/BattleMap" segments are VERBATIM -- this exact casing
|
|
142
|
+
# round-tripped in-game (2026-06-09); do NOT lowercase it to match fieldmaps_dir above.
|
|
143
|
+
return (self.root / "StreamingAssets" / "Assets" / "Resources"
|
|
144
|
+
/ "BattleMap" / "BattleModel" / "battleMap_all")
|
|
145
|
+
|
|
146
|
+
def battlemap_dir(self, bbg: str) -> Path:
|
|
147
|
+
"""Folder holding ``<bbg>.fbx`` + its ``image#.png`` textures (the loose-FBX override slot)."""
|
|
148
|
+
return self.battlemap_all_dir / bbg
|
|
149
|
+
|
|
150
|
+
# --- minted battle SCENE assets (tier c). Paths VERBATIM-proven in-game (C1/C2, 2026-06-09);
|
|
151
|
+
# casing matches battlemap_all_dir above (capitalized BattleMap), not the lowercase field tree. ---
|
|
152
|
+
@property
|
|
153
|
+
def _battle_resources(self) -> Path:
|
|
154
|
+
return self.root / "StreamingAssets" / "Assets" / "Resources"
|
|
155
|
+
|
|
156
|
+
def battle_scene_dir(self, scene_name: str) -> Path:
|
|
157
|
+
"""``…/BattleMap/BattleScene/EVT_BATTLE_<NAME>`` — holds ``dbfile0000.raw16.bytes`` (gameplay) +
|
|
158
|
+
``<scene_id>.raw17.bytes`` (btlseq + camera)."""
|
|
159
|
+
return self._battle_resources / "BattleMap" / "BattleScene" / f"EVT_BATTLE_{scene_name}"
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def battle_info_dir(self) -> Path:
|
|
163
|
+
"""``…/BattleMap/BattleInfo`` — holds ``INB_B<N>.inb.bytes`` (BBGINFO: bbgnumber + anim flags)."""
|
|
164
|
+
return self._battle_resources / "BattleMap" / "BattleInfo"
|
|
165
|
+
|
|
166
|
+
def battle_eb_path(self, lang: str, scene_name: str) -> Path:
|
|
167
|
+
"""``…/CommonAsset/EventEngine/EventBinary/Battle/<lang>/EVT_BATTLE_<NAME>.eb.bytes`` (battle AI)."""
|
|
168
|
+
return (self._battle_resources / "CommonAsset" / "EventEngine" / "EventBinary"
|
|
169
|
+
/ "Battle" / lang / f"EVT_BATTLE_{scene_name}.eb.bytes")
|
|
170
|
+
|
|
171
|
+
def battle_text_dir(self, lang: str) -> Path:
|
|
172
|
+
"""``<root>/FF9_Data/embeddedasset/text/<lang>/battle`` — holds ``<scene_id>.mes`` (battle text)."""
|
|
173
|
+
return self.root / "FF9_Data" / "embeddedasset" / "text" / lang / "battle"
|
|
174
|
+
|
|
175
|
+
# --- field event scripts (.eb), one folder per language ---
|
|
176
|
+
@property
|
|
177
|
+
def eventbinary_field_dir(self) -> Path:
|
|
178
|
+
return (
|
|
179
|
+
self.root
|
|
180
|
+
/ "StreamingAssets" / "assets" / "resources" / "commonasset"
|
|
181
|
+
/ "eventengine" / "eventbinary" / "field"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def eb_path(self, lang: str, evt_name: str) -> Path:
|
|
185
|
+
"""``<root>/.../eventbinary/field/<lang>/<evt_name>`` (evt_name includes .eb.bytes)."""
|
|
186
|
+
return self.eventbinary_field_dir / lang / evt_name
|
|
187
|
+
|
|
188
|
+
def mapconfig_path(self, evt_name: str) -> Path:
|
|
189
|
+
"""``<root>/.../commonasset/mapconfigdata/<evt_name>.bytes`` -- the field's 3D-model LIGHTING config
|
|
190
|
+
(per-floor lights + shadows + per-object colors), loaded at field setup by the SAME event name as
|
|
191
|
+
the ``.eb`` (``MapConfiguration.LoadMapConfigData`` / ``fldmcf.cs``). Not per-language."""
|
|
192
|
+
return (self.root / "StreamingAssets" / "assets" / "resources" / "commonasset"
|
|
193
|
+
/ "mapconfigdata" / f"{evt_name}.bytes")
|
|
194
|
+
|
|
195
|
+
# --- dialogue text (.mes), one folder per language ---
|
|
196
|
+
def text_field_dir(self, lang: str) -> Path:
|
|
197
|
+
return self.root / "FF9_Data" / "embeddedasset" / "text" / lang / "field"
|
|
198
|
+
|
|
199
|
+
def mes_path(self, lang: str, mes_id: int) -> Path:
|
|
200
|
+
return self.text_field_dir(lang) / f"{mes_id}.mes"
|
|
201
|
+
|
|
202
|
+
# --- item / character DATA CSVs (mod-global; the engine merges/overrides them across FolderNames at
|
|
203
|
+
# new-game). Written at the mod-write stage from the entry field's [start_inventory]/[equipment]. ---
|
|
204
|
+
@property
|
|
205
|
+
def initial_items_csv(self) -> Path:
|
|
206
|
+
"""The new-game starting bag (``Data/Items/InitialItems.csv``). HIGHEST-priority-wins -> a mod must
|
|
207
|
+
write the FULL bag, and a stacked folder SHADOWS it (lint)."""
|
|
208
|
+
return self.root / "StreamingAssets" / "Data" / "Items" / "InitialItems.csv"
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def default_equipment_csv(self) -> Path:
|
|
212
|
+
"""Per-character starting equipment (``Data/Characters/DefaultEquipment.csv``). MERGED low->high by
|
|
213
|
+
the engine -> a partial delta (only the characters you change) works."""
|
|
214
|
+
return self.root / "StreamingAssets" / "Data" / "Characters" / "DefaultEquipment.csv"
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def shop_items_csv(self) -> Path:
|
|
218
|
+
"""Custom shop inventories (``Data/Items/ShopItems.csv``). MERGED by id low->high by the engine -> a
|
|
219
|
+
partial delta (only the custom shops, ids >= 32) works; the base supplies shops 0-31."""
|
|
220
|
+
return self.root / "StreamingAssets" / "Data" / "Items" / "ShopItems.csv"
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def synthesis_csv(self) -> Path:
|
|
224
|
+
"""Custom synthesis recipes (``Data/Items/Synthesis.csv`` = FF9MIX_DATA: Shops/Price/Result/Ingredients).
|
|
225
|
+
MERGED by id low->high (whole-row, ff9mix.LoadSynthesis) -> a partial delta works; the kit MINTS recipe
|
|
226
|
+
ids above the base max so it only ADDS recipes. A shop id opens as Synthesis iff it is absent from
|
|
227
|
+
ShopItems.csv (ff9buy.FF9Buy_GetType); a recipe shows at every shop id in its ``Shops`` list."""
|
|
228
|
+
return self.root / "StreamingAssets" / "Data" / "Items" / "Synthesis.csv"
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def weapons_csv(self) -> Path:
|
|
232
|
+
"""Weapon combat data (``Data/Items/Weapons.csv``: Power/Elements/Category...). MERGED by id low->high
|
|
233
|
+
(WHOLE-ROW replace) -> a partial delta (the base header + only the rows you tune) works."""
|
|
234
|
+
return self.root / "StreamingAssets" / "Data" / "Items" / "Weapons.csv"
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def armors_csv(self) -> Path:
|
|
238
|
+
"""Armor defence data (``Data/Items/Armors.csv``: P.Def/P.Eva/M.Def/M.Eva). MERGED by id low->high
|
|
239
|
+
(whole-row replace) -> a partial delta works."""
|
|
240
|
+
return self.root / "StreamingAssets" / "Data" / "Items" / "Armors.csv"
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def items_csv(self) -> Path:
|
|
244
|
+
"""Item info (``Data/Items/Items.csv``: Price/SellingPrice/equip...). MERGED by id low->high (whole-row
|
|
245
|
+
replace) -> a partial delta works. (NOT InitialItems.csv -- that's the new-game bag, highest-wins.)"""
|
|
246
|
+
return self.root / "StreamingAssets" / "Data" / "Items" / "Items.csv"
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def stats_csv(self) -> Path:
|
|
250
|
+
"""Equip stat bonuses + elemental affinity (``Data/Items/Stats.csv`` = ItemStats, keyed by BonusId).
|
|
251
|
+
MERGED by id low->high (whole-row replace, ff9equip.cs:26) -> a partial delta works; new minted bonus
|
|
252
|
+
rows just add entries. The input the level-up stat-growth accumulator reads (ff9play.cs:302-305)."""
|
|
253
|
+
return self.root / "StreamingAssets" / "Data" / "Items" / "Stats.csv"
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def item_effects_csv(self) -> Path:
|
|
257
|
+
"""Consumable use-effects (``Data/Items/ItemEffects.csv`` = ItemEffect, keyed by EffectId). MERGED by id
|
|
258
|
+
low->high (whole-row replace, ff9item.LoadItemEffects) -> a partial delta works; EffectId is 1:1 with a
|
|
259
|
+
usable item (no shared Empty row), so a row is edited in place. Power/Rate/Element/Status/Dead are the
|
|
260
|
+
gameplay knobs; the ScriptId (the behaviour) stays."""
|
|
261
|
+
return self.root / "StreamingAssets" / "Data" / "Items" / "ItemEffects.csv"
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def actions_csv(self) -> Path:
|
|
265
|
+
"""Shared player abilities (``Data/Battle/Actions.csv``). MERGED by id low->high (whole-row replace) ->
|
|
266
|
+
a partial delta (only the abilities you change) works; the base supplies the other 192 rows."""
|
|
267
|
+
return self.root / "StreamingAssets" / "Data" / "Battle" / "Actions.csv"
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def status_data_csv(self) -> Path:
|
|
271
|
+
"""Status definitions (``Data/Battle/StatusData.csv``). MERGED by id low->high (whole-row replace) ->
|
|
272
|
+
a partial delta (only the statuses you change) works; the base supplies the other 33 rows."""
|
|
273
|
+
return self.root / "StreamingAssets" / "Data" / "Battle" / "StatusData.csv"
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def status_sets_csv(self) -> Path:
|
|
277
|
+
"""Named multi-status BUNDLES (``Data/Battle/StatusSets.csv``) an action's ``status_index`` points at.
|
|
278
|
+
MERGED by id low->high -> a partial works (ids 0-38 are the base sets; use >=39 for a custom one)."""
|
|
279
|
+
return self.root / "StreamingAssets" / "Data" / "Battle" / "StatusSets.csv"
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def magic_sword_sets_csv(self) -> Path:
|
|
283
|
+
"""Combo-unlock sets (``Data/Battle/MagicSwordSets.csv``): a Supporter's abilities unlock a Beneficiary's
|
|
284
|
+
(Vivi -> Steiner's Magic Sword). MERGED by id low->high -> a partial (only the author's sets) works."""
|
|
285
|
+
return self.root / "StreamingAssets" / "Data" / "Battle" / "MagicSwordSets.csv"
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def base_stats_csv(self) -> Path:
|
|
289
|
+
"""Per-character base combat stats (``Data/Characters/BaseStats.csv``). MERGED by CharacterId low->high
|
|
290
|
+
-> a partial delta (only the characters you change) works; the base supplies the other 11."""
|
|
291
|
+
return self.root / "StreamingAssets" / "Data" / "Characters" / "BaseStats.csv"
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def character_parameters_csv(self) -> Path:
|
|
295
|
+
"""Per-character identity (``Data/Characters/CharacterParameters.csv``): row / category / menu-preset /
|
|
296
|
+
equip-set. MERGED by CharacterId low->high -> a partial delta works (the base supplies the rest)."""
|
|
297
|
+
return self.root / "StreamingAssets" / "Data" / "Characters" / "CharacterParameters.csv"
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def command_sets_csv(self) -> Path:
|
|
301
|
+
"""Per-character battle-menu command LAYOUTS (``Data/Characters/CommandSets.csv``), keyed by preset 0-19.
|
|
302
|
+
MERGED low->high -> a partial delta (only the presets you re-point) works."""
|
|
303
|
+
return self.root / "StreamingAssets" / "Data" / "Characters" / "CommandSets.csv"
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def leveling_csv(self) -> Path:
|
|
307
|
+
"""The 99-row growth curve (``Data/Characters/Leveling.csv``). HIGHEST-priority-wins (WHOLE-FILE, gated at
|
|
308
|
+
>=99 rows) -> a mod must emit the FULL 99-row file, and a stacked folder SHADOWS it (lint)."""
|
|
309
|
+
return self.root / "StreamingAssets" / "Data" / "Characters" / "Leveling.csv"
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def ability_gems_csv(self) -> Path:
|
|
313
|
+
"""Support-ability gem COSTS (``Data/Characters/Abilities/AbilityGems.csv``). MERGED per-SupportAbility
|
|
314
|
+
low->high -> a partial delta (only the abilities you re-cost) works; the base supplies the other 63."""
|
|
315
|
+
return self.root / "StreamingAssets" / "Data" / "Characters" / "Abilities" / "AbilityGems.csv"
|
|
316
|
+
|
|
317
|
+
def abilities_csv(self, preset_name: str) -> Path:
|
|
318
|
+
"""A per-preset learn list (``Data/Characters/Abilities/<CharacterPresetId>.csv``, e.g. ``Vivi.csv``).
|
|
319
|
+
WHOLE-FILE highest-priority-wins -> the kit re-emits the complete list. A METHOD (not a property): the
|
|
320
|
+
Abilities learn lists are a FILE SET (one per preset), like ``battle_eb_path(lang, scene)``."""
|
|
321
|
+
return self.root / "StreamingAssets" / "Data" / "Characters" / "Abilities" / f"{preset_name}.csv"
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def ability_features_txt(self) -> Path:
|
|
325
|
+
"""The ability-EFFECT DSL (``Data/Characters/Abilities/AbilityFeatures.txt``). MERGED per-ability
|
|
326
|
+
low->high (accumulator) -> a partial file (only the abilities you change) works; the base supplies the
|
|
327
|
+
rest. Authored from ``[[ability_feature]]`` (:mod:`battle.abilityfeatures`)."""
|
|
328
|
+
return self.root / "StreamingAssets" / "Data" / "Characters" / "Abilities" / "AbilityFeatures.txt"
|
|
329
|
+
|
|
330
|
+
def ensure_dirs(self, fbg_name: str | None = None, *, bbg: str | None = None,
|
|
331
|
+
langs: tuple[str, ...] = LANGS) -> None:
|
|
332
|
+
"""Create the directory skeleton a field (and/or battle-map) write needs."""
|
|
333
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
if fbg_name:
|
|
335
|
+
self.fieldmap_dir(fbg_name).mkdir(parents=True, exist_ok=True)
|
|
336
|
+
if bbg:
|
|
337
|
+
self.battlemap_dir(bbg).mkdir(parents=True, exist_ok=True)
|
|
338
|
+
for lang in langs:
|
|
339
|
+
(self.eventbinary_field_dir / lang).mkdir(parents=True, exist_ok=True)
|
|
340
|
+
self.text_field_dir(lang).mkdir(parents=True, exist_ok=True)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---- FBG (background scene) naming -------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
MIN_AREA = 10 # the area id must be >= 10: the FieldScene parser builds "FBG_N"+area with no
|
|
346
|
+
# zero-padding and the asset loader reads exactly 2 chars, so 00-09 black-screen.
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def fbg_name(area: int, name: str) -> str:
|
|
350
|
+
"""Build the canonical background-scene folder/key name, e.g. (11, 'HUT_EXT') -> 'FBG_N11_HUT_EXT'.
|
|
351
|
+
|
|
352
|
+
Raises ConfigError for single-digit areas (the zero-padding gotcha from the field plumbing).
|
|
353
|
+
"""
|
|
354
|
+
if area < MIN_AREA:
|
|
355
|
+
raise ConfigError(
|
|
356
|
+
f"area id must be >= {MIN_AREA} (got {area}). The FieldScene directive builds the "
|
|
357
|
+
f"background name as 'FBG_N{{area}}' with no zero-padding and the loader reads exactly "
|
|
358
|
+
f"two characters, so single-digit areas (00-09) fail to resolve and black-screen."
|
|
359
|
+
)
|
|
360
|
+
return f"FBG_N{area}_{name}"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Generalized field-script content injectors (operate on .eb bytes via the eb library).
|
|
2
|
+
|
|
3
|
+
npc - inject an NPC (model/anims/dialogue) + move the player spawn
|
|
4
|
+
gateway - inject a field-exit region trigger (warp to another field)
|
|
5
|
+
encounter - add random battles
|
|
6
|
+
reinit - add the after-battle handler (+ fast fade-in) custom fields need
|
|
7
|
+
music - field BGM on entry and after battle
|
|
8
|
+
text - author dialogue .mes entries (high-TXID, non-colliding)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import encounter, gateway, music, npc, reinit, text
|
|
12
|
+
|
|
13
|
+
__all__ = ["npc", "gateway", "encounter", "reinit", "music", "text"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Suppress a BG-borrowed field's inherited AREA-TITLE overlays -- the big "Ice Cavern" / "Mognet Central"
|
|
2
|
+
card.
|
|
3
|
+
|
|
4
|
+
The title is a range of scene OVERLAYS (indices from :mod:`ff9mapkit.areatitle`). In the real game the
|
|
5
|
+
DONOR field's own ``.eb`` owns the title's whole lifecycle: ``Main_Init`` hides the overlays at load, and a
|
|
6
|
+
*scenario-gated* block later shows + fades them (and, on a transition field, warps onward). A ``--verbatim``
|
|
7
|
+
fork carries that script byte-for-byte, so a forked field replays the stock show+fade on its own when the
|
|
8
|
+
journey seeds the trigger scenario -- the kit does NOT script the title for forks (doing so would double
|
|
9
|
+
the card and re-fire the donor's warp).
|
|
10
|
+
|
|
11
|
+
The gap this module fills is the OTHER case: a SYNTHESIZED field that BG-borrows an area-title room (the
|
|
12
|
+
World Hub borrows Mognet Central's room) inherits those overlays Active-by-default, with no donor ``.eb`` to
|
|
13
|
+
retire them -- so the title sits there statically claiming to be that place. :func:`hide` prepends
|
|
14
|
+
``ShowTile(i, 0)`` for the title overlays to ``Main_Init`` (entry-0 tag-0) so it never shows. A tag-0
|
|
15
|
+
prepend (``rel_off == 0``) is shift-safe even on jump-table donors; the injection is language-identical
|
|
16
|
+
and no-ops when the field has no area title. Mirrors :mod:`ff9mapkit.content.entry_settle`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ..eb import edit, opcodes
|
|
22
|
+
|
|
23
|
+
SHOWTILE = 0x5B # ShowTile / BGLACTIVE: ShowTile(overlayIdx, active) -- active 0 = hide, 1 = show
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hide(eb_bytes, start, end) -> bytes:
|
|
27
|
+
"""Prepend ``ShowTile(i, 0)`` for every overlay ``i`` in ``[start, end]`` to Main_Init (entry-0 tag-0)
|
|
28
|
+
so the area-title overlays are suppressed from the first frame. Returns the input unchanged when the
|
|
29
|
+
field has no title range (``start``/``end`` is ``None``). ``.eb``-language-identical (call once)."""
|
|
30
|
+
if start is None or end is None:
|
|
31
|
+
return eb_bytes
|
|
32
|
+
ovr = list(range(int(start), int(end) + 1))
|
|
33
|
+
if not ovr:
|
|
34
|
+
return eb_bytes
|
|
35
|
+
body = b"".join(opcodes.encode(SHOWTILE, i, 0) for i in ovr)
|
|
36
|
+
return edit.insert_in_function(eb_bytes, 0, 0, 0, body)
|
ff9mapkit/content/ate.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""``[[ate]]`` -- synthesize an Active Time Event (FF9's optional "Press SELECT" cutscene mechanism).
|
|
2
|
+
|
|
3
|
+
An ATE has three engine-visible parts (see ``docs/ATE_SYSTEM.md``); this module emits all of them onto
|
|
4
|
+
a custom field, byte-for-byte as the real Lindblum Main-St hub (field 552, the "Small-Town Knight" ATE):
|
|
5
|
+
|
|
6
|
+
Main_Init wiring (prepended):
|
|
7
|
+
ATE(mode) # 0xD7 -> EIcon.SetAIcon -> the blinking "Active Time Event" HUD prompt
|
|
8
|
+
<avail flag> = 1 # the author's own GLOB availability flag
|
|
9
|
+
InitCode(<menu slot>) # activate the menu code-entry (its tag-1 func then loops each frame)
|
|
10
|
+
|
|
11
|
+
the menu code-entry (appended):
|
|
12
|
+
func0 (tag 0): RETURN # trivial init
|
|
13
|
+
func1 (tag 1): the per-frame LOOP --
|
|
14
|
+
if ( usercontrol==1 AND <avail>==1 AND B_KEYON(SELECT) ) { # the real gate, field 552 [11667]
|
|
15
|
+
DisableMove ; EnableDialogChoices ; WindowSync(win, 64=winATE, prompt) ; # the "Select event" menu
|
|
16
|
+
<branch on GetChoose -> the picked row's body> ; EnableMove
|
|
17
|
+
}
|
|
18
|
+
RETURN
|
|
19
|
+
|
|
20
|
+
Engine facts grounding every byte (all verified): ``AICON=0xD7`` (the blink HUD), ``winATE=64`` ->
|
|
21
|
+
``CaptionType.ActiveTimeEvent``, ``GetChoose`` = sysvar 9, ``B_KEYON=0x4F`` (press-edge), ``Select=1u``,
|
|
22
|
+
sysvar 2 = ``usercontrol``. The menu + per-row branch reuse :mod:`ff9mapkit.content.choice` (the same
|
|
23
|
+
``GetChoose`` machinery) with ``flags=64`` so the window renders with the ATE caption; each row's body is the
|
|
24
|
+
ordinary event/choice action vocabulary (a narration line, a story-flag set, or a ``warp`` to the cutscene
|
|
25
|
+
field -- the real hub->destination pattern, e.g. Small-Town Knight -> ``Field(555)``).
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import struct
|
|
30
|
+
|
|
31
|
+
from ..eb import EbScript, edit, opcodes
|
|
32
|
+
from . import choice as _choice, region as _region
|
|
33
|
+
|
|
34
|
+
# Availability-flag band: GLOB bools, clear of events (8000), cutscene (8100), choice (8200). Each [[ate]]
|
|
35
|
+
# on a field claims ATE_FLAG_BASE + i so multiple ATEs don't collide.
|
|
36
|
+
ATE_FLAG_BASE = 8300
|
|
37
|
+
|
|
38
|
+
# ATE(mode) presets -- the arg is a 3-bit FLAG WORD, not an enum (see opcodes.ate / EIcon.cs:416-454 /
|
|
39
|
+
# docs/ATE_SYSTEM.md): >0 enable (0=off); &3==2 Gray (else Blue); &4 force-show (draw without user control).
|
|
40
|
+
MODE_BLUE = 1 # Blue, no force -- the optional Press-SELECT prompt (shows only with control). The [ate] HUB default.
|
|
41
|
+
MODE_GRAY = 2 # Gray, no force -- NEVER used in the real game (can't show during a no-control beat; use 6 for grey).
|
|
42
|
+
MODE_FORCE = 5 # Blue + force -- field 206's lone real site. AVOID for authoring: re-flashes the SELECT glyph (use 6).
|
|
43
|
+
|
|
44
|
+
WIN_ATE = 64 # ETb.winATE -> Dialog.CaptionType.ActiveTimeEvent
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_entry(funcs, etype: int = 0) -> bytes:
|
|
48
|
+
"""Assemble an entry body from ``funcs = [(tag, code_bytes), ...]``: ``[type][func_count]`` + the
|
|
49
|
+
func table (``tag:u16, fpos:u16`` each; ``fpos`` is relative to entryStart+2) + the concatenated code.
|
|
50
|
+
Mirrors the layout :class:`ff9mapkit.eb.model.EbScript` parses (so it round-trips)."""
|
|
51
|
+
fc = len(funcs)
|
|
52
|
+
fpos = fc * 4 # code starts right after the func table
|
|
53
|
+
table = bytearray()
|
|
54
|
+
code = bytearray()
|
|
55
|
+
for tag, c in funcs:
|
|
56
|
+
table += struct.pack("<HH", tag, fpos)
|
|
57
|
+
code += c
|
|
58
|
+
fpos += len(c)
|
|
59
|
+
return bytes([etype, fc]) + bytes(table) + bytes(code)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
JMP_OP = 0x01 # the undocumented UNCONDITIONAL jump (op_01): target = instr.end + signed-i16 offset
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def menu_loop_body(prompt_txid: int, option_bodies, *, avail_idx: int, window: int = 1,
|
|
66
|
+
setup: bytes = b"") -> bytes:
|
|
67
|
+
"""The menu entry's tag-1 LOOP body. It must POLL each frame, exactly as the real ATE menu does
|
|
68
|
+
(field 552 entry-7 tag-1)::
|
|
69
|
+
|
|
70
|
+
loop: if ( usercontrol AND avail AND B_KEYON(SELECT) ) { winATE menu }
|
|
71
|
+
Wait(1) # yield a frame -> one poll per frame (not a busy-spin)
|
|
72
|
+
JMP loop # op_01 unconditional jump back to the top; NO RETURN
|
|
73
|
+
|
|
74
|
+
The poller lives as long as the field is loaded (no terminate). ``setup`` is the optional
|
|
75
|
+
``EnableDialogChoices`` pre-choose opcode (default / cancel / hidden rows), from
|
|
76
|
+
:func:`ff9mapkit.content.choice.pre_choose`.
|
|
77
|
+
|
|
78
|
+
NB the earlier ``if(gate){menu} + RETURN`` form ran ONCE right after ``InitCode`` and never polled
|
|
79
|
+
again -- so SELECT did nothing. The ``Wait(1)`` + ``op_01`` jump-back is what makes it a real loop."""
|
|
80
|
+
menu = _choice.region_body(prompt_txid, option_bodies, window=window, flags=WIN_ATE, setup=setup)
|
|
81
|
+
gate = _region.cond_ate_select(_region.GLOB_BOOL, avail_idx)
|
|
82
|
+
body = _region.if_block(gate, menu) + opcodes.wait(1)
|
|
83
|
+
back = -(len(body) + 3) # op_01 is 3 bytes; jump back to the loop top (offset 0)
|
|
84
|
+
return body + bytes([JMP_OP]) + struct.pack("<h", back)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def menu_entry(prompt_txid: int, option_bodies, *, avail_idx: int, window: int = 1,
|
|
88
|
+
setup: bytes = b"") -> bytes:
|
|
89
|
+
"""The complete ATE menu CODE-ENTRY body (ready for :func:`ff9mapkit.eb.edit.append_entry`): a trivial
|
|
90
|
+
tag-0 init (RETURN) + the tag-1 polling loop (:func:`menu_loop_body`). InitCode'd from Main_Init."""
|
|
91
|
+
return _build_entry([(0, opcodes.RETURN),
|
|
92
|
+
(1, menu_loop_body(prompt_txid, option_bodies, avail_idx=avail_idx,
|
|
93
|
+
window=window, setup=setup))])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main_init_inject(*, avail_idx: int, menu_slot: int, mode: int = MODE_BLUE) -> bytes:
|
|
97
|
+
"""The Main_Init wiring, prepended to entry-0 tag-0: arm the blinking prompt, set the availability
|
|
98
|
+
flag, and activate the menu entry. ``ATE(mode) ; <avail>=1 ; InitCode(menu_slot)``."""
|
|
99
|
+
return (opcodes.ate(mode)
|
|
100
|
+
+ _region.set_var(_region.GLOB_BOOL, avail_idx, 1)
|
|
101
|
+
+ opcodes.init_code(menu_slot))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def inject_ate(data, prompt_txid: int, option_bodies, *, avail_idx: int = ATE_FLAG_BASE,
|
|
105
|
+
mode: int = MODE_BLUE, setup: bytes = b"") -> bytes:
|
|
106
|
+
"""Synthesize a complete ATE onto ``data`` (a field ``.eb``): append the SELECT-polling menu entry
|
|
107
|
+
(:func:`menu_entry`) into a free slot, then PREPEND the Main_Init wiring (:func:`main_init_inject` --
|
|
108
|
+
``ATE(mode)`` + set the avail flag + ``InitCode`` the menu slot) to entry-0 tag-0. Returns new ``.eb``
|
|
109
|
+
bytes. The prepend goes through :func:`ff9mapkit.eb.edit.insert_in_function`, which is boundary-safe even
|
|
110
|
+
on a Main_Init with a ``0x06`` scenario jump-table. No-op-safe: pass real ``option_bodies`` (one per row).
|
|
111
|
+
Default ``mode`` is Blue (mode 1, shows while the player has control -- the HUB convention). For a forced
|
|
112
|
+
auto-play use mode 6 (Gray+force) via ``[cutscene] ate``, not ``MODE_FORCE`` (5, Blue+force) -- a force-shown
|
|
113
|
+
Blue icon re-flashes the SELECT glyph during auto-play (mode arg = a 3-bit flag word; see EIcon.cs:416-454)."""
|
|
114
|
+
out = data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
|
|
115
|
+
entry = menu_entry(prompt_txid, option_bodies, avail_idx=avail_idx, setup=setup)
|
|
116
|
+
slot = EbScript.from_bytes(out).first_free_slot()
|
|
117
|
+
out = edit.append_entry(out, slot, entry)
|
|
118
|
+
return edit.insert_in_function(out, 0, 0, 0, main_init_inject(avail_idx=avail_idx, menu_slot=slot, mode=mode))
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Camera-services / scrolling control (the ``BGCACTIVE`` opcode).
|
|
2
|
+
|
|
3
|
+
Larger-than-screen fields scroll the view to follow the player. Memoria's 3D scroll
|
|
4
|
+
(``FieldMap.SceneService3DScroll``) runs automatically for a walkable field, but only when the
|
|
5
|
+
field's ``Active`` flag is set — and that flag is set by the field-script opcode
|
|
6
|
+
``EnableCameraServices`` (``BGCACTIVE`` = 0x71, args ``isActive, frameCount, sinusOrLinear``).
|
|
7
|
+
|
|
8
|
+
A field cloned from a static (non-scrolling) base never calls it, so :func:`enable_camera_services`
|
|
9
|
+
injects ``EnableCameraServices(1, 0, 0)`` at the start of Main_Init. Proven in-game on the 768x448
|
|
10
|
+
scroll spike (field 4003): with the wide ``Range`` + scroll ``Viewport`` (see
|
|
11
|
+
:func:`ff9mapkit.scene.cam.scroll_bounds`) the view then pans + clamps cleanly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import struct
|
|
17
|
+
|
|
18
|
+
from ..eb import EbScript, edit, opcodes
|
|
19
|
+
from . import region as _region
|
|
20
|
+
|
|
21
|
+
BGCACTIVE_OP = 0x71 # EnableCameraServices
|
|
22
|
+
|
|
23
|
+
# The camera-state flag. VAR_GlobUInt8_24 is exactly what the real field (Gargan Roo/Passage) uses;
|
|
24
|
+
# our init entry resets it to 0 on every field load, so it never collides across loads.
|
|
25
|
+
DEFAULT_FLAG = (_region.GLOB_UINT8, 24)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def enable_camera_services(eb_bytes, *, frame_count: int = 0, scroll_type: int = 0) -> bytes:
|
|
29
|
+
"""Insert ``EnableCameraServices(1, frame_count, scroll_type)`` at the start of Main_Init.
|
|
30
|
+
|
|
31
|
+
``frame_count`` = duration (frames) of the camera's reposition-to-player when it activates
|
|
32
|
+
(0 = instant; -1 defaults to 30 in the engine). ``scroll_type`` = 8 for sinusoidal, else linear.
|
|
33
|
+
Uses :func:`edit.insert_bytes` (relocates jumps/fpos), so it is safe alongside other injectors.
|
|
34
|
+
"""
|
|
35
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
36
|
+
f = eb.entry(0).func_by_tag(0)
|
|
37
|
+
if f is None:
|
|
38
|
+
raise ValueError("entry 0 has no Main_Init (tag 0) to enable camera services in")
|
|
39
|
+
code = opcodes.encode(BGCACTIVE_OP, 1, int(frame_count), int(scroll_type))
|
|
40
|
+
return edit.insert_bytes(eb_bytes, f.abs_start, code)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- multi-camera switch
|
|
44
|
+
# Generalizes the real-field convention (decoded byte-for-byte from Gargan Roo/Passage,
|
|
45
|
+
# evt_gargan_gr_lef_0) to N cameras via an AREA model: a state flag holds the CURRENT camera index,
|
|
46
|
+
# and each zone owns the floor area where its camera should be active. Entering a zone for camera K
|
|
47
|
+
# while flag != K switches to camera K, stores K in the flag, and re-tunes movement
|
|
48
|
+
# (SetControlDirection for K's yaw). The flag guard stops re-firing while you stand in a zone;
|
|
49
|
+
# NON-OVERLAPPING zones can't flap. An init code-entry resets the flag to 0 + arms every zone on
|
|
50
|
+
# field load (state is consistent on entry); after a battle (Main_Init doesn't run) the tag-10
|
|
51
|
+
# restore (:func:`add_camera_restore`) re-applies the stored camera + movement.
|
|
52
|
+
|
|
53
|
+
REINIT_TAG = 10
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _zone_body(to_camera: int, control_value: int, flag) -> bytes:
|
|
57
|
+
"""A camera zone's Range body: movement-gate, then `if (flag != to_camera) { SetFieldCamera;
|
|
58
|
+
set flag = to_camera; SetControlDirection }`."""
|
|
59
|
+
flag_class, flag_idx = flag
|
|
60
|
+
actions = (opcodes.set_field_camera(to_camera)
|
|
61
|
+
+ _region.set_var(flag_class, flag_idx, to_camera)
|
|
62
|
+
+ opcodes.set_control_direction(control_value, control_value))
|
|
63
|
+
return (_region.MOVEMENT_GATE
|
|
64
|
+
+ _region.if_not_block(_region.cond_eq(flag_class, flag_idx, to_camera), actions)
|
|
65
|
+
+ opcodes.RETURN)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def inject_camera_zones(data, zones, control_values, *, flag=DEFAULT_FLAG, spawn_wait_n: int = 2,
|
|
69
|
+
spawn_wait_occurrence: int = 0) -> bytes:
|
|
70
|
+
"""Inject N camera-switch zones (the area model). Returns new .eb bytes.
|
|
71
|
+
|
|
72
|
+
``zones`` = list of ``(to_camera, [4 (x, z) corners])``; ``control_values[k]`` = the
|
|
73
|
+
SetControlDirection (TWIST) value for camera ``k`` (derive from its yaw). Each zone owns the floor
|
|
74
|
+
area where its camera is active; standing in it sets that camera. Zones SHOULD NOT overlap
|
|
75
|
+
(overlapping zones flap). Needs ``len(zones) + 1`` free entry slots (the zones + one load-time
|
|
76
|
+
init/arm entry that resets the flag to 0 and arms them all)."""
|
|
77
|
+
flag_class, flag_idx = flag
|
|
78
|
+
zones = list(zones)
|
|
79
|
+
eb = EbScript.from_bytes(data)
|
|
80
|
+
if len(eb.free_slots()) < len(zones) + 1:
|
|
81
|
+
raise ValueError(f"need {len(zones) + 1} free entry slots for {len(zones)} camera zones, "
|
|
82
|
+
f"have {len(eb.free_slots())}")
|
|
83
|
+
out = data
|
|
84
|
+
slots = []
|
|
85
|
+
for to_camera, corners in zones:
|
|
86
|
+
body = _zone_body(int(to_camera), int(control_values[int(to_camera)]), flag)
|
|
87
|
+
out, slot = _region.inject_region(out, [tuple(p) for p in corners], body, activate=False)
|
|
88
|
+
slots.append(slot)
|
|
89
|
+
# init/arm entry: reset flag = 0 (camera 0 at load) + arm every zone.
|
|
90
|
+
init_body = _region.set_var(flag_class, flag_idx, 0)
|
|
91
|
+
for s in slots:
|
|
92
|
+
init_body += opcodes.init_region(s, 0)
|
|
93
|
+
init_body += opcodes.RETURN
|
|
94
|
+
init_entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + init_body
|
|
95
|
+
init_slot = EbScript.from_bytes(out).first_free_slot()
|
|
96
|
+
out = edit.append_entry(out, init_slot, init_entry)
|
|
97
|
+
out = edit.activate(out, opcodes.init_code(init_slot, 0), spawn_wait_n=spawn_wait_n,
|
|
98
|
+
spawn_wait_occurrence=spawn_wait_occurrence)
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def add_camera_restore(data, cameras_used, control_values, *, flag=DEFAULT_FLAG) -> bytes:
|
|
103
|
+
"""Add an after-battle camera restore to Main_Reinit (tag 10). Returns new .eb bytes.
|
|
104
|
+
|
|
105
|
+
For each non-zero camera ``K`` in ``cameras_used``: ``if (flag == K) { SetFieldCamera(K);
|
|
106
|
+
SetControlDirection(K) }``. After a battle the field runs tag-10 (NOT Main_Init, so the flag isn't
|
|
107
|
+
reset) -- this re-applies the camera + movement the player was on. Requires an existing tag-10
|
|
108
|
+
(``content.reinit.add_reinit`` / an encounter); a no-op if no non-zero camera is used."""
|
|
109
|
+
flag_class, flag_idx = flag
|
|
110
|
+
eb = EbScript.from_bytes(data)
|
|
111
|
+
f = eb.entry(0).func_by_tag(REINIT_TAG)
|
|
112
|
+
if f is None:
|
|
113
|
+
raise ValueError("entry 0 has no tag-10 handler (run content.reinit.add_reinit first)")
|
|
114
|
+
restore = b""
|
|
115
|
+
for k in sorted({int(c) for c in cameras_used}):
|
|
116
|
+
if k == 0:
|
|
117
|
+
continue
|
|
118
|
+
actions = opcodes.set_field_camera(k) + opcodes.set_control_direction(
|
|
119
|
+
int(control_values[k]), int(control_values[k]))
|
|
120
|
+
restore += _region.if_block(_region.cond_eq(flag_class, flag_idx, k), actions)
|
|
121
|
+
if not restore:
|
|
122
|
+
return data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
|
|
123
|
+
return edit.insert_bytes(data, f.abs_start, restore)
|