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
ff9mapkit/logic_add.py
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""Phase 4a: length-CHANGING in-place ADDITIONS to a verbatim fork's ``.eb`` -- PREPEND a guarded effect to
|
|
2
|
+
an existing routine. The structural sibling of :mod:`ff9mapkit.logic_edit` (length-PRESERVING value swaps).
|
|
3
|
+
|
|
4
|
+
Where ``[[logic_edit]]`` overwrites an operand same-width, ``[[logic_add]]`` ADDS instructions, which changes
|
|
5
|
+
the ``.eb``'s length. Two placements:
|
|
6
|
+
* ``where = "prepend"`` (default, Phase 4a) -- :func:`ff9mapkit.eb.edit.insert_in_function` at ``rel_off=0``,
|
|
7
|
+
the 676/676-proven ALWAYS-safe prepend (the engine is uniformly IP-relative, so the whole function body
|
|
8
|
+
shifts together; safe even over a 0x06/0x0B switch table).
|
|
9
|
+
* ``where = "after"`` (Phase 4b) -- insert the effect AFTER the ``after_nth``-th ``after_op`` instruction, via
|
|
10
|
+
the keystone (:func:`ff9mapkit.eb.cmdasm.disassemble_items` -> splice the effect's labeled source ->
|
|
11
|
+
:func:`ff9mapkit.eb.cmdasm.assemble_block` -> :func:`ff9mapkit.eb.edit.replace_function_body`), so EVERY jump
|
|
12
|
+
and switch in the function relocates past the inserted bytes (the keystone round-trips all 676 fields +
|
|
13
|
+
3155/3155 switch functions byte-exact, and relocates them under a length change).
|
|
14
|
+
|
|
15
|
+
Kinds:
|
|
16
|
+
* ``set_flag`` -- write a GLOB story flag. IDEMPOTENT, so prepended UNGATED into any routine.
|
|
17
|
+
* ``give_item`` / ``give_gil`` -- CUMULATIVE (each call adds more), so wrapped in the FF9 chest once-guard
|
|
18
|
+
``if(!guard){guard=1; body}`` (the guard flag auto-allocated from the safe band) -- UNLESS it's a tag-3
|
|
19
|
+
talk handler with ``repeat = true`` (a deliberately repeatable per-interaction effect).
|
|
20
|
+
* ``show_line`` -- open a dialogue window showing an authored line. Its only effect is the message, so
|
|
21
|
+
it's the way to ANNOUNCE a silent ``give_item``/``give_gil`` (otherwise they hand over the reward with
|
|
22
|
+
no "Received X!" box). Any other kind may also carry an optional ``message = "..."`` to announce its
|
|
23
|
+
effect in the SAME once-guard (give THEN show, atomically).
|
|
24
|
+
* ``add_case`` -- ADD a new arm to an existing jump table (0x06/0x0B/0x0D) -- a NEW dispatch arm (a scenario
|
|
25
|
+
value, an ATE/dialogue menu row) that runs one of the effects above (named by ``effect``) then rejoins the
|
|
26
|
+
switch's DEFAULT arm (``then = "merge"``). 0x0B/0x0D are contiguous (``case = "auto"`` = base+ncases, the
|
|
27
|
+
only legal extension); 0x06 takes an explicit unused ``case`` value. Length-changing: the operand table
|
|
28
|
+
grows + the branch body is appended at the function end, and the keystone re-anchors every reloff.
|
|
29
|
+
``add_case`` alone makes an EXISTING-but-default selector value live -- it adds a dispatch arm but no
|
|
30
|
+
SELECTABLE, LABELLED menu row.
|
|
31
|
+
* ``menu_row`` -- the full coordinated MENU ROW: add a NEW selectable + labelled option to an existing
|
|
32
|
+
dialogue-choice menu. Orchestrates three legs over a CANONICAL menu (a base-0 contiguous GetChoose switch
|
|
33
|
+
+ a text-gated ``[CHOO]`` row list): (A) the dispatch arm (an ``add_case`` at the next contiguous row
|
|
34
|
+
index, running ``effect``), (B) a best-effort widen of the ``EnableDialogChoices`` (0x7C) availability
|
|
35
|
+
mask, and (C) a verified splice of the new ``\n[MOVE=18,0]<label>`` row into the menu's single ``.mes``
|
|
36
|
+
entry (the row-label leg ships in :func:`menu_row_text_plan` / :func:`apply_menu_row_text`, applied by the
|
|
37
|
+
build). v1 targets TEXT-gated menus (no pre-tag / ``[PCHC]``); a ``[PCHM]`` mask-gated menu (incl. the ATE
|
|
38
|
+
avail-word menus) fails closed -- the runtime mask leg is a follow-up. One ``menu_row`` per switch.
|
|
39
|
+
|
|
40
|
+
A ``show_line`` (or any ``message =``) line is APPENDED to the donor ``.mes`` above its txids -- the same
|
|
41
|
+
append-and-resolve channel ``[[on_entry]]`` narration uses (:func:`build._verbatim_on_entry_messages`) -- so
|
|
42
|
+
the inserted ``WindowSync`` resolves into real text. A message ALWAYS implies a once-guard (a window in a
|
|
43
|
+
tread zone would re-open every frame), even on an otherwise-idempotent ``set_flag``.
|
|
44
|
+
|
|
45
|
+
The effect bytes reuse the proven :mod:`ff9mapkit.content.region` / :mod:`ff9mapkit.content.event` encoders
|
|
46
|
+
verbatim (zero new bytecode). The composed ``.eb`` is re-validated by :func:`ff9mapkit.eblint.lint_eb` before
|
|
47
|
+
the build ships it -- a bad add fails the BUILD (a clean :class:`LogicAddError`), never a silent mis-splice.
|
|
48
|
+
"""
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import re
|
|
52
|
+
|
|
53
|
+
from . import flags as _flags
|
|
54
|
+
from .content import event as _event
|
|
55
|
+
from .content import region as _region
|
|
56
|
+
from .eb import edit as _edit
|
|
57
|
+
from .eb.model import EbScript
|
|
58
|
+
|
|
59
|
+
GLOB_BOOL = _region.GLOB_BOOL # 0xC4 -- save-persistent story-flag class
|
|
60
|
+
_ADD_KINDS = ("set_flag", "give_item", "give_gil", "show_line")
|
|
61
|
+
_CUMULATIVE = ("give_item", "give_gil") # need a once-guard (set_flag is idempotent)
|
|
62
|
+
TALK_TAG = 3 # the only tag where an UNGATED cumulative effect is sane
|
|
63
|
+
_GIL_CAP = 9_999_999
|
|
64
|
+
_SWITCH_OPS = (0x06, 0x0B, 0x0D) # JMP_SWITCHEX (explicit) / JMP_SWITCH (contiguous) / 2-byte
|
|
65
|
+
_DISPATCH_KINDS = ("add_case", "menu_row") # kinds whose EFFECT is named by `effect` (not `kind`)
|
|
66
|
+
ENABLE_DIALOG_CHOICES = 0x7C # CHOOSEPARAM: [argflags][availMask:u16][default:u8]
|
|
67
|
+
CHOICE_INDENT = "[MOVE=18,0]" # = content.text.CHOICE_INDENT (per-row menu indent)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _effective_effect_add(add):
|
|
71
|
+
"""The add whose ``kind`` names the EFFECT to encode. For a normal effect add that's ``add`` itself; for a
|
|
72
|
+
DISPATCH add (``add_case``/``menu_row`` -- ``kind`` stays the dispatch kind, the payload is named by
|
|
73
|
+
``effect``) it's a synthesized ``{**add, "kind": <effect>}`` -- so the effect/message/guard machinery treats
|
|
74
|
+
all of them uniformly. ``None`` for a dispatch add with no ``effect`` (a stub arm)."""
|
|
75
|
+
if add.get("kind") not in _DISPATCH_KINDS:
|
|
76
|
+
return add
|
|
77
|
+
eff = add.get("effect")
|
|
78
|
+
return {**add, "kind": eff} if eff is not None else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LogicAddError(ValueError):
|
|
82
|
+
"""A ``[[logic_add]]`` that can't be applied safely (bad routine, out-of-band flag, overflow, no guards
|
|
83
|
+
left, an unsafe ungated effect) -- it fails the BUILD, never silently mis-adds."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _int(add, key, *, default=None, optional=False):
|
|
87
|
+
if key not in add:
|
|
88
|
+
if optional:
|
|
89
|
+
return default
|
|
90
|
+
raise LogicAddError(f"logic_add ({add.get('kind', '?')}) missing required key '{key}'")
|
|
91
|
+
v = add[key]
|
|
92
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
93
|
+
raise LogicAddError(f"logic_add ({add.get('kind', '?')}) key '{key}' must be an integer, "
|
|
94
|
+
f"got {type(v).__name__} ({v!r})")
|
|
95
|
+
return v
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _add_message(add) -> "str | None":
|
|
99
|
+
"""The authored line this add SHOWS, or None. ``show_line`` requires one; any other kind may carry an
|
|
100
|
+
optional ``message = "..."`` to announce its effect. Raises on a present-but-malformed message."""
|
|
101
|
+
add = _effective_effect_add(add)
|
|
102
|
+
if add is None: # an add_case stub (no effect) -> no message
|
|
103
|
+
return None
|
|
104
|
+
msg = add.get("message")
|
|
105
|
+
if add.get("kind") == "show_line":
|
|
106
|
+
if not isinstance(msg, str) or not msg:
|
|
107
|
+
raise LogicAddError("logic_add show_line needs a non-empty `message` string (the line to show)")
|
|
108
|
+
return msg
|
|
109
|
+
if msg is None:
|
|
110
|
+
return None
|
|
111
|
+
if not isinstance(msg, str) or not msg:
|
|
112
|
+
raise LogicAddError(f"logic_add ({add.get('kind', '?')}) message must be a non-empty string")
|
|
113
|
+
return msg
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _needs_guard(add) -> bool:
|
|
117
|
+
"""A once-guard is needed for a CUMULATIVE effect (give_item/give_gil pile up) OR any add that shows a
|
|
118
|
+
MESSAGE (a window in a tread zone would re-open every frame). ``set_flag`` alone is idempotent -> ungated."""
|
|
119
|
+
return add.get("kind") in _CUMULATIVE or _add_message(add) is not None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _effect_body(add, *, message_txid=None) -> bytes:
|
|
123
|
+
"""The raw effect bytes for one add (no guard) -- reuses the content encoders. When the add carries a
|
|
124
|
+
message (or IS a ``show_line``), a ``WindowSync(message_txid)`` is APPENDED after the effect (give THEN
|
|
125
|
+
announce); ``message_txid`` is the build-allocated id of the appended ``.mes`` line."""
|
|
126
|
+
kind = add.get("kind")
|
|
127
|
+
msg = _add_message(add)
|
|
128
|
+
if msg is not None and message_txid is None: # the build allocates message txids; a
|
|
129
|
+
raise LogicAddError(f"logic_add ({kind}) has a message but no text id was allocated (internal: "
|
|
130
|
+
"apply_logic_adds needs message_txids -- the build/Check plan provides it)")
|
|
131
|
+
tail = _event.message(int(message_txid)) if msg is not None else b""
|
|
132
|
+
if kind == "show_line":
|
|
133
|
+
return tail # the message IS the whole effect
|
|
134
|
+
if kind == "set_flag":
|
|
135
|
+
idx, val = _int(add, "flag"), _int(add, "value", default=1, optional=True)
|
|
136
|
+
if not _flags.is_safe_custom(idx):
|
|
137
|
+
raise LogicAddError(f"set_flag index {idx} is outside the safe custom band "
|
|
138
|
+
f"[{_flags.FIRST_SAFE_FLAG}, {_flags.CHOICE_SCRATCH_FLOOR}) (or reserved)")
|
|
139
|
+
return _region.set_var(GLOB_BOOL, idx, val) + tail
|
|
140
|
+
if kind == "give_item":
|
|
141
|
+
count = _int(add, "count", default=1, optional=True)
|
|
142
|
+
if not (1 <= count <= 255):
|
|
143
|
+
raise LogicAddError(f"give_item count {count} out of range (1-255; AddItem count is one byte)")
|
|
144
|
+
try:
|
|
145
|
+
return _event.give_item(add.get("item"), count) + tail # name or id, resolved by items
|
|
146
|
+
except (ValueError, KeyError) as ex:
|
|
147
|
+
raise LogicAddError(f"give_item: {ex}")
|
|
148
|
+
if kind == "give_gil":
|
|
149
|
+
amount = _int(add, "amount")
|
|
150
|
+
if not (0 < amount <= _GIL_CAP):
|
|
151
|
+
raise LogicAddError(f"give_gil amount {amount} out of range (1-{_GIL_CAP})")
|
|
152
|
+
return _event.give_gil(amount) + tail
|
|
153
|
+
raise LogicAddError(f"logic_add unknown kind '{kind}' (kinds: {_ADD_KINDS})")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _guarded(body: bytes, guard: int) -> bytes:
|
|
157
|
+
"""``if(!guard){ guard=1; body }`` -- the FF9 chest once-guard (dedup-flag FIRST so a window in ``body``
|
|
158
|
+
can't double-fire), via the proven region encoders."""
|
|
159
|
+
return _region.if_block(_region.cond_not(GLOB_BOOL, guard),
|
|
160
|
+
_region.set_var(GLOB_BOOL, guard, 1) + body)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _GuardAlloc:
|
|
164
|
+
"""Hands out disjoint once-guard flags. Avoids collisions with: the author's other ``set_flag`` indices,
|
|
165
|
+
the project's OTHER authored story flags (``reserved`` -- ``[startup]``/``[[flag]]``/``[[on_entry]]``/
|
|
166
|
+
gateway ``set_flags``; else a guard could alias a load-time flag and silently pre-fire), each other, and
|
|
167
|
+
(in a campaign) a SIBLING member's window. Auto-guards are drawn from ``[base, base+window)`` (a campaign
|
|
168
|
+
member's flag block) or ``[FIRST_SAFE_FLAG, CHOICE_SCRATCH_FLOOR)`` for a single field; an explicit
|
|
169
|
+
``guard`` is band-checked + collision-checked but not window-confined (it may be a deliberate shared flag)."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, base, window, adds, reserved):
|
|
172
|
+
self._base = base if base is not None else _flags.FIRST_SAFE_FLAG
|
|
173
|
+
self._next = self._base
|
|
174
|
+
self._hi = (self._base + window) if (base is not None and window) else _flags.CHOICE_SCRATCH_FLOOR
|
|
175
|
+
self._hi = min(self._hi, _flags.CHOICE_SCRATCH_FLOOR)
|
|
176
|
+
self._used = {f for f in (reserved or ()) if isinstance(f, int) and not isinstance(f, bool)}
|
|
177
|
+
for a in adds or []: # other set_flag targets (handed-out guards are recorded in take)
|
|
178
|
+
ea = _effective_effect_add(a) # incl. an add_case whose effect is set_flag
|
|
179
|
+
if ea and ea.get("kind") == "set_flag" and isinstance(ea.get("flag"), int) and not isinstance(ea.get("flag"), bool):
|
|
180
|
+
self._used.add(ea["flag"])
|
|
181
|
+
|
|
182
|
+
def take(self, add):
|
|
183
|
+
g = add.get("guard")
|
|
184
|
+
if g is not None:
|
|
185
|
+
if isinstance(g, bool) or not isinstance(g, int):
|
|
186
|
+
raise LogicAddError("logic_add guard must be an integer")
|
|
187
|
+
if not _flags.is_safe_custom(g):
|
|
188
|
+
raise LogicAddError(f"logic_add guard {g} is outside the safe custom band "
|
|
189
|
+
f"[{_flags.FIRST_SAFE_FLAG}, {_flags.CHOICE_SCRATCH_FLOOR}) (or reserved)")
|
|
190
|
+
if g in self._used:
|
|
191
|
+
raise LogicAddError(f"logic_add guard {g} collides with another guard or an authored flag "
|
|
192
|
+
f"-- pick a free index in [{self._base}, {self._hi})")
|
|
193
|
+
self._used.add(g)
|
|
194
|
+
return g
|
|
195
|
+
while self._next < self._hi:
|
|
196
|
+
g = self._next
|
|
197
|
+
self._next += 1
|
|
198
|
+
if g not in self._used and _flags.is_safe_custom(g):
|
|
199
|
+
self._used.add(g)
|
|
200
|
+
return g
|
|
201
|
+
raise LogicAddError(f"logic_add ran out of safe guard flags in [{self._base}, {self._hi}) -- "
|
|
202
|
+
"raise [campaign] flags_per_field or set an explicit guard = N")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _core_bytes(add, alloc, warnings=None, *, message_txid=None) -> bytes:
|
|
206
|
+
"""The bytes to prepend for one add: the effect, guarded unless it's idempotent (a message-less
|
|
207
|
+
set_flag) or a deliberately-repeatable tag-3 talk effect (``repeat = true``)."""
|
|
208
|
+
body = _effect_body(add, message_txid=message_txid)
|
|
209
|
+
if not _needs_guard(add):
|
|
210
|
+
return body # set_flag w/o a message -- idempotent, ungated
|
|
211
|
+
if add.get("repeat"):
|
|
212
|
+
if _int(add, "tag") != TALK_TAG:
|
|
213
|
+
raise LogicAddError(f"logic_add {add['kind']} repeat=true is only allowed on a tag-{TALK_TAG} "
|
|
214
|
+
f"talk handler (got tag {_int(add, 'tag')}); else it would fire every frame")
|
|
215
|
+
if warnings is not None: # tag-3 is talk for an NPC but action-press for a region
|
|
216
|
+
warnings.append(f"[[logic_add]] {add['kind']} repeat=true on entry {_int(add, 'entry')} tag {TALK_TAG} "
|
|
217
|
+
"re-fires on EVERY interaction (NPC talk / region action-button press), not once")
|
|
218
|
+
return body # an opt-in per-interaction repeat
|
|
219
|
+
return _guarded(body, alloc.take(add)) # default: once-guarded (safe in any routine)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _insert_after(eb_bytes, eb, f, entry, tag, add, core, warnings) -> bytes:
|
|
223
|
+
"""Phase-4b: splice ``core`` AFTER the ``after_nth``-th occurrence of ``after_op`` in the routine. Uses the
|
|
224
|
+
keystone -- disassemble the function to labeled source, insert the effect's (re-labeled) source right after
|
|
225
|
+
the anchor instruction's line, reassemble (so EVERY jump/switch in the function relocates past the inserted
|
|
226
|
+
bytes), and swap the rebuilt body in via :func:`eb.edit.replace_function_body`. The effect's own once-guard
|
|
227
|
+
skip lands on the function's continuation, exactly as a prepend's does."""
|
|
228
|
+
from .eb import cmdasm as _cmdasm
|
|
229
|
+
from .eb import disasm as _disasm
|
|
230
|
+
from .eb import exprasm as _exprasm
|
|
231
|
+
after_op = _int(add, "after_op")
|
|
232
|
+
after_nth = _int(add, "after_nth", default=0, optional=True)
|
|
233
|
+
hits = [i for i in eb.instrs(f) if i.op == after_op]
|
|
234
|
+
if not (0 <= after_nth < len(hits)):
|
|
235
|
+
raise LogicAddError(f"logic_add where='after': no {_disasm.op_name(after_op)} #{after_nth} in "
|
|
236
|
+
f"entry{_int(add, 'entry')}/tag{_int(add, 'tag')} (found {len(hits)})")
|
|
237
|
+
anchor = hits[after_nth]
|
|
238
|
+
if warnings is not None and (anchor.op in _disasm.TERMINATOR_OPS or anchor.op == 0x01):
|
|
239
|
+
kind = "terminator" if anchor.op in _disasm.TERMINATOR_OPS else "unconditional JMP"
|
|
240
|
+
warnings.append(f"logic_add where='after' anchors on a {kind} ({_disasm.op_name(anchor.op)}) in "
|
|
241
|
+
f"entry{_int(add, 'entry')}/tag{_int(add, 'tag')} -- the inserted effect is unreachable "
|
|
242
|
+
"(control never falls through to it). Anchor on an earlier instruction.")
|
|
243
|
+
anchor_rel = anchor.off - f.abs_start
|
|
244
|
+
try: # the rebuild can raise CmdAsmError (a sibling of
|
|
245
|
+
items = _cmdasm.disassemble_items(eb.data, f.abs_start, f.abs_end) # LogicAddError) -- normalize it so the
|
|
246
|
+
line_idx = next((k for k, (off, _t) in enumerate(items) if off == anchor_rel), None) # build/Check report
|
|
247
|
+
if line_idx is None: # the anchor is a decoded instr -> always found
|
|
248
|
+
raise LogicAddError("logic_add where='after': could not locate the anchor instruction (internal)")
|
|
249
|
+
effect_src = _cmdasm.relabel(_cmdasm.disassemble_block(core, 0, len(core)), "_e")
|
|
250
|
+
texts = [t for _o, t in items]
|
|
251
|
+
spliced = "\n".join(texts[:line_idx + 1] + effect_src.split("\n") + texts[line_idx + 1:])
|
|
252
|
+
new_body = _cmdasm.assemble_block(spliced)
|
|
253
|
+
except (_cmdasm.CmdAsmError, _exprasm.AssembleError) as ex: # ...is a clean failure, not a raw traceback
|
|
254
|
+
raise LogicAddError(f"logic_add where='after': could not rebuild entry{_int(add, 'entry')}/"
|
|
255
|
+
f"tag{_int(add, 'tag')}: {ex}")
|
|
256
|
+
return _edit.replace_function_body(eb_bytes, entry, tag, new_body)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# --- add_case: ADD a new arm to a jump table (length-changing: grow the operand table + a new branch body) ---
|
|
260
|
+
def _locate_switch(eb, entry, tag, nth):
|
|
261
|
+
"""Find the ``nth`` switch (0x06/0x0B/0x0D) in entry/tag; return ``(f, ins, SwitchInfo)``. ``nth`` may be
|
|
262
|
+
None when the function has exactly one switch. Raises a clean :class:`LogicAddError` on any miss."""
|
|
263
|
+
from .eb import disasm as _disasm
|
|
264
|
+
if not (0 <= entry < eb.entry_count) or eb.entry(entry).empty:
|
|
265
|
+
raise LogicAddError(f"add_case entry {entry} is empty or out of range (0..{eb.entry_count - 1})")
|
|
266
|
+
f = eb.entry(entry).func_by_tag(tag)
|
|
267
|
+
if f is None:
|
|
268
|
+
raise LogicAddError(f"add_case entry {entry} has no function tag {tag}")
|
|
269
|
+
switches = [i for i in eb.instrs(f) if i.op in _SWITCH_OPS]
|
|
270
|
+
if not switches:
|
|
271
|
+
raise LogicAddError(f"add_case: no switch (0x06/0x0B/0x0D) in entry{entry}/tag{tag}")
|
|
272
|
+
if nth is None:
|
|
273
|
+
if len(switches) > 1:
|
|
274
|
+
raise LogicAddError(f"add_case: {len(switches)} switches in entry{entry}/tag{tag} -- "
|
|
275
|
+
f"add `nth` (0..{len(switches) - 1})")
|
|
276
|
+
nth = 0
|
|
277
|
+
if not (0 <= nth < len(switches)):
|
|
278
|
+
raise LogicAddError(f"add_case nth {nth} out of range (0..{len(switches) - 1})")
|
|
279
|
+
ins = switches[nth]
|
|
280
|
+
si = _disasm.decode_switch(ins)
|
|
281
|
+
if si is None:
|
|
282
|
+
raise LogicAddError(f"add_case: the switch in entry{entry}/tag{tag} has computed operands (can't add)")
|
|
283
|
+
return f, ins, si
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _resolve_add_case(add, si, ncases):
|
|
287
|
+
"""Validate + return the new selector value. 0x0B/0x0D (contiguous): only extends at ``base + ncases``;
|
|
288
|
+
``case = "auto"`` resolves to it, an explicit int must equal it. 0x06 (explicit): any unused value 0-65535."""
|
|
289
|
+
case = add.get("case", "auto")
|
|
290
|
+
if si.op in (0x0B, 0x0D):
|
|
291
|
+
nxt = si.base + ncases
|
|
292
|
+
if case == "auto":
|
|
293
|
+
return nxt
|
|
294
|
+
v = _int(add, "case")
|
|
295
|
+
if v != nxt:
|
|
296
|
+
raise LogicAddError(f"add_case: a contiguous SWITCH only extends at the NEXT selector {nxt} "
|
|
297
|
+
f"(base {si.base} + {ncases} cases) -- got {v}; use case=\"auto\"")
|
|
298
|
+
return nxt
|
|
299
|
+
if case == "auto": # 0x06 SWITCHEX -- needs an explicit value
|
|
300
|
+
raise LogicAddError("add_case: a 0x06 SWITCHEX needs an explicit `case` value (no contiguous 'auto')")
|
|
301
|
+
v = _int(add, "case")
|
|
302
|
+
if v in {e.value for e in si.edges if not e.is_default}:
|
|
303
|
+
raise LogicAddError(f"add_case: case value {v} already exists in this SWITCHEX (a duplicate arm is dead)")
|
|
304
|
+
if not (0 <= v <= 0xFFFF):
|
|
305
|
+
raise LogicAddError(f"add_case: case value {v} out of range (0-65535)")
|
|
306
|
+
return v
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _insert_case(eb_bytes, eb, f, entry, tag, ins, si, case_value, core):
|
|
310
|
+
"""Append a new case arm to the switch: grow its operand list (``L<new>`` for 0x0B/0x0D, ``value, L<new>``
|
|
311
|
+
for 0x06) and append the new branch (``NEWCASE:`` + the effect's re-labeled source + a ``JMP`` to the
|
|
312
|
+
switch's DEFAULT arm) at the function end, then reassemble (cmdasm re-anchors every reloff) + swap the body
|
|
313
|
+
in. The new arm's reloff is FORWARD (the branch is after the switch); the merge JMP is a plain backward
|
|
314
|
+
0x01 to the default. The count byte is recomputed by cmdasm from the operand-list length (no manual bump)."""
|
|
315
|
+
from .eb import cmdasm as _cmdasm
|
|
316
|
+
from .eb import exprasm as _exprasm
|
|
317
|
+
sw_rel = ins.off - f.abs_start
|
|
318
|
+
default_rel = next(e.target for e in si.edges if e.is_default) - f.abs_start
|
|
319
|
+
if not (0 <= default_rel < (f.abs_end - f.abs_start)): # a default arm AT the function end (a malformed
|
|
320
|
+
raise LogicAddError(f"add_case: the switch's default arm in entry{entry}/tag{tag} is not an in-function "
|
|
321
|
+
"instruction boundary -- no safe merge target (the donor is itself broken)")
|
|
322
|
+
try:
|
|
323
|
+
items = _cmdasm.disassemble_items(eb.data, f.abs_start, f.abs_end)
|
|
324
|
+
line_idx = next((k for k, (off, _t) in enumerate(items) if off == sw_rel), None)
|
|
325
|
+
if line_idx is None: # the located switch is decoded -> always present
|
|
326
|
+
raise LogicAddError("add_case: could not locate the switch (internal)")
|
|
327
|
+
texts = [t for _o, t in items]
|
|
328
|
+
line = texts[line_idx]
|
|
329
|
+
mnem = line[:line.index("(")]
|
|
330
|
+
ops = line[line.index("(") + 1:line.rindex(")")].split(", ")
|
|
331
|
+
ops += ([str(case_value), "NEWCASE"] if si.op == 0x06 else ["NEWCASE"]) # 0x0B/0x0D: positional (base+n)
|
|
332
|
+
texts[line_idx] = mnem + "(" + ", ".join(ops) + ")"
|
|
333
|
+
branch = ["NEWCASE:"]
|
|
334
|
+
if core: # the effect body (re-labeled so its L<n> can't collide)
|
|
335
|
+
branch += _cmdasm.relabel(_cmdasm.disassemble_block(core, 0, len(core)), "_c").split("\n")
|
|
336
|
+
branch.append(f"JMP(L{default_rel})") # then="merge": rejoin the switch's default arm
|
|
337
|
+
new_body = _cmdasm.assemble_block("\n".join(texts + branch))
|
|
338
|
+
except (_cmdasm.CmdAsmError, _exprasm.AssembleError) as ex:
|
|
339
|
+
raise LogicAddError(f"add_case: could not rebuild entry{entry}/tag{tag}: {ex}")
|
|
340
|
+
return _edit.replace_function_body(eb_bytes, entry, tag, new_body)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _apply_add_case(eb_bytes, add, alloc, warnings=None, *, message_txid=None) -> bytes:
|
|
344
|
+
"""Add ONE new case arm to an existing switch, running a reused effect (set_flag/give_item/give_gil/
|
|
345
|
+
show_line) then rejoining the switch's default arm. The keystone rebuild relocates everything."""
|
|
346
|
+
if add.get("repeat"):
|
|
347
|
+
raise LogicAddError("add_case does not support `repeat` (a dispatch arm is not a tag-3 talk poll)")
|
|
348
|
+
then = add.get("then", "merge")
|
|
349
|
+
if then != "merge":
|
|
350
|
+
raise LogicAddError(f"add_case then='{then}' is not supported (v1 ships then=\"merge\" = rejoin the "
|
|
351
|
+
"switch's own default arm)")
|
|
352
|
+
eff = add.get("effect")
|
|
353
|
+
if eff is None:
|
|
354
|
+
raise LogicAddError("add_case needs an `effect` (set_flag/give_item/give_gil/show_line) -- a stub arm "
|
|
355
|
+
"that only rejoins the default is a no-op")
|
|
356
|
+
if eff not in _ADD_KINDS:
|
|
357
|
+
raise LogicAddError(f"add_case effect '{eff}' must be one of {_ADD_KINDS}")
|
|
358
|
+
entry, tag = _int(add, "entry"), _int(add, "tag")
|
|
359
|
+
nth = _int(add, "nth", default=None, optional=True)
|
|
360
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
361
|
+
f, ins, si = _locate_switch(eb, entry, tag, nth)
|
|
362
|
+
ncases = len([e for e in si.edges if not e.is_default])
|
|
363
|
+
cc = _int(add, "case_count", default=None, optional=True) # optional shape guard (donor drift -> fail)
|
|
364
|
+
if cc is not None and cc != ncases:
|
|
365
|
+
raise LogicAddError(f"add_case case_count guard: the switch has {ncases} cases, not {cc} (donor drift)")
|
|
366
|
+
cap = 65535 if si.op == 0x0D else 255 # the count byte width (0x06/0x0B = 1 byte)
|
|
367
|
+
if ncases >= cap:
|
|
368
|
+
raise LogicAddError(f"add_case: this switch is full ({ncases}/{cap} cases)")
|
|
369
|
+
case_value = _resolve_add_case(add, si, ncases)
|
|
370
|
+
core = _core_bytes(_effective_effect_add(add), alloc, warnings, message_txid=message_txid)
|
|
371
|
+
return _insert_case(eb_bytes, eb, f, entry, tag, ins, si, case_value, core)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# --- menu_row: the full coordinated ADD of a selectable+labelled choice-menu row -----------------------
|
|
375
|
+
def _menu_row_switch(eb, add):
|
|
376
|
+
"""Locate + VALIDATE a ``menu_row``'s dispatch switch: it must be a base-0 CONTIGUOUS GetChoose switch
|
|
377
|
+
(0x0B/0x0D, base 0) so the row index IS the case value (1:1). Returns ``(f, ins, si, new_row)`` where
|
|
378
|
+
``new_row`` = the existing case count = the next contiguous row index. Raises a clean LogicAddError on a
|
|
379
|
+
non-canonical menu (explicit 0x06 / non-zero base) -- author those with a manual add_case + logic_edit text."""
|
|
380
|
+
from .eb import disasm as _disasm
|
|
381
|
+
entry, tag = _int(add, "entry"), _int(add, "tag")
|
|
382
|
+
nth = _int(add, "nth", default=None, optional=True)
|
|
383
|
+
f, ins, si = _locate_switch(eb, entry, tag, nth)
|
|
384
|
+
if si.op not in (0x0B, 0x0D):
|
|
385
|
+
raise LogicAddError(f"menu_row needs a CONTIGUOUS GetChoose switch (0x0B/0x0D); entry{entry}/tag{tag}'s "
|
|
386
|
+
f"switch is {_disasm.op_name(si.op)} (explicit 0x06) -- author this with a manual "
|
|
387
|
+
"add_case (explicit case) + a [[logic_edit]] text row instead")
|
|
388
|
+
if si.base != 0:
|
|
389
|
+
raise LogicAddError(f"menu_row needs a base-0 GetChoose switch (the picked row index IS the case value); "
|
|
390
|
+
f"entry{entry}/tag{tag}'s switch has base {si.base} -- not a 1:1 menu")
|
|
391
|
+
new_row = len([e for e in si.edges if not e.is_default])
|
|
392
|
+
return f, ins, si, new_row
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _widen_dialog_mask(eb_bytes, eb, f, sw_ins, new_row, warnings=None) -> bytes:
|
|
396
|
+
"""BEST-EFFORT leg B: OR the new row's bit into the ``EnableDialogChoices`` (0x7C) availability mask that
|
|
397
|
+
sets up this menu (the last 0x7C before the switch), an in-place LENGTH-PRESERVING operand edit. A no-op
|
|
398
|
+
(with a warning) when the mask is absent (text-gated menu) or computed at runtime (an expression operand
|
|
399
|
+
-- ATE avail-word copy / dynamic flag-gated). For a TEXT-gated menu (no pre-tag / ``[PCHC]``) the mask is
|
|
400
|
+
not consulted, so leaving it unwidened is harmless; this keeps a literal all-on mask looking consistent."""
|
|
401
|
+
masks = [i for i in eb.instrs(f) if i.op == ENABLE_DIALOG_CHOICES and i.off < sw_ins.off]
|
|
402
|
+
if not masks:
|
|
403
|
+
if warnings is not None:
|
|
404
|
+
warnings.append("menu_row: no EnableDialogChoices (0x7C) precedes the switch -- the menu enables its "
|
|
405
|
+
"rows from the text ([CHOO] rows / a [PCHC] count), so the new row relies on the .mes "
|
|
406
|
+
"row (+ count); no mask to widen.")
|
|
407
|
+
return eb_bytes
|
|
408
|
+
m = masks[-1]
|
|
409
|
+
if m.arg_is_expr[0]: # a RUNTIME-computed mask -- no literal to patch
|
|
410
|
+
if warnings is not None:
|
|
411
|
+
warnings.append("menu_row: the EnableDialogChoices mask is computed at runtime (expression operand), "
|
|
412
|
+
"not widened. v1 targets text-gated menus ([PCHC]/no pre-tag); if this menu hides "
|
|
413
|
+
"rows via a [PCHM] mask, the new row may not be selectable.")
|
|
414
|
+
return eb_bytes
|
|
415
|
+
if new_row >= 16: # the availMask is a u16 -- bit 16+ is unrepresentable
|
|
416
|
+
if warnings is not None:
|
|
417
|
+
warnings.append(f"menu_row: row index {new_row} exceeds the 16-bit EnableDialogChoices mask -- the "
|
|
418
|
+
"availability bit can't be set. (A [PCHM] mask-gated menu with >16 rows is unsupported; "
|
|
419
|
+
"a text-gated menu doesn't consult the mask, so this is harmless there.)")
|
|
420
|
+
return eb_bytes
|
|
421
|
+
old = m.imm(0)
|
|
422
|
+
new = (old | (1 << new_row)) & 0xFFFF
|
|
423
|
+
if new == old:
|
|
424
|
+
return eb_bytes # bit already set
|
|
425
|
+
ba = bytearray(eb_bytes)
|
|
426
|
+
ba[m.off + 2:m.off + 4] = new.to_bytes(2, "little") # op(1) + argflags(1) -> mask u16 LE at off+2
|
|
427
|
+
return bytes(ba)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _apply_menu_row(eb_bytes, add, alloc, warnings=None, *, message_txid=None) -> bytes:
|
|
431
|
+
"""The ``.eb`` side of a ``menu_row``: widen the availability mask (leg B), then ADD the dispatch arm at
|
|
432
|
+
the next contiguous row index (leg A = ``add_case`` with ``case="auto"``, running ``effect``). The ``.mes``
|
|
433
|
+
row-label leg (C) is applied by the build via :func:`menu_row_text_plan` / :func:`apply_menu_row_text`."""
|
|
434
|
+
label = add.get("label")
|
|
435
|
+
if not isinstance(label, str) or not label:
|
|
436
|
+
raise LogicAddError("menu_row needs a non-empty `label` (the new row's menu text)")
|
|
437
|
+
_int(add, "menu_txid") # required: the .mes entry holding the choice rows
|
|
438
|
+
if add.get("case", "auto") != "auto":
|
|
439
|
+
raise LogicAddError('menu_row uses case="auto" (the new row is always the next contiguous choice index)')
|
|
440
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
441
|
+
f, ins, si, new_row = _menu_row_switch(eb, add) # validates base-0 contiguous + computes new_row
|
|
442
|
+
eb_bytes = _widen_dialog_mask(eb_bytes, eb, f, ins, new_row, warnings)
|
|
443
|
+
return _apply_add_case(eb_bytes, add, alloc, warnings, message_txid=message_txid) # leg A (the dispatch arm)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _apply_one(eb_bytes, add, alloc, warnings=None, *, message_txid=None) -> bytes:
|
|
447
|
+
kind = add.get("kind")
|
|
448
|
+
if kind == "add_case": # ADD a switch arm (length-changing table grow)
|
|
449
|
+
return _apply_add_case(eb_bytes, add, alloc, warnings, message_txid=message_txid)
|
|
450
|
+
if kind == "menu_row": # ADD a selectable+labelled choice-menu row
|
|
451
|
+
return _apply_menu_row(eb_bytes, add, alloc, warnings, message_txid=message_txid)
|
|
452
|
+
if kind not in _ADD_KINDS:
|
|
453
|
+
raise LogicAddError(f"logic_add unknown kind '{kind}' (kinds: {_ADD_KINDS} + 'add_case'/'menu_row')")
|
|
454
|
+
where = add.get("where", "prepend")
|
|
455
|
+
if where not in ("prepend", "after"):
|
|
456
|
+
raise LogicAddError(f"logic_add where='{where}' is not supported (use 'prepend' or 'after'; "
|
|
457
|
+
"'after' takes an after_op anchor)")
|
|
458
|
+
entry, tag = _int(add, "entry"), _int(add, "tag")
|
|
459
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
460
|
+
if not (0 <= entry < eb.entry_count) or eb.entry(entry).empty:
|
|
461
|
+
raise LogicAddError(f"logic_add entry {entry} is empty or out of range (0..{eb.entry_count - 1})")
|
|
462
|
+
f = eb.entry(entry).func_by_tag(tag)
|
|
463
|
+
if f is None:
|
|
464
|
+
raise LogicAddError(f"logic_add entry {entry} has no function tag {tag}")
|
|
465
|
+
core = _core_bytes(add, alloc, warnings, message_txid=message_txid)
|
|
466
|
+
if where == "prepend":
|
|
467
|
+
return _edit.insert_in_function(eb_bytes, entry, tag, 0, core) # the always-safe rel_off=0 prepend
|
|
468
|
+
return _insert_after(eb_bytes, eb, f, entry, tag, add, core, warnings) # mid-function (keystone rebuild)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _normalize_adds(adds):
|
|
472
|
+
"""The canonical filtered add list -- shared by :func:`apply_logic_adds` and :func:`plan_messages` so a
|
|
473
|
+
message txid keyed by index lines up with the add the build actually applies. Raises on a non-list or a
|
|
474
|
+
non-table element (the same clean errors the build/Check surface)."""
|
|
475
|
+
if adds is None:
|
|
476
|
+
return []
|
|
477
|
+
if not isinstance(adds, (list, tuple)): # a single [logic_add] table (or junk) -> clean error
|
|
478
|
+
raise LogicAddError("logic_add must be an array of tables ([[logic_add]]), not "
|
|
479
|
+
f"{type(adds).__name__} (you likely wrote [logic_add] instead of [[logic_add]])")
|
|
480
|
+
adds = [a for a in adds if a]
|
|
481
|
+
for a in adds:
|
|
482
|
+
if not isinstance(a, dict):
|
|
483
|
+
raise LogicAddError(f"each logic_add must be a table, got {type(a).__name__}")
|
|
484
|
+
return adds
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def plan_messages(adds):
|
|
488
|
+
"""The appended ``.mes`` lines a ``[[logic_add]]`` list needs, in apply order: ``[(idx, message,
|
|
489
|
+
speaker, tail)]`` for every add that SHOWS a line (a ``show_line`` or any kind with ``message =``).
|
|
490
|
+
``idx`` is the index into the normalized (filtered) add list -- the SAME index :func:`apply_logic_adds`
|
|
491
|
+
enumerates -- so the build can allocate one txid per entry and hand them back as ``message_txids``."""
|
|
492
|
+
out = []
|
|
493
|
+
for idx, add in enumerate(_normalize_adds(adds)):
|
|
494
|
+
msg = _add_message(add)
|
|
495
|
+
if msg is not None:
|
|
496
|
+
out.append((idx, msg, add.get("speaker"), add.get("tail")))
|
|
497
|
+
return out
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def apply_logic_adds(eb_bytes, adds, *, guard_base=None, guard_window=None, reserved_flags=None,
|
|
501
|
+
message_txids=None, warnings=None) -> bytes:
|
|
502
|
+
"""Apply every ``[[logic_add]]`` to ``eb_bytes`` (a guarded PREPEND, or a mid-function insert for
|
|
503
|
+
``where="after"``) and return the new bytes. Empty -> byte-identical. Raises :class:`LogicAddError` on
|
|
504
|
+
any unsafe add. ``guard_base``/``guard_window`` confine auto once-guards to a campaign member's flag
|
|
505
|
+
block; ``reserved_flags`` is the project's OTHER authored safe-band flags (so a guard never aliases one).
|
|
506
|
+
``message_txids`` maps a normalized-add index (see :func:`plan_messages`) to the txid of its appended
|
|
507
|
+
``.mes`` line -- required for any add that shows a message. ``warnings`` (a list) collects advisories."""
|
|
508
|
+
adds = _normalize_adds(adds)
|
|
509
|
+
if not adds:
|
|
510
|
+
return bytes(eb_bytes)
|
|
511
|
+
message_txids = message_txids or {}
|
|
512
|
+
alloc = _GuardAlloc(guard_base, guard_window, adds, reserved_flags)
|
|
513
|
+
if any(a.get("kind") == "menu_row" for a in adds): # leg-A/leg-C alignment guard (see below)
|
|
514
|
+
_assert_menu_row_switches(EbScript.from_bytes(eb_bytes), adds)
|
|
515
|
+
out = bytes(eb_bytes)
|
|
516
|
+
for idx, add in enumerate(adds):
|
|
517
|
+
out = _apply_one(out, add, alloc, warnings, message_txid=message_txids.get(idx))
|
|
518
|
+
return out
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# --- menu_row leg C: the .mes row-label splice (applied by the build, NOT part of the .eb byte stream) ---
|
|
522
|
+
_PCHC_RE = re.compile(r"\[PCHC=(\d+),(\d+)((?:,\d+)*)\]") # pre-choose config: count, cancel-row, +extra fields
|
|
523
|
+
_TRAILING_TAGS_RE = re.compile(r"(?:\[[A-Z][A-Z0-9]*(?:=[^\]]*)?\])+$") # a run of [TAG]/[TAG=..] at the very end
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _switch_off(eb, add):
|
|
527
|
+
"""The absolute byte offset of the switch an ``add_case``/``menu_row`` targets on ``eb``'s bytes, or None for
|
|
528
|
+
a non-dispatch add. Used to detect two dispatch adds hitting the SAME switch regardless of how ``nth`` is
|
|
529
|
+
spelled (``None`` vs an explicit ``0`` on a single-switch function resolve to the same instruction)."""
|
|
530
|
+
if add.get("kind") not in _DISPATCH_KINDS:
|
|
531
|
+
return None
|
|
532
|
+
_f, ins, _si = _locate_switch(eb, _int(add, "entry"), _int(add, "tag"),
|
|
533
|
+
_int(add, "nth", default=None, optional=True))
|
|
534
|
+
return ins.off
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _assert_menu_row_switches(eb, adds):
|
|
538
|
+
"""Reject a ``menu_row`` that shares its switch with ANY other dispatch add (``add_case``/``menu_row``).
|
|
539
|
+
The ``menu_row`` row index (leg C) is planned from the PRE-add switch, while the dispatch case (leg A) is
|
|
540
|
+
computed from the LIVE bytes as adds apply sequentially -- so a second dispatch add growing the same switch
|
|
541
|
+
first would land leg A on a different case than the planned row, mis-aligning the menu (no error otherwise).
|
|
542
|
+
Keys by switch OFFSET, so it also catches ``nth=None`` vs ``nth=0`` duplicates + two menu_rows on one switch.
|
|
543
|
+
``eb`` = the pre-add bytes; a non-locatable other add is skipped (its own miss surfaces at apply)."""
|
|
544
|
+
dispatch = [(i, a) for i, a in enumerate(adds) if a.get("kind") in _DISPATCH_KINDS]
|
|
545
|
+
offs = {}
|
|
546
|
+
for i, a in dispatch:
|
|
547
|
+
try:
|
|
548
|
+
offs[i] = _switch_off(eb, a)
|
|
549
|
+
except LogicAddError:
|
|
550
|
+
offs[i] = None # the other add's own error surfaces at apply
|
|
551
|
+
for i, a in dispatch:
|
|
552
|
+
if a.get("kind") != "menu_row" or offs[i] is None:
|
|
553
|
+
continue
|
|
554
|
+
for j, _b in dispatch:
|
|
555
|
+
if j != i and offs[j] == offs[i]:
|
|
556
|
+
raise LogicAddError(f"menu_row at index {i} shares its switch (entry{_int(a, 'entry')}/"
|
|
557
|
+
f"tag{_int(a, 'tag')}) with another dispatch add (add_case/menu_row) at index "
|
|
558
|
+
f"{j} -- they would mis-align the row index. Use ONE menu_row per switch, or "
|
|
559
|
+
"split across fields.")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def menu_row_text_plan(eb_bytes, adds):
|
|
563
|
+
"""For each ``menu_row`` add (in normalized order): ``(idx, menu_txid, label, new_row)`` -- the data the
|
|
564
|
+
build needs to splice the row LABEL into the donor ``.mes``. ``new_row`` is read from the ORIGINAL switch
|
|
565
|
+
(``eb_bytes`` = the pre-``logic_add`` bytes), so it matches the dispatch arm :func:`apply_logic_adds` adds.
|
|
566
|
+
Empty -> no ``.mes`` work. :func:`_assert_menu_row_switches` enforces ONE dispatch add per switch (a second
|
|
567
|
+
would mis-align the row index). Raises a clean :class:`LogicAddError` on a non-canonical menu."""
|
|
568
|
+
adds = _normalize_adds(adds)
|
|
569
|
+
if not any(a.get("kind") == "menu_row" for a in adds):
|
|
570
|
+
return []
|
|
571
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
572
|
+
_assert_menu_row_switches(eb, adds)
|
|
573
|
+
out = []
|
|
574
|
+
for idx, add in enumerate(adds):
|
|
575
|
+
if add.get("kind") != "menu_row":
|
|
576
|
+
continue
|
|
577
|
+
label = add.get("label")
|
|
578
|
+
if not isinstance(label, str) or not label:
|
|
579
|
+
raise LogicAddError("menu_row needs a non-empty `label` (the new row's menu text)")
|
|
580
|
+
if "\n" in label:
|
|
581
|
+
raise LogicAddError(f"menu_row label may not contain a newline (it would inject a phantom row): {label!r}")
|
|
582
|
+
_f, _ins, _si, new_row = _menu_row_switch(eb, add)
|
|
583
|
+
out.append((idx, _int(add, "menu_txid"), label, new_row))
|
|
584
|
+
return out
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def apply_menu_row_text(body, plan, lang, warnings=None):
|
|
588
|
+
r"""Leg C: splice each planned ``menu_row``'s ``\n[MOVE=18,0]<label>`` row into the menu's single ``.mes``
|
|
589
|
+
entry (the verbatim donor body), bumping a ``[PCHC]`` row count. A VERIFIED splice (every other entry stays
|
|
590
|
+
byte-identical, via :func:`logic_edit.verified_mes_splice`). Fails CLOSED on a ``[PCHM]`` mask-gated menu, a
|
|
591
|
+
missing/non-choice entry, an unsupported trailing window tag (only a final ``[IMME]`` is handled), or a row
|
|
592
|
+
count that doesn't match the dispatch (so the row index and case stay 1:1). ``plan`` =
|
|
593
|
+
:func:`menu_row_text_plan`; empty plan/body -> unchanged."""
|
|
594
|
+
if not plan or not body:
|
|
595
|
+
return body
|
|
596
|
+
from . import logic_edit as _le
|
|
597
|
+
from .dialogue import parse_mes
|
|
598
|
+
for (_idx, menu_txid, label, new_row) in plan:
|
|
599
|
+
ent = parse_mes(body).get(menu_txid)
|
|
600
|
+
if ent is None:
|
|
601
|
+
raise LogicAddError(f"menu_row: menu_txid {menu_txid} not found in the {lang} .mes")
|
|
602
|
+
text = ent.text
|
|
603
|
+
choo = text.find("[CHOO]")
|
|
604
|
+
if choo < 0:
|
|
605
|
+
raise LogicAddError(f"menu_row: menu_txid {menu_txid} is not a choice menu (no [CHOO] tag) in the "
|
|
606
|
+
f"{lang} .mes")
|
|
607
|
+
if "[PCHM=" in text: # mask-gated (any arity) -- v1 fails closed
|
|
608
|
+
raise LogicAddError(f"menu_row: menu_txid {menu_txid} is a [PCHM] mask-gated menu -- v1 targets "
|
|
609
|
+
"text-gated menus ([PCHC] / no pre-tag); a mask-gated row is a follow-up")
|
|
610
|
+
after = text[choo + len("[CHOO]"):] # the row list (then maybe a trailing tag run)
|
|
611
|
+
tail = _TRAILING_TAGS_RE.search(after)
|
|
612
|
+
tail_str = tail.group(0) if tail else ""
|
|
613
|
+
if tail_str and tail_str != "[IMME]": # an unknown trailing window tag -> don't corrupt it
|
|
614
|
+
raise LogicAddError(f"menu_row: menu_txid {menu_txid} ends with an unsupported trailing tag "
|
|
615
|
+
f"{tail_str!r} ({lang} .mes) -- v1 handles plain rows (+ a final [IMME])")
|
|
616
|
+
rows = after[:len(after) - len(tail_str)].split("\n") # the row segments (excluding the trailing tag run)
|
|
617
|
+
if len(rows) != new_row:
|
|
618
|
+
raise LogicAddError(f"menu_row: menu_txid {menu_txid} has {len(rows)} row(s) but the dispatch expects "
|
|
619
|
+
f"the new row at index {new_row} ({lang} .mes) -- the menu's rows and its "
|
|
620
|
+
"GetChoose switch aren't 1:1 (donor drift / not a single-line canonical menu)")
|
|
621
|
+
seg = "\n" + CHOICE_INDENT + label
|
|
622
|
+
new_text = (text[:len(text) - len(tail_str)] + seg + tail_str) if tail_str else (text + seg)
|
|
623
|
+
cm = _PCHC_RE.search(text)
|
|
624
|
+
if cm and cm.start() < choo: # a [PCHC] BEFORE the rows: bump count, keep the rest
|
|
625
|
+
new_text = (new_text[:cm.start()] + f"[PCHC={int(cm.group(1)) + 1},{cm.group(2)}{cm.group(3)}]"
|
|
626
|
+
+ new_text[cm.end():])
|
|
627
|
+
elif warnings is not None: # no pre-tag: CANCEL (B) = the (new) last row
|
|
628
|
+
warnings.append(f"menu_row: menu_txid {menu_txid} has no pre-tag, so CANCEL (B) returns the LAST row "
|
|
629
|
+
"-- the new row is now last, so B will trigger it. Add a [PCHC] cancel row if that's "
|
|
630
|
+
"unwanted.")
|
|
631
|
+
body = _le.verified_mes_splice(body, menu_txid, new_text, lang=lang, err=LogicAddError)
|
|
632
|
+
return body
|