Files
Takeya SaitoandClaude Opus 5.5 d3f841a737 Keep the Recall reticle and gauge over their target on wide and tall game resolutions
At a game resolution wider than 16:9 the ability reticle and the Recall progress ring sat between their target and the
middle of the screen (at half the distance on 32:9). Both are moved in code to a point in the 1920x1080 grid on a pane
under the root, so the root's scale k scaled their position too, like the markers in MARKER_ROOT.

Pointer_00 and ModorecoGauge_00 join MARKER_ROOT:
- Pointer_00: the code moves N_App_00. Its children's scales are animated, so k goes on N_App_00 itself (a pane's
  own scale doesn't scale its translate), and on the centred bow and throw pointers N_Arrow_00 / N_WeaponThrow_00.
- ModorecoGauge_00: the code moves N_Trans_00 and sets its scale by distance, so k goes on the ring's pictures. They
  ship mirrored at -1 and are turned only 0 or 180 degrees, so apply() now accepts a shipped scale of -1 and keeps
  its sign.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:43:34 +00:00

297 lines
22 KiB
Python

# Adapted from MacNativeGames games/totk/scripts/ultrawide_layout.py @ 933b6a0 (MIT, Copyright (c) 2026
# MacNativeGames contributors). Used by tools/make_romfs_patches.py --aspect.
"""The game's UI layouts for a canvas of another shape than 16:9 (output_res = 3440x1440 or 1280x800 in totk.ini),
edited in place in the decompressed UI/LayoutArchive/Common.Product.*.blarc (tools/make_romfs_patches.py --aspect).
The game lays its UI out on a 1920x1080 grid and maps that grid onto the canvas, so on a 21:9 canvas every layout comes
out 34% too wide and on a 16:10 one 11% too tall. Here each layout's root pane is scaled back to 16:9 on the stretched
axis (scale x = 16/9 over the aspect when wider, scale y = the aspect over 16/9 when narrower), backgrounds that must
cover the whole screen are stretched back, and the HUD's parts move out to the screen's corners (wider) or its top and
bottom edges (narrower). The pane list and the distances are the ones the community TOTK-Ultrawide mod for emulators
found for 1.0.0 .. 1.2.1 (GPL; only those values are taken, no code; THIRD_PARTY_NOTICES.md). This is an independent
implementation, checked against 1.2.1's Common.Product.110 and 1.4.3's Common.Product.140: every pane is found by name
and every pane field and animation key holds the value the edit expects before it is written.
"""
import struct
# BFLYT pane sections (pan1, pic1, txt1, ...): name at +12 (24 bytes), translate x / y at +44 / +48, scale x / y at +68 / +72.
PANE_MAGICS = (b'pan1', b'pic1', b'txt1', b'wnd1', b'bnd1', b'prt1', b'ali1', b'scr1')
FIELD = {'x': 44, 'y': 48, 'scale_x': 68, 'scale_y': 72}
# Full-screen layouts whose root pane stays as wide as the canvas.
KEEP_ROOT = {'AppMenuBG_00', 'PauseMenuBG_00', 'ShopMenuParasailBG_00', 'ShortCutBG_00', 'TitleMenuBG_00', 'EventFade_00',
'EventFadeWipe_00', 'CameraShutter_00', 'PaAppPictureBookDetail_00', 'AppMenuOverlay_00'}
# Layouts whose markers the game places over a point in the world (the lock-on arrow, the name of the item or character
# in front of Link, enemy life bars and notices, speech balloons): the code moves a pane under the root to the target's
# projected position in the 1920x1080 grid, which spans the whole canvas. Under a scaled root that position would be
# scaled too, so the marker lands short of its target, toward the middle of the screen (at half the distance on 32:9).
# Their root keeps scale 1 and the scale goes one level down instead, to the panes listed here: ones the code does not
# move and no animation scales, so the markers keep their 16:9 proportions.
MARKER_ROOT = {
'AttentionBalloon_00': ('C_Capture_00', 'N_InOut_00'), # N_PosRoot_00 moves; N_InOut_00 animates y and alpha
'AttentionLockOn_00': ('N_Offset_00',), # N_PosRoot_00 moves; N_Offset_00 animates y
'EnemyInfo_00': ('Pa_EnemyLife_00', 'Pa_EnemyNotice_00'), # the parts move; N_EnemyLifeArea_* stay screen-sized
'BalloonMessage_00': (), # the speech balloon part is pooled: its scale goes inside, see BALLOON_PART
# The ability reticle (seen with Recall): N_App_00 moves to the target (a pane's own scale doesn't scale its
# position); its children's scales are animated. The bow and throw pointers stay centred.
'Pointer_00': ('N_App_00', 'N_Arrow_00', 'N_WeaponThrow_00'),
# The Recall gauge ring: N_Trans_00 moves to the object and the code sets its scale by distance, so the scale goes
# on the ring's pictures (mirrored, -1 as shipped; turned 0 or 180 degrees, so a scale on x stays undistorted).
'ModorecoGauge_00': ('P_Smoke_00', 'P_Sh_00') + tuple(f'P_{p}{s}_00' for p in ('Base', 'Glow', 'Main', 'MainLight',
'BandLight') for s in 'LR'),
}
# Speech balloons are pooled: the game clones Pa_BalloonMessage_00 into Pa_BalloonMessage_%02d at run time, and the clones
# don't keep a scale set on the template part pane, so they drew at full canvas width. The scale goes on the part
# layout's top panes instead (their offset scaled with them, so they sit where a scaled root would put them); its
# animations only key alpha, visibility and N_Loop_00's y. (pane, x, y) as shipped.
BALLOON_PART = (('C_CaptureBase_00', 226.0, 20.0), ('N_InOut_00', 30.0, -10.0))
def edits(k, hud_corner):
"""(layout, pane, field, value) for the aspect: k = 16/9 over the aspect (the root's scale), w = how far a 1920-wide
layout's edge moves out in its own units (960 * (1 / k - 1)). OFF moves a pane out of sight."""
w, OFF = 960 * (1 / k - 1), 10000.0
wide = 1 / k
out = [
('AppAlbum_00', 'N_FadeInOut_00', 'scale_x', k), ('AppAlbum_00', 'N_Open_01', 'scale_x', wide),
*[('AppMap_00', f'N_BlurInOut_0{i}', 'scale_x', wide) for i in range(4)],
('AppMap_00', 'N_MiniMap_00', 'x', -270 * (1 - k)),
('AppMenuOverlay_00', 'N_FadeInOut_00', 'scale_x', k), ('AppMenuOverlay_00', 'N_FadeInOut_01', 'scale_x', k),
('AppMenuOverlay_00', 'N_Icon_00', 'scale_x', k), ('AppMenuOverlay_00', 'N_TitleOne_01', 'scale_x', k),
('AppOpenDemoWindow_00', 'P_BG_00', 'scale_x', wide), ('AutoBuilder_00', 'N_BG_00', 'scale_x', wide),
('CameraPointer_00', 'N_InOutScope_00', 'scale_x', wide), ('CameraPointer_00', 'N_InOutScope_00', 'scale_y', wide),
('CameraPointer_00', 'N_Type_01', 'scale_x', k), ('CameraPointer_00', 'Pa_CameraFocus_00', 'scale_x', wide),
('CameraPointer_00', 'S_Scissor_00', 'scale_x', wide), ('CameraPointer_00', 'W_Shadow_01', 'scale_x', wide),
('CameraSystemWindow_00', 'N_AlbumCon_00', 'scale_x', k), ('CameraSystemWindow_00', 'N_InOut_00', 'scale_x', wide),
('CameraSystemWindow_00', 'Pa_BtnL_00', 'scale_x', k), ('CameraSystemWindow_00', 'Pa_BtnR_00', 'scale_x', k),
('CameraSystemWindow_00', 'T_Text_00', 'scale_x', k),
('ChallengeLog_00', 'C_CaptureUp_00', 'x', OFF), ('ChallengeLog_00', 'C_CaptureUp_01', 'x', OFF),
('GameOver_00', 'P_BG_00', 'scale_x', wide),
('LoadingFade_00', 'N_Base_00', 'scale_x', wide), ('LoadingFade_00', 'N_Logo_00', 'x', w),
('LoadingFade_00', 'N_Status_00', 'x', w), ('LoadingFade_00', 'N_Tips_00', 'x', -w),
('LoadingFade_00', 'P_BG_00', 'scale_x', wide), ('LoadingFade_00', 'P_SideBGDeco_00', 'scale_x', k),
('LoadingFade_00', 'Pa_IconPlayer_00', 'scale_x', k), ('LoadingFade_00', 'Pa_LargeDungeonMap_00', 'scale_x', k),
('LoadingFade_00', 'Pa_LargeDungeonMap_01', 'scale_x', k), ('LoadingFade_00', 'Pa_Map_00', 'scale_x', k),
('LoadingFade_00', 'Pa_Map_01', 'scale_x', k),
('MessageLog_01', 'P_Capture_00', 'scale_x', wide), ('OwnedHorseList_00', 'N_BG_00', 'scale_x', wide),
('PaCommonBtnTitle_00', 'B_Hit_00', 'x', OFF), ('PaCommonBtnTitle_00', 'N_Cursor_00', 'x', OFF),
('PaLargeDungeonMap_00', 'P_BG_00', 'scale_x', wide), ('PaMessageBtn_00', 'B_Hit_00', 'x', OFF),
('PaRecipeCardList_00', 'P_BG_00', 'scale_x', wide),
('SaveLoadIcon_00', 'RootPane', 'x', -960 * (1 - k)),
('ShopMenu_00', 'N_BG_00', 'scale_x', wide), ('StaffRoll_00', 'N_BG_00', 'scale_x', wide),
*[e for pane, x, _ in BALLOON_PART for e in (('PaBalloonMessage_00', pane, 'scale_x', k),
('PaBalloonMessage_00', pane, 'x', x * k))],
]
# Menus framed by two side decorations, a header and a background: the frame stays at the screen's edges.
for layout, deco in (('PauseMenuOverlay_00', 'P_Deco{}_00'), ('SystemLoadList_00', 'P_Deco{}_00'),
('SystemOption_00', 'P_Deco{}_00'), ('SystemSaveLoad_00', 'P_Deco{}_00')):
out += [(layout, deco.format('L'), 'scale_x', k), (layout, deco.format('L'), 'x', -(675 + w) * k),
(layout, deco.format('R'), 'scale_x', k), (layout, deco.format('R'), 'x', (737 + w) * k)]
if layout == 'PauseMenuOverlay_00':
out += [(layout, 'P_FooterBase_00', 'scale_x', wide), (layout, 'S_DecoScissor_00', 'scale_x', wide)]
else:
out += [(layout, 'A_Title_00', 'scale_x', k), (layout, 'N_Header_00', 'scale_x', wide),
(layout, 'N_SystemBG_00', 'scale_x', wide)]
out += [('SystemActionGuide_00', 'A_Title_00', 'scale_x', k), ('SystemActionGuide_00', 'N_Header_00', 'scale_x', wide),
('SystemActionGuide_00', 'N_SystemBG_00', 'scale_x', wide),
('SystemActionGuide_00', 'P_DecoL_01', 'scale_x', k), ('SystemActionGuide_00', 'P_DecoL_01', 'x', -(675 + w) * k),
('SystemActionGuide_00', 'P_DecoL_02', 'scale_x', k), ('SystemActionGuide_00', 'P_DecoL_02', 'x', (675 + w) * k)]
if hud_corner: # the HUD in the screen's corners instead of the middle 16:9
right = 960 * (1 - k)
out += [(name, 'RootPane', 'x', right) for name in ('EnvironmentInfo_00', 'EventSkip_00', 'InstantTipsAppMenu_00',
'InstantTipsWithPict_00', 'InstantTips_00', 'NumDisplaySecond_00',
'NumDisplay_00', 'PickUpWin_00')]
out += [('Location_00', 'N_Small_00', 'x', -(388 + w)),
('PlayerStatus3D_00', 'N_ExtraStaminaTarget_01', 'x', -(745 + w)),
('PlayerStatus_00', 'N_DevilDemoTargetC_00', 'x', -(284 + w)),
('PlayerStatus_00', 'N_DevilDemoTargetLT_00', 'x', -(576 + w)),
('PlayerStatus_00', 'N_HeartGaugeTargetLT_00', 'x', -(576 + w)),
('PlayerStatus_00', 'N_HeartPos_00', 'x', -w),
('PlayerStatus_00', 'Pa_CompanionLife_00', 'x', -(840 + w))]
return out
SYSTEM_MENUS = ('SystemLoadList_00', 'SystemOption_00', 'SystemSaveLoad_00')
def tall_edits(k):
"""(layout, pane, field, value) for a canvas narrower than 16:9: k = the aspect over 16/9 (the root's scale y),
h = how far a 1080-high layout's edge moves out in its own units (540 * (1 / k - 1))."""
h, tall = 540 * (1 / k - 1), 1 / k
out = [('AppAlbum_00', 'N_FadeInOut_00', 'scale_y', k), ('AppAlbum_00', 'N_Open_01', 'scale_y', tall),
*[('AppMenuOverlay_00', p, 'scale_y', k) for p in ('N_FadeInOut_00', 'N_FadeInOut_01', 'N_Icon_00',
'N_TitleOne_01')],
('CameraPointer_00', 'N_Type_01', 'scale_y', k), ('CameraSystemWindow_00', 'N_FirstOut_00', 'scale_y', k),
*[('LoadingFade_00', p, 'scale_y', k) for p in ('Pa_IconPlayer_00', 'Pa_LargeDungeonMap_00',
'Pa_LargeDungeonMap_01', 'Pa_Map_00', 'Pa_Map_01')],
*[('AppMap_00', f'N_BlurInOut_0{i}', 'scale_y', tall) for i in range(4)],
*[(layout, 'P_BG_00', 'scale_y', tall) for layout in ('AppOpenDemoWindow_00', 'GameOver_00', 'LoadingFade_00',
'PaLargeDungeonMap_00', 'PaRecipeCardList_00')],
('LoadingFade_00', 'N_Base_00', 'scale_y', tall),
*[(layout, 'N_BG_00', 'scale_y', tall) for layout in ('AutoBuilder_00', 'OwnedHorseList_00', 'ShopMenu_00',
'StaffRoll_00')],
*[('CameraPointer_00', p, 'scale_y', tall) for p in ('N_InOutScope_00', 'Pa_CameraFocus_00', 'S_Scissor_00',
'W_Shadow_01')],
('CameraSystemWindow_00', 'N_InOut_00', 'scale_y', tall), ('MessageLog_01', 'P_Capture_00', 'scale_y', tall),
*[(layout, 'N_SystemBG_00', 'scale_y', tall) for layout in ('SystemActionGuide_00',) + SYSTEM_MENUS],
*[e for pane, _, y in BALLOON_PART for e in (('PaBalloonMessage_00', pane, 'scale_y', k),
('PaBalloonMessage_00', pane, 'y', y * k))]]
# The HUD at the screen's top and bottom edges instead of the middle 16:9
out += [(name, 'RootPane', 'y', 540 * (1 - k)) for name in (
'AppAlbum_00', 'AppPictureBook_00', 'ChallengeLog_00', 'Challenge_00', 'InstantTipsWithPict_00', 'InstantTips_00',
'MiniGameTimer_00', 'NumDisplaySecond_00', 'NumDisplay_00', 'PickUpWin_00', 'PlayerStatus_00', 'Pouch_00',
'ShopMenu_00', 'SystemActionGuide_00') + SYSTEM_MENUS]
out += [(name, 'RootPane', 'y', -540 * (1 - k)) for name in (
'CtrlGuide_00', 'DoCommand_00', 'EnvironmentInfo_00', 'HorseGauge_00', 'MessageDialog_00', 'MessageLog_01',
'PauseMenuOverlay_00', 'SaveLoadIcon_00', 'TreasureFullShortCut_00')]
out += [('AppAlbum_00', 'N_PicLoad_01', 'y', -540 * (1 - k)), ('AppMap_00', 'N_AllMapTime_00', 'y', -h),
('AppMenuOverlay_00', 'A_Align_00', 'y', -(505 + h)), ('AppMenuOverlay_00', 'Pa_GuideLeft_00', 'y', -(505 + h)),
('AppMenuOverlay_00', 'N_TitleTab_00', 'y', 491 + h),
('LoadingFade_00', 'N_Logo_00', 'y', -h), ('LoadingFade_00', 'N_Sage_00', 'y', -477 - 2 * h),
('LoadingFade_00', 'N_Status_00', 'y', h), ('LoadingFade_00', 'N_Tips_00', 'y', -(360 + h)),
('Location_00', 'N_Large_00', 'y', 270 + h), ('Location_00', 'N_Small_00', 'y', -(395 + h)),
('MessageLog_01', 'P_Capture_00', 'y', h),
('PaExtraStaminaGauge_00', 'N_BasePattern_00', 'y', h), ('PaExtraStaminaGauge_00', 'N_GaugeMainPattern_00', 'y', h),
('Pouch_00', 'N_Info_00', 'y', -h), ('Pouch_00', 'Pa_ExtraStamina_00', 'y', -h),
('ShopMenu_00', 'A_AlignGuide_00', 'y', -(477 + h)),
*[(layout, 'N_SystemBG_00', 'y', -h) for layout in ('SystemActionGuide_00',) + SYSTEM_MENUS],
('SystemLoadList_00', 'Pa_Guide_00', 'y', -(474 + h)), ('SystemOption_00', 'A_Guide_00', 'y', -(474 + h))]
return out
# BFLAN animation targets: FLPA (pane SRT) translate y, FLTS (texture SRT) scale y
FLPA_Y, FLTS_SY = 1, 4
def anim_keys(blarc, lo, pane, tag, target):
"""The absolute offsets of each key (frame, value, slope: 12 bytes) of the hermite curves of one pane, animation
tag (b'FLPA', b'FLTS') and target in the BFLAN at blarc[lo:], one list per curve. Found by walking the pai1 section,
so they hold in any version whose animation has that curve."""
if blarc[lo:lo + 4] != b'FLAN': raise SystemExit(f'{lo:#x}: not a BFLAN')
pos, out = lo + struct.unpack_from('<H', blarc, lo + 6)[0], []
for _ in range(struct.unpack_from('<H', blarc, lo + 0x10)[0]):
magic, size = bytes(blarc[pos:pos + 4]), struct.unpack_from('<I', blarc, pos + 4)[0]
if magic == b'pai1':
table = pos + struct.unpack_from('<I', blarc, pos + 16)[0]
for e in range(struct.unpack_from('<H', blarc, pos + 14)[0]):
eo = pos + struct.unpack_from('<I', blarc, table + 4 * e)[0]
if bytes(blarc[eo:eo + 28]).split(b'\0')[0].decode() != pane: continue
for t in range(blarc[eo + 28]):
to = eo + struct.unpack_from('<I', blarc, eo + 32 + 4 * t)[0]
if bytes(blarc[to:to + 4]) != tag: continue
for g in range(blarc[to + 4]):
go = to + struct.unpack_from('<I', blarc, to + 8 + 4 * g)[0]
if blarc[go + 1] != target: continue
if blarc[go + 2] != 2: raise SystemExit(f'{pane}: a {tag.decode()} curve that is not hermite')
keys = go + struct.unpack_from('<I', blarc, go + 8)[0]
out.append([keys + 12 * i for i in range(struct.unpack_from('<H', blarc, go + 4)[0])])
pos += size
return out
def tall_anim(blarc, members, k, changed):
"""The animation keys that hold positions or zooms on the screen's height, for a canvas narrower than 16:9."""
h = 540 * (1 / k - 1)
get = lambda at: struct.unpack_from('<f', blarc, at)[0]
put = lambda at, v: struct.pack_into('<f', blarc, at, v)
def anim(name):
member = f'anim/{name}.bflan'
if member not in members: raise SystemExit(f'{name}: no animation {member} in the archive')
changed.add(member)
return members[member][0]
# The minimap's four cut panes slide in on straight lines from 0 at frame 0 to y -360, the bottom of a 16:9 layout
# (hermite keys: the first one's slope is the end point); the end point moves with the screen's edge.
lo = anim('AppMap_00_MiniMap')
for pane in ('N_Cut_00', 'N_Cut_01', 'N_Cut_02', 'N_Cut_03'):
curves = anim_keys(blarc, lo, pane, b'FLPA', FLPA_Y)
if len(curves) != 1 or len(curves[0]) != 2: raise SystemExit(f'AppMap_00_MiniMap {pane}: {len(curves)} y curves')
k0, k1 = curves[0]
if not (get(k0) == 0 and get(k0 + 4) == 0 and abs(get(k0 + 8) + 360) < 1e-3 and get(k1) == 1 and
abs(get(k1 + 4) + 360) < 1e-3 and get(k1 + 8) == 0):
raise SystemExit(f'AppMap_00_MiniMap {pane}: not a line from 0 to y -360')
put(k0 + 8, -(360 + h)), put(k1 + 4, -(360 + h))
# The large place name of a discovery holds its height (285, then 260) in flat keys; they move to the top edge.
curves = anim_keys(blarc, anim('Location_00_IsDiscovery'), 'N_Large_00', b'FLPA', FLPA_Y)
values = [get(key + 4) for keys in curves for key in keys]
if len(curves) != 1 or values != [285, 285, 260]: raise SystemExit(f'Location_00_IsDiscovery N_Large_00: keys {values}')
for key in curves[0]: put(key + 4, get(key + 4) + h)
# The dragon's tears map icon zooms its picture to 1.08 and 1.28 in flat keys; on the stretched axis by 1 / k more.
curves = anim_keys(blarc, anim('PaMapIconDragonTears_00_Zoom'), 'P_Geoglyph_00', b'FLTS', FLTS_SY)
values = [round(get(key + 4), 4) for keys in curves for key in keys]
if len(curves) != 1 or values != [1.08, 1.28]: raise SystemExit(f'PaMapIconDragonTears_00_Zoom P_Geoglyph_00: keys {values}')
for key in curves[0]: put(key + 4, get(key + 4) / k)
def anim_edits(k, hud_corner):
"""(animation, byte offset, value the game ships, new value): floats inside BFLAN key tables."""
w = 960 * (1 / k - 1)
out = [('PaMapIconDragonTears_00_Zoom', 288, 1.08, 1.08 / k), ('PaMapIconDragonTears_00_Zoom', 300, 1.28, 1.28 / k)]
if hud_corner: # the minimap's slide-in keys end at x 765, the right edge of a 16:9 layout
out += [('AppMap_00_MiniMap', o, 765.0, 765 + w) for o in (872, 880, 1628, 1636, 3264, 3272, 3468, 3476)]
return out
def panes(blarc, lo, hi):
"""pane name -> absolute section offset, first occurrence, for the BFLYT at blarc[lo:hi]."""
assert blarc[lo:lo + 4] == b'FLYT', 'not a BFLYT'
head, count = struct.unpack_from('<H', blarc, lo + 6)[0], struct.unpack_from('<H', blarc, lo + 0x10)[0]
pos, out = lo + head, {}
for _ in range(count):
magic, size = bytes(blarc[pos:pos + 4]), struct.unpack_from('<I', blarc, pos + 4)[0]
if magic in PANE_MAGICS:
out.setdefault(bytes(blarc[pos + 12:pos + 36]).split(b'\0')[0].decode(), pos)
pos += size
assert pos <= hi
return out
def find_pane(found, layout, name):
"""The pane of that exact name (a version that renamed it stops the script)."""
if name not in found: raise SystemExit(f'{layout}: no pane {name}')
return found[name]
def apply(blarc, members, width, height, hud_corner=True):
"""Edits blarc (bytearray) in place for a width x height canvas of another shape than 16:9. Returns the members it
changed. hud_corner (wider canvases): the HUD goes to the screen's corners; a narrower one always has it at the
top and bottom edges."""
wide = width / height > 16 / 9
k = (16 / 9) / (width / height) if wide else (width / height) / (16 / 9)
assert k < 1, 'only for canvases of another shape than 16:9'
root_scale = 'scale_x' if wide else 'scale_y'
changed, layouts = set(), {}
unknown = sorted(n for n in KEEP_ROOT | MARKER_ROOT.keys() if f'blyt/{n}.bflyt' not in members)
if unknown: raise SystemExit(f'KEEP_ROOT or MARKER_ROOT names no layout: {unknown}')
def pane_offset(layout, pane):
member = f'blyt/{layout}.bflyt'
if member not in members: raise SystemExit(f'{layout}: no layout {member} in the archive')
if member not in layouts: layouts[member] = panes(blarc, *members[member])
changed.add(member)
return find_pane(layouts[member], layout, pane)
for member in sorted(m for m in members if m.startswith('blyt/') and m.endswith('.bflyt')):
if member[5:-6] in KEEP_ROOT: continue
if member[5:-6] in MARKER_ROOT: # the root stays at 1, its listed panes take the scale
layout = member[5:-6]
for pane in ('RootPane',) + MARKER_ROOT[layout]:
at = pane_offset(layout, pane) + FIELD[root_scale]
have = struct.unpack_from('<f', blarc, at)[0]
if abs(abs(have) - 1.0) > 1e-3: raise SystemExit(f'{layout} {pane} {root_scale}: {have}, expected ±1.0')
if pane != 'RootPane': struct.pack_into('<f', blarc, at, k * have) # mirrored panes stay mirrored
continue
at = pane_offset(member[5:-6], 'RootPane') + FIELD[root_scale]
have = struct.unpack_from('<f', blarc, at)[0]
if abs(have - 1.0) > 1e-3: raise SystemExit(f'{member[5:-6]} RootPane {root_scale}: {have}, expected 1.0')
struct.pack_into('<f', blarc, at, k)
# Every edit's value at k = 1 is the value the game ships (a scale of 1, the 16:9 position): checked before writing,
# so a version with other numbers stops here instead of getting wrong positions. OFF moves a pane away: not checked.
table = zip(edits(k, hud_corner), edits(1.0, hud_corner)) if wide else zip(tall_edits(k), tall_edits(1.0))
for (layout, pane, field, value), (_, _, _, shipped) in table:
at = pane_offset(layout, pane) + FIELD[field]
have = struct.unpack_from('<f', blarc, at)[0]
if value != 10000.0 and abs(have - shipped) > 1e-3: raise SystemExit(f'{layout} {pane} {field}: {have}, expected {shipped}')
struct.pack_into('<f', blarc, at, value)
if not wide:
tall_anim(blarc, members, k, changed)
return sorted(changed)
for anim, offset, shipped, value in anim_edits(k, hud_corner):
member = f'anim/{anim}.bflan'
if member not in members: raise SystemExit(f'{anim}: no animation {member} in the archive')
lo, hi = members[member]
if offset + 4 > hi - lo: raise SystemExit(f'{anim}: too short for +{offset}')
have = struct.unpack_from('<f', blarc, lo + offset)[0]
if abs(have - shipped) >= 1e-4: raise SystemExit(f'{anim} +{offset}: {have}, expected {shipped}')
struct.pack_into('<f', blarc, lo + offset, value); changed.add(member)
return sorted(changed)