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_edit.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"""Phase-2: in-place, length-preserving, GUARDED value edits on a verbatim fork's ``.eb`` (+ ``.mes`` text).
|
|
2
|
+
|
|
3
|
+
The write-side sibling of :mod:`ff9mapkit.logic_map` (read) and :mod:`ff9mapkit.eblint` (validate): a verbatim
|
|
4
|
+
fork ships the donor's whole compiled ``.eb``, and this lets you EDIT it IN PLACE -- change an item reward, a gil
|
|
5
|
+
amount, a ``Field()`` warp destination, a story-flag index, a dialogue txid, or rewrite a dialogue STRING -- WITHOUT
|
|
6
|
+
regenerating or splicing the script (that's Phase 4). Every ``.eb`` edit is strictly LENGTH-PRESERVING (a same-width
|
|
7
|
+
operand overwrite), so the entry table / fpos never move; the composed ``.eb`` is re-validated by the Phase-3 linter
|
|
8
|
+
(:func:`ff9mapkit.eblint.lint_eb`) before the build ships it. Each edit is **old-guarded**: it locates its site by
|
|
9
|
+
``entry``/``tag``/``op`` AND the current value, and REFUSES (a clean ``LogicEditError`` -> build failure, never a
|
|
10
|
+
silent mis-patch) if the donor bytes drifted. Authored declaratively as ``[[logic_edit]]`` in the member field.toml;
|
|
11
|
+
applied in build's verbatim pass (CLAUDE.md / docs/FORK_FIDELITY.md). Empty list -> byte-identical no-op.
|
|
12
|
+
|
|
13
|
+
v1 kinds: ``field`` (0x2B dest) · ``item`` (0x48 id/count) · ``gil`` (0xCE amount) · ``txid`` (a Window op's text id)
|
|
14
|
+
· ``flag_index`` (the GLOB ``C4``/``E4`` index inside an 0x05 expression -- a same-width remap is an in-place
|
|
15
|
+
operand swap; a remap that CROSSES the 0xFF C4/E4 token boundary is length-changing and rebuilt via the Phase-4b
|
|
16
|
+
keystone) · ``switch_case`` (REDIRECT one case/default arm of a jump table 0x06/0x0B/0x0D to a different
|
|
17
|
+
in-function target -- re-wire which branch a dialogue-menu row / ATE / scenario value triggers; keystone rebuild,
|
|
18
|
+
length-neutral) · ``text`` (a ``.mes`` dialogue-string rewrite, targets the per-language ``.mes``, not the
|
|
19
|
+
``.eb``). Deferred: ADDING a switch case (a new menu row / dispatch arm -- length-changing, a logic_add follow-up).
|
|
20
|
+
|
|
21
|
+
The ``.eb`` bytecode is language-identical (only the 84-byte name differs), so one edit set patches all 7 langs.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
import struct
|
|
27
|
+
from dataclasses import dataclass, field as _dc_field
|
|
28
|
+
|
|
29
|
+
from .eb import disasm
|
|
30
|
+
from .eb.model import EbScript
|
|
31
|
+
from .content.object import _arg_byte_offset
|
|
32
|
+
|
|
33
|
+
ITEM_OP = 0x48 # AddItem(item_id:u16, count:u8)
|
|
34
|
+
GIL_OP = 0xCE # AddGil(amount:u24)
|
|
35
|
+
FIELD_OP = 0x2B # Field(dest:u16)
|
|
36
|
+
EXPR_OP = 0x05 # an expression statement (a GLOB flag read/write rides here)
|
|
37
|
+
SETTEXTVAR_OP = 0x66 # SetTextVariable(slot:u8, value:u16) -- feeds the "Received <item>!" DISPLAY id
|
|
38
|
+
WINDOW_OPS = {0x1F: 2, 0x20: 2, 0x95: 3, 0x96: 3} # Window op -> its txid operand index (dialogue.WINDOW_OPS)
|
|
39
|
+
_ITEM_OPERAND = {"id": 0, "count": 1}
|
|
40
|
+
_EB_KINDS = ("field", "item", "gil", "txid", "flag_index", "operand", "item_display", "item_count", "switch_case")
|
|
41
|
+
_SWITCH_OPS = (0x06, 0x0B, 0x0D) # JMP_SWITCHEX (explicit) / JMP_SWITCH (contiguous) / 2-byte-count variant
|
|
42
|
+
_TAIL_RE = re.compile(r"\[TAIL=([^\]]*)\]")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LogicEditError(ValueError):
|
|
46
|
+
"""A logic-edit that can't be applied safely (bad address, drifted donor, overflow, unsupported) -- it
|
|
47
|
+
fails the BUILD, never silently mis-patches the shipped script."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _req(ed, key):
|
|
51
|
+
if key not in ed:
|
|
52
|
+
raise LogicEditError(f"logic_edit ({ed.get('kind', '?')}) missing required key '{key}'")
|
|
53
|
+
return ed[key]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _int(ed, key, *, optional=False):
|
|
57
|
+
"""Require ``key`` be a plain int (TOML floats/strings/bools are author mistakes -> a clean LogicEditError,
|
|
58
|
+
not a raw TypeError). ``optional`` returns None when the key is absent."""
|
|
59
|
+
if optional and key not in ed:
|
|
60
|
+
return None
|
|
61
|
+
v = _req(ed, key)
|
|
62
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
63
|
+
raise LogicEditError(f"logic_edit ({ed.get('kind', '?')}) key '{key}' must be an integer, "
|
|
64
|
+
f"got {type(v).__name__} ({v!r})")
|
|
65
|
+
return v
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _func(eb, ed):
|
|
69
|
+
entry, tag = _int(ed, "entry"), _int(ed, "tag")
|
|
70
|
+
if not (0 <= entry < eb.entry_count):
|
|
71
|
+
raise LogicEditError(f"logic_edit entry {entry} out of range (0..{eb.entry_count - 1})")
|
|
72
|
+
e = eb.entry(entry)
|
|
73
|
+
if e.empty:
|
|
74
|
+
raise LogicEditError(f"logic_edit entry {entry} is an empty slot")
|
|
75
|
+
f = e.func_by_tag(tag)
|
|
76
|
+
if f is None:
|
|
77
|
+
raise LogicEditError(f"logic_edit entry {entry} has no function tag {tag}")
|
|
78
|
+
return f
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pick(hits, ed, what):
|
|
82
|
+
"""Choose among instrs already filtered to match the old value: exactly one, or ``nth`` to disambiguate."""
|
|
83
|
+
if not hits:
|
|
84
|
+
raise LogicEditError(f"logic_edit found no {what} (the donor drifted or the address is wrong)")
|
|
85
|
+
if len(hits) == 1:
|
|
86
|
+
return hits[0]
|
|
87
|
+
nth = _int(ed, "nth", optional=True)
|
|
88
|
+
if nth is None:
|
|
89
|
+
raise LogicEditError(f"logic_edit is ambiguous: {len(hits)} {what} -- add `nth` (0..{len(hits) - 1})")
|
|
90
|
+
if not (0 <= nth < len(hits)):
|
|
91
|
+
raise LogicEditError(f"logic_edit nth={nth} out of range (0..{len(hits) - 1}) for {what}")
|
|
92
|
+
return hits[nth]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _guarded_write(buf, abs_off, w, old, new):
|
|
96
|
+
"""Overwrite ``w`` bytes at ``abs_off`` IN PLACE, asserting the current bytes encode ``old`` first (a real
|
|
97
|
+
guard that also catches an offset miscalculation). ``new`` must fit ``w`` bytes."""
|
|
98
|
+
if not (0 <= new < (1 << (8 * w))):
|
|
99
|
+
raise LogicEditError(f"logic_edit new value {new} doesn't fit a {w}-byte operand")
|
|
100
|
+
expect = old.to_bytes(w, "little")
|
|
101
|
+
cur = bytes(buf[abs_off:abs_off + w])
|
|
102
|
+
if cur != expect:
|
|
103
|
+
raise LogicEditError(f"logic_edit guard @{abs_off}: expected {expect.hex()} got {cur.hex()} "
|
|
104
|
+
"(donor drift or a bad address)")
|
|
105
|
+
buf[abs_off:abs_off + w] = new.to_bytes(w, "little")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _operand_edit(eb, buf, ed, op, operand_index, *, extra=None, what=None):
|
|
109
|
+
"""Locate an instr (op, current operand == old, + an optional ``extra(ins)`` guard) in entry/tag and
|
|
110
|
+
overwrite that operand same-width. ``extra`` lets a kind pin a SECOND operand (e.g. the item-display
|
|
111
|
+
text slot) so the value-filtered nth matches discovery -- without it, an unrelated same-value instr
|
|
112
|
+
could be mis-targeted."""
|
|
113
|
+
f = _func(eb, ed)
|
|
114
|
+
old, new = _int(ed, "old"), _int(ed, "new")
|
|
115
|
+
hits = [i for i in eb.instrs(f) if i.op == op and i.imm(operand_index) == old and (extra is None or extra(i))]
|
|
116
|
+
ins = _pick(hits, ed, what or (f"{disasm.op_name(op)} with operand[{operand_index}]=={old} in "
|
|
117
|
+
f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}"))
|
|
118
|
+
bo = _arg_byte_offset(ins, operand_index)
|
|
119
|
+
if bo is None:
|
|
120
|
+
raise LogicEditError(f"logic_edit cannot address {disasm.op_name(op)} operand {operand_index} "
|
|
121
|
+
"(a preceding operand is an expression)")
|
|
122
|
+
_guarded_write(buf, ins.off + bo, disasm.argsize(op, operand_index), old, new)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _flag_width(idx: int) -> int:
|
|
126
|
+
"""The GLOB var token's index width in BYTES: 1 for ``idx <= 0xFF`` (the C4 short token), 2 for the E4 long
|
|
127
|
+
token. The engine reads the token byte to know the width, so a remap crossing 0xFF changes BOTH the token
|
|
128
|
+
byte and its length -- a length-changing edit (the keystone rebuild), not an in-place same-width swap."""
|
|
129
|
+
return 1 if idx <= 0xFF else 2
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _flag_locate(eb, ed):
|
|
133
|
+
"""Find the 0x05 expression instruction that reads/writes GLOB flag ``ed['flag']`` (disambiguated by
|
|
134
|
+
``nth``); return ``(ins, idx, tok_len, old, new)``. Shared by the in-place and re-width flag paths so they
|
|
135
|
+
locate IDENTICALLY. Raises on an out-of-range target or a flag that isn't found (donor drift / bad addr)."""
|
|
136
|
+
from .eventscan import _glob_var_token
|
|
137
|
+
f = _func(eb, ed)
|
|
138
|
+
old, new = _int(ed, "flag"), _int(ed, "new_flag")
|
|
139
|
+
if not (0 <= new <= 0xFFFF):
|
|
140
|
+
raise LogicEditError(f"logic_edit flag remap target {new} out of range (0-65535)")
|
|
141
|
+
hits = []
|
|
142
|
+
for i in eb.instrs(f):
|
|
143
|
+
if i.op != EXPR_OP:
|
|
144
|
+
continue
|
|
145
|
+
tok = _glob_var_token(eb.data, i.off + 1) # the var token sits right after the 0x05
|
|
146
|
+
if tok is not None and tok[0] == old:
|
|
147
|
+
hits.append((i, tok))
|
|
148
|
+
ins, (idx, tok_len) = _pick(hits, ed, f"GLOB flag {old} read/write in "
|
|
149
|
+
f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
|
|
150
|
+
return ins, idx, tok_len, old, new
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _flag_edit(eb, buf, ed):
|
|
154
|
+
"""In-place (length-preserving) GLOB story-flag index remap inside an 0x05 expression -- ONLY when the new
|
|
155
|
+
index stays in the SAME C4/E4 width class. A cross-0xFF remap is length-changing and routed to
|
|
156
|
+
:func:`_flag_rewidth` by :func:`apply_logic_edits`; this raises if one somehow reaches the in-place path."""
|
|
157
|
+
ins, idx, tok_len, old, new = _flag_locate(eb, ed)
|
|
158
|
+
if _flag_width(new) != (tok_len - 1): # C4 -> 1-byte index, E4 -> 2-byte index
|
|
159
|
+
raise LogicEditError(f"internal: cross-0xFF flag remap {old}->{new} must use the re-width pass")
|
|
160
|
+
_guarded_write(buf, ins.off + 2, tok_len - 1, old, new) # 0x05 at off, token byte at off+1, index at off+2
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _flag_rewidth(eb_bytes, ed, anchor_rel, old, new) -> bytes:
|
|
164
|
+
"""A cross-0xFF GLOB flag remap (length-CHANGING: the C4 short token <-> the E4 long token), applied at the
|
|
165
|
+
EXACT instruction located during the split (``anchor_rel`` is its function-relative offset, captured before
|
|
166
|
+
the in-place pass and adjusted for any prior same-function rebuild). Rewrite that 0x05 expression's
|
|
167
|
+
``Global.Bit[old]`` token to ``Global.Bit[new]`` in the disassembled function SOURCE and reassemble via the
|
|
168
|
+
keystone (``exprasm`` picks the new index's natural width; ``cmdasm`` relocates every jump/switch past the
|
|
169
|
+
length change), then swap the rebuilt body in. Old-guarded: it asserts the byte at ``anchor_rel`` is still a
|
|
170
|
+
0x05 reading ``old`` (so conflicting edits that drifted the site fail cleanly, never silently mis-patch)."""
|
|
171
|
+
from .eb import cmdasm as _cmdasm
|
|
172
|
+
from .eb import edit as _edit
|
|
173
|
+
from .eb import exprasm as _exprasm
|
|
174
|
+
from .eb._exprtable import decode_var
|
|
175
|
+
from .eventscan import _glob_var_token, GLOB_BOOL_SHORT, GLOB_BOOL_LONG
|
|
176
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
177
|
+
f = _func(eb, ed)
|
|
178
|
+
abs_off = f.abs_start + anchor_rel
|
|
179
|
+
tok = _glob_var_token(eb.data, abs_off + 1) if (0 <= abs_off < len(eb.data) and eb.data[abs_off] == EXPR_OP) else None
|
|
180
|
+
if tok is None or tok[0] != old: # the captured site no longer holds the old flag
|
|
181
|
+
raise LogicEditError(f"logic_edit flag remap {old}->{new}: the target 0x05 expression in "
|
|
182
|
+
f"entry{_int(ed, 'entry')}/tag{_int(ed, 'tag')} drifted (conflicting edits?)")
|
|
183
|
+
old_tok = decode_var(GLOB_BOOL_SHORT if old <= 0xFF else GLOB_BOOL_LONG, old) # "Global.Bit[old]"
|
|
184
|
+
new_tok = decode_var(GLOB_BOOL_SHORT if new <= 0xFF else GLOB_BOOL_LONG, new) # "Global.Bit[new]"
|
|
185
|
+
try:
|
|
186
|
+
items = _cmdasm.disassemble_items(eb.data, f.abs_start, f.abs_end)
|
|
187
|
+
line_idx = next((k for k, (off, _t) in enumerate(items) if off == anchor_rel), None)
|
|
188
|
+
if line_idx is None: # the guarded instr is decoded -> always present
|
|
189
|
+
raise LogicEditError("logic_edit flag remap: could not locate the expression instruction (internal)")
|
|
190
|
+
_off, text = items[line_idx]
|
|
191
|
+
if old_tok not in text: # second guard: the decoded expr must show the old flag
|
|
192
|
+
raise LogicEditError(f"logic_edit flag remap: {old_tok} not in the decoded expression ({text})")
|
|
193
|
+
texts = [t for _o, t in items]
|
|
194
|
+
texts[line_idx] = text.replace(old_tok, new_tok)
|
|
195
|
+
new_body = _cmdasm.assemble_block("\n".join(texts))
|
|
196
|
+
except (_cmdasm.CmdAsmError, _exprasm.AssembleError) as ex: # normalize the rebuild failure (clean build error)
|
|
197
|
+
raise LogicEditError(f"logic_edit flag remap {old}->{new}: could not rebuild "
|
|
198
|
+
f"entry{_int(ed, 'entry')}/tag{_int(ed, 'tag')}: {ex}")
|
|
199
|
+
return _edit.replace_function_body(eb_bytes, _int(ed, "entry"), _int(ed, "tag"), new_body)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# --- switch_case: REDIRECT a jump-table arm (0x06/0x0B/0x0D) to a different in-function target -------------
|
|
203
|
+
def _switch_case_key(ed):
|
|
204
|
+
"""The selector an edit targets: an int case VALUE, or the string ``"default"``."""
|
|
205
|
+
case = ed.get("case")
|
|
206
|
+
if case == "default":
|
|
207
|
+
return "default"
|
|
208
|
+
if isinstance(case, bool) or not isinstance(case, int):
|
|
209
|
+
raise LogicEditError('logic_edit switch_case needs `case` = an integer selector value or "default"')
|
|
210
|
+
return case
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _switch_locate(eb, ed):
|
|
214
|
+
"""Find the switch instruction + the edge for ``case`` in entry/tag; return ``(ins, anchor_rel, case,
|
|
215
|
+
old_target)``. Disambiguate among multiple switches with ``nth``. Guard: the selected edge currently
|
|
216
|
+
resolves to ``ed['old_target']`` (a function-relative offset) -- a wrong old_target / drifted donor fails."""
|
|
217
|
+
f = _func(eb, ed)
|
|
218
|
+
case = _switch_case_key(ed)
|
|
219
|
+
old_target = _int(ed, "old_target")
|
|
220
|
+
switches = [i for i in eb.instrs(f) if i.op in _SWITCH_OPS]
|
|
221
|
+
if not switches:
|
|
222
|
+
raise LogicEditError(f"logic_edit switch_case: no switch (0x06/0x0B/0x0D) in "
|
|
223
|
+
f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
|
|
224
|
+
ins = _pick(switches, ed, f"switch instruction in entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
|
|
225
|
+
si = disasm.decode_switch(ins)
|
|
226
|
+
if si is None: # a switch whose operands aren't plain immediates
|
|
227
|
+
raise LogicEditError(f"logic_edit switch_case: could not decode the switch in "
|
|
228
|
+
f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')} (computed operands?)")
|
|
229
|
+
if case == "default":
|
|
230
|
+
edge = next((e for e in si.edges if e.is_default), None)
|
|
231
|
+
else:
|
|
232
|
+
edge = next((e for e in si.edges if not e.is_default and e.value == case), None)
|
|
233
|
+
if edge is None:
|
|
234
|
+
raise LogicEditError(f"logic_edit switch_case: no case {case} in the switch "
|
|
235
|
+
f"(values: {[e.value for e in si.edges if not e.is_default]})")
|
|
236
|
+
cur = edge.target - f.abs_start
|
|
237
|
+
if cur != old_target:
|
|
238
|
+
raise LogicEditError(f"logic_edit switch_case guard: case {case} currently targets {cur}, not "
|
|
239
|
+
f"old_target {old_target} (donor drift or wrong old_target)")
|
|
240
|
+
return ins, ins.off - f.abs_start, case, old_target
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _switch_operand_index(op, ops, case):
|
|
244
|
+
"""The index in the cmdasm SWITCH/SWITCHEX operand list of the LABEL operand for ``case``.
|
|
245
|
+
0x0B/0x0D: ``[base, default, case0, case1, ...]`` (case i is selector base+i). 0x06: ``[default, val0,
|
|
246
|
+
lbl0, val1, lbl1, ...]`` (explicit values)."""
|
|
247
|
+
if op in (0x0B, 0x0D): # SWITCH(base, default, case0, case1, ...)
|
|
248
|
+
if case == "default":
|
|
249
|
+
return 1
|
|
250
|
+
try:
|
|
251
|
+
base = disasm._sx_hi(int(ops[0]) & 0xFFFF) # cmdasm re-emits the base as RAW u16: a negative
|
|
252
|
+
except (ValueError, IndexError): # base (-1) shows as "65535" -> sign-decode it
|
|
253
|
+
raise LogicEditError("logic_edit switch_case: malformed SWITCH operands (internal)")
|
|
254
|
+
i = case - base
|
|
255
|
+
if not (0 <= i < len(ops) - 2):
|
|
256
|
+
raise LogicEditError(f"logic_edit switch_case: selector {case} is outside the contiguous range "
|
|
257
|
+
f"{base}..{base + len(ops) - 3} of this SWITCH (only those cases or "
|
|
258
|
+
'"default" are redirectable; an arbitrary value needs a 0x06 SWITCHEX)')
|
|
259
|
+
return 2 + i
|
|
260
|
+
if case == "default": # 0x06 SWITCHEX(default, val0, lbl0, ...)
|
|
261
|
+
return 0
|
|
262
|
+
for k in range(1, len(ops) - 1, 2):
|
|
263
|
+
try:
|
|
264
|
+
if int(ops[k]) == case:
|
|
265
|
+
return k + 1
|
|
266
|
+
except ValueError:
|
|
267
|
+
continue
|
|
268
|
+
raise LogicEditError(f"logic_edit switch_case: no explicit case value {case} in this SWITCHEX")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _switch_redirect(eb_bytes, ed, anchor_rel, case, old_target, new_target) -> bytes:
|
|
272
|
+
"""Redirect a switch ``case``/default arm to a different in-function instruction boundary, applied at the
|
|
273
|
+
EXACT switch located during the split. Swap that one arm's ``L<old_target>`` label for ``L<new_target>`` in
|
|
274
|
+
the disassembled source (injecting ``L<new_target>:`` if the boundary isn't already a branch target -- a
|
|
275
|
+
label is zero bytes, so this stays length-NEUTRAL) and reassemble via the keystone (``cmdasm`` re-anchors
|
|
276
|
+
every reloff; it RAISES on a backward / >u16 reloff -> normalized here). Old-guarded: the byte at
|
|
277
|
+
``anchor_rel`` is still a switch of the same op AND the arm's operand is still ``L<old_target>``."""
|
|
278
|
+
from .eb import cmdasm as _cmdasm
|
|
279
|
+
from .eb import edit as _edit
|
|
280
|
+
from .eb import exprasm as _exprasm
|
|
281
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
282
|
+
f = _func(eb, ed)
|
|
283
|
+
abs_off = f.abs_start + anchor_rel
|
|
284
|
+
op = eb.data[abs_off] if (0 <= abs_off < len(eb.data)) else None
|
|
285
|
+
if op not in _SWITCH_OPS: # the captured site no longer holds a switch
|
|
286
|
+
raise LogicEditError(f"logic_edit switch_case: the switch in entry{_int(ed, 'entry')}/"
|
|
287
|
+
f"tag{_int(ed, 'tag')} drifted (conflicting edits?)")
|
|
288
|
+
old_label, new_label = f"L{old_target}", f"L{new_target}"
|
|
289
|
+
try:
|
|
290
|
+
items = _cmdasm.disassemble_items(eb.data, f.abs_start, f.abs_end)
|
|
291
|
+
line_idx = next((k for k, (off, _t) in enumerate(items) if off == anchor_rel), None)
|
|
292
|
+
if line_idx is None: # the guarded switch is decoded -> always present
|
|
293
|
+
raise LogicEditError("logic_edit switch_case: could not locate the switch (internal)")
|
|
294
|
+
texts = [t for _o, t in items]
|
|
295
|
+
line = texts[line_idx]
|
|
296
|
+
mnem = line[:line.index("(")]
|
|
297
|
+
ops = line[line.index("(") + 1:line.rindex(")")].split(", ")
|
|
298
|
+
op_idx = _switch_operand_index(op, ops, case)
|
|
299
|
+
if ops[op_idx] != old_label: # second guard: the arm still points at old_target
|
|
300
|
+
raise LogicEditError(f"logic_edit switch_case guard: the case {case} arm is {ops[op_idx]}, not "
|
|
301
|
+
f"{old_label} (donor drift)")
|
|
302
|
+
if not any(o is None and t.strip() == new_label + ":" for o, t in items):
|
|
303
|
+
tgt_idx = next((k for k, (off, _t) in enumerate(items) if off == new_target), None)
|
|
304
|
+
if tgt_idx is None: # new_target must be a real instruction boundary
|
|
305
|
+
raise LogicEditError(f"logic_edit switch_case: new_target {new_target} is not an instruction "
|
|
306
|
+
f"boundary in entry{_int(ed, 'entry')}/tag{_int(ed, 'tag')}")
|
|
307
|
+
texts.insert(tgt_idx, new_label + ":") # label a bare boundary (zero bytes, length-neutral)
|
|
308
|
+
if tgt_idx <= line_idx:
|
|
309
|
+
line_idx += 1
|
|
310
|
+
ops[op_idx] = new_label
|
|
311
|
+
texts[line_idx] = mnem + "(" + ", ".join(ops) + ")"
|
|
312
|
+
new_body = _cmdasm.assemble_block("\n".join(texts))
|
|
313
|
+
except (_cmdasm.CmdAsmError, _exprasm.AssembleError) as ex:
|
|
314
|
+
raise LogicEditError(f"logic_edit switch_case {old_target}->{new_target}: could not rebuild "
|
|
315
|
+
f"entry{_int(ed, 'entry')}/tag{_int(ed, 'tag')}: {ex}")
|
|
316
|
+
return _edit.replace_function_body(eb_bytes, _int(ed, "entry"), _int(ed, "tag"), new_body)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _apply_eb_edit(eb, buf, ed):
|
|
320
|
+
kind = _req(ed, "kind")
|
|
321
|
+
if kind == "field":
|
|
322
|
+
_operand_edit(eb, buf, ed, FIELD_OP, 0)
|
|
323
|
+
elif kind == "item":
|
|
324
|
+
_operand_edit(eb, buf, ed, ITEM_OP, _ITEM_OPERAND.get(ed.get("operand", "id"), 0))
|
|
325
|
+
elif kind == "gil":
|
|
326
|
+
_operand_edit(eb, buf, ed, GIL_OP, 0)
|
|
327
|
+
elif kind == "txid":
|
|
328
|
+
op = _int(ed, "op")
|
|
329
|
+
if op not in WINDOW_OPS:
|
|
330
|
+
raise LogicEditError(f"logic_edit txid op {op:#x} is not a Window op {sorted(WINDOW_OPS)}")
|
|
331
|
+
_operand_edit(eb, buf, ed, op, WINDOW_OPS[op])
|
|
332
|
+
elif kind == "flag_index":
|
|
333
|
+
_flag_edit(eb, buf, ed)
|
|
334
|
+
elif kind == "operand": # generic escape hatch: patch literal operand
|
|
335
|
+
_operand_edit(eb, buf, ed, _int(ed, "op"), _int(ed, "operand")) # `operand` of any op (caller owns
|
|
336
|
+
# the choice of op/operand/nth -- e.g. a hand-authored display patch; no slot guard)
|
|
337
|
+
elif kind == "item_display": # the "Received <item>!" DISPLAY half of a reward:
|
|
338
|
+
slot = _int(ed, "slot", optional=True) # SetTextVariable(slot, item_id). FF9's item-get
|
|
339
|
+
slot = 0 if slot is None else slot # display is slot 0; pin it so a same-value
|
|
340
|
+
old = _int(ed, "old") # SetTextVariable in another slot isn't corrupted.
|
|
341
|
+
_operand_edit(eb, buf, ed, SETTEXTVAR_OP, 1, extra=lambda i: i.imm(0) == slot,
|
|
342
|
+
what=f"SetTextVariable(slot={slot}, id={old}) display in "
|
|
343
|
+
f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
|
|
344
|
+
elif kind == "item_count": # the QUANTITY operand of a specific AddItem(id):
|
|
345
|
+
iid = _int(ed, "item_id") # pin the item id so a same-count AddItem of a
|
|
346
|
+
old = _int(ed, "old") # DIFFERENT item isn't retargeted.
|
|
347
|
+
_operand_edit(eb, buf, ed, ITEM_OP, _ITEM_OPERAND["count"], extra=lambda i: i.imm(0) == iid,
|
|
348
|
+
what=f"AddItem(id={iid}) count=={old} in "
|
|
349
|
+
f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
|
|
350
|
+
else:
|
|
351
|
+
raise LogicEditError(f"logic_edit unknown .eb kind '{kind}' (kinds: {_EB_KINDS} + 'text')")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _edit_list(edits):
|
|
355
|
+
"""Validate the ``[[logic_edit]]`` container is an array of tables (so ``[logic_edit]`` -- a single TOML
|
|
356
|
+
table -- or junk is a clean :class:`LogicEditError`, not a raw ``AttributeError`` from ``.get`` on a key)
|
|
357
|
+
and return its non-empty entries."""
|
|
358
|
+
if edits is None:
|
|
359
|
+
return []
|
|
360
|
+
if not isinstance(edits, (list, tuple)):
|
|
361
|
+
raise LogicEditError("logic_edit must be an array of tables ([[logic_edit]]), not "
|
|
362
|
+
f"{type(edits).__name__} (you likely wrote [logic_edit] instead of [[logic_edit]])")
|
|
363
|
+
out = [e for e in edits if e]
|
|
364
|
+
for e in out:
|
|
365
|
+
if not isinstance(e, dict):
|
|
366
|
+
raise LogicEditError(f"each logic_edit must be a table, got {type(e).__name__}")
|
|
367
|
+
return out
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def apply_logic_edits(eb_bytes, edits) -> bytes:
|
|
371
|
+
"""Apply every NON-text ``[[logic_edit]]`` to ``eb_bytes`` and return the patched bytes. Empty / text-only ->
|
|
372
|
+
byte-identical. Most edits are length-preserving in-place operand swaps (old-guarded); two kinds need the
|
|
373
|
+
keystone REBUILD instead -- a ``flag_index`` remap that CROSSES the 0xFF C4/E4 boundary (length-changing) and
|
|
374
|
+
a ``switch_case`` redirect (length-neutral but the reloff is computed, not a literal). The rebuilds run in a
|
|
375
|
+
SECOND pass AFTER the in-place edits (so the in-place edits' donor-based offsets aren't shifted first), each
|
|
376
|
+
located by the EXACT function-relative offset captured here, delta-adjusted for prior same-function rebuilds.
|
|
377
|
+
Raises :class:`LogicEditError` on any unsafe edit."""
|
|
378
|
+
eb_edits = [e for e in _edit_list(edits) if e.get("kind") != "text"]
|
|
379
|
+
if not eb_edits:
|
|
380
|
+
return bytes(eb_bytes)
|
|
381
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
382
|
+
inplace, rebuilds = [], [] # in-place operand swaps vs keystone rebuilds
|
|
383
|
+
for ed in eb_edits:
|
|
384
|
+
kind = ed.get("kind")
|
|
385
|
+
if kind == "flag_index":
|
|
386
|
+
ins, _idx, tok_len, old, new = _flag_locate(eb, ed)
|
|
387
|
+
if _flag_width(new) != (tok_len - 1): # crosses 0xFF -> keystone rebuild (length-changing)
|
|
388
|
+
rebuilds.append(("flag", ed, (_int(ed, "entry"), _int(ed, "tag")),
|
|
389
|
+
ins.off - _func(eb, ed).abs_start, _flag_width(new) - _flag_width(old),
|
|
390
|
+
(old, new)))
|
|
391
|
+
continue
|
|
392
|
+
elif kind == "switch_case": # redirect a switch arm (keystone, length-neutral)
|
|
393
|
+
ins, anchor_rel, case, old_target = _switch_locate(eb, ed)
|
|
394
|
+
rebuilds.append(("switch", ed, (_int(ed, "entry"), _int(ed, "tag")), anchor_rel, 0,
|
|
395
|
+
(case, old_target, _int(ed, "new_target"))))
|
|
396
|
+
continue
|
|
397
|
+
inplace.append(ed)
|
|
398
|
+
buf = bytearray(eb_bytes)
|
|
399
|
+
for ed in inplace: # length-preserving: instruction offsets stay put
|
|
400
|
+
_apply_eb_edit(eb, buf, ed)
|
|
401
|
+
out = bytes(buf)
|
|
402
|
+
# keystone rebuilds: locate each by its captured function-relative offset (stable through the in-place pass),
|
|
403
|
+
# adjusted for the byte delta of prior same-function rebuilds (ascending offset) so multiple rebuilds compose.
|
|
404
|
+
# Ties each rebuild to the EXACT instruction the split saw -- it can't drift onto a different same-flag instr.
|
|
405
|
+
deltas: dict = {}
|
|
406
|
+
for rkind, ed, key, anchor_rel, byte_delta, payload in sorted(rebuilds, key=lambda r: (r[2], r[3])):
|
|
407
|
+
dk = deltas.get(key, 0) # cumulative byte shift from prior same-function rebuilds
|
|
408
|
+
eff = anchor_rel + dk
|
|
409
|
+
if rkind == "flag":
|
|
410
|
+
out = _flag_rewidth(out, ed, eff, *payload)
|
|
411
|
+
else: # a switch's case targets are all FORWARD of it, so
|
|
412
|
+
case, old_t, new_t = payload # they shifted by dk too -- relocate them, not just
|
|
413
|
+
out = _switch_redirect(out, ed, eff, case, old_t + dk, new_t + dk) # the anchor (the composition fix)
|
|
414
|
+
deltas[key] = dk + byte_delta
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# --- .mes dialogue-string rewrite (kind="text") -- a verified in-place splice, per language ----------
|
|
419
|
+
def _splice_block(part: str, new_text: str) -> str:
|
|
420
|
+
"""Replace the text payload of one ``[STRT=...]...[ENDN]`` block (a ``[STRT=``-split segment), preserving
|
|
421
|
+
its STRT geometry, optional [TAIL], the [ENDN], and any trailing bytes before the next entry."""
|
|
422
|
+
b = part.find("]")
|
|
423
|
+
if b < 0:
|
|
424
|
+
raise LogicEditError("malformed .mes entry (no STRT close)")
|
|
425
|
+
rest = part[b + 1:]
|
|
426
|
+
mt = _TAIL_RE.match(rest)
|
|
427
|
+
tail_str = mt.group(0) if mt else ""
|
|
428
|
+
endn = part.find("[ENDN]", b + 1 + len(tail_str))
|
|
429
|
+
if endn < 0:
|
|
430
|
+
raise LogicEditError("malformed .mes entry (no [ENDN])")
|
|
431
|
+
return part[:b + 1] + tail_str + new_text + part[endn:]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def verified_mes_splice(body: str, txid: int, new_text: str, *, lang: str, err=None) -> str:
|
|
435
|
+
"""Replace ONLY ``txid``'s text payload with ``new_text`` in an index-implicit ``.mes`` body, then re-parse
|
|
436
|
+
and assert every OTHER entry is byte-identical -- a botched splice fails the build, not the player. Shared by
|
|
437
|
+
the dialogue-string rewrite (``kind="text"``) and the ``logic_add`` ``menu_row`` row-label splice. ``err`` is
|
|
438
|
+
the exception class to raise (defaults to :class:`LogicEditError`, so a ``menu_row`` caller can pass its own
|
|
439
|
+
``LogicAddError``). v1 supports the verbatim donor body (no ``[TXID=]`` re-index markers)."""
|
|
440
|
+
from .dialogue import parse_mes
|
|
441
|
+
err = err or LogicEditError
|
|
442
|
+
if "[TXID=" in body:
|
|
443
|
+
raise err("a .mes splice on a [TXID=]-reindexed body is not supported (Phase 2b)")
|
|
444
|
+
before = parse_mes(body)
|
|
445
|
+
if txid not in before:
|
|
446
|
+
raise err(f"txid {txid} not found in the {lang} .mes")
|
|
447
|
+
parts = body.split("[STRT=")
|
|
448
|
+
if not (0 <= txid + 1 < len(parts)): # index-implicit: txid == position == part index-1
|
|
449
|
+
raise err(f"txid {txid} out of range in the {lang} .mes")
|
|
450
|
+
try:
|
|
451
|
+
parts[txid + 1] = _splice_block(parts[txid + 1], new_text)
|
|
452
|
+
except LogicEditError as ex: # _splice_block speaks LogicEditError -> normalize to err
|
|
453
|
+
raise err(str(ex))
|
|
454
|
+
spliced = "[STRT=".join(parts)
|
|
455
|
+
after = parse_mes(spliced) # VERIFY: only the target entry changed
|
|
456
|
+
if len(after) != len(before):
|
|
457
|
+
raise err(f"the .mes splice changed the entry count ({lang})")
|
|
458
|
+
for t, e in before.items():
|
|
459
|
+
got = after.get(t)
|
|
460
|
+
if got is None:
|
|
461
|
+
raise err(f"the .mes splice dropped txid {t} ({lang})")
|
|
462
|
+
if t == txid:
|
|
463
|
+
if got.text != new_text:
|
|
464
|
+
raise err(f"the .mes splice didn't take for txid {txid} ({lang})")
|
|
465
|
+
elif (got.text, got.strt, got.tail) != (e.text, e.strt, e.tail):
|
|
466
|
+
raise err(f"the .mes splice corrupted txid {t} ({lang})")
|
|
467
|
+
return spliced
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def apply_logic_text_edits(body: str, edits, lang: str) -> str:
|
|
471
|
+
"""Apply every ``kind="text"`` edit whose ``lang`` is unset or == ``lang`` to a ``.mes`` body, returning the
|
|
472
|
+
rewritten body. A VERIFIED in-place splice (:func:`verified_mes_splice`): it replaces one entry's text
|
|
473
|
+
payload, then re-parses and asserts every OTHER entry is byte-identical (so a botched splice fails the build,
|
|
474
|
+
not the player). v1 supports the index-implicit verbatim donor body (no ``[TXID=]`` re-index markers)."""
|
|
475
|
+
text_edits = [e for e in _edit_list(edits) if e.get("kind") == "text" and e.get("lang") in (None, lang)]
|
|
476
|
+
if not text_edits or not body:
|
|
477
|
+
return body
|
|
478
|
+
from .dialogue import parse_mes, strip_tags
|
|
479
|
+
if "[TXID=" in body:
|
|
480
|
+
raise LogicEditError("logic_edit text rewrite on a [TXID=]-reindexed .mes is not supported (Phase 2b)")
|
|
481
|
+
for ed in text_edits:
|
|
482
|
+
txid, old, new = _int(ed, "txid"), _req(ed, "old"), _req(ed, "new")
|
|
483
|
+
if not isinstance(old, str) or not isinstance(new, str):
|
|
484
|
+
raise LogicEditError(f"logic_edit text txid {txid}: 'old' and 'new' must be strings")
|
|
485
|
+
ent = parse_mes(body).get(txid)
|
|
486
|
+
if ent is None:
|
|
487
|
+
raise LogicEditError(f"logic_edit text: txid {txid} not found in the {lang} .mes")
|
|
488
|
+
if old not in (ent.text, strip_tags(ent.text)):
|
|
489
|
+
raise LogicEditError(f"logic_edit text txid {txid} ({lang}): current line != `old` (donor drifted)")
|
|
490
|
+
body = verified_mes_splice(body, txid, new, lang=lang)
|
|
491
|
+
return body
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# --- discovery: the editable value-sites of one routine (the GUI authoring surface) -----------------
|
|
495
|
+
# The GUI (Workspace "Script (verbatim .eb)" subtree) can't ask the user to hand-write entry/tag/op/nth/old
|
|
496
|
+
# coordinates. So this walks ONE (entry, tag) routine the way the appliers' `_pick` filters do, and returns a
|
|
497
|
+
# legible EditSite per editable value, each carrying ready-to-fill [[logic_edit]] templates. Edit -> click ->
|
|
498
|
+
# pick a new value; :func:`synth_edits` splices it in; :func:`upsert_edits` merges into the field.toml list.
|
|
499
|
+
@dataclass
|
|
500
|
+
class EditSite:
|
|
501
|
+
"""One editable value in a routine -- a row + 'Edit…' affordance in the GUI. ``templates`` are the
|
|
502
|
+
[[logic_edit]] dicts MINUS the new-value key (filled by :func:`synth_edits`). For an item reward the
|
|
503
|
+
AddItem give and the matched ``SetTextVariable`` 'Received <item>!' display are paired in
|
|
504
|
+
``display_templates`` so ONE edit retargets both -- the give-vs-display decoupling: if only the give
|
|
505
|
+
changes, the message lies (the chest-says-Potion-gives-Elixir bug)."""
|
|
506
|
+
group: str # item | gil | field | flag | text (what's being edited)
|
|
507
|
+
value_kind: str # item | int | flag | string -> how the dialog renders/validates NEW
|
|
508
|
+
label: str # the row label shown in the panel
|
|
509
|
+
old: object # the donor's current value (int, or the us string for text)
|
|
510
|
+
new_key: str = "new" # the template key the NEW value goes under ("new", or "new_flag" for flag)
|
|
511
|
+
templates: list = _dc_field(default_factory=list) # the primary edits (the give / the value)
|
|
512
|
+
display_templates: list = _dc_field(default_factory=list) # item: the paired display edits
|
|
513
|
+
count_templates: list = _dc_field(default_factory=list) # item: the quantity (AddItem count) edits
|
|
514
|
+
count_old: object = None # item: the current quantity (None if it varies across give-paths -> not editable)
|
|
515
|
+
note: str = "" # an advisory (e.g. no display site found / count not shown)
|
|
516
|
+
key: str = "" # a stable id for this site within the routine (GUI row <-> its edits)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _op_tmpl(kind, entry, tag, old, nth, total, *, op=None, operand=None):
|
|
520
|
+
"""One length-preserving operand template (the new value spliced in later). ``nth`` is included only
|
|
521
|
+
when the value is ambiguous (``total`` > 1) -- mirroring what the appliers' ``_pick`` requires."""
|
|
522
|
+
t = {"kind": kind, "entry": int(entry), "tag": int(tag), "old": old}
|
|
523
|
+
if op is not None:
|
|
524
|
+
t["op"] = op
|
|
525
|
+
if operand is not None:
|
|
526
|
+
t["operand"] = operand
|
|
527
|
+
if total > 1:
|
|
528
|
+
t["nth"] = nth
|
|
529
|
+
return t
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _value_groups(instrs, op, operand_index):
|
|
533
|
+
"""``{value: [nth, ...]}`` for every immediate ``operand_index`` of ``op`` -- the value-filtered nth
|
|
534
|
+
each occurrence gets (the exact index :func:`_pick` resolves), so an edit can target all or one."""
|
|
535
|
+
groups: dict = {}
|
|
536
|
+
for ins in instrs:
|
|
537
|
+
if ins.op != op:
|
|
538
|
+
continue
|
|
539
|
+
v = ins.imm(operand_index)
|
|
540
|
+
if v is None:
|
|
541
|
+
continue
|
|
542
|
+
groups.setdefault(v, []).append(len(groups.get(v, [])))
|
|
543
|
+
return groups
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _line_old(entries, txid):
|
|
547
|
+
"""The donor's current line (tag-stripped) for ``txid``, or None (no ``.mes`` / not found)."""
|
|
548
|
+
if not entries:
|
|
549
|
+
return None
|
|
550
|
+
from .dialogue import strip_tags
|
|
551
|
+
ent = entries.get(int(txid))
|
|
552
|
+
return strip_tags(ent.text) if ent is not None else None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _short(s, width=44):
|
|
556
|
+
s = " ".join(str(s).split())
|
|
557
|
+
return (s[:width] + "…") if len(s) > width else s
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _text_templates(txid, lang_bodies, fallback_old):
|
|
561
|
+
"""A per-language ``text`` template (each guarded by THAT language's own current string) so a single
|
|
562
|
+
new string is written to every localized copy consistently. Skips a ``[TXID=]``-reindexed body (Phase
|
|
563
|
+
4) and a language missing the txid. Falls back to one lang-agnostic template when no bodies are given."""
|
|
564
|
+
if not lang_bodies:
|
|
565
|
+
return [{"kind": "text", "txid": int(txid), "old": fallback_old}] if fallback_old is not None else []
|
|
566
|
+
from .dialogue import parse_mes, strip_tags
|
|
567
|
+
out = []
|
|
568
|
+
for lang, body in lang_bodies.items():
|
|
569
|
+
if not body or "[TXID=" in body:
|
|
570
|
+
continue
|
|
571
|
+
ent = parse_mes(body).get(int(txid))
|
|
572
|
+
if ent is None:
|
|
573
|
+
continue
|
|
574
|
+
out.append({"kind": "text", "lang": lang, "txid": int(txid), "old": strip_tags(ent.text)})
|
|
575
|
+
return out
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def editable_effects(eb_bytes, entry, tag, *, entries=None, lang_bodies=None):
|
|
579
|
+
"""Discover the editable value-sites of one ``(entry, tag)`` routine of a verbatim fork's ``.eb`` --
|
|
580
|
+
item rewards (give + paired display), gil grants, ``Field()`` warps, GLOB story-flag indices, and
|
|
581
|
+
dialogue lines -- each as an :class:`EditSite` the GUI authors a ``[[logic_edit]]`` from. Pure; never
|
|
582
|
+
mutates. ``entries`` = parsed us ``.mes`` (``{txid: MesEntry}``) for line text; ``lang_bodies`` =
|
|
583
|
+
``{lang: raw .mes body}`` for per-language text-edit guards."""
|
|
584
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
585
|
+
if not (0 <= entry < eb.entry_count):
|
|
586
|
+
return []
|
|
587
|
+
e = eb.entry(entry)
|
|
588
|
+
if e.empty:
|
|
589
|
+
return []
|
|
590
|
+
f = e.func_by_tag(tag)
|
|
591
|
+
if f is None:
|
|
592
|
+
return []
|
|
593
|
+
from . import forkreport as FR
|
|
594
|
+
instrs = list(eb.instrs(f))
|
|
595
|
+
sites: list = []
|
|
596
|
+
|
|
597
|
+
# items: group AddItem by id (skipping the engine no-op grants the read map also hides); pair each with
|
|
598
|
+
# the same-id SetTextVariable in TEXT SLOT 0 (FF9's item-get display, build.set_text_variable(0, id)) so
|
|
599
|
+
# the reward + its "Received <item>!" message change together. A same-value SetTextVariable in another
|
|
600
|
+
# slot (e.g. a preview row) is NOT the item display and is left alone.
|
|
601
|
+
disp_groups: dict = {}
|
|
602
|
+
for ins in instrs:
|
|
603
|
+
if ins.op == SETTEXTVAR_OP and ins.imm(0) == 0:
|
|
604
|
+
v = ins.imm(1)
|
|
605
|
+
if v is not None:
|
|
606
|
+
disp_groups.setdefault(v, []).append(len(disp_groups.get(v, [])))
|
|
607
|
+
item_groups: dict = {}
|
|
608
|
+
for ins in instrs:
|
|
609
|
+
if ins.op != ITEM_OP:
|
|
610
|
+
continue
|
|
611
|
+
iid = ins.imm(0)
|
|
612
|
+
if iid is None or iid == FR.NO_ITEM or FR.item_inert(iid):
|
|
613
|
+
continue
|
|
614
|
+
item_groups.setdefault(iid, []).append(len(item_groups.get(iid, [])))
|
|
615
|
+
for iid, nths in item_groups.items():
|
|
616
|
+
give = [_op_tmpl("item", entry, tag, iid, n, len(nths), op=ITEM_OP, operand="id") for n in nths]
|
|
617
|
+
dn = disp_groups.get(iid, [])
|
|
618
|
+
disp = [{**_op_tmpl("item_display", entry, tag, iid, n, len(dn), op=SETTEXTVAR_OP, operand=1), "slot": 0}
|
|
619
|
+
for n in dn]
|
|
620
|
+
# quantity: editable only when every give-path of this item grants the SAME count (the usual case);
|
|
621
|
+
# if the counts vary, expose no count edit (the user can hand-author per-path) and note it.
|
|
622
|
+
counts = [ins.imm(1) for ins in instrs if ins.op == ITEM_OP and ins.imm(0) == iid]
|
|
623
|
+
uniform = bool(counts) and counts[0] is not None and len(set(counts)) == 1
|
|
624
|
+
count_old = counts[0] if uniform else None
|
|
625
|
+
cnt = ([{**_op_tmpl("item_count", entry, tag, count_old, n, len(nths), op=ITEM_OP, operand=1),
|
|
626
|
+
"item_id": int(iid)} for n in nths] if uniform else [])
|
|
627
|
+
qty = f" ×{count_old}" if count_old is not None else ""
|
|
628
|
+
paths = f" ({len(nths)} give-paths)" if len(nths) > 1 else ""
|
|
629
|
+
label = f"gives {FR.item_label(iid)}{qty}{paths}"
|
|
630
|
+
note = "" if disp else "no 'Received <item>!' display message paired — only the give changes"
|
|
631
|
+
if not uniform and len(counts) > 1:
|
|
632
|
+
note = (note + " " if note else "") + "quantity varies across give-paths — not editable here"
|
|
633
|
+
sites.append(EditSite("item", "item", label, int(iid), "new", give, disp, cnt, count_old,
|
|
634
|
+
note, f"item:{iid}"))
|
|
635
|
+
|
|
636
|
+
# gil grants (skip the > party-cap sentinel amounts -- a scripted/computed AddGil, not a treasure reward)
|
|
637
|
+
for amt, nths in _value_groups(instrs, GIL_OP, 0).items():
|
|
638
|
+
if amt > FR.GIL_CAP:
|
|
639
|
+
continue
|
|
640
|
+
tmpls = [_op_tmpl("gil", entry, tag, amt, n, len(nths), op=GIL_OP) for n in nths]
|
|
641
|
+
sites.append(EditSite("gil", "int", f"gives {amt} gil", int(amt), "new", tmpls, key=f"gil:{amt}"))
|
|
642
|
+
|
|
643
|
+
# Field() warps (an alternative to the [verbatim_eb] retarget table -- per-site, not by global dest)
|
|
644
|
+
for dest, nths in _value_groups(instrs, FIELD_OP, 0).items():
|
|
645
|
+
tmpls = [_op_tmpl("field", entry, tag, dest, n, len(nths), op=FIELD_OP) for n in nths]
|
|
646
|
+
sites.append(EditSite("field", "field", f"warps to field {dest}", int(dest), "new", tmpls,
|
|
647
|
+
key=f"field:{dest}"))
|
|
648
|
+
|
|
649
|
+
# GLOB story-flag indices (read + write of one index remap together so they stay in sync)
|
|
650
|
+
from .eventscan import _glob_var_token
|
|
651
|
+
flag_groups: dict = {}
|
|
652
|
+
for ins in instrs:
|
|
653
|
+
if ins.op != EXPR_OP:
|
|
654
|
+
continue
|
|
655
|
+
tok = _glob_var_token(eb.data, ins.off + 1)
|
|
656
|
+
if tok is not None:
|
|
657
|
+
flag_groups.setdefault(tok[0], []).append(len(flag_groups.get(tok[0], [])))
|
|
658
|
+
for idx, nths in flag_groups.items():
|
|
659
|
+
tmpls = [{"kind": "flag_index", "entry": int(entry), "tag": int(tag), "flag": int(idx),
|
|
660
|
+
**({"nth": n} if len(nths) > 1 else {})} for n in nths]
|
|
661
|
+
n = len(nths)
|
|
662
|
+
sites.append(EditSite("flag", "flag", f"story flag {idx}" + (f" (×{n})" if n > 1 else ""),
|
|
663
|
+
int(idx), "new_flag", tmpls, key=f"flag:{idx}"))
|
|
664
|
+
|
|
665
|
+
# dialogue lines (one site per distinct Window-op txid that resolves to a line)
|
|
666
|
+
seen_txid: set = set()
|
|
667
|
+
for ins in instrs:
|
|
668
|
+
if ins.op not in WINDOW_OPS:
|
|
669
|
+
continue
|
|
670
|
+
txid = ins.imm(WINDOW_OPS[ins.op])
|
|
671
|
+
if txid is None or txid in seen_txid:
|
|
672
|
+
continue
|
|
673
|
+
seen_txid.add(txid)
|
|
674
|
+
us_old = _line_old(entries, txid)
|
|
675
|
+
tmpls = _text_templates(txid, lang_bodies, us_old)
|
|
676
|
+
if not tmpls:
|
|
677
|
+
continue # no editable .mes for this line
|
|
678
|
+
label = (f'line {txid}: "{_short(us_old)}"' if us_old else f"line {txid}")
|
|
679
|
+
langs = [t["lang"] for t in tmpls if t.get("lang")]
|
|
680
|
+
note = ("rewrites " + ", ".join(langs)) if langs else ""
|
|
681
|
+
sites.append(EditSite("text", "string", label, us_old if us_old is not None else "",
|
|
682
|
+
"new", tmpls, note=note, key=f"text:{txid}"))
|
|
683
|
+
return sites
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# --- edit synthesis + merge (the GUI writes these into the field.toml's logic_edit list) -------------
|
|
687
|
+
_COORD_KEYS = ("kind", "entry", "tag", "op", "operand", "slot", "item_id", "nth", "lang", "txid", "flag", "old")
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def synth_edits(site: EditSite, new) -> list:
|
|
691
|
+
"""The ``[[logic_edit]]`` dicts that realize editing ``site`` to ``new`` -- the value edits PLUS, for an
|
|
692
|
+
item, the paired display edits (so the 'Received <item>!' message always tracks the give). For an item the
|
|
693
|
+
quantity is unchanged (use :func:`synth_item_edits` to also set the count)."""
|
|
694
|
+
out = [{**t, site.new_key: new} for t in site.templates]
|
|
695
|
+
out += [{**t, "new": new} for t in site.display_templates]
|
|
696
|
+
return out
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def synth_item_edits(site: EditSite, new_id, new_count=None) -> list:
|
|
700
|
+
"""The edits to retarget an item reward to ``new_id`` (give + paired display) AND, when ``new_count`` is
|
|
701
|
+
given and differs, its quantity. A component whose value is unchanged emits NO edit (so a count-only change
|
|
702
|
+
doesn't author a redundant give edit, and vice-versa)."""
|
|
703
|
+
out = []
|
|
704
|
+
if new_id != site.old:
|
|
705
|
+
out += [{**t, "new": new_id} for t in site.templates]
|
|
706
|
+
out += [{**t, "new": new_id} for t in site.display_templates]
|
|
707
|
+
if new_count is not None and site.count_old is not None and new_count != site.count_old:
|
|
708
|
+
out += [{**t, "new": new_count} for t in site.count_templates]
|
|
709
|
+
return out
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def edit_coord(ed: dict) -> tuple:
|
|
713
|
+
"""The identifying coordinates of a logic_edit (everything but the NEW value) -- for dedup/replace."""
|
|
714
|
+
return tuple((k, ed.get(k)) for k in _COORD_KEYS)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def site_footprint(site: EditSite) -> set:
|
|
718
|
+
"""The coords of EVERY edit ``site`` can author (value + display + quantity) -- so re-editing or clearing a
|
|
719
|
+
site removes all of its prior edits before adding new ones (handles a changed display/count set cleanly)."""
|
|
720
|
+
return {edit_coord(t) for t in (site.templates + site.display_templates + site.count_templates)}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def upsert_edits(existing, new_edits, *, drop=None) -> list:
|
|
724
|
+
"""Return ``existing`` with edits whose coords are in ``drop`` (default = the new edits' own coords)
|
|
725
|
+
removed, then ``new_edits`` appended. Pure -- re-editing a site replaces its edits, never stacks them."""
|
|
726
|
+
drop = set(drop) if drop is not None else {edit_coord(e) for e in new_edits}
|
|
727
|
+
kept = [e for e in (existing or []) if edit_coord(e) not in drop]
|
|
728
|
+
return kept + list(new_edits)
|