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,369 @@
|
|
|
1
|
+
"""Tune a forked battle's gameplay (the BTL_SCENE ``dbfile0000.raw16``) from a battle.toml ``[scene]``.
|
|
2
|
+
|
|
3
|
+
A tier-c mint forks a donor battle's raw16 verbatim; this module SURGICALLY patches it with author
|
|
4
|
+
overrides -- enemy positions, stats, rewards, and the camera pose -- WITHOUT changing enemy TYPES (so the
|
|
5
|
+
forked raw17 attack sequences + the loaded enemy GEO/models stay valid). Only the edited fields change;
|
|
6
|
+
every other byte is verbatim, so we never risk mis-packing the 116-byte monster struct.
|
|
7
|
+
|
|
8
|
+
Layout (from Memoria ``BTL_SCENE.ReadBattleScene``):
|
|
9
|
+
header 8B: Ver(1) PatCount(1) TypCount(1) AtkCount(1) Flags(2) pad(2)
|
|
10
|
+
pattern i @ 8 + 56*i (56B): Rate(1) MonsterCount(1) Camera(1) Pad0(1) AP(u32) then 4x SB2_PUT(12B)
|
|
11
|
+
SB2_PUT j @ +8 + 12*j: TypeNo(1) Flags(1) Pease(1) Pad(1) Xpos(i16) Ypos(i16) Zpos(i16) Rot(i16)
|
|
12
|
+
monster t @ 8 + 56*PatCount + 116*t (116B, SB2_MON_PARM): the fields we edit are
|
|
13
|
+
ResistStatus@0 AutoStatus@4 InitialStatus@8 (u32 BattleStatus masks)
|
|
14
|
+
MaxHP@12(u16) MaxMP@14(u16) WinGil@16(u16) WinExp@18(u16) WinItems@20(4B) StealItems@24(4B)
|
|
15
|
+
Element{Speed@52,Str@53,Mag@54,Spr@55} Null/Absorb/Half/Weak-Element@60/61/62/63(1B each)
|
|
16
|
+
Level@64 Category@65 HitRate@66 Phys/Mag-Def+Evade@67-70 BlueMagic@71 WinCard@105
|
|
17
|
+
pattern AP @ pattern+4 (u32) = the GAMEPLAY AP reward (the per-type AP@50 is unused for rewards).
|
|
18
|
+
|
|
19
|
+
A stat edit targets the slot's TYPE (SB2_MON_PARM is per type), so two slots sharing a type share stats
|
|
20
|
+
(a real FF9 constraint) -- the editor warns when a type is tuned twice. Element/status fields take a list of
|
|
21
|
+
NAMES (or a raw int); see :mod:`battlecsv` for the name<->bit tables and the [PatchableField] note.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import struct
|
|
26
|
+
|
|
27
|
+
from .. import items
|
|
28
|
+
from . import battlecsv
|
|
29
|
+
|
|
30
|
+
_HDR = 8
|
|
31
|
+
_PAT = 56
|
|
32
|
+
_MON = 116
|
|
33
|
+
_PUT = 12
|
|
34
|
+
_FLG_TARGETABLE = 1 # SB2_PUT.FLG_TARGETABLE -- the enemy can be selected/attacked (FLG_MULTIPART=2)
|
|
35
|
+
|
|
36
|
+
# SB2_MON_PARM scalar field -> (offset, struct-fmt). Offsets RELATIVE to the 116-byte monster block; verified
|
|
37
|
+
# vs BTL_SCENE.cs:54-122 + the scene_codec round-trip. All raw17-safe (no model/type bytes).
|
|
38
|
+
_MON_FIELDS = {
|
|
39
|
+
"hp": (12, "<H"), "mp": (14, "<H"), "gil": (16, "<H"), "exp": (18, "<H"),
|
|
40
|
+
"speed": (52, "<B"), "strength": (53, "<B"), "magic": (54, "<B"), "spirit": (55, "<B"),
|
|
41
|
+
"level": (64, "<B"), "category": (65, "<B"), "hit_rate": (66, "<B"),
|
|
42
|
+
"phys_def": (67, "<B"), "phys_evade": (68, "<B"), "mag_def": (69, "<B"), "mag_evade": (70, "<B"),
|
|
43
|
+
"blue_magic": (71, "<B"), "win_card": (105, "<B"),
|
|
44
|
+
}
|
|
45
|
+
_MON_INT_MAX = {"<H": 0xFFFF, "<B": 0xFF}
|
|
46
|
+
|
|
47
|
+
# Element-affinity bytes (a 1-byte EffectElement bitmask each) + status masks (a u32 BattleStatus each),
|
|
48
|
+
# RELATIVE to the monster block. The author value is a list of element/status NAMES (or a raw int) -- see
|
|
49
|
+
# battlecsv.encode_elements / encode_status. `null` = GuardElement (nullified/immune elements).
|
|
50
|
+
_MON_ELEM_FIELDS = {"null": 60, "absorb": 61, "half": 62, "weak": 63}
|
|
51
|
+
_MON_STATUS_FIELDS = {"resist_status": 0, "auto_status": 4, "initial_status": 8}
|
|
52
|
+
|
|
53
|
+
# Per-enemy MON flags (SB2_MON_PARM.Flags @48, u16 = ENEMY_INFO.flags) -- raw16-ONLY (not a [PatchableField], so
|
|
54
|
+
# BattlePatch can't reach it; this is the one enemy-identity gap the BP surface leaves). Named bits from
|
|
55
|
+
# ENEMY.cs:37-39: die_atk/die_dmg pick the death-animation path; non_dying_boss = the enemy SURVIVES HP=0 (a
|
|
56
|
+
# scripted boss can't be killed by normal damage). The author value is a list of NAMES or a raw int (the high
|
|
57
|
+
# bits are read by some enemies' AI .eb, so a raw int passes them through).
|
|
58
|
+
_MON_FLAGS_OFF = 48
|
|
59
|
+
_MON_FLAG_NAMES = {"die_atk": 1, "die_dmg": 2, "non_dying_boss": 4}
|
|
60
|
+
|
|
61
|
+
# [scene]-level Flags (the scene header @4, a u16) -- the ENCOUNTER RULES. Named bits from BTL_SCENE scene_flags
|
|
62
|
+
# (scene_codec: preemptive = SpecialStart bit0, back_attack bit1, no_exp bit3, can't-escape = Runaway bit5).
|
|
63
|
+
# Authoring a `[scene] flags` list CLEARS these 4 known bits then ORs the named ones (any OTHER header bit is
|
|
64
|
+
# preserved); a raw int REPLACES the whole word. Absent -> the donor's header flags are kept verbatim.
|
|
65
|
+
_SCENE_FLAGS_OFF = 4
|
|
66
|
+
_SCENE_FLAG_NAMES = {"preemptive": 0x01, "back_attack": 0x02, "no_exp": 0x08, "no_escape": 0x20}
|
|
67
|
+
_SCENE_FLAG_MASK = 0x01 | 0x02 | 0x08 | 0x20
|
|
68
|
+
|
|
69
|
+
# RE-SKIN: the (offset, length) byte ranges of the MODEL + display fields, copied VERBATIM from a real donor
|
|
70
|
+
# enemy so a forked enemy's BODY looks like a different creature while keeping its own gameplay. The appearance
|
|
71
|
+
# is a self-consistent group: Geo (the model), Mot[6] (six GLOBAL animation ids = the IDLE/DAMAGE/DEATH motions,
|
|
72
|
+
# `BattleUnit.cs:858-878`; the engine resolves them to THAT model's clips, `btl_init.cs:240`/`:521-522`, and a
|
|
73
|
+
# clip that doesn't belong to the loaded model FREEZES the battle), Mesh[2], Radius (-> radius_collision,
|
|
74
|
+
# `btl_init.cs:68`; model-size-attached -- the Geo table only sets radius_effect/height, so Radius@28 is LIVE,
|
|
75
|
+
# keep it), plus the model-ATTACHED cosmetics (bone[4], die/start SFX, status-icon bones + offsets, shadow
|
|
76
|
+
# bones + offsets). Swapping Geo alone leaves the OLD model's Mot ids -> wrong/missing clips, so a re-skin
|
|
77
|
+
# transplants the WHOLE block. The GAMEPLAY fields (status/hp/mp/rewards/stats/elements/level/category/defences/
|
|
78
|
+
# blue_magic/win_card) are deliberately OUTSIDE these ranges -- they stay the target's.
|
|
79
|
+
#
|
|
80
|
+
# SCOPE -- this is a BODY re-skin, NOT a full one (★ IN-GAME PROVEN 2026-06-13: a Goblin re-skinned to the Fang
|
|
81
|
+
# IDLED as a quadruped Fang but ATTACKED Goblin-like). The transplanted Mot[6] DO drive the new model's own idle/
|
|
82
|
+
# damage/death; but the per-ATTACK animation is bound by the donor scene's raw17 btlseq (keyed by Konran@78, the
|
|
83
|
+
# per-type AnmOfsList selector, `btlseq.cs:1150-1151`), which is KEPT -- so the ATTACK plays the TARGET's clip
|
|
84
|
+
# retargeted onto the new mesh (clip-path-by-name, `AnimationFactory.cs:60`, never crashes). DO NOT add Konran@78
|
|
85
|
+
# or MesCnt@79 (the message-count cursor) to these ranges -- both are raw17/text linkage tied to the TARGET scene.
|
|
86
|
+
# Flags@48 (incl. die_atk/die_dmg, which pick the death-anim path) also stays the target's -- it carries gameplay bits.
|
|
87
|
+
_RESKIN_RANGES = (
|
|
88
|
+
(28, 20), # Radius(2) Geo(2) Mot[6](12) Mesh[2](4) -- the model + its 6 idle/damage/death anim ids
|
|
89
|
+
(72, 6), # Bone[4](4) DieSfx(2)
|
|
90
|
+
(80, 18), # IconBone[6] IconY[6] IconZ[6] -- status-icon attach (bone indices are model-specific)
|
|
91
|
+
(98, 6), # StartSfx(2) ShadowX(2) ShadowZ(2)
|
|
92
|
+
(104, 1), # ShadowBone (NOT WinCard@105 -- a reward)
|
|
93
|
+
(106, 4), # ShadowOfsX(2) ShadowOfsZ(2)
|
|
94
|
+
(110, 1), # ShadowBone2 (NOT Pad0@111)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SceneEditError(ValueError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_counts(raw16: bytes):
|
|
103
|
+
"""(PatCount, TypCount, AtkCount) from the header."""
|
|
104
|
+
if len(raw16) < _HDR:
|
|
105
|
+
raise SceneEditError("raw16 too short")
|
|
106
|
+
return raw16[1], raw16[2], raw16[3]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _mon_base(patcount: int) -> int:
|
|
110
|
+
return _HDR + _PAT * patcount
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def mon_block(raw16: bytes, type_no: int) -> bytes:
|
|
114
|
+
"""The verbatim 116-byte SB2_MON_PARM block for enemy ``type_no`` -- the SOURCE of a re-skin transplant
|
|
115
|
+
(read from a real donor scene). Raises if ``type_no`` is out of range or the block is truncated."""
|
|
116
|
+
patcount, typcount, _atk = parse_counts(raw16)
|
|
117
|
+
if not 0 <= type_no < typcount:
|
|
118
|
+
raise SceneEditError(f"donor type {type_no} out of range (the donor scene has {typcount} type(s))")
|
|
119
|
+
base = _mon_base(patcount) + _MON * type_no
|
|
120
|
+
if len(raw16) < base + _MON:
|
|
121
|
+
raise SceneEditError("donor raw16 truncated -- can't read the monster block")
|
|
122
|
+
return bytes(raw16[base:base + _MON])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _apply_reskin_block(b: bytearray, mon_off: int, donor_block: bytes, slot=None) -> None:
|
|
126
|
+
"""Copy the :data:`_RESKIN_RANGES` (model + display fields) from a real donor's 116-byte monster block
|
|
127
|
+
into the target type's block at ``mon_off`` -- the model swap. The gameplay fields are left untouched."""
|
|
128
|
+
if len(donor_block) != _MON:
|
|
129
|
+
where = f"slot {slot}: " if slot is not None else ""
|
|
130
|
+
raise SceneEditError(f"{where}re-skin donor block is {len(donor_block)} bytes, expected {_MON}")
|
|
131
|
+
for off, ln in _RESKIN_RANGES:
|
|
132
|
+
b[mon_off + off:mon_off + off + ln] = donor_block[off:off + ln]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def apply_scene_edits(raw16: bytes, scene: dict) -> tuple[bytes, list[str]]:
|
|
136
|
+
"""Patch ``raw16`` with a battle.toml ``[scene]`` dict. Returns (patched_bytes, warnings).
|
|
137
|
+
|
|
138
|
+
SPAWN COMPOSITION (``monster_count`` set) re-composes a DETERMINISTIC fight: it writes the SAME
|
|
139
|
+
composition (count + per-slot type/placement) to EVERY pattern, so whichever pattern the engine rolls
|
|
140
|
+
yields the user's fight -- and ``build.py`` re-authors the battle eb's Main_Init to match (one enemy-AI
|
|
141
|
+
object per slot), which is what lets a mint exceed the donor's natural enemy count (up to the engine's
|
|
142
|
+
hard cap of 4) using the scene's existing types. Without ``monster_count`` it only TUNES the one
|
|
143
|
+
``pattern`` (positions/stats/rewards/camera), leaving the composition + the eb untouched.
|
|
144
|
+
"""
|
|
145
|
+
patcount, typcount, _atk = parse_counts(raw16)
|
|
146
|
+
b = bytearray(raw16)
|
|
147
|
+
warnings: list[str] = []
|
|
148
|
+
enemies = scene.get("enemy", [])
|
|
149
|
+
|
|
150
|
+
if "flags" in scene: # [scene] ENCOUNTER RULES -> the header Flags u16
|
|
151
|
+
cur = struct.unpack_from("<H", b, _SCENE_FLAGS_OFF)[0]
|
|
152
|
+
struct.pack_into("<H", b, _SCENE_FLAGS_OFF, _encode_scene_flags(scene["flags"], cur))
|
|
153
|
+
|
|
154
|
+
cam = None
|
|
155
|
+
if "camera" in scene:
|
|
156
|
+
cam = int(scene["camera"])
|
|
157
|
+
if not 0 <= cam <= 255:
|
|
158
|
+
raise SceneEditError(f"[scene] camera {cam} out of range (0-2 = a fixed PSX pose, >=3 random)")
|
|
159
|
+
mc = None
|
|
160
|
+
if "monster_count" in scene:
|
|
161
|
+
mc = int(scene["monster_count"])
|
|
162
|
+
if not 1 <= mc <= 4:
|
|
163
|
+
raise SceneEditError(f"[scene] monster_count {mc} out of range (1-4; engine hard cap)")
|
|
164
|
+
ap = None
|
|
165
|
+
if "ap" in scene:
|
|
166
|
+
ap = int(scene["ap"])
|
|
167
|
+
if not 0 <= ap <= 0xFFFFFFFF:
|
|
168
|
+
raise SceneEditError(f"[scene] ap {ap} out of range (0-{0xFFFFFFFF})")
|
|
169
|
+
|
|
170
|
+
# spawn composition -> apply to ALL patterns (uniform/deterministic); else tune the one selected pattern
|
|
171
|
+
if mc is not None:
|
|
172
|
+
pats = list(range(patcount))
|
|
173
|
+
else:
|
|
174
|
+
p = int(scene.get("pattern", 0))
|
|
175
|
+
if not 0 <= p < patcount:
|
|
176
|
+
raise SceneEditError(f"[scene] pattern {p} out of range (this scene has {patcount} pattern(s))")
|
|
177
|
+
pats = [p]
|
|
178
|
+
|
|
179
|
+
mon_base = _mon_base(patcount)
|
|
180
|
+
for pat in pats:
|
|
181
|
+
pat_off = _HDR + _PAT * pat
|
|
182
|
+
if cam is not None:
|
|
183
|
+
b[pat_off + 2] = cam
|
|
184
|
+
if mc is not None:
|
|
185
|
+
b[pat_off + 1] = mc
|
|
186
|
+
for e in enemies:
|
|
187
|
+
_edit_placement(b, pat_off, e, typcount)
|
|
188
|
+
count = b[pat_off + 1] # every ACTIVE slot must be a valid, hittable type
|
|
189
|
+
for s in range(count):
|
|
190
|
+
po = pat_off + 8 + _PUT * s
|
|
191
|
+
if b[po] >= typcount:
|
|
192
|
+
raise SceneEditError(f"active slot {s} (monster_count {count}) has enemy type {b[po]} >= "
|
|
193
|
+
f"TypCount {typcount}; give it a 'type' of 0-{typcount - 1}")
|
|
194
|
+
if not (b[po + 1] & _FLG_TARGETABLE):
|
|
195
|
+
raise SceneEditError(f"active slot {s} (monster_count {count}) is not targetable -- set its "
|
|
196
|
+
f"'type' so it becomes a normal attackable enemy (else the fight can't end)")
|
|
197
|
+
|
|
198
|
+
# the AP reward is per-PATTERN (the gameplay-effective AP, awarded whole) -> write it to EVERY pattern so
|
|
199
|
+
# whichever formation the engine rolls gives the authored AP.
|
|
200
|
+
if ap is not None:
|
|
201
|
+
for pat in range(patcount):
|
|
202
|
+
struct.pack_into("<I", b, _HDR + _PAT * pat + 4, ap)
|
|
203
|
+
|
|
204
|
+
# stats / rewards are per TYPE (one shared block; same-type slots share it) -> apply once (slot types are
|
|
205
|
+
# uniform across patterns, so resolve each enemy's type from the representative pattern).
|
|
206
|
+
rep = _HDR + _PAT * pats[0] + 8
|
|
207
|
+
tuned_types: dict[int, int] = {}
|
|
208
|
+
for e in enemies:
|
|
209
|
+
slot = int(e["slot"])
|
|
210
|
+
stat_keys = [k for k in e if k in _MON_FIELDS or k in _MON_ELEM_FIELDS
|
|
211
|
+
or k in _MON_STATUS_FIELDS or k in ("drop", "steal", "flags")]
|
|
212
|
+
reskin_block = e.get("_reskin_block") # a resolved real-donor block (build injects it)
|
|
213
|
+
if not stat_keys and reskin_block is None:
|
|
214
|
+
continue
|
|
215
|
+
type_no = b[rep + _PUT * slot]
|
|
216
|
+
if type_no in tuned_types and tuned_types[type_no] != slot:
|
|
217
|
+
warnings.append(f"slots {tuned_types[type_no]} and {slot} share enemy type {type_no}; "
|
|
218
|
+
f"their stats/model are the SAME data -- slot {slot} wins")
|
|
219
|
+
tuned_types.setdefault(type_no, slot)
|
|
220
|
+
if type_no >= typcount:
|
|
221
|
+
raise SceneEditError(f"slot {slot} references type {type_no} >= TypCount {typcount}")
|
|
222
|
+
mon_off = mon_base + _MON * type_no
|
|
223
|
+
if reskin_block is not None: # re-skin: transplant the donor's model+display block
|
|
224
|
+
_apply_reskin_block(b, mon_off, reskin_block, slot)
|
|
225
|
+
for k in stat_keys:
|
|
226
|
+
if k in _MON_FIELDS:
|
|
227
|
+
off, fmt = _MON_FIELDS[k]
|
|
228
|
+
v = int(e[k])
|
|
229
|
+
if not 0 <= v <= _MON_INT_MAX[fmt]:
|
|
230
|
+
raise SceneEditError(f"slot {slot} {k}={v} out of range (0-{_MON_INT_MAX[fmt]})")
|
|
231
|
+
struct.pack_into(fmt, b, mon_off + off, v)
|
|
232
|
+
elif k in _MON_ELEM_FIELDS: # null/absorb/half/weak: a 1-byte element bitmask
|
|
233
|
+
try:
|
|
234
|
+
v = battlecsv.encode_elements(e[k])
|
|
235
|
+
except ValueError as ex:
|
|
236
|
+
raise SceneEditError(f"slot {slot} {k}: {ex}")
|
|
237
|
+
if not 0 <= v <= 0xFF:
|
|
238
|
+
raise SceneEditError(f"slot {slot} {k} bitmask {v} out of range (0-255)")
|
|
239
|
+
b[mon_off + _MON_ELEM_FIELDS[k]] = v
|
|
240
|
+
elif k in _MON_STATUS_FIELDS: # resist/auto/initial: a u32 BattleStatus mask
|
|
241
|
+
try:
|
|
242
|
+
v = battlecsv.encode_status(e[k])
|
|
243
|
+
except ValueError as ex:
|
|
244
|
+
raise SceneEditError(f"slot {slot} {k}: {ex}")
|
|
245
|
+
struct.pack_into("<I", b, mon_off + _MON_STATUS_FIELDS[k], v & 0xFFFFFFFF)
|
|
246
|
+
elif k == "flags": # per-enemy MON flags @48 (non_dying_boss / die_*)
|
|
247
|
+
struct.pack_into("<H", b, mon_off + _MON_FLAGS_OFF, _encode_flags(e[k], slot))
|
|
248
|
+
else: # drop / steal: 4 item slots (id/name; 255 = none)
|
|
249
|
+
base = mon_off + (20 if k == "drop" else 24)
|
|
250
|
+
for i, iid in enumerate(_resolve_items(e[k], slot, k)):
|
|
251
|
+
b[base + i] = iid
|
|
252
|
+
return bytes(b), warnings
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _edit_placement(b: bytearray, pat_off: int, e: dict, typcount: int) -> None:
|
|
256
|
+
"""Apply one [[scene.enemy]]'s slot TYPE + placement (pos/y/rot) within a single pattern."""
|
|
257
|
+
if "slot" not in e:
|
|
258
|
+
raise SceneEditError("[[scene.enemy]] needs a 'slot' (0-3, the placement in the pattern)")
|
|
259
|
+
slot = int(e["slot"])
|
|
260
|
+
if not 0 <= slot < 4:
|
|
261
|
+
raise SceneEditError(f"[[scene.enemy]] slot {slot} out of range (0-3)")
|
|
262
|
+
put_off = pat_off + 8 + _PUT * slot
|
|
263
|
+
if "type" in e:
|
|
264
|
+
t = int(e["type"])
|
|
265
|
+
if not 0 <= t < typcount:
|
|
266
|
+
raise SceneEditError(f"slot {slot} type {t} out of range (0-{typcount - 1}); must be an enemy "
|
|
267
|
+
f"type ALREADY in this scene, so the forked raw17/GEO/AI covers it")
|
|
268
|
+
b[put_off] = t
|
|
269
|
+
b[put_off + 1] = _FLG_TARGETABLE # normal, targetable, single-part enemy
|
|
270
|
+
# GROUND it: default an activated slot's height to slot 0's Ypos (a real on-ground enemy). Explicit y wins.
|
|
271
|
+
struct.pack_into("<h", b, put_off + 6, struct.unpack_from("<h", b, pat_off + 8 + 6)[0])
|
|
272
|
+
if "pos" in e:
|
|
273
|
+
pos = list(e["pos"])
|
|
274
|
+
if len(pos) not in (2, 3):
|
|
275
|
+
raise SceneEditError(f"[[scene.enemy]] slot {slot}: pos must be [x, z] or [x, y, z]")
|
|
276
|
+
struct.pack_into("<h", b, put_off + 4, _clamp_i16(int(pos[0]))) # Xpos
|
|
277
|
+
struct.pack_into("<h", b, put_off + 8, _clamp_i16(int(pos[-1]))) # Zpos
|
|
278
|
+
if len(pos) == 3:
|
|
279
|
+
struct.pack_into("<h", b, put_off + 6, _clamp_i16(int(pos[1]))) # Ypos (height)
|
|
280
|
+
if "y" in e:
|
|
281
|
+
struct.pack_into("<h", b, put_off + 6, _clamp_i16(int(e["y"])))
|
|
282
|
+
if "rot" in e:
|
|
283
|
+
struct.pack_into("<h", b, put_off + 10, _clamp_i16(int(e["rot"])))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _encode_flags(value, slot) -> int:
|
|
287
|
+
"""A ``[[scene.enemy]] flags`` value -> a u16. Accepts a list of NAMES (``_MON_FLAG_NAMES``, OR'd), a single
|
|
288
|
+
name, or a raw int (passes the unnamed high bits through to the enemy's AI)."""
|
|
289
|
+
if isinstance(value, bool):
|
|
290
|
+
raise SceneEditError(f"slot {slot} flags can't be a boolean")
|
|
291
|
+
if isinstance(value, int):
|
|
292
|
+
if not 0 <= value <= 0xFFFF:
|
|
293
|
+
raise SceneEditError(f"slot {slot} flags {value} out of range (0-65535)")
|
|
294
|
+
return value
|
|
295
|
+
names = [value] if isinstance(value, str) else value
|
|
296
|
+
if not isinstance(names, list):
|
|
297
|
+
raise SceneEditError(f"slot {slot} flags must be a name / list of names {sorted(_MON_FLAG_NAMES)} "
|
|
298
|
+
f"or a raw int")
|
|
299
|
+
if names and all(isinstance(n, int) and not isinstance(n, bool) for n in names):
|
|
300
|
+
return _or_raw_bits(names, lambda n: f"slot {slot} flags {n} out of range (0-65535)") # the [9] round-trip of `flags = 9`
|
|
301
|
+
bits = 0
|
|
302
|
+
for nm in names:
|
|
303
|
+
key = str(nm).strip().lower()
|
|
304
|
+
if key not in _MON_FLAG_NAMES:
|
|
305
|
+
raise SceneEditError(f"slot {slot} unknown enemy flag {nm!r}; known: {sorted(_MON_FLAG_NAMES)} "
|
|
306
|
+
f"(or pass a raw int)")
|
|
307
|
+
bits |= _MON_FLAG_NAMES[key]
|
|
308
|
+
return bits
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _or_raw_bits(ints, err) -> int:
|
|
312
|
+
"""OR a list of raw u16 ints into one flag word (the GUI round-trip of a raw-int `flags = N`: a STRLIST
|
|
313
|
+
re-parses `N` to the one-int list `[N]`, which must mean the same raw word, not a flag NAMED `N`)."""
|
|
314
|
+
word = 0
|
|
315
|
+
for n in ints:
|
|
316
|
+
if not 0 <= n <= 0xFFFF:
|
|
317
|
+
raise SceneEditError(err(n))
|
|
318
|
+
word |= n
|
|
319
|
+
return word
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _encode_scene_flags(value, current: int) -> int:
|
|
323
|
+
"""A ``[scene] flags`` value -> the new header Flags u16. A list of NAMES (``_SCENE_FLAG_NAMES``) CLEARS the
|
|
324
|
+
4 known bits in ``current`` then ORs the named ones (preserving any other header bit); a raw int REPLACES the
|
|
325
|
+
whole word. The encounter RULES: preemptive / back_attack / no_exp / no_escape (can't-flee)."""
|
|
326
|
+
if isinstance(value, bool):
|
|
327
|
+
raise SceneEditError("[scene] flags can't be a boolean (use a name list or a raw int)")
|
|
328
|
+
if isinstance(value, int):
|
|
329
|
+
if not 0 <= value <= 0xFFFF:
|
|
330
|
+
raise SceneEditError(f"[scene] flags {value} out of range (0-65535)")
|
|
331
|
+
return value
|
|
332
|
+
names = [value] if isinstance(value, str) else value
|
|
333
|
+
if not isinstance(names, list):
|
|
334
|
+
raise SceneEditError(f"[scene] flags must be a name / list of names {sorted(_SCENE_FLAG_NAMES)} or a raw int")
|
|
335
|
+
if names and all(isinstance(n, int) and not isinstance(n, bool) for n in names):
|
|
336
|
+
return _or_raw_bits(names, lambda n: f"[scene] flags {n} out of range (0-65535)") # the [9] round-trip of `flags = 9`
|
|
337
|
+
bits = current & ~_SCENE_FLAG_MASK
|
|
338
|
+
for nm in names:
|
|
339
|
+
key = str(nm).strip().lower()
|
|
340
|
+
if key not in _SCENE_FLAG_NAMES:
|
|
341
|
+
raise SceneEditError(f"[scene] unknown flag {nm!r}; known: {sorted(_SCENE_FLAG_NAMES)} (or a raw int)")
|
|
342
|
+
bits |= _SCENE_FLAG_NAMES[key]
|
|
343
|
+
return bits
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _clamp_i16(v: int) -> int:
|
|
347
|
+
return max(-32768, min(32767, v))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _resolve_items(value, slot: int, key: str) -> list[int]:
|
|
351
|
+
if not isinstance(value, list) or len(value) != 4:
|
|
352
|
+
raise SceneEditError(f"slot {slot} {key} must be a list of exactly 4 items "
|
|
353
|
+
f"(name/id; use \"none\" or 255 for an empty slot)")
|
|
354
|
+
out = []
|
|
355
|
+
for it in value:
|
|
356
|
+
if isinstance(it, str) and it.strip().lower() in ("none", "", "-"):
|
|
357
|
+
out.append(255)
|
|
358
|
+
else:
|
|
359
|
+
out.append(items.resolve(it))
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def validate_scene(raw16: bytes, scene: dict) -> list[str]:
|
|
364
|
+
"""Offline problems (empty => OK). Re-runs the edit on a copy to surface any error as a message."""
|
|
365
|
+
try:
|
|
366
|
+
_, warnings = apply_scene_edits(raw16, scene)
|
|
367
|
+
return []
|
|
368
|
+
except (SceneEditError, ValueError) as e:
|
|
369
|
+
return [str(e)]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Offline BALANCE lint for a battle scene -- the "I can't see the game" superpower for battle tuning.
|
|
2
|
+
|
|
3
|
+
Reads a parsed :class:`~ff9mapkit.battle.scene_codec.Scene` (every enemy's stats / affinities / rewards) and
|
|
4
|
+
flags design problems an actual playthrough would otherwise reveal. The bar is TRUST: it must be quiet on
|
|
5
|
+
well-designed vanilla fights and loud only on real problems -- so every check here was validated against a
|
|
6
|
+
sweep of all ~562 shipped scenes to confirm it does not cry wolf (a noisy lint trains the user to ignore it).
|
|
7
|
+
|
|
8
|
+
Checks:
|
|
9
|
+
* no_reward (warn) -- a real fight that yields NOTHING (0 EXP / gil / AP / no drops or steals).
|
|
10
|
+
* bad_item (warn) -- a drop/steal references an item id that isn't a real item.
|
|
11
|
+
* status_immune (info)-- the enemy resists EVERY common offensive status -> status abilities are dead choices.
|
|
12
|
+
* element_wall (info) -- the enemy resists/absorbs/halves >=7/8 elements -> almost no element does full damage.
|
|
13
|
+
* phys_wall / mag_wall (info) -- a defence in the weapon-power band (>=50; real enemies cap ~24, FF9 weapon
|
|
14
|
+
power caps ~108) -> attacks are heavily reduced (FF9 defence is SUBTRACTIVE).
|
|
15
|
+
* level5 (info) -- the enemy's Level is a multiple of 5 AND it isn't Death-immune -> LV5 Death one-shots it.
|
|
16
|
+
|
|
17
|
+
Severity: ``warn`` = a likely real problem; ``info`` = design awareness (an intentional choice may be fine).
|
|
18
|
+
|
|
19
|
+
NOT done here (deferred -- the kit has no live party model): a turns-to-kill / time-to-kill-a-PC estimator and
|
|
20
|
+
an economy-curve-vs-zone check. FF9 physical damage is `Attack(~Strength) * max(1, weaponPower - defence)` with
|
|
21
|
+
3-4 attackers/round, so a single-attacker turns estimate is off by ~1-2 orders of magnitude and flags ~half the
|
|
22
|
+
bestiary as a "sponge" -- it carries no signal without a real party model, so it is intentionally omitted.
|
|
23
|
+
|
|
24
|
+
Pure + offline; reads only the parsed scene (the offensive-status mask is built at import from committed tables).
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from .. import items
|
|
31
|
+
from . import battlecsv
|
|
32
|
+
|
|
33
|
+
# The common statuses a PLAYER tries to inflict -- if an enemy resists ALL of these, status-disabling
|
|
34
|
+
# abilities are dead choices against it. (Buffs like Haste/Protect and odd ones are excluded on purpose.)
|
|
35
|
+
_OFFENSIVE_STATUSES = ["Petrify", "Venom", "Silence", "Blind", "Death", "Confuse", "Berserk", "Stop",
|
|
36
|
+
"Poison", "Sleep", "Slow", "Mini"]
|
|
37
|
+
_OFFENSIVE_MASK = battlecsv.encode_status(_OFFENSIVE_STATUSES)
|
|
38
|
+
_DEATH_MASK = battlecsv.encode_status(["Death"])
|
|
39
|
+
|
|
40
|
+
_N_ELEMENTS = 8
|
|
41
|
+
# A subtractive-defence "wall": FF9 weapon power runs ~40-108 (Excalibur II caps at 108) and real shipped
|
|
42
|
+
# enemies cap at phys_def ~24, so a defence at/above this band is an AUTHORED wall that floors normal attacks.
|
|
43
|
+
_WALL_DEF = 50
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Finding:
|
|
48
|
+
severity: str # "warn" | "info"
|
|
49
|
+
code: str # a short stable id (e.g. "status_immune")
|
|
50
|
+
message: str
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
return f"[{self.severity}] {self.message}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def lint_scene(scene) -> list[Finding]:
|
|
57
|
+
"""Return balance :class:`Finding`s for a parsed Scene (empty => nothing flagged)."""
|
|
58
|
+
out: list[Finding] = []
|
|
59
|
+
combat = [m for m in scene.monsters if m.hp > 1] # hp<=1 => a placeholder / multipart / non-fightable type
|
|
60
|
+
|
|
61
|
+
# --- scene-level: does the whole fight reward anything? ---
|
|
62
|
+
if combat:
|
|
63
|
+
total_exp = sum(m.exp for m in combat)
|
|
64
|
+
total_gil = sum(m.gil for m in combat)
|
|
65
|
+
max_ap = max((p.ap for p in scene.patterns), default=0)
|
|
66
|
+
has_item = any(i != 255 for m in combat for i in (*m.drop, *m.steal))
|
|
67
|
+
if total_exp == 0 and total_gil == 0 and max_ap == 0 and not has_item:
|
|
68
|
+
out.append(Finding("warn", "no_reward",
|
|
69
|
+
"this battle yields NOTHING: 0 EXP, 0 gil, 0 AP, no drops/steals"))
|
|
70
|
+
|
|
71
|
+
# --- per-enemy-type ---
|
|
72
|
+
for t, m in enumerate(scene.monsters):
|
|
73
|
+
if m.hp <= 1:
|
|
74
|
+
continue
|
|
75
|
+
lbl = f"enemy type {t} (Lv {m.level}, HP {m.hp})"
|
|
76
|
+
|
|
77
|
+
# counter-play: an enemy that resists/absorbs/halves nearly every element (almost no element exploits it)
|
|
78
|
+
resisted = set(battlecsv.decode_elements(m.guard_element)) \
|
|
79
|
+
| set(battlecsv.decode_elements(m.absorb_element)) | set(battlecsv.decode_elements(m.half_element))
|
|
80
|
+
if len(resisted) >= _N_ELEMENTS - 1:
|
|
81
|
+
out.append(Finding("info", "element_wall",
|
|
82
|
+
f"{lbl}: resists/absorbs/halves {len(resisted)}/{_N_ELEMENTS} elements -- almost "
|
|
83
|
+
f"no element does full damage"))
|
|
84
|
+
|
|
85
|
+
# counter-play: immune to every offensive status -> status abilities are dead choices
|
|
86
|
+
if (m.resist_status & _OFFENSIVE_MASK) == _OFFENSIVE_MASK:
|
|
87
|
+
out.append(Finding("info", "status_immune",
|
|
88
|
+
f"{lbl}: immune to every common offensive status -- status abilities are dead "
|
|
89
|
+
f"choices here"))
|
|
90
|
+
|
|
91
|
+
# LV5 Death exploit -- only when it actually lands (the level is a multiple of 5 AND Death isn't resisted)
|
|
92
|
+
if m.level > 0 and m.level % 5 == 0 and not ((m.resist_status | m.auto_status) & _DEATH_MASK):
|
|
93
|
+
out.append(Finding("info", "level5",
|
|
94
|
+
f"{lbl}: level {m.level} is a multiple of 5 and not Death-immune -- LV5 Death "
|
|
95
|
+
f"one-shots it"))
|
|
96
|
+
|
|
97
|
+
# rewards: an item id that isn't a real item
|
|
98
|
+
for kind, ids in (("drop", m.drop), ("steal", m.steal)):
|
|
99
|
+
for i in ids:
|
|
100
|
+
if i != 255 and items.name_of(i) is None:
|
|
101
|
+
out.append(Finding("warn", "bad_item",
|
|
102
|
+
f"{lbl}: {kind} references item id {i}, which is not a known item"))
|
|
103
|
+
|
|
104
|
+
# subtractive-defence walls (a defence in the weapon-power band floors normal attacks)
|
|
105
|
+
if m.phys_def >= _WALL_DEF:
|
|
106
|
+
out.append(Finding("info", "phys_wall",
|
|
107
|
+
f"{lbl}: phys_def {m.phys_def} -- physical attacks heavily reduced (FF9 weapon "
|
|
108
|
+
f"power ~40-108; subtractive defence)"))
|
|
109
|
+
if m.mag_def >= _WALL_DEF:
|
|
110
|
+
out.append(Finding("info", "mag_wall",
|
|
111
|
+
f"{lbl}: mag_def {m.mag_def} -- magical attacks heavily reduced"))
|
|
112
|
+
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def format_findings(findings, *, indent: str = " ") -> str:
|
|
117
|
+
"""A human-readable block (empty findings => an OK line)."""
|
|
118
|
+
if not findings:
|
|
119
|
+
return f"{indent}lint: no balance problems flagged."
|
|
120
|
+
warns = [f for f in findings if f.severity == "warn"]
|
|
121
|
+
infos = [f for f in findings if f.severity == "info"]
|
|
122
|
+
lines = [f"{indent}lint: {len(warns)} warning(s), {len(infos)} note(s)"]
|
|
123
|
+
for f in warns + infos:
|
|
124
|
+
lines.append(f"{indent} {f}")
|
|
125
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The raw17 sequence ASSEMBLER -- the inverse of :mod:`seqdis` (the analog of ``eb/cmdasm`` for enemy AI). Turns
|
|
2
|
+
a textual sequence source into the instruction bytes the engine interprets, so a NET-NEW attack choreography can
|
|
3
|
+
be authored from scratch (then spliced by :mod:`seqauthor` with the length-changing :func:`seqcodec.serialize_repacked`
|
|
4
|
+
repack). A sequence has NO control flow (no jumps/labels) -- it is a flat list of ``Name(field=value, ...)``
|
|
5
|
+
instructions ending in a terminator -- so the assembler is a direct line-by-line transcription, with each operand
|
|
6
|
+
range-checked against its field width/signedness.
|
|
7
|
+
|
|
8
|
+
Round-trip INVARIANT (proven over the 562-corpus): ``assemble(to_source(instrs)) == instrs`` and
|
|
9
|
+
``to_source(parse_instr(b))`` re-assembles to the original bytes -- the assembler and the codec decoder are exact
|
|
10
|
+
mutual inverses. Only the open-source opcode NAMES are committed.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re as _re
|
|
15
|
+
|
|
16
|
+
from . import seqcodec as _sc
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SeqAsmError(ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_NAME_TO_OP = {nm: op for op, (nm, _f) in _sc._OPS.items()}
|
|
24
|
+
_LINE_RE = _re.compile(r"^\s*(?:\[\d+\]\s*)?([A-Za-z_]\w*)\s*(?:\(([^()]*)\))?\s*$") # tolerates a [offset] prefix
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_int(tok: str) -> int:
|
|
28
|
+
tok = tok.strip()
|
|
29
|
+
try:
|
|
30
|
+
return int(tok, 0) # accepts 10, 0x0a, -5
|
|
31
|
+
except ValueError:
|
|
32
|
+
raise SeqAsmError(f"operand value {tok!r} is not an integer")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _field_range(width: int, signed: bool):
|
|
36
|
+
return (-(1 << (8 * width - 1)), (1 << (8 * width - 1)) - 1) if signed else (0, (1 << (8 * width)) - 1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def assemble_instr_text(line: str) -> _sc.Instr:
|
|
40
|
+
"""One ``Name(field=value, ...)`` line -> an :class:`seqcodec.Instr` (offset 0). A leading ``[offset]`` and a
|
|
41
|
+
trailing ``# comment`` are tolerated (so a disassembly line pastes back in). Every real operand must be given
|
|
42
|
+
(the 0x19 ``Sfx`` ``_pad`` hole defaults to 0); each is range-checked against its field."""
|
|
43
|
+
line = line.split("#", 1)[0].strip()
|
|
44
|
+
m = _LINE_RE.match(line)
|
|
45
|
+
if not m:
|
|
46
|
+
raise SeqAsmError(f"cannot parse instruction {line!r} (expected `Name(field=value, ...)`)")
|
|
47
|
+
name, argstr = m.group(1), (m.group(2) or "").strip()
|
|
48
|
+
op = _NAME_TO_OP.get(name)
|
|
49
|
+
if op is None:
|
|
50
|
+
raise SeqAsmError(f"unknown opcode name {name!r} (see `battle-seq` / the seqcodec opcode table)")
|
|
51
|
+
fields = _sc._OPS[op][1]
|
|
52
|
+
provided: dict = {}
|
|
53
|
+
if argstr:
|
|
54
|
+
for part in argstr.split(","):
|
|
55
|
+
if "=" not in part:
|
|
56
|
+
raise SeqAsmError(f"{name}: operand {part.strip()!r} must be `field=value`")
|
|
57
|
+
k, v = part.split("=", 1)
|
|
58
|
+
key = k.strip()
|
|
59
|
+
if key in provided:
|
|
60
|
+
raise SeqAsmError(f"{name}: operand {key!r} given more than once")
|
|
61
|
+
provided[key] = _parse_int(v)
|
|
62
|
+
fieldnames = {fn for fn, _o, _w, _s, _k in fields}
|
|
63
|
+
unknown = set(provided) - fieldnames
|
|
64
|
+
if unknown:
|
|
65
|
+
raise SeqAsmError(f"{name}: unknown operand(s) {sorted(unknown)} (fields: {sorted(fieldnames) or 'none'})")
|
|
66
|
+
operands = []
|
|
67
|
+
for fn, _o, w, signed, _k in fields:
|
|
68
|
+
if fn in provided:
|
|
69
|
+
val = provided[fn]
|
|
70
|
+
elif fn == "_pad":
|
|
71
|
+
val = 0 # the discarded Sfx hole -- default 0 (engine ignores it)
|
|
72
|
+
else:
|
|
73
|
+
raise SeqAsmError(f"{name}: missing operand {fn!r}")
|
|
74
|
+
lo, hi = _field_range(w, signed)
|
|
75
|
+
if not lo <= val <= hi:
|
|
76
|
+
raise SeqAsmError(f"{name}.{fn} = {val} is out of range [{lo}, {hi}] "
|
|
77
|
+
f"({w}-byte {'signed' if signed else 'unsigned'})")
|
|
78
|
+
operands.append(val)
|
|
79
|
+
return _sc.Instr(op, 0, operands)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def assemble(source: str) -> list:
|
|
83
|
+
"""A multi-line / ``;``-separated sequence source -> ``[Instr]``. Must end in a terminator (End/FastEnd) and
|
|
84
|
+
contain NO terminator before the end (an unreachable tail is a likely authoring error). Blank lines + ``#``
|
|
85
|
+
comments are ignored. The byte form is ``b"".join(seqcodec.emit_instr(i) for i in assemble(src))``."""
|
|
86
|
+
raw_lines = [ln for chunk in source.replace(";", "\n").splitlines() for ln in [chunk.strip()] if ln]
|
|
87
|
+
instrs = []
|
|
88
|
+
for ln in raw_lines:
|
|
89
|
+
if ln.split("#", 1)[0].strip(): # skip pure-comment / blank lines
|
|
90
|
+
instrs.append(assemble_instr_text(ln))
|
|
91
|
+
if not instrs:
|
|
92
|
+
raise SeqAsmError("empty sequence source (need at least a terminator)")
|
|
93
|
+
if instrs[-1].op not in _sc.TERMINATORS:
|
|
94
|
+
raise SeqAsmError(f"a sequence must end in a terminator (End or FastEnd); got {instrs[-1].name}")
|
|
95
|
+
for i, ins in enumerate(instrs[:-1]):
|
|
96
|
+
if ins.op in _sc.TERMINATORS:
|
|
97
|
+
raise SeqAsmError(f"terminator {ins.name} at instruction {i} is not last -- the tail is unreachable")
|
|
98
|
+
out = assemble_bytes(instrs) # SELF-VERIFY: the bytes re-decode to exactly these instrs
|
|
99
|
+
redec, _end = _sc._decode_body(out, 0, len(out))
|
|
100
|
+
if [(i.op, tuple(i.operands)) for i in redec] != [(i.op, tuple(i.operands)) for i in instrs]:
|
|
101
|
+
raise SeqAsmError("internal: assembled bytes did not re-decode to the source instructions")
|
|
102
|
+
return instrs
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def assemble_fragment(source: str) -> list:
|
|
106
|
+
"""A sequence FRAGMENT (for a mid-body splice via :func:`seqauthor.insert_sequence`) -> ``[Instr]``. Like
|
|
107
|
+
:func:`assemble` but with NO terminator: a fragment is inserted INTO a body, so it must NOT contain End/FastEnd
|
|
108
|
+
(which would truncate the body early)."""
|
|
109
|
+
raw_lines = [ln for chunk in source.replace(";", "\n").splitlines() for ln in [chunk.strip()] if ln]
|
|
110
|
+
instrs = [assemble_instr_text(ln) for ln in raw_lines if ln.split("#", 1)[0].strip()]
|
|
111
|
+
if not instrs:
|
|
112
|
+
raise SeqAsmError("empty fragment source")
|
|
113
|
+
for ins in instrs:
|
|
114
|
+
if ins.op in _sc.TERMINATORS:
|
|
115
|
+
raise SeqAsmError(f"a fragment must not contain a terminator ({ins.name}) -- it is spliced mid-body")
|
|
116
|
+
return instrs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def assemble_bytes(instrs) -> bytes:
|
|
120
|
+
return b"".join(_sc.emit_instr(i) for i in instrs)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def to_source(instrs, *, multiline: bool = True) -> str:
|
|
124
|
+
"""The canonical (round-trippable) source for an instruction list -- includes EVERY operand (incl. the Sfx
|
|
125
|
+
``_pad``), so ``assemble(to_source(x)) == x`` byte-for-byte. (The human ``seqdis`` view hides ``_pad`` + adds
|
|
126
|
+
notes; this is the machine form.)"""
|
|
127
|
+
lines = []
|
|
128
|
+
for ins in instrs:
|
|
129
|
+
args = ", ".join(f"{fn}={val}" for (fn, _o, _w, _s, _k), val in zip(ins.fields, ins.operands))
|
|
130
|
+
lines.append(f"{ins.name}({args})" if args else ins.name)
|
|
131
|
+
return "\n".join(lines) if multiline else "; ".join(lines)
|