watchthelight labs

Stage 4 on Meteor Lake 7D55 (Core Ultra 7 155H): boot framebuffer at GGTT 0xFE800000 straddles GUC_GGTT_TOP, reserve_bootfb returns -28 #13

open
samwu07 2026-11-01

Machine

  • Lenovo ThinkBook 14+ (2024), Core Ultra 7 155H, iGPU 8086:7D55 rev 8, built-in 3072×1920 panel on pipe A
  • macOS 15.8.1 (24H32), OpenCore 1.0.8, SIP 0x0A43
  • XeGfx 0.0.47 from the release zip (binary unmodified), loaded through OpenCore Kernel/Add (prelinked). It stays idle until kmutil load -b org.watchthelight.XeGfx, and was confirmed not loaded before that.
  • boot-args: -v keepsyms=1 debug=0x100 ps2kbdonly=1 itlwm_he=1 itlwm_mtl_internal_aux=1 amfi_get_out_of_my_way=0x1 amfi=0x80 ipc_control_port_options=0 xegfx.nvram_trace=0

Result

Stage Result
1 pass
2 pass (XeGfxProbe result 1)
3 pass
4 fail: debug.xegfx_stage stays at 3
5, 6 not reached

Stage 4 gets a long way before it fails:

drmn0: [drm] xegfx: display left alone (compat.linuxkpi.i915_xegfx_no_display)
drmn0: [drm] Skipping GT1 (Standalone Media GT) - MEI driver not available on FreeBSD
drmn0: [drm] xegfx: stolen allocator starts at 0x1680000, after the boot framebuffer
drmn0: successfully loaded firmware image 'i915/mtl_guc_70.bin'
drmn0: [drm] GT0: GuC firmware i915/mtl_guc_70.bin version 70.53.0
drmn0: [drm] GT0: GUC: submission enabled
drmn0: [drm] GT0: GUC: SLPC enabled
drmn0: [drm] GT0: GUC: RC enabled
<6>[drm] Initialized i915 1.6.0 for drmn0 on minor 0

After that first stage 4 run:

XeGfxGt   = {"result"=1, "wedged"=0, "guc-fw-status"=10, "guc-submission"=1, "guc-version"=0x463500, "engines"=3,
             "render-minor"=128, "gts"=1, "huc-fw-status"=-1, "gsc-fw-status"=-1, "gtawake-gts"=0}
XeGfxPost = {"result"=1, "frmcount-delta"=6, "ggtt-pte-surf"=0x800003, "plane-surf-1-a"=0xFE800000}

The display check, xg_drm_attached() and xg_gt_check() all pass, and yet the stage counter doesn't move. The only failure path left in xg_run_stage(4) is the boot surface reservation: xg_i915_reserve_bootfb(0xFE800000, 0x1680000) returns -ENOSPC. Because gBootSurf (0xFE800000) is below 0xFEE00000, rc = -28. I haven't seen the XeGfx: boot surface GGTT ... reserved: IOLog line itself, because my log capture missed it. This is deduced from the code path and the values above.

Why it overlaps

From stage 2 (XeGfxProbe):

  • PLANE_SURF_1_A = 0xFE800000
  • boot-fb-size = 0x1680000 (22.5 MiB, 3072×1920×4)
  • stride 12288 bytes
  • dsm-base-lo = 0x7A800000
  • ggc = 0x2C0

So the GOP left the scanout at GGTT [0xFE800000, 0xFFE80000). That range crosses GUC_GGTT_TOP (0xFEE00000).

i915's ggtt_reserve_guc_top() reserves [0xFEE00000, 4 GiB) as ggtt->uc_fw. intel_uc_fw_init() → uc_fw_bind_ggtt() maps the GuC image at the start of that node, type 0, with INTEL_UC_RSVD_GGTT_PER_FW = 2 MiB per slot. The image stays mapped until intel_uc_fw_fini(). So once i915 initialises, the panel's PTEs from 0xFEE00000 point at the GuC binary. That point is 6 MiB into the framebuffer: rows 512 to about 538 on this panel, for the 320 KiB image. XeGfxPost only checks the PTE at the surface start (0x800003, unchanged), so the overlap doesn't show up there. (References are Linux v6.10. I assume the drm-kmod copy in XeGfx matches.)

The panel confirms it: after the stage runs, a thin strip of coloured noise runs across the full width, starting about 27–28% of the way down. That's measured from a photo, so perspective skews it a little. Row 512 of 1920 is 26.7%, and the noise looks like firmware bytes being scanned out as pixels. The strip looks a bit taller than the predicted ~27 rows, but the photo is too blurry to measure that, and four i915 instances were running by then (see below). Everything above and below the strip is intact. The same panel showed a band whenever another i915 port (VedCloud's NMXGPU, also display-disabled) initialised GuC, which fits this explanation.

The -28 guard is right to fail here. The N-256 exception (surface at or above GUC_GGTT_TOP) doesn't cover a surface that starts below GUC_GGTT_TOP and ends above it.

Possible fixes (your call)

  1. Move the scanout before i915 initialises the GGTT. Copy the framebuffer's PTEs to a free range below GUC_GGTT_TOP that the stolen/GGTT allocators keep clear, write PLANE_SURF_1_A to the new offset (it latches at vblank), then reserve that range. It's a single display register write, after which all of the framebuffer is outside the GuC region.
  2. Keep the uC firmware away from the panel. Make the uc_fw slot avoid the live surface. This is probably harder, because the GuC can't address above GUC_GGTT_TOP.

Secondary: a failed stage 4 can be run again

My stage runner wrote debug.xegfx_stage again after the failure. That was my mistake: I read debug.xegfx_results without -b. Each write re-ran stage 4 and attached another i915 instance on top of the first:

xegfx: devclass vgapci: drmn drmn drmn drmn
xegfx: make_dev drm/0: error 17
WARNING !xa_empty(&drm_minors_xa) failed at .../drm/drm_drv.c:1122

It might be worth making the stage sysctl refuse to run a stage that already failed this boot, or at least refuse stage 4.

Observation, low confidence

Without xegfx.nvram_trace=0, the desktop hard-froze right after the login password on two boots:

  • once with XeGfx in the AuxKC
  • once with it prelinked through OpenCore

XeGfx was not loaded in either case: no xegfx lines in the log, and no panic. With xegfx.nvram_trace=0 (README advice), login was normal and stages 1–4 ran. That's one boot of each, and I can't explain how a boot-arg affects a kext that isn't loaded, so this may be a coincidence.

Reports (ioreg dicts, debug.xegfx_log after every stage) are available if useful.

samwu07 2026-11-01

I ran it again with a corrected stage runner. It now reads sysctl -b debug.xegfx_results and never writes the stage sysctl again after a failure. On a fresh boot, that's one load and one stage 4 attempt:

per-stage result s0..s7: 0 1 1 1 -28 0 0 0
debug.xegfx_stage: 3 -> 3

So the -28 from xg_i915_reserve_bootfb now comes straight from debug.xegfx_results, not from my deduction. Stage 2's XeGfxProbe is the same as before: surface 0xFE800000, 0x1680000 bytes, 3072×1920.

The band showed up after this single i915 init, at the same height as before, roughly a quarter of the way down. So the extra instances in my first report weren't needed to produce it. The GuC mapping at 0xFEE00000 is enough on its own.

I also tried to move the framebuffer without changing code. With OpenCore UEFI/Output/Resolution = 2560x1600@32, the firmware ignored the request. The panel stayed 3072×1920 at the same GGTT address, so this GOP seems to offer only the native mode. A smaller boot framebuffer is therefore not a workaround on this machine, and the scanout would need to be moved in code, like fix 1 in the issue.

Reply on GitHub