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,305 @@
|
|
|
1
|
+
"""Phase-6c-ii: enemy-AI branch AUTHORING -- the write-side companion to the 6a disassembler + 6b same-length
|
|
2
|
+
patcher. Where 6b retunes a CONSTANT in place (no byte moves), this ADDS or REPLACES a whole AI function (a new
|
|
3
|
+
phase branch, a counter, a rewritten body) -- the first LENGTH-CHANGING AI edit.
|
|
4
|
+
|
|
5
|
+
It is a thin bridge: assemble the readable AI source with the Phase-6c command assembler
|
|
6
|
+
(:func:`ff9mapkit.eb.cmdasm.assemble_block`), then splice the resulting body into the forked battle ``.eb`` with
|
|
7
|
+
the existing byte-safe length-changing primitives (:mod:`ff9mapkit.eb.edit`), which do the entry-table + intra-entry
|
|
8
|
+
``fpos`` fixup. Battle ``.eb`` is the same container/interpreter as a field script, and ``replace_function_body``
|
|
9
|
+
is already used on battle ebs (to re-author Main_Init for an edited spawn), so the machinery is proven; what 6c
|
|
10
|
+
adds is the way to WRITE the new bytecode by hand.
|
|
11
|
+
|
|
12
|
+
The AI-phase tags the engine dispatches (project-ff9-battle-tuning): 1 Main, 6 Counter, 7 ATB, 9 Dying (tag 0 =
|
|
13
|
+
the entry's Init). A fuller battle linter -- valid tags, an Attack index in range, a terminating RET -- is the next
|
|
14
|
+
step (Phase 6c-iii); these wrappers stay deliberately thin.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ..eb import cmdasm, disasm
|
|
19
|
+
from ..eb import edit as _edit
|
|
20
|
+
from ..eb.model import EbScript
|
|
21
|
+
from .ailint import TERMINATOR_OPS as _TERMINATOR_OPS # the flow-terminators (RET/TerminateEntry/GameOver/...),
|
|
22
|
+
|
|
23
|
+
# the AI-phase function tags an enemy-type entry dispatches (0 = Init, the spawn binding, edited via Main_Init).
|
|
24
|
+
AI_PHASE_TAGS = {1: "Main", 6: "Counter", 7: "ATB", 9: "Dying"}
|
|
25
|
+
|
|
26
|
+
# (`_TERMINATOR_OPS` shared with the linter so the two never drift.) The engine has NO per-function length bound,
|
|
27
|
+
# so an authored body that doesn't END in one of these runs the IP off into adjacent bytecode at runtime.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AiAuthorError(ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_entry(eb_bytes: bytes, entry_index: int):
|
|
35
|
+
"""Re-parse + bounds-check the entry; returns the parsed EbScript. Raises a clean error on a bad index."""
|
|
36
|
+
try:
|
|
37
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
38
|
+
except (ValueError, IndexError) as ex:
|
|
39
|
+
raise AiAuthorError(f"malformed battle .eb: {type(ex).__name__}: {ex}")
|
|
40
|
+
if not 0 <= entry_index < len(eb.entries) or eb.entries[entry_index].empty:
|
|
41
|
+
raise AiAuthorError(f"entry {entry_index} is out of range / empty "
|
|
42
|
+
f"({sum(not e.empty for e in eb.entries)} non-empty entries)")
|
|
43
|
+
return eb
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def add_ai_function(eb_bytes: bytes, entry_index: int, tag: int, block_text: str) -> bytes:
|
|
47
|
+
"""Assemble ``block_text`` (cmdasm) and ADD it as a NEW function ``tag`` to enemy-AI entry ``entry_index``.
|
|
48
|
+
|
|
49
|
+
Returns the new eb bytes (the entry's function table grows by one slot, every existing func's ``fpos`` and
|
|
50
|
+
every later entry's table offset are fixed up by :func:`eb.edit.add_function`). Raises if ``tag`` already
|
|
51
|
+
exists on that entry (use :func:`replace_ai_function` to rewrite an existing one)."""
|
|
52
|
+
eb = _check_entry(eb_bytes, entry_index)
|
|
53
|
+
if eb.entries[entry_index].func_by_tag(tag) is not None:
|
|
54
|
+
raise AiAuthorError(f"entry {entry_index} already has a function with tag {tag} "
|
|
55
|
+
f"({AI_PHASE_TAGS.get(tag, '?')}) -- use replace_ai_function to rewrite it")
|
|
56
|
+
body = _assemble(block_text)
|
|
57
|
+
return _edit.add_function(eb_bytes, entry_index, tag, body)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def replace_ai_function(eb_bytes: bytes, entry_index: int, tag: int, block_text: str) -> bytes:
|
|
61
|
+
"""Assemble ``block_text`` and REPLACE function ``tag``'s body in entry ``entry_index`` (any length).
|
|
62
|
+
|
|
63
|
+
The later functions' ``fpos`` + later entries' offsets shift by the size delta (handled by
|
|
64
|
+
:func:`eb.edit.replace_function_body`). Raises if ``tag`` is absent."""
|
|
65
|
+
eb = _check_entry(eb_bytes, entry_index)
|
|
66
|
+
if eb.entries[entry_index].func_by_tag(tag) is None:
|
|
67
|
+
raise AiAuthorError(f"entry {entry_index} has no function with tag {tag} -- use add_ai_function to add it")
|
|
68
|
+
body = _assemble(block_text)
|
|
69
|
+
return _edit.replace_function_body(eb_bytes, entry_index, tag, body)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def apply_ai_functions(eb_bytes: bytes, specs) -> bytes:
|
|
73
|
+
"""Apply a list of ``[[scene.ai_function]]`` specs (add / replace AI functions) to ``eb_bytes`` IN ORDER.
|
|
74
|
+
|
|
75
|
+
Each spec is a table: ``entry`` (int, the enemy-type entry), ``tag`` (int, the AI-phase tag), ``source`` (str,
|
|
76
|
+
the `cmdasm` command block), and optional ``replace`` (bool, default false -> add; true -> replace the body).
|
|
77
|
+
Length-changing -- run this AFTER the same-length `[[scene.ai_patch]]` so the patch offsets are still valid."""
|
|
78
|
+
if not isinstance(specs, list):
|
|
79
|
+
raise AiAuthorError("[[scene.ai_function]] must be a list of tables")
|
|
80
|
+
eb = eb_bytes
|
|
81
|
+
for n, spec in enumerate(specs, 1):
|
|
82
|
+
if not isinstance(spec, dict):
|
|
83
|
+
raise AiAuthorError(f"[[scene.ai_function]] #{n} must be a table (got {type(spec).__name__})")
|
|
84
|
+
try:
|
|
85
|
+
entry, tag = int(spec["entry"]), int(spec["tag"])
|
|
86
|
+
except (KeyError, TypeError, ValueError):
|
|
87
|
+
raise AiAuthorError(f"[[scene.ai_function]] #{n} needs integer entry + tag (and a source string)")
|
|
88
|
+
if not 0 <= tag <= 0xFFFF: # the func-table slot stores tag as a u16 (else struct
|
|
89
|
+
raise AiAuthorError(f"[[scene.ai_function]] #{n} tag {tag} out of range (0-65535); the AI-phase tags "
|
|
90
|
+
f"are {sorted(AI_PHASE_TAGS)}") # would raise a raw error deep in eb.edit)
|
|
91
|
+
source = spec.get("source")
|
|
92
|
+
if not isinstance(source, str) or not source.strip():
|
|
93
|
+
raise AiAuthorError(f"[[scene.ai_function]] #{n} needs a non-empty source string")
|
|
94
|
+
src = source.replace(";", "\n") # accept ';' as a line separator (one-line TOML source)
|
|
95
|
+
eb = replace_ai_function(eb, entry, tag, src) if spec.get("replace") else add_ai_function(eb, entry, tag, src)
|
|
96
|
+
return eb
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_ai_functions(eb_bytes: bytes, specs, *, atk_count: int | None = None) -> list:
|
|
100
|
+
"""Dry-run :func:`apply_ai_functions` + LINT the result; return error strings (empty == ok) for the offline
|
|
101
|
+
build validate. Catches a bad entry/tag/source, a duplicate/missing tag, AND a structural fault in the
|
|
102
|
+
authored AI (a non-terminating branch, an out-of-bounds jump, an out-of-range Attack index when ``atk_count``
|
|
103
|
+
is given) via :mod:`ff9mapkit.battle.ailint`."""
|
|
104
|
+
from . import ailint as _ailint
|
|
105
|
+
try:
|
|
106
|
+
out = apply_ai_functions(eb_bytes, specs)
|
|
107
|
+
except AiAuthorError as ex:
|
|
108
|
+
return [str(ex)]
|
|
109
|
+
return [f"lint: {i}" for i in _ailint.lint_ai(out, atk_count=atk_count)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------------------------------------------
|
|
113
|
+
# Phase-6c (productized): INSERT a branch into an existing function (the length-changing primitive made declarative)
|
|
114
|
+
# + the HP-PHASE convenience that generates the branch. `ai_function` REPLACES a whole function; these SPLICE a
|
|
115
|
+
# fragment into one -- the missing surface that the in-game-proven coin-flip / HP-phase branches needed by hand.
|
|
116
|
+
# ---------------------------------------------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
_VAR_RE = __import__("re").compile(r"^[A-Za-z]+\.[A-Za-z0-9]+\[\d+\]$") # a Source.Type[i] variable token
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _func_pretty(eb_bytes: bytes, entry_index: int, tag: int):
|
|
122
|
+
"""(EbScript, Func, [(off, mnemonic, [operand_str]) ...]) for entry/tag -- the NAMED decode (battleai)."""
|
|
123
|
+
from .battleai import _decode_func_pretty
|
|
124
|
+
eb = _check_entry(eb_bytes, entry_index)
|
|
125
|
+
f = eb.entries[entry_index].func_by_tag(tag)
|
|
126
|
+
if f is None:
|
|
127
|
+
raise AiAuthorError(f"entry {entry_index} has no function tag {tag} ({AI_PHASE_TAGS.get(tag, '?')})")
|
|
128
|
+
instrs = list(_decode_func_pretty(eb.data, f.abs_start, min(f.abs_end, len(eb.data))))
|
|
129
|
+
return eb, f, instrs
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _locate_insert(f, instrs, spec, n: int) -> int:
|
|
133
|
+
"""Resolve a spec's locator to an ABSOLUTE byte offset inside function ``f``. Locators: ``before``/``after`` =
|
|
134
|
+
a command mnemonic (insert before / after the FIRST match), or ``at`` = a body offset (0 = prepend)."""
|
|
135
|
+
have = [k for k in ("before", "after", "at") if k in spec]
|
|
136
|
+
if len(have) != 1:
|
|
137
|
+
raise AiAuthorError(f"#{n} needs exactly one locator: before = \"<mnemonic>\" | after = \"<mnemonic>\" | "
|
|
138
|
+
f"at = <body offset> (got {have or 'none'})")
|
|
139
|
+
boundaries = {off for off, _, _ in instrs} # the instruction-start offsets = the only valid insert points
|
|
140
|
+
if "at" in spec:
|
|
141
|
+
try:
|
|
142
|
+
rel = int(spec["at"])
|
|
143
|
+
except (TypeError, ValueError):
|
|
144
|
+
raise AiAuthorError(f"#{n} at must be an integer body offset")
|
|
145
|
+
if not 0 <= rel < f.length: # f.length (the end) is NOT insertable -- see the append note
|
|
146
|
+
raise AiAuthorError(f"#{n} at = {rel} is outside the func body (0-{f.length - 1}); to add code at the "
|
|
147
|
+
f"end, splice before the terminator (before = \"RET\"), not after it")
|
|
148
|
+
if f.abs_start + rel not in boundaries: # mid-instruction insert would split an opcode -> corrupt
|
|
149
|
+
valid = sorted(o - f.abs_start for o in boundaries)
|
|
150
|
+
raise AiAuthorError(f"#{n} at = {rel} is not an instruction boundary (would split an instruction); "
|
|
151
|
+
f"valid body offsets are {valid}")
|
|
152
|
+
return f.abs_start + rel
|
|
153
|
+
key = "before" if "before" in spec else "after"
|
|
154
|
+
mnem = spec[key]
|
|
155
|
+
offs = [off for off, mn, _ in instrs if mn == mnem] # offsets of each instruction with that mnemonic
|
|
156
|
+
if not offs:
|
|
157
|
+
present = sorted({mn for _, mn, _ in instrs})
|
|
158
|
+
raise AiAuthorError(f"#{n} {key} = {mnem!r}: no such instruction in the function (has: {', '.join(present)})")
|
|
159
|
+
if key == "before":
|
|
160
|
+
return offs[0]
|
|
161
|
+
seq = [off for off, _, _ in instrs] + [f.abs_end] # after: the byte AFTER the first match = the next instr's off
|
|
162
|
+
nxt = seq[seq.index(offs[0]) + 1]
|
|
163
|
+
if nxt == f.abs_end: # the match is the LAST instruction -> would append past the
|
|
164
|
+
raise AiAuthorError(f"#{n} after = {mnem!r}: that is the function's LAST instruction; you cannot append " # end
|
|
165
|
+
f"after the final instruction -- splice before the terminator instead (before = ...)")
|
|
166
|
+
return nxt
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def apply_ai_inserts(eb_bytes: bytes, specs) -> bytes:
|
|
170
|
+
"""Apply a list of ``[[scene.ai_insert]]`` specs IN ORDER. Each splices an assembled FRAGMENT into a function:
|
|
171
|
+
``entry`` + ``tag`` (which function), a locator (``before``/``after`` = a command mnemonic, or ``at`` = a body
|
|
172
|
+
offset), and ``source`` (the `cmdasm` block -- a FRAGMENT, NOT required to end in RET; it flows into the rest of
|
|
173
|
+
the function). Splice = :func:`eb.edit.insert_in_function` (fpos fixup; it refuses if one of the function's own
|
|
174
|
+
jumps STRADDLES the insert point -- surfaced as a clean error). Length-changing -> run AFTER `ai_patch`."""
|
|
175
|
+
if not isinstance(specs, list):
|
|
176
|
+
raise AiAuthorError("[[scene.ai_insert]] must be a list of tables")
|
|
177
|
+
eb = eb_bytes
|
|
178
|
+
for n, spec in enumerate(specs, 1):
|
|
179
|
+
if not isinstance(spec, dict):
|
|
180
|
+
raise AiAuthorError(f"[[scene.ai_insert]] #{n} must be a table (got {type(spec).__name__})")
|
|
181
|
+
try:
|
|
182
|
+
entry, tag = int(spec["entry"]), int(spec["tag"])
|
|
183
|
+
except (KeyError, TypeError, ValueError):
|
|
184
|
+
raise AiAuthorError(f"[[scene.ai_insert]] #{n} needs integer entry + tag")
|
|
185
|
+
source = spec.get("source")
|
|
186
|
+
if not isinstance(source, str) or not source.strip():
|
|
187
|
+
raise AiAuthorError(f"[[scene.ai_insert]] #{n} needs a non-empty source string")
|
|
188
|
+
try:
|
|
189
|
+
body = cmdasm.assemble_block(source.replace(";", "\n"))
|
|
190
|
+
except cmdasm.CmdAsmError as ex:
|
|
191
|
+
raise AiAuthorError(f"[[scene.ai_insert]] #{n} source did not assemble: {ex}")
|
|
192
|
+
_eb, f, instrs = _func_pretty(eb, entry, tag)
|
|
193
|
+
abs_off = _locate_insert(f, instrs, spec, n)
|
|
194
|
+
try:
|
|
195
|
+
eb = _edit.insert_in_function(eb, entry, tag, abs_off - f.abs_start, body)
|
|
196
|
+
except ValueError as ex: # a straddling jump / bad offset from the splice primitive
|
|
197
|
+
raise AiAuthorError(f"[[scene.ai_insert]] #{n}: {ex}")
|
|
198
|
+
return eb
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _gen_hp_phase(stat: str, below: float, then_atk: int, else_atk: int, var: str, n: int,
|
|
202
|
+
atk_count: int | None = None) -> str:
|
|
203
|
+
"""Generate the `cmdasm` source for a stat-threshold branch: when SELF ``stat`` < ``below`` of max, set the
|
|
204
|
+
attack-index var to ``then_atk``, else ``else_atk``. Uses the exact ``_E``/``B_PICK``/``B_COUNT`` extract idiom
|
|
205
|
+
56 shipping bosses use for 'cur vs fraction-of-max' (the ``_E`` ops bind the read target via the SysList)."""
|
|
206
|
+
pair = {"hp": ("cur.hp", "max.hp"), "mp": ("cur.mp", "max.mp"), "at": ("cur.at", "max.at")}.get(stat)
|
|
207
|
+
if pair is None:
|
|
208
|
+
raise AiAuthorError(f"[[scene.ai_phase]] #{n}: stat must be hp/mp/at (got {stat!r})")
|
|
209
|
+
if not 0.0 < below < 1.0:
|
|
210
|
+
raise AiAuthorError(f"[[scene.ai_phase]] #{n}: below must be a fraction 0<below<1 (e.g. 0.5 = half)")
|
|
211
|
+
div = round(1.0 / below) # the proven idiom is cur < max/DIV (a unit fraction)
|
|
212
|
+
if not 2 <= div <= 0xFFFF or abs(1.0 / div - below) > 1e-6: # div is emitted as a 2-byte const(N) -> <= 0xFFFF
|
|
213
|
+
raise AiAuthorError(f"[[scene.ai_phase]] #{n}: below = {below} must be a unit fraction 1/N with 2<=N<=65535 "
|
|
214
|
+
f"(0.5, 0.25, 0.2, …) to use the cur < max/N idiom")
|
|
215
|
+
# then/else index the scene's GLOBAL enemy_attack[] table. The offline lint CANNOT range-check it (the value is
|
|
216
|
+
# written into a variable, so the Attack operand is an expression, not an immediate) -- so guard it HERE when the
|
|
217
|
+
# scene's attack count is known, else fall back to the byte ceiling.
|
|
218
|
+
hi = (atk_count - 1) if atk_count else 0xFF
|
|
219
|
+
for nm, v in (("then", then_atk), ("else", else_atk)):
|
|
220
|
+
if not 0 <= v <= hi:
|
|
221
|
+
scope = f" (scene has {atk_count} attacks)" if atk_count else ""
|
|
222
|
+
raise AiAuthorError(f"[[scene.ai_phase]] #{n}: {nm} attack index {v} out of range (0-{hi}){scope}")
|
|
223
|
+
cur, mx = pair
|
|
224
|
+
return "\n".join([
|
|
225
|
+
f"SET({{B_SYSLIST[1] B_MEMBER({cur}) B_SYSLIST[1] B_MEMBER({mx}) B_PICK const({div}) B_DIV B_LT_E B_COUNT B_EXPR_END}})",
|
|
226
|
+
"JMP_IFNOT(L_phase_else)",
|
|
227
|
+
f"SET({{{var} const({then_atk}) B_LET B_EXPR_END}})",
|
|
228
|
+
"JMP(L_phase_done)",
|
|
229
|
+
"L_phase_else:",
|
|
230
|
+
f"SET({{{var} const({else_atk}) B_LET B_EXPR_END}})",
|
|
231
|
+
"L_phase_done:",
|
|
232
|
+
])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _infer_attack_var(instrs, n: int) -> str:
|
|
236
|
+
"""The variable an `Attack` command reads as its index, so the phase branch can override it. Requires exactly
|
|
237
|
+
one `Attack` whose operand is a single ``{ Source.Type[i] B_EXPR_END }`` expression."""
|
|
238
|
+
atks = [ops for _, mn, ops in instrs if mn == "Attack"]
|
|
239
|
+
if len(atks) != 1:
|
|
240
|
+
raise AiAuthorError(f"[[scene.ai_phase]] #{n}: the function must have exactly ONE Attack (found "
|
|
241
|
+
f"{len(atks)}) -- use [[scene.ai_insert]] with an explicit source instead")
|
|
242
|
+
toks = atks[0][0].strip().strip("{}").split() if atks[0] else []
|
|
243
|
+
if len(toks) != 2 or toks[1] != "B_EXPR_END" or not _VAR_RE.match(toks[0]):
|
|
244
|
+
raise AiAuthorError(f"[[scene.ai_phase]] #{n}: the Attack must read a single variable (e.g. "
|
|
245
|
+
f"Attack({{Instance.Byte[18] B_EXPR_END}})); this one is Attack({atks[0][0] if atks[0] else ''}) "
|
|
246
|
+
f"-- use [[scene.ai_insert]] instead")
|
|
247
|
+
return toks[0]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def apply_ai_phases(eb_bytes: bytes, specs, *, atk_count: int | None = None) -> bytes:
|
|
251
|
+
"""Apply ``[[scene.ai_phase]]`` specs: a high-level "enrage below X% HP" surface that GENERATES an HP-threshold
|
|
252
|
+
branch and splices it before the function's `Attack`. Each spec: ``entry`` + ``tag``; ``stat`` (hp/mp/at,
|
|
253
|
+
default hp); ``below`` (unit fraction, default 0.5); ``then`` / ``else`` (the attack index below / above the
|
|
254
|
+
threshold). The attack-index variable is INFERRED from the function's `Attack`. Built on `apply_ai_inserts``.
|
|
255
|
+
``atk_count`` (when known) range-checks then/else against the scene's attack table -- the one fault the composed
|
|
256
|
+
lint can't see, since the index flows through a runtime variable."""
|
|
257
|
+
if not isinstance(specs, list):
|
|
258
|
+
raise AiAuthorError("[[scene.ai_phase]] must be a list of tables")
|
|
259
|
+
eb = eb_bytes
|
|
260
|
+
for n, spec in enumerate(specs, 1):
|
|
261
|
+
if not isinstance(spec, dict):
|
|
262
|
+
raise AiAuthorError(f"[[scene.ai_phase]] #{n} must be a table (got {type(spec).__name__})")
|
|
263
|
+
try:
|
|
264
|
+
entry, tag = int(spec["entry"]), int(spec["tag"])
|
|
265
|
+
then_atk, else_atk = int(spec["then"]), int(spec["else"])
|
|
266
|
+
below = float(spec.get("below", 0.5)) # a non-numeric below -> a clean AiAuthorError, not a crash
|
|
267
|
+
except (KeyError, TypeError, ValueError):
|
|
268
|
+
raise AiAuthorError(f"[[scene.ai_phase]] #{n} needs integer entry, tag, then, else and a numeric below")
|
|
269
|
+
stat = str(spec.get("stat", "hp"))
|
|
270
|
+
_eb, _f, instrs = _func_pretty(eb, entry, tag)
|
|
271
|
+
var = _infer_attack_var(instrs, n)
|
|
272
|
+
source = _gen_hp_phase(stat, below, then_atk, else_atk, var, n, atk_count=atk_count)
|
|
273
|
+
eb = apply_ai_inserts(eb, [{"entry": entry, "tag": tag, "source": source, "before": "Attack"}])
|
|
274
|
+
return eb
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def validate_ai_edits(eb_bytes: bytes, *, inserts=None, phases=None, atk_count: int | None = None) -> list:
|
|
278
|
+
"""Dry-run :func:`apply_ai_inserts` / :func:`apply_ai_phases` + LINT the composed result (for the build's offline
|
|
279
|
+
validate). Returns error strings (empty == ok) -- never raises on a bad spec (returns it as an error string)."""
|
|
280
|
+
from . import ailint as _ailint
|
|
281
|
+
out = eb_bytes
|
|
282
|
+
try:
|
|
283
|
+
if phases:
|
|
284
|
+
out = apply_ai_phases(out, phases, atk_count=atk_count)
|
|
285
|
+
if inserts:
|
|
286
|
+
out = apply_ai_inserts(out, inserts)
|
|
287
|
+
except AiAuthorError as ex:
|
|
288
|
+
return [str(ex)]
|
|
289
|
+
return [f"lint: {i}" for i in _ailint.lint_ai(out, atk_count=atk_count)]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _assemble(block_text: str) -> bytes:
|
|
293
|
+
try:
|
|
294
|
+
body = cmdasm.assemble_block(block_text)
|
|
295
|
+
except cmdasm.CmdAsmError as ex:
|
|
296
|
+
raise AiAuthorError(f"AI source did not assemble: {ex}")
|
|
297
|
+
# Require a flow terminator: the engine has no per-function length bound, so a body that doesn't end in RET
|
|
298
|
+
# (0x04) / TerminateEntry (0x1C) runs the IP off the end into adjacent bytecode at runtime (a runaway AI turn).
|
|
299
|
+
last = None
|
|
300
|
+
for last in disasm.iter_code(body, 0, len(body)):
|
|
301
|
+
pass
|
|
302
|
+
if last is None or last.op not in _TERMINATOR_OPS:
|
|
303
|
+
raise AiAuthorError("an AI branch must END in RET() (or TerminateEntry) -- otherwise the engine runs the "
|
|
304
|
+
"instruction pointer off the function into adjacent bytecode at runtime")
|
|
305
|
+
return body
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Phase-6c-iii: the enemy-AI LINTER -- validate a battle scene's AI bytecode OFFLINE (the "I can't see the game"
|
|
2
|
+
superpower applied to the AI stack). The capstone of Phase 6c: 6a reads the AI, 6b patches a constant, 6c-i/-ii
|
|
3
|
+
author expressions/branches, and this CHECKS the result before deploy.
|
|
4
|
+
|
|
5
|
+
The checks are all SOUND -- a shipping scene must lint CLEAN (validated by a sweep over real battle scenes), so
|
|
6
|
+
every check passes valid AI and only flags a genuine fault:
|
|
7
|
+
|
|
8
|
+
* decode -- every entry/function decodes cleanly to its declared boundary (a truncated/corrupt eb).
|
|
9
|
+
* jump bounds -- every relative jump (JMP/JMP_IFNOT/JMP_IF) lands ON an instruction inside its own function (a
|
|
10
|
+
jump out of bounds / into the middle of an instruction = a desync/crash; this also catches a backward
|
|
11
|
+
JMP_IFNOT, whose offset the engine reads UNSIGNED -> a huge out-of-bounds target).
|
|
12
|
+
* reachable terminator -- a forward reachability walk (follow jumps + fall-through, conditional = both, bound by
|
|
13
|
+
visited offsets so loops terminate); flag a function where a path falls through the END without hitting a
|
|
14
|
+
terminator (RET 0x04 / TerminateEntry 0x1C). The engine has NO per-function length bound, so such a path runs
|
|
15
|
+
the IP off into adjacent bytecode. (Trailing NOP padding after a RET/loop is correctly UNREACHABLE -> clean.)
|
|
16
|
+
* attack index -- an IMMEDIATE Attack (0x38) operand must be < the scene's attack count (an out-of-range index
|
|
17
|
+
reads past the scene's `atk[]` table). Skipped when the index is an expression (computed at runtime) or when
|
|
18
|
+
the attack count is unknown.
|
|
19
|
+
|
|
20
|
+
Read-only + offline. Provenance: only opcode NAMES are used; the donor bytes are read live.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from ..eb import disasm
|
|
27
|
+
from ..eb.model import EbScript
|
|
28
|
+
|
|
29
|
+
# the flow-TERMINATOR opcodes -- a path reaching one ENDS (the engine's per-function dispatch stops via adFin(),
|
|
30
|
+
# the IP never advances into adjacent bytecode). RET (case 4) + DELETE/TerminateEntry (case 28) are the common
|
|
31
|
+
# pair; the high ops whose DoEventCode return code (3-8) also routes through adFin() terminate identically:
|
|
32
|
+
# Battle 0x2A / Field 0x2B / WorldMap 0xB6 / STOP 0x4F / TetraMaster 0xAE / GameOver 0xF5 (verified vs EBin.cs).
|
|
33
|
+
# (Shared with aiauthor's authoring guard so the two never drift.)
|
|
34
|
+
TERMINATOR_OPS = {0x04, 0x1C, 0x2A, 0x2B, 0x4F, 0xAE, 0xB6, 0xF5}
|
|
35
|
+
_TERMINATORS = TERMINATOR_OPS
|
|
36
|
+
_JUMP_OPS = {0x01, 0x02, 0x03} # JMP / JMP_IFNOT / JMP_IF (op<0x10, a 2-byte relative offset operand)
|
|
37
|
+
_JUMP_TABLE_OPS = {0x06, 0x0B, 0x0D} # SWITCHEX / SWITCH / SWITCH2 -- a multi-target dispatch (conservatively
|
|
38
|
+
# treated as terminating a reachability path: it transfers control onward)
|
|
39
|
+
_ATTACK_OP = 0x38 # the Attack command -- operand 0 selects an attack from the scene's atk[] table
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AiIssue:
|
|
44
|
+
severity: str # "error" | "warning"
|
|
45
|
+
where: str # e.g. "entry1/tag5 @1159"
|
|
46
|
+
message: str
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return f"[{self.severity}] {self.where}: {self.message}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _jump_target(ins) -> int | None:
|
|
53
|
+
"""The absolute target of a relative jump, or None if its offset is an expression (computed at runtime).
|
|
54
|
+
|
|
55
|
+
Signedness MATCHES the engine: JMP (0x01, ``bra``) and JMP_IF (0x03, ``bne``->``bra``) read a SIGNED int16
|
|
56
|
+
(``getShortIP``); JMP_IFNOT (0x02, ``beq``) reads its skip offset UNSIGNED (``getUShortIP``) -- so a backward
|
|
57
|
+
JMP_IFNOT becomes a huge forward target the bounds check flags (the exact fault the linter exists to catch)."""
|
|
58
|
+
if ins.arg_is_expr[0]:
|
|
59
|
+
return None
|
|
60
|
+
raw = ins.imm(0)
|
|
61
|
+
if ins.op == 0x02: # JMP_IFNOT (beq) -- engine reads this UNSIGNED
|
|
62
|
+
return ins.end + raw
|
|
63
|
+
return ins.end + (raw - 0x10000 if raw >= 0x8000 else raw) # JMP / JMP_IF -- signed int16
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _lint_function(data: bytes, where: str, start: int, end: int, atk_count) -> list:
|
|
67
|
+
issues: list = []
|
|
68
|
+
instrs: dict = {}
|
|
69
|
+
try:
|
|
70
|
+
for ins in disasm.iter_code(data, start, end):
|
|
71
|
+
instrs[ins.off] = ins
|
|
72
|
+
except (IndexError, KeyError):
|
|
73
|
+
return [AiIssue("error", where, "bytecode does not decode cleanly (truncated/corrupt)")]
|
|
74
|
+
if not instrs:
|
|
75
|
+
return [AiIssue("error", where, "empty function body")]
|
|
76
|
+
last = instrs[max(instrs)]
|
|
77
|
+
if last.end != end: # decode under/overran the declared boundary
|
|
78
|
+
return [AiIssue("error", where, f"bytecode does not decode to the function boundary "
|
|
79
|
+
f"(last instr ends at {last.end}, boundary {end})")]
|
|
80
|
+
|
|
81
|
+
# jump bounds + attack index (per instruction)
|
|
82
|
+
for off, ins in instrs.items():
|
|
83
|
+
if ins.op in _JUMP_OPS:
|
|
84
|
+
tgt = _jump_target(ins)
|
|
85
|
+
if tgt is not None and (tgt < start or tgt >= end or tgt not in instrs):
|
|
86
|
+
issues.append(AiIssue("error", f"{where} @{off}",
|
|
87
|
+
f"{disasm.op_name(ins.op)} target {tgt} is outside the function / not an "
|
|
88
|
+
f"instruction boundary [{start}..{end})"))
|
|
89
|
+
elif ins.op == _ATTACK_OP and atk_count is not None and ins.args and not ins.arg_is_expr[0]:
|
|
90
|
+
idx = ins.imm(0)
|
|
91
|
+
if idx is not None and idx >= atk_count:
|
|
92
|
+
issues.append(AiIssue("error", f"{where} @{off}",
|
|
93
|
+
f"Attack index {idx} >= the scene's attack count {atk_count}"))
|
|
94
|
+
|
|
95
|
+
# reachable terminator -- a forward walk; flag a path that falls through the end without a terminator
|
|
96
|
+
seen: set = set()
|
|
97
|
+
stack = [start]
|
|
98
|
+
ran_off = False
|
|
99
|
+
while stack:
|
|
100
|
+
o = stack.pop()
|
|
101
|
+
if o >= end: # a path fell through the function boundary
|
|
102
|
+
ran_off = True
|
|
103
|
+
continue
|
|
104
|
+
if o in seen or o not in instrs: # already explored, or a bad target (already flagged)
|
|
105
|
+
continue
|
|
106
|
+
seen.add(o)
|
|
107
|
+
op = instrs[o].op
|
|
108
|
+
if op in _TERMINATORS or op in _JUMP_TABLE_OPS: # path ends here (RET / dispatched onward)
|
|
109
|
+
continue
|
|
110
|
+
if op == 0x01: # unconditional JMP -> its target only
|
|
111
|
+
tgt = _jump_target(instrs[o])
|
|
112
|
+
stack.append(tgt if tgt is not None else instrs[o].end)
|
|
113
|
+
elif op in (0x02, 0x03): # conditional -> the target AND the fall-through
|
|
114
|
+
tgt = _jump_target(instrs[o])
|
|
115
|
+
if tgt is not None:
|
|
116
|
+
stack.append(tgt)
|
|
117
|
+
stack.append(instrs[o].end)
|
|
118
|
+
else:
|
|
119
|
+
stack.append(instrs[o].end) # fall through to the next instruction
|
|
120
|
+
if ran_off:
|
|
121
|
+
issues.append(AiIssue("error", where, "a control-flow path runs off the end of the function without a "
|
|
122
|
+
"terminator (RET/TerminateEntry) -- the engine would execute "
|
|
123
|
+
"adjacent bytecode at runtime"))
|
|
124
|
+
return issues
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def lint_ai(eb_bytes: bytes, *, atk_count: int | None = None) -> list:
|
|
128
|
+
"""Lint a battle scene's enemy-AI ``.eb`` -> a list of :class:`AiIssue` (empty == clean). ``atk_count`` (the
|
|
129
|
+
scene's attack-table size, from ``scene_data.parse_counts``) enables the Attack-index range check. Read-only."""
|
|
130
|
+
try:
|
|
131
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
132
|
+
except (ValueError, IndexError) as ex:
|
|
133
|
+
return [AiIssue("error", "eb", f"malformed battle .eb: {type(ex).__name__}: {ex}")]
|
|
134
|
+
issues: list = []
|
|
135
|
+
for e in eb.entries:
|
|
136
|
+
if e.empty:
|
|
137
|
+
continue
|
|
138
|
+
for f in e.funcs:
|
|
139
|
+
issues += _lint_function(eb.data, f"entry{e.index}/tag{f.tag}", f.abs_start, f.abs_end, atk_count)
|
|
140
|
+
return issues
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Phase-6b: SAME-LENGTH enemy-AI constant patches -- the first AI *authoring* step (read = Phase-6a `battleai`).
|
|
2
|
+
|
|
3
|
+
An enemy's AI is the per-scene ``EVT_BATTLE_*.eb`` bytecode. The safest authoring edit is a *literal* one: change
|
|
4
|
+
a numeric CONSTANT in place without moving any bytes -- an HP threshold a phase-switch compares (``B_CONST`` in
|
|
5
|
+
an expression), the attack index a turn selects (a ``BTLCMD`` immediate), a ``Wait`` count. No length change means
|
|
6
|
+
no ``fpos``/entry-table fixup and no risk of mis-packing -- byte-accurate by construction (the eb-codec identity
|
|
7
|
+
holds), exactly like ``scene_data``'s surgical raw16 patch.
|
|
8
|
+
|
|
9
|
+
Addressing is by BYTE OFFSET (from ``battle-ai --sites``) + a required OLD-value guard: the patch only applies if
|
|
10
|
+
the constant at that offset currently equals ``old`` (so a stale/wrong offset fails LOUD instead of corrupting a
|
|
11
|
+
random byte), and ``new`` must fit the SAME byte width. Because a battle eb's bytecode is language-identical
|
|
12
|
+
(only the 84-byte name field differs), the same offset patches every language's eb.
|
|
13
|
+
|
|
14
|
+
This reaches NUMERIC LITERALS only (command immediates + ``B_CONST``/``B_CONST4`` expression literals) -- the
|
|
15
|
+
"same-length literal patch" tier. Structural AI changes (new branches, an expression assembler, retargeting which
|
|
16
|
+
variable is read) are Phase-6c. Read-the-AI-first is mandatory: there is no semantic search; you cite the offset
|
|
17
|
+
the disassembler prints.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from ..eb._optables import OP_ARG_COUNT
|
|
24
|
+
from ..eb import disasm as _disasm
|
|
25
|
+
from ..eb.model import EbScript
|
|
26
|
+
|
|
27
|
+
_I32 = 2 ** 31 - 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AiPatchError(ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Site:
|
|
36
|
+
"""One patchable numeric constant in the AI bytecode."""
|
|
37
|
+
offset: int # absolute byte offset of the constant's first byte (the patch target)
|
|
38
|
+
width: int # byte width (1/2/3/4) -- a same-length patch occupies exactly these bytes
|
|
39
|
+
value: int # the current little-endian unsigned value
|
|
40
|
+
where: str # human context, e.g. "entry2/tag1 BTLCMD arg0" or "entry2/tag1 expr-const"
|
|
41
|
+
vmax: int # the largest value the ENGINE accepts here (usually 2^(8w)-1; B_CONST4 masks to 26 bits)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _full_max(width: int) -> int:
|
|
45
|
+
return (1 << (8 * width)) - 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _le(raw: bytes, pos: int, sz: int) -> int:
|
|
49
|
+
v = 0
|
|
50
|
+
for k in range(sz):
|
|
51
|
+
v |= raw[pos + k] << (8 * k)
|
|
52
|
+
return v
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _expr_constants(raw: bytes, pos: int, ctx: str) -> tuple[list, int]:
|
|
56
|
+
"""Walk one expression token stream (mirrors :func:`disasm.pretty_expr`), collecting the ``B_CONST`` (2-byte)
|
|
57
|
+
and ``B_CONST4`` (4-byte) literal sites. Returns (sites, new_pos)."""
|
|
58
|
+
sites = []
|
|
59
|
+
while True:
|
|
60
|
+
o = raw[pos]; pos += 1
|
|
61
|
+
isconst = o in (0x7D, 0x7E)
|
|
62
|
+
isvar = o >= 0xC0 or o in (0x29, 0x5F, 0x78, 0x79, 0x7A)
|
|
63
|
+
if not isconst and not isvar:
|
|
64
|
+
if o == 0x7F:
|
|
65
|
+
break
|
|
66
|
+
continue
|
|
67
|
+
if o == 0x7E: # B_CONST4 -- a 4-byte literal, MASKED to 26 bits in-engine
|
|
68
|
+
sites.append(Site(pos, 4, _le(raw, pos, 4), f"{ctx} expr-const4", 0x3FFFFFF)); pos += 4
|
|
69
|
+
elif o == 0x7D: # B_CONST -- a 2-byte literal (signed 16; byte-faithful)
|
|
70
|
+
sites.append(Site(pos, 2, _le(raw, pos, 2), f"{ctx} expr-const", _full_max(2))); pos += 2
|
|
71
|
+
elif o >= 0xE0 or o == 0x78: # long var / B_OBJSPECA -- 2 operand bytes (NOT a literal)
|
|
72
|
+
pos += 2
|
|
73
|
+
else: # short var / B_SYSLIST / B_SYSVAR / B_MEMBER / B_PTR
|
|
74
|
+
pos += 1
|
|
75
|
+
return sites, pos
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _func_constants(raw: bytes, start: int, end: int, ctx: str) -> list:
|
|
79
|
+
"""Collect every patchable numeric constant in ``raw[start:end]`` (command immediates + expression literals).
|
|
80
|
+
Mirrors :func:`disasm.read_code`'s operand walk exactly so the offsets always line up with the disassembly."""
|
|
81
|
+
sites = []
|
|
82
|
+
pos = start
|
|
83
|
+
guard = 0
|
|
84
|
+
while pos < end and guard < 100000:
|
|
85
|
+
guard += 1
|
|
86
|
+
op = raw[pos]; pos += 1
|
|
87
|
+
if op == 0xFF:
|
|
88
|
+
op = 0x100 | raw[pos]; pos += 1
|
|
89
|
+
ac = OP_ARG_COUNT[op] if op < len(OP_ARG_COUNT) else 0
|
|
90
|
+
arg_flag = 0
|
|
91
|
+
if op >= 0x10 and ac != 0:
|
|
92
|
+
arg_flag = raw[pos]; pos += 1
|
|
93
|
+
if op == 0x05:
|
|
94
|
+
arg_flag = 1
|
|
95
|
+
if ac < 0:
|
|
96
|
+
ac = raw[pos]; pos += 1
|
|
97
|
+
if op == 0x0D:
|
|
98
|
+
ac |= raw[pos] << 8; pos += 1
|
|
99
|
+
if op == 0x06:
|
|
100
|
+
ac = 1 + 2 * ac
|
|
101
|
+
elif op in (0x0B, 0x0D):
|
|
102
|
+
ac = 2 + ac
|
|
103
|
+
for i in range(ac):
|
|
104
|
+
if arg_flag & (1 << i):
|
|
105
|
+
esites, pos = _expr_constants(raw, pos, ctx)
|
|
106
|
+
sites += esites
|
|
107
|
+
else:
|
|
108
|
+
sz = _disasm.argsize(op, i)
|
|
109
|
+
if sz:
|
|
110
|
+
sites.append(Site(pos, sz, _le(raw, pos, sz), f"{ctx} {_disasm.op_name(op)} arg{i}", _full_max(sz)))
|
|
111
|
+
pos += sz
|
|
112
|
+
return sites
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def constant_sites(eb_bytes: bytes) -> list:
|
|
116
|
+
"""Every patchable numeric constant in a battle ``.eb``'s AI, in byte order. The ``offset`` of each is the
|
|
117
|
+
``at`` you cite in an ``[[scene.ai_patch]]``; the disassembler (``battle-ai --sites``) prints them."""
|
|
118
|
+
try: # a truncated/corrupt eb (e.g. a bad funcCount) can index
|
|
119
|
+
eb = EbScript.from_bytes(eb_bytes) # past the buffer during parse -> raise a CLEAN error, not
|
|
120
|
+
except (ValueError, IndexError) as ex: # a raw IndexError (mirrors battleai.disassemble_ai)
|
|
121
|
+
raise AiPatchError(f"malformed/truncated AI .eb: {type(ex).__name__}: {ex}")
|
|
122
|
+
out = []
|
|
123
|
+
for e in eb.entries:
|
|
124
|
+
if e.empty:
|
|
125
|
+
continue
|
|
126
|
+
for f in e.funcs:
|
|
127
|
+
ctx = f"entry{e.index}/tag{f.tag}"
|
|
128
|
+
out += _func_constants(eb.data, f.abs_start, min(f.abs_end, len(eb.data)), ctx)
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def apply_ai_patches(eb_bytes: bytes, patches) -> tuple[bytes, list]:
|
|
133
|
+
"""Apply ``[{at, old, new}, ...]`` same-length constant patches to ``eb_bytes``. Each ``at`` must be a real
|
|
134
|
+
constant site whose current value == ``old`` (the guard) and whose width fits ``new``. Returns (patched, warns).
|
|
135
|
+
Raises AiPatchError on a bad offset / old-mismatch / width-overflow -- so a wrong patch fails the build, never
|
|
136
|
+
the game."""
|
|
137
|
+
if not isinstance(patches, list):
|
|
138
|
+
raise AiPatchError("[[scene.ai_patch]] must be a list of tables")
|
|
139
|
+
sites = {s.offset: s for s in constant_sites(eb_bytes)}
|
|
140
|
+
b = bytearray(eb_bytes)
|
|
141
|
+
warnings: list = []
|
|
142
|
+
seen: dict = {}
|
|
143
|
+
for n, p in enumerate(patches):
|
|
144
|
+
if not isinstance(p, dict):
|
|
145
|
+
raise AiPatchError(f"[[scene.ai_patch]] #{n} must be a table (got {type(p).__name__})")
|
|
146
|
+
at, old, new = p.get("at"), p.get("old"), p.get("new")
|
|
147
|
+
for k, v in (("at", at), ("old", old), ("new", new)):
|
|
148
|
+
if not isinstance(v, int) or isinstance(v, bool):
|
|
149
|
+
raise AiPatchError(f"[[scene.ai_patch]] #{n} needs integer {k} (at = offset, old/new = values)")
|
|
150
|
+
if at in seen:
|
|
151
|
+
warnings.append(f"[[scene.ai_patch]] #{n} and #{seen[at]} both patch offset {at} -- the later wins")
|
|
152
|
+
seen[at] = n
|
|
153
|
+
site = sites.get(at)
|
|
154
|
+
if site is None:
|
|
155
|
+
raise AiPatchError(f"[[scene.ai_patch]] #{n}: no patchable constant at offset {at} "
|
|
156
|
+
f"(cite an offset from `battle-ai --sites`)")
|
|
157
|
+
if site.value != old:
|
|
158
|
+
raise AiPatchError(f"[[scene.ai_patch]] #{n}: expected old = {old} at offset {at}, but the eb has "
|
|
159
|
+
f"{site.value} ({site.where}) -- wrong offset, or already patched?")
|
|
160
|
+
if not 0 <= new <= site.vmax: # site.vmax handles ANY width + the B_CONST4 26-bit mask
|
|
161
|
+
note = " (the engine masks this B_CONST4 literal to 26 bits)" if site.vmax == 0x3FFFFFF else ""
|
|
162
|
+
raise AiPatchError(f"[[scene.ai_patch]] #{n}: new = {new} does not fit the {site.width}-byte constant "
|
|
163
|
+
f"at offset {at} (0-{site.vmax}){note} -- a same-length patch can't widen it")
|
|
164
|
+
for k in range(site.width): # little-endian, generic width (1/2/3/4) -> no struct map
|
|
165
|
+
b[at + k] = (new >> (8 * k)) & 0xFF
|
|
166
|
+
return bytes(b), warnings
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def validate_patches(eb_bytes: bytes, patches) -> list:
|
|
170
|
+
"""Offline problems (empty => OK): re-run the patch on a copy and surface any AiPatchError as a message."""
|
|
171
|
+
try:
|
|
172
|
+
apply_ai_patches(eb_bytes, patches)
|
|
173
|
+
return []
|
|
174
|
+
except AiPatchError as ex:
|
|
175
|
+
return [str(ex)]
|