Imported from ideagenplc/display-guard (
AGENTS.md). Install upstream withnpx skills add ideagenplc/display-guard. Copyright stays with the author.
AGENTS.md
- Write every response to the user in ASD-STE100 Simplified Technical English.
- Write every doc, comment, and commit message in ASD-STE100 Simplified Technical English. The
rules are in
standards/ste100.md. - Do not change a log string for style. Program output is a machine interface.
What this repo is
display-guard keeps one external display healthy on an Apple Silicon Mac. It prevents bad display modes. It repairs a display that does not come back after a dock replug. The tool is one Swift file plus one Objective-C helper for raw DDC/CI.
The hardware this tool knows
The tool was built and tested against one desk setup on 2026-08-12:
| Part | Model |
|---|---|
| Mac | MacBook Pro (M5 Pro), macOS 26 (Darwin 25.6.0) |
| Dock | Dell WD19TB (Thunderbolt 3, Titan Ridge) |
| Monitor | ASUS ROG Strix XG43UQ (4K, 144 Hz, DisplayPort 1.4 with DSC) |
| Video | USB-C to DisplayPort cable, on the dock TB-passthrough port |
The constants in display-guard.swift encode this setup. The tool will not work with a
different monitor until an agent adapts it. The section "How to adapt" below gives the steps.
Platform facts. Do not learn these again.
Each fact below cost real debugging time. Trust them.
- A long-running process gets a frozen CoreGraphics topology snapshot. macOS does not send
display add, display remove, or reconfiguration callbacks to a background process. A resident
watcher cannot see a display disappear. For this reason the
watchcommand runs a freshcheckchild on each cycle. Do not refactor the tool into a resident process that calls CoreGraphics for topology. - Dock video ports behind an MST hub block DDC. The kernel rejects the I2C write with error
0xe0114000. The EDID stays readable, because EDID transport is a native MST feature. Only a direct tunnel (the TB-passthrough port) carries DDC. A high count of MST entries in the IORegistry does not show that the video uses the MST path. A USB-C device in the second dock port also enumerates the branch. The DDC probe result is the discriminator. - The XG43UQ can need up to about 200 ms between a DDC request write and the reply read. The
same monitor answered in 10 ms in a later session, so the delay varies.
ddcvcpwaits for this.m1ddcwaits 10 ms and reads zeros. A mute monitor is often a timing problem. - VCP
0xD6(power) is write-only on the XG43UQ. A value above 1 is a one-way door. Standby (value 4) drops the DisplayPort link and kills the AUX channel. Software cannot reach the monitor after that. Only the power button recovers it. - On ASUS firmware, Adaptive-Sync and DDC/CI exclude each other. When Adaptive-Sync is on, the DDC/CI menu item is grey and the DDC engine is mute. macOS does not use Adaptive-Sync here, so the correct OSD state is: Adaptive-Sync off, DDC/CI on.
- A port move can end in a bring-up race: the EDID is readable, but WindowServer never attaches
the display. The panel stays black with an orange LED. A display-sleep retrain does not fix
this state. A full system sleep and wake does. The command is
display-guard rescue --deep. - On a dock replug, the dock USB side enumerates about 10 seconds before the DisplayPort tunnel
attaches the display. The 20 second grace period in
watchexists for this. Do not remove it. - On lid close, macOS can move the external display to a lower mode. The check child enforces the More Space mode and undoes this.
How to adapt to another display
Do the steps in order. Keep the user at the desk. Their eyes are the only test for a lit panel.
- Run
make. Then run./display-guard statusand./ddcvcp probe. - Read the vendor and model from the probe output line
CopyEDID: ... vendor=XXXX product=XXXX. SetEXPECTED_VENDORandEXPECTED_MODELindisplay-guard.swift. Note the byte order: the CoreGraphics model number swaps the EDID product bytes. - Find the dock name in the IORegistry:
ioreg -p IOUSB. SetDOCK_IOREG_NAMEto a name that is present only when the dock is attached. Test it:./display-guard statusmust show "dock attached", and must show "dock NOT attached" after an unplug. - Interpret the probe result:
CopyEDIDfails: the display is not enumerated. Fix the video path first.- Writes fail with
0xe0114000: the video path goes through an MST hub. Move the video cable to a direct or TB-passthrough port. DDC will not work on the MST path. - Writes succeed but replies are zeros: check the monitor OSD (fact 5), then run
./ddcvcp scan 10to search across wait times and read offsets.
- Check the DDC round trip:
./ddcvcp get 10must return the brightness. Then do a visible write test:./ddcvcp set 10 5, wait,./ddcvcp set 10 90. The user must see the panel dim and then get bright. - Check the power semantics of the new monitor with care.
set d6 1while the panel is lit is safe. Do not send a value above 1 unless the user agrees and sits at the power button (fact 4). - Choose
maxHz. Test a higher mode with an automatic revert before you make it permanent: set the mode, hold it for 15 seconds, revert. The user reports if the panel held the image. - Run the full gauntlet below.
The test gauntlet
Run these tests after any adaptation or any change to the rescue logic. Start
./display-guard watch under supervision and read its log after each step. The pass state for
every test is: the panel shows a correct image, and status reports the expected identity and
mode.
| Test | Action | Expected behaviour |
|---|---|---|
| T1 | None. Baseline. | status shows the display, the correct identity, and the target mode. |
| T2 | Unplug the dock from the Mac. Wait 60 s. Replug. | The log shows "standing by". No rescue runs while the dock is out. The built-in panel does not blank. |
| T3 | Close the lid. Use the external display for 30 s. Open the lid. | The check child enforces the target mode within one cycle if macOS changes it. |
| T4 | Move the dock cable to each Mac port. | The display attaches on each port, with the correct identity. A stuck port recovers with rescue --deep. |
| S1 | Sabotage: set a wrong mode with a helper script. | The watch enforces the target mode within two cycles. |
The problem taxonomy
The tool exists to solve these states. Each state has one signature and one remedy.
| State | Signature | Remedy |
|---|---|---|
| Wrong mode | Display present. Mode is not the target. | The check child sets the target mode. |
| Dark but enumerated | Display present in CoreGraphics. Panel dark, orange LED. EDID readable. | DDC wake (0xD6=1), then a link retrain. |
| Not attached, dock present | Zero externals. EDID often still readable. | Link retrain. If that fails: rescue --deep. |
| Fallback EDID | Vendor or model is wrong. The name shows the scaler brand, for example "Novatek". | Software cannot fix this. Power-cycle the monitor for 60 s. |
| Intentional unplug | Dock absent. | Do nothing. Stand by. |