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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""SAME-LENGTH raw17 ``btlseq`` operand patches -- the first sequence *authoring* step (read = :mod:`seqdis`).
|
|
2
|
+
|
|
3
|
+
The safest sequence edit is a *literal* one: change one operand in place without moving any bytes -- retime a
|
|
4
|
+
``Wait``/``MoveTo*`` frame count, swap an ``Anim`` code or a ``SetCamera`` id, tweak a ``Scale``/``FadeOut``
|
|
5
|
+
value. No length change means no ``seqOffset``/``camOffset`` repack and no risk of mis-packing -- byte-accurate by
|
|
6
|
+
construction (the seqcodec identity holds), exactly like :mod:`aipatch` (enemy AI) and ``scene_data`` (raw16).
|
|
7
|
+
|
|
8
|
+
Addressing is by BYTE OFFSET (from ``battle-seq --sites``) + a required OLD-value guard: the patch only applies
|
|
9
|
+
if the operand at that offset currently equals ``old`` (a stale/wrong offset fails LOUD instead of corrupting a
|
|
10
|
+
byte), and ``new`` must fit the SAME field (width + signedness). raw17 bytecode is language-independent, so a
|
|
11
|
+
forked scene ships one raw17 for all languages -- the patch applies once.
|
|
12
|
+
|
|
13
|
+
This reaches OPERAND LITERALS only (frame counts, ids, masks, coords). Length-changing edits (inserting/removing
|
|
14
|
+
instructions, a new sequence) are the deferred assembler/codec-repack tier (:mod:`seqcodec` provides the model).
|
|
15
|
+
The 0x19 ``Sfx`` discarded-pad byte is NOT a site (the engine ignores it); the Move ``Next`` advance is not in the
|
|
16
|
+
bytes at all (hard-coded in the engine) so there is no Next site to patch.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from . import seqcodec as _sc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SeqPatchError(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Site:
|
|
31
|
+
"""One patchable operand in a sequence body."""
|
|
32
|
+
sub_no: int # the canonical sub_no (attack index) owning this body (shared bodies: the first slot)
|
|
33
|
+
offset: int # absolute byte offset of the operand's first byte (the ``at`` you cite)
|
|
34
|
+
width: int # 1 or 2
|
|
35
|
+
signed: bool
|
|
36
|
+
kind: str # frames | anim_code | camera | vfx | svfx | sfx | mesh_mask | message | coord | ...
|
|
37
|
+
value: int # the current decoded value (signed per the field)
|
|
38
|
+
where: str # human context, e.g. "sub0 Anim anim_code"
|
|
39
|
+
shared_subs: tuple = () # ALL sub_nos whose seqOffset aliases this body (>1 => a SHARED body: a patch here
|
|
40
|
+
# rewrites every one of them; 289/562 real scenes alias one body across several slots)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def vmin(self) -> int:
|
|
44
|
+
return -(1 << (8 * self.width - 1)) if self.signed else 0
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def vmax(self) -> int:
|
|
48
|
+
return (1 << (8 * self.width - 1)) - 1 if self.signed else (1 << (8 * self.width)) - 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _canonical_sub(model: _sc.Raw17, body: _sc.Body) -> int:
|
|
52
|
+
for sub in range(model.seq_count):
|
|
53
|
+
if model.seq_offset[sub] == body.offset:
|
|
54
|
+
return sub
|
|
55
|
+
return -1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def constant_sites(raw17: bytes) -> list:
|
|
59
|
+
"""Every patchable operand in a raw17's sequence bodies, in byte order. The ``offset`` of each is the ``at``
|
|
60
|
+
you cite in a ``[[scene.seq_patch]]``; ``battle-seq --sites`` prints them. The 0x19 discarded-pad byte and
|
|
61
|
+
terminator/no-operand opcodes contribute no sites."""
|
|
62
|
+
try:
|
|
63
|
+
model = _sc.parse(raw17)
|
|
64
|
+
except _sc.SeqCodecError as ex:
|
|
65
|
+
raise SeqPatchError(f"malformed raw17: {ex}")
|
|
66
|
+
out = []
|
|
67
|
+
for body in model.bodies:
|
|
68
|
+
sub = _canonical_sub(model, body)
|
|
69
|
+
shared = tuple(s for s in range(model.seq_count) if model.seq_offset[s] == body.offset)
|
|
70
|
+
for ins in body.instrs:
|
|
71
|
+
for (name, rel, w, signed, kind), val in zip(ins.fields, ins.operands):
|
|
72
|
+
if kind == "pad":
|
|
73
|
+
continue
|
|
74
|
+
out.append(Site(sub, ins.offset + rel, w, signed, kind, val,
|
|
75
|
+
f"sub{sub} {ins.name} {name}", shared))
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_PATCH_KEYS = {"at", "old", "new", "seq"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_seq_patches(raw17: bytes, patches) -> tuple:
|
|
83
|
+
"""Apply ``[{at, old, new, seq?}, ...]`` same-length operand patches to ``raw17``. Each ``at`` must be a real
|
|
84
|
+
operand site whose current value == ``old`` (the guard) and whose field fits ``new``. Returns (patched, warns).
|
|
85
|
+
Raises SeqPatchError on a bad offset / old-mismatch / range-overflow -- so a wrong patch fails the build,
|
|
86
|
+
never the game."""
|
|
87
|
+
if not isinstance(patches, list):
|
|
88
|
+
raise SeqPatchError("[[scene.seq_patch]] must be a list of tables")
|
|
89
|
+
sites = {s.offset: s for s in constant_sites(raw17)}
|
|
90
|
+
b = bytearray(raw17)
|
|
91
|
+
warnings: list = []
|
|
92
|
+
seen: dict = {}
|
|
93
|
+
for n, p in enumerate(patches):
|
|
94
|
+
if not isinstance(p, dict):
|
|
95
|
+
raise SeqPatchError(f"[[scene.seq_patch]] #{n} must be a table (got {type(p).__name__})")
|
|
96
|
+
unknown = set(p) - _PATCH_KEYS
|
|
97
|
+
if unknown:
|
|
98
|
+
raise SeqPatchError(f"[[scene.seq_patch]] #{n}: unknown key(s) {sorted(unknown)} "
|
|
99
|
+
f"(expected at / old / new / seq) -- typo?")
|
|
100
|
+
at, old, new = p.get("at"), p.get("old"), p.get("new")
|
|
101
|
+
for k, v in (("at", at), ("old", old), ("new", new)):
|
|
102
|
+
if not isinstance(v, int) or isinstance(v, bool):
|
|
103
|
+
raise SeqPatchError(f"[[scene.seq_patch]] #{n} needs integer {k} (at = offset, old/new = values)")
|
|
104
|
+
site = sites.get(at)
|
|
105
|
+
if site is None:
|
|
106
|
+
raise SeqPatchError(f"[[scene.seq_patch]] #{n}: no patchable operand at offset {at} "
|
|
107
|
+
f"(cite an offset from `battle-seq --sites`)")
|
|
108
|
+
seq = p.get("seq")
|
|
109
|
+
if seq is not None and seq not in site.shared_subs: # a wrong seq number (typo), NOT an alias
|
|
110
|
+
owners = "/".join(str(s) for s in site.shared_subs)
|
|
111
|
+
warnings.append(f"[[scene.seq_patch]] #{n}: seq={seq} does not own offset {at} (owned by sub {owners}) "
|
|
112
|
+
f"-- check the seq/at numbers")
|
|
113
|
+
if len(site.shared_subs) > 1: # a genuinely SHARED body: the edit hits
|
|
114
|
+
owners = ",".join(str(s) for s in site.shared_subs) # every aliasing attack, regardless of seq
|
|
115
|
+
warnings.append(f"[[scene.seq_patch]] #{n}: offset {at} drives a SHARED sequence body (subs {owners}) "
|
|
116
|
+
f"-- this edit changes the choreography of ALL of them")
|
|
117
|
+
if at in seen:
|
|
118
|
+
warnings.append(f"[[scene.seq_patch]] #{n} and #{seen[at]} both patch offset {at} -- the later wins")
|
|
119
|
+
seen[at] = n
|
|
120
|
+
if site.value != old:
|
|
121
|
+
raise SeqPatchError(f"[[scene.seq_patch]] #{n}: expected old = {old} at offset {at}, but the raw17 has "
|
|
122
|
+
f"{site.value} ({site.where}) -- wrong offset, or already patched?")
|
|
123
|
+
if not site.vmin <= new <= site.vmax:
|
|
124
|
+
raise SeqPatchError(f"[[scene.seq_patch]] #{n}: new = {new} does not fit the {site.width}-byte "
|
|
125
|
+
f"{'signed' if site.signed else 'unsigned'} {site.kind} operand at offset {at} "
|
|
126
|
+
f"({site.vmin}..{site.vmax}) -- a same-length patch can't widen it")
|
|
127
|
+
b[at:at + site.width] = int(new).to_bytes(site.width, "little", signed=site.signed)
|
|
128
|
+
return bytes(b), warnings
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_patches(raw17: bytes, patches) -> list:
|
|
132
|
+
"""Offline problems (empty => OK): re-run the patch on a copy and surface any SeqPatchError as a message."""
|
|
133
|
+
try:
|
|
134
|
+
apply_seq_patches(raw17, patches)
|
|
135
|
+
return []
|
|
136
|
+
except SeqPatchError as ex:
|
|
137
|
+
return [str(ex)]
|
ff9mapkit/battle_bgm.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Donor battle BGM, read LIVE from the install (provenance-clean): the ``(field, scene) -> song`` map.
|
|
2
|
+
|
|
3
|
+
FF9 chooses a field battle's song by ``(originMap, battleId)`` == ``(fldMapNo, entered-scene)`` from
|
|
4
|
+
``EmbeddedAsset/Manifest/Sounds/BtlEncountBgmMetaData.txt`` (``FF9SndMetaData.GetMusicForBattle`` /
|
|
5
|
+
``BattleSwirl.RequestPlayBattleEncounterSongForField``). ``fldMapNo`` is the FIELD id; ``battleId`` is the
|
|
6
|
+
``nextMapNo`` the field actually ENTERS -- for a RANDOM encounter that's the chosen ``SetRandomBattles``
|
|
7
|
+
scene, for a SCRIPTED battle it's the explicit ``Battle(0x2A)`` scene. A field forked to a custom id
|
|
8
|
+
(>= 4000) is NOT in the map, the donor song is lost (``GetMusicForBattle`` returns ``-1``), and the kit's
|
|
9
|
+
forced ``Music: 0`` then pins the encounter to the generic Battle Theme. The kit reproduces a song by
|
|
10
|
+
emitting a ``Music: <song>`` BattlePatch line: that populates ``FF9SndMetaData.BtlBgmPatcherMapper[scene]``,
|
|
11
|
+
which is keyed on the SCENE id the fork KEEPS (not the field id), so it wins regardless of the custom
|
|
12
|
+
origin. This is the same id-gated-table-lost-on-a-mint family as the walkmesh hotfixes and the narrow-map
|
|
13
|
+
width table, but reproducible because the override is scene-keyed.
|
|
14
|
+
|
|
15
|
+
★ SCOPE: the live map's NON-zero songs (e.g. the boss/special theme 35) ALL belong to SCRIPTED-battle
|
|
16
|
+
scenes; every random-encounter field maps to song 0. So ``import`` prefilling ``[encounter] battle_music``
|
|
17
|
+
from the donor's RANDOM primary scene (``extract._donor_battle_song``) is correct but inert for the shipping
|
|
18
|
+
game -- it can only ever read a 0. Carrying a donor's SPECIAL battle theme means looking up the donor's
|
|
19
|
+
SCRIPTED ``Battle(0x2A)`` scene id (decoded from the carried ``.eb`` of a ``--verbatim`` fork), which the
|
|
20
|
+
declarative ``[encounter]`` block never sees -- a separate follow-up (docs/FORK_FIDELITY.md #6).
|
|
21
|
+
|
|
22
|
+
The metadata is a Square-Enix ``TextAsset`` inside ``FF9_Data/resources.assets`` (NOT Memoria source), so it
|
|
23
|
+
is read LIVE from the install and cached in-memory, **shipping/committing NOTHING** -- the same
|
|
24
|
+
provenance-clean live pattern as :mod:`ff9mapkit.keyitems` / :mod:`ff9mapkit.itemstats`. If the asset isn't
|
|
25
|
+
reachable (no install, no UnityPy, asset moved/renamed), every accessor returns ``None`` and the caller
|
|
26
|
+
degrades gracefully (``battle_music`` stays unset -> the build's default 0).
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
|
|
32
|
+
ASSET_NAME = "BtlEncountBgmMetaData.txt" # the field-map encounter-BGM TextAsset in resources.assets
|
|
33
|
+
|
|
34
|
+
_CACHE = None # {int field_id: {int scene_id: int song_id}}, or the _MISS sentinel
|
|
35
|
+
_MISS = object() # distinguishes "tried, unreachable" from "not yet loaded" (None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resources_assets(game=None):
|
|
39
|
+
"""Path to the install's ``resources.assets`` (the Unity data file holding the BGM TextAsset), or ``None``.
|
|
40
|
+
The 64-bit build's data lives under ``x64/FF9_Data``; fall back to ``x86`` then a flat ``FF9_Data``."""
|
|
41
|
+
from .config import find_game_path
|
|
42
|
+
try:
|
|
43
|
+
root = find_game_path(game)
|
|
44
|
+
except Exception: # noqa: BLE001 -- install not resolvable -> degrade
|
|
45
|
+
return None
|
|
46
|
+
for sub in ("x64", "x86", ""):
|
|
47
|
+
p = (root / sub / "FF9_Data" / "resources.assets") if sub else (root / "FF9_Data" / "resources.assets")
|
|
48
|
+
if p.exists():
|
|
49
|
+
return p
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read(game=None):
|
|
54
|
+
"""Parse the BtlEncountBgmMetaData TextAsset from ``resources.assets`` -> ``{field: {scene: song}}``, or
|
|
55
|
+
``None`` if anything is unavailable. Lazy UnityPy import (kept out of the core kit's hot path)."""
|
|
56
|
+
p = _resources_assets(game)
|
|
57
|
+
if p is None:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
import UnityPy # noqa: PLC0415 -- only import/extract needs it
|
|
61
|
+
except ImportError:
|
|
62
|
+
return None
|
|
63
|
+
from .extract import _raw_bytes # the canonical TextAsset byte reader (UnityPy-version-safe)
|
|
64
|
+
try:
|
|
65
|
+
env = UnityPy.load(str(p))
|
|
66
|
+
for obj in env.objects:
|
|
67
|
+
if obj.type.name != "TextAsset":
|
|
68
|
+
continue
|
|
69
|
+
data = obj.read()
|
|
70
|
+
name = getattr(data, "m_Name", None) or getattr(data, "name", "")
|
|
71
|
+
if name != ASSET_NAME:
|
|
72
|
+
continue
|
|
73
|
+
body = _raw_bytes(data)
|
|
74
|
+
if body is None:
|
|
75
|
+
continue
|
|
76
|
+
return parse(body.decode("utf-8", "replace"))
|
|
77
|
+
except Exception: # noqa: BLE001 -- any UnityPy/parse failure -> degrade
|
|
78
|
+
return None
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse(text):
|
|
83
|
+
"""``{"field": {"scene": "song"}}`` JSON -> ``{int field: {int scene: int song}}`` (malformed JSON or
|
|
84
|
+
non-numeric keys/values are skipped, never raised on -- the file is data, never trusted to be perfectly
|
|
85
|
+
formed; an unparseable blob yields ``{}``)."""
|
|
86
|
+
try:
|
|
87
|
+
root = json.loads(text)
|
|
88
|
+
except (ValueError, TypeError): # JSONDecodeError (empty/garbled) -> empty map, never raise
|
|
89
|
+
return {}
|
|
90
|
+
out: dict = {}
|
|
91
|
+
for fk, scenes in (root or {}).items():
|
|
92
|
+
try:
|
|
93
|
+
fid = int(fk)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
continue
|
|
96
|
+
row: dict = {}
|
|
97
|
+
for sk, song in (scenes or {}).items():
|
|
98
|
+
try:
|
|
99
|
+
row[int(sk)] = int(song)
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
continue
|
|
102
|
+
out[fid] = row
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load(game=None):
|
|
107
|
+
"""``{field: {scene: song}}`` (cached), or ``None`` if the install/asset can't be read."""
|
|
108
|
+
global _CACHE
|
|
109
|
+
if _CACHE is None:
|
|
110
|
+
parsed = _read(game)
|
|
111
|
+
_CACHE = _MISS if parsed is None else parsed
|
|
112
|
+
return None if _CACHE is _MISS else _CACHE
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def song(field_id, scene_id, game=None):
|
|
116
|
+
"""The donor field's real battle song for ``scene_id`` (the akao song-play id, e.g. 35), or ``None`` when
|
|
117
|
+
the ``(field, scene)`` pair isn't in the map (the engine plays no special song -> the field BGM bleeds
|
|
118
|
+
into the battle) or the install can't be read. ``0`` is a REAL value (the standard Battle Theme), so it is
|
|
119
|
+
returned as ``0`` and is DISTINCT from ``None`` ("unknown / no mapping")."""
|
|
120
|
+
if field_id is None:
|
|
121
|
+
return None
|
|
122
|
+
table = _load(game)
|
|
123
|
+
if not table:
|
|
124
|
+
return None
|
|
125
|
+
row = table.get(int(field_id))
|
|
126
|
+
if not row:
|
|
127
|
+
return None
|
|
128
|
+
return row.get(int(scene_id))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def available(game=None) -> bool:
|
|
132
|
+
"""True if the install's BtlEncountBgmMetaData could be read (so donor battle songs are live)."""
|
|
133
|
+
return _load(game) is not None
|
ff9mapkit/binutils.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Little-endian struct helpers shared by every binary codec in the kit.
|
|
2
|
+
|
|
3
|
+
The original tools each redefined some variant of these (``u16``, ``i16``, ``w16``, inline
|
|
4
|
+
``struct.pack_into``); they are collected here so the ``.eb`` / ``.bgi`` / ``.bgx`` codecs
|
|
5
|
+
share one tested implementation. All multi-byte values in FF9 field binaries are
|
|
6
|
+
little-endian.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import struct
|
|
12
|
+
|
|
13
|
+
# --- read (from bytes/bytearray at an offset) ---
|
|
14
|
+
|
|
15
|
+
def u8(b: bytes, o: int) -> int:
|
|
16
|
+
return b[o]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def u16(b: bytes, o: int) -> int:
|
|
20
|
+
"""Unsigned 16-bit little-endian."""
|
|
21
|
+
return b[o] | (b[o + 1] << 8)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def i16(b: bytes, o: int) -> int:
|
|
25
|
+
"""Signed 16-bit little-endian."""
|
|
26
|
+
return struct.unpack_from("<h", b, o)[0]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def u32(b: bytes, o: int) -> int:
|
|
30
|
+
return struct.unpack_from("<I", b, o)[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def i32(b: bytes, o: int) -> int:
|
|
34
|
+
return struct.unpack_from("<i", b, o)[0]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- pack (to bytes) ---
|
|
38
|
+
|
|
39
|
+
def pu16(v: int) -> bytes:
|
|
40
|
+
"""Pack an unsigned 16-bit little-endian value."""
|
|
41
|
+
return struct.pack("<H", v & 0xFFFF)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pi16(v: int) -> bytes:
|
|
45
|
+
"""Pack a signed 16-bit little-endian value."""
|
|
46
|
+
return struct.pack("<h", v)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pu32(v: int) -> bytes:
|
|
50
|
+
return struct.pack("<I", v & 0xFFFFFFFF)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- write (in place on a bytearray at an offset) ---
|
|
54
|
+
|
|
55
|
+
def set_u16(b: bytearray, o: int, v: int) -> None:
|
|
56
|
+
struct.pack_into("<H", b, o, v & 0xFFFF)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_i16(b: bytearray, o: int, v: int) -> None:
|
|
60
|
+
struct.pack_into("<h", b, o, v)
|