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.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. 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)]
@@ -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)