From 4f0217cc60566b124c5c0c496693868df2095291 Mon Sep 17 00:00:00 2001
From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com>
Date: Thu, 5 Nov 2026 12:41:56 +0800
Subject: [PATCH] feat: add 1080p and 720p resolution presets
---
.github/workflows/ci.yml | 5 +
README.en.md | 8 +-
README.md | 8 +-
internal/avd/avd.go | 92 ++++++++++++----
internal/avd/avd_test.go | 4 +-
internal/avd/resolution_test.go | 144 +++++++++++++++++++++++++
internal/avd/testdata/instances_ref.py | 17 +--
internal/cli/args_test.go | 11 +-
internal/cli/commands.go | 21 ++--
internal/cli/format.go | 4 +-
internal/cli/run_test.go | 6 +-
internal/emu/crud.go | 8 +-
internal/emu/launch.go | 8 +-
internal/emu/ops_root_test.go | 8 +-
internal/emu/status.go | 2 +-
internal/emu/types.go | 11 +-
internal/mcp/mcp_test.go | 10 +-
internal/mcp/tools.go | 18 +++-
internal/ui/API.md | 10 +-
internal/ui/device.go | 26 +++--
internal/ui/device_test.go | 41 +++++++
internal/ui/handlers.go | 10 +-
internal/ui/http_test.go | 10 +-
internal/ui/web/dev/mock.js | 11 +-
internal/ui/web/js/display.js | 10 ++
internal/ui/web/js/forms.js | 9 +-
internal/ui/web/js/viewer.js | 15 ++-
scripts/test_display_tap.mjs | 31 ++++++
scripts/test_resolution_guards.py | 70 ++++++++++++
scripts/test_resolution_guest.py | 94 ++++++++++++++++
30 files changed, 616 insertions(+), 106 deletions(-)
create mode 100644 internal/avd/resolution_test.go
create mode 100644 internal/ui/web/js/display.js
create mode 100644 scripts/test_display_tap.mjs
create mode 100644 scripts/test_resolution_guards.py
create mode 100644 scripts/test_resolution_guest.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 96d93e9..1adea78 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,6 +32,11 @@ jobs:
with:
go-version: ${{ steps.go.outputs.version }}
cache: false
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: 24
+ - name: display coordinate tests
+ run: node --test scripts/test_display_tap.mjs
- name: gofmt
shell: pwsh
run: |
diff --git a/README.en.md b/README.en.md
index 1458cad..dfa8e7f 100644
--- a/README.en.md
+++ b/README.en.md
@@ -62,8 +62,8 @@ modavd [--root
] [--json] [--quiet] [--verbose] [args]
| --- | --- |
| `list` | All instances and their status |
| `info ` / `status ` | Details of one instance / its status |
-| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--no-bridge] [--timeout 1200]`, `create --resume N` | Create an instance, then start it once in the background, headless, to finish initialization, and shut it down (see below). The arm64 execution bridge is included by default; `--no-bridge` records that this instance does not want the bridge, so it is not installed on later starts either. `--resume N` redoes the initialization of an instance whose creation failed |
-| `set [--title T] [--memory M] [--cpu N] [--fps F] [--data-gb N]` | Change settings; they take effect at the next start. `--data-gb` can only be increased, and game data is untouched: the emulator (from 0.36) grows the disk image at start and grows the file system online after boot. A disk created before 0.36 at 16 GB or smaller can only be grown to just below the next backup group (a 16 GB disk to about 30 GB) |
+| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--resolution 1080\|720] [--no-bridge] [--timeout 1200]`, `create --resume N` | Create an instance, then start it once in the background, headless, to finish initialization, and shut it down (see below). The arm64 execution bridge is included by default; `--no-bridge` records that this instance does not want the bridge, so it is not installed on later starts either. `--resume N` redoes the initialization of an instance whose creation failed |
+| `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb N]` | Change settings; they take effect at the next start. `--data-gb` can only be increased, and game data is untouched: the emulator (from 0.36) grows the disk image at start and grows the file system online after boot. A disk created before 0.36 at 16 GB or smaller can only be grown to just below the next backup group (a 16 GB disk to about 30 GB) |
| `proxy [on\|off\|test] [--server URL\|auto] [--add PKG] [--remove PKG] [--apps PKG]` | Per-app proxy: the listed apps go through a proxy, the others direct (see below) |
| `delete --yes` | Delete a stopped instance, together with its game data |
| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | Start (headless by default, with the window for `--window`), wait until boot finishes and the post-boot setup is done, and before returning confirm that files can be written to `/sdcard` (normally it is ready long before this; it waits up to 60 more seconds and only warns at the deadline). If the instance is shutting down (for example, right after a click on the emulator window's ×), it first waits for the shutdown to finish (up to 30 seconds) and then starts it. If the instance has no execution bridge yet, install it afterward; `--no-bridge` skips that this time. No switch while it runs: if the instance is already running and `--window` / `--headless` asks for the other way, it fails (exit code 5) without taking it over or restarting it; to switch, `stop` and then `start`. With neither, it is taken over as it runs |
@@ -148,6 +148,10 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args]
- The number of loops never exceeds what the host has left: logical processors minus the vCPUs of every qemu (this instance's included; counted within its group when pinned).
- So on machines with fewer than twice as many logical processors as the instance has vCPUs, even a single instance gets fewer loops: a default 6-core instance gets 2 on an 8-thread machine and none on 6 threads or fewer, and downloads are slower than with all loops (measured on a 7950X3D, 1–2 loops do about as well as none).
- This is a deliberate trade-off: it leaves processors for qemu's own threads, Windows and other programs.
+Two display presets are available: 1080p is 1920×1080 / DPI 280; 720p is 1280×720 / DPI 240. New instances default to 1080p. 720p uses fewer resources. The manager forms, CLI `--resolution 1080|720` and MCP `resolution` share these presets. Existing landscape or portrait 1920×1080 instances stay at 1080p; other configurations, including missing dimensions, stay at 720p. Changing the preset updates width, height and DPI together, preserves orientation, and takes effect at the next cold boot. DPI is not a separate option. No runtime `wm size` or `wm density` is used. Update the resolution in your automation tool as well.
+
+AUTO-MAS currently prefers the packaged CLI, which preserves the selected preset. Old roots without a usable CLI fall back to the Python launch path, which still forces 720p. Update the packaged CLI before using 1080p there. AUTO-MAS creation and display settings still need matching two-preset support; use the ModAVD manager to set the preset meanwhile.
+
## MCP
```
diff --git a/README.md b/README.md
index a986b44..5c0b967 100644
--- a/README.md
+++ b/README.md
@@ -62,8 +62,8 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
| --- | --- |
| `list` | 全部实例和状态 |
| `info ` / `status ` | 一台实例的详细信息 / 状态 |
-| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--no-bridge] [--timeout 1200]`、`create --resume N` | 新建,并在后台无头开机一次把初始化做完再关机(见下)。默认带 arm64 执行桥;`--no-bridge` 记下这台不要桥,以后开机也不装。`--resume N` 重做新建失败的那台的初始化 |
-| `set [--title T] [--memory M] [--cpu N] [--fps F] [--data-gb N]` | 修改,下次开机生效。`--data-gb` 只能改大,游戏数据不动:模拟器(0.36 起)开机时扩磁盘镜像,开机后在线扩文件系统。0.36 之前按 16 GB 及以下建的盘只能扩到下一个备份组之前(16 GB 约到 30 GB) |
+| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--resolution 1080\|720] [--no-bridge] [--timeout 1200]`、`create --resume N` | 新建,并在后台无头开机一次把初始化做完再关机(见下)。默认带 arm64 执行桥;`--no-bridge` 记下这台不要桥,以后开机也不装。`--resume N` 重做新建失败的那台的初始化 |
+| `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb N]` | 修改,下次开机生效。`--data-gb` 只能改大,游戏数据不动:模拟器(0.36 起)开机时扩磁盘镜像,开机后在线扩文件系统。0.36 之前按 16 GB 及以下建的盘只能扩到下一个备份组之前(16 GB 约到 30 GB) |
| `proxy [on\|off\|test] [--server URL\|auto] [--add 包名] [--remove 包名] [--apps 包名]` | 按应用走代理:名单里的应用走代理,其余直连(见下) |
| `delete --yes` | 删除已关机的实例,连同游戏数据 |
| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 |
@@ -151,6 +151,10 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
- 循环数不超过宿主的余量:逻辑核数减去所有 qemu 的 vCPU 数(含本实例;绑了核就按那一组算)。
- 所以逻辑核数不到本实例 vCPU 两倍的电脑上,只开一台也会少开:默认 6 核的实例在 8 线程的电脑上只开 2 个,6 线程及以下不开,下载会比开满时慢(在 7950X3D 上实测,1–2 个循环和不开差不多)。
- 这是有意的取舍:给 qemu 自己的线程、Windows 和别的程序留出核。
+分辨率只有两档:1080p 为 1920×1080 / DPI 280,720p 为 1280×720 / DPI 240。新建默认 1080p,720p 更省资源。管理器的新建和实例设置、CLI `--resolution 1080|720`、MCP `resolution` 使用同一套档位。已有实例按宽高识别:横屏或竖屏的 1920×1080 保持 1080p,其余配置及缺字段的旧配置保持 720p。换档联动宽、高、DPI,保留方向,下次冷启动生效;DPI 不单独设置,不使用运行时 `wm size` 或 `wm density`。自动化工具里的分辨率也要跟着改。
+
+AUTO-MAS 当前优先调用包内 CLI,这条启动路径保持所选档位。没有可用 CLI 的旧根目录会回退到固定 720p 的 Python 启动路径,使用 1080p 前须更新包内 CLI。AUTO-MAS 的旧新建和显示配置表单仍需配套支持两档;可先在 ModAVD 管理器里设置。
+
## MCP
```
diff --git a/internal/avd/avd.go b/internal/avd/avd.go
index 5eee38b..a10f095 100644
--- a/internal/avd/avd.go
+++ b/internal/avd/avd.go
@@ -2,7 +2,7 @@
// mas-avd.json. File operations only; nothing here looks at processes or ports (callers pass a busy-port check).
//
// Port of AUTO-MAS app/utils/common/emulator2/avd/instances.py (+ the choices in constants.py). Files must come out
-// byte-identical to what AUTO-MAS writes, so the two can manage the same root during the move to modavd.
+// compatible with AUTO-MAS, except for the two display presets added in ModAVD#100.
package avd
import (
@@ -24,9 +24,10 @@ import (
// Instance options (user decisions: memory and cores only in these steps, no silent rounding).
var (
- MemoryChoicesMB = []int{3072, 4096, 5120, 6144}
- CPUChoices = []int{2, 4, 6}
- FPSChoices = []int{30, 60}
+ MemoryChoicesMB = []int{3072, 4096, 5120, 6144}
+ CPUChoices = []int{2, 4, 6}
+ FPSChoices = []int{30, 60}
+ ResolutionChoices = []int{1080, 720}
)
const (
@@ -36,9 +37,9 @@ const (
DefaultDataPartitionGB = 64
MinDataPartitionGB = 16
MaxDataPartitionGB = 512
- // Display is fixed at 720p (user 10-04), written into config.ini before every boot, never `wm size`.
- ScreenWidth, ScreenHeight, ScreenDensity = 1280, 720, 240
- TargetAPI = "android-34"
+ // New instances default to 1080p. Existing instances are identified by their dimensions.
+ DefaultResolution = 1080
+ TargetAPI = "android-34"
// image.sysdir.1, relative to the SDK root, with the trailing backslash AUTO-MAS writes.
SystemImageSysdir = `system-images\android-34\google_apis\x86_64\`
)
@@ -531,6 +532,18 @@ func ValidateFPS(fps *int) (int, error) {
return value, nil
}
+// ValidateResolution defaults new instances to 1080p; DPI is part of the preset.
+func ValidateResolution(resolution *int) (int, error) {
+ value := DefaultResolution
+ if resolution != nil {
+ value = *resolution
+ }
+ if !slices.Contains(ResolutionChoices, value) {
+ return 0, errs.New(errs.InvalidArgument, "分辨率只能选 1080 或 720")
+ }
+ return value, nil
+}
+
// isLineBreak is every character str.splitlines() splits at: a title holding one would break config.ini apart.
func isLineBreak(r rune) bool {
switch r {
@@ -556,6 +569,7 @@ type CreateOptions struct {
CPU int
DataPartitionGB int
FPS int
+ Resolution int // 0 = DefaultResolution
// Creating also sets instances..creationState = "creating" (emu's create setup), in the same write as the other
// keys: the instance is never listed without it.
Creating bool
@@ -585,6 +599,15 @@ func Create(r *root.Root, i int, opt CreateOptions) (*Instance, error) {
if err != nil {
return nil, err
}
+ var resolutionArg *int
+ if opt.Resolution != 0 {
+ resolutionArg = &opt.Resolution
+ }
+ resolution, err := ValidateResolution(resolutionArg)
+ if err != nil {
+ return nil, err
+ }
+ display := displayChanges(nil, resolution)
if err := validateTitle(opt.Title); err != nil {
return nil, err
}
@@ -612,9 +635,9 @@ func Create(r *root.Root, i int, opt CreateOptions) (*Instance, error) {
{"disk.dataPartition.size", strconv.Itoa(dataGB) + "G"},
{"hw.cpu.ncore", strconv.Itoa(cpu)},
{"hw.ramSize", strconv.Itoa(memory) + "M"},
- {"hw.lcd.width", strconv.Itoa(ScreenWidth)},
- {"hw.lcd.height", strconv.Itoa(ScreenHeight)},
- {"hw.lcd.density", strconv.Itoa(ScreenDensity)},
+ {"hw.lcd.width", display[0][1]},
+ {"hw.lcd.height", display[1][1]},
+ {"hw.lcd.density", display[2][1]},
{"hw.lcd.vsync", strconv.Itoa(fps)},
{"image.sysdir.1", SystemImageSysdir},
} {
@@ -703,10 +726,11 @@ func FreeIndex(plan root.PortPlan, existing map[int]bool, busy func(ports []int)
}
type UpdateOptions struct {
- Title *string
- MemoryMB *int
- CPU *int
- FPS *int
+ Title *string
+ MemoryMB *int
+ CPU *int
+ FPS *int
+ Resolution *int
// DataPartitionGB raises disk.dataPartition.size (modavd only; AUTO-MAS cannot change it): within
// MinDataPartitionGB..MaxDataPartitionGB and never below the current value. The emulator (sdk-mas36 on) grows the
// disk image at the next boot without touching the data, and the boot's after-boot step grows the file system.
@@ -750,6 +774,12 @@ func Update(r *root.Root, i int, opt UpdateOptions) (map[string]any, error) {
return nil, err
}
}
+ var resolution int
+ if opt.Resolution != nil {
+ if resolution, err = ValidateResolution(opt.Resolution); err != nil {
+ return nil, err
+ }
+ }
memoryArg := opt.MemoryMB
if memoryArg == nil {
current := MemoryFor(r, i)
@@ -786,6 +816,9 @@ func Update(r *root.Root, i int, opt UpdateOptions) (map[string]any, error) {
if opt.FPS != nil {
changes = append(changes, [2]string{"hw.lcd.vsync", strconv.Itoa(fps)})
}
+ if opt.Resolution != nil {
+ changes = append(changes, displayChanges(in.Config, resolution)...)
+ }
if opt.DataPartitionGB != nil {
changes = append(changes, [2]string{"disk.dataPartition.size", strconv.Itoa(data) + "G"})
}
@@ -817,6 +850,9 @@ func Update(r *root.Root, i int, opt UpdateOptions) (map[string]any, error) {
if opt.FPS != nil {
applied["fps"] = fps
}
+ if opt.Resolution != nil {
+ applied["resolution"] = resolution
+ }
if opt.DataPartitionGB != nil {
applied["data_partition_gb"] = data
}
@@ -900,19 +936,35 @@ func deleteInstanceFiles(r *root.Root, i int) error {
// ---- display -----------------------------------------------------------------------------------------------------
-// DisplayChanges is the fixed 720p for cfg keeping its orientation (portrait when height > width), plus
-// hw.mainKeys=yes: hw.lcd.width, hw.lcd.height, hw.lcd.density, hw.mainKeys in that order.
-func DisplayChanges(cfg *Ini) [][2]string {
+// Resolution identifies 1080p in either orientation. All other old configurations stay 720p.
+func (in *Instance) Resolution() int { return resolutionFor(in.Config) }
+
+func resolutionFor(cfg *Ini) int {
w, _ := intOrNone(cfg.Value("hw.lcd.width"))
h, _ := intOrNone(cfg.Value("hw.lcd.height"))
- width, height := ScreenWidth, ScreenHeight
+ if (w == 1920 && h == 1080) || (w == 1080 && h == 1920) {
+ return 1080
+ }
+ return 720
+}
+
+// DisplayChanges normalizes the current preset's dimensions and DPI, preserving orientation.
+func DisplayChanges(cfg *Ini) [][2]string { return displayChanges(cfg, resolutionFor(cfg)) }
+
+func displayChanges(cfg *Ini, resolution int) [][2]string {
+ w, _ := intOrNone(cfg.Value("hw.lcd.width"))
+ h, _ := intOrNone(cfg.Value("hw.lcd.height"))
+ width, height, density := 1280, 720, 240
+ if resolution == 1080 {
+ width, height, density = 1920, 1080, 280
+ }
if h > w {
- width, height = ScreenHeight, ScreenWidth
+ width, height = height, width
}
return [][2]string{
{"hw.lcd.width", strconv.Itoa(width)},
{"hw.lcd.height", strconv.Itoa(height)},
- {"hw.lcd.density", strconv.Itoa(ScreenDensity)},
+ {"hw.lcd.density", strconv.Itoa(density)},
{"hw.mainKeys", "yes"},
}
}
diff --git a/internal/avd/avd_test.go b/internal/avd/avd_test.go
index bc3225d..c39df45 100644
--- a/internal/avd/avd_test.go
+++ b/internal/avd/avd_test.go
@@ -658,7 +658,7 @@ func TestListAndGet(t *testing.T) {
if d, ok := in.DataPartitionGB(); !ok || d != 64 {
t.Errorf("DataPartitionGB %d", d)
}
- if w, h, dpi := in.Display(); in.FPS() != 60 || w != 1280 || h != 720 || dpi != 240 {
+ if w, h, dpi := in.Display(); in.FPS() != 60 || w != 1920 || h != 1080 || dpi != 280 {
t.Errorf("FPS / Display %d %d %d %d", in.FPS(), w, h, dpi)
}
// An instance whose config.ini cannot be read (opened elsewhere without sharing) is listed with an empty config
@@ -1066,7 +1066,7 @@ func TestApplyDisplay(t *testing.T) {
if err != nil || !wrote {
t.Fatalf("first: %v %v", wrote, err)
}
- want := "hw.lcd.width=720\r\nhw.lcd.height=1280\r\nhw.lcd.density=240\r\nx=1\r\nhw.mainKeys=yes\r\n"
+ want := "hw.lcd.width=1080\r\nhw.lcd.height=1920\r\nhw.lcd.density=280\r\nx=1\r\nhw.mainKeys=yes\r\n"
if got := string(readFile(t, ConfigPath(r, 0))); got != want {
t.Fatalf("got %q", got)
}
diff --git a/internal/avd/resolution_test.go b/internal/avd/resolution_test.go
new file mode 100644
index 0000000..8eccbbb
--- /dev/null
+++ b/internal/avd/resolution_test.go
@@ -0,0 +1,144 @@
+//go:build windows
+
+package avd
+
+import (
+ "fmt"
+ "os"
+ "testing"
+
+ "modavd/internal/errs"
+)
+
+func assertDisplay(t *testing.T, in *Instance, want [3]int) {
+ t.Helper()
+ w, h, dpi := in.Display()
+ if got := [3]int{w, h, dpi}; got != want {
+ t.Fatalf("display = %v, want %v", got, want)
+ }
+ for n, key := range []string{"hw.lcd.width", "hw.lcd.height", "hw.lcd.density"} {
+ if in.Config.Value(key) != fmt.Sprint(want[n]) {
+ t.Fatalf("stored %s = %q, want %d", key, in.Config.Value(key), want[n])
+ }
+ }
+}
+
+func TestResolutionCreateSwitchAndReboot(t *testing.T) {
+ for _, preset := range []int{0, 1080, 720} {
+ for _, portrait := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%d/portrait=%t", preset, portrait), func(t *testing.T) {
+ r := newRoot(t)
+ in, err := Create(r, 0, CreateOptions{MemoryMB: 6144, CPU: 6, DataPartitionGB: 64, FPS: 60, Resolution: preset})
+ if err != nil {
+ t.Fatal(err)
+ }
+ initial := preset
+ if initial == 0 {
+ initial = 1080
+ }
+ if portrait {
+ w, h, _ := in.Display()
+ in.Config.Set("hw.lcd.width", fmt.Sprint(h))
+ in.Config.Set("hw.lcd.height", fmt.Sprint(w))
+ if err := WriteIni(ConfigPath(r, 0), in.Config); err != nil {
+ t.Fatal(err)
+ }
+ }
+ other := 720
+ if initial == 720 {
+ other = 1080
+ }
+ for step, resolution := range []int{initial, other, initial} {
+ if step > 0 {
+ applied, err := Update(r, 0, UpdateOptions{Resolution: &resolution})
+ if err != nil || applied["resolution"] != resolution {
+ t.Fatalf("update: %v %v", applied, err)
+ }
+ }
+ want := [3]int{1280, 720, 240}
+ if resolution == 1080 {
+ want = [3]int{1920, 1080, 280}
+ }
+ if portrait {
+ want[0], want[1] = want[1], want[0]
+ }
+ // Re-read from disk across repeated pre-boot writes, as independent starts do.
+ for boot := 0; boot < 3; boot++ {
+ if _, err := ApplyDisplay(r, 0); err != nil {
+ t.Fatal(err)
+ }
+ in, err = Get(r, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if in.Resolution() != resolution {
+ t.Fatalf("resolution = %d", in.Resolution())
+ }
+ assertDisplay(t, in, want)
+ }
+ }
+ })
+ }
+ }
+}
+
+func TestResolutionLegacy(t *testing.T) {
+ for _, tc := range []struct {
+ name, cfg string
+ want [3]int
+ }{
+ {"missing", "x=1\n", [3]int{1280, 720, 240}},
+ {"missing width", "hw.lcd.height=720\n", [3]int{720, 1280, 240}},
+ {"missing height", "hw.lcd.width=1920\n", [3]int{1280, 720, 240}},
+ {"bad width", "hw.lcd.width=bad\nhw.lcd.height=1\n", [3]int{720, 1280, 240}},
+ {"720 no dpi", "hw.lcd.width=1280\nhw.lcd.height=720\n", [3]int{1280, 720, 240}},
+ {"1080 wrong dpi", "hw.lcd.width=1920\nhw.lcd.height=1080\nhw.lcd.density=240\n", [3]int{1920, 1080, 280}},
+ {"portrait 1080", "hw.lcd.width=1080\nhw.lcd.height=1920\n", [3]int{1080, 1920, 280}},
+ {"custom", "hw.lcd.width=1440\nhw.lcd.height=2560\nhw.lcd.density=400\n", [3]int{720, 1280, 240}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ r := newRoot(t)
+ writeFile(t, ConfigPath(r, 0), []byte(tc.cfg))
+ if _, err := ApplyDisplay(r, 0); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := ReadIni(ConfigPath(r, 0))
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertDisplay(t, &Instance{Config: cfg}, tc.want)
+ if wrote, err := ApplyDisplay(r, 0); err != nil || wrote {
+ t.Fatalf("repeat: %t %v", wrote, err)
+ }
+ })
+ }
+}
+
+func TestResolutionValidationBeforeWrite(t *testing.T) {
+ if got, err := ValidateResolution(nil); err != nil || got != 1080 {
+ t.Fatalf("default: %d %v", got, err)
+ }
+ r := newRoot(t)
+ in, err := Create(r, 0, CreateOptions{MemoryMB: 6144, CPU: 6, DataPartitionGB: 64, FPS: 60})
+ if err != nil {
+ t.Fatal(err)
+ }
+ before, _ := os.ReadFile(ConfigPath(r, 0))
+ for _, value := range []int{0, -1, 1440, 1920} {
+ _, err := ValidateResolution(&value)
+ wantCode(t, err, errs.InvalidArgument)
+ _, err = Update(r, 0, UpdateOptions{Resolution: &value})
+ wantCode(t, err, errs.InvalidArgument)
+ after, _ := os.ReadFile(ConfigPath(r, 0))
+ if string(before) != string(after) {
+ t.Fatal("invalid update changed config")
+ }
+ }
+ bad := 1440
+ _, err = Create(r, 1, CreateOptions{MemoryMB: 6144, CPU: 6, DataPartitionGB: 64, FPS: 60, Resolution: bad})
+ wantCode(t, err, errs.InvalidArgument)
+ if _, err := os.Stat(Dir(r, 1)); !os.IsNotExist(err) {
+ t.Fatal("invalid create left files")
+ }
+ assertDisplay(t, in, [3]int{1920, 1080, 280})
+}
diff --git a/internal/avd/testdata/instances_ref.py b/internal/avd/testdata/instances_ref.py
index e911e1c..adcebfc 100644
--- a/internal/avd/testdata/instances_ref.py
+++ b/internal/avd/testdata/instances_ref.py
@@ -2,8 +2,8 @@
# app/utils/common/emulator2/avd/instances.py (and the parts of constants.py, components.py and manager.py it uses)
# so it runs without the app package. Keep it a copy: when AUTO-MAS changes, copy again.
#
-# The only change: create_instance_files takes created_at instead of calling datetime.now(), so the output can be
-# compared byte for byte.
+# Intentional differences: created_at is injected for byte comparisons. ModAVD#100
+# defaults new instances to 1080p and preserves either preset on existing instances.
#
# python -I instances_ref.py
#
@@ -35,7 +35,7 @@
DEFAULT_CPU = 6
DEFAULT_DATA_PARTITION_GB = 64
DATA_PARTITION_RANGE_GB = (16, 512)
-SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_DENSITY = 1280, 720, 240
+SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_DENSITY = 1920, 1080, 280
FPS_CHOICES = (30, 60)
DEFAULT_FPS = 60
@@ -283,14 +283,17 @@ def remove_instance_meta(root, native_index):
def display_changes(config):
width = _int_or_none(config.get("hw.lcd.width")) or 0
height = _int_or_none(config.get("hw.lcd.height")) or 0
- if height > width:
- width, height = SCREEN_HEIGHT, SCREEN_WIDTH
+ portrait = height > width
+ if sorted((width, height)) == [1080, 1920]:
+ width, height, density = 1920, 1080, 280
else:
- width, height = SCREEN_WIDTH, SCREEN_HEIGHT
+ width, height, density = 1280, 720, 240
+ if portrait:
+ width, height = height, width
return {
"hw.lcd.width": str(width),
"hw.lcd.height": str(height),
- "hw.lcd.density": str(SCREEN_DENSITY),
+ "hw.lcd.density": str(density),
"hw.mainKeys": "yes",
}
diff --git a/internal/cli/args_test.go b/internal/cli/args_test.go
index 1db35e4..11d5277 100644
--- a/internal/cli/args_test.go
+++ b/internal/cli/args_test.go
@@ -253,17 +253,17 @@ func TestBuildCreateRequest(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if req.Index != nil || req.MemoryMB != nil || req.CPU != nil || req.DataPartitionGB != nil || req.FPS != nil || req.Title != "" {
+ if req.Index != nil || req.MemoryMB != nil || req.CPU != nil || req.DataPartitionGB != nil || req.FPS != nil || req.Resolution != nil || req.Title != "" {
t.Errorf("defaults: %+v, want all nil", req)
}
req, err = buildCreateRequest(mustParse(t, "create", "--index", "mas_4", "--title", "星铁", "--memory", "6G",
- "--cpu", "4", "--data-gb", "128", "--fps", "30"))
+ "--cpu", "4", "--data-gb", "128", "--fps", "30", "--resolution", "720"))
if err != nil {
t.Fatal(err)
}
if req.Index == nil || *req.Index != 4 || req.Title != "星铁" || req.MemoryMB == nil || *req.MemoryMB != 6144 ||
req.CPU == nil || *req.CPU != 4 || req.DataPartitionGB == nil || *req.DataPartitionGB != 128 ||
- req.FPS == nil || *req.FPS != 30 {
+ req.FPS == nil || *req.FPS != 30 || req.Resolution == nil || *req.Resolution != 720 {
t.Errorf("all options: %+v", req)
}
// Zero is passed on (emu rejects it with the AUTO-MAS wording), not mistaken for "not given".
@@ -327,6 +327,11 @@ func TestBuildUpdateRequest(t *testing.T) {
if err != nil || req.DataPartitionGB == nil || *req.DataPartitionGB != 96 || req.MemoryMB != nil {
t.Errorf("data-gb alone: %+v %v", req, err)
}
+ req, err = buildUpdateRequest(mustParse(t, "set", "3", "--resolution", "1080"))
+ if err != nil || req.Resolution == nil || *req.Resolution != 1080 || req.MemoryMB != nil {
+ t.Fatalf("resolution alone: %+v %v", req, err)
+ }
+
}
func TestBuildStartOptions(t *testing.T) {
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index 3f55353..18b3cb3 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -62,6 +62,7 @@ var (
optMemory = flagSpec{"memory", kindString, "M", "内存:3072 / 4096 / 5120 / 6144 MB,也可写 6G"}
optCPU = flagSpec{"cpu", kindInt, "N", "核数:2 / 4 / 6"}
optDataGB = flagSpec{"data-gb", kindInt, "N", "数据盘大小(GB):16–512"}
+ optResolution = flagSpec{"resolution", kindInt, "", "分辨率:1080 / 720,DPI 随档位切换;新建默认 1080"}
optFPS = flagSpec{"fps", kindInt, "F", "帧率:30 / 60"}
optYes = flagSpec{"yes", kindBool, "", "确认删除(连同游戏数据)"}
optWindow = flagSpec{"window", kindBool, "", "带窗口开机(默认无头);实例已经无头运行时报错,不接管"}
@@ -125,24 +126,24 @@ func init() {
run: runStatus, human: humanOf(formatStatus),
},
{
- name: "create", args: "[--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30|60] [--no-bridge] [--timeout 1200] | --resume N",
+ name: "create", args: "[--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30|60] [--resolution 1080|720] [--no-bridge] [--timeout 1200] | --resume N",
summary: "新建一台实例,后台开机做完初始化后关机",
- details: "不给的选项用默认值:内存 6 GB、6 核、数据盘 64 GB、60 帧。内存和核数只能取列出的档位,不会自动凑整。" +
+ details: "不给的选项用默认值:内存 6 GB、6 核、数据盘 64 GB、60 帧、1080p(1920×1080 / DPI 280)。内存和核数只能取列出的档位,不会自动凑整。" +
"建好文件后在后台无头开机一次,做完开机设置、宿主输入法、首次初始化和 arm64 执行桥(只对内置名单里的游戏生效," +
"其余应用照旧;--no-bridge 不装,以后也不装),再先 sync 后正常关机,一般要几分钟,进度逐步打印(「新建中 35%」,按做完的步骤算);" +
"之后第一次开机和平常一样快。" +
"这期间实例的状态是 creating(status、list 里 creation_progress 是百分比),不能开机、修改或删除;失败(或被打断)是 create_failed," +
"用 create --resume N 重做初始化,或者 delete N --yes 删掉。",
- flags: []flagSpec{optIndex, optTitle, optMemory, optCPU, optDataGB, optFPS, optNoBridge, optCreateTimeout, optResume},
+ flags: []flagSpec{optIndex, optTitle, optMemory, optCPU, optDataGB, optFPS, optResolution, optNoBridge, optCreateTimeout, optResume},
final: true,
run: runCreate, human: humanOf(formatCreate),
},
{
- name: "set", args: " [--title T] [--memory M] [--cpu N] [--fps F] [--data-gb N]",
- summary: "修改实例的标题、内存、核数、帧率、数据盘大小",
+ name: "set", args: " [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080|720] [--data-gb N]",
+ summary: "修改实例的标题、内存、核数、帧率、分辨率、数据盘大小",
details: "至少给一个选项。改动写进实例配置,下次开机生效。" +
"--data-gb 只能改大(16–512),游戏数据不动:下次开机时模拟器(0.36 起)扩大磁盘镜像,开机后再在线扩大文件系统。",
- flags: []flagSpec{optTitle, optMemory, optCPU, optFPS, optDataGB},
+ flags: []flagSpec{optTitle, optMemory, optCPU, optFPS, optResolution, optDataGB},
posNames: []string{"实例编号"}, minPos: 1, maxPos: 1,
final: true,
run: runSet, human: humanOf(formatUpdate),
@@ -455,6 +456,7 @@ func buildCreateRequest(inv *invocation) (emu.CreateRequest, error) {
req.CPU = intPtrOpt(inv, "cpu")
req.DataPartitionGB = intPtrOpt(inv, "data-gb")
req.FPS = intPtrOpt(inv, "fps")
+ req.Resolution = intPtrOpt(inv, "resolution")
req.NoBridge = inv.boolOpt("no-bridge")
if inv.isSet("timeout") {
n := inv.intOpt("timeout")
@@ -464,7 +466,7 @@ func buildCreateRequest(inv *invocation) (emu.CreateRequest, error) {
req.Timeout = time.Duration(n) * time.Second
}
if inv.isSet("resume") {
- for _, o := range []string{"index", "title", "memory", "cpu", "data-gb", "fps", "no-bridge"} {
+ for _, o := range []string{"index", "title", "memory", "cpu", "data-gb", "fps", "resolution", "no-bridge"} {
if inv.isSet(o) {
return req, errs.Newf(errs.Usage, "--resume 只重做初始化,不能再给 --%s", o)
}
@@ -494,9 +496,10 @@ func buildUpdateRequest(inv *invocation) (emu.UpdateRequest, error) {
}
req.CPU = intPtrOpt(inv, "cpu")
req.FPS = intPtrOpt(inv, "fps")
+ req.Resolution = intPtrOpt(inv, "resolution")
req.DataPartitionGB = intPtrOpt(inv, "data-gb")
- if req.Title == nil && req.MemoryMB == nil && req.CPU == nil && req.FPS == nil && req.DataPartitionGB == nil {
- return req, errs.New(errs.Usage, "没有要改的内容:至少给 --title、--memory、--cpu、--fps、--data-gb 中的一个").
+ if req.Title == nil && req.MemoryMB == nil && req.CPU == nil && req.FPS == nil && req.Resolution == nil && req.DataPartitionGB == nil {
+ return req, errs.New(errs.Usage, "没有要改的内容:至少给 --title、--memory、--cpu、--fps、--resolution、--data-gb 中的一个").
WithAdvice(findCommand("set").usageAdvice())
}
return req, nil
diff --git a/internal/cli/format.go b/internal/cli/format.go
index 3374052..f3fa8bd 100644
--- a/internal/cli/format.go
+++ b/internal/cli/format.go
@@ -294,7 +294,7 @@ func formatCreate(w io.Writer, in *emu.InstanceInfo) {
}
// appliedOrder is the order changes are listed in; unknown keys follow alphabetically.
-var appliedOrder = []string{"title", "memory_mb", "cpu", "fps", "data_partition_gb"}
+var appliedOrder = []string{"title", "memory_mb", "cpu", "fps", "resolution", "data_partition_gb"}
func appliedParts(applied map[string]any) []string {
keys := make([]string, 0, len(applied))
@@ -332,6 +332,8 @@ func appliedParts(applied map[string]any) []string {
parts = append(parts, fmt.Sprintf("核数 %v", v))
case "fps":
parts = append(parts, fmt.Sprintf("帧率 %v", v))
+ case "resolution":
+ parts = append(parts, fmt.Sprintf("分辨率 %vp", v))
case "data_partition_gb":
parts = append(parts, fmt.Sprintf("数据盘 %v GB(下次开机扩大)", v))
default:
diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go
index ffd92c4..34c7bb4 100644
--- a/internal/cli/run_test.go
+++ b/internal/cli/run_test.go
@@ -977,9 +977,9 @@ func TestCreateFlags(t *testing.T) {
in := sampleInstance(1)
return &in, nil
}
- r := h.run(t, nil, "create", "--index", "6", "--timeout", "600", "--no-bridge")
+ r := h.run(t, nil, "create", "--index", "6", "--timeout", "600", "--no-bridge", "--resolution", "720")
if r.code != 0 || len(got) != 1 || *got[0].Index != 6 || got[0].Timeout != 600*time.Second || !got[0].NoBridge ||
- got[0].Resume || !strings.Contains(r.stderr, "新建中 2%:后台无头开机") ||
+ got[0].Resolution == nil || *got[0].Resolution != 720 || got[0].Resume || !strings.Contains(r.stderr, "新建中 2%:后台无头开机") ||
!strings.Contains(r.stderr, "新建中 35%:实例 6 宿主输入法") || strings.Contains(r.stderr, "新建中 100%") ||
!strings.Contains(r.stderr, "实例 6 新建完成") {
t.Fatalf("create: exit %d, %+v, stderr %s", r.code, got, r.stderr)
@@ -989,7 +989,7 @@ func TestCreateFlags(t *testing.T) {
t.Fatalf("resume: exit %d, %+v", r.code, got)
}
for _, bad := range [][]string{{"create", "--resume", "6", "--memory", "4G"}, {"create", "--resume", "6", "--index", "6"},
- {"create", "--timeout", "0"}, {"create", "--resume", "5"}} {
+ {"create", "--resume", "6", "--resolution", "1080"}, {"create", "--timeout", "0"}, {"create", "--resume", "5"}} {
if r := h.run(t, nil, bad...); r.code != 2 {
t.Errorf("%v: exit %d, want 2 (%s)", bad, r.code, r.stderr)
}
diff --git a/internal/emu/crud.go b/internal/emu/crud.go
index df4f94a..4eb7616 100644
--- a/internal/emu/crud.go
+++ b/internal/emu/crud.go
@@ -34,6 +34,10 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*InstanceInfo,
if err != nil {
return nil, err
}
+ resolution, err := avd.ValidateResolution(req.Resolution)
+ if err != nil {
+ return nil, err
+ }
// A root used by modavd alone has no mas-avd.json yet (AUTO-MAS writes it when the root is added).
if _, err := m.Root.EnsureMetadata(); err != nil {
return nil, err
@@ -81,7 +85,7 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*InstanceInfo,
return nil, err
}
in, err := avd.Create(m.Root, i, avd.CreateOptions{
- Title: req.Title, MemoryMB: memory, CPU: cpu, DataPartitionGB: dataGB, FPS: fps, Creating: !req.filesOnly,
+ Title: req.Title, MemoryMB: memory, CPU: cpu, DataPartitionGB: dataGB, FPS: fps, Resolution: resolution, Creating: !req.filesOnly,
})
if err != nil {
lock.Unlock()
@@ -209,7 +213,7 @@ func (m *Manager) Update(ctx context.Context, i int, req UpdateRequest) (*Update
return nil, err
}
applied, err := avd.Update(m.Root, i, avd.UpdateOptions{
- Title: req.Title, MemoryMB: req.MemoryMB, CPU: req.CPU, FPS: req.FPS, DataPartitionGB: req.DataPartitionGB,
+ Title: req.Title, MemoryMB: req.MemoryMB, CPU: req.CPU, FPS: req.FPS, Resolution: req.Resolution, DataPartitionGB: req.DataPartitionGB,
})
if err != nil {
return nil, err
diff --git a/internal/emu/launch.go b/internal/emu/launch.go
index 48c8cb9..fbdc7af 100644
--- a/internal/emu/launch.go
+++ b/internal/emu/launch.go
@@ -106,7 +106,7 @@ func (m *Manager) prepareLaunch(ctx context.Context, inst *avd.Instance, window
}
// launchPlanFor is the rest of prepareLaunch, once the checks passed: the instance's ports in the root's plan (free?),
-// the private adb server, the fixed 720p display, old logs, the log file name, the emulator's arguments.
+// the private adb server, the selected display preset, old logs, the log file name, the emulator's arguments.
func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryMB int, window bool) (*launchPlan, error) {
i := inst.Index
port := m.Root.Ports().Console(i)
@@ -139,7 +139,7 @@ func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryM
return nil, errs.Wrap(errs.Internal, err, fmt.Sprintf("实例 %d 写显示设置(config.ini)失败: %v", i, err))
}
if wrote {
- m.Log.Infof("实例 %d 显示改回 720p、不显示导航栏(已写入 config.ini)", i)
+ m.Log.Infof("实例 %d 显示设为 %dp、不显示导航栏(已写入 config.ini)", i, inst.Resolution())
}
headless := !window
@@ -156,8 +156,8 @@ func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryM
if n, ok := inst.CPU(); ok {
cpu = strconv.Itoa(n)
}
- m.Log.Infof("启动魔改 AVD 实例 %d(%s,720p %d Hz,%d MB / %s 核,气球开,不带声卡,ANDROID_EMU_VK_HIDE_REBAR=1): emulator %s",
- i, mode, inst.FPS(), memoryMB, cpu, strings.Join(args, " "))
+ m.Log.Infof("启动魔改 AVD 实例 %d(%s,%dp %d Hz,%d MB / %s 核,气球开,不带声卡,ANDROID_EMU_VK_HIDE_REBAR=1): emulator %s",
+ i, mode, inst.Resolution(), inst.FPS(), memoryMB, cpu, strings.Join(args, " "))
return &launchPlan{args: args, logPath: logPath, memoryMB: memoryMB}, nil
}
diff --git a/internal/emu/ops_root_test.go b/internal/emu/ops_root_test.go
index 2f3b763..35ddadf 100644
--- a/internal/emu/ops_root_test.go
+++ b/internal/emu/ops_root_test.go
@@ -61,7 +61,7 @@ func TestOpsInstanceLifecycle(t *testing.T) {
if !root.ValidIndex(i) || created.Title != "测试实例" || created.Status != StatusOffline || created.Running || created.PID != 0 {
t.Fatalf("created = %+v", created)
}
- want := InstanceConfig{MemoryMB: 6144, CPU: 6, FPS: 60, DataPartitionGB: 64, Width: 1280, Height: 720, DPI: 240}
+ want := InstanceConfig{MemoryMB: 6144, CPU: 6, FPS: 60, DataPartitionGB: 64, Resolution: 1080, Width: 1920, Height: 1080, DPI: 280}
if created.Config != want {
t.Fatalf("config = %+v, want %+v", created.Config, want)
}
@@ -94,12 +94,12 @@ func TestOpsInstanceLifecycle(t *testing.T) {
t.Fatalf("list = %+v", list)
}
- mem, cpu, fps := 4096, 4, 30
- up, err := m.Update(ctx, i, UpdateRequest{MemoryMB: &mem, CPU: &cpu, FPS: &fps})
+ mem, cpu, fps, resolution := 4096, 4, 30, 720
+ up, err := m.Update(ctx, i, UpdateRequest{MemoryMB: &mem, CPU: &cpu, FPS: &fps, Resolution: &resolution})
if err != nil {
t.Fatal(err)
}
- if up.AppliesNextBoot || up.Instance.Config.MemoryMB != 4096 || up.Instance.Config.CPU != 4 || up.Instance.Config.FPS != 30 {
+ if up.AppliesNextBoot || up.Instance.Config.MemoryMB != 4096 || up.Instance.Config.CPU != 4 || up.Instance.Config.FPS != 30 || up.Instance.Config.Resolution != 720 || up.Instance.Config.Width != 1280 || up.Instance.Config.DPI != 240 {
t.Fatalf("update = %+v", up)
}
if len(up.Applied) == 0 {
diff --git a/internal/emu/status.go b/internal/emu/status.go
index 63c90eb..6412b5a 100644
--- a/internal/emu/status.go
+++ b/internal/emu/status.go
@@ -184,7 +184,7 @@ func (m *Manager) instanceInfo(ctx context.Context, in *avd.Instance, p *win.Pro
info.Status = StatusCreateFailed
}
- cfg := InstanceConfig{MemoryMB: avd.MemoryFor(m.Root, i), FPS: in.FPS()}
+ cfg := InstanceConfig{MemoryMB: avd.MemoryFor(m.Root, i), FPS: in.FPS(), Resolution: in.Resolution()}
if n, ok := in.CPU(); ok {
cfg.CPU = n
}
diff --git a/internal/emu/types.go b/internal/emu/types.go
index d82bcb8..e0f734c 100644
--- a/internal/emu/types.go
+++ b/internal/emu/types.go
@@ -35,6 +35,7 @@ type InstanceConfig struct {
MemoryMB int `json:"memory_mb"` // what the next boot passes as -memory
CPU int `json:"cpu"`
FPS int `json:"fps"`
+ Resolution int `json:"resolution"`
DataPartitionGB int `json:"data_partition_gb"`
Width int `json:"width"`
Height int `json:"height"`
@@ -115,6 +116,7 @@ type CreateRequest struct {
CPU *int
DataPartitionGB *int
FPS *int
+ Resolution *int
// NoBridge: no execution bridge (bridge.go), recorded for good. By default a new instance gets the bridge's boot
// marker and create's setup installs it.
NoBridge bool
@@ -129,10 +131,11 @@ type CreateRequest struct {
}
type UpdateRequest struct {
- Title *string
- MemoryMB *int
- CPU *int
- FPS *int
+ Title *string
+ MemoryMB *int
+ CPU *int
+ FPS *int
+ Resolution *int
// DataPartitionGB raises the data partition (never lowers it); see avd.UpdateOptions.
DataPartitionGB *int
}
diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go
index 001b286..0b3a256 100644
--- a/internal/mcp/mcp_test.go
+++ b/internal/mcp/mcp_test.go
@@ -767,9 +767,9 @@ func TestToolCalls(t *testing.T) {
{"check_host", map[string]any{"memory_mb": 4096}, "check 4096"},
{"check_host", map[string]any{}, "check 0"},
{"create_instance", map[string]any{"title": "星铁", "index": 3, "memory_mb": 4096, "cpu": 4,
- "data_partition_gb": 128, "fps": 30}, "create"},
+ "data_partition_gb": 128, "fps": 30, "resolution": 720}, "create"},
{"create_instance", map[string]any{}, "create"},
- {"update_instance", map[string]any{"index": 1, "memory_mb": 5120.0}, "update 1"},
+ {"update_instance", map[string]any{"index": 1, "memory_mb": 5120.0, "resolution": 1080}, "update 1"},
{"delete_instance", map[string]any{"index": 2, "confirm": true}, "delete 2"},
{"start_instance", map[string]any{"index": 1, "window": true, "timeout_s": 120}, "start 1"},
{"start_instance", map[string]any{"index": 1}, "start 1"},
@@ -816,14 +816,14 @@ func TestToolCalls(t *testing.T) {
f := h.f
full := f.creates[0]
if full.Title != "星铁" || *full.Index != 3 || *full.MemoryMB != 4096 || *full.CPU != 4 || *full.DataPartitionGB != 128 ||
- *full.FPS != 30 {
+ *full.FPS != 30 || full.Resolution == nil || *full.Resolution != 720 {
t.Fatalf("create request %+v", full)
}
if empty := f.creates[1]; empty.Title != "" || empty.Index != nil || empty.MemoryMB != nil || empty.CPU != nil ||
- empty.DataPartitionGB != nil || empty.FPS != nil {
+ empty.DataPartitionGB != nil || empty.FPS != nil || empty.Resolution != nil {
t.Fatalf("defaults must stay nil: %+v", empty)
}
- if u := f.updates[0]; *u.MemoryMB != 5120 || u.Title != nil || u.CPU != nil || u.FPS != nil {
+ if u := f.updates[0]; u.Resolution == nil || *u.Resolution != 1080 || *u.MemoryMB != 5120 || u.Title != nil || u.CPU != nil || u.FPS != nil {
t.Fatalf("update request %+v", u)
}
if s := f.starts[0]; !s.Window || s.Headless || s.Timeout != 120*time.Second || s.Progress != nil {
diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go
index 2b69107..1f50801 100644
--- a/internal/mcp/tools.go
+++ b/internal/mcp/tools.go
@@ -93,6 +93,10 @@ func fpsProp(desc string) prop {
return prop{"fps", false, map[string]any{"type": "integer", "enum": avd.FPSChoices, "description": desc}}
}
+func resolutionProp(desc string) prop {
+ return prop{"resolution", false, map[string]any{"type": "integer", "enum": avd.ResolutionChoices, "description": desc}}
+}
+
func timeoutProp(def time.Duration, desc string) prop {
return prop{"timeout_s", false, map[string]any{"type": "integer", "minimum": 1, "maximum": maxTimeoutS,
"default": int(def / time.Second), "description": desc}}
@@ -193,6 +197,7 @@ func toolDefs() []*toolDef {
{"data_partition_gb", false, map[string]any{"type": "integer", "minimum": avd.MinDataPartitionGB,
"maximum": avd.MaxDataPartitionGB, "description": "Size of the data partition (games) in GB."}},
fpsProp("Display refresh rate."),
+ resolutionProp("Display preset: 1080 = 1920×1080 / DPI 280 (default), 720 = 1280×720 / DPI 240."),
},
func(ctx context.Context, b backend, a args, progress func(emu.Progress)) (any, error) {
var req emu.CreateRequest
@@ -218,6 +223,9 @@ func toolDefs() []*toolDef {
if req.FPS, err = a.optInt("fps"); err != nil {
return nil, err
}
+ if req.Resolution, err = a.optInt("resolution"); err != nil {
+ return nil, err
+ }
if progress != nil {
req.Progress = func(p emu.Progress) {
if p.Step != "done" {
@@ -230,7 +238,7 @@ func toolDefs() []*toolDef {
})),
finishing(newTool("update_instance", "Update instance",
- "Change the title, memory, CPU cores, frame rate or data partition size of an instance. The changes take "+
+ "Change the title, memory, CPU cores, frame rate, resolution or data partition size of an instance. The changes take "+
"effect at its next boot (applies_next_boot=true when it is running now).",
mutating(true, true), reflect.TypeFor[emu.UpdateResult](), []prop{
indexProp(true),
@@ -238,6 +246,7 @@ func toolDefs() []*toolDef {
memoryProp("New guest memory in MB."),
cpuProp("New CPU core count."),
fpsProp("New display refresh rate."),
+ resolutionProp("Display preset: 1080 = 1920×1080 / DPI 280, 720 = 1280×720 / DPI 240. Preserves orientation; next boot."),
{"data_partition_gb", false, map[string]any{"type": "integer", "minimum": avd.MinDataPartitionGB,
"maximum": avd.MaxDataPartitionGB, "description": "New data partition size in GB; can only be " +
"raised. Game data is kept: the emulator grows the disk at the next boot and the file " +
@@ -261,11 +270,14 @@ func toolDefs() []*toolDef {
if req.FPS, err = a.optInt("fps"); err != nil {
return nil, err
}
+ if req.Resolution, err = a.optInt("resolution"); err != nil {
+ return nil, err
+ }
if req.DataPartitionGB, err = a.optInt("data_partition_gb"); err != nil {
return nil, err
}
- if req.Title == nil && req.MemoryMB == nil && req.CPU == nil && req.FPS == nil && req.DataPartitionGB == nil {
- return nil, errs.New(errs.Usage, "没有要改的选项:title、memory_mb、cpu、fps、data_partition_gb 至少给一项")
+ if req.Title == nil && req.MemoryMB == nil && req.CPU == nil && req.FPS == nil && req.Resolution == nil && req.DataPartitionGB == nil {
+ return nil, errs.New(errs.Usage, "没有要改的选项:title、memory_mb、cpu、fps、resolution、data_partition_gb 至少给一项")
}
return b.Update(ctx, i, req)
})),
diff --git a/internal/ui/API.md b/internal/ui/API.md
index cbe5891..21da6bb 100644
--- a/internal/ui/API.md
+++ b/internal/ui/API.md
@@ -35,8 +35,8 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…","
| GET | `/api/info` | | `{"version","protocol","root":RootInfo}` |
| GET | `/api/instances` | | `ListResult` |
| GET | `/api/instances/{i}` | | `InstanceInfo` |
-| POST | `/api/instances` | `{title?, index?, memory_mb?, cpu?, data_partition_gb?, fps?}` | 202 `Job`(`kind` 是 `create`:后台开机做完初始化再关机,要几分钟;没给 `index` 时服务端先选好编号,`Job.index` 就是新实例的;结果是 `InstanceInfo`) |
-| PATCH | `/api/instances/{i}` | `{title?, memory_mb?, cpu?, fps?}` | `UpdateResult` |
+| POST | `/api/instances` | `{title?, index?, memory_mb?, cpu?, data_partition_gb?, fps?, resolution?}` | 202 `Job`(`kind` 是 `create`:后台开机做完初始化再关机,要几分钟;没给 `index` 时服务端先选好编号,`Job.index` 就是新实例的;结果是 `InstanceInfo`) |
+| PATCH | `/api/instances/{i}` | `{title?, memory_mb?, cpu?, fps?, resolution?, data_partition_gb?}` | `UpdateResult` |
| DELETE | `/api/instances/{i}?confirm=1` | 不带 `confirm=1` 回 400 `usage` | `DeleteResult` |
| POST | `/api/instances/{i}/start` | `{}` | 202 `Job`:一律带窗口开机(管理器不开无头的,无头开机只走命令行);实例已经以无头方式在运行时,任务失败,code 是 `busy`(运行中不切换) |
| POST | `/api/instances/{i}/stop` | `{force?: bool}` | 202 `Job` |
@@ -45,7 +45,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…","
| POST | `/api/instances/{i}/disk-trim` | `{}` | 202 `Job`(`kind` 是 `disk-trim`):让开着的安卓把删掉的文件占的空间还给模拟器(电脑上的文件要整理后才变小);实例没开机完成、或模拟器版本不支持时任务失败,code 是 `unsupported`;结果是 `DiskTrimResult` |
| POST | `/api/instances/{i}/disk-compact` | `{}` | 202 `Job`(`kind` 是 `disk-compact`):重写已关机实例的数据盘文件,只留在用的部分,要几分钟;实例没关机时任务失败,code 是 `busy`;结果是 `DiskCompactResult` |
| POST | `/api/instances/{i}/resume` | `{}` | 202 `Job`(`kind` 是 `create`):重做新建失败的那台的初始化 |
-| POST | `/api/instances/{i}/input` | `{type:"tap", x, y}`(坐标是 1280×720 显示的像素)或 `{type:"key", key:"back"\|"home"\|"recents"}` | `{}` |
+| POST | `/api/instances/{i}/input` | `{type:"tap", x, y}`(坐标是这台实例显示的像素(1280×720 或 1920×1080,竖屏时宽高对调))或 `{type:"key", key:"back"\|"home"\|"recents"}` | `{}` |
| GET | `/api/instances/{i}/screenshot` | | `image/png`(`adb exec-out screencap -p`),`Cache-Control: no-store`;没开机回 409 |
| GET | `/api/instances/{i}/log?kind=emulator\|logcat&lines=300` | | `text/plain; charset=utf-8`,最新那份日志的末尾;没有回 404 |
| GET | `/api/instances/{i}/proxy` | | `ProxyView`,见下 |
@@ -140,3 +140,7 @@ data: {} 每 15 秒
- Edge 进程在启动后 10 秒内就退出:当作把窗口交给了同一配置下已在运行的 Edge,不算关窗,之后按事件流规则判断。
- 真正关窗后,5 秒内没有事件流连着就退出(有任务先等任务做完)。
- `--dev` 不会因为空闲退出。
+
+`resolution` 只接受 1080 和 720。新建默认 1080,分别联动 1920×1080 / DPI 280 与 1280×720 / DPI 240;修改保留方向,下次开机生效。`InstanceInfo.config.resolution` 是按当前配置宽高识别的档位,运行中修改后表示下次开机的设置。
+
+画面点击按当前截图的尺寸换算并校验。运行中改档时,`config` 表示下次开机设置,当前画面和点击坐标在重启前保持原尺寸。
diff --git a/internal/ui/device.go b/internal/ui/device.go
index 395a4ee..7c87a48 100644
--- a/internal/ui/device.go
+++ b/internal/ui/device.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
+ "image/png"
"math"
"net/http"
"strconv"
@@ -23,8 +24,6 @@ const (
// statusMaxAge: how old a polled instance list may be to decide "not booted" without asking adb.
statusMaxAge = 10 * time.Second
inputTimeout = 10 * time.Second
- // The display the page's coordinates refer to when the instance config does not say (API.md: 1280x720).
- defaultWidth, defaultHeight = 1280, 720
)
var pngSignature = []byte("\x89PNG\r\n\x1a\n")
@@ -227,11 +226,24 @@ func (s *server) handleInput(w http.ResponseWriter, r *http.Request) {
writeError(w, err)
return
}
+ if !in.Booted {
+ writeError(w, notBooted(i))
+ return
+ }
if body.Type == "tap" {
- wd, ht := in.Config.Width, in.Config.Height
- if wd <= 0 || ht <= 0 {
- wd, ht = defaultWidth, defaultHeight
+ // Config describes the next boot. A running guest may still use the previous
+ // preset, so validate against its screenshot, shared with the viewer cache.
+ data, err := s.screenshot(r.Context(), i)
+ if err != nil {
+ writeError(w, err)
+ return
}
+ display, err := png.DecodeConfig(bytes.NewReader(data))
+ if err != nil {
+ writeError(w, errs.New(errs.Internal, "截图的显示尺寸读不出"))
+ return
+ }
+ wd, ht := display.Width, display.Height
x, y := *body.X, *body.Y
if math.IsNaN(x) || math.IsNaN(y) || x < 0 || y < 0 || x >= float64(wd) || y >= float64(ht) {
writeError(w, errs.Newf(errs.InvalidArgument, "点击位置 (%g, %g) 不在 %d×%d 的画面里", x, y, wd, ht))
@@ -239,10 +251,6 @@ func (s *server) handleInput(w http.ResponseWriter, r *http.Request) {
}
command = fmt.Sprintf("input tap %d %d", int(x), int(y))
}
- if !in.Booted {
- writeError(w, notBooted(i))
- return
- }
res, err := s.mgr.Shell(detached(r), i, command, inputTimeout)
if err != nil {
writeError(w, err)
diff --git a/internal/ui/device_test.go b/internal/ui/device_test.go
index 9609644..0d85dea 100644
--- a/internal/ui/device_test.go
+++ b/internal/ui/device_test.go
@@ -5,6 +5,9 @@ package ui
import (
"bytes"
"errors"
+ "fmt"
+ "image"
+ "image/png"
"io"
"io/fs"
"mime/multipart"
@@ -110,9 +113,19 @@ func TestScreenshot(t *testing.T) {
expectError(t, h.do("GET", "/api/instances/8/screenshot", nil), http.StatusNotFound, errs.NotFound)
}
+func displayPNG(t *testing.T, width, height int) []byte {
+ t.Helper()
+ var b bytes.Buffer
+ if err := png.Encode(&b, image.NewGray(image.Rect(0, 0, width, height))); err != nil {
+ t.Fatal(err)
+ }
+ return b.Bytes()
+}
+
func TestInputValidation(t *testing.T) {
h := newHarness(t, nil)
h.f.put(3, emu.StatusOnline)
+ h.f.shot = adb.Result{Code: 0, Stdout: displayPNG(t, 1280, 720)}
ok := map[string]string{
`{"type":"tap","x":100,"y":200}`: "input tap 100 200",
@@ -391,3 +404,31 @@ func TestCheckCached(t *testing.T) {
t.Fatalf("refresh=1 did not check again (%d)", n)
}
}
+
+// Next-boot settings must neither scale nor reject taps in the still-running guest.
+func TestInputUsesLiveDisplayAfterPresetChange(t *testing.T) {
+ for _, dimensions := range [][2]int{{1920, 1080}, {1280, 720}, {1080, 1920}, {720, 1280}} {
+ t.Run(fmt.Sprint(dimensions), func(t *testing.T) {
+ h := newHarness(t, nil)
+ h.f.put(3, emu.StatusOnline)
+ h.f.mu.Lock()
+ in := h.f.instances[3]
+ in.Config.Width, in.Config.Height = 720, 1280
+ if dimensions[0] <= 1280 && dimensions[1] <= 1280 {
+ in.Config.Width, in.Config.Height = 1920, 1080
+ }
+ h.f.instances[3] = in
+ h.f.shot = adb.Result{Code: 0, Stdout: displayPNG(t, dimensions[0], dimensions[1])}
+ h.f.mu.Unlock()
+ x, y := dimensions[0]-1, dimensions[1]-1
+ expect(t, h.do("POST", "/api/instances/3/input", fmt.Sprintf(`{"type":"tap","x":%d,"y":%d}`, x, y)), 200, nil)
+ h.f.mu.Lock()
+ got := h.f.shells[len(h.f.shells)-1]
+ h.f.mu.Unlock()
+ if want := fmt.Sprintf("input tap %d %d", x, y); got != want {
+ t.Fatalf("%s, want %s", got, want)
+ }
+ expectError(t, h.do("POST", "/api/instances/3/input", fmt.Sprintf(`{"type":"tap","x":%d,"y":0}`, dimensions[0])), 400, errs.InvalidArgument)
+ })
+ }
+}
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
index 904e815..775b652 100644
--- a/internal/ui/handlers.go
+++ b/internal/ui/handlers.go
@@ -56,12 +56,13 @@ func (s *server) handleCreate(w http.ResponseWriter, r *http.Request) {
CPU *int `json:"cpu"`
DataPartitionGB *int `json:"data_partition_gb"`
FPS *int `json:"fps"`
+ Resolution *int `json:"resolution"`
}
if !decode(w, r, &body) {
return
}
req := emu.CreateRequest{Index: body.Index, MemoryMB: body.MemoryMB, CPU: body.CPU,
- DataPartitionGB: body.DataPartitionGB, FPS: body.FPS}
+ DataPartitionGB: body.DataPartitionGB, FPS: body.FPS, Resolution: body.Resolution}
if body.Title != nil {
req.Title = *body.Title
}
@@ -114,17 +115,18 @@ func (s *server) handleUpdate(w http.ResponseWriter, r *http.Request) {
MemoryMB *int `json:"memory_mb"`
CPU *int `json:"cpu"`
FPS *int `json:"fps"`
+ Resolution *int `json:"resolution"`
DataPartitionGB *int `json:"data_partition_gb"`
}
if !decode(w, r, &body) {
return
}
- if body.Title == nil && body.MemoryMB == nil && body.CPU == nil && body.FPS == nil && body.DataPartitionGB == nil {
- writeError(w, errs.New(errs.Usage, "没有要改的设置").WithAdvice("至少给出 title、memory_mb、cpu、fps、data_partition_gb 中的一项"))
+ if body.Title == nil && body.MemoryMB == nil && body.CPU == nil && body.FPS == nil && body.Resolution == nil && body.DataPartitionGB == nil {
+ writeError(w, errs.New(errs.Usage, "没有要改的设置").WithAdvice("至少给出 title、memory_mb、cpu、fps、resolution、data_partition_gb 中的一项"))
return
}
res, err := s.mgr.Update(detached(r), i, emu.UpdateRequest{Title: body.Title, MemoryMB: body.MemoryMB, CPU: body.CPU,
- FPS: body.FPS, DataPartitionGB: body.DataPartitionGB})
+ FPS: body.FPS, Resolution: body.Resolution, DataPartitionGB: body.DataPartitionGB})
if err != nil {
writeError(w, err)
return
diff --git a/internal/ui/http_test.go b/internal/ui/http_test.go
index cea3fbe..c3302c6 100644
--- a/internal/ui/http_test.go
+++ b/internal/ui/http_test.go
@@ -133,7 +133,7 @@ func TestCreateUpdateDelete(t *testing.T) {
}
return h.waitJob(v.ID, stateIs(stateDone))
}
- j := create(map[string]any{"title": "测试机", "index": 2, "memory_mb": 4096, "cpu": 4, "data_partition_gb": 32, "fps": 30})
+ j := create(map[string]any{"title": "测试机", "index": 2, "memory_mb": 4096, "cpu": 4, "data_partition_gb": 32, "fps": 30, "resolution": 720})
if res, _ := j.Result.(map[string]any); j.Index != 2 || res["title"] != "测试机" {
t.Fatalf("created %+v", j)
}
@@ -151,11 +151,11 @@ func TestCreateUpdateDelete(t *testing.T) {
t.Fatalf("creates %d", len(c))
}
r := c[0]
- if r.Title != "测试机" || *r.Index != 2 || *r.MemoryMB != 4096 || *r.CPU != 4 || *r.DataPartitionGB != 32 || *r.FPS != 30 ||
+ if r.Title != "测试机" || *r.Index != 2 || *r.MemoryMB != 4096 || *r.CPU != 4 || *r.DataPartitionGB != 32 || *r.FPS != 30 || r.Resolution == nil || *r.Resolution != 720 ||
r.Progress == nil || r.Resume {
t.Fatalf("create request %+v", r)
}
- if r := c[1]; r.Title != "" || r.Index == nil || *r.Index != auto1.Index || r.MemoryMB != nil || r.CPU != nil || r.DataPartitionGB != nil || r.FPS != nil {
+ if r := c[1]; r.Title != "" || r.Index == nil || *r.Index != auto1.Index || r.MemoryMB != nil || r.CPU != nil || r.DataPartitionGB != nil || r.FPS != nil || r.Resolution != nil {
t.Fatalf("empty create request %+v", r)
}
if r := c[3]; !r.Resume || r.Index == nil || *r.Index != 3 {
@@ -177,7 +177,7 @@ func TestCreateUpdateDelete(t *testing.T) {
// update
expectError(t, h.do("PATCH", "/api/instances/2", "{}"), 400, errs.Usage)
var up emu.UpdateResult
- expect(t, h.do("PATCH", "/api/instances/2", `{"fps":30}`), http.StatusOK, &up)
+ expect(t, h.do("PATCH", "/api/instances/2", `{"fps":30,"resolution":1080}`), http.StatusOK, &up)
if up.Applied["fps"] != float64(30) {
t.Fatalf("update result %+v", up)
}
@@ -187,7 +187,7 @@ func TestCreateUpdateDelete(t *testing.T) {
h.f.mu.Lock()
u := h.f.updated
h.f.mu.Unlock()
- if len(u) != 4 || *u[0].FPS != 30 || u[0].Title != nil || u[0].MemoryMB != nil || u[0].CPU != nil ||
+ if len(u) != 4 || u[0].Resolution == nil || *u[0].Resolution != 1080 || *u[0].FPS != 30 || u[0].Title != nil || u[0].MemoryMB != nil || u[0].CPU != nil ||
u[0].DataPartitionGB != nil {
t.Fatalf("update requests %+v", u)
}
diff --git a/internal/ui/web/dev/mock.js b/internal/ui/web/dev/mock.js
index 67be311..10f89b6 100644
--- a/internal/ui/web/dev/mock.js
+++ b/internal/ui/web/dev/mock.js
@@ -44,7 +44,7 @@ function mk(i, title, o = {}) {
serial: 'emulator-' + console, adb_address: '127.0.0.1:' + (console + 1),
ports: { console, adb: console + 1, grpc: console + 2 },
screencap: { shm: 'SHM_videmulator' + console, shm_ready: st === 'online' },
- config: { memory_mb: o.mem || 6144, cpu: o.cpu || 6, fps: o.fps || 60, data_partition_gb: o.disk || 64, width: 1280, height: 720, dpi: 240 },
+ config: { memory_mb: o.mem || 6144, cpu: o.cpu || 6, fps: o.fps || 60, data_partition_gb: o.disk || 64, resolution: o.resolution || 720, width: o.resolution === 1080 ? 1920 : 1280, height: o.resolution === 1080 ? 1080 : 720, dpi: o.resolution === 1080 ? 280 : 240 },
initialized, launcher: initialized ? 'µLauncher' : '',
renderer: initialized ? (o.sw ? SWIFT : GPU) : '', software_renderer: !!o.sw,
path: `${ROOT}\\avd\\mas_${i}.avd`,
@@ -645,6 +645,7 @@ const errResp = (code, message, advice) => fail(STATUS_OF[code] || 500, code, me
function validate(b, create) {
if (b.memory_mb != null && !MEM.includes(b.memory_mb)) return ['invalid_argument', `内存只能选 3、4、5、6 GB(收到 ${b.memory_mb} MB)`, ''];
if (b.cpu != null && ![2, 4, 6].includes(b.cpu)) return ['invalid_argument', `CPU 核数只能是 2、4 或 6(收到 ${b.cpu})`, ''];
+ if (b.resolution != null && ![1080, 720].includes(b.resolution)) return ['invalid_argument', '分辨率只能是 1080 或 720', ''];
if (b.fps != null && ![30, 60].includes(b.fps)) return ['invalid_argument', `帧率只能是 30 或 60(收到 ${b.fps})`, ''];
if (create && b.data_partition_gb != null && (!Number.isInteger(b.data_partition_gb) || b.data_partition_gb < 16 || b.data_partition_gb > 512)) {
return ['invalid_argument', `数据盘上限只能是 16–512 GB 的整数(收到 ${b.data_partition_gb})`, ''];
@@ -673,7 +674,7 @@ async function handle(url, init) {
if (!INDEXES.includes(i)) return errResp('invalid_argument', `实例编号 ${i} 不可用(只能是 0–9,5 除外)`);
if (db.insts.has(i)) return errResp('already_exists', `实例 mas_${i} 已存在`);
await sleep(500);
- const inst = mk(i, body.title, { mem: body.memory_mb, cpu: body.cpu, fps: body.fps, disk: body.data_partition_gb, initialized: false, scene: i });
+ const inst = mk(i, body.title, { mem: body.memory_mb, cpu: body.cpu, fps: body.fps, resolution: body.resolution || 1080, disk: body.data_partition_gb, initialized: false, scene: i });
log('info', `已新建魔改 AVD 实例 ${i}(内存 ${inst.config.memory_mb} MB / ${inst.config.cpu} 核 / 数据盘 ${inst.config.data_partition_gb} GB / 帧率 ${inst.config.fps})`);
emit('instances', list());
return json(pub(inst), 201);
@@ -723,6 +724,12 @@ async function handle(url, init) {
if (bad) return errResp(...bad);
const applied = {};
if (body.title != null) { inst.title = body.title || inst.name; applied.title = body.title; }
+ if (body.resolution != null) {
+ const portrait = inst.config.height > inst.config.width;
+ const w = body.resolution === 1080 ? 1920 : 1280, h = body.resolution;
+ Object.assign(inst.config, { resolution: body.resolution, width: portrait ? h : w, height: portrait ? w : h, dpi: body.resolution === 1080 ? 280 : 240 });
+ applied.resolution = body.resolution;
+ }
for (const k of ['memory_mb', 'cpu', 'fps']) if (body[k] != null) { inst.config[k] = body[k]; applied[k] = body[k]; }
emit('instances', list());
return json({ instance: pub(inst), applied, applies_next_boot: inst.running });
diff --git a/internal/ui/web/js/display.js b/internal/ui/web/js/display.js
new file mode 100644
index 0000000..52debec
--- /dev/null
+++ b/internal/ui/web/js/display.js
@@ -0,0 +1,10 @@
+// Map a click through object-fit: contain, including portrait screenshots in a wide frame.
+export function displayTap(rect, width, height, clientX, clientY) {
+ if (width <= 0 || height <= 0 || rect.width <= 0 || rect.height <= 0) return null;
+ const scale = Math.min(rect.width / width, rect.height / height);
+ const shownWidth = width * scale, shownHeight = height * scale;
+ const x = (clientX - rect.left - (rect.width - shownWidth) / 2) / shownWidth;
+ const y = (clientY - rect.top - (rect.height - shownHeight) / 2) / shownHeight;
+ if (x < 0 || x > 1 || y < 0 || y > 1) return null;
+ return { type: 'tap', x: Math.round(x * (width - 1)), y: Math.round(y * (height - 1)) };
+}
diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js
index d585d41..81a8ec7 100644
--- a/internal/ui/web/js/forms.js
+++ b/internal/ui/web/js/forms.js
@@ -10,6 +10,7 @@ import * as A from './actions.js';
const MEM = [[3072, '3 GB'], [4096, '4 GB'], [5120, '5 GB'], [6144, '6 GB']];
const CPU = [[2, '2 核'], [4, '4 核'], [6, '6 核']];
const FPS = [[30, '30 Hz'], [60, '60 Hz']];
+const RESOLUTION = [[1080, '1080p'], [720, '720p']];
const INDEXES = [0, 1, 2, 3, 4, 6, 7, 8, 9];
const MIN12 = '约 1–2 分钟';
const CREATE_TIME = '一般要几分钟';
@@ -100,6 +101,7 @@ export function openCreate() {
segRow('内存', 'memory', MEM, 6144),
segRow('CPU', 'cpu', CPU, 6),
segRow('帧率', 'fps', FPS, 60, hint('30 更省资源')),
+ segRow('分辨率', 'resolution', RESOLUTION, 1080, hint('720p 更省资源')),
inputRow('数据盘上限', h('span', { class: 'with-suffix' }, disk, h('span', { class: 'suffix', text: 'GB' })), diskHint, diskErr),
inputRow('编号', idx, idxHint)), box),
actions: { ok },
@@ -119,7 +121,7 @@ export function openCreate() {
// a job: the setup boots the instance headless for a few minutes; its card shows the progress
const job = await api.post('/api/instances', {
title: title.value.trim() || undefined, memory_mb: Number(f.memory.value), cpu: Number(f.cpu.value),
- fps: Number(f.fps.value), data_partition_gb: n, index: idx.value === '' ? undefined : Number(idx.value),
+ fps: Number(f.fps.value), resolution: Number(f.resolution.value), data_partition_gb: n, index: idx.value === '' ? undefined : Number(idx.value),
});
A.onJob(job);
S.watch.add(job.id);
@@ -151,7 +153,8 @@ export function openSettings(i, field) {
inputRow('名称', title, hint(`不填就显示 ${inst0.name}`)),
segRow('内存', 'memory', MEM, cfg.memory_mb),
segRow('CPU', 'cpu', CPU, cfg.cpu),
- segRow('帧率', 'fps', FPS, cfg.fps, hint('30 更省资源'))),
+ segRow('帧率', 'fps', FPS, cfg.fps, hint('30 更省资源')),
+ segRow('分辨率', 'resolution', RESOLUTION, cfg.resolution, hint('下次开机生效,自动化工具里的分辨率也要跟着改'))),
box,
h('h3', { class: 'sec-title', text: '实例信息' }), kv),
actions: { left: del, ok: save },
@@ -168,7 +171,7 @@ export function openSettings(i, field) {
const f = ctl.form.elements;
const out = {};
if (title.value.trim() !== initTitle) out.title = title.value.trim();
- for (const [k, key] of [['memory', 'memory_mb'], ['cpu', 'cpu'], ['fps', 'fps']]) {
+ for (const [k, key] of [['memory', 'memory_mb'], ['cpu', 'cpu'], ['fps', 'fps'], ['resolution', 'resolution']]) {
const v = f[k].value === '' ? null : Number(f[k].value);
if (v != null && v !== cfg[key]) out[key] = v;
}
diff --git a/internal/ui/web/js/viewer.js b/internal/ui/web/js/viewer.js
index 9c7a95c..f928732 100644
--- a/internal/ui/web/js/viewer.js
+++ b/internal/ui/web/js/viewer.js
@@ -6,6 +6,7 @@ import { S, subscribe, instance, rootBlocked } from './store.js';
import { openDialog } from './ui.js';
import { label } from './format.js';
import * as A from './actions.js';
+import { displayTap } from './display.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -102,17 +103,15 @@ export function openViewer(i) {
frame.addEventListener('click', (e) => {
if (frame.classList.contains('off')) return;
const r = frame.getBoundingClientRect();
- const cfg = (instance(i) || inst).config || {};
- const fx = (e.clientX - r.left) / r.width;
- const fy = (e.clientY - r.top) / r.height;
- if (fx < 0 || fx > 1 || fy < 0 || fy > 1) return;
+ // The screenshot is the running display; config may already describe the next boot.
+ const tap = displayTap(r, img.naturalWidth, img.naturalHeight, e.clientX, e.clientY);
+ if (!tap) return;
const rip = h('span', { class: 'ripple' });
- rip.style.left = fx * 100 + '%';
- rip.style.top = fy * 100 + '%';
+ rip.style.left = (e.clientX - r.left) + 'px';
+ rip.style.top = (e.clientY - r.top) + 'px';
frame.append(rip);
setTimeout(() => rip.remove(), 600);
- // the API takes pixels of the 1280×720 display
- A.input(i, { type: 'tap', x: Math.round(fx * ((cfg.width || 1280) - 1)), y: Math.round(fy * ((cfg.height || 720) - 1)) });
+ A.input(i, tap);
});
const un = subscribe(() => {
const cur = instance(i);
diff --git a/scripts/test_display_tap.mjs b/scripts/test_display_tap.mjs
new file mode 100644
index 0000000..58930a2
--- /dev/null
+++ b/scripts/test_display_tap.mjs
@@ -0,0 +1,31 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { displayTap } from '../internal/ui/web/js/display.js';
+
+const frame = { left: 10, top: 20, width: 320, height: 180 };
+
+test('both presets use the current screenshot size', () => {
+ assert.deepEqual(displayTap(frame, 1280, 720, 170, 110), { type: 'tap', x: 640, y: 360 });
+ assert.deepEqual(displayTap(frame, 1920, 1080, 170, 110), { type: 'tap', x: 960, y: 540 });
+ assert.deepEqual(displayTap(frame, 1920, 1080, 330, 200), { type: 'tap', x: 1919, y: 1079 });
+});
+
+test('portrait taps exclude the empty side bands', () => {
+ assert.equal(displayTap(frame, 1080, 1920, 20, 110), null);
+ assert.equal(displayTap(frame, 720, 1280, 320, 110), null);
+ assert.deepEqual(displayTap(frame, 1080, 1920, 170, 110), { type: 'tap', x: 540, y: 960 });
+ assert.deepEqual(displayTap(frame, 720, 1280, 170, 110), { type: 'tap', x: 360, y: 640 });
+ assert.deepEqual(displayTap(frame, 1080, 1920, 119.375, 20), { type: 'tap', x: 0, y: 0 });
+});
+
+test('a tall frame excludes empty top and bottom bands', () => {
+ const tall = { left: 0, top: 0, width: 180, height: 320 };
+ assert.equal(displayTap(tall, 1920, 1080, 90, 10), null);
+ assert.deepEqual(displayTap(tall, 1920, 1080, 90, 160), { type: 'tap', x: 960, y: 540 });
+});
+
+test('unloaded images and zero-size frames cannot send a tap', () => {
+ assert.equal(displayTap(frame, 0, 0, 170, 110), null);
+ assert.equal(displayTap({ ...frame, width: 0 }, 1280, 720, 170, 110), null);
+ assert.equal(displayTap(frame, 1280, 720, 9, 110), null);
+});
diff --git a/scripts/test_resolution_guards.py b/scripts/test_resolution_guards.py
new file mode 100644
index 0000000..a980869
--- /dev/null
+++ b/scripts/test_resolution_guards.py
@@ -0,0 +1,70 @@
+"""Check resolution regressions with Go source overlays, without editing the checkout.
+
+python scripts/test_resolution_guards.py --go --out
+Each variant must compile and fail its named behavior test.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import subprocess
+import tempfile
+
+ROOT = Path(__file__).resolve().parents[1]
+CASES = [
+ ("new default", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot",
+ "DefaultResolution = 1080", "DefaultResolution = 720"),
+ ("1080 density", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot",
+ "width, height, density = 1920, 1080, 280", "width, height, density = 1920, 1080, 240"),
+ ("portrait recognition", "internal/avd/avd.go", "avd", "TestResolutionLegacy",
+ "(w == 1920 && h == 1080) || (w == 1080 && h == 1920)", "w == 1920 && h == 1080"),
+ ("orientation", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot",
+ "if h > w {", "if false && h > w {"),
+ ("update persistence", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot",
+ "changes = append(changes, displayChanges(in.Config, resolution)...)", "_ = resolution"),
+ ("pre-boot preset", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot",
+ "displayChanges(cfg, resolutionFor(cfg))", "displayChanges(cfg, 720)"),
+ ("CLI create wiring", "internal/cli/commands.go", "cli", "TestBuildCreateRequest",
+ 'req.Resolution = intPtrOpt(inv, "resolution")', "req.Resolution = nil"),
+ ("CLI update wiring", "internal/cli/commands.go", "cli", "TestBuildUpdateRequest",
+ 'req.Resolution = intPtrOpt(inv, "resolution")', "req.Resolution = nil"),
+ ("MCP wiring", "internal/mcp/tools.go", "mcp", "TestToolCalls",
+ 'a.optInt("resolution")', 'a.optInt("omitted_resolution")'),
+ ("HTTP wiring", "internal/ui/handlers.go", "ui", "TestCreateUpdateDelete",
+ "Resolution: body.Resolution", "Resolution: nil"),
+ ("live display bounds", "internal/ui/device.go", "ui", "TestInputUsesLiveDisplayAfterPresetChange",
+ "wd, ht := display.Width, display.Height", "wd, ht := in.Config.Width, in.Config.Height; _ = display"),
+]
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--go", default="go")
+ parser.add_argument("--out", type=Path, required=True)
+ args = parser.parse_args()
+ results = []
+ with tempfile.TemporaryDirectory(prefix="resolution-guards-") as directory:
+ scratch = Path(directory)
+ for n, (name, source, package, test, before, after) in enumerate(CASES):
+ original = ROOT / source
+ text = original.read_text("utf-8")
+ assert before in text, name
+ changed = scratch / f"variant-{n}.go"
+ changed.write_text(text.replace(before, after), "utf-8")
+ overlay = scratch / "overlay.json"
+ overlay.write_text(json.dumps({"Replace": {str(original): str(changed)}}), "utf-8")
+ run = subprocess.run([args.go, "test", "-trimpath", "-buildvcs=false", "-ldflags=-buildid=", "-count=1", "-timeout=45s",
+ "-overlay", str(overlay), "./internal/" + package, "-run", "^" + test + "$"],
+ cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=90)
+ output = run.stdout + run.stderr
+ caught = run.returncode != 0 and f"--- FAIL: {test}" in output and "[build failed]" not in output
+ results.append({"variant": name, "test": test, "caught": caught, "output": output})
+ print(f"{'caught' if caught else 'MISSED'}: {name}", flush=True)
+ args.out.parent.mkdir(parents=True, exist_ok=True)
+ args.out.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", "utf-8")
+ return 0 if all(r["caught"] for r in results) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/test_resolution_guest.py b/scripts/test_resolution_guest.py
new file mode 100644
index 0000000..86b5927
--- /dev/null
+++ b/scripts/test_resolution_guest.py
@@ -0,0 +1,94 @@
+"""Run display regression checks on an isolated Windows root with a private SDK.
+
+python scripts/test_resolution_guest.py --root C:/resolution-lab --legacy-cli C:/old/tools/modavd.exe --out C:/evidence/display.json
+
+The root must start without instances, use non-default private ports, and contain a
+complete SDK. The script keeps its instances for inspection and shuts them down.
+Only query forms of wm size/density are used. No guest display overrides are set.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import re
+import subprocess
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", type=Path, required=True)
+ parser.add_argument("--legacy-cli", type=Path, required=True)
+ parser.add_argument("--out", type=Path, required=True)
+ args = parser.parse_args()
+ root = args.root.resolve(strict=True)
+ meta = json.loads((root / "mas-avd.json").read_text("utf-8"))
+ assert meta.get("portBase") != 20000 and meta.get("portBase"), "use a private port plan"
+ assert meta.get("adbServerPort") not in (None, 20050, 5037)
+ assert meta.get("scriptAdbServerPort") not in (None, 20049, 5037)
+ assert not meta.get("sdkRoot"), "SDK must be copied into this isolated root"
+ assert (root / "sdk/emulator/emulator.exe").is_file()
+ assert not list((root / "avd").glob("mas_*")), "use a fresh root without instances"
+ cli = root / "tools/modavd.exe"
+ records: list[dict] = []
+ args.out.parent.mkdir(parents=True, exist_ok=True)
+
+ def call(*command: str, exe: Path = cli) -> dict:
+ run = subprocess.run([str(exe), "--root", str(root), "--json", *command],
+ capture_output=True, text=True, encoding="utf-8", timeout=1500)
+ records.append({"command": list(command), "legacy": exe != cli,
+ "code": run.returncode, "stdout": run.stdout, "stderr": run.stderr})
+ args.out.write_text(json.dumps(records, ensure_ascii=False, indent=2) + "\n", "utf-8")
+ assert run.returncode == 0, (command, run.stdout, run.stderr)
+ result = json.loads(run.stdout)
+ assert result["ok"], result
+ return result["data"]
+
+ def observe(index: int, width: int, height: int, dpi: int, stage: str) -> None:
+ call("start", str(index), "--headless", "--no-bridge")
+ output = call("shell", str(index), "wm size; wm density; dumpsys display")["output"]
+ assert f"Physical size: {width}x{height}" in output, output
+ assert f"Physical density: {dpi}" in output, output
+ assert "Override size:" not in output and "Override density:" not in output, output
+ # WindowManager query plus the guest's actual DisplayInfo, independent of config.ini.
+ assert re.search(rf"(?:real|supportedModes=.*?width=)\s*{width}(?:\s*x\s*|, height=){height}", output), output
+ print(f"{stage}: guest {width}x{height} / DPI {dpi}", flush=True)
+ call("stop", str(index))
+
+ try:
+ call("create", "--index", "0", "--memory", "3G", "--cpu", "2", "--no-bridge")
+ observe(0, 1920, 1080, 280, "new default")
+ call("start", "0", "--headless", "--no-bridge")
+ update = call("set", "0", "--resolution", "720")
+ assert update["applies_next_boot"] and update["instance"]["config"]["resolution"] == 720, update
+ live = call("shell", "0", "wm size; wm density")["output"]
+ assert "Physical size: 1920x1080" in live and "Physical density: 280" in live, live
+ print("running change: guest stays 1920x1080 / DPI 280 until restart", flush=True)
+ call("stop", "0")
+ observe(0, 1280, 720, 240, "running change after restart")
+ call("set", "0", "--resolution", "1080")
+ observe(0, 1920, 1080, 280, "restored 1080")
+ call("create", "--index", "1", "--memory", "3G", "--cpu", "2", "--no-bridge", exe=args.legacy_cli.resolve(strict=True))
+ observe(1, 1280, 720, 240, "old 720 first upgraded boot")
+ observe(1, 1280, 720, 240, "old 720 repeated boot")
+ call("set", "1", "--resolution", "1080")
+ observe(1, 1920, 1080, 280, "720 to 1080")
+ call("set", "1", "--resolution", "720")
+ observe(1, 1280, 720, 240, "1080 to 720")
+ call("create", "--index", "2", "--resolution", "720", "--memory", "3G", "--cpu", "2", "--no-bridge")
+ observe(2, 1280, 720, 240, "new explicit 720")
+ # Seed a portrait legacy configuration, then change the preset through the CLI.
+ cfg_path = root / "avd/mas_2.avd/config.ini"
+ cfg = cfg_path.read_text("utf-8").replace("hw.lcd.width=1280", "hw.lcd.width=720").replace("hw.lcd.height=720", "hw.lcd.height=1280")
+ cfg_path.write_text(cfg, "utf-8")
+ call("set", "2", "--resolution", "1080")
+ observe(2, 1080, 1920, 280, "portrait 720 to 1080")
+ observe(2, 1080, 1920, 280, "portrait 1080 repeated boot")
+ call("set", "2", "--resolution", "720")
+ observe(2, 720, 1280, 240, "portrait 1080 to 720")
+ finally:
+ call("stop", "--all")
+
+
+if __name__ == "__main__":
+ main()