2013/10/27

ReadyNASの掃除

ReadyNAS Ultra 2のファンが、ついにというか、時々軸音を発するようになったので、どんなものか見てみたら埃まみれになっていたので、外してみた。

Ultra 2の筐体はバックパネルの部分が外せそうだとは分かっていたが、実際に外したことはなかったので、自分にしてはやや慎重に4本のネジを外すと、パカっと。

以下は、既に埃を落とした状態。
ReadyNAS Ultra 2: Back side
ReadyNAS Ultra 2: Back side

SATAコネクタのあるバックプレーンを裏側から見たことはなかったので、少し新鮮。

このPCBに"FOXCONN"の文字があったので、改めて見たらメインのPCBにも"FOXCONN"の文字が入っていて、ついでにこれらを繋ぐコネクタもFoxconn製で、つまり高い確率でReadyNASの製造はFoxconnだったんだと、今更ながら了解。既にFoxconn製というか、Hon Hai製の電子製品は日常生活の至るところに溢れているわけだが、その一つだったと。

ファンの方はDeltaのAFB0912HHで、それは初めから見えていたのだが、
ReadyNAS Ultra 2: Fan

このブレードの排気側の縁が、くいっと曲がっているのに初めて気づいた。
ReadyNAS Ultra 2: Fan

おそらくは静音上の意味があるのだろうが、後発メーカーが先発メーカーとの差別化のために変わったことをするのは珍しくないにしても、ファンにかけては老舗のDeltaが、しかも直接ユーザーにアピールする必要のないアプライアンス向けの製品でこういう工夫をしているのは、正直意外だった。

それはそれとして、掃除した結果、また静かな状態に戻ったので満足。交換用に用意していたファンの出番は、結局なしで終わりそうな気がしてきた。

2013/10/24

WindowsフォームとPer-Monitor DPI(続)

Windows 8.1 Preview時点のものに続き、RTMではPer-Monitor DPIの要素のうちWM_DPICHANGEDメッセージに変更があったので、これへの対応について。

1. WM_DPICHANGEDが来るタイミング

RTMでは、OSからWM_DPICHANGEDが送られてくるタイミングが以下のように増えている。
  1. FormのLoadイベントが起こる前(おそらくコントロールが初期化される前)
  2. ウィンドウが属するモニターのDPIが変更されたとき
  3. ウィンドウがモニター間をまたいだとき(常に)
1.は初期化時にDPIが分かった方がいいというケースのためかと思うが、Loadイベントを起点にスケーリング処理を開始するアルゴリズムの場合、スルーするようにする必要があるかもしれない。

2.は純粋な改善で、モニターのDPI変更をトリガーとして、直ちにアプリがスケーリングすることが可能になった。

3.が本題で、Previewではモニター間をまたぐときに(途中でウィンドウをリサイズしても)1回しかWM_DPICHANGEDは来なかったが、RTMではまたぐ度に忠実に来るようになった。

これが何を意味するかというと、自分が観察した限りではOSはウィンドウとモニターの重なり合う面積が最大のものをそのウィンドウが属するモニターと見なし(Win32のMonitorFromWindow関数と同じ)、これに従ってWM_DPICHANGEDを送ってくるようなので、以下のループが発生し得る。
  1. ウィンドウがモニター間を移動するとき、移動のモニターに重なる面積と、移動のモニターに重なる面積を比べて、後者が大きくなったとき、すなわち属するモニターが変わったときにWM_DPICHANGEDが来る。
  2. これを受けて直ちにウィンドウをリサイズすると、リサイズはウィンドウの位置(左上角の位置)を固定して行われるので、移動のモニターに重なる面積と、移動のモニターに重なる面積が変化し、前者が大きくなることが起こり得る。
  3. 結果として、属するモニターが移動のモニターに一旦戻る形で変わるので、再度WM_DPICHANGEDが来る(以後、これが起こらなくなる位置までウィンドウが移動するまでループ)。
たぶんPreviewではこのループが起こらないようOS側でWM_DPICHANGEDを抑止していたのではないかと思うが、それでは何か不都合があったのか、RTMではアプリ側に対応を投げてきたということだと思う。

いずれにせよ、このループを回避するには、ウィンドウをリサイズしたときに移動先のDPIと同じになるような方策が必要となる。

2. 具体策

やり方は色々あり得るが、大きく二つに分けられると思う。
  • 即時型
    直ちに、リサイズ後に移動先のDPIと同じになる位置に積極的にウィンドウを移動させた後、リサイズする。

  • 遅延型
    直ちにリサイズはせず、リサイズ後に移動先のDPIと同じになる位置までウィンドウが移動するまで待ってから、リサイズする。
即時型の方がフラグ管理が不要なのでシンプルになるが、ドラッグ中にウィンドウの位置が飛ぶのは嬉しくないかもしれない。したがって、基本は遅延型になると思うが、ドラッグ中でない静止時には使えないので、ケースバイケース。

2.1. 遅延型

まず遅延型のアルゴリズムの例。
  1. ウィンドウの移動開始(Form.ResizeBegin)と移動終了(Form.ResizeEnd)イベントを捉えて、移動中か否かをチェックできるようにしておく。
  2. WM_DPICHANGEDが来たときは、それに含まれる移動先のDPIと、ウィンドウの現在のDPIを比べ、これが違うときで、移動中のときは待機に入り、移動中でないときは直ちにリサイズする。逆にDPIが同じときは、待機を解除する。
  3. ウィンドウの移動(Form.Move)イベントを捉え、待機中のときは、
    1. その位置でウィンドウをリサイズした場合と同じ長方形を生成する。
    2. この長方形が属するモニターをMonitorFromRect関数で得て、スクリーン外でないことを確認の上、そのモニターのDPIをGetDpiForMonitor関数で得る。
    3. これが移動先のDPIと一致するときはリサイズを実行し、待機を解除する。DPIが一致しないときはそのまま待機を続ける(以後、DPIが一致する位置に移動するまで繰り返し)。
  4. 待機中に移動元のモニターに戻ったときは、またWM_DPICHANGEDが来るので、2.によって待機は解除される。
この実装の主要部分。dpiOldは現在のウィンドウのDPI(この値は別途入れている)、dpiNewはWM_DPICHANGEDに含まれるDPI、AdjustWindowはウィンドウをリサイズするメソッド、W32はWin32のP/Invokeのためのクラス。
'Old (previous) DPI
Private dpiOld As Single = 0

'New (current) DPI
Private dpiNew As Single = 0

'Flag to set whether this window is being moved by user
Private isBeingMoved As Boolean = False

'Flag to set whether this window will be adjusted later
Private willBeAdjusted As Boolean = False

'Detect user began moving this window.
Private Sub MainForm_ResizeBegin(sender As Object, e As EventArgs) Handles MyBase.ResizeBegin
    isBeingMoved = True
End Sub

'Detect user ended moving this window.
Private Sub MainForm_ResizeEnd(sender As Object, e As EventArgs) Handles MyBase.ResizeEnd
    isBeingMoved = False
End Sub

'Catch window message of DPI change.
Protected Overrides Sub WndProc(ByRef m As Message)
    MyBase.WndProc(m)

    Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h

    If (m.Msg = WM_DPICHANGED) Then
        'wParam
        Dim lo As Single = W32.GetLoWord(m.WParam.ToInt32())

        'Hold new DPI as target for adjustment.
        dpiNew = lo

        If (dpiOld <> lo) Then
            If (isBeingMoved = True) Then
                willBeAdjusted = True
            Else
                AdjustWindow()
            End If
        Else
            willBeAdjusted = False
        End If
    End If
End Sub

'Detect this window is moved.
Private Sub MainForm_Move(sender As Object, e As EventArgs) Handles MyBase.Move
    If (willBeAdjusted = True) AndAlso IsLocationGood() Then
        willBeAdjusted = False

        AdjustWindow()
    End If
End Sub

'Check if current location of this window is good for delayed adjustment.
Private Function IsLocationGood() As Boolean
    If (dpiOld = 0) Then Return False 'Abort.

    Dim factor As Single = dpiNew / dpiOld

    'Prepare new rectangle shrinked or expanded sticking Left-Top corner.
    Dim widthDiff As Integer = Convert.ToInt32(Me.ClientSize.Width * factor) - Me.ClientSize.Width
    Dim heightDiff As Integer = Convert.ToInt32(Me.ClientSize.Height * factor) - Me.ClientSize.Height

    Dim rect As New W32.RECT() With {.left = Me.Bounds.Left,
                                     .top = Me.Bounds.Top,
                                     .right = Me.Bounds.Right + widthDiff,
                                     .bottom = Me.Bounds.Bottom + heightDiff}

    'Get handle to monitor that has the largest intersection with the rectangle.
    Dim handleMonitor As IntPtr = W32.MonitorFromRect(rect, W32.MONITOR_DEFAULTTONULL)

    If (handleMonitor <> IntPtr.Zero) Then
        'Check if DPI of the monitor matches.
        Dim dpiX As UInteger
        Dim dpiY As UInteger

        Dim result As Integer = W32.GetDpiForMonitor(handleMonitor, W32.Monitor_DPI_Type.MDT_Default, dpiX, dpiY)

        If (result = 0) Then 'If S_OK (= 0)
            If (Convert.ToSingle(dpiX) = dpiNew) Then
                Return True
            End If
        End If
    End If

    Return False
End Function
リサイズした場合の長方形の生成時には、タイトルバーを含めてクローム部分はDPIによって変わらないので、クライアント領域の分だけ元のウィンドウからサイズを増減しないと微妙にズレが出る点に注意。

2.2. 即時型

次に即時型のアルゴリズムの例。
  1. WM_DPICHANGEDが来たときは、現在のウィンドウの4つの角のそれぞれについて、その位置でウィンドウを固定してリサイズした場合と同じ長方形を生成する。この4つの長方形が移動先の候補となる。
  2. この長方形が属するモニターをMonitorFromRect関数で得て、スクリーン外でないことを確認する。さらに、この長方形の左上角と右上角のそれぞれについて、その位置を含むモニターをMonitorFromPoint関数で得て、少なくとも一方がスクリーン外でないことを確認する(タイトルバーがスクリーン外に行かないようにするため)。
  3. 長方形が属するモニターのDPIをGetDpiForMonitor関数で得る。
  4. これがWM_DPICHANGEDに含まれる移動先のDPIに一致するときは、この長方形の位置にウィンドウを移動させた後、リサイズする。
この4つの長方形の模式図。イメージ的には固定された角の対角にある角が移動して縮小・拡大する感じ。

これらを候補とするのは、モニターの位置関係、ウィンドウの移動方向、DPIの大小関係にかかわらず、この中に必ず移動先のモニターに属するものがあると思われるから(厳密に数学的に正しいかは知らない)。移動先のモニターと同じDPIのモニターが他にもある場合、そのモニターに属する長方形となる可能性もあるが、同じDPIならそのまま移動できるので問題にはならないと思う。

また、これらを調べる順番は、左上角に近い方から進めた方がタイトルバーが移動する可能性が減るので、ユーザーのストレスを抑えられると思う。

この実装の主要部分。変数名などは遅延型と同じ。
'Old (previous) DPI
Private dpiOld As Single = 0

'New (current) DPI
Private dpiNew As Single = 0

'Catch window message of DPI change.
Protected Overrides Sub WndProc(ByRef m As Message)
    MyBase.WndProc(m)

    Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h

    If (m.Msg = WM_DPICHANGED) Then
        'wParam
        Dim lo As Single = W32.GetLoWord(m.WParam.ToInt32())

        'Hold new DPI as target for adjustment.
        dpiNew = lo

        If (dpiOld <> lo) Then
            MoveWindow()
            AdjustWindow()
        End If
    End If
End Sub

'Move this window for immediate adjustment. 
Private Sub MoveWindow()
    If (dpiOld = 0) Then Exit Sub 'Abort.

    Dim factor As Single = dpiNew / dpiOld

    'Prepare new rectangles shrinked or expanded sticking four corners.
    Dim widthDiff As Integer = Convert.ToInt32(Me.ClientSize.Width * factor) - Me.ClientSize.Width
    Dim heightDiff As Integer = Convert.ToInt32(Me.ClientSize.Height * factor) - Me.ClientSize.Height

    Dim rectList As New List(Of W32.RECT)()

    'Left-Top corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left,
                                      .top = Me.Bounds.Top,
                                      .right = Me.Bounds.Right + widthDiff,
                                      .bottom = Me.Bounds.Bottom + heightDiff})

    'Right-Top corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left - widthDiff,
                                      .top = Me.Bounds.Top,
                                      .right = Me.Bounds.Right,
                                      .bottom = Me.Bounds.Bottom + heightDiff})

    'Left-Bottom corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left,
                                      .top = Me.Bounds.Top - heightDiff,
                                      .right = Me.Bounds.Right + widthDiff,
                                      .bottom = Me.Bounds.Bottom})

    'Right-Bottom corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left - widthDiff,
                                      .top = Me.Bounds.Top - heightDiff,
                                      .right = Me.Bounds.Right,
                                      .bottom = Me.Bounds.Bottom})

    'Get handle to monitor that has the largest intersection with each rectangle.
    For i = 0 To rectList.Count - 1
        Dim handleMonitor As IntPtr = W32.MonitorFromRect(rectList(i), W32.MONITOR_DEFAULTTONULL)

        If (handleMonitor <> IntPtr.Zero) Then
            'Check if at least Left-Top corner or Right-Top corner is inside monitors.
            Dim handleLeftTop As IntPtr = W32.MonitorFromPoint(New W32.POINT(rectList(i).left, rectList(i).top),
                                                               W32.MONITOR_DEFAULTTONULL)
            Dim handleRightTop As IntPtr = W32.MonitorFromPoint(New W32.POINT(rectList(i).right, rectList(i).top),
                                                                W32.MONITOR_DEFAULTTONULL)

            If (handleLeftTop <> IntPtr.Zero) OrElse (handleRightTop <> IntPtr.Zero) Then
                'Check if DPI of the monitor matches.
                Dim dpiX As UInteger
                Dim dpiY As UInteger

                Dim result As Integer = W32.GetDpiForMonitor(handleMonitor, W32.Monitor_DPI_Type.MDT_Default, dpiX, dpiY)

                If (result = 0) Then 'If S_OK (= 0)
                    If (Convert.ToSingle(dpiX) = dpiNew) Then
                        'Move this window.
                        Me.Location = New Point(rectList(i).left, rectList(i).top)
                        Exit For
                    End If
                End If
            End If
        End If
    Next
End Sub
P/Invokeが多いのは、標準にはないことをしている以上、まあ仕方ない。

3. デモアプリ

以上を引っくるめて修正したデモアプリ。VBに加えてC#でも作成した。
  • "CurrentAutoScaleDimensions"と"GetDeviceCaps (LOGPIXELSX)"は常に同じ(はず)。
  • Windows 8.1では、"GetDpiMonitor"にこのウィンドウが属しているモニターのDPIが表示される。
  • Windows 8.1では、"WM_DPICHANGED (Latest)"に最新のメッセージに含まれるDPIが表示される。
  • 中央のボックスにはWM_DPICHANGEDが来た時刻、そのwParamに含まれるDPI、lParamに含まれるRECTの位置・サイズが表示される。VB版でDEBUGビルドすると、追加情報も表示される。
  • ラジオボタンで即時型"Immediate"と遅延型"Delayed"の切り替えができる。遅延型の場合、その横に現在の待機状況が表示され、"Waiting"が待機中、"Resized"がリサイズしたこと、"Aborted"が待機に入ったが取りやめたことを示す。
  • 下の"Create Button"は動的にコントロールを作成する例として。
実行ファイル
完全なソースコード(VBとC#)

4. 残る問題

通知領域のあるモニターのDPI

モニター間の移動によるものでない、モニター設定によるDPIの変更は、そのモニターに属するウィンドウのみにWM_DPICHANGEDで知らされる。

それはそれで正しいのだが、通知領域アイコンを置いている場合で、アイコンの属するウィンドウが通知領域のあるモニターに現在表示されてない場合、そのモニターのDPIに変更があってもWM_DPICHANGEDは来ない。具体的には、そのウィンドウを最小化している場合や別のモニターに表示させている場合で、こういう場合は通知領域アイコンに付けたメニューなどをリサイズするトリガーがないことになる。

まあ何かのタイミングに引っ掛けて、例えばそのメニューが表示されるときのイベント(ContextMenuStripのVisibleChangedイベントなど)を捉えてDPIをチェックする方法が考えられるが、統一性には欠ける。

WM_DPICHANGEDのlParamのRECT

このRECTについて、MSDNの説明では"RECT structure that provides the size and position of the suggested window, scaled for the new DPI"となっている。

いかにもウィンドウのリサイズに利用できそうな感じがするが、実際に来ているものを見ると、関係があるっぽいのは分かるが正確に何を意味しているのかは分からないという……。別になくても困らないが、謎。

[追記]

通知領域のあるモニターのDPIに関する説明を増やした。

2013/09/08

スクリーン座標とPer-Monitor DPI

Per-Monitor DPI環境下でウィンドウのスクリーン座標を取得する際に気づいたことがあったので、検証してみた。

1. 検証アプリ

少し前にWindows 8.1 PreviewのPer-Monitor DPI環境下でDPI仮想化(DPI Virtualization)のかかったウィンドウのスクリーン座標を正確に取得できないことがあるのに気づいたので、検証アプリを作ってみた。
  • 上2つの"Chaser"は下の"Runner"のスクリーン座標を追いかけるためのアプリで、
    • "Process Name"にあるプロセス名のプロセスを見つけて、そのウィンドウの座標をWin32のGetWindowRect関数を使って表示する("GetWindowRect Left"がウィンドウの左上角のX座標、"GetWindowRect Top"が同じくY座標)。
    • "Process DPI Awareness"はそのプロセスのDPI Awarenessを示す。
    • タイトルバーにはこのChaser自身のDPI Awarenessを表示。

  • 下の"Runner"はドラッグして動かしてみるためのアプリで、
    • Runner自身の座標をWin32のGetWindowRect関数とForm.Locationプロパティを使って表示する(これらは常に一致する、はず)。
    • "Return Zero"はスクリーンの原点(左上角)にウィンドウを移動させる。原点はいかなる場合でも一致することの確認用。
    • "Move One Right"はウィンドウをドラッグによらず、右に座標1つ分だけずらす。
    • タイトルバーにはこのRunnerのDPI Awarenessを表示。
このChaserとRunnerはそれぞれ同じコード(下記参照)で、マニフェストファイルでDPI Awarenessだけ変えたものを用意した。

2. 検証結果

モニターは、1680x1050(メイン)と1024x768(セカンド)のマルチモニター。

これを元の96DPIの環境から、Per-Monitor DPI環境(メインは120DPIになり、セカンドは96DPIのまま)に設定後、サインアウトまたは再起動する前の、新しいDPIがまだデスクトップに反映されてない状態で、Chaser (Per-Monitor Aware)とChaser (System Aware)を起動しておく。

ここでRunner (System Aware)を起動し、メインからセカンドに移る直前まで動かした状態が以下。
DpiLocTest 96(120)-96 System Aware 1

正確な座標を示しているのはChaser (Per-Monitor Aware)で、Runner (System Aware)はDPI仮想化がかかっていて、拡大された分だけ座標は小さな値になっている。Chaser (System Aware)はRunner (System Aware)と同じで、正確でない。なお、Chaser (System Aware)にDPI仮想化がかかっているが、検証には関係ない。

このまま右にドラッグするとセカンドに移るが、敢えてMove One Rightで座標1つ分だけずらすとこうなる。
DpiLocTest 96(120)-96 System Aware 2

Runner (System Aware)のウィンドウが左上に飛んでいる。「何これ?」という感じだが、DPI仮想化の効果が切れ、座標も元に戻って、そこに移動している。三者の示す座標は同じ。普通にウィンドウを動かしている限りは陥らない状態だが、システム的にはこうなるということ。

この状態、あるいは最初の状態からドラッグして右に動かし、セカンドの左端に合わせたところ。
DpiLocTest 96(120)-96 System Aware 3

メインの幅は1680なので、三者とも座標は正確。ということは、Runner (System Aware)とChaser (System Aware)にとっては、座標が大きくジャンプしたことになる。その間の座標はどこに行ったかというと、上の状態のとおり。

さて、次にRunner (Per-Monitor Aware)を起動して、同じようにメインからセカンドに移る直前まで動かす。
DpiLocTest 96(120)-96 Per-Monitor Aware 1

Chaser (Per-Monitor Aware)とRunner (Per-Monitor Aware)は正確な座標を示しているが、Chaser (System Aware)は先ほどと同じく小さくなった値を示している。

同じようにMove One Rightで座標1つ分だけずらす。
DpiLocTest 96(120)-96 Per-Monitor Aware 2

Runner (Per-Monitor Aware)はそのまま波乱なく移動。Chaser (System Aware)は値がジャンプした。

このままセカンドの左端まで動かす。
DpiLocTest 96(120)-96 Per-Monitor Aware 3

三者とも正確な座標で一致。

これで結論は見えてきたが、確認のため一旦サインアウトして新しいDPIがデスクトップに反映された状態にし、同じようにChaser (Per-Monitor Aware)とChaser (System Aware)を起動しておく。

まずRunner (System Aware)を起動。
DpiLocTest 120-96 System Aware 1

ここまでは三者とも一致して正確。

Move One Rightで座標1つ分だけずらす。
DpiLocTest 120-96 System Aware 2

またRunner (System Aware)のウィンドウが飛んでいる。その座標をChaser (Per-Monitor Aware)は正確に捉えているが、Runner (System Aware)とChaser (System Aware)はそのまま右にずれたかのように認識している。ウィンドウにはDPI仮想化が縮小する方向にかかっている。

セカンドの左端まで動かす。
DpiLocTest 120-96 System Aware 3

Chaser (Per-Monitor Aware)は正確だが、Runner (System Aware)の座標はDPI仮想化で縮小した分だけ大きな値になっている。Chaser (System Aware)も同様。

最後にRunner (Per-Monitor Aware)を起動。
DpiLocTest 120-96 Per-Monitor Aware 1

ここまでは三者とも一致。

Move One Rightで座標1つ分だけずらす。
DpiLocTest 120-96 Per-Monitor Aware 2

Chaser (Per-Monitor Aware)とRunner (Per-Monitor Aware)は至って正確だが、Chaser (System Aware)は座標がジャンプした。

セカンドの左端まで動かす。
DpiLocTest 120-96 Per-Monitor Aware 3

Chaser (Per-Monitor Aware)とRunner (Per-Monitor Aware)は正確で、Runner (Per-Monitor Aware)にDPI仮想化もかかっていないが(当然)、Chaser (System Aware)の座標は大きくなった値のままずれている。

3. まとめ

以上から、とりあえずの結論。
  1. アプリのDPI AwarenessがPer-Monitor Awareに設定されていない場合、Per-Monitor DPI環境下ではそのプロセスで実行したGetWindowsRect関数が正確な値を返さないことがある。これは対象のプロセスが自分のアプリか他のアプリか(そして、その、他のアプリがPer-Monitor Awareに設定されているか否か)には関係ない。

  2. これは対象のアプリにDPI仮想化がかかっているか否かにも直接関係ない(この存在を示唆するものだとは思うが)。

  3. GetWindowsRect関数以外でも同様のことは起こり得る(GetClientRect関数でも起こることは確認)。

  4. したがって、ケースバイケースだが、自分のアプリのUIをPer-Monitor DPIに合わせて積極的に変えたりしない場合でも、スクリーン座標に関係する処理があればPer-Monitor Awareに設定した方が安全、と思われる。
初めはバグかとも思ったが、Per-Monitor Awareに設定すれば問題は起きないので、まあ仕様なのかと。RTMで変わるかもしれないが。

[追記]

Windows 8.1 RTMで試した結果も同じだったので、これが仕様らしいと確定。ついでに、Previewでは動作の怪しかったSetProcessDpiAwarenessが正しく機能するようになっていた。

4. コード

検証アプリは.NET Framework 4.5のWindowsフォームアプリで、Visual Studio 2013 Previewで作成。言語はC#。

Chaserの主要部分。
private Timer timerChaser;
private Process processRunner = null;

private void ChaserForm_Load(object sender, EventArgs e)
{
    timerChaser = new Timer();
    timerChaser.Tick += new System.EventHandler(this.CheckRunner);
    timerChaser.Interval = 400;
    timerChaser.Enabled = true;
}

private void CheckRunner(object sender, EventArgs e)
{
    if (String.IsNullOrWhiteSpace(this.textBox_ProcessName.Text)) return;

    GetProcess(this.textBox_ProcessName.Text);

    CheckLocation();
    CheckDpiAwareness();
}

// Get process of Runner.
private void GetProcess(string nameRunner)
{
    if (!String.IsNullOrWhiteSpace(nameRunner))
    {
        try
        {
            processRunner = Process.GetProcessesByName(nameRunner).FirstOrDefault();
        }
        catch (Exception ex)
        {
            Debug.WriteLine("Failed to get process. " + ex.Message);
        }
    }
}

// Check location of Runner.
private void CheckLocation()
{
    try
    {
        if ((processRunner != null) &&
            (processRunner.MainWindowHandle != IntPtr.Zero))
        {
            W32.RECT rct;

            bool result = W32.GetWindowRect(processRunner.MainWindowHandle, out rct);

            if (result)
            {
                this.textBox_GetWindowRectLeft.Text = rct.Left.ToString();
                this.textBox_GetWindowRectTop.Text = rct.Top.ToString();
            }
            else
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
        }
        else
        {
            this.textBox_GetWindowRectLeft.Text = "";
            this.textBox_GetWindowRectTop.Text = "";
        }
    }
    catch (Exception ex)
    {
        this.textBox_GetWindowRectLeft.Text = "Failed";
        this.textBox_GetWindowRectTop.Text = "Failed";

        Debug.WriteLine("Failed to get window rectangle. " + ex.Message);
    }
}

// Check DPI awareness of Runner.
private void CheckDpiAwareness()
{
    if (!OsVersion.IsEightOneOrNewer()) return;

    try
    {
        if ((processRunner != null) &&
            (processRunner.Handle != IntPtr.Zero))
        {
            W32.PROCESS_DPI_AWARENESS awareness;

            int result = W32.GetProcessDpiAwareness(processRunner.Handle, out awareness);

            if (result == 0) // If S_OK
            {
                this.textBox_DpiAwareness.Text = DpiAwareness.NameAwareness(awareness);
            }
            else
            {
                throw new Exception(result.ToString());
            }
        }
    }
    catch (Exception ex)
    {
        this.textBox_DpiAwareness.Text = "Failed";

        Debug.WriteLine("Failed to get DPI awareness. " + ex.Message);
    }
}
Runnerの主要部分。
private void RunnerForm_Move(object sender, EventArgs e)
{
    CheckLocation();

    this.textBox_LocationX.Text = this.Location.X.ToString();
    this.textBox_LocationY.Text = this.Location.Y.ToString();
}

// Check location of this Runner.
private void CheckLocation()
{
    IntPtr handleThis = Process.GetCurrentProcess().MainWindowHandle;

    if (handleThis != IntPtr.Zero)
    {
        try
        {
            W32.RECT rct;

            bool result = W32.GetWindowRect(handleThis, out rct);

            if (result)
            {
                this.textBox_GetWindowRectLeft.Text = rct.Left.ToString();
                this.textBox_GetWindowRectTop.Text = rct.Top.ToString();
            }
            else
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
        }
        catch (Exception ex)
        {
            this.textBox_GetWindowRectLeft.Text = "Failed";
            this.textBox_GetWindowRectTop.Text = "Failed";

            Debug.WriteLine("Failed to get window rectangle. " + ex.Message);
        }
    }
}

private void button_ReturnZero_Click(object sender, EventArgs e)
{
    this.Location = Point.Empty;
}

private void button_MoveOneRight_Click(object sender, EventArgs e)
{
    this.Location = new Point(this.Location.X + 1, this.Location.Y);
}
完全なコードはここからダウンロードできる。

2013/09/02

WindowsフォームとPer-Monitor DPI

Windows 8.1のPer-Monitor DPIに対応するための基本情報を元に、実際にWindowsフォームアプリを対応させる作業に関して。なお、Windows 8.1 Previewに基づくものなので、RTMでは変更があり得る。

[追記] RTMでOS側に変更があったので、続を加えて再構成した。

1. 3つのDPI

Windowsフォームのスケーリングに関する出発点としては以下を参照。この中の「現在の自動スケーリングのサポート」は.NET Framework 2.0の場合として書かれているが、自分が試した範囲では.NET Framework 4.0でも同じだった。
これを踏まえて要点をまとめると、以下の3つのDPIを考慮する必要がある。
  • デザイン時のDPI

    概要 : Visual StudioのデザイナーでUIをデザインしたとき(最後にデザイナーで変更を加えたとき)のDPI。FormのAutoScaleDimensionsプロパティの値。下手な訳では意味が不明瞭になるので、MSDNの説明を引けば、"this property will be set by the Windows Forms designer to the value your monitor is currently using"で、"the AutoScaleDimensions property represents the design-time reference dimensions of the design environment for the current control"となる。

    取得方法 : デザイン時のDPIが100%なら96。実行時にはAutoScaleDimensionsはPerformAutoScaleメソッドによりCurrentAutoScaleDimensionsと同じ値になるので(AutoScaleModeプロパティがDpiの場合)、実際のデザイン時の値は取得できない。あえて実行時に取得するなら対象Formをインスタンス化だけして読み取るなど。Visual Studioで直接確認するには、対象Formのデザイナーファイルを見ればいい。

  • 実行時のDPI(以下、仮に「共通DPI」)

    概要 : 実行時の、全モニター共通のDPI。従来のDPIといえば、これのこと。FormのCurrentAutoScaleDimensionsプロパティの値。MSDNの説明を引けば、"the current run-time dimensions of the screen"で、"The CurrentAutoScaleDimensions property represents the reference dimensions on the current screen"となる。

    取得方法 : CurrentAutoScaleDimensionsから取得。Win32のGetDeviceCapsからLOGPIXELSXまたはLOGPIXELSYフラグで取得したものと、自分が試した範囲では同じだった。CurrentAutoScaleDimensionsが内部的に参照しているものも出所は同じではないかと思う。

  • 実行時のDPI(Per-Monitor DPI用)(以下、仮に「個別DPI」)

    概要 : 実行時の、個別モニターのDPI。Windows 8.1以降のみ。96、120、144、192の4段階(現在のところ)。

    取得方法 : Win32のGetDpiForMonitorから取得するか、WM_DPICHANGEDが来たときは、それに含まれているものを見る。
従来のWindowsフォームの高DPI対応(System DPI Aware)では、基本的にFormのAutoScaleModeをDpiに設定するだけでよかった。これには内部的にデザイン時のDPIと実行時の共通DPIが使われている。

で、Per-Monitor DPI環境での対応(Per-Monitor DPI Aware)はどうかとなると、BUILD 2013のプレゼンテーション(Making Your Desktop Apps Shine on High-DPI Displays)の際、Windowsフォームの対応方法について質問した人が素気なく却下されたのを見るに、Microsoftが何か用意してくれそうな感じではないので、実行時の個別DPIを見て自分で実装することになる。

2. 実装

以下は、従来どおりAutoScaleModeをDpiに設定して、その上にPer-Monitor DPI対応を重ねる形で実装する方法。つまり、CLRにSystem DPI Aware対応をさせた上でPer-Moniter DPI対応を加えることになる。AutoScaleModeをNoneにして、全ての場合のスケーリングを自分で実装する方がシンプルになるかもしれないが、副作用がよく分からなかったので。言語はVB。

2.1 コントロールの位置・サイズ

冒頭のMSDNの説明にあるとおり、PerformAutoScaleは内部的にScaleメソッドを使っている。これは親コントロールから子コントロールに波及していくので、一番の親であるFormにScaleメソッドを使えばいい。この引数にはその前の状態からの倍率を入れる。

例えば、デザイン時のDPIが96、実行時の共通DPIが120、個別DPIが144の場合、アプリの起動時に自動的にPerformAutoScaleで96から120へ1.25倍の拡大が行われる。そこから個別DPIへは120から144へ1.2倍に拡大する必要があるので、以下のようにSizeF構造体にしてScaleを実行する。
Me.Scale(New SizeF(1.2F, 1.2F))
実行中に個別DPIが変わったときは、その前の状態から現在の個別DPIへの倍率を同様に適用すればいい。

ちなみに、これをPerformAutoScaleで行うことはできない。というのは、PerformAutoScaleは内部的にCurrentAutoScaleDimensionsを参照してこれに合わせるメソッドになっていて、CurrentAutoScaleDimensionsはReadOnlyプロパティなので外部から変更できないので。

2.2. フォント

フォントの方は結構トリッキー。

フォントサイズの倍率

第一に、フォントサイズを直接指定はできない。どういうことかというと、コード内で指定されたサイズから、デザイン時のDPIから実行時の共通DPIへの倍率で自動的に拡大されたサイズで表示される。

例えば、デザイン時のDPIが96で実行時の共通DPIが120の場合、コード内で9ポイントと指定されたフォントは1.25倍に拡大されて11.25ポイントで表示される。ここでサイズを参照しても9ポイントのまま。つまり、この表示のスケーリングはサイレントに行われ、コードから直接感知できない。

さらに、コントロールのPerformAutoScaleは基本的にアプリの起動時に行われるだけだが、このフォントのスケーリング効果は実行中ずっと続いている。RPGに例えるなら、基本ステータスは変わらないまま、ずっと拡大のバフ(魔法効果)がかかっているようなもの。

したがって、目的のサイズで表示させるには、このサイレントに行われるスケーリングの倍率を差し引いた倍率で拡大してコード内で指定する必要がある。といっても難しいことはなくて、実行時の共通DPIから個別DPIへの倍率をサイズに掛ければいいだけ。これが1.2倍の場合、以下のようにFontを設定し直す。
Me.Font = New Font(Me.Font.FontFamily, 
                   Me.Font.Size * 1.2F, 
                   Me.Font.Style)
実行中に個別DPIが変わったときは、コントロールと同じくその前の状態から現在の個別DPIへの倍率を適用すればいい。

対象とするコントロール

第二に、フォント設定の対象とするコントロールの問題がある。デザイン時にコントロールのフォント設定をしていない場合(デザイナーでFontプロパティが太字でない状態)、実行時には親コントロールのフォント設定が使われる。したがって、全フォントが同じであればFormのフォント設定だけすればよく、実行時にサイズを変えるときもそれだけを対象にすれば足りる。

が、実際のアプリではそういうわけにも行かないと思うので、以下のようなことが起こる。
  • 個別のコントロールによってフォント設定をした場合、そのコントロールは親コントロールのフォント設定には影響されない(当然)。よって、実行時にサイズを変えるにはそのコントロールを直接対象にする必要がある。

  • ならば、Form中の全コントロールを対象に舐めるようにフォント設定していけばいいかといえば、そうも行かない。デザイン時にフォント設定されていないコントロールには、親コントロールにかけられたスケーリングと直接かけられたスケーリングの両方の効果がかかってしまう。例えば、親コントロールのフォント設定で1.2倍にした後で、子コントロールのフォント設定で1.2倍にすると、合わせて1.44倍の拡大がかかる。
これを回避するには、うまく対象のコントロールを抽出する必要がある(デザイン時に全コントロールに個別にフォント設定する方法は避けたい)。

大まかには以下の流れになると思うが、これ一発でという方法はたぶんない。
  1. Form中のコントロールを配列かコレクションの形で再帰的に取得する(必要であれば、世代を限定する)。

  2. 取得した配列かコレクションからLINQなどでコントロールの型や特定の条件によって抽出する。
例として、Form中のButtonのみを個別にフォント設定している場合。GetChildInControlは引数のコントロールを親とする全コントロールを取得するメソッドで、この中からButtonの型で抽出している。
For Each b As Button In GetChildInControl(Me).OfType(Of Button)()
    b.Font = New Font(b.Font.FontFamily, 
                      b.Font.Size * 1.2F, 
                      b.Font.Style)
Next
別に難しくはないが、うまくやろうとすると頭を使わないといけないという意味で面倒な部分。

2.3 動的なコントロール

実行中に動的にコントロールを作成したり、位置・サイズを変更したりする場合は、
  • 位置・サイズについては、デザイン時の位置・サイズをコード内に持っておいて、デザイン時のDPIから個別DPIへの倍率を掛けるのが簡単だと思う。

  • フォントについては、同じくデザイン時のフォントをコード内に持っておいて、実行時の共通DPIから個別DPIへの倍率を掛ければいい。
掛ける倍率が違うのに注意。

なお、文字表示に必要なコントロールのサイズを求めるのにTextRenderer.MeasureTextメソッドを使うことがあると思うが、これは表示のスケーリングがかかった状態のサイズが取得できるので、とくに手を加える必要はない。ただし、一旦これでサイズを設定した後に個別DPIが変わってScaleメソッドをかけた場合、新しいDPIで取得したTextRenderer.MeasureTextのサイズと微妙にずれが出てくるので、再設定した方がいいかもしれない。

ちなみに、タイトルバーのアイコンと通知領域のアイコンについては、表示される大きさが共通DPIに従って固定されているようなので、変更する意味はとりあえずない。

2.4 正しくスケーリングされないコントロール

一部のコントロールはScaleメソッドを実行しても正しくスケーリングされない。自分が直接知っている例は以下のもの。
  • TextBoxをMultilineにしたときの高さ
  • ListViewをDetailsにしたときのColumnHeaderの幅
弥縫策的には個別にスケーリングした値を与えてやれば済むが、問題を修正したカスタムコントロールを作成してもいいかもしれない。

ということで、TextBoxの高さの方は色々試してみたが、オーバーロードできるメソッドではなかなか難しくて、諦めた(SetBoundsCoreメソッドを使えば何とかなるかもしれない)。が、実はこれにはデザイン時にできる簡単な回避策があって、Panelなどのコンテナコントロールに入れてDockプロパティをFillにすればいい。Panelは正しくスケーリングされるので、中のTextBoxのサイズも正しくスケーリングされるようになる。

ちなみに、これらはScaleメソッドの問題なので、従来の高DPI対応でも発生する。

3. WM_DPICHANGEDの変更

Windows 8.1 RTMではWM_DPICHANGEDが来るタイミングが変更されたので、これへの対応方法を変える必要がある。以下、にて。

2013/07/04

Per-Monitor DPIに備える

Windows 8.1 PreviewにおけるDPIの変化に関して。

1. 背景

Windows 8.1ではマルチモニターにしているときに、モニターごとに違うDPIで使えるようになった。これはユーザーがDPI(scaling level)を大ざっぱに指定するとOSの方で自動的に設定するもので、個別に手動設定できるわけではない。具体的には、「ディスプレイ」設定でスライダーをSmallerからLargerの間で動かして指定する(無段階ではない)。

下にある「Let me choose one scaling level for all my displays」をチェックすれば直接DPIを指定できるようになるが(これも無段階ではない)、その場合DPIは共通になるので、モニターごとに変えることはできない。

どうせならモニターごとに全て手動設定できるようにして欲しい気もするが、推測すれば、大多数のユーザーにとっては自動設定の方がよいと判断した、あるいは細かいカスタマイズを許すとUIの動作検証で死ぬ、という事情があるのかなと思う。アプリを作る側としても後者は無視できない問題なので。

ともかく、デスクトップアプリはこうしたモニターごとに違うDPIに対応できるように、というのがMicrosoftからの宿題になる。

2. コーディング

基本情報としては以下のとおり。
この中でキーになるのは以下のもの。
  • DPIの変化を知らせるウィンドウメッセージ: WM_DPICHANGED
  • モニターごとのDPIを取得するAPI: GetDpiForMonitor
  • アプリがDPI Awareであることを示すマニフェストの拡張: 「Per Monitor」と「True/PM」
で、現時点で出ている情報は基本的にC++向けなので、これをVBで書いたWindowsフォームアプリから試してみた。環境はWindows 8.1 Preview上のVisual Studio 2012 Expressで、対象のフレームワークを.NET Framework 4 Client ProfileとしたWindowsフォームアプリをVisual Basicで作成。

WM_DPICHANGED

このウィンドウメッセージのID番号はhiyohiyoさんに教えていただいて0x02E0と分かったので(Visual Studio 2013 PreviewのSDK中のWinUser.hにある)、後はLOWORDとHIWORDのマクロをメソッド化して作成。
Protected Overrides Sub WndProc(ByRef m As Message)
    MyBase.WndProc(m)

    Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h

    If (m.Msg = WM_DPICHANGED) Then
        'wParam
        Dim lo As Single = GetLoWord(m.WParam.ToInt32)
        Dim hi As Single = GetHiWord(m.WParam.ToInt32)

        Me.TextBox_LoWord.Text = lo.ToString()
        Me.TextBox_HiWord.Text = hi.ToString()

        'lParam
        Dim r As RECT = CType(Marshal.PtrToStructure(m.LParam, GetType(RECT)), RECT)

        Me.TextBox_Position.Text = String.Format("{0},{1}", r.top, r.left)
        Me.TextBox_Size.Text = String.Format("{0}x{1}", r.bottom - r.top, r.right - r.left)
    End If
End Sub

Private Function GetLoWord(dword As Int32) As Int16
    Return Convert.ToInt16(dword And &HFFFF)
End Function

Private Function GetHiWord(dword As Int32) As Int16
    Return Convert.ToInt16(dword >> 16)
End Function

<StructLayout(LayoutKind.Sequential)>
Private Structure RECT
    Private left As Integer
    Private top As Integer
    Private right As Integer
    Private bottom As Integer
End Structure
GetDpiForMonitor

これはまずアプリのウィンドウハンドルを取得し、次に現在のモニターのハンドルをMonitorFromWindowをP/Invokeで実行して取得し、その後にGetDpiForMonitorをP/Invokeで実行する流れで作成。
Private Sub Button_GetDpi_Click(sender As Object, e As EventArgs) Handles Button_GetDpi.Click
    'Get handle to this window.
    Dim windowHandle As IntPtr = Process.GetCurrentProcess().MainWindowHandle

    'Get handle to monitor that contains this window.
    Dim monitorHandle As IntPtr = MonitorFromWindow(windowHandle, MONITOR_DEFAULTTOPRIMARY)

    'Get DPI (If the OS is not Windows 8.1 or newer, calling GetDpiForMonitor will cause exception).
    Dim dpiX As UInteger
    Dim dpiY As UInteger
    Dim result As Integer

    Try
        result = GetDpiForMonitor(monitorHandle,
                                  Monitor_DPI_Type.MDT_Default, 
                                  dpiX, dpiY)

    Catch ex As Exception
        result = 1 'Not S_OK (= 0)
    End Try

    If (result = 0) Then 'If S_OK (= 0)
        Me.TextBox_dpiX.Text = dpiX.ToString()
        Me.TextBox_dpiY.Text = dpiY.ToString()
    Else
        Me.TextBox_dpiX.Text = "Failed"
        Me.TextBox_dpiY.Text = "Failed"
    End If
End Sub

<DllImport("user32.dll", SetLastError:=True)>
Private Shared Function MonitorFromWindow(ByVal hwnd As IntPtr,
                                          ByVal dwFlags As Integer) As IntPtr
End Function

Private Const MONITORINFOF_PRIMARY As Integer = &H1
Private Const MONITOR_DEFAULTTONEAREST As Integer = &H2
Private Const MONITOR_DEFAULTTONULL As Integer = &H0
Private Const MONITOR_DEFAULTTOPRIMARY As Integer = &H1

<DllImport("Shcore.dll", SetLastError:=True)>
Private Shared Function GetDpiForMonitor(ByVal hmonitor As IntPtr,
                                         ByVal dpiType As Monitor_DPI_Type,
                                         ByRef dpiX As UInteger,
                                         ByRef dpiY As UInteger) As Integer
End Function

Private Enum Monitor_DPI_Type As Integer
    MDT_Effective_DPI = 0
    MDT_Angular_DPI = 1
    MDT_Raw_DPI = 2
    MDT_Default = MDT_Effective_DPI
End Enum
MonitorFromWindowの第2引数とGetDpiForMonitorの第2引数の選択は要検討。なお、GetDpiForMonitorは「Shcore.dll」のない環境では当然ながら例外を起こす。

DPI Awareのマニフェスト

プロジェクトにマニフェストファイルを追加し、そこに<dpiAware>を記述。
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
  <asmv3:windowsSettings
       xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
    <dpiAware>Per Monitor</dpiAware>
  </asmv3:windowsSettings>
</asmv3:application>
ここの値を「Per Monitor」(ハイフンなし)か「True/PM」にしないとWM_DPICHANGEDは来ない模様。

3. テスト

このアプリの見た目は以下のとおりで、コード中の記述との対応関係は見れば分かると思う。「Get DPI」のボタンは手動でGetDpiForMonitorを実行するもの。最初にDPI120のモニター上にある状態。

これをドラッグしてDPI96のモニターに移すとこうなる。

さらにドラッグしてDPI120のモニターに戻すとこうなる。

まずは成功で、見ているとアプリのウィンドウがモニターの境界を半分過ぎたぐらいでWM_DPICHANGEDが来ている感じ。これで問題なしかというとそうでもなくて、スライダーを動かして「適用」したときにDPIが変わっても反応がなかったり、一旦サインアウトしないと効かないこともあったりして、「あれー?」というところはある。

ともあれ、以上のように割と少ない手間で実装はできると思う。その上でDPIに応じたUIを作り込むのはまた別問題だけど。

[追記] Windows 8.1のバージョンの取得

Windows 8.1のバージョン番号は6.3で、Windows 8の6.2からマイナー番号が1つ上がっている。これはverコマンドでも確認できる。

が、.NETのSystem.Environment.OSVersionで見ても、Win32のGetVersionExで見てもバージョンは「6.2.9200」と返ってくるので、「ベータだからか~」と思っていた(念のため、Visual Studio 2013 Previewでアプリを作成し、Visual Studioの中から実行するとちゃんとWindows 8.1のバージョンが返ってくる。ただし、同じアプリをVisual Studioの外から実行するとダメ)。

実際はそういうことではなくて、意図的なものだった。
GetVersionExはあえて古いバージョンを返すように変更されたということで、System.Environment.OSVersionについて言及はないが、内部的にGetVersionExを呼び出しているようなので同じ結果になる。

この対応策としては以下の二通り。
  1. 対応しているOSとしてWindows 8.1をマニフェストに追加する。
  2. Win32のVerifyVersionInfoを使う。
VerifyVersionInfoはOSに関係なく使えるが、こちらから提示したバージョンに対して正否を返してくるAPIなので、少しだけ面倒。一方、マニフェストへの追加はこの問題と関係なくやるだろうし、この方が正攻法、かつコード量もごく少なくて済む。

具体的には、以下のようなもの。
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
  <application>
    <!-- Windows 7 -->
    <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
    <!-- Windows 8 -->
    <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
    <!-- Windows 8.1 -->
    <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
  </application>
</compatibility>
これでSystem.Environment.OSVersionも、GetVersionExも普通に「6.3.9431」を返してくるようになった。というわけで、OSのバージョンを見てPer-Monitor DPI対応を切り替えることができる。

一応VerifyVersionInfoを使った方も。Windows 8.1以降か否かを判別可能。
Private Sub CheckEightOneOrNewer()
    'Set expected OS version.
    Dim osvi As New OSVERSIONINFOEX()
    osvi.dwMajorVersion = 6 'Major version number
    osvi.dwMinorVersion = 3 'Minor version number
    osvi.dwOSVersionInfoSize = Convert.ToUInt32(Marshal.SizeOf(osvi))

    'Set condition mask (equal to or newer than designated OS version).
    Dim cm As UInt64 = 0
    cm = VerSetConditionMask(cm, VER_MAJORVERSION, VER_GREATER_EQUAL)
    cm = VerSetConditionMask(cm, VER_MINORVERSION, VER_GREATER_EQUAL)

    'Perform VerifyVersionInfo.
    Dim result As Boolean = VerifyVersionInfoW(osvi,
                                               VER_MAJORVERSION Or VER_MINORVERSION,
                                               cm)

    If (result = True) Then
        MessageBox.Show("OS is Windows 8.1 or newer.")
    Else
        If (Marshal.GetLastWin32Error() = 1150) Then 'If ERROR_OLD_WIN_VERSION
            MessageBox.Show("OS is older than Windows 8.1.")
        Else
            MessageBox.Show("Failed to check OS version.")
        End If
    End If
End Sub

<DllImport("kernel32", SetLastError:=True)>
Private Shared Function VerifyVersionInfoW(ByVal osvi As OSVERSIONINFOEX,
                                           ByVal dwTypeMask As UInt32,
                                           ByVal dwlConditionMask As UInt64) As <MarshalAs(UnmanagedType.Bool)> Boolean
End Function

<DllImport("kernel32", SetLastError:=True)>
Private Shared Function VerSetConditionMask(ByVal dwlConditionMask As UInt64,
                                            ByVal dwTypeBitMask As UInt32,
                                            ByVal dwConditionMask As Byte) As UInt64
End Function

<StructLayout(LayoutKind.Sequential)>
Private Structure OSVERSIONINFOEX
    Public dwOSVersionInfoSize As UInt32
    Public dwMajorVersion As UInt32
    Public dwMinorVersion As UInt32
    Public dwBuildNumber As UInt32
    Public dwPlatformId As UInt32
    <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=128)>
    Public szCSDVersion As String
    Public wServicePackMajor As UInt16
    Public wServicePackMinor As UInt16
    Public wSuiteMask As UInt16
    Public wProductType As Byte
    Public wReserved As Byte
End Structure

Private VER_MINORVERSION As UInt32 = &H1
Private VER_MAJORVERSION As UInt32 = &H2
Private VER_BUILDNUMBER As UInt32 = &H4
Private VER_PLATFORMID As UInt32 = &H8
Private VER_SERVICEPACKMINOR As UInt32 = &H10
Private VER_SERVICEPACKMAJOR As UInt32 = &H20
Private VER_SUITENAME As UInt32 = &H40
Private VER_PRODUCT_TYPE As UInt32 = &H80

Private VER_EQUAL As Byte = 1
Private VER_GREATER As Byte = 2
Private VER_GREATER_EQUAL As Byte = 3
Private VER_LESS As Byte = 4
Private VER_LESS_EQUAL As Byte = 5
Private VER_AND As Byte = 6
Private VER_OR As Byte = 7

2013/03/20

バージョン情報の作成

Visual StudioでWin32アプリケーションを作るとき、Express版では実行ファイルのバージョン情報を作成する機能が省かれているが、これを手動で作成する方法。

1. 前置き

対象はVisual Studio Express 2012 for Windows Desktopで、Visual C++でWin32コンソールアプリケーションを選んだ場合。C#やVisual Basicの場合は標準でバージョン情報が付くので関係ない。

バージョン情報とは実行ファイルのプロパティを開いたとき「詳細」タブに表示されるもので、この有無はプログラムの実行には関係ないが、何のプログラムか分からなくなったときにその素性を知る手掛かりになる。というより、素性が不明の実行ファイルをいきなり実行するのは普通に考えて危険なので、そういう危険かもしれないファイル扱いされないためには、バージョン情報を付けておくのがベターという話。

2. 作成方法

テキストファイルをプロジェクトに追加するだけなので、そんなに難しくない。

2.1. リソーススクリプトの追加

リソーススクリプトとはバージョン情報などの情報を記述したテキストファイル。Win32コンソールアプリケーションの場合は自動的には作成されないので、追加する必要がある。この例ではプロジェクト名を「Nameless」、リソーススクリプト名を「Nameless.rc」とする。

ソリューションエクスプローラー -> リソースファイル -> 追加 -> 新しい項目 -> ユーティリティ -> テキストファイル(.txt) と進み、ファイル名を「Nameless.rc」と編集して追加。

2.2. リソーススクリプトの編集

作成されたNameless.rcのコードを表示して、以下のように編集する。
#include <windows.h>

VS_VERSION_INFO    VERSIONINFO
FILEVERSION        0,1,0,0
PRODUCTVERSION     0,1,0,0
FILEFLAGSMASK      VS_FFI_FILEFLAGSMASK
FILEFLAGS          0x0L
FILEOS             VOS_NT_WINDOWS32
FILETYPE           VFT_APP
FILESUBTYPE        VFT2_UNKNOWN
BEGIN
    BLOCK "StringFileInfo"
    BEGIN
        BLOCK "000004b0" // Neutral language, Unicode
        BEGIN
            VALUE "CompanyName",      ""
            VALUE "FileDescription",  "Win32 console application"
            VALUE "FileVersion",      "0.1.0.0"
            VALUE "InternalName",     "Nameless.exe"
            VALUE "LegalCopyright",   "Copyright (c) 2013 Nameless man"
            VALUE "OriginalFilename", "Nameless.exe"
            VALUE "ProductName",      "Nameless application"
            VALUE "ProductVersion",   "0.1.0.0"
        END 
    END
    BLOCK "VarFileInfo"
    BEGIN
        VALUE "Translation", 0x0, 1200 // Neutral language, Unicode
    END
END
それぞれの意味はMSDNのページ(VERSIONINFO resource)に網羅されている。

とくに説明が必要な点としては、
  • VS_FFI_FILEFLAGSMASK、VOS_NT_WINDOWS32、VFT_APP、VFT2_UNKNOWNの部分は直接数字(Long)で指定することも可能で、その場合は冒頭のwindows.hのincludeは不要。
  • 7行目のFILEFLAGSの0x0Lはリリース版の意味。
  • 14行目の「BLOCK "000004b0"」と28行目の「VALUE "Translation", 0x0, 1200」は対の関係にあって、前者の上4桁「0000」と後者の「0x0」はlangID、前者の下4桁「04b0」と後者の「1200」はcharsetIDを示す。この例ではlangIDは指定なし、すなわちニュートラル言語で、charsetIDはUnicodeの意味。
2.3. ビルド

後はプロジェクトのビルド時にリソーススクリプトのコンパイルとリンクもVisual Studioが自動的にやってくれるので、何もなし。

この例では実行ファイルの「詳細」タブは以下のようになる。

2013/03/15

HD Tuneのぎざぎざの評価

HD Tuneのぎざぎざの形は偽のものだとして、これをどう捉えるか。

(このエントリは一続きのエントリの2/2)

2.1. Partial testとFull test

Partial testのぎざぎざが偽のものだとしても、直ちにPartial testはダメで、Full testの方がいいとも言い切れない。この点は自分も逆に考えていた。

なぜFull testでは乱れの少ないきれいなチャートになるかというと、比較的大きなサイズの計測結果からまとめて計算することで、その間の動きをすべて平均化してしまうから。例えば1TBのHDDをFull testで計測すると、200等分の5GBごとにまとめて計算することになる。

こういう平均化は小さくランダムな動きをならして安定的な値を得るには役立つが、記録面の波形はDisk Gazerで見られるように画然として存在するし、その高さも幅も決して小さなものではない。HD Tuneのように位置ごとの速度を示すよう設計されたベンチマークで、波形を押し潰すようにして平均化することが果たして正しいか。

むしろ、チャートの各位置の範囲に含まれる速度の幅をなるべくそのまま表示した方がいいように思う。図にすると、

左側のような一本の線より、右側のような太さのある帯のイメージ。

結局は使う人が何を見たいかによるが、Partial testもその性格を理解して使うなら(という前提は往々にしてスルーされるものだが)、速度の幅をある程度示すものとしてアリではないかと思う。

2.2. Partial test中の違い

という前提でPartial testについて改めて考えると、5段階あるPartial testの各計測点におけるテストサイズは以下のようになっていた。
Fast側から
各計測点における
速度との比
平均
概数
1
13.0%
1/8
2
25.5%
1/4
3
50.5%
1/2
4
100.5%
1
5
200.6%
2

比は4段目を中心として2の倍数になっているが、1段目などはかなり小さい。

これが結果にどう影響するかというと、7K1000で段を変えながら計測すると以下のようになる。
Travelstar 7K1000: HD Tune Pro (Partial test)

段が低いとぎざぎざが激しく、段が高くなるにつれなだらかになるのが分かる。

このどちらの方がいいかとなると、なだらかになるのはテストサイズが大きくなる分、面の間に掛かりやすくなって双方の面の中間の値になる、すなわち双方ともの本来の値から乖離するということだから、実際の速度の幅を忠実に示すという観点からは、逆にテストサイズが小さい低い段の方が、面の本来の値を伝える計測点が多くなって望ましい、と考えることができる。

2.3. まとめ

HD Tuneのぎざぎざの形は偽のものだが、その点を理解して使うなら、むしろPartial testの、その中でもより粗い低い段の方が結果的に実際の速度を忠実に示すので望ましい、という一見転倒した結論になった。我ながら予想外。

HD Tuneのぎざぎざの理由

HD TuneのBenchmarkがAdaptive FormattingのHDDでぎざぎざを描く理由について、記録面の波形によるものと推測してきたが、その結論。

(このエントリは一続きのエントリの1/2)

1.1. HD TuneのBenchmarkの計測方法

他のソフトの動作を外から分析するのはあまり行儀がいいとは言えない気がするが、これを確認しないと先に進めないので、HD Tune Pro(4.01)がBenchmarkを実行中にどうアクセスしているかをProcess Monitorで見てみた。対象はTravelstar 7K1000で容量は1TB。設定はPartial testで5段階中のAccurate側の4段目(デフォルト)、Block sizeは1MiBに設定してリード。

これは開始時で、この後ずっとReadFile(Win32でリードするAPI)が続いていくが、このDetailにリードした位置とサイズが出ている。Offsetが先頭からの距離、すなわち位置で、Lengthがサイズ。単位はByte。

初めに位置0でサイズが512Byte、すなわち1セクタのリードをしている。設定と違うので何かと思ったが、計測位置をジャンプさせる度に初めは同様に512Byteのリードをしているので、これはたぶんHDDをシークさせるためだけのリードで、速度の計算には入れてないのだろうと思う。

これに続いて1048576Byte=1MiBのリードが、隙間なくシーケンシャルに行われている。

結果をまとめると、少し戸惑わせることが分かった。
  1. 計測したサイズ(1MiB×リード回数)は計22250MiBで、容量の2.3%。まあそんなものか。
  2. 計測点の数は200で、各計測点の開始位置は容量を単純に200等分したもの(0、5GB、10GB……)。これはログから予想できたこと。
  3. 各計測点のサイズは一定ではない。……何コレ。
速度を計算するには比が分かればいいのであって、一定のサイズにする必然性はないが、計測条件はなるべく揃えるものじゃないかと思いつつ眺めていたら、気づいた。各計測点のサイズは、その計測点における速度に比例している。

各計測点における速度とテストサイズの関係をまとめたのが以下。速度はMB/sに、テストサイズはMBに、位置は都合でGiBに換算してある。

ほぼ完全に一致している。意図はよく分からないが、たぶん計測しながら速度を見て後何回リードするか決めているのか、予め決めた時間内だけリードを繰り返すようにしているのだと思う。

ちなみに、X25-M G2を5段階中の5段目で計測したときはテストサイズは381MiBで一定だったので、上限はあるっぽい。

1.2. 計測点をDisk Gazerと比較

とにかくHD Tune Proがリードした位置とサイズは分かったので、先頭から40個の計測点について、その位置の波形をDisk Gazerで計測したものと比較してみる。

左側がHD Tune Proのログから抜き出したもので、赤点が各計測点。右側のDisk Gazerのチャートで計測点に当たる部分を赤枠で囲ってある(数が多いので、スライドさせながら見てもらうといいと思う)。

一目瞭然だが、HD Tune Proの値は計測点が波形のどの部分にかかっているかで決定されているのが分かる。荒れた海を走る船のごとく、高い面に乗れば高く、低い面に落ちれば低く、面の間にまたがったときはその割合に応じて、上下している。

これがHD Tuneのぎざぎざの理由、ということになる。

各計測点における値は、その位置とサイズで得られた値として間違ってはいないが、それらを結んだチャートの形は、そのとおりに実際の速度が変動しているわけではないという意味で、偽のものと言えると思う。

次に、これをどう評価するかについて。