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,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
|