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/forkreport.py
ADDED
|
@@ -0,0 +1,1383 @@
|
|
|
1
|
+
"""``fork-report`` -- preview, OFFLINE, what a fork of a real FF9 field will and won't reproduce.
|
|
2
|
+
|
|
3
|
+
The north star is fork FIDELITY (``docs/FORK_FIDELITY.md``): "fork a real field -> does it play identically?"
|
|
4
|
+
Before you fork, this answers it. For any real field it reads the compiled ``.eb`` (no game running) and reports:
|
|
5
|
+
|
|
6
|
+
* **Roster fidelity** -- how many persistent objects a fork carries, how many are ``Field()``-warp **directors**
|
|
7
|
+
(cutscene actors carried as NPCs -> the rotating-cast mess), and whether content rotates by story beat.
|
|
8
|
+
* **Interaction fidelity** -- per carried NPC, whether its talk handler PORTS (`graft_safety`): ``clean`` = fully
|
|
9
|
+
interactive on the fork, ``init_only`` = renders but its talk is dropped (re-author it), ``refuse`` = a stub.
|
|
10
|
+
* **Story gating** -- story-gated doors + the ScenarioCounter beats the field gates content on.
|
|
11
|
+
* **Items / treasure** -- the item/gil grants + shops the field's ``.eb`` performs (``AddItem`` / ``AddGil``
|
|
12
|
+
/ ``Menu(2, id)``). A ``--verbatim`` fork RUNS these (carries them byte-identically); a plain/synthesize
|
|
13
|
+
fork has no item scanner, so it DROPS every treasure + shop. (memory ``project-ff9-items-equipment``.)
|
|
14
|
+
* **Home beat** -- a suggested ``[startup] scenario`` (the author picks the beat -- they have the game knowledge).
|
|
15
|
+
|
|
16
|
+
It is **read-only** and reuses the existing scanners (``eventscan.scan_objects_verbatim`` for the carry
|
|
17
|
+
classification, ``eventscan.scan_gateway_entries`` for gated doors, ``flags`` for the beat table) -- it adds
|
|
18
|
+
no carry/scanner logic of its own. Two axes are reported SEPARATELY because they are independent: Daguerreo
|
|
19
|
+
is a clean *roster* (0 directors, renders faithfully) yet degrades *interactions* (half its NPCs go render-only).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import bisect as _bisect
|
|
24
|
+
import re
|
|
25
|
+
import struct
|
|
26
|
+
from dataclasses import dataclass, field as _dc_field
|
|
27
|
+
|
|
28
|
+
from . import flags as _flags
|
|
29
|
+
from .eb.model import EbScript
|
|
30
|
+
|
|
31
|
+
# --- bytecode signals -------------------------------------------------------------------------------
|
|
32
|
+
FIELD_OP = 0x2B # Field(target) -- a warp; in an object's tag-1 LOOP => a cutscene director/actor
|
|
33
|
+
PHASE_SWITCH_OP = 0x06 # op_06 -- a phase/state jump-table (the other director tell)
|
|
34
|
+
LOOP_TAG = 1 # object LOOP function (where cutscene warps live)
|
|
35
|
+
TALK_TAG = 3 # press-action talk handler
|
|
36
|
+
|
|
37
|
+
# A ScenarioCounter gate in an expression: push GLOB_UINT16[0] (DC 00), a constant (7D lo hi), then a
|
|
38
|
+
# COMPARISON op (a write would use 2C/3F instead, so comparisons alone are the field's story gates).
|
|
39
|
+
_SC_GATE = re.compile(rb"\xDC\x00\x7D(..)(.)", re.DOTALL)
|
|
40
|
+
_CMP_OPS = frozenset({0x18, 0x19, 0x1A, 0x1B, 0x20}) # < > <= >= ==
|
|
41
|
+
# Many distinct gate values => the field rotates its content/cast by story progress (the Dali shop gates
|
|
42
|
+
# at 11 values, Dali through Pandemonium; a static room gates at <=1).
|
|
43
|
+
_ROTATING_GATE_COUNT = 3
|
|
44
|
+
|
|
45
|
+
# The controlled PLAYER character (DefinePlayerCharacter's SetModel id). Most fields are Zidane; a
|
|
46
|
+
# non-Zidane primary means "you play as someone else" -- which forks faithfully ONLY via --verbatim (it
|
|
47
|
+
# ships the donor player rig + anim packs + the field's own party/cutscene setup whole). The graft path
|
|
48
|
+
# refuses non-Zidane player funcs ("model" graft-safety -- another rig's clip ids). Proven on Vivi/field 100.
|
|
49
|
+
# (memory project-ff9-non-zidane-donors). Names for the playable cast; others fall back to the GEO model name.
|
|
50
|
+
PLAYABLE_NAMES = {98: "Zidane", 532: "Zidane(ZDD)", 8: "Vivi", 5489: "Steiner", 526: "Steiner(STD)",
|
|
51
|
+
192: "Freya", 443: "Eiko", 185: "Garnet", 509: "Amarant", 273: "Kuja"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def player_name(model_id) -> str:
|
|
55
|
+
"""A friendly name for a player model id (the playable cast), else its GEO model name, else 'none'."""
|
|
56
|
+
if model_id is None:
|
|
57
|
+
return "none"
|
|
58
|
+
if model_id in PLAYABLE_NAMES:
|
|
59
|
+
return PLAYABLE_NAMES[model_id]
|
|
60
|
+
from ._modeldb import MODELS
|
|
61
|
+
return MODELS.get(model_id, f"model {model_id}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Which entry the engine BINDS CONTROL to when a field defines >1 DefinePlayerCharacter (0x2C). The engine
|
|
65
|
+
# sets controlUID = the uid of each 0x2C as it EXECUTES (last-write-wins; Memoria EventEngine.DoEventCode.cs),
|
|
66
|
+
# and entries run their Init in InitObject (0x09 in Main_Init) order -- so control binds to the entry whose
|
|
67
|
+
# 0x2C runs LAST: among entries whose tag-0 Init runs a 0x2C UNCONDITIONALLY, the one InitObject'd latest.
|
|
68
|
+
# In-game PROVEN on the Treno Dagger+Steiner room (-> Garnet, the last-executed 0x2C, NOT the first-spawned
|
|
69
|
+
# Steiner nor the warp-in Zidane). memory project-ff9-non-zidane-donors. Reliable for FIXED-SID character
|
|
70
|
+
# fields (the non-Zidane lane); a normal party field can route control through a party slot to the LIVE
|
|
71
|
+
# leader, which this doesn't model -- so trust it only when no Zidane is among the PCs (the lane).
|
|
72
|
+
_BRANCH_OPS = frozenset({0x02, 0x03, 0x04}) # conditional-branch family (empirically gates a following 0x2C)
|
|
73
|
+
INITOBJ_OP = 0x09
|
|
74
|
+
DEFINE_PC_OP = 0x2C
|
|
75
|
+
# control-flow opcodes for the beat-roster walk: an EXPR (0x05) ScenarioCounter comparison drives a
|
|
76
|
+
# conditional jump -- 0x02 skips its body when the condition is FALSE, 0x03 skips when TRUE. 0x01 is the
|
|
77
|
+
# undocumented UNCONDITIONAL jump (CLAUDE.md §7); it does NOT gate its body, so the walk FOLLOWS it (which
|
|
78
|
+
# is what correctly steps over an if/else's else-branch) rather than treating it as a guard.
|
|
79
|
+
JMP_UNCOND = 0x01
|
|
80
|
+
JMP_FALSE = 0x02
|
|
81
|
+
JMP_TRUE = 0x03
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _init_0x2c_status(eb, entry_index) -> str:
|
|
85
|
+
"""A player entry's load-time Init (tag 0) DefinePlayerCharacter: 'uncond' (binds at spawn), 'cond'
|
|
86
|
+
(behind a conditional branch -> story-dependent), or 'absent' (its 0x2C is in a cutscene func, not Init)."""
|
|
87
|
+
try:
|
|
88
|
+
f = eb.entry(entry_index).func_by_tag(0)
|
|
89
|
+
except (IndexError, AttributeError):
|
|
90
|
+
return "absent"
|
|
91
|
+
if f is None:
|
|
92
|
+
return "absent"
|
|
93
|
+
ins = list(eb.instrs(f))
|
|
94
|
+
idx = next((k for k, i in enumerate(ins) if i.op == DEFINE_PC_OP), None)
|
|
95
|
+
if idx is None:
|
|
96
|
+
return "absent"
|
|
97
|
+
return "cond" if any(i.op in _BRANCH_OPS for i in ins[:idx]) else "uncond"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def controlled_player(eb):
|
|
101
|
+
"""Best-effort (entry_index | None, confidence in {'high','low','none'}) for the player entry the engine
|
|
102
|
+
binds control to at field load (see the module note above). Single-PC -> that entry. Multi-PC -> among
|
|
103
|
+
the entries whose Init runs a 0x2C unconditionally (else any 0x2C-in-Init), the one InitObject'd latest in
|
|
104
|
+
Main_Init; 'low' confidence when that entry is multi-spawned or only gated (the binder is then ambiguous)."""
|
|
105
|
+
from . import eventscan as _es # lazy (extraction-free, but keeps import cost off the core path)
|
|
106
|
+
pents = _es.resolve_player_entries(eb)
|
|
107
|
+
if not pents:
|
|
108
|
+
return (None, "none")
|
|
109
|
+
if len(pents) == 1:
|
|
110
|
+
return (pents[0], "high")
|
|
111
|
+
mi = eb.entry(0).func_by_tag(0) if eb.entry_count > 0 else None
|
|
112
|
+
order = [i.imm(0) for i in eb.instrs(mi) if i.op == INITOBJ_OP] if mi is not None else []
|
|
113
|
+
|
|
114
|
+
def last_pos(p):
|
|
115
|
+
occ = [k for k, v in enumerate(order) if v == p]
|
|
116
|
+
return max(occ) if occ else -1
|
|
117
|
+
|
|
118
|
+
status = {p: _init_0x2c_status(eb, p) for p in pents}
|
|
119
|
+
pool = ([p for p in pents if status[p] == "uncond"]
|
|
120
|
+
or [p for p in pents if status[p] == "cond"] or list(pents))
|
|
121
|
+
binder = max(pool, key=last_pos)
|
|
122
|
+
multi_spawn = sum(1 for v in order if v == binder) > 1
|
|
123
|
+
conf = "high" if (status[binder] == "uncond" and not multi_spawn) else "low"
|
|
124
|
+
# Zidane-present hedge: if a Zidane model is defined among the PCs but the crowned binder is NOT Zidane,
|
|
125
|
+
# control likely routes through the party slot to the Zidane leader (the last-0x2C binder is unreliable
|
|
126
|
+
# here -- the Cargo Ship mispredicts) -> downgrade so no caller treats the pick as certain.
|
|
127
|
+
if conf == "high" and _es._player_model(eb, binder) not in _es.ZIDANE_MODELS \
|
|
128
|
+
and any(_es._player_model(eb, p) in _es.ZIDANE_MODELS for p in pents):
|
|
129
|
+
conf = "low"
|
|
130
|
+
return (binder, conf)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --- party-membership ops (a verbatim fork RUNS these -> the fork can change your party) ----------------
|
|
134
|
+
# CharacterOldIndex (the .eb id space the party ops take; project-ff9-pc-party-system). NOT the GEO model id.
|
|
135
|
+
CHAR_OLD_INDEX = {0: "Zidane", 1: "Vivi", 2: "Garnet", 3: "Steiner", 4: "Freya", 5: "Quina", 6: "Eiko",
|
|
136
|
+
7: "Amarant", 8: "Beatrix", 9: "Cinna", 10: "Marcus", 11: "Blank"}
|
|
137
|
+
PARTY_NONE = 0xFFFF # the NONE sentinel (slot-clear / add terminator) -- not a real member
|
|
138
|
+
REMOVE_PARTY_OP = 0xDD # RemoveParty(charIndex)
|
|
139
|
+
SET_PARTY_RESERVE_OP = 0xB4 # SetPartyReserve(mask) -- rebuilds the recruitable roster
|
|
140
|
+
JOIN_OP = 0xFE # SetCharacterData / JOIN -- a formal recruit (battle+menu init)
|
|
141
|
+
PARTY_MENU_OP = 0xB2 # Party() -- the change-members menu UI
|
|
142
|
+
EXPR_STMT_OP = 0x05 # an expression statement (holds the B_PARTYADD call)
|
|
143
|
+
# literal single-char ADD inside an expression: B_CONST(0x7D) <2-byte CharacterOldIndex> B_PARTYADD(0x6D)
|
|
144
|
+
_PARTYADD_RE = re.compile(rb"\x7d(..)\x6d", re.DOTALL)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def party_char_name(idx) -> str:
|
|
148
|
+
return CHAR_OLD_INDEX.get(int(idx), "#%d" % int(idx))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def scan_party_ops(eb_bytes) -> dict:
|
|
152
|
+
"""The party-membership operations a field performs -- a ``--verbatim`` fork RUNS these, so they preview
|
|
153
|
+
how a fork will change your party. Returns ``{adds, removes}`` (sorted distinct CharacterOldIndex, NONE
|
|
154
|
+
filtered) + the flags ``reset`` (``SetPartyReserve`` -> rebuilds the recruitable roster), ``recruit``
|
|
155
|
+
(``SetCharacterData``/JOIN), ``menu`` (the change-members UI). Heuristic: the literal single-char ADD
|
|
156
|
+
(``B_CONST <id> B_PARTYADD``) is decoded inside expression statements; the statement ops by their arg.
|
|
157
|
+
A field that drives membership from a variable (the common reserve-mask form) is captured by ``reset``."""
|
|
158
|
+
data = bytes(eb_bytes)
|
|
159
|
+
eb = EbScript.from_bytes(data)
|
|
160
|
+
adds, removes = set(), set()
|
|
161
|
+
reset = recruit = menu = False
|
|
162
|
+
for e in eb.entries:
|
|
163
|
+
if e.empty:
|
|
164
|
+
continue
|
|
165
|
+
for f in e.funcs:
|
|
166
|
+
for ins in eb.instrs(f):
|
|
167
|
+
if ins.op == REMOVE_PARTY_OP:
|
|
168
|
+
if ins.args and not any(ins.arg_is_expr):
|
|
169
|
+
removes.add(int(ins.args[0]))
|
|
170
|
+
elif ins.op == SET_PARTY_RESERVE_OP:
|
|
171
|
+
reset = True
|
|
172
|
+
elif ins.op == JOIN_OP:
|
|
173
|
+
recruit = True
|
|
174
|
+
elif ins.op == PARTY_MENU_OP:
|
|
175
|
+
menu = True
|
|
176
|
+
elif ins.op == EXPR_STMT_OP:
|
|
177
|
+
for h in _PARTYADD_RE.findall(data[ins.off:ins.end]):
|
|
178
|
+
adds.add(struct.unpack("<H", h)[0])
|
|
179
|
+
adds.discard(PARTY_NONE)
|
|
180
|
+
removes.discard(PARTY_NONE)
|
|
181
|
+
return {"adds": sorted(adds), "removes": sorted(removes), "reset": reset, "recruit": recruit, "menu": menu}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- item / treasure / shop ops -----------------------------------------------------------------------
|
|
185
|
+
# These live WHOLLY in the field `.eb`, so a `--verbatim` fork RUNS them (carries them byte-identically) but
|
|
186
|
+
# a plain/synthesize fork DROPS them -- eventscan has no AddItem scanner, and there is no shop authoring. A
|
|
187
|
+
# shop's `Menu(2, id)` carries too, but its STOCK comes from the base `ShopItems.csv` (a fork is parasitic on
|
|
188
|
+
# it -- it can't change the inventory). The kit catalogs only the regular 0-255 item space, so a key/card id
|
|
189
|
+
# gets a generic label. (memory project-ff9-items-equipment; opcodes AddItem/AddGil/Menu in eb/opcodes.py.)
|
|
190
|
+
ADD_ITEM_OP = 0x48 # AddItem(item_id, count) -- the real-chest / reward opcode (item_id 2B, count 1B)
|
|
191
|
+
REMOVE_ITEM_OP = 0x49 # RemoveItem(item_id, count)
|
|
192
|
+
ADD_GIL_OP = 0xCE # AddGil(amount) -- treasure gil (amount 3B, unsigned)
|
|
193
|
+
REMOVE_GIL_OP = 0xCF # RemoveGil(amount)
|
|
194
|
+
MENU_OP = 0x75 # Menu(menu_id, sub_id); menu_id 2 = SHOP (sub_id = shop id). 1=name 4=save 5=chocograph
|
|
195
|
+
SHOP_MENU_ID = 2
|
|
196
|
+
NO_ITEM = 255 # the RegularItem empty sentinel -- not a real grant (filtered, like PARTY_NONE)
|
|
197
|
+
GIL_CAP = 9_999_999 # the FF9 party-gil ceiling; a larger literal AddGil is a scripted sentinel, not treasure
|
|
198
|
+
# The event `AddItem` operand is a POOL-ENCODED item id, classified by `id % 1000` (ff9item.FF9Item_Add_Generic):
|
|
199
|
+
# 0-255 = regular item, 256-511 = important/key item, 512-611 = Tetra Master card, >= 612 = engine NO-OP (inert).
|
|
200
|
+
# A plain regular item (the normal chest/reward) has raw id 0-255 (pool 0), so `items.name_of` names it directly;
|
|
201
|
+
# higher pools (e.g. 31000, %1000=0) reference extended/modded regular ids the kit doesn't name. (project-ff9-items-equipment.)
|
|
202
|
+
POOL = 1000
|
|
203
|
+
REGULAR_MAX = 256 # id % 1000 < 256 -> regular item
|
|
204
|
+
IMPORTANT_MAX = 512 # 256 <= id % 1000 < 512 -> important/key item
|
|
205
|
+
CARD_MAX = 612 # 512 <= id % 1000 < 612 -> card; id % 1000 >= 612 -> inert (no grant)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def item_inert(item_id) -> bool:
|
|
209
|
+
"""True if the engine treats this ``AddItem`` id as a NO-OP (``id % 1000 >= 612`` falls in no item pool, so
|
|
210
|
+
``FF9Item_Add_Generic`` returns 0). Such ids grant nothing -> excluded from the preview's give list."""
|
|
211
|
+
return int(item_id) % POOL >= CARD_MAX
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def item_label(item_id) -> str:
|
|
215
|
+
"""A friendly label for an ``AddItem`` id, faithful to the engine's ``id % 1000`` pool decode: the
|
|
216
|
+
``RegularItem`` name for a plain 0-255 id (the normal treasure case), else a classified-but-unnamed
|
|
217
|
+
``item #N`` (extended/modded regular) / ``key item #N`` / ``card #N`` (the kit catalogs only the regular
|
|
218
|
+
0-255 space -- project-ff9-items-equipment)."""
|
|
219
|
+
iid = int(item_id)
|
|
220
|
+
if 0 <= iid < REGULAR_MAX: # pool 0: the raw id IS the RegularItem id (names directly)
|
|
221
|
+
from . import items as _items
|
|
222
|
+
nm = _items.name_of(iid)
|
|
223
|
+
if nm is not None:
|
|
224
|
+
return nm
|
|
225
|
+
m = iid % POOL
|
|
226
|
+
if m < REGULAR_MAX:
|
|
227
|
+
return "item #%d" % iid # a regular item in a higher pool (extended / modded id space)
|
|
228
|
+
if m < IMPORTANT_MAX:
|
|
229
|
+
return "key item #%d" % (m - REGULAR_MAX) # important/key item (a separate space the kit doesn't name)
|
|
230
|
+
if m < CARD_MAX:
|
|
231
|
+
return "card #%d" % (m - IMPORTANT_MAX) # Tetra Master card
|
|
232
|
+
return "item #%d (inert)" % iid # id % 1000 >= 612 -> engine no-op (shouldn't reach gives)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def scan_item_ops(eb_bytes) -> dict:
|
|
236
|
+
"""The item / gil / shop operations a field performs -- a ``--verbatim`` fork RUNS these, so they preview
|
|
237
|
+
the treasure + shops a fork reproduces (a plain/synthesize fork has NO item scanner, so it DROPS them all).
|
|
238
|
+
Returns ``{gives, gil_max, gil_any, shops, removes, var_give}``: ``gives`` = sorted distinct
|
|
239
|
+
``(item_id, count)`` literal ``AddItem`` grants (NoItem filtered; ``count`` = the MAX single-grant amount,
|
|
240
|
+
``None`` when computed); ``gil_max`` = the largest single PLAUSIBLE literal ``AddGil`` (<= ``GIL_CAP``);
|
|
241
|
+
``gil_any`` = any ``AddGil`` at all (literal or computed); ``shops`` = sorted distinct ``Menu(2, id)`` shop
|
|
242
|
+
ids; ``removes`` = ``RemoveItem`` op count; ``var_give`` = any ``AddItem`` with a COMPUTED id; ``var_shop`` =
|
|
243
|
+
any ``Menu(2, <computed>)`` (a story-gated shop whose id is picked at runtime) -- both un-previewable.
|
|
244
|
+
|
|
245
|
+
IMPORTANT -- DON'T SUM across paths: a field's ``.eb`` runs many MUTUALLY-EXCLUSIVE story-gated branches,
|
|
246
|
+
so the same chest's ``AddItem``/``AddGil`` recurs across them. We report DISTINCT items (max single-grant
|
|
247
|
+
count, not a sum) and gil as a per-grant max -- summing wildly overcounts (field 854 grants Ether x1 on two
|
|
248
|
+
parallel paths, not x2; its ~16.7M-gil literal is a scripted sentinel above the 9,999,999 cap, so it is
|
|
249
|
+
suppressed from ``gil_max`` but still flips ``gil_any``)."""
|
|
250
|
+
data = bytes(eb_bytes)
|
|
251
|
+
eb = EbScript.from_bytes(data)
|
|
252
|
+
gives: dict = {} # item_id -> max single-AddItem count (None once any occurrence has a computed count)
|
|
253
|
+
gil_max = 0 # largest single PLAUSIBLE literal AddGil (<= GIL_CAP)
|
|
254
|
+
gil_any = False # any AddGil at all (literal or computed)
|
|
255
|
+
shops: set = set()
|
|
256
|
+
removes = 0
|
|
257
|
+
var_give = False
|
|
258
|
+
var_shop = False
|
|
259
|
+
for e in eb.entries:
|
|
260
|
+
if e.empty:
|
|
261
|
+
continue
|
|
262
|
+
for f in e.funcs:
|
|
263
|
+
for ins in eb.instrs(f):
|
|
264
|
+
if ins.op == ADD_ITEM_OP:
|
|
265
|
+
iid = ins.imm(0)
|
|
266
|
+
if iid is None: # a computed item id -> can't say which item
|
|
267
|
+
var_give = True
|
|
268
|
+
continue
|
|
269
|
+
if iid == NO_ITEM or item_inert(iid): # NoItem / engine no-op (id % 1000 >= 612) -> no grant
|
|
270
|
+
continue
|
|
271
|
+
cnt = ins.imm(1)
|
|
272
|
+
prev = gives.get(iid, 0)
|
|
273
|
+
gives[iid] = None if (prev is None or cnt is None) else max(prev, cnt)
|
|
274
|
+
elif ins.op == REMOVE_ITEM_OP:
|
|
275
|
+
removes += 1
|
|
276
|
+
elif ins.op == ADD_GIL_OP:
|
|
277
|
+
gil_any = True
|
|
278
|
+
amt = ins.imm(0)
|
|
279
|
+
if amt is not None and amt <= GIL_CAP:
|
|
280
|
+
gil_max = max(gil_max, amt)
|
|
281
|
+
elif ins.op == MENU_OP and ins.imm(0) == SHOP_MENU_ID:
|
|
282
|
+
sid = ins.imm(1)
|
|
283
|
+
if sid is not None:
|
|
284
|
+
shops.add(sid)
|
|
285
|
+
else: # a story-gated shop (computed sub_id) -> can't name the id
|
|
286
|
+
var_shop = True
|
|
287
|
+
return {"gives": sorted(gives.items()), "gil_max": gil_max, "gil_any": gil_any,
|
|
288
|
+
"shops": sorted(shops), "removes": removes, "var_give": var_give, "var_shop": var_shop}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass
|
|
292
|
+
class ForkReport:
|
|
293
|
+
field_id: int
|
|
294
|
+
fbg_name: str = ""
|
|
295
|
+
event_name: str = ""
|
|
296
|
+
has_script: bool = True
|
|
297
|
+
n_objects: int = 0
|
|
298
|
+
n_props: int = 0 # non-talkable set-dressing
|
|
299
|
+
n_talkable: int = 0
|
|
300
|
+
n_interactive: int = 0 # talkable NPCs whose talk grafts CLEAN (keep interactions; props excluded)
|
|
301
|
+
n_speaking: int = 0 # carried NPCs whose tag-3 talk SHOWS dialogue (need --carry-text)
|
|
302
|
+
n_dialogue_lines: int = 0 # total distinct talk txids those NPCs show
|
|
303
|
+
directors: list = _dc_field(default_factory=list) # donor_idx of carried objects that warp/switch in LOOP
|
|
304
|
+
stacked: list = _dc_field(default_factory=list) # donor_idx of multi-instance (one-spot stacking) objects
|
|
305
|
+
safety: dict = _dc_field(default_factory=dict) # {clean: n, init_only: n, refuse: n}
|
|
306
|
+
gated_doors: int = 0
|
|
307
|
+
sc_gates: list = _dc_field(default_factory=list) # [(value, (milestone_value, beat))] sorted
|
|
308
|
+
suggested_scenario: int | None = None
|
|
309
|
+
roster_class: str = "static-roster" # "static-roster" | "story-event"
|
|
310
|
+
beat_roster: list = _dc_field(default_factory=list) # [(beat, milestone, [(slot, model_name, is_director)])]
|
|
311
|
+
# per ScenarioCounter beat (incl. 0), which carried objects the director actually spawns -- the
|
|
312
|
+
# #13 "rotating cast" preview (empty unless the roster genuinely VARIES across beats)
|
|
313
|
+
player_models: list = _dc_field(default_factory=list) # [(entry_index, model_id, name)] -- the defined PC(s)
|
|
314
|
+
multi_pc: bool = False # the field defines >1 DefinePlayerCharacter
|
|
315
|
+
non_zidane: bool = False # the controlled player isn't Zidane -> --verbatim is the faithful mode
|
|
316
|
+
controlled_entry: int | None = None # the entry the engine BINDS control to (multi-PC; controlled_player)
|
|
317
|
+
controlled_name: str = "" # its character name
|
|
318
|
+
control_confidence: str = "none" # 'high' | 'low' | 'none' (binder ambiguity)
|
|
319
|
+
swap_gesture_count: int = 0 # scripted player GESTURES that would glitch on a --swap-player
|
|
320
|
+
arrival_spots: int = 0 # distinct per-ENTRANCE player spawn points (#9); >1 = a synth
|
|
321
|
+
# fork collapses them to one [player] spawn (loses per-door arrival) -- --verbatim ships the real table
|
|
322
|
+
cam_pitch: float | None = None # camera downward pitch (deg); None = not read (.eb-only / no install)
|
|
323
|
+
cam_fov: float | None = None # horizontal FOV (deg) -> close/medium/wide feel
|
|
324
|
+
cam_scrolling: bool = False # a wide/scrolling field (range past one 384x448 screen)
|
|
325
|
+
cam_count: int = 0 # number of cameras (> 1 = a multi-camera field)
|
|
326
|
+
cam_range_h: int = 0 # camera visible height (screen units); the "how far back" signal
|
|
327
|
+
party_adds: list = _dc_field(default_factory=list) # distinct CharacterOldIndex names the field ADDS (B_PARTYADD)
|
|
328
|
+
party_removes: list = _dc_field(default_factory=list) # distinct names it REMOVES (RemoveParty)
|
|
329
|
+
party_reset: bool = False # SetPartyReserve -- rebuilds the recruitable roster (story reset)
|
|
330
|
+
party_recruit: bool = False # SetCharacterData/JOIN -- a formal recruit (battle+menu init)
|
|
331
|
+
party_menu: bool = False # opens the change-members MENU (moogle/save-point UI)
|
|
332
|
+
item_gives: list = _dc_field(default_factory=list) # [(item_id, count)] distinct AddItem grants (count = max single)
|
|
333
|
+
item_gil_max: int = 0 # largest single PLAUSIBLE literal AddGil (<= GIL_CAP)
|
|
334
|
+
item_gil_any: bool = False # any AddGil at all (literal or computed) -> treasure gil
|
|
335
|
+
item_shops: list = _dc_field(default_factory=list) # distinct Menu(2, id) shop ids the field opens
|
|
336
|
+
item_removes: int = 0 # RemoveItem op count
|
|
337
|
+
item_var_give: bool = False # an AddItem with a COMPUTED id (un-previewable)
|
|
338
|
+
item_var_shop: bool = False # a Menu(2, <computed>) -- a story-gated shop (id un-previewable)
|
|
339
|
+
lost_on_mint: list = _dc_field(default_factory=list) # [(label, detail)] -- USER-VISIBLE engine behaviors
|
|
340
|
+
# keyed on the real fldMapNo that a fork loses on its custom id (walkmesh hotfix / narrow-map letterbox /
|
|
341
|
+
# Chocobo HUD / intro FMV). The "impossible" axis of the taxonomy, per field (idgated.lost_on_mint).
|
|
342
|
+
area_title: tuple = None # (startOvr, endOvr) if the field has an area-title
|
|
343
|
+
# CARD -- donor identity SHOWN on --verbatim, dropped/auto-hidden on a synth (BG-borrow/native) fork
|
|
344
|
+
notes: list = _dc_field(default_factory=list)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _is_director(eb: EbScript, donor_idx: int) -> bool:
|
|
348
|
+
"""True if the object's LOOP (tag 1) warps (``Field()``) or runs a phase-switch -- a cutscene
|
|
349
|
+
director/actor carried as an NPC (the rotating-cast / stacked-spawn failure mode)."""
|
|
350
|
+
try:
|
|
351
|
+
loop = eb.entry(donor_idx).func_by_tag(LOOP_TAG)
|
|
352
|
+
except (IndexError, AttributeError):
|
|
353
|
+
return False
|
|
354
|
+
if loop is None:
|
|
355
|
+
return False
|
|
356
|
+
return any(ins.op in (FIELD_OP, PHASE_SWITCH_OP) for ins in eb.instrs(loop))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def scenario_gates(eb_bytes) -> list[int]:
|
|
360
|
+
"""Distinct ScenarioCounter values the field COMPARES against (the beats it gates content on), sorted.
|
|
361
|
+
A field with many of these rotates its cast/content by story progress; one (or none) is static."""
|
|
362
|
+
out = set()
|
|
363
|
+
for m in _SC_GATE.finditer(bytes(eb_bytes)):
|
|
364
|
+
if m.group(2)[0] in _CMP_OPS:
|
|
365
|
+
out.add(struct.unpack("<H", m.group(1))[0])
|
|
366
|
+
return sorted(out)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# --- roster by beat (#13): which carried objects the director actually spawns at each ScenarioCounter beat -
|
|
370
|
+
# A story-event field gates its InitObject calls on ScenarioCounter (the "rotating cast"). To preview the cast
|
|
371
|
+
# at a given beat WITHOUT deploying, we symbolically walk Main_Init at that beat: evaluate only the
|
|
372
|
+
# ScenarioCounter comparisons that drive conditional jumps (fall through every OTHER conditional -- flag gates
|
|
373
|
+
# are assumed satisfied), follow unconditional jumps, and collect the InitObject slots actually reached. This
|
|
374
|
+
# correctly handles if/else, nesting, and the `if(SC==BEAT){spawn}` dispatch chain (vs naive range-containment).
|
|
375
|
+
|
|
376
|
+
def _sc_cond(data, off):
|
|
377
|
+
"""If the EXPR statement at ``off`` is a SIMPLE ScenarioCounter comparison (``05 DC 00 7D <u16> <cmp> 7F``),
|
|
378
|
+
return ``(cmp_op, const)``; else ``None`` (compound/other exprs are treated as non-SC -> fall through)."""
|
|
379
|
+
if off + 7 >= len(data) or data[off] != EXPR_STMT_OP:
|
|
380
|
+
return None
|
|
381
|
+
if data[off + 1] != 0xDC or data[off + 2] != 0x00 or data[off + 3] != 0x7D:
|
|
382
|
+
return None
|
|
383
|
+
cmp = data[off + 6]
|
|
384
|
+
if cmp not in _CMP_OPS or data[off + 7] != 0x7F:
|
|
385
|
+
return None
|
|
386
|
+
return (cmp, data[off + 4] | (data[off + 5] << 8))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _eval_cmp(sc, cond) -> bool:
|
|
390
|
+
"""Does ``ScenarioCounter == sc`` satisfy the comparison ``cond = (cmp_op, const)``?"""
|
|
391
|
+
cmp, const = cond
|
|
392
|
+
return {0x20: sc == const, 0x18: sc < const, 0x19: sc > const,
|
|
393
|
+
0x1A: sc <= const, 0x1B: sc >= const}.get(cmp, False)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _jump_target(ins) -> int:
|
|
397
|
+
"""Absolute byte target of a jump instr (operand is a signed i16 skip distance from the instr end)."""
|
|
398
|
+
raw = ins.imm(0)
|
|
399
|
+
if raw is None:
|
|
400
|
+
return -1
|
|
401
|
+
return ins.end + (raw - 0x10000 if raw >= 0x8000 else raw)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _spawned_slots(instrs, sc_conds, sc) -> list:
|
|
405
|
+
"""Symbolically execute the Main_Init instr list at ``ScenarioCounter == sc``: take a conditional jump
|
|
406
|
+
only when its driving ScenarioCounter comparison is known (else fall through = run the guarded body),
|
|
407
|
+
follow forward jumps (incl. the unconditional 0x01 that steps over an if's else-branch), and return the
|
|
408
|
+
ordered InitObject slots reached. A FORWARD jump lands on the first instr at-or-after the target (so a
|
|
409
|
+
jump to the function end correctly terminates); a BACKWARD jump is not followed (loop guard). Bounded by
|
|
410
|
+
a visited set + step cap."""
|
|
411
|
+
offs = [ins.off for ins in instrs] # ascending (Main_Init in order)
|
|
412
|
+
n = len(instrs)
|
|
413
|
+
|
|
414
|
+
def _forward(i, ins): # next index for a jump from instr i, or fall-through
|
|
415
|
+
tgt = _jump_target(ins)
|
|
416
|
+
k = _bisect.bisect_left(offs, tgt) if tgt >= 0 else i + 1
|
|
417
|
+
return k if k > i else i + 1 # forward only; backward/unknown -> fall through
|
|
418
|
+
|
|
419
|
+
out, visited, last, i, steps = [], set(), None, 0, 0
|
|
420
|
+
while 0 <= i < n and steps < 20000:
|
|
421
|
+
steps += 1
|
|
422
|
+
if i in visited:
|
|
423
|
+
break
|
|
424
|
+
visited.add(i)
|
|
425
|
+
ins = instrs[i]
|
|
426
|
+
op = ins.op
|
|
427
|
+
if op == EXPR_STMT_OP:
|
|
428
|
+
last = sc_conds.get(ins.off) # the SC condition for an immediately-following jump (or None)
|
|
429
|
+
i += 1
|
|
430
|
+
continue
|
|
431
|
+
if op in (JMP_FALSE, JMP_TRUE):
|
|
432
|
+
take = None
|
|
433
|
+
if last is not None: # known SC condition -> decide the jump deterministically
|
|
434
|
+
base = _eval_cmp(sc, last)
|
|
435
|
+
take = (not base) if op == JMP_FALSE else base
|
|
436
|
+
last = None
|
|
437
|
+
i = _forward(i, ins) if take else i + 1 # take -> skip guarded body; else/unknown -> run it
|
|
438
|
+
continue
|
|
439
|
+
if op == JMP_UNCOND: # follow forward (steps over an if's else-branch)
|
|
440
|
+
last = None
|
|
441
|
+
i = _forward(i, ins)
|
|
442
|
+
continue
|
|
443
|
+
last = None
|
|
444
|
+
if op == INITOBJ_OP:
|
|
445
|
+
s = ins.imm(0)
|
|
446
|
+
if s is not None:
|
|
447
|
+
out.append(int(s))
|
|
448
|
+
i += 1
|
|
449
|
+
return out
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _roster_entry_name(eb, slot):
|
|
453
|
+
"""The model name for an InitObject slot (incl. cutscene actors ``scan_objects`` skips), or ``None`` for
|
|
454
|
+
the player / party (excluded from the roster table -- the cast of interest is the NPCs/actors)."""
|
|
455
|
+
from . import eventscan as _eventscan
|
|
456
|
+
from ._modeldb import MODELS
|
|
457
|
+
try:
|
|
458
|
+
e = eb.entry(slot)
|
|
459
|
+
except (IndexError, AttributeError):
|
|
460
|
+
return None
|
|
461
|
+
if e is None or e.empty:
|
|
462
|
+
return None
|
|
463
|
+
fi = e.func_by_tag(0)
|
|
464
|
+
if fi is None:
|
|
465
|
+
return None
|
|
466
|
+
try:
|
|
467
|
+
rd = _eventscan._read_object_init(eb, fi)
|
|
468
|
+
except Exception:
|
|
469
|
+
return None
|
|
470
|
+
m = rd.get("model")
|
|
471
|
+
if m is None or rd.get("player"):
|
|
472
|
+
return None
|
|
473
|
+
name = MODELS.get(m)
|
|
474
|
+
if name and name.startswith("GEO_MAIN"): # the party rig, not set-dressing
|
|
475
|
+
return None
|
|
476
|
+
return name or f"model {m}"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def roster_by_beat(eb, data, director_slots) -> list:
|
|
480
|
+
"""For each ScenarioCounter beat the field gates on (plus 0 = the scenario-zero baseline), the set of
|
|
481
|
+
carried objects the director SPAWNS at that beat. Returns ``[(beat, milestone, [(slot, name, is_dir)])]``,
|
|
482
|
+
or ``[]`` when the field has no gates OR the roster does not actually vary across beats (then the flat
|
|
483
|
+
``sc_gates`` line already says it all).
|
|
484
|
+
|
|
485
|
+
APPROXIMATE (a guide, confirm in-game): it evaluates only SIMPLE ScenarioCounter comparisons that drive a
|
|
486
|
+
jump; flag gates are assumed satisfied (so flag-gated actors are over-included), compound/looping
|
|
487
|
+
ScenarioCounter logic is run once (backward jumps fall through rather than iterate), and a director's OWN
|
|
488
|
+
per-beat model swap inside its LOOP is not traced (only WHICH objects spawn in Main_Init)."""
|
|
489
|
+
try:
|
|
490
|
+
mi = eb.entry(0).func_by_tag(0)
|
|
491
|
+
except (IndexError, AttributeError):
|
|
492
|
+
return []
|
|
493
|
+
if mi is None:
|
|
494
|
+
return []
|
|
495
|
+
instrs = list(eb.instrs(mi))
|
|
496
|
+
sc_conds = {ins.off: c for ins in instrs if ins.op == EXPR_STMT_OP
|
|
497
|
+
for c in (_sc_cond(data, ins.off),) if c is not None}
|
|
498
|
+
gates = scenario_gates(data)
|
|
499
|
+
if not gates:
|
|
500
|
+
return []
|
|
501
|
+
table = []
|
|
502
|
+
for beat in sorted(set(gates) | {0}):
|
|
503
|
+
seen, entries = set(), []
|
|
504
|
+
for s in _spawned_slots(instrs, sc_conds, beat):
|
|
505
|
+
if s in seen:
|
|
506
|
+
continue
|
|
507
|
+
nm = _roster_entry_name(eb, s)
|
|
508
|
+
if nm is None:
|
|
509
|
+
continue
|
|
510
|
+
seen.add(s)
|
|
511
|
+
entries.append((s, nm, s in director_slots))
|
|
512
|
+
table.append((beat, _flags.nearest_milestone(beat), entries))
|
|
513
|
+
# only meaningful if the cast genuinely rotates -- else the flat sc_gates line suffices. Compare by
|
|
514
|
+
# (slot, model) so a same-slot model swap also counts as variation (not just add/remove of slots).
|
|
515
|
+
rosters = {frozenset((s, n) for s, n, _d in row[2]) for row in table}
|
|
516
|
+
return table if len(rosters) >= 2 else []
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def resolve_field_id(token, *, game=None) -> int:
|
|
520
|
+
"""A field id (digit) or an FBG/event-name substring -> the numeric field id. Raises ValueError on no
|
|
521
|
+
match or an ambiguous substring (unless one candidate is an exact FBG/mapid match)."""
|
|
522
|
+
from .extract import ID_TO_FBG, ID_TO_EVT
|
|
523
|
+
s = str(token).strip()
|
|
524
|
+
if s.isdigit():
|
|
525
|
+
fid = int(s)
|
|
526
|
+
if fid in ID_TO_FBG: # a real, forkable field id (vs a typo that would silently read empty)
|
|
527
|
+
return fid
|
|
528
|
+
raise ValueError(f"no field with id {fid} -- pass a real field id or an FBG substring (see "
|
|
529
|
+
f"`list-fields`). Note: a bare number here is a FIELD ID, not a map number.")
|
|
530
|
+
sl = s.lower()
|
|
531
|
+
hits = [fid for fid, fbg in ID_TO_FBG.items() if sl in (fbg or "").lower()]
|
|
532
|
+
hits += [fid for fid, evt in ID_TO_EVT.items() if sl in (evt or "").lower() and fid not in hits]
|
|
533
|
+
if not hits:
|
|
534
|
+
raise ValueError(f"no field matches {token!r} -- pass a field id or an FBG substring (see `list-fields`)")
|
|
535
|
+
if len(hits) > 1:
|
|
536
|
+
exact = [fid for fid in hits if sl == (ID_TO_FBG.get(fid, "") or "").lower()
|
|
537
|
+
or ("_map" + sl) in (ID_TO_FBG.get(fid, "") or "").lower()]
|
|
538
|
+
if len(exact) == 1:
|
|
539
|
+
return exact[0]
|
|
540
|
+
# several fields can SHARE one FBG folder (the same room at different story beats), so listing folders
|
|
541
|
+
# would just repeat -- list the field IDS, which is exactly what disambiguates them.
|
|
542
|
+
ex = ", ".join(str(f) for f in hits[:8])
|
|
543
|
+
raise ValueError(f"{token!r} matches {len(hits)} fields (ids {ex}{'...' if len(hits) > 8 else ''}) "
|
|
544
|
+
f"-- pass the field id (a shared FBG folder maps to several fields)")
|
|
545
|
+
return hits[0]
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def analyze(field_id: int, *, game=None, bundle=None) -> ForkReport:
|
|
549
|
+
"""Build the fidelity preview for a real field id. ``bundle`` (an ``extract.EventBundle``) is reused
|
|
550
|
+
across calls when given; otherwise one is created. Read-only -- never touches the install's bytes."""
|
|
551
|
+
from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT, field_camera_info # lazy: UnityPy only when used
|
|
552
|
+
b = bundle or EventBundle(game)
|
|
553
|
+
data = b.eb_for_id(field_id)
|
|
554
|
+
fbg = ID_TO_FBG.get(field_id, "")
|
|
555
|
+
rep = analyze_eb(data, field_id=field_id, fbg_name=fbg, event_name=ID_TO_EVT.get(field_id, ""))
|
|
556
|
+
# the Camera axis lives in the scene .bgs (not the .eb), so it needs the install -- populate it here,
|
|
557
|
+
# NOT in the pure analyze_eb (which stays .eb-only + fixture-testable). None -> the line is omitted.
|
|
558
|
+
ci = field_camera_info(fbg, game=game) if fbg else None
|
|
559
|
+
if ci:
|
|
560
|
+
rep.cam_pitch, rep.cam_fov = ci["pitch"], ci["fov"]
|
|
561
|
+
rep.cam_scrolling, rep.cam_count = ci["scrolling"], ci["count"]
|
|
562
|
+
rep.cam_range_h = ci.get("range_h", 0)
|
|
563
|
+
# the area-title CARD is keyed on the scene name (manifest in resources.assets), so it needs the install too.
|
|
564
|
+
# ID_TO_FBG is lowercase; the manifest keys are the real (UPPER) scene names -> match on upper.
|
|
565
|
+
if fbg:
|
|
566
|
+
from . import areatitle as _at
|
|
567
|
+
rep.area_title = _at.title_range(fbg.upper(), game=game)
|
|
568
|
+
return rep
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def analyze_eb(eb_bytes, *, field_id: int = 0, fbg_name: str = "", event_name: str = "") -> ForkReport:
|
|
572
|
+
"""The pure analysis: a fidelity preview from a field's ``.eb`` bytes (no install needed -- so it is
|
|
573
|
+
unit-testable against a fixture). :func:`analyze` is the thin id->bytes loader over this."""
|
|
574
|
+
rep = ForkReport(field_id=field_id, fbg_name=fbg_name, event_name=event_name)
|
|
575
|
+
if not eb_bytes:
|
|
576
|
+
rep.has_script = False
|
|
577
|
+
rep.notes.append("no field event script (a world/special/unmapped field) -- nothing to fork")
|
|
578
|
+
return rep
|
|
579
|
+
data = bytes(eb_bytes)
|
|
580
|
+
|
|
581
|
+
from . import eventscan as _eventscan # lazy (keeps import cost off the core path)
|
|
582
|
+
try:
|
|
583
|
+
eb = EbScript.from_bytes(data) # raises on bad magic -> report gracefully, don't crash a preview
|
|
584
|
+
except ValueError as e:
|
|
585
|
+
rep.has_script = False
|
|
586
|
+
rep.notes.append(f"not a parseable field script ({e})")
|
|
587
|
+
return rep
|
|
588
|
+
# Classify carry at the FULL faithful-fork setting -- the recommended `import --native
|
|
589
|
+
# --graft-player-funcs --carry-text` recipe + the default STARTSEQ-helper closure -- so the portability
|
|
590
|
+
# numbers match what the author actually gets (else an object only blocked by a benign Seq helper or a
|
|
591
|
+
# graftable player gesture reads as render-only here but carries clean in a real fork).
|
|
592
|
+
objs = _eventscan.scan_objects_verbatim(data, graft_player_funcs=True, carry_text=True,
|
|
593
|
+
graft_seq_helpers=True)
|
|
594
|
+
rep.n_objects = len(objs)
|
|
595
|
+
for o in objs:
|
|
596
|
+
di = o.get("donor_idx")
|
|
597
|
+
rep.safety[o.get("graft_safety", "?")] = rep.safety.get(o.get("graft_safety", "?"), 0) + 1
|
|
598
|
+
if o.get("kind") == "npc":
|
|
599
|
+
rep.n_talkable += 1
|
|
600
|
+
if o.get("graft_safety") == "clean":
|
|
601
|
+
rep.n_interactive += 1 # clean-graft NPCs (those that KEEP their talk) -- props excluded
|
|
602
|
+
else:
|
|
603
|
+
rep.n_props += 1
|
|
604
|
+
if di is not None and _is_director(eb, di):
|
|
605
|
+
rep.directors.append(di)
|
|
606
|
+
if len(o.get("instances", []) or []) > 1:
|
|
607
|
+
rep.stacked.append(di)
|
|
608
|
+
|
|
609
|
+
# #5 preview (the TEXT axis, orthogonal to the interaction safety above): which carried NPCs SPEAK. A
|
|
610
|
+
# talk handler's WindowSync shows a donor txid that renders WRONG/missing unless the fork carries the
|
|
611
|
+
# words -- `--carry-text` remaps them, `--verbatim` ships the whole donor `.mes`. Mirrors the build-side
|
|
612
|
+
# lint (`build._entry_window_txids`) as a BEFORE-you-fork preview, via the dialogue reader (analysis layer).
|
|
613
|
+
try:
|
|
614
|
+
from . import dialogue as _dialogue
|
|
615
|
+
obj_idxs = {o.get("donor_idx") for o in objs}
|
|
616
|
+
speaking: dict = {}
|
|
617
|
+
for c in _dialogue.scan_dialogue(eb):
|
|
618
|
+
if c.func_tag == TALK_TAG and c.entry_idx in obj_idxs and c.txid is not None:
|
|
619
|
+
speaking.setdefault(c.entry_idx, set()).add(c.txid)
|
|
620
|
+
rep.n_speaking = len(speaking)
|
|
621
|
+
rep.n_dialogue_lines = sum(len(v) for v in speaking.values())
|
|
622
|
+
except Exception: # a preview must never crash on an odd field
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
gw = _eventscan.scan_gateway_entries(data)
|
|
627
|
+
rep.gated_doors = sum(1 for g in gw if g.get("story_gated"))
|
|
628
|
+
except (ValueError, IndexError, KeyError, struct.error): # a malformed gateway region -> just omit the count
|
|
629
|
+
rep.gated_doors = 0
|
|
630
|
+
|
|
631
|
+
# The controlled player character(s). resolve_player_entries returns EVERY DefinePlayerCharacter entry
|
|
632
|
+
# (182 fields define >1). CAUTION: in a multi-PC field the FIRST entry is NOT reliably who you control --
|
|
633
|
+
# the Cargo Ship lists Blank first but you play Zidane; co-actors are also "player characters". So we crown
|
|
634
|
+
# a single-PC field confidently, but for multi-PC we only enumerate + infer: if ANY pc is Zidane you most
|
|
635
|
+
# likely control the Zidane party-leader (the rest are co-actors); ONLY when NO Zidane is defined is the
|
|
636
|
+
# controlled character genuinely non-Zidane (the Treno Dagger/Steiner split). The exact bind is the frontier.
|
|
637
|
+
pents = _eventscan.resolve_player_entries(eb)
|
|
638
|
+
rep.player_models = [(pe, _eventscan._player_model(eb, pe),
|
|
639
|
+
player_name(_eventscan._player_model(eb, pe))) for pe in pents]
|
|
640
|
+
rep.multi_pc = len(pents) > 1
|
|
641
|
+
# #9 per-door spawn: a field that positions the player by ENTRANCE (reads D8:2, branches to N spots). A
|
|
642
|
+
# synth fork re-authors a single [player] spawn -> collapses the table; --verbatim ships the real Init.
|
|
643
|
+
try:
|
|
644
|
+
rep.arrival_spots = _eventscan.scan_player_arrivals(eb)["distinct"]
|
|
645
|
+
except Exception: # a preview must never crash on an odd field
|
|
646
|
+
rep.arrival_spots = 0
|
|
647
|
+
# swap-friendliness: how a `--swap-player` fares -- the scripted gestures on the entr(ies) the swap targets
|
|
648
|
+
# (the controlled-leader model). 0 = a clean free-roam swap; >0 = a cutscene field where those gestures
|
|
649
|
+
# glitch on the new rig (only movement clips are swapped). Reuses the same logic the swap + CLI WARN use.
|
|
650
|
+
from . import playerswap as _playerswap
|
|
651
|
+
try:
|
|
652
|
+
rep.swap_gesture_count = _playerswap.scripted_gesture_ops(data)
|
|
653
|
+
except Exception: # never let the preview crash on a swap-edge field
|
|
654
|
+
rep.swap_gesture_count = 0
|
|
655
|
+
models = [m for _, m, _ in rep.player_models if m is not None]
|
|
656
|
+
zidane_present = any(m in _eventscan.ZIDANE_MODELS for m in models)
|
|
657
|
+
if not rep.multi_pc:
|
|
658
|
+
rep.non_zidane = bool(models) and models[0] not in _eventscan.ZIDANE_MODELS
|
|
659
|
+
if rep.non_zidane:
|
|
660
|
+
nm = rep.player_models[0][2]
|
|
661
|
+
rep.notes.append(f"you play as {nm} (non-Zidane) -- fork with --verbatim: it ships the donor player "
|
|
662
|
+
f"rig + anim packs + the field's own party/cutscene setup whole (proven faithful on "
|
|
663
|
+
f"Vivi/field 100). --graft-player-funcs would drop {nm}'s funcs (wrong-rig clips)")
|
|
664
|
+
elif models:
|
|
665
|
+
names = ", ".join(n for _, _, n in rep.player_models)
|
|
666
|
+
rep.non_zidane = not zidane_present # no Zidane among the PCs -> genuinely non-Zidane control
|
|
667
|
+
if rep.non_zidane:
|
|
668
|
+
# compute WHICH non-Zidane PC binds control (the last DefinePlayerCharacter executed). This is
|
|
669
|
+
# in-game proven for fixed-SID fields (the lane); see controlled_player. A Zidane-present field is
|
|
670
|
+
# NOT computed -- control may route through a party slot to the live leader (left as the hedge below).
|
|
671
|
+
ce, conf = controlled_player(eb)
|
|
672
|
+
rep.controlled_entry, rep.control_confidence = ce, conf
|
|
673
|
+
if ce is not None:
|
|
674
|
+
rep.controlled_name = player_name(_eventscan._player_model(eb, ce))
|
|
675
|
+
hedge = "" if conf == "high" else " (likely -- ambiguous spawn/gating)"
|
|
676
|
+
who = rep.controlled_name or "a non-Zidane character"
|
|
677
|
+
rep.notes.append(f"you control {who}{hedge} -- the last DefinePlayerCharacter executed of the "
|
|
678
|
+
f"{len(models)} PCs ({names}); the rest are co-defined companions. Fork --verbatim "
|
|
679
|
+
f"(the player rig + anim packs ship whole). In-game proven on the Treno Dagger/Steiner room.")
|
|
680
|
+
else:
|
|
681
|
+
rep.notes.append(f"the field defines {len(models)} player characters ({names}) -- you most likely "
|
|
682
|
+
f"control the Zidane party-leader; the rest are co-actors. The exact bind in a fork "
|
|
683
|
+
f"is untested")
|
|
684
|
+
|
|
685
|
+
# Party-membership ops the field runs (a verbatim fork executes them -> the fork changes your party).
|
|
686
|
+
party = scan_party_ops(data)
|
|
687
|
+
rep.party_adds = [party_char_name(i) for i in party["adds"]]
|
|
688
|
+
rep.party_removes = [party_char_name(i) for i in party["removes"]]
|
|
689
|
+
rep.party_reset, rep.party_recruit, rep.party_menu = party["reset"], party["recruit"], party["menu"]
|
|
690
|
+
|
|
691
|
+
# Item / treasure / shop ops the field runs (a verbatim fork carries them; a plain/synthesize fork DROPS
|
|
692
|
+
# them -- no item scanner). The shop STOCK is parasitic on the base ShopItems.csv (a fork can't change it).
|
|
693
|
+
itm = scan_item_ops(data)
|
|
694
|
+
rep.item_gives = itm["gives"]
|
|
695
|
+
rep.item_gil_max = itm["gil_max"]
|
|
696
|
+
rep.item_gil_any = itm["gil_any"]
|
|
697
|
+
rep.item_shops = itm["shops"]
|
|
698
|
+
rep.item_removes = itm["removes"]
|
|
699
|
+
rep.item_var_give = itm["var_give"]
|
|
700
|
+
rep.item_var_shop = itm["var_shop"]
|
|
701
|
+
|
|
702
|
+
gates = scenario_gates(data)
|
|
703
|
+
rep.sc_gates = [(v, _flags.nearest_milestone(v)) for v in gates]
|
|
704
|
+
# earliest gate ~= when the field's story content first appears = its natural "home" beat. (A rotating
|
|
705
|
+
# field also gates at later beats; the author picks which one -- the list shows them all.)
|
|
706
|
+
rep.suggested_scenario = gates[0] if gates else None
|
|
707
|
+
# #13 rotating-cast preview: which carried objects the director spawns at each beat (empty unless it varies)
|
|
708
|
+
try:
|
|
709
|
+
rep.beat_roster = roster_by_beat(eb, data, set(rep.directors))
|
|
710
|
+
except Exception: # a preview must never crash on an odd field
|
|
711
|
+
rep.beat_roster = []
|
|
712
|
+
|
|
713
|
+
rotating = len(gates) >= _ROTATING_GATE_COUNT
|
|
714
|
+
rep.roster_class = "story-event" if (rep.directors or rotating) else "static-roster"
|
|
715
|
+
if rep.directors:
|
|
716
|
+
rep.notes.append(f"{len(rep.directors)} carried object(s) are cutscene DIRECTORS (Field()/phase-switch "
|
|
717
|
+
f"in their LOOP) -- forking runs that logic against the asserted beat (gap #13)")
|
|
718
|
+
if rotating:
|
|
719
|
+
rep.notes.append(f"content gates on {len(gates)} story beats -- this field ROTATES its cast/content; "
|
|
720
|
+
f"a fork shows one beat (pick it with [startup] scenario)")
|
|
721
|
+
if rep.stacked:
|
|
722
|
+
rep.notes.append(f"{len(rep.stacked)} object(s) are multi-instanced -- watch for one-spot stacking")
|
|
723
|
+
# LOST ON A MINT: every user-visible engine behavior keyed on the real fldMapNo a fork loses on its custom
|
|
724
|
+
# id (walkmesh hotfix / narrow-map letterbox / Chocobo HUD / intro FMV) -- the taxonomy's "impossible" axis,
|
|
725
|
+
# per field. Pure baked data (no install), so it's fine on the install-free analyze_eb path.
|
|
726
|
+
from . import idgated as _idg
|
|
727
|
+
rep.lost_on_mint = _idg.lost_on_mint(field_id)
|
|
728
|
+
return rep
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# --- rendering --------------------------------------------------------------------------------------
|
|
732
|
+
def _verdict_line(rep: ForkReport) -> str:
|
|
733
|
+
if rep.roster_class == "static-roster":
|
|
734
|
+
head = "a CLEAN static-roster field -- a native fork renders the cast faithfully"
|
|
735
|
+
else:
|
|
736
|
+
head = "a STORY-EVENT field -- a fork is a high-fidelity diorama, not a faithful slice (rotating cast / cutscene actors)"
|
|
737
|
+
if rep.n_talkable:
|
|
738
|
+
# numerator = CLEAN NPCs only (n_interactive), never the props-inclusive safety['clean']; the
|
|
739
|
+
# "render-only" tail only when there's a real render-only NPC remainder (not a refused prop).
|
|
740
|
+
inter = f"{rep.n_interactive} of {rep.n_talkable} NPC(s) keep their interactions"
|
|
741
|
+
if rep.n_talkable > rep.n_interactive:
|
|
742
|
+
inter += "; the rest render-only (re-author their dialogue)"
|
|
743
|
+
else:
|
|
744
|
+
inter = "no talkable NPCs"
|
|
745
|
+
parts = [f"{head}; {inter}."]
|
|
746
|
+
# The synthesized BOTTOM LINE across every axis: which fork MODE, and why. --verbatim is the faithful mode
|
|
747
|
+
# whenever the field has story-bound state a synth rebuild drops (gated cast/logic, a non-Zidane player,
|
|
748
|
+
# party/item grants, per-door arrival); otherwise --native is a clean diorama.
|
|
749
|
+
why = []
|
|
750
|
+
if rep.roster_class != "static-roster" or rep.sc_gates:
|
|
751
|
+
why.append("story-gated cast/logic")
|
|
752
|
+
if rep.non_zidane:
|
|
753
|
+
why.append("non-Zidane player")
|
|
754
|
+
if rep.party_adds or rep.party_removes or rep.party_reset or rep.party_recruit:
|
|
755
|
+
why.append("party changes")
|
|
756
|
+
if rep.item_gives or rep.item_var_give or rep.item_gil_any or rep.item_shops or rep.item_var_shop:
|
|
757
|
+
why.append("item/shop grants")
|
|
758
|
+
if rep.arrival_spots > 1:
|
|
759
|
+
why.append("per-door arrival")
|
|
760
|
+
if why:
|
|
761
|
+
reco = f"--verbatim (carries {', '.join(why[:3])}{'...' if len(why) > 3 else ''})"
|
|
762
|
+
if rep.sc_gates or rep.roster_class != "static-roster":
|
|
763
|
+
reco += " + a [startup] beat (else it boots scenario-zero)"
|
|
764
|
+
else:
|
|
765
|
+
reco = "--native (a faithful diorama; nothing story-bound to carry)"
|
|
766
|
+
parts.append(f"Recommended: {reco}.")
|
|
767
|
+
# The lost-on-a-mint steer -- only the NON-reproduced losses are fork-in-place-worthy ("auto-reproduced on
|
|
768
|
+
# fork" via a toggle prepend, or "reproduced by the engine fork-donor remap", both mean it's NOT lost).
|
|
769
|
+
losses = [lbl for lbl, det in rep.lost_on_mint if "reproduced" not in det]
|
|
770
|
+
if losses:
|
|
771
|
+
parts.append(f"Loses {', '.join(losses)} on a custom id -- fork IN-PLACE on the real id to keep (see Lost on mint).")
|
|
772
|
+
return " ".join(parts)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _party_line(rep: ForkReport) -> str:
|
|
776
|
+
"""The 'Party' axis: what a verbatim fork will do to your party. Empty when the field is party-neutral."""
|
|
777
|
+
if not (rep.party_adds or rep.party_removes or rep.party_reset or rep.party_recruit or rep.party_menu):
|
|
778
|
+
return ""
|
|
779
|
+
bits = []
|
|
780
|
+
if rep.party_adds:
|
|
781
|
+
shown = ", ".join(rep.party_adds[:6]) + (f" +{len(rep.party_adds) - 6}" if len(rep.party_adds) > 6 else "")
|
|
782
|
+
bits.append(f"adds {shown}")
|
|
783
|
+
if rep.party_reset:
|
|
784
|
+
bits.append("rebuilds the roster (story reset)" if rep.party_removes else "sets the recruitable roster")
|
|
785
|
+
elif rep.party_removes:
|
|
786
|
+
shown = ", ".join(rep.party_removes[:6]) + (f" +{len(rep.party_removes) - 6}" if len(rep.party_removes) > 6 else "")
|
|
787
|
+
bits.append(f"removes {shown}")
|
|
788
|
+
if rep.party_menu:
|
|
789
|
+
bits.append("opens the change-members menu")
|
|
790
|
+
tail = " (a --verbatim fork RUNS this; a plain fork inherits your current party)"
|
|
791
|
+
return f" Party : {'; '.join(bits)}{tail}"
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _items_line(rep: ForkReport) -> str:
|
|
795
|
+
"""The 'Items' axis: the treasure / gil / shops the field grants. A --verbatim fork RUNS these (carries
|
|
796
|
+
them byte-identically); a plain/synthesize fork DROPS them (no item scanner). Empty when nothing is granted."""
|
|
797
|
+
if not (rep.item_gives or rep.item_var_give or rep.item_gil_any or rep.item_shops or rep.item_var_shop):
|
|
798
|
+
return ""
|
|
799
|
+
bits = []
|
|
800
|
+
if rep.item_gives:
|
|
801
|
+
shown = ", ".join(item_label(i) + (" x%d" % c if c not in (None, 1) else "")
|
|
802
|
+
for i, c in rep.item_gives[:6])
|
|
803
|
+
more = f" +{len(rep.item_gives) - 6}" if len(rep.item_gives) > 6 else ""
|
|
804
|
+
bits.append(f"grants {shown}{more}")
|
|
805
|
+
if rep.item_var_give:
|
|
806
|
+
bits.append("computed-id item(s)")
|
|
807
|
+
if rep.item_gil_max:
|
|
808
|
+
bits.append(f"up to {rep.item_gil_max} gil")
|
|
809
|
+
elif rep.item_gil_any:
|
|
810
|
+
bits.append("gil (scripted)")
|
|
811
|
+
if rep.item_shops:
|
|
812
|
+
ids = ", ".join("#%d" % s for s in rep.item_shops[:6])
|
|
813
|
+
more = f" +{len(rep.item_shops) - 6}" if len(rep.item_shops) > 6 else ""
|
|
814
|
+
bits.append(f"opens shop(s) {ids}{more}" + (" + a story-gated shop" if rep.item_var_shop else ""))
|
|
815
|
+
elif rep.item_var_shop:
|
|
816
|
+
bits.append("opens a story-gated shop")
|
|
817
|
+
tail = " (--verbatim carries these; a plain/synthesize fork DROPS them"
|
|
818
|
+
tail += "; shop stock = base ShopItems.csv)" if (rep.item_shops or rep.item_var_shop) else ")"
|
|
819
|
+
return f" Items : {'; '.join(bits)}{tail}"
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _camera_line(rep: ForkReport) -> str:
|
|
823
|
+
"""The Camera axis: a close/medium/wide feel + the raw pitch/FOV (the lens the fork plays through).
|
|
824
|
+
Empty when the camera wasn't read (the pure .eb-only path / no install) -- so the report degrades."""
|
|
825
|
+
if rep.cam_pitch is None:
|
|
826
|
+
return ""
|
|
827
|
+
fov = rep.cam_fov
|
|
828
|
+
if fov is None:
|
|
829
|
+
feel = "unknown-fov"
|
|
830
|
+
elif fov < 10:
|
|
831
|
+
feel = "distant" # a sub-10 "FOV" is a far telephoto (FF9 projection is orthographic-like,
|
|
832
|
+
# so a tiny FOV = zoomed FAR OUT, model is a speck) -- NOT an intimate room
|
|
833
|
+
elif fov < 35:
|
|
834
|
+
feel = "close" # an intimate room (e.g. ac_rst_x ~29.5) -- a good --swap/demo test room
|
|
835
|
+
elif fov < 50:
|
|
836
|
+
feel = "medium"
|
|
837
|
+
else:
|
|
838
|
+
feel = "wide" # an establishing/scrolling shot (e.g. the Hangar ~61) -- details are tiny
|
|
839
|
+
bits = ([f"FOV {fov:g} deg"] if fov is not None else []) + [f"pitch {rep.cam_pitch:g} deg"]
|
|
840
|
+
extra = []
|
|
841
|
+
if rep.cam_scrolling:
|
|
842
|
+
extra.append("scrolling")
|
|
843
|
+
if rep.cam_count > 1:
|
|
844
|
+
extra.append(f"{rep.cam_count} cameras")
|
|
845
|
+
tail = ("; " + ", ".join(extra)) if extra else ""
|
|
846
|
+
return f" Camera : {feel} ({', '.join(bits)}){tail}"
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _entry_settle_line(rep: ForkReport) -> str:
|
|
850
|
+
"""The entry-camera-settle advisory (coarse flag). The engine's smooth-camera follower eases onto the
|
|
851
|
+
spawn on a warp-in; a SYNTH fork (--native/BG-borrow) reveals immediately, so on a SCROLLING field that
|
|
852
|
+
ease is VISIBLE as a drift (worst on an F6/hard warp; the bigger the spawn-to-centre delta, the longer it
|
|
853
|
+
drifts). Empty for a fixed-camera field (no center-on-player motion) or when the camera wasn't read. A
|
|
854
|
+
--verbatim fork carries the donor's real entry sequence, which hides it. (content/entry_settle.py.)"""
|
|
855
|
+
if not rep.cam_scrolling:
|
|
856
|
+
return ""
|
|
857
|
+
return (" Entry settle : scrolling camera -> a SYNTH (--native/BG-borrow) fork may show the camera ease "
|
|
858
|
+
"onto the spawn on warp-in (worst on an F6/hard warp; a big spawn-to-centre delta drifts longer). "
|
|
859
|
+
"Add `[camera] entry_settle = 45` to hide it behind the load fade; a --verbatim fork carries the "
|
|
860
|
+
"real entry sequence and doesn't need it.")
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def format_report(rep: ForkReport) -> str:
|
|
864
|
+
title = rep.fbg_name or f"field {rep.field_id}"
|
|
865
|
+
lines = [f"fork-report: {title} (field {rep.field_id}{', ' + rep.event_name if rep.event_name else ''})", ""]
|
|
866
|
+
if not rep.has_script:
|
|
867
|
+
lines.append(" " + (rep.notes[0] if rep.notes else "no event script"))
|
|
868
|
+
return "\n".join(lines)
|
|
869
|
+
|
|
870
|
+
if rep.player_models:
|
|
871
|
+
if rep.multi_pc:
|
|
872
|
+
names = ", ".join(n for _, _, n in rep.player_models)
|
|
873
|
+
if rep.non_zidane and rep.controlled_name:
|
|
874
|
+
q = "" if rep.control_confidence == "high" else "?"
|
|
875
|
+
pc = f"controls {rep.controlled_name}{q} of [{names}] [MULTI-PC non-Zidane -> --verbatim]"
|
|
876
|
+
else:
|
|
877
|
+
pc = f"{len(rep.player_models)} PCs: {names} [MULTI-PC; likely Zidane party-leader]"
|
|
878
|
+
else:
|
|
879
|
+
pc = rep.player_models[0][2] + (" [non-Zidane -> --verbatim]" if rep.non_zidane else "")
|
|
880
|
+
# swap-friendliness tag: is this a good `--swap-player` target? (the gestures glitch on a cutscene field)
|
|
881
|
+
swap = ("swap-clean" if rep.swap_gesture_count == 0
|
|
882
|
+
else f"swap: {rep.swap_gesture_count} gesture(s) glitch")
|
|
883
|
+
lines.append(f" Player : {pc} ({swap})")
|
|
884
|
+
if rep.arrival_spots > 1:
|
|
885
|
+
lines.append(f" Arrival : {rep.arrival_spots} per-door spawn points (#9) -- a SYNTH fork uses one "
|
|
886
|
+
f"[player] spawn (you arrive at the same spot via every door); --verbatim ships the real table")
|
|
887
|
+
cam_line = _camera_line(rep)
|
|
888
|
+
if cam_line:
|
|
889
|
+
lines.append(cam_line)
|
|
890
|
+
settle_line = _entry_settle_line(rep)
|
|
891
|
+
if settle_line:
|
|
892
|
+
lines.append(settle_line)
|
|
893
|
+
if rep.area_title:
|
|
894
|
+
a, b = rep.area_title
|
|
895
|
+
lines.append(f" Area title : this field shows an area-title CARD (overlays {a}-{b}) -- donor identity: "
|
|
896
|
+
f"kept on --verbatim (real show+fade), auto-hidden on a synth/BG-borrow fork (DROP on reuse)")
|
|
897
|
+
if rep.lost_on_mint:
|
|
898
|
+
lines.append(" Lost on mint : engine behavior(s) keyed on the real field id a fork loses on a custom id "
|
|
899
|
+
"(fork IN-PLACE to keep, unless noted auto-reproduced):")
|
|
900
|
+
for label, detail in rep.lost_on_mint:
|
|
901
|
+
lines.append(f" - {label}: {detail}")
|
|
902
|
+
s = rep.safety
|
|
903
|
+
dirs = f"{len(rep.directors)} director(s)" if rep.directors else "0 directors"
|
|
904
|
+
stack = f", {len(rep.stacked)} multi-instance" if rep.stacked else ""
|
|
905
|
+
lines.append(f" Roster : {rep.n_objects} carried object(s) ({rep.n_talkable} NPC, {rep.n_props} prop) "
|
|
906
|
+
f"- {dirs}{stack} -> {rep.roster_class.upper()}")
|
|
907
|
+
lines.append(f" Interactions : {s.get('clean', 0)} fully interactive, {s.get('init_only', 0)} render-only, "
|
|
908
|
+
f"{s.get('refuse', 0)} stub (faithful carry = --graft-player-funcs --carry-text)")
|
|
909
|
+
if rep.n_speaking:
|
|
910
|
+
lines.append(f" Dialogue : {rep.n_speaking} NPC(s) speak {rep.n_dialogue_lines} line(s) -- "
|
|
911
|
+
f"--carry-text (or --verbatim) ships them; else they render WRONG text (lint #5)")
|
|
912
|
+
if rep.sc_gates:
|
|
913
|
+
beats = ", ".join(f"{v} ({nm[1] if nm else '?'})" for v, nm in rep.sc_gates)
|
|
914
|
+
lines.append(f" Story gating : {rep.gated_doors} gated door(s); ScenarioCounter gates at {beats}")
|
|
915
|
+
else:
|
|
916
|
+
lines.append(f" Story gating : {rep.gated_doors} gated door(s); no ScenarioCounter gates (beat-agnostic)")
|
|
917
|
+
if rep.suggested_scenario is not None:
|
|
918
|
+
nm = _flags.nearest_milestone(rep.suggested_scenario)
|
|
919
|
+
beat = f' "{nm[1]}"' if nm else ""
|
|
920
|
+
lines.append(f" Home beat : suggested [startup] scenario = {rep.suggested_scenario}{beat} "
|
|
921
|
+
f"(the earliest gate -- adjust to the beat you're forking)")
|
|
922
|
+
if rep.beat_roster:
|
|
923
|
+
lines.append(" Roster by beat: which carried NPCs/actors the director spawns at each beat "
|
|
924
|
+
"(set [startup] scenario to one):")
|
|
925
|
+
has_dir = False
|
|
926
|
+
for bv, nm, entries in rep.beat_roster:
|
|
927
|
+
label = (nm[1] if nm else "?")
|
|
928
|
+
if entries:
|
|
929
|
+
names = ", ".join((n[4:] if n.startswith("GEO_") else n) + ("*" if d else "")
|
|
930
|
+
for _s, n, d in entries)
|
|
931
|
+
has_dir = has_dir or any(d for _s, _n, d in entries)
|
|
932
|
+
else:
|
|
933
|
+
names = "(no carried cast)"
|
|
934
|
+
base = " <- scenario-zero baseline" if bv == 0 else ""
|
|
935
|
+
lines.append(f" {bv:>6} {label:<20}: {names}{base}")
|
|
936
|
+
lines.append(" (approximate -- a guide, confirm in-game: flag-gated content is assumed present; "
|
|
937
|
+
"compound/looping ScenarioCounter logic is run once)")
|
|
938
|
+
if has_dir:
|
|
939
|
+
lines.append(" (* = a director; its OWN model may further vary by beat inside its loop -- not traced)")
|
|
940
|
+
party_line = _party_line(rep)
|
|
941
|
+
if party_line:
|
|
942
|
+
lines.append(party_line)
|
|
943
|
+
items_line = _items_line(rep)
|
|
944
|
+
if items_line:
|
|
945
|
+
lines.append(items_line)
|
|
946
|
+
lines += ["", " Verdict: " + _verdict_line(rep)]
|
|
947
|
+
if rep.notes:
|
|
948
|
+
lines.append("")
|
|
949
|
+
for n in rep.notes:
|
|
950
|
+
lines.append(f" - {n}")
|
|
951
|
+
# suggested authoring -- a non-Zidane player forks faithfully only via --verbatim (it ships the donor
|
|
952
|
+
# player rig + anim packs + the field's own party/cutscene setup whole; the graft path drops them).
|
|
953
|
+
fbg = rep.fbg_name or str(rep.field_id)
|
|
954
|
+
lines += ["", " Suggested authoring:"]
|
|
955
|
+
if rep.non_zidane:
|
|
956
|
+
who = "the non-Zidane PC(s)" if rep.multi_pc else rep.player_models[0][2]
|
|
957
|
+
lines.append(f" ff9mapkit import {fbg} --verbatim"
|
|
958
|
+
f" # ships {who} + rig/anim/party-setup whole (non-Zidane)")
|
|
959
|
+
else:
|
|
960
|
+
lines.append(f" ff9mapkit import {fbg} --native --graft-player-funcs --carry-text")
|
|
961
|
+
if rep.suggested_scenario is not None:
|
|
962
|
+
nm = _flags.nearest_milestone(rep.suggested_scenario)
|
|
963
|
+
lines += [" [startup]",
|
|
964
|
+
f" scenario = {rep.suggested_scenario}" + (f" # {nm[1]}" if nm else "")]
|
|
965
|
+
return "\n".join(lines)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
# ============================================================================================
|
|
969
|
+
# Room finder -- sweep ALL forkable fields for the best swap/demo TEST ROOMS.
|
|
970
|
+
#
|
|
971
|
+
# A "good room" = a place to walk as a swapped character (`--swap-player`) or stage a visual test
|
|
972
|
+
# where the model's DETAIL is actually visible. Grounded in a 676-field calibration sweep:
|
|
973
|
+
# FOV ALONE is not a detail proxy (FF9's projection is orthographic-like, k~0.93 -- a tiny "FOV" is
|
|
974
|
+
# zoomed FAR OUT, not close), so a room is the AND of (single-PC) + (swap-clean) + a CLOSE 3/4
|
|
975
|
+
# single-screen camera = bounded FOV AND a 3/4 pitch band AND a near-one-screen RANGE AND not a
|
|
976
|
+
# `_CS_` cutscene-staging field. Two-phase for speed (~45s): a cheap .eb-only prefilter, then the
|
|
977
|
+
# expensive per-field camera read only on the survivors. (memory project-ff9-non-zidane-donors.)
|
|
978
|
+
# ============================================================================================
|
|
979
|
+
_REAL_FBG = re.compile(r"^fbg_n\d+_")
|
|
980
|
+
|
|
981
|
+
# Calibration constants (validated against the sweep; the proven anchor is field 1200 ac_rst_x,
|
|
982
|
+
# FOV 29.5 / pitch 28.8 / range_h 336). Good rooms cluster FOV ~22-42, pitch ~8-48, range_h 224-368.
|
|
983
|
+
ROOM_MIN_FOV = 10.0 # below this is a degenerate telephoto/orthographic camera (model is a speck), not a room
|
|
984
|
+
ROOM_MAX_FOV = 45.0 # above this is a wide establishing lens
|
|
985
|
+
ROOM_MIN_PITCH = 6.0 # below this is a flat/side-on view (no 3/4 detail; the proven anchors sit >= 8.7)
|
|
986
|
+
ROOM_MAX_PITCH = 48.0 # above this is near-top-down -- you see the head, not the face/body
|
|
987
|
+
ROOM_MAX_RANGE_H = 420 # camera visible height; intimate rooms sit 224-368, distant/wide shots 448-592
|
|
988
|
+
ROOM_IDEAL_FOV = 30.0 # the proven anchor sits here
|
|
989
|
+
ROOM_IDEAL_PITCH = 28.0 # ...and pitch ~28 -- the classic 3/4 detail view
|
|
990
|
+
ROOM_SCROLL_DEMERIT = 15.0 # a wide-pan field is not a tight single-screen stage (rank down, don't exclude)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _is_real_fbg(fbg: str) -> bool:
|
|
994
|
+
"""True for a genuine field background name (fbg_nNN_...) -- filters placeholders like 'invalidfieldmapid'."""
|
|
995
|
+
return bool(_REAL_FBG.match(fbg or ""))
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def room_score(rep: ForkReport, *, max_fov: float = ROOM_MAX_FOV) -> float | None:
|
|
999
|
+
"""A swap/demo test-room rank key (LOWER = tighter on the model), or None if ``rep`` fails a HARD
|
|
1000
|
+
filter. Assumes ``rep`` already passed the .eb prefilter (single-PC + swap-clean) and has its camera
|
|
1001
|
+
fields populated. FOV alone is not a detail proxy, so this ANDs FOV + pitch + the visible range + a
|
|
1002
|
+
cutscene-name guard -- the combination the calibration sweep validated."""
|
|
1003
|
+
fov, pitch, rh = rep.cam_fov, rep.cam_pitch, rep.cam_range_h
|
|
1004
|
+
if fov is None or pitch is None:
|
|
1005
|
+
return None # no readable camera -> un-rankable
|
|
1006
|
+
if "_CS_" in (rep.event_name or "").upper():
|
|
1007
|
+
return None # a cutscene-staging field, definitionally not a room
|
|
1008
|
+
if not (ROOM_MIN_FOV <= fov <= max_fov):
|
|
1009
|
+
return None # degenerate telephoto / wide establishing lens
|
|
1010
|
+
if not (ROOM_MIN_PITCH <= pitch <= ROOM_MAX_PITCH):
|
|
1011
|
+
return None # flat/side-on (no 3/4 detail) OR near-top-down
|
|
1012
|
+
if rh and rh > ROOM_MAX_RANGE_H:
|
|
1013
|
+
return None # camera too far back (a distant/wide view)
|
|
1014
|
+
key = abs(fov - ROOM_IDEAL_FOV) + 0.3 * abs(pitch - ROOM_IDEAL_PITCH)
|
|
1015
|
+
if rep.cam_scrolling:
|
|
1016
|
+
key += ROOM_SCROLL_DEMERIT
|
|
1017
|
+
return key
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
@dataclass
|
|
1021
|
+
class RoomSweep:
|
|
1022
|
+
rooms: list = _dc_field(default_factory=list) # list[ForkReport], best-first
|
|
1023
|
+
scanned: int = 0 # real fields examined (cheap .eb pass)
|
|
1024
|
+
swap_clean: int = 0 # passed the single-PC + swap-clean prefilter
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def find_rooms(*, game=None, limit: int = 20, max_fov: float = ROOM_MAX_FOV,
|
|
1028
|
+
ids=None, bundle=None) -> RoomSweep:
|
|
1029
|
+
"""Sweep every forkable field and return the best swap/demo TEST ROOMS, best-first. Two-phase for
|
|
1030
|
+
speed: a cheap .eb-only prefilter (ONE EventBundle, no per-field scene load) keeps single-PC +
|
|
1031
|
+
swap-clean fields, then the expensive per-field camera read (``field_camera_info``) runs ONLY on those
|
|
1032
|
+
survivors and scores them (``room_score``). Pass ``ids`` to restrict the sweep to a candidate set (also
|
|
1033
|
+
keeps it fast). Read-only. Needs the install (reads the scene cameras)."""
|
|
1034
|
+
from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT, field_camera_info # lazy: UnityPy only when used
|
|
1035
|
+
b = bundle or EventBundle(game)
|
|
1036
|
+
items = [(i, ID_TO_FBG.get(i, "")) for i in ids] if ids is not None else list(ID_TO_FBG.items())
|
|
1037
|
+
# phase 1 (cheap, ~30s): the .eb-only filter -- a real field, a real single player, swap-clean.
|
|
1038
|
+
survivors = []
|
|
1039
|
+
scanned = 0
|
|
1040
|
+
for fid, fbg in items:
|
|
1041
|
+
if not _is_real_fbg(fbg):
|
|
1042
|
+
continue
|
|
1043
|
+
scanned += 1
|
|
1044
|
+
rep = analyze_eb(b.eb_for_id(fid), field_id=fid, fbg_name=fbg, event_name=ID_TO_EVT.get(fid, ""))
|
|
1045
|
+
# single-PC, swap-clean, a PLAYABLE controller (not a submarine/monster rig), and a STATIC roster
|
|
1046
|
+
# (a story-event field rotates its cast/spawns by beat -> forks as a diorama, not a clean room).
|
|
1047
|
+
if (rep.has_script and rep.player_models and not rep.multi_pc and rep.swap_gesture_count == 0
|
|
1048
|
+
and rep.roster_class == "static-roster"
|
|
1049
|
+
and rep.player_models[0][1] in PLAYABLE_NAMES):
|
|
1050
|
+
survivors.append(rep)
|
|
1051
|
+
# phase 2 (expensive, ~15s): read the camera ONLY for survivors, then hard-filter + score.
|
|
1052
|
+
scored = []
|
|
1053
|
+
for rep in survivors:
|
|
1054
|
+
ci = field_camera_info(rep.fbg_name, game=game)
|
|
1055
|
+
if not ci:
|
|
1056
|
+
continue # no readable scene -> skip (never rank a None camera)
|
|
1057
|
+
rep.cam_pitch, rep.cam_fov = ci["pitch"], ci["fov"]
|
|
1058
|
+
rep.cam_scrolling, rep.cam_count = ci["scrolling"], ci["count"]
|
|
1059
|
+
rep.cam_range_h = ci.get("range_h", 0)
|
|
1060
|
+
key = room_score(rep, max_fov=max_fov)
|
|
1061
|
+
if key is not None:
|
|
1062
|
+
scored.append((key, rep))
|
|
1063
|
+
scored.sort(key=lambda kr: (kr[0], kr[1].field_id))
|
|
1064
|
+
n = limit if (limit and limit > 0) else len(scored) # a non-positive limit -> show all (never the slice bug)
|
|
1065
|
+
return RoomSweep(rooms=[r for _, r in scored[:n]], scanned=scanned, swap_clean=len(survivors))
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def format_room_table(sweep: RoomSweep) -> str:
|
|
1069
|
+
"""Render a RoomSweep as a ranked ASCII table (cp1252-safe)."""
|
|
1070
|
+
lines = ["swap/demo test rooms -- single-PC, swap-clean, a close 3/4 single-screen camera",
|
|
1071
|
+
"(walk as a swapped character, or stage a visual test where the model's detail is visible)", ""]
|
|
1072
|
+
if not sweep.rooms:
|
|
1073
|
+
lines.append(" no rooms matched -- try a wider --max-fov, or --limit")
|
|
1074
|
+
return "\n".join(lines)
|
|
1075
|
+
lines.append(f" {len(sweep.rooms)} room(s) (swept {sweep.scanned} fields; "
|
|
1076
|
+
f"{sweep.swap_clean} single-PC + swap-clean). best-first:")
|
|
1077
|
+
lines += ["", f" {'#':>2} {'field':>5} {'fbg':<36} {'player':<12} camera"]
|
|
1078
|
+
for i, rep in enumerate(sweep.rooms, 1):
|
|
1079
|
+
fbg = (rep.fbg_name or "")[:36]
|
|
1080
|
+
who = (rep.controlled_name or (rep.player_models[0][2] if rep.player_models else "?"))[:11]
|
|
1081
|
+
if rep.non_zidane:
|
|
1082
|
+
who += "*"
|
|
1083
|
+
cam = (f"FOV {rep.cam_fov:g}, pitch {rep.cam_pitch:g}"
|
|
1084
|
+
if rep.cam_fov is not None and rep.cam_pitch is not None else "(no camera)")
|
|
1085
|
+
flags = (["scroll"] if rep.cam_scrolling else []) + ([f"{rep.cam_count}cam"] if rep.cam_count > 1 else [])
|
|
1086
|
+
tail = (" " + " ".join(flags)) if flags else ""
|
|
1087
|
+
lines.append(f" {i:>2} {rep.field_id:>5} {fbg:<36} {who:<12} {cam}{tail}")
|
|
1088
|
+
lines += ["", " * = non-Zidane player (forks via --verbatim). Fork a room:",
|
|
1089
|
+
" ff9mapkit import <fbg> --verbatim --swap-player <char>"]
|
|
1090
|
+
return "\n".join(lines)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
# ============================================================================================
|
|
1094
|
+
# "Who do you play as" listing -- enrich a field list with the controlled player, so the
|
|
1095
|
+
# non-Zidane donors are discoverable WITHOUT forking each. Id-centric (a player is a property of
|
|
1096
|
+
# the .eb, so an alternate event script on a shared background is its OWN row -- more complete than
|
|
1097
|
+
# the folder-centric `list-fields`). Reuses analyze_eb's in-game-proven player resolution.
|
|
1098
|
+
# ============================================================================================
|
|
1099
|
+
@dataclass
|
|
1100
|
+
class FieldPlayer:
|
|
1101
|
+
field_id: int
|
|
1102
|
+
fbg: str
|
|
1103
|
+
event_name: str
|
|
1104
|
+
player: str # the compact "who you control" label
|
|
1105
|
+
non_zidane: bool
|
|
1106
|
+
multi_pc: bool
|
|
1107
|
+
playable: bool = True # the controlled model is a named cast member (vs a GEO_SUB cutscene-driver)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _player_is_playable(rep: ForkReport) -> bool:
|
|
1111
|
+
"""True if you control a named playable cast member (not a GEO_SUB/GEO_ACC cutscene-driver model).
|
|
1112
|
+
Mirrors player_label's character choice so the flag matches the displayed name."""
|
|
1113
|
+
if not rep.player_models:
|
|
1114
|
+
return False
|
|
1115
|
+
if rep.multi_pc:
|
|
1116
|
+
if not rep.non_zidane:
|
|
1117
|
+
return True # Zidane-present multi-PC -> you control Zidane (playable)
|
|
1118
|
+
m = None
|
|
1119
|
+
if rep.controlled_entry is not None:
|
|
1120
|
+
m = next((mm for pe, mm, _ in rep.player_models if pe == rep.controlled_entry), None)
|
|
1121
|
+
return (m if m is not None else rep.player_models[0][1]) in PLAYABLE_NAMES
|
|
1122
|
+
return rep.player_models[0][1] in PLAYABLE_NAMES
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def player_label(rep: ForkReport) -> tuple:
|
|
1126
|
+
"""(compact 'who you control' label, is_non_zidane) for a browse/list view. For a Zidane-PRESENT
|
|
1127
|
+
multi-PC field control most likely routes to the Zidane party-leader (not the first entry), so it
|
|
1128
|
+
labels 'Zidane'; a no-Zidane multi-PC field names the computed binder (`controlled_name`)."""
|
|
1129
|
+
if not rep.player_models:
|
|
1130
|
+
return ("(no player)", False)
|
|
1131
|
+
if rep.multi_pc:
|
|
1132
|
+
co = len(rep.player_models) - 1
|
|
1133
|
+
if rep.non_zidane: # keep the flag even if the binder name is blank
|
|
1134
|
+
name = rep.controlled_name or rep.player_models[0][2]
|
|
1135
|
+
return (f"{name} +{co}", True) # the non-Zidane control binder
|
|
1136
|
+
return (f"Zidane +{co}", False) # Zidane-present multi-PC: likely the leader
|
|
1137
|
+
return (rep.player_models[0][2], rep.non_zidane)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def field_players(*, game=None, pattern=None, non_zidane_only=False, bundle=None):
|
|
1141
|
+
"""Sweep fields and resolve WHO you control in each (the model behind ``DefinePlayerCharacter``).
|
|
1142
|
+
Filter by an FBG substring (``pattern``) and/or ``non_zidane_only``. Returns ``(rows, scanned)``
|
|
1143
|
+
(rows = FieldPlayer, sorted by fbg then id). Reuses analyze_eb (eb-only, ONE EventBundle). A full
|
|
1144
|
+
no-pattern sweep reads ~675 scripts (~30s); a pattern narrows it. Read-only. Needs UnityPy."""
|
|
1145
|
+
from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT # lazy: UnityPy only when used
|
|
1146
|
+
b = bundle or EventBundle(game)
|
|
1147
|
+
pat = pattern.lower() if pattern else None
|
|
1148
|
+
rows = []
|
|
1149
|
+
scanned = 0
|
|
1150
|
+
for fid, fbg in sorted(ID_TO_FBG.items(), key=lambda kv: (kv[1], kv[0])):
|
|
1151
|
+
if not _is_real_fbg(fbg):
|
|
1152
|
+
continue
|
|
1153
|
+
if pat and pat not in fbg.lower():
|
|
1154
|
+
continue
|
|
1155
|
+
scanned += 1
|
|
1156
|
+
rep = analyze_eb(b.eb_for_id(fid), field_id=fid, fbg_name=fbg, event_name=ID_TO_EVT.get(fid, ""))
|
|
1157
|
+
label, nz = player_label(rep)
|
|
1158
|
+
if non_zidane_only and not nz:
|
|
1159
|
+
continue
|
|
1160
|
+
rows.append(FieldPlayer(fid, fbg, ID_TO_EVT.get(fid, "") or "", label, nz, rep.multi_pc,
|
|
1161
|
+
_player_is_playable(rep)))
|
|
1162
|
+
return rows, scanned
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
# --- fork-report --explain: decode a field's NPC interactions into readable English -------------------
|
|
1166
|
+
# The antidote to "staring at bits": for every carried NPC, trace its tag-3 talk handler into plain
|
|
1167
|
+
# steps (real dialogue text + items/gil/menus + cross-refs), INLINING the funcs it RunScripts -- the
|
|
1168
|
+
# Main_Init shared logic (uid 0), the player sequences (uid 250 / a player entry), a sibling object --
|
|
1169
|
+
# so a multi-NPC sidequest reads as one quest. This is also WHY a render-only NPC is render-only: you
|
|
1170
|
+
# SEE that its talk routine is the field's own quest logic, not a graftable gesture (-> use --verbatim).
|
|
1171
|
+
# Pure structure is .eb-only; dialogue TEXT enriches it when the install's .mes is available. Read-only;
|
|
1172
|
+
# reuses the disassembler + item-pool decode + dialogue.parse_mes (no carry/graft logic of its own).
|
|
1173
|
+
_EXPLAIN_WIN = {0x1F: 2, 0x95: 3, 0x20: 2, 0x96: 3} # window op -> txid arg index (mirrors dialogue.WINDOW_OPS)
|
|
1174
|
+
_RUNSCRIPT_OPS = (0x10, 0x12, 0x14) # RunScript[Async|Sync](level, uid, tag)
|
|
1175
|
+
SAVE_MENU_ID = 4 # Menu(4, 0) = the save point (memory project-ff9-savepoint)
|
|
1176
|
+
_VERDICT = {"clean": "interactive", "init_only": "render-only", "refuse": "not carried"}
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
@dataclass
|
|
1180
|
+
class NpcExplain:
|
|
1181
|
+
slot: int
|
|
1182
|
+
model: str
|
|
1183
|
+
verdict: str # interactive | render-only | not carried
|
|
1184
|
+
reason: str = "" # why, in English (render-only / not-carried only)
|
|
1185
|
+
steps: list = _dc_field(default_factory=list) # [(depth, kind, text)] -- kind: say|give|gil|menu|call
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
@dataclass
|
|
1189
|
+
class ExplainReport:
|
|
1190
|
+
field_id: int
|
|
1191
|
+
fbg_name: str = ""
|
|
1192
|
+
event_name: str = ""
|
|
1193
|
+
npcs: list = _dc_field(default_factory=list) # NpcExplain, in spawn order
|
|
1194
|
+
n_props: int = 0 # non-talkable set-dressing (carried, no interaction)
|
|
1195
|
+
has_text: bool = False # the .mes was resolved (dialogue is real text vs <line N>)
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _resolve_line(entries, txid, *, width=72) -> str:
|
|
1199
|
+
"""A window's txid -> its (tag-stripped, one-line, truncated) text, or a ``<line N>`` placeholder when
|
|
1200
|
+
no ``.mes`` is loaded / the id is absent / the operand is computed."""
|
|
1201
|
+
if txid is None:
|
|
1202
|
+
return "(text chosen at runtime)"
|
|
1203
|
+
if not entries:
|
|
1204
|
+
return f"<line {txid}>"
|
|
1205
|
+
e = entries.get(int(txid))
|
|
1206
|
+
if e is None:
|
|
1207
|
+
return f"<line {txid}: not in this field's text>"
|
|
1208
|
+
from . import dialogue as _d
|
|
1209
|
+
s = _d.strip_tags(e.text).replace("\n", " / ").strip()
|
|
1210
|
+
s = " ".join(s.split()) # collapse runs of whitespace from the join
|
|
1211
|
+
return (s[:width] + "...") if len(s) > width else (s or "(blank line)")
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _explain_call(eb, current_entry, uid, tag, pents):
|
|
1215
|
+
"""Label a ``RunScript(uid, tag)`` in English + the entry index(es) to INLINE its body from.
|
|
1216
|
+
The uid->entry convention lives in :func:`eventscan.resolve_uid` (the single source of truth); this is
|
|
1217
|
+
the English-label layer over it."""
|
|
1218
|
+
from . import eventscan
|
|
1219
|
+
kind, targets = eventscan.resolve_uid(uid, current_entry, pents, eb.entry_count)
|
|
1220
|
+
label = {
|
|
1221
|
+
"self": f"runs its own routine #{tag}",
|
|
1222
|
+
"player": f"directs the player (sequence #{tag})",
|
|
1223
|
+
"party": f"calls a party member (routine #{tag})",
|
|
1224
|
+
"main": f"runs shared field logic (Main_Init routine #{tag})",
|
|
1225
|
+
"object": f"drives object #{uid} (routine #{tag})",
|
|
1226
|
+
}.get(kind, f"calls uid {uid} (routine #{tag})")
|
|
1227
|
+
return label, targets
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _trace_interaction(eb, entry_idx, tag, entries, *, depth, visited, steps, pents):
|
|
1231
|
+
"""Walk one function into readable steps, recursing (depth-capped, cycle-guarded) into the player /
|
|
1232
|
+
Main_Init / sibling routines it RunScripts -- so a sidequest split across helpers reads as one flow."""
|
|
1233
|
+
key = (entry_idx, tag)
|
|
1234
|
+
if depth > 2 or key in visited:
|
|
1235
|
+
return
|
|
1236
|
+
visited.add(key)
|
|
1237
|
+
e = eb.entry(entry_idx) if 0 <= entry_idx < eb.entry_count else None
|
|
1238
|
+
f = e.func_by_tag(tag) if (e is not None and not e.empty) else None
|
|
1239
|
+
if f is None:
|
|
1240
|
+
return
|
|
1241
|
+
for ins in eb.instrs(f):
|
|
1242
|
+
op = ins.op
|
|
1243
|
+
if op in _EXPLAIN_WIN:
|
|
1244
|
+
steps.append((depth, "say", _resolve_line(entries, ins.imm(_EXPLAIN_WIN[op]))))
|
|
1245
|
+
elif op == ADD_ITEM_OP:
|
|
1246
|
+
iid = ins.imm(0)
|
|
1247
|
+
if iid is None:
|
|
1248
|
+
steps.append((depth, "give", "an item (chosen at runtime)"))
|
|
1249
|
+
elif iid != NO_ITEM and not item_inert(iid):
|
|
1250
|
+
cnt = ins.imm(1)
|
|
1251
|
+
steps.append((depth, "give", item_label(iid) + (f" x{cnt}" if cnt and cnt != 1 else "")))
|
|
1252
|
+
elif op == ADD_GIL_OP:
|
|
1253
|
+
steps.append((depth, "gil", "gil"))
|
|
1254
|
+
elif op == MENU_OP:
|
|
1255
|
+
mid = ins.imm(0)
|
|
1256
|
+
steps.append((depth, "menu", "a shop" if mid == SHOP_MENU_ID
|
|
1257
|
+
else ("the save menu" if mid == SAVE_MENU_ID else f"menu #{mid}")))
|
|
1258
|
+
elif op in _RUNSCRIPT_OPS:
|
|
1259
|
+
uid, t = ins.imm(1), ins.imm(2)
|
|
1260
|
+
if uid is None or t is None:
|
|
1261
|
+
continue
|
|
1262
|
+
label, inline_from = _explain_call(eb, entry_idx, uid, t, pents)
|
|
1263
|
+
steps.append((depth, "call", label))
|
|
1264
|
+
for cand in inline_from: # inline the FIRST candidate that actually defines the tag
|
|
1265
|
+
ce = eb.entry(cand) if 0 <= cand < eb.entry_count else None
|
|
1266
|
+
if ce is not None and not ce.empty and ce.func_by_tag(t) is not None:
|
|
1267
|
+
_trace_interaction(eb, cand, t, entries, depth=depth + 1,
|
|
1268
|
+
visited=visited, steps=steps, pents=pents)
|
|
1269
|
+
break
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _explain_reason(spec) -> str:
|
|
1273
|
+
"""Why a non-clean NPC's talk handler can't be carried as-is, in English (from its classified refs)."""
|
|
1274
|
+
cats = []
|
|
1275
|
+
for r in spec["refs"]:
|
|
1276
|
+
k = r["klass"]
|
|
1277
|
+
if k in ("self", "sibling"):
|
|
1278
|
+
continue
|
|
1279
|
+
if k == "player" and r.get("tag") is None: # TurnTowardObject(player) etc. -- already safe
|
|
1280
|
+
continue
|
|
1281
|
+
if k == "player" and r.get("tag") is not None:
|
|
1282
|
+
cats.append("a scripted player sequence")
|
|
1283
|
+
elif r["op"] == 0x43: # RunSharedScript (STARTSEQ) -- a background script
|
|
1284
|
+
cats.append("a background script")
|
|
1285
|
+
elif k == "uncarried" and r.get("value") == 0 and r.get("tag") is not None:
|
|
1286
|
+
cats.append("shared field logic (Main_Init)")
|
|
1287
|
+
elif k == "uncarried" and r.get("tag") is not None:
|
|
1288
|
+
cats.append("another object that isn't carried")
|
|
1289
|
+
elif k == "expr":
|
|
1290
|
+
cats.append("a runtime-computed reference")
|
|
1291
|
+
elif k == "party":
|
|
1292
|
+
cats.append("a party member")
|
|
1293
|
+
seen = []
|
|
1294
|
+
for c in cats:
|
|
1295
|
+
if c not in seen:
|
|
1296
|
+
seen.append(c)
|
|
1297
|
+
if not seen:
|
|
1298
|
+
return "its talk routine references something the carry can't resolve"
|
|
1299
|
+
if len(seen) == 1:
|
|
1300
|
+
body = seen[0]
|
|
1301
|
+
else:
|
|
1302
|
+
body = ", ".join(seen[:-1]) + " and " + seen[-1]
|
|
1303
|
+
return "its talk routine depends on " + body
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def explain_eb(eb_bytes, *, field_id: int = 0, fbg_name: str = "", event_name: str = "",
|
|
1307
|
+
entries=None) -> ExplainReport:
|
|
1308
|
+
"""Decode a field's NPC interactions to an :class:`ExplainReport` from its ``.eb`` bytes (pure;
|
|
1309
|
+
``entries`` = a parsed ``.mes`` ``{txid: MesEntry}`` enriches the windows with real text -- omit it
|
|
1310
|
+
for the structure-only view). Reuses ``scan_objects_verbatim`` with FULL grafting on, so a verdict of
|
|
1311
|
+
``render-only`` means the NPC stays render-only even with every graft -- i.e. genuinely field logic."""
|
|
1312
|
+
from . import eventscan # lazy (keeps import cost off the core path)
|
|
1313
|
+
rep = ExplainReport(field_id=field_id, fbg_name=fbg_name, event_name=event_name, has_text=bool(entries))
|
|
1314
|
+
if not eb_bytes:
|
|
1315
|
+
return rep
|
|
1316
|
+
eb = EbScript.from_bytes(bytes(eb_bytes))
|
|
1317
|
+
pents = set(eventscan.resolve_player_entries(eb))
|
|
1318
|
+
specs = eventscan.scan_objects_verbatim(bytes(eb_bytes), graft_player_funcs=True,
|
|
1319
|
+
carry_text=True, graft_seq_helpers=True)
|
|
1320
|
+
for s in specs:
|
|
1321
|
+
if s["kind"] != "npc":
|
|
1322
|
+
rep.n_props += 1
|
|
1323
|
+
continue
|
|
1324
|
+
steps: list = []
|
|
1325
|
+
_trace_interaction(eb, s["donor_idx"], 3, entries or {}, depth=0,
|
|
1326
|
+
visited=set(), steps=steps, pents=pents)
|
|
1327
|
+
verdict = _VERDICT.get(s["graft_safety"], s["graft_safety"])
|
|
1328
|
+
reason = _explain_reason(s) if s["graft_safety"] != "clean" else ""
|
|
1329
|
+
rep.npcs.append(NpcExplain(s["donor_idx"], s["model"], verdict, reason, steps))
|
|
1330
|
+
return rep
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def explain(field_id: int, *, game=None, bundle=None, lang: str = "us") -> ExplainReport:
|
|
1334
|
+
"""The id->bytes loader over :func:`explain_eb` -- resolves the field's ``.mes`` (install needed for
|
|
1335
|
+
real dialogue text; degrades to ``<line N>`` placeholders without it). Read-only."""
|
|
1336
|
+
from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT # lazy: UnityPy only when used
|
|
1337
|
+
b = bundle or EventBundle(game)
|
|
1338
|
+
data = b.eb_for_id(field_id)
|
|
1339
|
+
entries = None
|
|
1340
|
+
try:
|
|
1341
|
+
from . import dialogue as _d
|
|
1342
|
+
mes = _d.extract_field_mes(str(field_id), lang=lang, game=game)
|
|
1343
|
+
if mes:
|
|
1344
|
+
entries = _d.parse_mes(mes)
|
|
1345
|
+
except Exception: # no install / no UnityPy / no text block -> structure-only
|
|
1346
|
+
entries = None
|
|
1347
|
+
return explain_eb(data, field_id=field_id, fbg_name=ID_TO_FBG.get(field_id, ""),
|
|
1348
|
+
event_name=ID_TO_EVT.get(field_id, ""), entries=entries)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
_STEP_GLYPH = {"say": '"{}"', "give": "gives {}", "gil": "gives gil", "menu": "opens {}", "call": "-> {}"}
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def format_explain(rep: ExplainReport) -> str:
|
|
1355
|
+
"""Render an :class:`ExplainReport` as a readable cast-interaction transcript."""
|
|
1356
|
+
head = rep.fbg_name or f"field {rep.field_id}"
|
|
1357
|
+
suffix = f" (field {rep.field_id}{', ' + rep.event_name if rep.event_name else ''})"
|
|
1358
|
+
out = [f"fork-report --explain: {head}{suffix}", ""]
|
|
1359
|
+
n_int = sum(1 for n in rep.npcs if n.verdict == "interactive")
|
|
1360
|
+
n_ro = sum(1 for n in rep.npcs if n.verdict == "render-only")
|
|
1361
|
+
out.append(f" {len(rep.npcs)} NPC(s): {n_int} interactive, {n_ro} render-only"
|
|
1362
|
+
f"{f', {rep.n_props} prop(s)' if rep.n_props else ''}.")
|
|
1363
|
+
out.append(" Each NPC's talk routine is decoded below (dialogue + items + the funcs it runs).")
|
|
1364
|
+
if n_ro:
|
|
1365
|
+
out.append(" A render-only NPC's routine IS field logic (shared/player/quest), not a graftable")
|
|
1366
|
+
out.append(" gesture -- fork with --verbatim to keep it interactive (or re-author the interaction).")
|
|
1367
|
+
if not rep.has_text:
|
|
1368
|
+
out.append(" (no install / text block -> dialogue shown as <line N>; run with the game present for words.)")
|
|
1369
|
+
out.append("")
|
|
1370
|
+
if not rep.npcs:
|
|
1371
|
+
out.append(" (no carried NPCs -- nothing to explain.)")
|
|
1372
|
+
return "\n".join(out)
|
|
1373
|
+
for n in rep.npcs:
|
|
1374
|
+
glyph = "*" if n.verdict == "interactive" else "o"
|
|
1375
|
+
tag = f"[{n.verdict}{' -- ' + n.reason if n.reason else ''}]"
|
|
1376
|
+
out.append(f" {glyph} {n.model} (slot {n.slot}) {tag}")
|
|
1377
|
+
if not n.steps:
|
|
1378
|
+
out.append(" (no talk routine -- silent NPC)")
|
|
1379
|
+
for depth, kind, text in n.steps:
|
|
1380
|
+
pad = " " + " " * depth
|
|
1381
|
+
out.append(pad + _STEP_GLYPH.get(kind, "{}").format(text))
|
|
1382
|
+
out.append("")
|
|
1383
|
+
return "\n".join(out).rstrip()
|