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_map.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""``logic-map`` -- a read-only, legible VIEW of a field's WHOLE event script (``.eb``).
|
|
2
|
+
|
|
3
|
+
A ``--verbatim`` fork ships the donor's whole compiled ``.eb`` (entry-0 + every object + every gateway +
|
|
4
|
+
every shared subroutine), so the declarative ``[[npc]]``/``[[gateway]]`` model is empty -- the content is
|
|
5
|
+
raw bytecode (docs/FORK_FIDELITY.md, CLAUDE.md section 8 #14). You can't EXTRACT an NPC's talk handler into a
|
|
6
|
+
portable block (it RunScripts into Main_Init shared helpers, drives siblings, puppeteers the player -- proven
|
|
7
|
+
0-of-55 tractable). But you CAN make the entanglement *legible*: this module aggregates the scanners the kit
|
|
8
|
+
already has into ONE per-field graph --
|
|
9
|
+
|
|
10
|
+
* NODES = every ``(entry, function/tag)`` (the byte-exact skeleton from :mod:`ff9mapkit.eb.model`),
|
|
11
|
+
classified by role (Main_Init / a player sequence / an NPC talk handler / a gateway region / a shared
|
|
12
|
+
routine / set-dressing);
|
|
13
|
+
* EDGES = the resolved call graph -- every ``RunScript[Sync|Async](uid, tag)`` resolved to the entry it
|
|
14
|
+
dispatches into via :func:`ff9mapkit.eventscan.resolve_uid` (the one ``GetObjUID`` convention), plus
|
|
15
|
+
``Field()``/``WorldMap()`` warps and ``StartSeq`` launches;
|
|
16
|
+
* per-node SIDE EFFECTS -- the dialogue lines (``Window*`` txids), item/gil/shop grants, and GLOB story-flag
|
|
17
|
+
reads/writes each routine performs.
|
|
18
|
+
|
|
19
|
+
It is **read-only and derived** -- the inverse of nothing; it never edits or regenerates the ``.eb``. It is
|
|
20
|
+
the data behind Phase 0 of the field-logic-map plan: the foundation the GUI surfaces (so a verbatim fork's
|
|
21
|
+
empty tree fills with its real, inspectable content) and a future in-place edit layer keys off.
|
|
22
|
+
|
|
23
|
+
THE FIDELITY CEILING (honest, permanent): an operand chosen at runtime (an expression-computed uid / Field id
|
|
24
|
+
/ item / flag) or a ``REPLY*`` dynamic-caller (``0x16/0x18/0x1A``) cannot be resolved offline -- those are
|
|
25
|
+
MARKED in ``unresolved`` but not drawn as edges. The map is high-fidelity-WITH-HOLES, not exhaustive.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
from dataclasses import dataclass, field as _dc_field
|
|
31
|
+
|
|
32
|
+
from .eb.model import EbScript
|
|
33
|
+
|
|
34
|
+
# --- opcode constants (the side-effect + edge surface) ----------------------------------------------
|
|
35
|
+
WINDOW_OPS = {0x1F: 2, 0x20: 2, 0x95: 3, 0x96: 3} # WindowSync/Async[Ex] -> txid arg index (dialogue.WINDOW_OPS)
|
|
36
|
+
ADD_ITEM_OP = 0x48 # AddItem(item_id, count)
|
|
37
|
+
REMOVE_ITEM_OP = 0x49 # RemoveItem(item_id, count)
|
|
38
|
+
ADD_GIL_OP = 0xCE # AddGil(amount)
|
|
39
|
+
REMOVE_GIL_OP = 0xCF # RemoveGil(amount)
|
|
40
|
+
MENU_OP = 0x75 # Menu(menu_id, sub_id); 2 = shop, 4 = save, 1 = name, 5 = chocograph
|
|
41
|
+
SHOP_MENU_ID = 2
|
|
42
|
+
SAVE_MENU_ID = 4
|
|
43
|
+
FIELD_OP = 0x2B # Field(dest) -- a field-to-field warp
|
|
44
|
+
WORLDMAP_OP = 0xB6 # WorldMap(loc) -- leave to the overworld (loc = a LOCATION id, not a field)
|
|
45
|
+
RUNSCRIPT_OPS = (0x10, 0x12, 0x14) # RunScript[Async|Sync](level, uid, tag)
|
|
46
|
+
RUNSCRIPT_NAMES = {0x10: "RunScriptAsync", 0x12: "RunScript", 0x14: "RunScriptSync"}
|
|
47
|
+
REPLY_OPS = (0x16, 0x18, 0x1A) # Reply[Async|Sync] -- dispatch to the DYNAMIC caller (unresolvable)
|
|
48
|
+
STARTSEQ_OP = 0x43 # RunSharedScript / STARTSEQ(entry) -- launch a concurrent Seq by ENTRY index
|
|
49
|
+
EXPR_STMT_OP = 0x05 # an expression statement (flag reads/writes ride here)
|
|
50
|
+
INIT_OBJECT_OP = 0x09 # InitObject(slot, arg) in Main_Init -- spawns/activates an object entry
|
|
51
|
+
DEFINE_PC_OP = 0x2C # DefinePlayerCharacter
|
|
52
|
+
|
|
53
|
+
# GLOB story-flag expression tokens (shared with eventscan's raw-byte scanners) -----------------------
|
|
54
|
+
from .eventscan import (_glob_var_token, _PUSH_CONST16, _T_ASSIGN, _T_OR_ASSIGN, # noqa: E402
|
|
55
|
+
_T_NOT, _T_END, _JMP_FALSE, _JMP_TRUE)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- data model -------------------------------------------------------------------------------------
|
|
59
|
+
@dataclass
|
|
60
|
+
class Call:
|
|
61
|
+
"""A resolved (or unresolvable) dispatch edge out of a routine."""
|
|
62
|
+
op: str # RunScript / RunScriptSync / RunScriptAsync / StartSeq
|
|
63
|
+
uid: int | None
|
|
64
|
+
tag: int | None
|
|
65
|
+
target_kind: str # self | player | party | main | object | seq | unknown
|
|
66
|
+
targets: list # candidate entry index(es) it dispatches into ([] = unresolved offline)
|
|
67
|
+
off: int
|
|
68
|
+
label: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Node:
|
|
73
|
+
"""One ``(entry, function/tag)`` with everything it does, attributed per-routine."""
|
|
74
|
+
entry: int
|
|
75
|
+
tag: int
|
|
76
|
+
kind: str # main_init / shared_routine / player_seq / npc_talk / object_loop / ...
|
|
77
|
+
abs_start: int
|
|
78
|
+
abs_end: int
|
|
79
|
+
says: list = _dc_field(default_factory=list) # [{txid, text}]
|
|
80
|
+
gives: list = _dc_field(default_factory=list) # [{kind, ...}] item/gil/shop/save/menu/remove_*
|
|
81
|
+
flags_set: list = _dc_field(default_factory=list) # [{index, mode}] mode = set | or
|
|
82
|
+
flags_read: list = _dc_field(default_factory=list) # [{index, require_set}]
|
|
83
|
+
warps: list = _dc_field(default_factory=list) # [{op, to}] Field / WorldMap
|
|
84
|
+
calls: list = _dc_field(default_factory=list) # [Call]
|
|
85
|
+
branches: list = _dc_field(default_factory=list) # [{op, base, edges:[{value, target, is_default}]}] switch tables
|
|
86
|
+
unresolved: list = _dc_field(default_factory=list) # [{op, reason, off}] runtime-computed / dynamic
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def empty(self) -> bool:
|
|
90
|
+
return not (self.says or self.gives or self.flags_set or self.flags_read
|
|
91
|
+
or self.warps or self.calls or self.branches or self.unresolved)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class EntryInfo:
|
|
96
|
+
"""One entry-table slot, classified."""
|
|
97
|
+
index: int
|
|
98
|
+
role: str # main | player | npc | object | gateway | logic | empty
|
|
99
|
+
model_id: int | None = None
|
|
100
|
+
model_name: str | None = None
|
|
101
|
+
talkable: bool = False
|
|
102
|
+
spawns: int = 0 # how many times Main_Init InitObject()s this entry (0 = not spawned)
|
|
103
|
+
tags: list = _dc_field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class LogicMap:
|
|
108
|
+
field_id: int = 0
|
|
109
|
+
fbg_name: str = ""
|
|
110
|
+
event_name: str = ""
|
|
111
|
+
has_text: bool = False
|
|
112
|
+
sha256: str = ""
|
|
113
|
+
entries: list = _dc_field(default_factory=list) # EntryInfo
|
|
114
|
+
nodes: list = _dc_field(default_factory=list) # Node
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- per-routine attribution helpers ----------------------------------------------------------------
|
|
118
|
+
def _line_text(entries, txid, width: int = 60):
|
|
119
|
+
"""A readable one-line rendering of dialogue txid (or None if no .mes / not found)."""
|
|
120
|
+
if txid is None or not entries:
|
|
121
|
+
return None
|
|
122
|
+
e = entries.get(int(txid))
|
|
123
|
+
if e is None:
|
|
124
|
+
return None
|
|
125
|
+
from .dialogue import strip_tags
|
|
126
|
+
s = strip_tags(e.text).replace("\n", " / ").strip()
|
|
127
|
+
s = " ".join(s.split())
|
|
128
|
+
return (s[:width] + "...") if len(s) > width else (s or "(blank line)")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _flag_write_at(d: bytes, off: int):
|
|
132
|
+
"""If the 0x05 expression at ``off`` is a GLOB flag WRITE (``05 <glob> 7D <i16> 2C|3F 7F``), return
|
|
133
|
+
``(idx, mode)`` (mode = 'set'|'or'); else None. Mirrors :func:`eventscan.scan_flags_set` per-instr."""
|
|
134
|
+
tok = _glob_var_token(d, off + 1)
|
|
135
|
+
if tok is None:
|
|
136
|
+
return None
|
|
137
|
+
idx, vlen = tok
|
|
138
|
+
p = off + 1 + vlen
|
|
139
|
+
if p + 4 < len(d) and d[p] == _PUSH_CONST16 and d[p + 3] in (_T_ASSIGN, _T_OR_ASSIGN) and d[p + 4] == _T_END:
|
|
140
|
+
return (idx, "set" if d[p + 3] == _T_ASSIGN else "or")
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _flag_read_at(d: bytes, off: int):
|
|
145
|
+
"""If the 0x05 expression at ``off`` is a GLOB flag READ driving a conditional jump, return
|
|
146
|
+
``(idx, require_set)``; else None. Mirrors :func:`eventscan.scan_required_flags` per-instr."""
|
|
147
|
+
tok = _glob_var_token(d, off + 1)
|
|
148
|
+
if tok is None:
|
|
149
|
+
return None
|
|
150
|
+
idx, vlen = tok
|
|
151
|
+
p = off + 1 + vlen
|
|
152
|
+
negated = p < len(d) and d[p] == _T_NOT
|
|
153
|
+
if negated:
|
|
154
|
+
p += 1
|
|
155
|
+
if p + 1 >= len(d) or d[p] != _T_END:
|
|
156
|
+
return None
|
|
157
|
+
jmp = d[p + 1]
|
|
158
|
+
if jmp not in (_JMP_FALSE, _JMP_TRUE):
|
|
159
|
+
return None
|
|
160
|
+
require_set = (jmp == _JMP_TRUE and not negated) or (jmp == _JMP_FALSE and negated)
|
|
161
|
+
return (idx, require_set)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _func_kind(role: str, tag: int) -> str:
|
|
165
|
+
"""A readable kind for a (role, tag) -- the engine's func-tag conventions."""
|
|
166
|
+
if role == "main":
|
|
167
|
+
return {0: "main_init", 10: "main_reinit"}.get(tag, "shared_routine")
|
|
168
|
+
if role == "player":
|
|
169
|
+
return {0: "player_init", 1: "player_loop"}.get(tag, "player_seq")
|
|
170
|
+
if role == "gateway":
|
|
171
|
+
return {2: "gateway_tread", 3: "gateway_press", 10: "gateway_reinit"}.get(tag, "gateway_routine")
|
|
172
|
+
if role in ("npc", "object"):
|
|
173
|
+
return {0: "object_init", 1: "object_loop", 2: "object_tread", 3: "npc_talk",
|
|
174
|
+
10: "object_reinit"}.get(tag, "object_routine")
|
|
175
|
+
return {0: "init", 2: "tread", 3: "press", 10: "reinit"}.get(tag, "routine")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --- the builder ------------------------------------------------------------------------------------
|
|
179
|
+
def build_logic_map(eb_bytes, *, entries=None, field_id: int = 0, fbg_name: str = "",
|
|
180
|
+
event_name: str = "") -> LogicMap:
|
|
181
|
+
"""Build a :class:`LogicMap` from a field's ``.eb`` bytes (pure; ``entries`` = a parsed ``.mes``
|
|
182
|
+
``{txid: MesEntry}`` to enrich dialogue with real text -- omit for the structure-only view)."""
|
|
183
|
+
from . import eventscan
|
|
184
|
+
from . import forkreport as FR
|
|
185
|
+
from ._modeldb import MODELS
|
|
186
|
+
|
|
187
|
+
lm = LogicMap(field_id=field_id, fbg_name=fbg_name, event_name=event_name, has_text=bool(entries))
|
|
188
|
+
if not eb_bytes:
|
|
189
|
+
return lm
|
|
190
|
+
data = bytes(eb_bytes)
|
|
191
|
+
lm.sha256 = hashlib.sha256(data).hexdigest()
|
|
192
|
+
eb = EbScript.from_bytes(data)
|
|
193
|
+
|
|
194
|
+
player_entries = eventscan.resolve_player_entries(eb)
|
|
195
|
+
gateway_entries = {g["entry_idx"] for g in eventscan.scan_gateway_entries(data)}
|
|
196
|
+
|
|
197
|
+
# count Main_Init InitObject() spawns per entry (0 = the entry is defined but never activated)
|
|
198
|
+
spawns: dict = {}
|
|
199
|
+
e0 = next((e for e in eb.entries if not e.empty and e.index == 0), None)
|
|
200
|
+
f0 = e0.func_by_tag(0) if e0 else None
|
|
201
|
+
if f0 is not None:
|
|
202
|
+
for ins in eb.instrs(f0):
|
|
203
|
+
if ins.op == INIT_OBJECT_OP and ins.args and isinstance(ins.args[0], int):
|
|
204
|
+
spawns[int(ins.args[0])] = spawns.get(int(ins.args[0]), 0) + 1
|
|
205
|
+
|
|
206
|
+
for e in eb.entries:
|
|
207
|
+
if e.empty:
|
|
208
|
+
lm.entries.append(EntryInfo(e.index, "empty"))
|
|
209
|
+
continue
|
|
210
|
+
rd = eventscan._read_object_init(eb, e.func_by_tag(0)) if e.func_by_tag(0) else {}
|
|
211
|
+
model_id = rd.get("model")
|
|
212
|
+
model_name = MODELS.get(model_id) if model_id is not None else None
|
|
213
|
+
talkable = e.func_by_tag(3) is not None
|
|
214
|
+
if e.index == 0:
|
|
215
|
+
role = "main"
|
|
216
|
+
elif e.index in player_entries or rd.get("player"):
|
|
217
|
+
role = "player"
|
|
218
|
+
elif e.index in gateway_entries:
|
|
219
|
+
role = "gateway"
|
|
220
|
+
elif model_id is not None:
|
|
221
|
+
role = "npc" if talkable else "object"
|
|
222
|
+
else:
|
|
223
|
+
role = "logic"
|
|
224
|
+
lm.entries.append(EntryInfo(e.index, role, model_id, model_name, talkable,
|
|
225
|
+
spawns.get(e.index, 0), [f.tag for f in e.funcs]))
|
|
226
|
+
|
|
227
|
+
for f in e.funcs:
|
|
228
|
+
node = Node(e.index, f.tag, _func_kind(role, f.tag), f.abs_start, f.abs_end)
|
|
229
|
+
for ins in eb.instrs(f):
|
|
230
|
+
op = ins.op
|
|
231
|
+
if op in WINDOW_OPS:
|
|
232
|
+
txid = ins.imm(WINDOW_OPS[op])
|
|
233
|
+
if txid is None:
|
|
234
|
+
node.unresolved.append({"op": ins.name, "reason": "text chosen at runtime", "off": ins.off})
|
|
235
|
+
else:
|
|
236
|
+
node.says.append({"txid": int(txid), "text": _line_text(entries, txid)})
|
|
237
|
+
elif op == ADD_ITEM_OP:
|
|
238
|
+
iid = ins.imm(0)
|
|
239
|
+
if iid is None:
|
|
240
|
+
node.unresolved.append({"op": ins.name, "reason": "item chosen at runtime", "off": ins.off})
|
|
241
|
+
elif iid != FR.NO_ITEM and not FR.item_inert(iid): # skip the engine no-op grants (as scan_item_ops)
|
|
242
|
+
node.gives.append({"kind": "item", "id": int(iid), "count": ins.imm(1),
|
|
243
|
+
"label": FR.item_label(iid)})
|
|
244
|
+
elif op == REMOVE_ITEM_OP:
|
|
245
|
+
node.gives.append({"kind": "remove_item", "id": ins.imm(0), "count": ins.imm(1)})
|
|
246
|
+
elif op == ADD_GIL_OP:
|
|
247
|
+
node.gives.append({"kind": "gil", "amount": ins.imm(0)})
|
|
248
|
+
elif op == REMOVE_GIL_OP:
|
|
249
|
+
node.gives.append({"kind": "remove_gil", "amount": ins.imm(0)})
|
|
250
|
+
elif op == MENU_OP:
|
|
251
|
+
mid = ins.imm(0)
|
|
252
|
+
if mid == SHOP_MENU_ID:
|
|
253
|
+
node.gives.append({"kind": "shop", "id": ins.imm(1)})
|
|
254
|
+
elif mid == SAVE_MENU_ID:
|
|
255
|
+
node.gives.append({"kind": "save_menu"})
|
|
256
|
+
elif mid is not None:
|
|
257
|
+
node.gives.append({"kind": "menu", "id": int(mid)})
|
|
258
|
+
elif op == FIELD_OP:
|
|
259
|
+
to = ins.imm(0)
|
|
260
|
+
if to is None:
|
|
261
|
+
node.unresolved.append({"op": ins.name, "reason": "warp target computed", "off": ins.off})
|
|
262
|
+
else:
|
|
263
|
+
node.warps.append({"op": "Field", "to": int(to)})
|
|
264
|
+
elif op == WORLDMAP_OP:
|
|
265
|
+
loc = ins.imm(0)
|
|
266
|
+
node.warps.append({"op": "WorldMap", "to": int(loc) if loc is not None else None})
|
|
267
|
+
elif op in RUNSCRIPT_OPS:
|
|
268
|
+
uid, t = ins.imm(1), ins.imm(2)
|
|
269
|
+
if uid is None or t is None:
|
|
270
|
+
node.unresolved.append({"op": ins.name, "reason": "call target computed", "off": ins.off})
|
|
271
|
+
else:
|
|
272
|
+
kind, targets = eventscan.resolve_uid(uid, e.index, player_entries, eb.entry_count)
|
|
273
|
+
node.calls.append(Call(RUNSCRIPT_NAMES.get(op, ins.name), int(uid), int(t),
|
|
274
|
+
kind, targets, ins.off, _call_label(kind, uid, t)))
|
|
275
|
+
elif op == STARTSEQ_OP:
|
|
276
|
+
slot = ins.imm(0)
|
|
277
|
+
if slot is None:
|
|
278
|
+
node.unresolved.append({"op": ins.name, "reason": "seq target computed", "off": ins.off})
|
|
279
|
+
else:
|
|
280
|
+
node.calls.append(Call("StartSeq", None, None, "seq", [int(slot)], ins.off,
|
|
281
|
+
f"starts concurrent seq (entry #{slot})"))
|
|
282
|
+
elif op in REPLY_OPS:
|
|
283
|
+
node.unresolved.append({"op": ins.name, "reason": "dispatches to the dynamic caller",
|
|
284
|
+
"off": ins.off})
|
|
285
|
+
elif ins.is_switch:
|
|
286
|
+
sw = ins.switch()
|
|
287
|
+
if sw is None:
|
|
288
|
+
node.unresolved.append({"op": ins.name, "reason": "switch operands computed", "off": ins.off})
|
|
289
|
+
else:
|
|
290
|
+
node.branches.append({"op": ins.name, "base": sw.base,
|
|
291
|
+
"edges": [{"value": e.value, "target": e.target,
|
|
292
|
+
"is_default": e.is_default} for e in sw.edges]})
|
|
293
|
+
elif op == EXPR_STMT_OP:
|
|
294
|
+
w = _flag_write_at(data, ins.off)
|
|
295
|
+
if w is not None:
|
|
296
|
+
node.flags_set.append({"index": w[0], "mode": w[1]})
|
|
297
|
+
else:
|
|
298
|
+
r = _flag_read_at(data, ins.off)
|
|
299
|
+
if r is not None:
|
|
300
|
+
node.flags_read.append({"index": r[0], "require_set": r[1]})
|
|
301
|
+
lm.nodes.append(node)
|
|
302
|
+
return lm
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _call_label(kind: str, uid, tag) -> str:
|
|
306
|
+
return {
|
|
307
|
+
"self": f"runs its own routine #{tag}",
|
|
308
|
+
"player": f"directs the player (sequence #{tag})",
|
|
309
|
+
"party": f"calls a party member (routine #{tag})",
|
|
310
|
+
"main": f"runs shared field logic (Main_Init routine #{tag})",
|
|
311
|
+
"object": f"drives object #{uid} (routine #{tag})",
|
|
312
|
+
}.get(kind, f"calls uid {uid} (routine #{tag})")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# --- id -> bytes loader (the install-backed entry point) --------------------------------------------
|
|
316
|
+
def logic_map(field_id: int, *, game=None, bundle=None, lang: str = "us") -> LogicMap:
|
|
317
|
+
"""Load a real field's ``.eb`` (+ ``.mes`` for dialogue text) and build its :class:`LogicMap`.
|
|
318
|
+
Read-only; degrades to ``<line N>`` placeholders without an install. Mirrors ``forkreport.explain``."""
|
|
319
|
+
from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT
|
|
320
|
+
b = bundle or EventBundle(game)
|
|
321
|
+
data = b.eb_for_id(field_id)
|
|
322
|
+
entries = None
|
|
323
|
+
try:
|
|
324
|
+
from . import dialogue as _d
|
|
325
|
+
mes = _d.extract_field_mes(str(field_id), lang=lang, game=game)
|
|
326
|
+
if mes:
|
|
327
|
+
entries = _d.parse_mes(mes)
|
|
328
|
+
except Exception:
|
|
329
|
+
entries = None
|
|
330
|
+
return build_logic_map(data, entries=entries, field_id=field_id,
|
|
331
|
+
fbg_name=ID_TO_FBG.get(field_id, ""), event_name=ID_TO_EVT.get(field_id, ""))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --- serialization ----------------------------------------------------------------------------------
|
|
335
|
+
def to_dict(lm: LogicMap) -> dict:
|
|
336
|
+
"""A JSON-serializable dict of the map (the generated read-only VIEW)."""
|
|
337
|
+
return {
|
|
338
|
+
"field_id": lm.field_id, "fbg_name": lm.fbg_name, "event_name": lm.event_name,
|
|
339
|
+
"has_text": lm.has_text, "generated_from_sha256": lm.sha256,
|
|
340
|
+
"entries": [vars(e) for e in lm.entries],
|
|
341
|
+
"nodes": [{**{k: v for k, v in vars(n).items() if k != "calls"},
|
|
342
|
+
"calls": [vars(c) for c in n.calls]} for n in lm.nodes],
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# --- readable rendering -----------------------------------------------------------------------------
|
|
347
|
+
_ROLE_GLYPH = {"main": "*", "player": "@", "npc": "o", "object": ".", "gateway": ">", "logic": "-"}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _fmt_node_lines(n: Node, indent: str = " ") -> list:
|
|
351
|
+
out = []
|
|
352
|
+
for s in n.says:
|
|
353
|
+
t = s["text"] if s["text"] else f"<line {s['txid']}>"
|
|
354
|
+
out.append(f'{indent}"{t}"')
|
|
355
|
+
for g in n.gives:
|
|
356
|
+
if g["kind"] == "item":
|
|
357
|
+
out.append(f"{indent}gives {g['label']}" + (f" x{g['count']}" if g.get("count") not in (None, 1) else ""))
|
|
358
|
+
elif g["kind"] == "gil":
|
|
359
|
+
out.append(f"{indent}gives gil" + (f" ({g['amount']})" if g.get("amount") is not None else ""))
|
|
360
|
+
elif g["kind"] == "shop":
|
|
361
|
+
out.append(f"{indent}opens shop #{g.get('id')}")
|
|
362
|
+
elif g["kind"] == "save_menu":
|
|
363
|
+
out.append(f"{indent}opens the save menu")
|
|
364
|
+
elif g["kind"] == "menu":
|
|
365
|
+
out.append(f"{indent}opens menu #{g.get('id')}")
|
|
366
|
+
elif g["kind"] == "remove_item":
|
|
367
|
+
out.append(f"{indent}takes item #{g.get('id')}")
|
|
368
|
+
elif g["kind"] == "remove_gil":
|
|
369
|
+
out.append(f"{indent}takes gil")
|
|
370
|
+
for fr in n.flags_read:
|
|
371
|
+
out.append(f"{indent}reads flag {fr['index']} (needs {'set' if fr['require_set'] else 'clear'})")
|
|
372
|
+
for fs in n.flags_set:
|
|
373
|
+
out.append(f"{indent}{'sets' if fs['mode'] == 'set' else 'or-sets'} flag {fs['index']}")
|
|
374
|
+
for w in n.warps:
|
|
375
|
+
out.append(f"{indent}{w['op']}({w.get('to')})")
|
|
376
|
+
for c in n.calls:
|
|
377
|
+
tgt = f" -> entry {c.targets}" if c.targets else ""
|
|
378
|
+
out.append(f"{indent}-> {c.label}{tgt}")
|
|
379
|
+
for b in n.branches:
|
|
380
|
+
ncases = sum(1 for e in b["edges"] if not e["is_default"])
|
|
381
|
+
arms = [("default" if e["is_default"] else str(e["value"])) + f"->@{e['target']}" for e in b["edges"]]
|
|
382
|
+
shown = ", ".join(arms[:6]) + (f", ... (+{len(arms) - 6} more)" if len(arms) > 6 else "")
|
|
383
|
+
out.append(f"{indent}switch ({ncases} cases): {shown}")
|
|
384
|
+
for u in n.unresolved:
|
|
385
|
+
out.append(f"{indent}? {u['op']}: {u['reason']}")
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def node_summary(n: Node) -> str:
|
|
390
|
+
"""A terse ONE-LINE 'what this routine does' from the per-routine attribution (calls / dialogue / rewards /
|
|
391
|
+
flags / warps / branches) -- context for the GUI edit panel + tooling. ``''`` for an empty routine. This is
|
|
392
|
+
a SUMMARY, not the full transcript (:func:`_fmt_node_lines` lists each item)."""
|
|
393
|
+
parts = []
|
|
394
|
+
if n.calls:
|
|
395
|
+
tags = sorted({c.tag for c in n.calls if c.tag is not None})
|
|
396
|
+
parts.append(f"runs tag {tags[0]}" if (len(n.calls) == 1 and len(tags) == 1)
|
|
397
|
+
else f"calls {len(n.calls)} routines")
|
|
398
|
+
if n.says:
|
|
399
|
+
parts.append(f"says {len(n.says)} line" + ("s" if len(n.says) != 1 else ""))
|
|
400
|
+
gkinds = [g["kind"] for g in n.gives]
|
|
401
|
+
reward = sum(1 for k in gkinds if k in ("item", "gil"))
|
|
402
|
+
if reward:
|
|
403
|
+
parts.append(f"gives {reward} reward" + ("s" if reward != 1 else ""))
|
|
404
|
+
for kind, phrase in (("shop", "opens a shop"), ("save_menu", "opens the save menu"),
|
|
405
|
+
("menu", "opens a menu"), ("remove_item", "takes an item"), ("remove_gil", "takes gil")):
|
|
406
|
+
c = gkinds.count(kind)
|
|
407
|
+
if c:
|
|
408
|
+
parts.append(phrase + (f" ×{c}" if c > 1 else ""))
|
|
409
|
+
if n.flags_read:
|
|
410
|
+
parts.append(f"reads flag {n.flags_read[0]['index']}" if len(n.flags_read) == 1
|
|
411
|
+
else f"reads {len(n.flags_read)} flags")
|
|
412
|
+
if n.flags_set:
|
|
413
|
+
parts.append(f"sets flag {n.flags_set[0]['index']}" if len(n.flags_set) == 1
|
|
414
|
+
else f"sets {len(n.flags_set)} flags")
|
|
415
|
+
if n.warps:
|
|
416
|
+
parts.append(f"{len(n.warps)} warp" + ("s" if len(n.warps) != 1 else ""))
|
|
417
|
+
if n.branches:
|
|
418
|
+
parts.append(f"{len(n.branches)} switch" + ("es" if len(n.branches) != 1 else ""))
|
|
419
|
+
if n.unresolved:
|
|
420
|
+
parts.append(f"{len(n.unresolved)} runtime-computed")
|
|
421
|
+
return " · ".join(parts)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def node_report(n: Node) -> list:
|
|
425
|
+
"""A FRIENDLY, human-readable per-routine transcript (for the GUI 'What this routine does' block). Less
|
|
426
|
+
cryptic than :func:`_fmt_node_lines`: dialogue shows its text, flag READS read as run-conditions, flag
|
|
427
|
+
WRITES say 'sets story flag N', warps say 'warps to field N' / 'exits to the world map', and switch arms
|
|
428
|
+
show their CASE VALUES (the scenario / menu-row numbers) instead of raw byte offsets. The inherent
|
|
429
|
+
crypticness that remains -- raw story-flag indices + routine #tags -- has no friendlier source (FF9 story
|
|
430
|
+
flags + function tags are unnamed). Empty list for an empty routine."""
|
|
431
|
+
out = []
|
|
432
|
+
for s in n.says:
|
|
433
|
+
out.append(f'Says: "{s["text"] or ("line " + str(s["txid"]))}"')
|
|
434
|
+
for g in n.gives:
|
|
435
|
+
k = g["kind"]
|
|
436
|
+
if k == "item":
|
|
437
|
+
out.append("Gives the player " + g["label"]
|
|
438
|
+
+ (f" ×{g['count']}" if g.get("count") not in (None, 1) else ""))
|
|
439
|
+
elif k == "gil":
|
|
440
|
+
out.append(f"Gives {g['amount']} gil" if g.get("amount") is not None else "Gives gil")
|
|
441
|
+
elif k == "shop":
|
|
442
|
+
out.append("Opens a shop")
|
|
443
|
+
elif k == "save_menu":
|
|
444
|
+
out.append("Opens the save-point menu")
|
|
445
|
+
elif k == "menu":
|
|
446
|
+
out.append(f"Opens a menu (#{g.get('id')})")
|
|
447
|
+
elif k == "remove_item":
|
|
448
|
+
out.append(f"Takes an item (#{g.get('id')})")
|
|
449
|
+
elif k == "remove_gil":
|
|
450
|
+
out.append("Takes gil")
|
|
451
|
+
for fr in n.flags_read:
|
|
452
|
+
out.append(f"Runs only if story flag {fr['index']} is " + ("SET" if fr["require_set"] else "CLEAR"))
|
|
453
|
+
for fs in n.flags_set:
|
|
454
|
+
out.append(("Sets" if fs["mode"] == "set" else "Sets (OR into)") + f" story flag {fs['index']}")
|
|
455
|
+
for w in n.warps:
|
|
456
|
+
op = str(w["op"])
|
|
457
|
+
if op.lower().startswith("field"):
|
|
458
|
+
out.append(f"Warps to field {w.get('to')}")
|
|
459
|
+
elif "world" in op.lower():
|
|
460
|
+
out.append("Exits to the world map")
|
|
461
|
+
else:
|
|
462
|
+
out.append(f"{op}({w.get('to')})")
|
|
463
|
+
for c in n.calls:
|
|
464
|
+
lbl = c.label or f"calls routine #{c.tag}"
|
|
465
|
+
tgt = f" [→ entry {', '.join(str(t) for t in c.targets)}]" if c.targets else ""
|
|
466
|
+
out.append(lbl[:1].upper() + lbl[1:] + tgt)
|
|
467
|
+
for b in n.branches:
|
|
468
|
+
vals = [str(e["value"]) for e in b["edges"] if not e["is_default"]]
|
|
469
|
+
if vals:
|
|
470
|
+
shown = ", ".join(vals[:8]) + (f", +{len(vals) - 8} more" if len(vals) > 8 else "")
|
|
471
|
+
out.append(f"Branches on a value → cases {shown} (else a default path)")
|
|
472
|
+
else:
|
|
473
|
+
out.append("Branches (a default path only)")
|
|
474
|
+
for u in n.unresolved:
|
|
475
|
+
out.append(f"Calls a routine chosen at runtime — {u['reason']}")
|
|
476
|
+
return out
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def node_hint(n: Node) -> str:
|
|
480
|
+
"""A SHORT, high-confidence tree-label suffix -- emitted ONLY when the routine has a SINGLE kind of action
|
|
481
|
+
(so the hint can't mislead). A mixed routine returns ``''`` and stays plain (its detail is in the panel
|
|
482
|
+
summary / :func:`_fmt_node_lines`)."""
|
|
483
|
+
cats = (bool(n.calls), bool(n.says), bool(n.gives), bool(n.warps), bool(n.flags_set), bool(n.branches))
|
|
484
|
+
if sum(cats) != 1:
|
|
485
|
+
return ""
|
|
486
|
+
if n.calls:
|
|
487
|
+
tags = sorted({c.tag for c in n.calls if c.tag is not None})
|
|
488
|
+
return f" → tag {tags[0]}" if (len(n.calls) == 1 and len(tags) == 1) else f" → {len(n.calls)} calls"
|
|
489
|
+
if n.warps:
|
|
490
|
+
return " → warp"
|
|
491
|
+
if n.says:
|
|
492
|
+
return " · dialogue"
|
|
493
|
+
if n.gives:
|
|
494
|
+
return " · reward"
|
|
495
|
+
if n.flags_set:
|
|
496
|
+
return " · sets flag"
|
|
497
|
+
return " · switch" # the only remaining single category (branches)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def format_logic_map(lm: LogicMap) -> str:
|
|
501
|
+
"""Render a :class:`LogicMap` as a readable per-entry transcript of the whole script."""
|
|
502
|
+
head = lm.fbg_name or f"field {lm.field_id}"
|
|
503
|
+
suffix = f" (field {lm.field_id}{', ' + lm.event_name if lm.event_name else ''})"
|
|
504
|
+
out = [f"logic-map: {head}{suffix}", ""]
|
|
505
|
+
used = [e for e in lm.entries if e.role != "empty"]
|
|
506
|
+
out.append(f" {len(used)} entries, {len(lm.nodes)} routines"
|
|
507
|
+
f"{'' if lm.has_text else ' (no install/.mes -> dialogue as <line N>)'}")
|
|
508
|
+
out.append("")
|
|
509
|
+
by_entry: dict = {}
|
|
510
|
+
for n in lm.nodes:
|
|
511
|
+
by_entry.setdefault(n.entry, []).append(n)
|
|
512
|
+
for e in lm.entries:
|
|
513
|
+
if e.role == "empty":
|
|
514
|
+
continue
|
|
515
|
+
glyph = _ROLE_GLYPH.get(e.role, " ")
|
|
516
|
+
model = f" {e.model_name or ('model ' + str(e.model_id))}" if e.model_id is not None else ""
|
|
517
|
+
spawn = "" if e.role in ("main", "player") or e.spawns else " (defined, not spawned)"
|
|
518
|
+
out.append(f" {glyph} entry {e.index}: {e.role}{model}{spawn}")
|
|
519
|
+
for n in by_entry.get(e.index, []):
|
|
520
|
+
lines = _fmt_node_lines(n)
|
|
521
|
+
if not lines:
|
|
522
|
+
continue
|
|
523
|
+
out.append(f" [{n.kind} / tag {n.tag}]")
|
|
524
|
+
out.extend(lines)
|
|
525
|
+
out.append("")
|
|
526
|
+
return "\n".join(out).rstrip()
|