# 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(' 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(' 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(' 1e-3: raise SystemExit(f'{layout} {pane} {root_scale}: {have}, expected ±1.0') if pane != 'RootPane': struct.pack_into(' 1e-3: raise SystemExit(f'{member[5:-6]} RootPane {root_scale}: {have}, expected 1.0') struct.pack_into(' 1e-3: raise SystemExit(f'{layout} {pane} {field}: {have}, expected {shipped}') struct.pack_into(' hi - lo: raise SystemExit(f'{anim}: too short for +{offset}') have = struct.unpack_from('= 1e-4: raise SystemExit(f'{anim} +{offset}: {have}, expected {shipped}') struct.pack_into('