Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor

## [Unreleased]

### Fixes
- Displays: going back to the PC restores the exact layout from before (the second monitor no longer slides aside) and keeps the TV off. Only the screens that were on come back, the TV is detached before the positions, and the result is checked against the backup and redone if needed. (#134)

## [1.6.0-alpha.5]

### Fixes
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH

## [Unreleased]

### Correções
- Telas: ao voltar para o PC o layout volta idêntico ao de antes (a tela 2 não desliza mais para o lado) e a TV continua desligada. Só voltam as telas que estavam ligadas, a TV é desconectada antes das posições, o resultado é conferido com o backup e refeito se preciso. (#134)

## [1.6.0-alpha.5]

### Correções
Expand Down
10 changes: 10 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,16 @@ Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (sa
- [ ] Menu da sessão (Select + Y): o volume inicial é o do Windows; ◀/▶ muda de 5 em 5 e tira o mudo; A alterna o mudo. Resultado: ______
- [ ] Ao restaurar a mesa, o som volta para a saída de antes do modo console. Resultado: ______

## Restaurar a mesa: posições das telas

Pré-condições: pelo menos 3 telas (a TV desligada na mesa normal, mais duas telas empilhadas ou lado a lado, p. ex. uma ultrawide com outra logo abaixo, alinhadas à esquerda); anote o mapa em Configurações → Sistema → Tela antes de começar.

- [ ] Estratégia "Desconectar": **Jogar agora** e depois **Voltar ao PC** (botão, menu da sessão e tray): o mapa em Configurações → Sistema → Tela volta **idêntico** ao anotado (cada tela no mesmo lugar, mesma tela principal, a TV desligada como antes). Resultado: ______
- [ ] O `consolemode.log` mostra a ordem `Telas: desativar <TV>` **antes** de `Telas: layout restaurado => 0` e não tem `posições fora do backup`. Se tiver, o texto diz quais telas e em qual tentativa a restauração acertou. Resultado: ______
- [ ] Repetir 3 vezes seguidas: o layout não deriva a cada ciclo. Resultado: ______
- [ ] TV que já estava ligada (como tela estendida) antes da sessão: depois de voltar, ela continua ligada no mesmo lugar. Resultado: ______
- [ ] Se uma das telas originais não voltar (cabo solto): o log diz que a tela de jogo ficou ligada e o PC não fica sem imagem. Resultado: ______

## Telas com código próprio (issue #91)

Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (tela de jogo e telas a esconder escolhidas). Anote antes, na 1.5, como o mapa de telas aparece, para comparar.
Expand Down
31 changes: 31 additions & 0 deletions src/ConsoleMode/Native/CcdHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,37 @@ public static int SetHdrState(string gdiDeviceName, bool enable)
return DisplayConfigGetDeviceInfo(ref req) != 0 ? null : req.viewGdiDeviceName;
}

/// <summary>
/// Moves active screens to the given desktop positions in one CCD call. Only the paths that are active
/// right now are touched, so a screen that is detached stays detached (unlike the registry-based
/// ChangeDisplaySettingsEx flush, which can bring one back). 0 = nothing to change or applied.
/// </summary>
public static int SetPositions(IReadOnlyDictionary<string, (int X, int Y)> positions)
{
var err = GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, out var numPaths, out var numModes);
if (err != 0) return err;
var paths = new PATH_INFO[numPaths];
var modes = new MODE_INFO[numModes];
err = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, ref numPaths, paths, ref numModes, modes, 0);
if (err != 0) return err;

var changed = false;
for (var i = 0; i < numModes; i++)
{
if (modes[i].infoType != MODE_INFO_TYPE_SOURCE) continue;
var name = GetSourceGdiName(modes[i].adapterId, modes[i].id);
if (name is null || !positions.TryGetValue(name, out var target)) continue;
if (modes[i].mode.sourceMode.position.x == target.X && modes[i].mode.sourceMode.position.y == target.Y) continue;
modes[i].mode.sourceMode.position.x = target.X;
modes[i].mode.sourceMode.position.y = target.Y;
changed = true;
}
if (!changed) return 0;

return SetDisplayConfig(numPaths, paths, numModes, modes,
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_SAVE_TO_DATABASE | SDC_ALLOW_CHANGES);
}

public static int SetPrimary(string gdiDeviceName)
{
var err = GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, out var numPaths, out var numModes);
Expand Down
6 changes: 5 additions & 1 deletion src/ConsoleMode/Native/NativeDisplays.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,11 @@ public static int Disable(IReadOnlyCollection<string> gdiNames)
public static int QueueMode(string gdiName, int width, int height, int frequency, int bitsPerPixel, int? x, int? y, int orientation = -1)
{
var dm = new NativeWindows.DEVMODE { dmSize = (short)Marshal.SizeOf<NativeWindows.DEVMODE>() };
if (!NativeWindows.EnumDisplaySettings(gdiName, NativeWindows.ENUM_CURRENT_SETTINGS, ref dm)) return -1;
if (!NativeWindows.EnumDisplaySettings(gdiName, NativeWindows.ENUM_CURRENT_SETTINGS, ref dm))
{
AppLog.Write($"Telas: {gdiName} sem modo atual; posição e modo não aplicados");
return -1;
}
dm.dmFields = 0;
if (width > 0 && height > 0)
{
Expand Down
1 change: 1 addition & 0 deletions src/ConsoleMode/Services/DisplayBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ public Dictionary<string, Dictionary<string, string>> ResolveLayout(Dictionary<s
public void Enable(IReadOnlyList<string> names) => NativeDisplays.Enable(names);
public void Disable(IReadOnlyList<string> names) => NativeDisplays.Disable(names);
public void SetPrimary(string name) => CcdHelper.SetPrimary(name);
public int SetPositions(IReadOnlyDictionary<string, (int X, int Y)> positions) => CcdHelper.SetPositions(positions);
public void PowerOn(string name) => NativeDisplays.SetPower(name, on: true);
public void PowerOff(string name) => NativeDisplays.SetPower(name, on: false);

Expand Down
23 changes: 23 additions & 0 deletions src/ConsoleMode/Services/DisplayIdentity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,29 @@ public static Dictionary<string, Dictionary<string, string>> ParseLayout(IEnumer
return specs;
}

/// <summary>
/// Monitors that were on in the backup (they have a size) whose current state is wrong: not active, or not
/// at the saved position. Used to check a restore and to retry it. Specs without a saved position are skipped.
/// </summary>
public static List<string> LayoutMismatches(
IReadOnlyDictionary<string, Dictionary<string, string>> specs,
IEnumerable<(string Name, bool IsActive, int X, int Y)> current)
{
var now = current.GroupBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var wrong = new List<string>();
foreach (var (name, spec) in specs)
{
int.TryParse(spec.GetValueOrDefault("Width"), out var width);
int.TryParse(spec.GetValueOrDefault("Height"), out var height);
if (width <= 0 || height <= 0) continue; // was off
if (!int.TryParse(spec.GetValueOrDefault("PositionX"), out var x) ||
!int.TryParse(spec.GetValueOrDefault("PositionY"), out var y)) continue;
if (!now.TryGetValue(name, out var monitor) || !monitor.IsActive || monitor.X != x || monitor.Y != y)
wrong.Add(name);
}
return wrong;
}

/// <summary>
/// Re-keys layout sections by the name each monitor has now, matched by <c>MonitorID</c>: a
/// monitor that was disabled can come back as another \.\DISPLAYn. Sections whose monitor isn't
Expand Down
127 changes: 102 additions & 25 deletions src/ConsoleMode/Services/MonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,21 +113,29 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state)
Thread.Sleep(300);
}

var allDeviceNames = backupSpecs.Values
.Select(s => s.GetValueOrDefault("Name") ?? "")
.Where(n => n.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
// Screens that were on before the session come back; the ones that were off (the TV, usually) stay off.
var monitorsToVerify = backupSpecs
.Where(kv => IsBackupSpecActive(kv.Value))
.Select(kv => kv.Key)
.ToList();
var monitorsToDisable = backupSpecs
.Where(kv => !IsBackupSpecActive(kv.Value))
.Select(kv => kv.Key)
.ToList();
if (ctx.FocusWasInactive && !string.IsNullOrWhiteSpace(ctx.FocusMonitor) &&
!monitorsToDisable.Contains(ctx.FocusMonitor, StringComparer.OrdinalIgnoreCase))
{
monitorsToDisable.Add(ctx.FocusMonitor);
}

if (allDeviceNames.Count > 0)
// 1. Re-attach only the screens that were on. Enabling the ones that were off too (as 1.5's tool did)
// made Windows lay them out and then re-flow everything when they were detached again.
var restored = true;
if (monitorsToVerify.Count > 0)
{
_backend.Enable(allDeviceNames);
_backend.Enable(monitorsToVerify);

if (monitorsToVerify.Count > 0 && !WaitMonitorsActive(monitorsToVerify))
if (!WaitMonitorsActive(monitorsToVerify))
{
foreach (var name in monitorsToVerify)
_backend.Enable([name]);
Expand All @@ -136,43 +144,85 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state)
AppLog.Write($"Restore: /enable não confirmou todos ({string.Join('+', monitorsToVerify)}); usando ExtendAll");
CcdHelper.ExtendAll();
if (!WaitMonitorsActive(monitorsToVerify))
{
restored = false;
result.Issues.Add($"Nem todos os monitores foram reativados: {string.Join(", ", monitorsToVerify)}");
}
}
}
}

// 2. Detach the screens that were off BEFORE placing the others: while the TV is still part of the
// desktop, Windows keeps the positions around it and moves the other screens when it goes. If the
// originals didn't come back, the TV is the only picture left: leave it on.
if (restored)
{
AppLog.Write($"Restore: telas que voltam a ficar desligadas: {(monitorsToDisable.Count == 0 ? "nenhuma" : string.Join('+', monitorsToDisable))}");
foreach (var name in monitorsToDisable)
{
if (string.Equals(name, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase)) continue;
if (!DisableWithRetry(name))
result.Issues.Add($"Não foi possível desconectar {name}");
}
}
else
{
AppLog.Write("Restore: telas originais não voltaram; a tela de jogo fica ligada para não ficar sem imagem");
}

// 3. Primary, then every position and mode of the backup in one go, and check that it stuck.
if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary))
{
backupSpecs.TryGetValue(ctx.OriginalPrimary, out var primarySpec);
if (!RestorePrimaryWithRetry(ctx.OriginalPrimary, primarySpec))
result.Issues.Add($"Não foi possível restaurar o primário para {ctx.OriginalPrimary}");
}

_backend.LoadLayout(AppPaths.BackupMonitorConfig);
Thread.Sleep(800);
const int layoutAttempts = 3;
for (var attempt = 1; attempt <= layoutAttempts; attempt++)
{
_backend.LoadLayout(AppPaths.BackupMonitorConfig);
Thread.Sleep(800);
var wrong = LayoutMismatches(backupSpecs);
if (wrong.Count == 0) break;
AppLog.Write($"Restore: posições fora do backup em {string.Join('+', wrong)} (tentativa {attempt}/{layoutAttempts})");
}

if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary))
// LoadLayout applies the registry's display settings, and a screen that was just detached (the TV) can
// come back with them: detach it again. Doing that can move the others, so the positions are put back
// by CCD, which only touches the screens that are on and never brings a detached one back.
if (restored)
{
backupSpecs.TryGetValue(ctx.OriginalPrimary, out var primarySpec);
if (!RestorePrimaryWithRetry(ctx.OriginalPrimary, primarySpec))
result.Issues.Add($"Primário não confirmado em {ctx.OriginalPrimary} após LoadConfig");
for (var round = 1; round <= 2; round++)
{
var back = monitorsToDisable
.Where(n => !string.Equals(n, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase) && IsActiveNow(n))
.ToList();
if (back.Count == 0) break;
AppLog.Write($"Restore: {string.Join('+', back)} voltou a ligar ao aplicar o layout; desconectando de novo ({round}/2)");
foreach (var name in back)
{
if (!DisableWithRetry(name) && round == 2)
result.Issues.Add($"Não foi possível desconectar {name}");
}
}
}

var monitorsToDisable = backupSpecs
.Where(kv => !IsBackupSpecActive(kv.Value))
.Select(kv => kv.Key)
.ToList();
if (ctx.FocusWasInactive && !string.IsNullOrWhiteSpace(ctx.FocusMonitor) &&
!monitorsToDisable.Contains(ctx.FocusMonitor, StringComparer.OrdinalIgnoreCase))
var misplaced = LayoutMismatches(backupSpecs);
if (misplaced.Count > 0)
{
monitorsToDisable.Add(ctx.FocusMonitor);
var code = _backend.SetPositions(SavedPositions(backupSpecs));
AppLog.Write($"Restore: posições acertadas por CCD => {code}");
Thread.Sleep(600);
misplaced = LayoutMismatches(backupSpecs);
if (misplaced.Count > 0) result.Issues.Add($"Posição das telas diferente do backup: {string.Join(", ", misplaced)}");
}

foreach (var name in monitorsToDisable)
if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary))
{
if (string.Equals(name, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase)) continue;
if (!DisableWithRetry(name))
result.Issues.Add($"Não foi possível desconectar {name}");
backupSpecs.TryGetValue(ctx.OriginalPrimary, out var primarySpec);
if (!RestorePrimaryWithRetry(ctx.OriginalPrimary, primarySpec))
result.Issues.Add($"Primário não confirmado em {ctx.OriginalPrimary} após restaurar o layout");
}

ClearCache();
Expand All @@ -181,6 +231,33 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state)
return result;
}

private bool IsActiveNow(string name) =>
GetMonitors(true).Any(m => m.IsActive && string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));

/// <summary>Saved desktop position of every screen that was on.</summary>
private static Dictionary<string, (int X, int Y)> SavedPositions(Dictionary<string, Dictionary<string, string>> backupSpecs)
{
var positions = new Dictionary<string, (int X, int Y)>(StringComparer.OrdinalIgnoreCase);
foreach (var (name, spec) in backupSpecs)
{
if (!IsBackupSpecActive(spec)) continue;
if (int.TryParse(spec.GetValueOrDefault("PositionX"), out var x) && int.TryParse(spec.GetValueOrDefault("PositionY"), out var y))
positions[name] = (x, y);
}
return positions;
}

/// <summary>Screens that were on in the backup whose position (or state) is not the saved one right now.</summary>
private List<string> LayoutMismatches(Dictionary<string, Dictionary<string, string>> backupSpecs)
{
var now = GetMonitors(true).Select(m =>
{
ParseLeftTop(m.LeftTop, out var x, out var y);
return (m.Name, m.IsActive, x ?? 0, y ?? 0);
});
return DisplayIdentity.LayoutMismatches(backupSpecs, now);
}

public void SetPrimary(string monitorName)
{
_backend.SetPrimary(monitorName);
Expand Down
46 changes: 46 additions & 0 deletions tests/ConsoleMode.Tests/DisplayIdentityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,50 @@ private static void WriteText(byte[] edid, int offset, byte tag, string text)
var bytes = Encoding.ASCII.GetBytes(text + "\n");
for (var i = 0; i < 13; i++) edid[offset + 5 + i] = i < bytes.Length ? bytes[i] : (byte)0x20;
}

// The desk before the session: DISPLAY1 at the origin, DISPLAY2 right below it, the TV off.
private static Dictionary<string, Dictionary<string, string>> DeskBackup() => DisplayIdentity.ParseLayout(DisplayIdentity.FormatLayout(
[
new LayoutEntry(@"\\.\DISPLAY1", "A", "", 32, 2560, 1080, 144, 0, 0),
new LayoutEntry(@"\\.\DISPLAY2", "B", "", 32, 2560, 1440, 144, 0, 1080),
new LayoutEntry(@"\\.\DISPLAY3", "TV", "", 0, 0, 0, 0, 0, 0)
]).Split("\r\n"));

[Fact]
public void Layout_check_passes_when_every_screen_that_was_on_is_where_it_was()
{
var wrong = DisplayIdentity.LayoutMismatches(DeskBackup(),
[
(@"\\.\DISPLAY1", true, 0, 0),
(@"\\.\DISPLAY2", true, 0, 1080),
(@"\\.\DISPLAY3", false, 0, 0) // the TV was off: its state is not part of the check
]);
Assert.Empty(wrong);
}

[Fact]
public void Layout_check_flags_a_screen_that_slid_aside()
{
var wrong = DisplayIdentity.LayoutMismatches(DeskBackup(),
[
(@"\\.\DISPLAY1", true, 0, 0),
(@"\\.\DISPLAY2", true, -2560, 1080),
(@"\\.\DISPLAY3", false, 0, 0)
]);
Assert.Equal(new[] { @"\\.\DISPLAY2" }, wrong);
}

[Fact]
public void Layout_check_flags_a_screen_that_is_off_or_missing()
{
var wrong = DisplayIdentity.LayoutMismatches(DeskBackup(), [(@"\\.\DISPLAY1", false, 0, 0)]);
Assert.Equal(new[] { @"\\.\DISPLAY1", @"\\.\DISPLAY2" }, wrong.Order());
}

[Fact]
public void Layout_check_ignores_backups_without_a_position()
{
var specs = DisplayIdentity.ParseLayout(["[Monitor0]", @"Name=\\.\DISPLAY1", "Width=1920", "Height=1080"]);
Assert.Empty(DisplayIdentity.LayoutMismatches(specs, [(@"\\.\DISPLAY1", true, 500, 500)]));
}
}
Loading