chiaki-ng 配置转换说明(chiaki-settings.json -> chiaki-ng-*.ini

1. 目标与适用范围

本文说明如何把一个平台导出的 chiaki-settings.json(JSON 格式)转换为桌面版可导入的 chiaki-ng-*.ini(QSettings INI 格式)。

适用场景:

  • 你只有 JSON 备份,想在桌面版 chiaki-ng 中恢复主机注册信息。
  • 需要迁移 registered_hostsmanual_hosts

不适用场景:

  • 直接把 JSON 文件喂给桌面版导入器(桌面版导入器只接受 *.ini)。

2. 输入与输出

  • 输入文件:chiaki-settings.json
  • 输出文件:chiaki-ng-Default.ini(或任意 *.ini 文件名)

建议先备份当前 INI:

Copy-Item .\chiaki-ng-Default.ini .\chiaki-ng-Default.ini.bak

3. 解析 JSON 结构

chiaki-settings.json 顶层应包含:

  • format:固定为 chiaki-settings
  • version:当前为 2
  • settings:对象,包含以下数组
    • registered_hosts
    • manual_hosts

registered_hosts 单项字段:

  • target(例如 PS5_1 / PS4_10
  • ap_ssid
  • ap_bssid
  • ap_key
  • ap_name
  • server_mac(字符串,格式 aa:bb:cc:dd:ee:ff
  • server_nickname
  • rp_regist_key(Base64,解码后应为 16 字节)
  • rp_key_type(整数)
  • rp_key(Base64,解码后应为 16 字节)

manual_hosts 单项字段:

  • host(IP 或域名)
  • server_mac(可空)

4. 字段转换规则(核心)

4.1 target 字符串转整数

写入 INI 时,target 必须是整数枚举值:

  • PS4_UNKNOWN -> 0
  • PS4_8 -> 800
  • PS4_9 -> 900
  • PS4_10 -> 1000
  • PS5_UNKNOWN -> 1000000
  • PS5_1 -> 1000100

4.2 server_mac 文本转 @ByteArray

JSON 中:

aa:bb:cc:dd:ee:ff

INI 中:

@ByteArray(\xAA\xBB\xCC\xDD\xEE\xFF)

要求严格为 6 字节。

4.3 rp_regist_key / rp_key

  • 先 Base64 解码
  • 解码结果必须是 16 字节
  • 写入 @ByteArray(...)

示例:

rp_key=@ByteArray(\x11\x22\x33\x44\x55\x66\x77\x88\x99\xAA\xBB\xCC\xDD\xEE\xFF\x00)

4.4 manual_hosts 的注册关联

如果 manual_hosts[i].server_mac 不为空:

  • registered=true
  • registered_mac=@ByteArray(...)(6 字节 MAC)

如果 server_mac 为空:

  • registered=false
  • registered_mac 可写 6 字节全 0

5. 写入 INI 的结构

桌面版可导入的 INI 至少包含以下段:

  • [registered_hosts]
  • [hidden_hosts]
  • [manual_hosts]
  • [controller_mappings]
  • [settings]
  • [General]

其中数组段使用 n\field=value 格式,n1 开始,并包含 size

  • 1\target=...
  • 1\server_mac=...
  • ...
  • size=1

注意:size 必须与实际条目数一致,否则导入后可能丢项。

6. 匿名示例(完整 INI 骨架)

[registered_hosts]
1\target=1000100
1\ap_ssid=
1\ap_bssid=
1\ap_key=
1\ap_name=PS5
1\server_nickname=Console-A
1\server_mac=@ByteArray(\xAA\xBB\xCC\xDD\xEE\xFF)
1\rp_regist_key=@ByteArray(\x10\x20\x30\x40\x50\x60\x70\x80\x90\xA0\xB0\xC0\xD0\xE0\xF0\x00)
1\rp_key_type=2
1\rp_key=@ByteArray(\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10)
size=1

[hidden_hosts]
size=0

[manual_hosts]
1\id=0
1\host=192.168.0.10
1\registered=true
1\registered_mac=@ByteArray(\xAA\xBB\xCC\xDD\xEE\xFF)
size=1

[controller_mappings]
size=0

[settings]
hw_decoder=auto
this_profile=

[General]
version=2

7. 最小校验清单

导入前检查:

  • format/version/settings 在 JSON 中存在。
  • 每个 rp_regist_key Base64 解码后是 16 字节。
  • 每个 rp_key Base64 解码后是 16 字节。
  • 每个 MAC 最终是 6 字节。
  • INI 中每个数组段 size 正确。
  • [General]version=2

可用 PowerShell 快速验证 Base64 长度:

$j = Get-Content .\chiaki-settings.json -Raw | ConvertFrom-Json
$j.settings.registered_hosts | ForEach-Object {
  [PSCustomObject]@{
    server_nickname = $_.server_nickname
    rp_regist_key_len = ([Convert]::FromBase64String($_.rp_regist_key)).Length
    rp_key_len = ([Convert]::FromBase64String($_.rp_key)).Length
  }
}

8. 导入到 chiaki-ng

  1. 打开 chiaki-ng。
  2. 进入设置,执行 Import Profile From File
  3. 选择转换后的 *.ini
  4. 导入后重启应用并检查主机列表。

9. 常见失败原因

  • 使用了 JSON 直接导入(桌面导入器不支持)。
  • rp_key / rp_regist_key 没有先 Base64 解码就直接写入。
  • MAC 仍是字符串格式,未写成 @ByteArray
  • target 仍是字符串,未转整数。
  • size 与条目数不一致。

10. 自动转换脚本(Python,推荐)

已提供脚本:

  • scripts/convert_chiaki_json_to_ini.py

功能:

  • 输入 chiaki-settings.json 和一个 ini 模板文件。
  • 自动清空并重写 [registered_hosts][manual_hosts]
  • 其他段(如 [settings][General])保持原样。

前置要求(脚本会检查并提示):

  • 模板 INI 必须是“空备份配置文件”,即:
    • [registered_hosts]size=0
    • [manual_hosts]size=0
    • 不能有 1\... 这类索引项

运行示例:

py -3 .\scripts\convert_chiaki_json_to_ini.py --json .\chiaki-settings.json --template-ini .\chiaki-ng-Default.ini --output-ini .\chiaki-ng-Default-from-json.ini

若不传 --output-ini,默认输出:

  • <模板文件名>-from-json.ini

11. Python 脚本(嵌入)

"""
Convert chiaki-settings.json to chiaki-ng ini backup format.

Only [registered_hosts] and [manual_hosts] sections are replaced.
All other sections are preserved from the template ini.
"""

from __future__ import annotations

import argparse
import base64
import binascii
import json
import re
import sys
from pathlib import Path
from typing import Any


TARGET_MAP = {
    "PS4_UNKNOWN": 0,
    "PS4_8": 800,
    "PS4_9": 900,
    "PS4_10": 1000,
    "PS5_UNKNOWN": 1000000,
    "PS5_1": 1000100,
}


def target_to_int(value: Any) -> int:
    if isinstance(value, int):
        return value
    if isinstance(value, str) and value in TARGET_MAP:
        return TARGET_MAP[value]
    raise ValueError(f"Unsupported target value: {value!r}")


def bytes_to_qbytearray_literal(data: bytes) -> str:
    return "@ByteArray(" + "".join(f"\\x{b:02X}" for b in data) + ")"


def mac_to_bytes(mac: str) -> bytes:
    parts = mac.split(":")
    if len(parts) != 6:
        raise ValueError(f"Invalid MAC address {mac!r}, expected aa:bb:cc:dd:ee:ff")
    try:
        return bytes(int(p, 16) for p in parts)
    except ValueError as exc:
        raise ValueError(f"Invalid MAC address {mac!r}") from exc


def decode_base64(value: str, field_name: str) -> bytes:
    try:
        return base64.b64decode(value, validate=True)
    except (ValueError, binascii.Error) as exc:
        raise ValueError(f"Field {field_name!r} is not valid Base64") from exc


def section_regex(section: str) -> re.Pattern[str]:
    escaped = re.escape(section)
    return re.compile(rf"^\[{escaped}\]\r?\n.*?(?=^\[[^\]]+\]\r?\n|\Z)", re.MULTILINE | re.DOTALL)


def get_section_block(content: str, section: str) -> str | None:
    m = section_regex(section).search(content)
    return m.group(0) if m else None


def assert_empty_section(content: str, section: str) -> None:
    block = get_section_block(content, section)
    if block is None:
        raise ValueError(f"Template ini is missing required section [{section}]")

    body = re.sub(rf"^\[{re.escape(section)}\]\r?\n", "", block, count=1, flags=re.MULTILINE | re.DOTALL)
    size_match = re.search(r"^size=(\d+)\s*$", body, flags=re.MULTILINE)
    if not size_match:
        raise ValueError(f"Template ini section [{section}] must contain size=0")

    size = int(size_match.group(1))
    has_entries = bool(re.search(r"^\d+\\", body, flags=re.MULTILINE))
    if size != 0 or has_entries:
        raise ValueError(
            f"Template ini section [{section}] is not empty. "
            "Use an empty backup ini (size=0 and no indexed entries)."
        )


def replace_or_append_section(content: str, section: str, body: str, nl: str) -> str:
    pattern = section_regex(section)
    replacement = f"[{section}]{nl}{body.rstrip(chr(13) + chr(10))}{nl}"
    if pattern.search(content):
        return pattern.sub(lambda _m: replacement, content, count=1)
    return content.rstrip("\r\n") + nl + nl + replacement


def to_str(value: Any) -> str:
    if value is None:
        return ""
    return str(value)


def build_registered_section(hosts: list[dict[str, Any]]) -> str:
    lines: list[str] = []
    for idx, host in enumerate(hosts, start=1):
        target_value = target_to_int(host.get("target"))
        server_mac_literal = bytes_to_qbytearray_literal(mac_to_bytes(to_str(host.get("server_mac"))))

        rp_reg = decode_base64(to_str(host.get("rp_regist_key")), "rp_regist_key")
        if len(rp_reg) != 16:
            raise ValueError(f"registered_hosts[{idx - 1}].rp_regist_key length {len(rp_reg)} != 16")

        rp_key = decode_base64(to_str(host.get("rp_key")), "rp_key")
        if len(rp_key) != 16:
            raise ValueError(f"registered_hosts[{idx - 1}].rp_key length {len(rp_key)} != 16")

        try:
            rp_key_type = int(host.get("rp_key_type", 0))
        except (TypeError, ValueError) as exc:
            raise ValueError(f"registered_hosts[{idx - 1}].rp_key_type is not an integer") from exc

        lines.append(f"{idx}\\target={target_value}")
        lines.append(f"{idx}\\ap_ssid={to_str(host.get('ap_ssid'))}")
        lines.append(f"{idx}\\ap_bssid={to_str(host.get('ap_bssid'))}")
        lines.append(f"{idx}\\ap_key={to_str(host.get('ap_key'))}")
        lines.append(f"{idx}\\ap_name={to_str(host.get('ap_name'))}")
        lines.append(f"{idx}\\server_nickname={to_str(host.get('server_nickname'))}")
        lines.append(f"{idx}\\server_mac={server_mac_literal}")
        lines.append(f"{idx}\\rp_regist_key={bytes_to_qbytearray_literal(rp_reg)}")
        lines.append(f"{idx}\\rp_key_type={rp_key_type}")
        lines.append(f"{idx}\\rp_key={bytes_to_qbytearray_literal(rp_key)}")

    lines.append(f"size={len(hosts)}")
    return "\n".join(lines)


def build_manual_section(hosts: list[dict[str, Any]]) -> str:
    lines: list[str] = []
    for idx, host in enumerate(hosts, start=1):
        lines.append(f"{idx}\\id={idx - 1}")
        lines.append(f"{idx}\\host={to_str(host.get('host'))}")

        server_mac = to_str(host.get("server_mac")).strip()
        if server_mac:
            lines.append(f"{idx}\\registered=true")
            lines.append(f"{idx}\\registered_mac={bytes_to_qbytearray_literal(mac_to_bytes(server_mac))}")
        else:
            lines.append(f"{idx}\\registered=false")
            lines.append(f"{idx}\\registered_mac={bytes_to_qbytearray_literal(bytes([0, 0, 0, 0, 0, 0]))}")

    lines.append(f"size={len(hosts)}")
    return "\n".join(lines)


def detect_newline(text: str) -> str:
    return "\r\n" if "\r\n" in text else "\n"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Convert chiaki-settings.json into a chiaki-ng ini backup.\n"
            "Only [registered_hosts] and [manual_hosts] are replaced."
        )
    )
    parser.add_argument("--json", dest="json_path", required=True, help="Path to chiaki-settings.json")
    parser.add_argument("--template-ini", dest="template_ini", required=True, help="Path to empty backup ini template")
    parser.add_argument("--output-ini", dest="output_ini", default=None, help="Output ini path")
    return parser.parse_args()


def main() -> int:
    args = parse_args()

    json_path = Path(args.json_path).expanduser().resolve()
    template_ini_path = Path(args.template_ini).expanduser().resolve()

    if not json_path.is_file():
        print(f"JSON file not found: {json_path}", file=sys.stderr)
        return 1
    if not template_ini_path.is_file():
        print(f"Template ini file not found: {template_ini_path}", file=sys.stderr)
        return 1

    print("This script requires an EMPTY backup ini template exported by chiaki-ng.")
    print("Required: [registered_hosts] size=0 and [manual_hosts] size=0.")

    try:
        json_data = json.loads(json_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        print(f"Invalid JSON: {exc}", file=sys.stderr)
        return 1

    template_text = template_ini_path.read_text(encoding="utf-8-sig")
    newline = detect_newline(template_text)

    if json_data.get("format") != "chiaki-settings":
        print("Invalid JSON field 'format'. Expected 'chiaki-settings'.", file=sys.stderr)
        return 1
    if json_data.get("version") != 2:
        print(f"Unsupported JSON version: {json_data.get('version')!r}. Expected 2.", file=sys.stderr)
        return 1

    settings = json_data.get("settings")
    if not isinstance(settings, dict):
        print("Invalid JSON: missing 'settings' object.", file=sys.stderr)
        return 1

    try:
        assert_empty_section(template_text, "registered_hosts")
        assert_empty_section(template_text, "manual_hosts")
    except ValueError as exc:
        print(str(exc), file=sys.stderr)
        return 1

    registered_hosts = settings.get("registered_hosts") or []
    manual_hosts = settings.get("manual_hosts") or []
    if not isinstance(registered_hosts, list) or not isinstance(manual_hosts, list):
        print("Invalid JSON: 'registered_hosts' and 'manual_hosts' must be arrays.", file=sys.stderr)
        return 1

    try:
        registered_body = build_registered_section(registered_hosts)
        manual_body = build_manual_section(manual_hosts)
    except ValueError as exc:
        print(str(exc), file=sys.stderr)
        return 1

    updated_text = template_text
    updated_text = replace_or_append_section(updated_text, "registered_hosts", registered_body, newline)
    updated_text = replace_or_append_section(updated_text, "manual_hosts", manual_body, newline)

    if args.output_ini:
        output_path = Path(args.output_ini).expanduser().resolve()
    else:
        output_path = template_ini_path.with_name(template_ini_path.stem + "-from-json.ini")
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(updated_text, encoding="utf-8")

    print("")
    print("Conversion completed.")
    print(f"Input JSON:       {json_path}")
    print(f"Template INI:     {template_ini_path}")
    print(f"Output INI:       {output_path}")
    print(f"Registered hosts: {len(registered_hosts)}")
    print(f"Manual hosts:     {len(manual_hosts)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())