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,148 @@
1
+ """Phase-6a: the enemy-AI DISASSEMBLER VIEW -- the read-only "see the enemy's AI" step.
2
+
3
+ A battle scene's ``EVT_BATTLE_<NAME>.eb`` is the SAME ``.eb`` container as a field script, run by the same
4
+ ``EventEngine`` interpreter -- so the kit already round-trips it (``EbScript``) and decodes its bytecode
5
+ (``eb.disasm``). What was missing to READ enemy AI is the vocabulary: this module names the battle structure
6
+ (entry 0 = Main_Init spawn-binding; entries ``1..TypCount`` = per-enemy-type AI; functions by TAG = AI phases),
7
+ the COMMAND opcodes (via the field ``OP_NAMES``, incl. ``BTLCMD`` = the attack-select command), and the
8
+ EXPRESSION operators + variable reads (via :mod:`ff9mapkit.eb._exprtable` -- ``B_CURHP``/``B_LT`` and decoded
9
+ ``Global.Bit[..]`` story-flag / ``obj(uid).f[..]`` battle-char reads). The output is the import->SEE step that
10
+ authoring (Phase 6b/6c: same-length constant patches, then new branches) will build on.
11
+
12
+ Read-only + offline: no engine, no edit. Provenance: only the open-source opcode/operator NAMES are committed;
13
+ the donor bytes are read live from the install, never committed.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re as _re
18
+
19
+ from ..eb import disasm as _disasm
20
+ from ..eb._membertable import member_name as _member_name
21
+ from ..eb._optables import OP_ARG_COUNT
22
+ from ..eb.model import EbScript
23
+
24
+ _RE_MEMBER = _re.compile(r"B_MEMBER\((\d+)\)") # find B_MEMBER selectors in an instruction's operands
25
+
26
+
27
+ def _member_annotation(operands) -> str:
28
+ """A trailing `` # B_MEMBER 36=cur.hp ...`` comment naming the battle-unit members an instruction reads/writes
29
+ (display only -- the operand text itself stays the round-trippable raw form). '' when no named member is used."""
30
+ seen, named = set(), []
31
+ for op in operands:
32
+ for sel in _RE_MEMBER.findall(op):
33
+ n = int(sel)
34
+ nm = _member_name(n)
35
+ if nm and n not in seen:
36
+ seen.add(n); named.append(f"{n}={nm}")
37
+ return f" # B_MEMBER {' '.join(named)}" if named else ""
38
+
39
+ # Battle-AI function TAGS -> their role (the engine dispatches an enemy object's functions by these tags via
40
+ # Request/RequestAction). Tag 0 = the entry's Init; the rest are AI phases. (project-ff9-battle-tuning §2b.)
41
+ _BATTLE_TAGS = {0: "Init", 1: "Main", 2: "Tag2", 6: "Counter", 7: "ATB", 9: "Dying", 10: "Reinit"}
42
+
43
+ # the low CONTROL opcodes the engine handles in EBin.jumpToCommand (not DoEventCode), which OP_NAMES leaves
44
+ # unnamed (they are "rsvNN" in event_code_binary). Naming them is what makes the AI's branches readable.
45
+ _CTRL_NAMES = {0x01: "JMP", 0x02: "JMP_IFNOT", 0x03: "JMP_IF", 0x04: "RET", 0x05: "SET", 0x06: "SWITCHEX",
46
+ 0x0B: "SWITCH", 0x0D: "SWITCH2"}
47
+
48
+
49
+ def _tag_role(tag: int) -> str:
50
+ return _BATTLE_TAGS.get(tag, f"tag{tag}")
51
+
52
+
53
+ def _cmd_name(op: int) -> str:
54
+ """Command mnemonic: the control overlay first, then the field OP_NAMES, then opXX."""
55
+ return _CTRL_NAMES.get(op, _disasm.op_name(op))
56
+
57
+
58
+ def _decode_func_pretty(raw: bytes, start: int, end: int):
59
+ """Yield ``(offset, mnemonic, [operand_str...])`` for each instruction in ``raw[start:end]``. Mirrors
60
+ ``disasm.read_code``'s operand walk EXACTLY (same arg-flag / variable-count handling) but renders expression
61
+ operands with :func:`disasm.pretty_expr` (named) instead of the raw ``opXX`` form."""
62
+ pos = start
63
+ guard = 0
64
+ while pos < end and guard < 100000:
65
+ guard += 1
66
+ off = pos
67
+ op = raw[pos]; pos += 1
68
+ if op == 0xFF:
69
+ op = 0x100 | raw[pos]; pos += 1
70
+ ac = OP_ARG_COUNT[op] if op < len(OP_ARG_COUNT) else 0
71
+ arg_flag = 0
72
+ if op >= 0x10 and ac != 0:
73
+ arg_flag = raw[pos]; pos += 1
74
+ if op == 0x05:
75
+ arg_flag = 1
76
+ if ac < 0:
77
+ ac = raw[pos]; pos += 1
78
+ if op == 0x0D:
79
+ ac |= raw[pos] << 8; pos += 1
80
+ if op == 0x06:
81
+ ac = 1 + 2 * ac
82
+ elif op in (0x0B, 0x0D):
83
+ ac = 2 + ac
84
+ operands = []
85
+ for i in range(ac):
86
+ if arg_flag & (1 << i):
87
+ s, pos = _disasm.pretty_expr(raw, pos)
88
+ operands.append(s)
89
+ else:
90
+ sz = _disasm.argsize(op, i)
91
+ v = 0
92
+ for k in range(sz):
93
+ v |= raw[pos + k] << (8 * k)
94
+ pos += sz
95
+ operands.append(str(v))
96
+ yield off, _cmd_name(op), operands
97
+
98
+
99
+ def disassemble_ai(eb_bytes: bytes) -> str:
100
+ """Render a battle ``.eb``'s enemy AI as annotated text: each entry (Main_Init + per-type AI), each tagged
101
+ function (its phase role), each instruction (named command + annotated operands). Read-only."""
102
+ try: # a truncated/corrupt eb can have a valid 'EV' magic but
103
+ eb = EbScript.from_bytes(eb_bytes) # an entry table that indexes past the buffer
104
+ except (ValueError, IndexError) as ex:
105
+ return f"<unreadable/malformed .eb: {type(ex).__name__}: {ex}>"
106
+ lines: list[str] = [f"battle AI: {len(eb.entries)} entries ({eb!r})"]
107
+ for e in eb.entries:
108
+ if e.empty:
109
+ continue
110
+ role = "Main_Init (spawn/AI binding)" if e.index == 0 else f"Enemy type {e.index - 1} AI"
111
+ lines.append(f"\n=== entry {e.index}: {role} (type byte {e.type}, {e.func_count} func(s)) ===")
112
+ for f in e.funcs:
113
+ lines.append(f" -- tag {f.tag} [{_tag_role(f.tag)}] ({f.length} bytes) --")
114
+ end = min(f.abs_end, len(eb.data)) # a truncated/corrupt eb can claim a func past the buffer
115
+ try:
116
+ for off, mn, operands in _decode_func_pretty(eb.data, f.abs_start, end):
117
+ lines.append(f" [{off}] {mn}({', '.join(operands)}){_member_annotation(operands)}")
118
+ except IndexError: # malformed bytecode runs off the end -> a legible note,
119
+ lines.append(f" <truncated/malformed bytecode -- decode stopped at offset {len(eb.data)}>")
120
+ return "\n".join(lines)
121
+
122
+
123
+ def _scene_eb(donor: str, game=None, lang: str = "us") -> bytes:
124
+ from . import extract as _extract
125
+ assets = _extract.read_scene_assets(donor, game=game)
126
+ eb = assets.get("eb", {}).get(lang) or next((b for b in assets.get("eb", {}).values() if b), None)
127
+ if not eb:
128
+ raise FileNotFoundError(f"no EVT_BATTLE_{donor}.eb found for scene {donor!r}")
129
+ return eb
130
+
131
+
132
+ def scene_ai_sites(donor: str, game=None, lang: str = "us") -> str:
133
+ """List a scene AI's patchable numeric constants (the ``[[scene.ai_patch]]`` targets): byte offset, width,
134
+ current value, context. Read-only -- the 'find the offset to patch' companion to the disassembly."""
135
+ from . import aipatch as _aipatch
136
+ sites = _aipatch.constant_sites(_scene_eb(donor, game=game, lang=lang))
137
+ lines = [f"# patchable AI constants of scene {donor} ({len(sites)} sites)",
138
+ f"# cite the offset in [[scene.ai_patch]] (at = <offset>, old = <value>, new = <same-width value>)"]
139
+ for s in sites:
140
+ lines.append(f" at={s.offset:<6} {s.width}B = {s.value:<8} {s.where}")
141
+ return "\n".join(lines)
142
+
143
+
144
+ def analyze_scene(donor: str, game=None, lang: str = "us") -> str:
145
+ """Read a real battle scene's ``EVT_BATTLE_<donor>.eb`` LIVE from the install + disassemble its AI. ``donor``
146
+ is the scene name after ``EVT_BATTLE_`` (e.g. ``EF_R007``). Raises on a missing install/donor."""
147
+ return (f"# enemy AI of scene {donor} (EVT_BATTLE_{donor}, {lang})\n"
148
+ + disassemble_ai(_scene_eb(donor, game=game, lang=lang)))
@@ -0,0 +1,390 @@
1
+ """Read-live battle catalogs -- the externalized CSV side of battle tuning:
2
+
3
+ * ``Data/Battle/Actions.csv`` -- the 192 shared PLAYER abilities (id 0-191): scriptId/power/elements/
4
+ rate/status/mp + targeting. (Enemy attacks are NOT here -- they live
5
+ per-scene in the raw16 atk[] block; see :mod:`scene_codec`.)
6
+ * ``Data/Battle/StatusData.csv`` -- the 33 status definitions (tick/duration).
7
+ * ``Data/Battle/StatusSets.csv`` -- named multi-status bundles an action's ``statusIndex`` points at.
8
+
9
+ PROVENANCE -- these are Square-Enix game DATA (the very tables the engine loads), so -- exactly like
10
+ :mod:`ff9mapkit.itemstats` -- the numbers are read LIVE from YOUR OWN install and nothing is committed.
11
+ The ONLY committed battle data here is the **scriptId formula catalog** (:data:`SCRIPT_IDS`), which is
12
+ names/ids transcribed from the open-source Memoria ``Memoria.Scripts/Sources/Battle`` filenames -- and a
13
+ data-vs-DLL flag (re-pointing an action to an EXISTING scriptId is pure CSV; a NEW formula needs a
14
+ ``Memoria.Scripts.<Mod>.dll`` rebuild, NOT the engine DLL).
15
+
16
+ If the install/CSVs aren't reachable every accessor returns ``None``/empty (offline-safe).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field as _dcfield
21
+
22
+ # Reuse the single committed element/status name tables (identical bitmask space, EffectElement /
23
+ # BattleStatus). encode_* (name -> bit) are added here for the Phase-1 raw16 authoring side.
24
+ from ..itemstats import ELEMENTS, STATUSES, decode_elements, decode_status
25
+
26
+ # ---- scriptId formula catalog (COMMITTED: names/ids from Memoria.Scripts/Sources/Battle/00NN_*.cs) ----
27
+ # The number = the battle-calc formula a player action / enemy attack dispatches to (ScriptsLoader's
28
+ # BattleBaseScripts[]). These are the EXTERNALIZED Memoria formulas -- re-pointing an action at one of THESE
29
+ # is pure CSV. A handful of legacy ids (47, 64, 78, 79, 81, 82) are NOT externalized here yet ARE used by
30
+ # shipping enemy attacks (base-engine-handled), so an uncatalogued id is reported neutrally, NOT as "needs a
31
+ # DLL" -- only AUTHORING a brand-new formula needs a Memoria.Scripts.<Mod>.dll (not the engine DLL).
32
+ SCRIPT_IDS = {
33
+ 1: "SimpleWeapon", 2: "WillWeapon", 3: "DexterityWeapon", 4: "MagicWeapon", 5: "RandomWeapon",
34
+ 6: "BloodSwordWeapon", 7: "LevelWeapon", 8: "EnemyPhysicalAttack", 9: "MagicAttack",
35
+ 10: "MagicRecovery", 11: "MagicApplyNegativeStatus", 12: "MagicCureStatus", 13: "Revive", 14: "Death",
36
+ 15: "DrainMp", 16: "DrainHp", 17: "MagicGravityDamage", 18: "Meteorite", 19: "PhysicalAttack",
37
+ 20: "OriginalMagicAttack", 21: "GoblinPunch", 22: "LvDirectHPDamage", 23: "LvHoly",
38
+ 24: "LvReduceDefence", 25: "PreciseDirectHPDamage", 26: "ThousandNeedles", 27: "DirectHPDamage",
39
+ 28: "LimitGlove", 29: "DifferentCasterHpAttack", 30: "WhiteWind", 31: "RandomMpDamage", 32: "Darkside",
40
+ 33: "ArmourBreak", 34: "PowerBreak", 35: "MentalBreak", 36: "MagicBreak", 37: "Chakra",
41
+ 38: "SpareChange", 39: "Lancer", 40: "DragonBreath", 41: "WhiteDraw", 42: "Throw", 43: "Might",
42
+ 44: "Focus", 45: "Sacrifice", 46: "SoulBlade", 48: "Spear", 49: "Phoenix", 50: "SixDragons",
43
+ 51: "Curse", 52: "AngelSnack", 53: "LuckySeven", 54: "WhatIsThat", 55: "ChangeRow", 56: "FleeIteration",
44
+ 57: "Flee", 58: "Steal", 59: "Scan", 60: "Detect", 61: "Charge", 62: "ItemSoft", 63: "MagicSwordAttack",
45
+ 65: "Eat", 66: "FrogDrop", 67: "Thievery", 68: "DragonCrest", 69: "ItemPotion", 70: "ItemEther",
46
+ 71: "ItemElixir", 72: "ItemPhoenix", 73: "ItemCureStatus", 74: "ItemGem", 75: "DeadPepper", 76: "Tent",
47
+ 77: "DarkMatter", 80: "DoubleCastSpecial", 83: "MassSpear", 84: "Jewel", 85: "Summon", 86: "Atomos",
48
+ 87: "Odin", 88: "Melt", 89: "HPSwitching", 90: "HalfDefence", 91: "Cannon", 92: "ItemAdd",
49
+ 93: "Maelstrom", 94: "AbsorbMagic", 95: "AbsorbStrength", 96: "TranceFull", 97: "Entice",
50
+ 98: "SimpleAttackGaia", 99: "FlareStar", 100: "PreciseEnemyPhysicalAttack", 101: "EnemySteal",
51
+ 102: "EnemyMug", 103: "MagicApplyPositiveStatus", 104: "TonberryKarma", 105: "GrandCross",
52
+ 106: "Swallow", 107: "PreciseEnemyPhysicalAttackAndChangeRow", 108: "IaiStrike", 109: "Mini",
53
+ }
54
+
55
+
56
+ def script_name(script_id) -> str:
57
+ """Formula name for a scriptId, or a neutral ``"scriptId N"`` when it isn't in the externalized catalog
58
+ (which does NOT imply it's unhandled -- a few legacy ids are base-engine-handled; see the table comment)."""
59
+ try:
60
+ sid = int(script_id)
61
+ except (TypeError, ValueError):
62
+ return "scriptId ?"
63
+ return SCRIPT_IDS.get(sid, f"scriptId {sid}")
64
+
65
+
66
+ def is_stock_script(script_id) -> bool:
67
+ """True if this scriptId is in the externalized Memoria.Scripts catalog (freely re-pointable, no DLL)."""
68
+ try:
69
+ return int(script_id) in SCRIPT_IDS
70
+ except (TypeError, ValueError):
71
+ return False
72
+
73
+
74
+ # ---- name <-> bit helpers (committed; the encode side powers Phase-1 raw16 authoring) ------------------
75
+ _ELEM_BY_NAME = {name.lower(): bit for bit, name in ELEMENTS}
76
+ _STATUS_BY_NAME = {name.lower(): bit for bit, name in STATUSES}
77
+
78
+
79
+ def encode_elements(names) -> int:
80
+ """A list of element names (or a bitmask int) -> the bitmask. Unknown names raise ValueError."""
81
+ if isinstance(names, int):
82
+ return names
83
+ mask = 0
84
+ for n in names or []:
85
+ if isinstance(n, int):
86
+ mask |= n
87
+ continue
88
+ bit = _ELEM_BY_NAME.get(str(n).strip().lower())
89
+ if bit is None:
90
+ raise ValueError(f"unknown element {n!r} (known: {', '.join(nm for _, nm in ELEMENTS)})")
91
+ mask |= bit
92
+ return mask
93
+
94
+
95
+ def encode_status(names) -> int:
96
+ """A list of status names (or a bitmask int) -> the BattleStatus bitmask. Unknown names raise."""
97
+ if isinstance(names, int):
98
+ return names
99
+ mask = 0
100
+ for n in names or []:
101
+ if isinstance(n, int):
102
+ mask |= n
103
+ continue
104
+ bit = _STATUS_BY_NAME.get(str(n).strip().lower())
105
+ if bit is None:
106
+ raise ValueError(f"unknown status {n!r} (known: {', '.join(nm for _, nm in STATUSES)})")
107
+ mask |= bit
108
+ return mask
109
+
110
+
111
+ # ---- TargetType / TargetDisplay (Memoria.Data enums; the Actions.csv cell format is "Name(value)") -------
112
+ # Committed open-source enum NAMES (TargetType.cs / TargetDisplay.cs); the value is the enum's int.
113
+ TARGET_TYPES = ("SingleAny", "SingleAlly", "SingleEnemy", "ManyAny", "ManyAlly", "ManyEnemy", "All", "AllAlly",
114
+ "AllEnemy", "Random", "RandomAlly", "RandomEnemy", "Everyone", "Self", "Automatic", "Special")
115
+ TARGET_DISPLAYS = ("None", "Hp", "Mp", "Debuffs", "Buffs")
116
+
117
+
118
+ def _encode_enum(value, names, label) -> str:
119
+ """A name (case-insensitive) or a 0..N-1 id -> the ``Name(value)`` CSV cell. ValueError on a bad value."""
120
+ if isinstance(value, bool):
121
+ raise ValueError(f"{label} can't be a boolean")
122
+ if isinstance(value, int) or (isinstance(value, str) and value.strip().lstrip("-").isdigit()):
123
+ i = int(value)
124
+ else:
125
+ i = {n.lower(): k for k, n in enumerate(names)}.get(str(value).strip().lower())
126
+ if i is None:
127
+ raise ValueError(f"unknown {label} {value!r} (known: {', '.join(names)})")
128
+ if not 0 <= i < len(names):
129
+ raise ValueError(f"{label} id {i} out of range (0-{len(names) - 1})")
130
+ return f"{names[i]}({i})"
131
+
132
+
133
+ def encode_target_type(value) -> str:
134
+ """A TargetType name (``SingleEnemy``/``AllEnemy``/…) or 0-15 id -> the ``Name(value)`` Actions.csv cell."""
135
+ return _encode_enum(value, TARGET_TYPES, "targets")
136
+
137
+
138
+ def encode_target_display(value) -> str:
139
+ """A TargetDisplay name (``None``/``Hp``/``Mp``/``Debuffs``/``Buffs``) or 0-4 id -> the ``Name(value)`` cell."""
140
+ return _encode_enum(value, TARGET_DISPLAYS, "menu_window")
141
+
142
+
143
+ # StatusData ClearOnApply/ImmunityProvided cells are a ``Name(bitIndex), ...`` list (BattleStatusId, the
144
+ # ``#! UnshiftStatuses`` format); the index = the status's bit position (Petrify=0 … GradualPetrify=31).
145
+ _STATUS_INDEX_BY_NAME = {nm.lower(): (bm.bit_length() - 1, nm) for bm, nm in STATUSES}
146
+
147
+
148
+ def encode_status_list(value) -> str:
149
+ """A list of status names (or ``None``/``""``/``"none"``) -> the ``Name(idx), Name(idx)`` cell for a
150
+ StatusData BattleStatus column. ValueError on an unknown name."""
151
+ if value is None:
152
+ return ""
153
+ if isinstance(value, str):
154
+ value = [] if value.strip().lower() in ("", "none", "-") else [value]
155
+ out = []
156
+ for n in value or []:
157
+ hit = _STATUS_INDEX_BY_NAME.get(str(n).strip().lower())
158
+ if hit is None:
159
+ raise ValueError(f"unknown status {n!r} (known: {', '.join(nm for _, nm in STATUSES)})")
160
+ out.append(f"{hit[1]}({hit[0]})")
161
+ return ", ".join(out)
162
+
163
+
164
+ # ---- CSV parsing (mirrors itemstats._read_csv; legend keyed on an 'id' column, parens stripped) --------
165
+ def _read_csv(path) -> tuple:
166
+ """Parse a Memoria battle CSV -> ``(cols, rows)``. ``cols`` maps each header name (normalized:
167
+ lower-cased, ``Foo(bar)`` -> ``foo``) to its column index, taken from the first ``#``-legend line that
168
+ has an ``id`` field. Data rows are ``;``-split (a trailing ``# name`` comment cell is left as an extra
169
+ field, ignored by name access)."""
170
+ cols: "dict | None" = None
171
+ rows: list = []
172
+ # cp1252 (the install's real encoding -- a few action names carry a 0x92 curly apostrophe; reading them as
173
+ # UTF-8 would mangle the name). Strip a stray UTF-8 BOM if one ever appears.
174
+ data = path.read_bytes()
175
+ if data.startswith(b"\xef\xbb\xbf"):
176
+ data = data[3:]
177
+ for raw in data.decode("cp1252", errors="replace").splitlines():
178
+ s = raw.strip()
179
+ if not s:
180
+ continue
181
+ if s.startswith("#"):
182
+ if cols is None and not s.startswith("#!"):
183
+ fields = [f.strip().split("(")[0].strip().lower() for f in s.lstrip("#").strip().split(";")]
184
+ if "id" in fields and len(fields) > 1:
185
+ cols = {name: i for i, name in enumerate(fields)}
186
+ continue
187
+ rows.append([c.strip() for c in raw.split(";")])
188
+ return (cols or {}), rows
189
+
190
+
191
+ def _cell(row, cols, name, default=None):
192
+ idx = cols.get(name)
193
+ if idx is None or idx >= len(row):
194
+ return default
195
+ return row[idx]
196
+
197
+
198
+ def _int(row, cols, name, default=None):
199
+ v = _cell(row, cols, name)
200
+ try:
201
+ return int(v)
202
+ except (ValueError, TypeError):
203
+ return default
204
+
205
+
206
+ def _name_cell(row):
207
+ """The first column (Comment) is the human name; strip a trailing inline ``# ...`` if it leaked in."""
208
+ return (row[0].split("#")[0].strip() if row else "") or ""
209
+
210
+
211
+ # ---- records -----------------------------------------------------------------------------------------
212
+ @dataclass
213
+ class Action:
214
+ """One Actions.csv row -- a shared player ability (white/black magic, skill, summon, ...)."""
215
+ id: int
216
+ name: str
217
+ script_id: int
218
+ power: int
219
+ elements: list = _dcfield(default_factory=list) # decoded names
220
+ rate: int = 0
221
+ category: int = 0
222
+ status_index: int = 0 # -> StatusSets.csv id (resolve via status_set)
223
+ mp: int = 0
224
+ type: int = 0
225
+ targets: str = ""
226
+ menu_window: str = ""
227
+
228
+ def summary(self) -> str:
229
+ bits = [script_name(self.script_id)]
230
+ if self.power:
231
+ bits.append(f"pow {self.power}")
232
+ if self.elements:
233
+ bits.append("/".join(self.elements))
234
+ if self.rate not in (0, 255):
235
+ bits.append(f"rate {self.rate}")
236
+ if self.mp:
237
+ bits.append(f"{self.mp} MP")
238
+ return f"{self.name} -- " + ", ".join(bits)
239
+
240
+
241
+ @dataclass
242
+ class Status:
243
+ """One StatusData.csv row -- a status ailment/buff definition."""
244
+ id: int
245
+ name: str
246
+ tick: int = 0 # OprCount (per-tick effect counter)
247
+ duration: int = 0 # ContiCount (0 = permanent until cured)
248
+
249
+
250
+ @dataclass
251
+ class StatusSet:
252
+ """One StatusSets.csv row -- a named bundle of statuses an action inflicts/cures."""
253
+ id: int
254
+ name: str
255
+ statuses: list = _dcfield(default_factory=list) # status names
256
+
257
+
258
+ # ---- the in-memory load (cached) ---------------------------------------------------------------------
259
+ _CACHE = None # None = not loaded; False = unavailable; dict with 'actions'/'statuses'/'sets'
260
+
261
+
262
+ def _battle_dir(game=None):
263
+ from ..config import find_game_path
264
+ return find_game_path(game) / "StreamingAssets" / "Data" / "Battle"
265
+
266
+
267
+ def _parse_status_tokens(field) -> list:
268
+ """``"Silence(3), Blind(4)"`` -> ``["Silence", "Blind"]`` (the name before each paren)."""
269
+ out = []
270
+ for tok in (field or "").split(","):
271
+ tok = tok.strip()
272
+ if not tok:
273
+ continue
274
+ out.append(tok.split("(")[0].strip())
275
+ return out
276
+
277
+
278
+ def _load(game=None):
279
+ global _CACHE
280
+ if _CACHE is not None:
281
+ return _CACHE or None
282
+ try:
283
+ d = _battle_dir(game)
284
+ acols, arows = _read_csv(d / "Actions.csv")
285
+ scols, srows = _read_csv(d / "StatusData.csv")
286
+ tcols, trows = _read_csv(d / "StatusSets.csv")
287
+ if not (acols and arows):
288
+ raise ValueError("Actions.csv had no parseable header/rows")
289
+ except (FileNotFoundError, OSError, RuntimeError, ValueError):
290
+ _CACHE = False
291
+ return None
292
+
293
+ sets = {}
294
+ for r in trows:
295
+ sid = _int(r, tcols, "id")
296
+ if sid is None:
297
+ continue
298
+ sets[sid] = StatusSet(id=sid, name=_name_cell(r), statuses=_parse_status_tokens(_cell(r, tcols, "statuses")))
299
+
300
+ statuses = {}
301
+ for r in srows:
302
+ sid = _int(r, scols, "id")
303
+ if sid is None:
304
+ continue
305
+ statuses[sid] = Status(id=sid, name=_name_cell(r),
306
+ tick=_int(r, scols, "oprcount", 0) or 0,
307
+ duration=_int(r, scols, "conticount", 0) or 0)
308
+
309
+ actions = {}
310
+ for r in arows:
311
+ aid = _int(r, acols, "id")
312
+ if aid is None:
313
+ continue
314
+ actions[aid] = Action(
315
+ id=aid, name=_name_cell(r),
316
+ script_id=_int(r, acols, "scriptid", 0) or 0,
317
+ power=_int(r, acols, "power", 0) or 0,
318
+ elements=decode_elements(_int(r, acols, "elements", 0) or 0),
319
+ rate=_int(r, acols, "rate", 0) or 0,
320
+ category=_int(r, acols, "category", 0) or 0,
321
+ status_index=_int(r, acols, "statusindex", 0) or 0,
322
+ mp=_int(r, acols, "mp", 0) or 0,
323
+ type=_int(r, acols, "type", 0) or 0,
324
+ targets=_cell(r, acols, "targets", "") or "",
325
+ menu_window=_cell(r, acols, "menuwindow", "") or "")
326
+
327
+ _CACHE = {"actions": actions, "statuses": statuses, "sets": sets}
328
+ return _CACHE
329
+
330
+
331
+ # ---- public API --------------------------------------------------------------------------------------
332
+ def available(game=None) -> bool:
333
+ return _load(game) is not None
334
+
335
+
336
+ def action(action_id, *, game=None):
337
+ t = _load(game)
338
+ try:
339
+ return t and t["actions"].get(int(action_id))
340
+ except (ValueError, TypeError):
341
+ return None
342
+
343
+
344
+ def actions(*, game=None) -> list:
345
+ t = _load(game)
346
+ return sorted(t["actions"].values(), key=lambda a: a.id) if t else []
347
+
348
+
349
+ def action_by_name(name, *, game=None):
350
+ t = _load(game)
351
+ if not t:
352
+ return None
353
+ key = str(name).strip().lower()
354
+ for a in t["actions"].values():
355
+ if a.name.lower() == key:
356
+ return a
357
+ return None
358
+
359
+
360
+ def status(status_id, *, game=None):
361
+ t = _load(game)
362
+ try:
363
+ return t and t["statuses"].get(int(status_id))
364
+ except (ValueError, TypeError):
365
+ return None
366
+
367
+
368
+ def statuses(*, game=None) -> list:
369
+ t = _load(game)
370
+ return sorted(t["statuses"].values(), key=lambda s: s.id) if t else []
371
+
372
+
373
+ def status_set(set_id, *, game=None):
374
+ t = _load(game)
375
+ try:
376
+ return t and t["sets"].get(int(set_id))
377
+ except (ValueError, TypeError):
378
+ return None
379
+
380
+
381
+ def status_set_names(set_id, *, game=None) -> list:
382
+ """The status names an action's ``statusIndex`` inflicts/cures (empty if unknown/unloaded)."""
383
+ s = status_set(set_id, game=game)
384
+ return list(s.statuses) if s else []
385
+
386
+
387
+ def _reset_cache():
388
+ """Test hook -- drop the cache so a later call re-reads (e.g. after pointing at a fixture)."""
389
+ global _CACHE
390
+ _CACHE = None