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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""One-shot field EVENTS -- walk-into-a-zone triggers that fire authored logic, optionally once.
|
|
2
|
+
|
|
3
|
+
This is the conditional-region primitive (:mod:`ff9mapkit.content.region`) cashed in as authorable
|
|
4
|
+
content. An event is a region whose ``_Range`` runs a composed sequence -- show a message, give an
|
|
5
|
+
item / gil, set a story flag -- gated by a GlobBool so an ``once`` event (a looted chest, a one-time
|
|
6
|
+
line, an ATE) never re-fires. Same shape the real game uses for treasure (decoded from a real chest
|
|
7
|
+
handler: ``AddItem`` + a "received X" ``WindowSync``) and the same flag-gated ``if (!done){..;
|
|
8
|
+
done=1}`` the camera-switch zones use.
|
|
9
|
+
|
|
10
|
+
Compose a body from the part builders (:func:`message` / :func:`give_item` / :func:`give_gil` /
|
|
11
|
+
:func:`set_flag`); :func:`inject_events` adds any number of events through a SINGLE arming entry (so
|
|
12
|
+
they don't each consume a Main_Init ``Wait`` filler).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import struct
|
|
18
|
+
|
|
19
|
+
from .. import items as _items
|
|
20
|
+
from ..eb import EbScript, edit, opcodes
|
|
21
|
+
from . import region as _region
|
|
22
|
+
|
|
23
|
+
# 'once' flags live in the SAVE-PERSISTENT Global bool (region.GLOB_BOOL) so a looted chest / one-time
|
|
24
|
+
# event stays done across field reloads + saves. The base is high in gEventGlobal (byte ~1000) to stay
|
|
25
|
+
# clear of the base game's flags (which sit low); override per event with `flag = N`.
|
|
26
|
+
EVENT_FLAG_CLASS = _region.GLOB_BOOL
|
|
27
|
+
EVENT_FLAG_BASE = 8000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def message(text_id: int, *, window: int = 1, flags: int = 128) -> bytes:
|
|
31
|
+
"""Body part: open a dialogue window (WindowSync) showing text ``text_id``."""
|
|
32
|
+
return opcodes.window_sync(window, flags, text_id)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def give_item(item_id, count: int = 1) -> bytes:
|
|
36
|
+
"""Body part: AddItem(item, count). ``item_id`` may be a numeric id OR a name ("Potion") --
|
|
37
|
+
resolved via :mod:`ff9mapkit.items` so authors don't have to memorize ids. Works for ANY item,
|
|
38
|
+
including weapons/armor ("Excalibur") -- the engine renders the name in the "Received X" box."""
|
|
39
|
+
return opcodes.add_item(_items.resolve(item_id), count)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def take_item(item_id, count: int = 1) -> bytes:
|
|
43
|
+
"""Body part: RemoveItem(item, count) -- the symmetric counterpart of :func:`give_item` (a trade,
|
|
44
|
+
a quest-item consume). ``item_id`` may be a numeric id OR a name; the engine clamps to what's held."""
|
|
45
|
+
return opcodes.remove_item(_items.resolve(item_id), count)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def give_gil(amount: int) -> bytes:
|
|
49
|
+
"""Body part: change the party's gil by ``amount`` -- positive ADDS (AddGil), negative SUBTRACTS
|
|
50
|
+
(RemoveGil). The two opcodes both take an unsigned amount, so we pick by sign here (a negative
|
|
51
|
+
``amount`` would otherwise wrap to a huge ADD and max out gil)."""
|
|
52
|
+
return opcodes.add_gil(amount) if amount >= 0 else opcodes.remove_gil(-amount)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def set_flag(flag_idx: int, value: int = 1, *, flag_class=EVENT_FLAG_CLASS) -> bytes:
|
|
56
|
+
"""Body part: set a GlobBool story flag (gate other content on it)."""
|
|
57
|
+
return _region.set_var(flag_class, flag_idx, value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def reveal_object(slot: int) -> bytes:
|
|
61
|
+
"""Body part: re-run an object's Init (``InitObject``). Used after :func:`set_flag` to make a
|
|
62
|
+
flag-gated NPC appear (or vanish) LIVE in the same room -- its Init re-evaluates the gate with the
|
|
63
|
+
flag's new value (without this, a gated NPC only updates on field re-entry, since Init runs once
|
|
64
|
+
at spawn)."""
|
|
65
|
+
return opcodes.init_object(slot, 0)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# RunSoundCode(265, 65535): the field-transition whoosh present byte-identically in EVERY real warp
|
|
69
|
+
# (verified in the airship 455<->457, the Dali innkeeper 351->352, Gargan Roo 950->914 talk handlers).
|
|
70
|
+
WARP_SOUND = (265, 65535)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# The proven field-transition fade-out, byte-identical to what ladders + gateways emit (and what the
|
|
74
|
+
# engine itself does on a worldmap entry): FadeFilter(mode 6 = SUB, 24f, target colour white) drives the
|
|
75
|
+
# screen to BLACK (SUB shows `screen - colour`, so colour=white => black), then Wait(25) holds until the
|
|
76
|
+
# fade has fully finished. Confirmed against SceneDirector.InitFade/ServiceFade + content.ladder.
|
|
77
|
+
WARP_FADE = (6, 24, 0, 255, 255, 255)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def warp(target: int, entrance: "int | None" = None, *, fade: bool = False) -> bytes:
|
|
81
|
+
"""Body part: warp the player to ``Field(target)``. Grounded in real *talk-handler* warps -- a Field
|
|
82
|
+
op TRANSITIONS directly from an event / tag-3 _SpeakBTN context (unlike a bare Field in Main_Init,
|
|
83
|
+
which no-ops -- see memory project_ff9_field_warp_pattern); 14+ shipping fields warp the player from a
|
|
84
|
+
tag-3 handler this way (the Dali innkeeper, the airship, Gargan Roo...). Plays the transition sound,
|
|
85
|
+
then warps. The warp TRANSITIONS AWAY, so this MUST be the LAST part of a body (anything after is
|
|
86
|
+
unreachable).
|
|
87
|
+
|
|
88
|
+
``fade`` (default False) prepends the proven transition FADE-OUT (:data:`WARP_FADE` + ``Wait(25)``).
|
|
89
|
+
This is what fixes the World-Hub "static screen on spawn": a bare ``Field()`` warp transitions with the
|
|
90
|
+
SOURCE field still fully drawn, so the destination loads *in the clear* and you SEE its camera-init
|
|
91
|
+
frames (the smooth-cam needs ~0.8s to wire up player-tracking -- ``FieldMap.SceneService3DScroll`` --
|
|
92
|
+
during which the camera sits on the bare scene centre with the player off in a corner). Every real
|
|
93
|
+
gateway/ladder/worldmap entry fades to black FIRST, so the destination loads black and its own reveal
|
|
94
|
+
fade (and any ``[camera] entry_settle``) hides the wire-up. ``fade=True`` makes the choice-warp do the
|
|
95
|
+
same. (The destination's reveal LERPs the SUB layer from white->black; if the source never set it to
|
|
96
|
+
white, that reveal is a no-op and nothing is hidden -- which is exactly the bug.)
|
|
97
|
+
|
|
98
|
+
``entrance`` (when set) writes the ARRIVAL-ENTRANCE var (D8:2) before the warp, like a gateway
|
|
99
|
+
(:func:`ff9mapkit.content.region.set_field_entrance`) -- the destination's player-init may switch on it
|
|
100
|
+
to place the player. NOTE it is NOT a camera fix and is silently overridden by destinations whose
|
|
101
|
+
Main_Init rewrites D8:2 on entry (e.g. the forked Ice Cavern sets D8:2=10000 up front); the fade is the
|
|
102
|
+
camera fix. Default None = no entrance write."""
|
|
103
|
+
pre = opcodes.fade_filter(*WARP_FADE) + opcodes.wait(25) if fade else b""
|
|
104
|
+
if entrance is not None:
|
|
105
|
+
pre += _region.set_field_entrance(int(entrance))
|
|
106
|
+
return pre + opcodes.run_sound_code(*WARP_SOUND) + opcodes.field(int(target))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def set_scenario(value: int) -> bytes:
|
|
110
|
+
"""Body part: set the ScenarioCounter (the story beat) -- the save-backed gEventGlobal UInt16 @0.
|
|
111
|
+
Reuses :func:`ff9mapkit.content.startup.startup_body` (story_flags' lever) so an event/choice can
|
|
112
|
+
advance the beat (e.g. a hub journey-pick seeds the destination's beat before the warp)."""
|
|
113
|
+
from . import startup as _startup
|
|
114
|
+
return _startup.startup_body([], scenario=int(value))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def event_range_body(body: bytes, once_flag: int | None, flag_class=EVENT_FLAG_CLASS,
|
|
118
|
+
requires_flag: int | None = None, requires_set: bool = True,
|
|
119
|
+
space_item: int | None = None) -> bytes:
|
|
120
|
+
"""The region ``_Range`` body for an event: a movement gate, an optional ``requires_flag`` story
|
|
121
|
+
gate (the event only fires when that flag is in-state), then ``body`` -- gated
|
|
122
|
+
``if (!flag) { flag = 1; body }`` when ``once_flag`` is set, so it fires once. The once-flag is set
|
|
123
|
+
BEFORE the body, matching FF9's treasure-chest convention (``if(!opened){ opened=1; reward; msg }``):
|
|
124
|
+
the dedup flag lands the instant the event fires, before any (movement-unblocking) reward message --
|
|
125
|
+
so it can't double-fire even if the reward window is left open.
|
|
126
|
+
|
|
127
|
+
``space_item`` (a chest nicety) wraps everything in ``if (GetItemCount(item) < 99) { ... }`` so the
|
|
128
|
+
reward is skipped (and the once-flag NOT set -> retryable) when the bag is full -- exactly FF9's
|
|
129
|
+
item-chest guard, with the space check OUTERMOST."""
|
|
130
|
+
parts = [_region.MOVEMENT_GATE]
|
|
131
|
+
if requires_flag is not None:
|
|
132
|
+
parts.append(_region.flag_gate(flag_class, requires_flag, require_set=requires_set))
|
|
133
|
+
if once_flag is not None:
|
|
134
|
+
core = _region.if_block(_region.cond_not(flag_class, once_flag),
|
|
135
|
+
_region.set_var(flag_class, once_flag, 1) + body)
|
|
136
|
+
else:
|
|
137
|
+
# No once flag = the raw region trigger: tag 2 is LEVEL-triggered (the engine fires it every
|
|
138
|
+
# frame the player treads the quad -- TreadQuad is a pure position test, no edge detection), so
|
|
139
|
+
# a `once=false` message re-fires as soon as it closes while still inside. Correct for a
|
|
140
|
+
# continuous effect; edge-triggered "once per visit" would need a leave-detecting re-arm zone.
|
|
141
|
+
core = body
|
|
142
|
+
if space_item is not None: # FF9 chest: only give if there's room (space outermost)
|
|
143
|
+
core = _region.if_block(_region.cond_item_count_lt(space_item), core)
|
|
144
|
+
parts.append(core)
|
|
145
|
+
parts.append(opcodes.RETURN)
|
|
146
|
+
return b"".join(parts)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def inject_event(data, *, zone, body: bytes, once_flag: int | None = None,
|
|
150
|
+
requires_flag: int | None = None, requires_set: bool = True,
|
|
151
|
+
flag_class=EVENT_FLAG_CLASS, slot=None, spawn_wait_n: int = 2,
|
|
152
|
+
spawn_wait_occurrence: int = 0, reserve_party_band: bool = False):
|
|
153
|
+
"""Inject ONE walk-in event region (armed at load via InitRegion-over-Wait). Returns
|
|
154
|
+
``(new_bytes, slot)``. For several events prefer :func:`inject_events` (one shared arm entry)."""
|
|
155
|
+
range_body = event_range_body(body, once_flag, flag_class, requires_flag, requires_set)
|
|
156
|
+
return _region.inject_region(data, zone, range_body, slot=slot, activate=True,
|
|
157
|
+
spawn_wait_n=spawn_wait_n, spawn_wait_occurrence=spawn_wait_occurrence,
|
|
158
|
+
reserve_party_band=reserve_party_band)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def inject_events(data, events, *, flag_class=EVENT_FLAG_CLASS, spawn_wait_n: int = 2,
|
|
162
|
+
spawn_wait_occurrence: int = 0, reserve_party_band: bool = False) -> bytes:
|
|
163
|
+
"""Inject many events through a single arming entry. ``events`` is a list of dicts with keys
|
|
164
|
+
``zone`` (corners), ``body`` (composed action bytes), ``once_flag`` (int or None).
|
|
165
|
+
|
|
166
|
+
Each event becomes a region (appended, not auto-armed); one type-0 code entry then ``InitRegion``s
|
|
167
|
+
them all and is activated once via ``InitCode`` over a Main_Init ``Wait`` filler -- so N events
|
|
168
|
+
cost ONE filler, not N. ``reserve_party_band`` (the VERBATIM-fork path): every region AND the arm
|
|
169
|
+
entry are seated BELOW the reserved party-character band. The arm's ``InitRegion`` targets the region
|
|
170
|
+
SLOTS, which sit below the band and so are never shifted by a later below-band insert -> stay valid.
|
|
171
|
+
Returns new .eb bytes."""
|
|
172
|
+
events = list(events)
|
|
173
|
+
if not events:
|
|
174
|
+
return data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
|
|
175
|
+
out = data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
|
|
176
|
+
region_slots = []
|
|
177
|
+
for ev in events:
|
|
178
|
+
rb = event_range_body(ev["body"], ev.get("once_flag"), flag_class,
|
|
179
|
+
ev.get("requires_flag"), ev.get("requires_set", True),
|
|
180
|
+
space_item=ev.get("space_item"))
|
|
181
|
+
out, slot = _region.inject_region(out, ev["zone"], rb, activate=False,
|
|
182
|
+
reserve_party_band=reserve_party_band)
|
|
183
|
+
region_slots.append(slot)
|
|
184
|
+
|
|
185
|
+
arm = b"".join(opcodes.init_region(s, 0) for s in region_slots) + opcodes.RETURN
|
|
186
|
+
arm_entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + arm
|
|
187
|
+
from . import object as _object # local: object imports region -> avoid the top-level cycle
|
|
188
|
+
out, arm_slot = _object.seat_entry(out, arm_entry, reserve_party_band=reserve_party_band)
|
|
189
|
+
out = edit.activate(out, opcodes.init_code(arm_slot, 0), spawn_wait_n=spawn_wait_n,
|
|
190
|
+
spawn_wait_occurrence=spawn_wait_occurrence)
|
|
191
|
+
return out
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Inject a field-exit gateway (a region trigger that warps to another field).
|
|
2
|
+
|
|
3
|
+
Clones the proven field-109 exit-region template (a SetRegion polygon ->
|
|
4
|
+
CalculateExitPosition/ExitField -> PreloadField -> FadeFilter -> set FieldEntrance ->
|
|
5
|
+
Field(target)), patches its trigger polygon + target field + arrival entrance, appends it
|
|
6
|
+
into a free entry slot, and activates it by overwriting a Main_Init ``Wait(2)`` filler with
|
|
7
|
+
``InitRegion`` (shift-free).
|
|
8
|
+
|
|
9
|
+
Zone gotchas (baked into :func:`quad_zone`): the engine's IsInQuad tests a *fan* of
|
|
10
|
+
consecutive vertex triplets, so three collinear points make a dead zone — use a convex quad
|
|
11
|
+
with the **last vertex doubled** (5 points). Point order matters: q0->q1 is the edge the
|
|
12
|
+
player walks out across, so put the front edge first for a natural forward exit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import struct
|
|
18
|
+
|
|
19
|
+
from .. import data
|
|
20
|
+
from ..eb import EbScript, edit, opcodes
|
|
21
|
+
from . import region as _region
|
|
22
|
+
|
|
23
|
+
# offsets within the 272-byte region template
|
|
24
|
+
REL_PTS, REL_ENTRANCE, REL_FIELD = 13, 263, 269
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def quad_zone(corners) -> list:
|
|
28
|
+
"""Make a 5-point IsInQuad-safe zone from 4 (x, z) corners (doubles the last vertex)."""
|
|
29
|
+
pts = [tuple(c) for c in corners]
|
|
30
|
+
if len(pts) != 4:
|
|
31
|
+
raise ValueError("quad_zone needs 4 corners")
|
|
32
|
+
return pts + [pts[-1]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def inject_gateway(eb_bytes, target: int, *, entrance: int = 0, zone, slot: int | None = None,
|
|
36
|
+
spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0,
|
|
37
|
+
gate_flag: int | None = None, gate_require_set: bool = True,
|
|
38
|
+
on_exit_body: bytes = b"", reserve_party_band: bool = False) -> bytes:
|
|
39
|
+
"""Inject an exit gateway to ``Field(target)`` arriving at ``entrance``. Returns new bytes.
|
|
40
|
+
|
|
41
|
+
``gate_flag`` (a GlobBool index) locks the exit behind a story flag: the region's trigger returns
|
|
42
|
+
early unless the flag is in the required state (``gate_require_set`` True = open when SET, e.g. a
|
|
43
|
+
door that unlocks once a switch flag is set; False = open when CLEAR).
|
|
44
|
+
|
|
45
|
+
``on_exit_body`` (raw ``set_var`` bytes -- e.g. from :func:`ff9mapkit.content.startup.startup_body`)
|
|
46
|
+
ADVANCES story state when the player TAKES this exit: it is prepended to the Range trigger behind a
|
|
47
|
+
``usercontrol`` guard, so the writes fire only on an actual walk-out (not while the player is puppeted
|
|
48
|
+
through with control disabled) and -- when the exit is also ``gate_flag``-locked -- only when the gate
|
|
49
|
+
passes (the flag gate sits ahead of the writes). The byte sequence runs just before the template's own
|
|
50
|
+
warp path, so the ScenarioCounter / story bits commit to the save-backed gEventGlobal before the
|
|
51
|
+
transition. Empty -> no change (the gateway builds byte-identically to before)."""
|
|
52
|
+
zone = list(zone)
|
|
53
|
+
if len(zone) != 5:
|
|
54
|
+
raise ValueError("zone must be 5 points (convex quad + doubled last vertex); see quad_zone()")
|
|
55
|
+
tpl = bytearray(data.region_template())
|
|
56
|
+
for i, (x, z) in enumerate(zone):
|
|
57
|
+
struct.pack_into("<hh", tpl, REL_PTS + i * 4, int(x), int(z))
|
|
58
|
+
struct.pack_into("<H", tpl, REL_ENTRANCE, entrance)
|
|
59
|
+
struct.pack_into("<H", tpl, REL_FIELD, target)
|
|
60
|
+
|
|
61
|
+
from . import object as _object # local: object imports region -> avoid the top-level cycle
|
|
62
|
+
out, slot = _object.seat_entry(eb_bytes, bytes(tpl), reserve_party_band=reserve_party_band, slot=slot)
|
|
63
|
+
out = edit.activate(out, opcodes.init_region(slot, 0), spawn_wait_n=spawn_wait_n,
|
|
64
|
+
spawn_wait_occurrence=spawn_wait_occurrence)
|
|
65
|
+
# Order matters: prepend the on-exit writes first, then the flag gate, so the final Range reads
|
|
66
|
+
# [flag gate] -> [usercontrol guard + writes] -> [template warp]. (Each prepend goes to Range's start.)
|
|
67
|
+
if on_exit_body:
|
|
68
|
+
out = _region.prepend_range_gate(out, slot, _region.MOVEMENT_GATE + on_exit_body)
|
|
69
|
+
if gate_flag is not None:
|
|
70
|
+
out = _region.prepend_range_gate(out, slot, _region.flag_gate(
|
|
71
|
+
_region.GLOB_BOOL, gate_flag, require_set=gate_require_set))
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def graft_gateway_entry(eb_bytes, entry_bytes, *, retarget=None, slot=None):
|
|
76
|
+
"""Graft a story-gated door's region entry VERBATIM (preserving its whole conditional state machine), then
|
|
77
|
+
arm it -- the faithful counterpart to :func:`inject_gateway`'s re-synthesis. A real story-gated door
|
|
78
|
+
(``eventscan.scan_gateway_entries`` ``story_gated``) checks GLOB save flags in a complex conditional the
|
|
79
|
+
declarative rebuild can't reproduce; carrying the entry whole keeps that logic, and its GLOB conditions
|
|
80
|
+
then read the ``[startup]``-preset story state (docs/FORK_FIDELITY.md #2b). Mirrors the object carry.
|
|
81
|
+
|
|
82
|
+
``retarget`` maps a real destination field id -> a new id; each ``Field(id)`` literal whose id is in the
|
|
83
|
+
map is patched in place (ids NOT in the map are left as live seams, like the import's live-seam doors).
|
|
84
|
+
``slot`` defaults to the first free entry. Returns ``(new_bytes, slot)``.
|
|
85
|
+
|
|
86
|
+
LIMIT: a door-only carry does NOT reconstruct MAP/transient vars the field's *main* logic sets on entry,
|
|
87
|
+
so a door whose firing also depends on those may still mis-evaluate (documented; ~30% of gated entries
|
|
88
|
+
also reference other entries and aren't carried by this path at all)."""
|
|
89
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
90
|
+
if slot is None:
|
|
91
|
+
slot = eb.first_free_slot()
|
|
92
|
+
out = edit.append_entry(eb_bytes, slot, bytes(entry_bytes))
|
|
93
|
+
if retarget:
|
|
94
|
+
ge = EbScript.from_bytes(out)
|
|
95
|
+
buf = bytearray(out)
|
|
96
|
+
for f in ge.entry(slot).funcs:
|
|
97
|
+
for i in ge.instrs(f):
|
|
98
|
+
if i.op == 0x2B and i.imm(0) in retarget: # Field(id) -> retarget[id] (2-byte literal @ +2)
|
|
99
|
+
struct.pack_into("<H", buf, i.off + 2, int(retarget[i.imm(0)]) & 0xFFFF)
|
|
100
|
+
out = bytes(buf)
|
|
101
|
+
return edit.activate(out, opcodes.init_region(slot, 0)), slot
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""``[start_inventory]`` -- author the NEW-GAME starting bag (the items the player begins a New Game with).
|
|
2
|
+
|
|
3
|
+
Writes ``<mod>/StreamingAssets/Data/Items/InitialItems.csv``. ★ The engine reads this
|
|
4
|
+
**HIGHEST-PRIORITY-WINS** (NOT merged -- ``ff9item.LoadInitialItems`` via ``GetCsvWithHighestPriority``), so
|
|
5
|
+
this file **REPLACES the base starting bag entirely**: list the COMPLETE intended inventory. A stacked mod
|
|
6
|
+
folder that also defines ``InitialItems.csv`` SHADOWS this one (the ``text_block`` trap) -> the build lints.
|
|
7
|
+
|
|
8
|
+
Read ONCE at new-game init, so it only affects a true **New Game** (not an F6 / campaign mid-game entry).
|
|
9
|
+
It is mod-global (one bag per mod) and lives on the ENTRY field's ``field.toml`` -- emitted at the mod-write
|
|
10
|
+
stage, not into any field's ``.eb``. (memory project-ff9-items-equipment / project-ff9-branch-lanes.)
|
|
11
|
+
|
|
12
|
+
[start_inventory]
|
|
13
|
+
items = [["Potion", 20], ["Phoenix Down", 5], ["Tent", 3], ["Ether", 10]]
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .. import items as _items
|
|
18
|
+
|
|
19
|
+
NO_ITEM = 255 # the empty sentinel -- never a real starting item
|
|
20
|
+
MAX_COUNT = 99 # the per-item inventory cap (UInt8 column; the engine clamps, we clamp too)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def inventory_rows(items) -> list:
|
|
24
|
+
"""``[[name, count], ...]`` (or bare names) -> sorted ``[(item_id, count), ...]`` -- names resolved,
|
|
25
|
+
dup ids summed, counts clamped 1..99, NoItem dropped. Raises ValueError (via :func:`items.resolve`) on an
|
|
26
|
+
unknown name."""
|
|
27
|
+
by_id: dict = {}
|
|
28
|
+
for entry in items:
|
|
29
|
+
if isinstance(entry, (list, tuple)):
|
|
30
|
+
name = entry[0]
|
|
31
|
+
count = int(entry[1]) if len(entry) > 1 else 1
|
|
32
|
+
else:
|
|
33
|
+
name, count = entry, 1
|
|
34
|
+
iid = _items.resolve(name)
|
|
35
|
+
if iid == NO_ITEM:
|
|
36
|
+
continue
|
|
37
|
+
by_id[iid] = min(MAX_COUNT, by_id.get(iid, 0) + max(1, count))
|
|
38
|
+
return sorted(by_id.items())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_initial_items(items) -> str:
|
|
42
|
+
"""The FULL ``InitialItems.csv`` text (header + ``id;count;# name`` rows). Replaces the base bag entirely
|
|
43
|
+
(highest-priority-wins), so this is the complete starting inventory."""
|
|
44
|
+
lines = [
|
|
45
|
+
"# ff9mapkit [start_inventory] -- the FULL new-game starting bag (REPLACES the base; highest-priority-wins).",
|
|
46
|
+
"# ItemID;Count",
|
|
47
|
+
"# Int32;UInt8",
|
|
48
|
+
]
|
|
49
|
+
for iid, count in inventory_rows(items):
|
|
50
|
+
nm = _items.name_of(iid)
|
|
51
|
+
lines.append(f"{iid};{count};" + (f"# {nm}" if nm else ""))
|
|
52
|
+
return "\n".join(lines) + "\n"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_initial_items(layout, items) -> None:
|
|
56
|
+
"""Pure writer: emit the starting-bag CSV into ``layout``'s mod root (``Data/Items/InitialItems.csv``)."""
|
|
57
|
+
path = layout.initial_items_csv
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
path.write_text(render_initial_items(items), encoding="utf-8", newline="\n")
|