Skip to content

iOS: play upright on the iPhone, a Rotation setting, and a portrait picture in the game's own shape - #2132

Closed
stan69b wants to merge 22 commits into
seedds:mainfrom
stan69b:pr/ios-portrait
Closed

stan69b wants to merge 22 commits into
seedds:mainfrom
stan69b:pr/ios-portrait

Conversation

@stan69b

@stan69b stan69b commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1971 (screen_aspect any); merge that first. Until then this diff also shows #1971's commits; this PR's own commits are the six from 6dfba3b7 to 205fd448.

  • Rotation (Automatic / Landscape / Portrait, DM_ROTATION) on the settings screen and in the in-game menu locks the orientation, so an iPhone can be played upright.

  • Portrait layout (DM_PORTRAIT_LAYOUT, only for games with screen_aspect any):

    • Full screen (the default): the picture fills the screen with the touch controls over it, as in landscape.
    • Picture above controls: the picture fills the space above the controls.

    Either way the game renders that shape, not a 16:9 picture with bars. Other games keep the 16:9 picture above the controls. With a controller and no touch controls shown, the picture takes the whole screen.

  • Upright cap (DM_SCREEN_UPRIGHT_FOV, default 1): an upright picture shows no more height than a square one would, so a phone no longer looks up into parts of the world the game never builds. 0 turns it off.

  • Tested on an iPhone 17 with Ocarina of Time (six runs: Full screen, Picture above controls, landscape), and with the Duo harness on the Simulator (42 tests, 0 failures, including a real rotation). Also test_oot_aspect, test_launcher_settings and test_docs.

  • Regression check: not run yet (regress.py check --games); this PR stays a draft until it has run and the owner has tried portrait on the phone.

  • Open:

    • the HUD keeps its 16:9 arrangement, small, around mid-height;
    • videos stay 16:9 with bars.

seedds added 22 commits November 9, 2026 18:07
…k before the commit, so the commit no longer pages the guest's files in from the GPU driver's path, where the M1 iPad froze for good

With the guest's memory in files (the iOS app's default when the game's pool is larger
than its memory), Ocarina of Time on an M1 iPad (8 GB) froze with sound and no picture,
once in Link's house and once at the title movie. A sysdiagnose spindump during the
freeze: the encode thread in res_commit <- gmem_map, IOGPUFamily -> the VM -> apfs
pagein, blocked by an APFS rwlock owned by a launchd background-I/O thread that stopped
4 ms after it and never ran again. Ordinary page-ins of the same files (700,000 in a run)
never hung.

file_wire (nvn_mtl.m): a shared buffer whose pages are file-backed (vm_region_recurse_64:
external pager) is mlocked before it joins the set, and the mlocks are given back right
after the commit, which holds the pages for the GPU from then on. Anonymous memory and
Metal's own buffers are untouched, so nothing changes with the guest anonymous (every Mac
run unless DM_GUEST_FILE=1). Counted in the 300-frame residency line. t_file_wire checks
it (failed before the fix, passes after).

iPad, the same route that froze twice: alive past frame 12,900, 20 fps in the house (GPU
34-42 ms), headroom 5.3 GB, commits under 2 ms per 300 frames. Mac (M4 Air 16 GB, idle)
with DM_GUEST_FILE=1, a route that stalled at 7-37 s a frame before: played through,
frame time p50 16.7 ms, 3.9 GB wired at most.

memory.md "The residency commit froze over the guest's files"; Ocarina of Time's note.
…share bytes in a placement heap per 32 MB span, as on NVN: Ocarina of Time no longer runs out of GPU memory going outdoors on an M1 iPad

With the render targets' recycled heaps (rh_texture, 7f50222) every live description in a
GPU-only pool is a Metal texture of its own. On the M1 iPad (8 GB, recommended working set
5,461 MB) that reached 5,184 MB resident at the outdoor save load (544 heaps of 1,384 MB
over 1,455 MB of textures), and every command buffer then failed with
kIOGPUCommandBufferCallbackErrorOutOfMemory.

window_texture (nvn_mtl.m): a texture whose storage lies in a CPU_NO_ACCESS pool (not
sparse, not linear, one sample) is placed in a heap standing for a 32 MB span of guest
addresses, at its NVN offset scaled by 9/8 and rounded to Metal's alignment, if its Metal
size ends within its scaled NVN range, so textures overlap here where they overlap on NVN.
A span's heap is made only once it saves memory; a texture across a span's end tries the
half-offset spans; covered bytes are inherited, new ones zero-filled. Placed textures have
no GPU-optimized contents: with lossless compression, another format's writes over the
same bytes hung the iPad's GPU (kIOGPUCommandBufferCallbackErrorHang by Link's house).
Anything that does not fit takes the usual path (image_texture, then rh_texture).

On only where recommendedMaxWorkingSetSize is under 8 GB (window_wants): sharing a target
pool's bytes corrupted the picture on the Mac in PR seedds#1453 (the game keeps live targets in
the part of the full-size scene target its current resolution leaves unused, which only
NVN's layout makes safe), so larger devices keep each target's own layout. 8 GB devices
have 5.3-5.5 GB of working set, a 16 GB M4 Mac 12,124 MB; the separate heaps needed
5.2 GB at the outdoor save load, sharing 4.3 GB. DM_NVN_WINDOW_ALIAS=0/1 forces it, and a
log line says which way it went. The gfx test Makefile runs every test with it off unless
the test turns it on, so an 8 GB Mac runs them as a 16 GB one does.

Also for reading the set (res_top: the first GPU out-of-memory error, DM_CTL restop, and
once when the set passes three quarters of the working set): the set by the source line
that added each object, the resident pools by raw table / binding, by who gave an address
in them (hle_nvn.c passes the kind: mtl_pool_addressed_by) and by pool flags
(mtl_pool_tag), the GPU-made textures by pool, and the spans' counts.

Tests: t_window_alias (new; 25 checks with the spans on, direct and deferred encoding,
Metal validation; 8 fail with them off, the separate allocations), including the 8 GB rule.
t_image_alias, t_rt_recycle, t_alias_takeover, t_upgrade_alias, t_copy_alias,
t_rt_lifetime, t_pool_chunks, t_addr_known, t_file_wire pass.

memory.md "Textures in GPU-only memory took an allocation per description"; rendering.md's
recycled-heap entry links it; env_switches.tsv.
…edicted sleeps, the waits a sleep ended too late for, how late the sleeps woke and the period: Ocarina of Time makes none, so its slow motion on the M1 iPad is GPU time

Before, the power line counted clock waits only for its two top yielders, so a clock wait on any
other thread was invisible. On an M1 iPad, Ocarina of Time ran at game speed 0.62 in one run
and 0.99 in another with DM_YIELD_CLOCK=0, which pointed at 8bdfcb7's predicted sleep.

dm_clock_top (hle_thread.c), printed by the power report every 300 frames (hle_nvn.c) as
"clock waits up to frame <n>": the two threads with most clock-wait yields since the last
report, their predicted sleeps, how many of those waits had already ended by the first poll
after the sleep (slept through), how late the sleeps woke (mean / max) and the learned period.
No clock wait, no line. No behaviour changes: counters only.

On the M1 iPad, 12 minutes from the title through the Deku Tree (upstream main + this
branch, no switches but the device's own): no line at all, so no thread of the game makes a
clock wait and the predicted sleep never runs. Its slow motion there is the GPU: views that
take 54-67 ms of GPU a frame cannot fit the frame step's longest hold, 3 vsyncs (game speed
0.61, then 0.53), where lighter views hold 20 fps at 34-36 ms.

t_clockwait checks the clause (the thread, 40 predicted sleeps, the waits slept through, a
16.68 ms period, and nothing once no clock wait came); t_pollwait, t_preempt, t_condvar,
t_condvar_old, t_sync_wait, t_system_event and t_notify_sched pass. threads.md "Clock waits
sleep to their predicted end".
…ee's entrance with the guest in files and a 3.2 GB residency set; heavier views are GPU-bound

"For an iPhone or iPad build" under Memory: the device (iPad Pro 11-inch 3rd generation, M1,
8 GB, iPadOS 27.0.1), the memory limit and headroom (7.97 GB of room against the 9 GB pool,
so the guest in files; footprint 2.2-2.3 GB, headroom 5.87 GB at its lowest; working set
5,461 MB, residency set 3.2 GB with the GPU-only spans on), fps and GPU time (20.0 fps in
every window of two 32-minute runs at the Deku Tree's entrance, GPU 34-35 ms, game speed
0.97-1.00; heavier views 54-67 ms of GPU, 11-12 fps and game speed 0.53-0.61; the title and
game over screens 61 and 66-74 ms), no clock waits, no switch needed, and what is open: the
heavy views' GPU time, a GPU hang on an earlier build in the draws of PR seedds#1842's M1 hangs,
one kernel data abort, and PR seedds#1906 for an iOS build on 7ffa63c.

Found on: "Textures in GPU-only memory took an allocation per description"; memory.md's two
entries link the new section. The Open list's "no iOS run" line points at it.
…height matches the shape the host asked for gives the picture that shape instead of 16:9

A game that renders a wider or taller picture into larger window textures and crops the window to it (Ocarina of Time: a 2536x1152 crop of 2560x1440 textures at
2.2:1) was presented as a 16:9 picture. present_shape / present_fit (nvn_mtl.m) now take the crop's own ratio when it matches the screen shape mtl_present_screen set
(screen_aspect any), and keep 16:9 for a crop of any other shape. t_present_crop checks a wider crop, a taller crop, a crop of another shape and a 5:4 crop filling a 5:4
canvas on the classic and Metal 4 presents: 120 / 120.
…e while the game runs, from 0.25 to 6, portrait included

game.py: screen_aspect off / on / any (DM_GAME_SCREEN_ASPECT 0 / 1 / 2); on (Breath of the Wild, Animal Crossing: taller than 16:9, set at the start) is unchanged.
loader.c (screen_any_apply / screen_any_write / dm_screen_view_changed): for an any profile, DM_SCREEN_ASPECT unset or auto follows the front end's view, 1.777 or 0
is 16:9, a number fixes the shape. It writes the state page: +0xa0 the ratio (0 = 16:9), +0xa4 / +0xa8 the width and height factors, +0xac a change count, +0xc4 /
+0xc8 the camera's x / y multipliers (wider than 16:9 keeps the vertical angle, between 1:1 and 16:9 the horizontal one; upright takes the scale of the same screen
turned landscape), tells the present the shape, and on a change pushes the display-change notifications (hle_oe.c dm_display_changed) so the game lays out again.
front_mac.m reports the window's shape 300 ms after a resize settles; front_ios.m the app window's in landscape (upright stays 16:9 for now). settings.tsv: the
Picture shape setting for any profiles (Match window / Match screen, or 16:9), auto by default.

Front-ends: new: front_view_aspect() (front.h) answers the game view's width / height and dm_screen_view_changed(aspect) reports a settled resize; a profile with screen_aspect any (Ocarina of Time) follows them under DM_SCREEN_ASPECT auto, and renders 16:9 until a front end provides them (the loader's weak front_view_aspect returns 0).
… for matrices and float pairs in guest memory, and a write watch armed from a probe's register

DM_PROBE_FP prints s0-s7 on probe hits. DM_PROBE_FINDMAT=<s>[,<n>] scans the guest heap for perspective matrices and arms the write watch on the n-th at 16:9;
DM_PROBE_FINDF=<s>,<a>,<b>[,<gap>[,<n>]] scans the heap and the alias region's mapped parts for two floats and arms it on one. DM_PROBE_WATCH=@xn+<offset>[+<length>]
arms the watch on that register plus the offset at the first printed probe hit, for objects whose addresses move between runs (Ocarina of Time's layouts in the alias
region); DM_PROBE_XREG=N lets DM_PROBE_X2 dump xN's memory. The watch carries out CAS and single-lane ST1 itself. Found Ocarina of Time's camera copy and HUD
screen size.
… its output size, window textures and camera projection follow the shape, with no bars and no stretch

screen_aspect any, and four stubs as function-matched others of the frame-step rule (patches.py, found on v131072 main):
- output size (main+0x1087f40, the display settings' size setter): the requested size times the shape's factors, capped at 1.5 times the pixels, each side a multiple
  of 8; the crop, the scene's targets and the dynamic-resolution sizes follow. It copies the camera multipliers the size was made with to state +0xb8 / +0xbc.
- window crop (main+0xe9a860, setCrop): a crop beyond the 2560x1440 window textures grows them; the next acquire re-creates them.
- camera copy (main+0x33a8c0 +0x2ec): the camera named "Main" hands its cached sead::PerspectiveProjection matrix here each frame; rows x / y times the multipliers,
  the aspect beside it, in memory and in the registers the frustum code reads after. set(), tanf and the 16/9 literals do not run in play: found with DM_PROBE_FINDMAT
  and the write watch.
- display update (main+0x6502e0 +0x18): flag bit 17 set when the loader's change count moved, so the game recomputes its size and a resized window takes effect.
Wider than 16:9 keeps the vertical angle, between 1:1 and 16:9 the horizontal one, upright the scale of the same screen turned landscape. test_oot_aspect.py runs the
stubs with unicorn (12 tests). Checked on the Mac at six window shapes resized live (iPhone and iPad upright and landscape, 1700x478, 16:9). The HUD is the next commit.
…instead of a 16:9 layout stretched over the picture

Every eui layout file holds a screen size of 3840x2160 (LayoutResData +0x200, file offset 0x2f8). A screen's init (main+0x773b90) builds its orthographic projection
from it once and keeps it in the screen's uniform block, which the draw copies to the frame's ring each frame (main+0x2d2ba0). Two stubs, function-matched others of the
frame-step rule:
- hud upload (main+0x2d2ba0 +0x4c, after the memcpy): the copy's x / y rows times the HUD's multipliers and the size divided by them, each frame, so a resize takes
  effect at the next one; a capture's block (its own size beside the screen's) is left as it is.
- layout size (main+0x583fd0, the getter): the size the HUD draws with for the game's conversions between the layout space and the screen; the screen's init still
  gets the file's.
The HUD's multipliers are the camera's divided by the largest of them and 1: in landscape the HUD keeps the size it has at 16:9 of the same height or width; upright the
whole 16:9 space fits the width, smaller, nothing cut off. test_oot_aspect.py: 6 more tests (18). Checked on the Mac at six shapes resized live, the pause menu, the
"A Open" prompt and the Kokiri Forest cutscene with its location card.

Left open (game note, Screen shape): eui places panes about the screen's centre and does not anchor them to its edges, so the HUD keeps its 16:9 arrangement in the
middle (in wide shapes the hearts sit at the 16:9 band's edge, upright the HUD sits around mid-height), and full-screen panes (fades, the menu backdrop, 3840x2160)
cover the 16:9 middle only. Moving the edge groups (60x80 nulls at the space's edges) and stretching those panes needs eui's per-frame pane transform, not found yet.
…e screen_aspect key, DM_SCREEN_ASPECT for any profiles, the probe switches, and a reference entry for a camera that caches its projection

- Game note: a Screen shape section (the six stubs, the rule for wider, taller and upright, the HUD, what is open: the HUD's edge groups and full-screen panes,
  upright on iOS, the vertical angle upright) and its line under Found on this game.
- docs/reference/rendering.md: a screen-shape stub on the projection setter changes nothing, the camera caches its matrix (find the per-frame reader with
  DM_PROBE_FINDMAT, DM_PROBE_FINDF and the watch armed from a register); the symptom index regenerated.
- dm/games/README.md: the screen_aspect key (off / on / any). docs/env_switches.tsv: DM_SCREEN_ASPECT's any range and auto, DM_PROBE_FINDMAT, DM_PROBE_FINDF,
  DM_PROBE_XREG and DM_PROBE_WATCH's heap+ and @xn forms. docs/settings.md: Picture shape for any profiles.
- docs/agents/game-patches.md regenerated: Ocarina of Time's screen-shape and HUD stubs are function-matched (BINDING rows in its patches.py), and Animal Crossing's
  row names them as the model. docs_patches.py's dry run puts a rule's other functions 4 MB apart instead of 16 MB: with 16 MB the ninth (Ocarina's layout size)
  landed out of a branch's reach of the patch area.
…ecord is unwired at once instead of the list being lost and written through a null pointer

clang-tidy (bugprone-suspicious-realloc-usage) on 29fa5b0's g_fwire = realloc(g_fwire, ...). t_file_wire: 10 / 10.
…the GPU never reads shader code, so nothing makes those pools resident and wires their pages for the table's sake (Ocarina of Time on the iPhone: 223 pools, 317 MB)

nvnMemoryPoolGetBufferAddress on a pool with NVN_MEMORY_POOL_FLAGS_SHADER_CODE (not virtual) reports kind 4, which nvn_mtl.m records for res_top but leaves out of the addressed ranges. A buffer made in such a pool still puts it in (nvnBufferInitialize reports kind 0 and 1). On the 8 GB iPhone the wired pools left too little room for the guest's file pages, which were read back 0.2-0.4 GB every 10 s while playing. t_addr_known checks it.
… that measured the same work, so a loading screen's GPU time no longer starts probes of 60 fps

nvn_fstep_gpu_slowed compares the GPU time at a longer hold with a running mean of the frames
at kmin; 1.3 times as much is taken for a clock that fell with the hold and starts a probe
back to kmin. In Ocarina of Time on an M4 Air the only long run at hold 1 was the title and
the loading screen (GPU 0.2 vsyncs, 2-60 draws). Gameplay (700-900 draws, 1.3 vsyncs at hold 1,
1.4 at hold 2) read as a GPU slowed 6 times: 32 probes of hold 1 in a 120 s walk, all failed,
each failure's late frames stepping on to hold 3.

nvn_fgpu keeps the draws a frame encodes beside each reference (dref_at) and at the longer hold
(dcur); fstep_tick takes them from mtl_stats. The GPU counts as slowed only while those are
within FSTEP_GPU_WORK (1.5) of each other; without draw counts (0) it is as before.

t_fstep (81 checks, 0 failed): a loading-screen reference (30 draws) against gameplay at 900 is
not slowed and makes no probe, where the same run without draw counts makes 8 failed probes;
the same draws at both holds still give Breath of the Wild's one probe that holds.

Measured on the M4 Air, Link's house (120 s walk): probes 32 (all failed) -> 7 (all at the title
and the load), frame max 122 -> 73 ms, p95 51.1 -> 51.8 ms, but 27.2 -> 24.2 fps: the failed
probes' late frames had stepped down to hold 2 now and then, and the hold-3 stretches left come
from GPU time at hold 2 of p90 33 ms against 33.3. Kokiri Forest: 19.0 fps, every frame at hold 3,
GPU span 42.4 ms, game speed 0.95; with DM_FPS_TARGET=30 21.0 fps at hold 2, game speed
0.92-0.94. Not run: regress.py (the owner's rule for this branch); BotW and TotK, which use the
same controller, were not measured.
… its own clock, so Ocarina of Time's title movie no longer plays in slow motion under the frame step

The owner saw the scene behind the title in slow motion, as the game was before the frame step. That scene is a 60 fps movie (Event/Movie/Dm_Title_*.webm, AV1 through
nn::codec), and the game's player reads one picture from the decoder each game frame, never skipping: right on the console, where the title runs at 60; under the host's
frame step the title runs at 20-30 fps. Measured with a new DM_MOVIE_TRACE trace of nn::codec packets and picture reads (title, 100 s, M4 Air): one read per game frame,
times 16-17 ms apart, movie speed 0.50 at a 30 cap and 0.36 at the 60 cap (the title at 21.8 fps).

The decoder now answers for such a player (cv_due in hle_codec_video.c): when a released buffer takes a picture, held pictures behind the movie's clock are dropped and the
one nearest the clock goes in. The clock is a picture's time when put in plus host time since; it starts again at the first picture, after 200 ms with none put in (paused),
after a reset and when the stream's time goes back. A dropped picture signals the submission-ready event as a packet taken does. Only streams whose pictures step 4-100 ms
read as nanoseconds are clocked: the unit is the client's, and any other is left as before. A player that keeps up drops nothing. The frame step's movie rule (hold 30 for
30 fps movies, raised by movie::Decoder only) is unchanged.

Test first: t_codec_video's movie clock case (written failing: at 30 reads a second no picture was skipped): 60 reads/s lose none, 30 reads/s step two pictures each read
(299 of 299) and show 10 s of movie in 10 s, 20 reads/s 15 s in 15 s, a µs-timed stream drops none, a 500 ms pause gives no catch-up. "nearest the clock" rather than "last
not after it": on the stream's millisecond-rounded times the latter stepped 1 and 3 pictures by turns (199 skips). t_codec_video all passed; test_env_switches and test_docs OK.

In the game (dmhost_main_mclock, title, 100 s each): speed 1.00 at the 30 cap (30.0 reads/s, steps 33 / 34 ms) and at the 60 cap. Not run on the other nn::codec games
(Donkey Kong Bananza, Fire Emblem Fortune's Weave, Mario Tennis Fever): no regress.py on this branch (the owner's rule for it); their players, read at their movies' rate,
drop nothing by the test's 60 reads/s case.

Docs: docs/reference/movies.md entry, the game note (resolved, and Found on this game), DM_MOVIE_TRACE's line in docs/env_switches.tsv, symptoms index.
…arina of Time's title no longer probes hold 1 against the boot screens' reference

787e00d counts the GPU as slowed only while the draws at the longer hold match the reference's. Draws
do not see compute: on the M1 iPad the title spends 61 ms on its movie network's 14 dispatches with
20-80 draws, while the hold-1 reference came from the boot screens (0.4 ms, no dispatch). The title read
as a GPU slowed 9.9 times and probed hold 1 every 3-6 s (4 in its first 350 presents), each probe about
33 frames at hold 1, none held. nvn_fgpu now keeps the dispatches a frame encodes (+1, so none is a
count) beside the draws, and both must match; mtl_dispatches() gives the count (dispatch_enc).

t_fstep 94 checks, 0 failed: the title case does not probe with dispatch counts and probes 17 times
without; the same draws and dispatches at both holds still give BotW's one probe that holds.
…st), and a reference entry for the shader-code pools the raw pool table wired
…ngs screen and in the in-game menu locks the orientation, and lets an iPhone be played upright

DM_ROTATION, a launcher and in-game menu setting (dm/games/settings.tsv, platform:ios). The phone's Info.plist lists only the two landscapes, so an iPhone could never turn upright: the app delegate (main.m) now answers application:supportedInterfaceOrientationsForWindow: with front_ios_app_orientations (front_ios.m), which replaces that list. Under auto, the default, it returns the list itself (the phone's two landscapes, the iPad's four), so nothing changes for anyone who leaves it. landscape locks the two landscapes; portrait upright (and upside down where the device has it). The root view controller and the launcher's DmHostingController allow every orientation under auto and the locked ones otherwise (dm_rotation_vc_mask; the pure mask is DmLayout.h dm_rotation_mask). A pick calls setNeedsUpdateOfSupportedInterfaceOrientations on every view controller and, when the window's orientation is no longer allowed, requestGeometryUpdateWithPreferences, so the screen turns at once: on the settings screen as soon as it is picked (Launcher.swift onChange), in the in-game menu (dm_setting_live), and at the start for a saved pick, DM_LAUNCHER=0 or an env file. Upright, the settings screen puts the game's name and Play above the settings instead of in a third of the width.

Duo harness: the fixture defines DM_GAME_SCREEN_ASPECT and the stubs add dm_screen_view_changed, dm_players_reported and front_web_touch, which front_ios.m and DmGamepad.m call since screen_aspect any and the controller applet's prompt: the harness did not compile or link without them. Its app delegate answers as main.m's. New test: the masks for each value, and the in-game menu offering Rotation.

test_launcher_settings: DM_ROTATION joins the iOS-only settings (the test listed them; updated deliberately).

Checked: the Duo harness on the iPhone 17 Simulator, 41 tests, 0 failures (1 skipped, iOS 27.1 only), including the rotation test of the next commit, which turns the Simulator's landscape-only iPhone upright; test_launcher_settings, test_orientation_policy, test_docs.

Front-ends: new: DM_ROTATION (Rotation). An iOS host app with its own app delegate answers application:supportedInterfaceOrientationsForWindow: with front_ios_app_orientations() (DmRotation.h) for Portrait to turn a phone upright
… touch controls instead of a 16:9 picture with bars, and with a controller the whole screen

A profile with screen_aspect any renders the shape the front end reports (front_view_aspect, dm_screen_view_changed). front_ios.m reported the app window's shape in landscape and 16:9 upright, so upright the game rendered 16:9 and the front end fitted it, with bars, into the part above the controls. DmLayout now carries `area`, where the picture may go: the view in the overlay layout, the part above the controls in a split. viewDidLayoutSubviews reports that area's shape (on a TV the TV's), so upright the game renders the space above the controls: 402x375 points (1.07:1) on an iPhone 17 instead of a 402x226 picture. Landscape reports the whole view, as before. Profiles without screen_aspect any keep the 16:9 picture above the controls.

With a controller connected the touch controls hide (DmGamepad.m, now dm_gamepad_off, which lays the view out again when that changes): upright with no fold to split at, the picture's area is then the whole safe area (DmLayout.h dm_layout_controls_hidden); a half-folded Duo keeps its split, landscape is unchanged. Fill applies only to a landscape view, so a 16:9 picture upright with the controls hidden stays fitted instead of losing most of its width.

Tests (Duo harness): the area in landscape and upright, the hidden-controls layout and Fill upright, the shape the production root reports, and a real turn of the Simulator's iPhone to Portrait and back: the shape handed to the host is the area's, and once the game presents it the picture fills the area (402x375).

Checked: the Duo harness on the iPhone 17 Simulator, 41 tests, 0 failures (1 skipped, iOS 27.1 only).
…cture's (DM_SCREEN_UPRIGHT_FOV), so a phone upright no longer looks up into parts of the world the game never builds

Upright, the camera rule takes the scale of the same screen turned landscape, its width getting landscape's vertical angle; a phone upright (0.46:1) then sees about 90 degrees vertically (Ocarina's fovy is 50 degrees at 16:9), and above Link's house the world is not built. loader.c now raises the camera's y multiplier (state +0xc4 / +0xc8, which the size stub copies with the size) to that of a picture of the cap's shape when it is smaller, and x with it, so the projection keeps the screen's aspect and the width narrows instead: by default the cap is a square (79 degrees). DM_SCREEN_UPRIGHT_FOV moves it (1.777: the game's own vertical angle) or turns it off (0); landscape is never capped. The HUD's multipliers are the camera's divided by the largest of them, so the HUD does not change. No stub changes: the multipliers were already the loader's (screen_camera, screen_upright_cap; the log line names them).

test_oot_aspect: camera() mirrors the cap (checked against loader.c's code compiled on its own for 0, 1 and 1.777 and six shapes: equal to 1e-5). test_shapes encoded the uncapped rule for a phone upright; it now expects every upright shape to show a square's height (updated deliberately); the uncapped rule moved to test_upright_without_the_cap, and test_the_upright_cap_holds_the_vertical_angle checks the cap at 1, 0.75 and 16:9, the shape kept, and landscape untouched.

Checked: test_oot_aspect (20), loader.c syntax-checked with -Wall -Wextra. Not yet looked at above Link's house or in the Kokiri Forest cutscene upright.
…nders any shape fills the whole screen with the touch controls drawn over it, as in landscape; Picture above controls keeps the split

The owner asked for the picture to fill the whole screen upright with the controls over it, as in landscape, and to keep the split of the previous commit as a choice. DM_PORTRAIT_LAYOUT (launcher and in-game menu): full (the default) or split. Full screen (DmLayout.h dm_layout_full_picture) keeps the split's control arrangement in the lower half, so the controls are where the portrait layout puts them, and lays the picture over the whole view under them; the picture's area is the whole view, so the game renders the screen's shape (0.46:1 on an iPhone) and the upright cap of the previous commit holds its vertical angle to a square's. Touches above the controls go to the game's touch screen as in the split. It needs a picture that takes the screen's shape, so it applies only to a profile with screen_aspect any following the view (DM_SCREEN_ASPECT auto, front_ios.m portrait_full); other games keep their 16:9 picture above the controls, where centred on an upright screen it would sit under them, and a half-folded Duo splits at its fold. With a controller Full screen stays as it is (the controls hide).

The row is shown only for a screen_aspect any profile on iOS: settings.py's shown column now takes <cond>&<cond>, as the options column already did.

Tests: Duo harness, the full-screen layout (area the whole view, the controls' rectangle kept and inside the picture, landscape and a half-folded Duo unchanged) and the production root turned upright in each layout; test_launcher_settings, the row for Ocarina on iOS only.

Checked: the Duo harness on the iPhone 17 Simulator, 42 tests, 0 failures (1 skipped, iOS 27.1 only); test_launcher_settings, test_docs.

Front-ends: new: DM_PORTRAIT_LAYOUT (Portrait layout), and settings.tsv's shown column takes <cond>&<cond>
…after every uncapped shape

55b30f6 put the upright cap's line before the 16:9 line's else, so the else
followed the cap test instead of the shape test: an iPhone run logged
"screen shape 1.072 ... camera x 1.000 y 0.603" then "screen shape 16:9"
although the state page held 1.072. Only the log was wrong.
…ight cap), measured on an iPhone 17 with Ocarina of Time

The game note's Screen shape section follows the picture area upright
(Full screen 0.46:1, capped; Picture above controls 1.07:1), records the
upright cap and the HUD upright, and the six iPhone runs: every shape in
proportion, the same frame rate upright and in landscape, and the one-frame
present crop red line while the window textures grow. The iOS layouts
reference entry gains the symptoms, the fix, the harness checks and the
iPhone test; the symptom index is regenerated.
@github-actions github-actions Bot added host The Horizon host: SVCs, services, threads, memory (dm/host) nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) rewriter The NSO rewriter and per-build patches ios The iPhone and iPad app (dm/ios) mac The Mac front end and app (dm/mac, front_mac.m) tools Tools and scripts (dm/tools, scripts) docs Documentation game: 01006F8002326000 Animal Crossing: New Horizons labels Nov 10, 2026
@stan69b

stan69b commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Closed: these commits now sit on #1971, which they build on (screen_aspect any).

@stan69b stan69b closed this Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 01006F8002326000 Animal Crossing: New Horizons game: 0400486024DEC000 A game host The Horizon host: SVCs, services, threads, memory (dm/host) ios The iPhone and iPad app (dm/ios) mac The Mac front end and app (dm/mac, front_mac.m) nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) rewriter The NSO rewriter and per-build patches tools Tools and scripts (dm/tools, scripts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants