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