diff --git a/.claude/.gitkeep b/.claude/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.commitlintrc.yaml b/.commitlintrc.yaml new file mode 100644 index 0000000..b91687f --- /dev/null +++ b/.commitlintrc.yaml @@ -0,0 +1,23 @@ +--- +extends: + - "@commitlint/config-conventional" +rules: + header-max-length: + - 2 + - always + - 96 + type-enum: + - 2 + - always + - - build + - ui + - ci + - docs + - feat + - fix + - perf + - refactor + - revert + - format + - test + - chore diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..d8eb66e --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,104 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 +ARG _REMOTE_USER_HOME=/home/vscode + +# ── Reverse Engineering Tools + Theos 依存パッケージ (apt) ── +RUN \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + --mount=type=cache,target=/var/cache/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + wget \ + unzip \ + jq \ + radare2 \ + apktool \ + make \ + perl \ + fakeroot \ + zip \ + xz-utils \ + lzma \ + xxd \ + binutils \ + libtinfo6 \ + libxml2 \ + libncurses6 \ + libz3-dev \ + adb \ + android-sdk-platform-tools \ + # ── agent-iossolve 向け追加ツール ──────────────────────── + # libimobiledevice: idevicesyslog で os_log 取得 (rootless iOS には log コマンドが無いため、SSH 経由ではなく USB 経由が現実的) + # class-dump: Ubuntu 24.04 で利用可能 (universe)。recon の主力 + # socat / netcat: JB 機との中継、port forward + # file: Mach-O / FAT 判定 + libimobiledevice-utils \ + libimobiledevice6 \ + ideviceinstaller \ + libplist-utils \ + socat \ + netcat-openbsd \ + file \ + llvm + +# ── jadx (GitHub Release) ── +# Android APK → Java 逆コンパイル (プラットフォーム非依存 JAR、JRE は devcontainer feature の Java で提供) +RUN JADX_URL=$(wget -qO- https://api.github.com/repos/skylot/jadx/releases/latest | jq -r '.assets[] | select(.name | test("^jadx-[0-9].*\\.zip$")) | .browser_download_url') && \ + wget -q "$JADX_URL" -O /tmp/jadx.zip && \ + unzip -q /tmp/jadx.zip -d /opt/jadx && \ + ln -s /opt/jadx/bin/jadx /usr/local/bin/jadx && \ + ln -s /opt/jadx/bin/jadx-gui /usr/local/bin/jadx-gui && \ + rm /tmp/jadx.zip + +# ── Ghidra (Headless) ── +# NSA 製リバースエンジニアリングツール。ヘッドレスモードで C++ 擬似コード生成に使用 +# 使い方: analyzeHeadless /tmp/project name -import +RUN GHIDRA_URL=$(wget -qO- https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/latest | jq -r '.assets[] | select(.name | test("PUBLIC.*\\.zip$")) | .browser_download_url') && \ + wget -q "$GHIDRA_URL" -O /tmp/ghidra.zip && \ + unzip -q /tmp/ghidra.zip -d /opt && \ + GHIDRA_DIR=$(find /opt -maxdepth 1 -name 'ghidra_*' -type d) && \ + ln -s "$GHIDRA_DIR" /opt/ghidra && \ + rm /tmp/ghidra.zip +ENV PATH="/opt/ghidra/support:$PATH" + +# ── ipsw ── +# Go 製 Mach-O 解析ツール。iOS バイナリの ObjC/Swift クラスダンプ、シンボル解析に使用 +# 使い方: ipsw macho info --class-dump +RUN IPSW_URL=$(wget -qO- https://api.github.com/repos/blacktop/ipsw/releases/latest | jq -r '.assets[] | select(.name | test("^ipsw_.*linux_arm64\\.tar\\.gz$")) | .browser_download_url') && \ + wget -q "$IPSW_URL" -O /tmp/ipsw.tar.gz && \ + tar xzf /tmp/ipsw.tar.gz -C /usr/local/bin ipsw && \ + rm /tmp/ipsw.tar.gz + +# ── yq (mikefarah/yq, Go 製) ── +# YAML 操作。agent-iossolve の hook 仕様 YAML を扱う recon/frida エージェントが使う +RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') && \ + YQ_URL=$(wget -qO- https://api.github.com/repos/mikefarah/yq/releases/latest | jq -r --arg arch "$ARCH" '.assets[] | select(.name == "yq_linux_\($arch)") | .browser_download_url') && \ + wget -q "$YQ_URL" -O /usr/local/bin/yq && \ + chmod +x /usr/local/bin/yq + +# ── Theos 本体 ── +USER vscode +ENV THEOS=/home/vscode/theos +RUN git clone --recursive https://github.com/theos/theos.git ${THEOS} + +# ── Toolchain (L1ghtmann, aarch64 対応) ── +RUN ARCH=$(uname -m) && \ + curl -fsSL "https://github.com/L1ghtmann/llvm-project/releases/latest/download/iOSToolchain-${ARCH}.tar.xz" \ + | tar xJ -C ${THEOS}/toolchain + +# ── Swift Toolchain (kabiroberai, iOS クロスコンパイル用) ── +RUN ARCH=$(uname -m) && \ + curl -fsSL "https://github.com/kabiroberai/swift-toolchain-linux/releases/download/v2.3.0/swift-5.8-ubuntu22.04-${ARCH}.tar.xz" \ + | tar xJ -C ${THEOS}/toolchain + +# ── ホスト Swift (SPM ビルド用) ── +USER root +RUN ARCH=$(uname -m) && \ + curl -fsSL "https://download.swift.org/swift-5.8.1-release/ubuntu2204-${ARCH}/swift-5.8.1-RELEASE/swift-5.8.1-RELEASE-ubuntu22.04-${ARCH}.tar.gz" \ + | tar xz --strip-components=2 -C /usr +USER vscode + +# ── iOS SDKs (15.6 + 16.5) ── +RUN curl -fsSL "https://github.com/theos/sdks/archive/master.tar.gz" \ + | tar xz -C /tmp && \ + mv /tmp/sdks-master/iPhoneOS15.6.sdk ${THEOS}/sdks/ && \ + mv /tmp/sdks-master/iPhoneOS16.5.sdk ${THEOS}/sdks/ && \ + rm -rf /tmp/sdks-master diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml new file mode 100644 index 0000000..661a9a7 --- /dev/null +++ b/.devcontainer/compose.yaml @@ -0,0 +1,15 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - ../:/home/vscode/app:cached + - venv:/home/vscode/app/.venv + network_mode: host + tty: true + stdin_open: true + +volumes: + venv: + driver: local diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..ea2230f --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,49 @@ +{ + "features": { + "ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": { + "version": "1.0.17", + "resolved": "ghcr.io/devcontainers-extra/features/ffmpeg-apt-get@sha256:735491c8e9b2236cae727d6d4904663f6a58eb131d8c4b6b18fd0eea3d8c5b71", + "integrity": "sha256:735491c8e9b2236cae727d6d4904663f6a58eb131d8c4b6b18fd0eea3d8c5b71" + }, + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.5.7", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:dbf431d6b42d55cde50fa1df75c7f7c3999a90cde6d73f7a7071174b3c3d0cc4", + "integrity": "sha256:dbf431d6b42d55cde50fa1df75c7f7c3999a90cde6d73f7a7071174b3c3d0cc4" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671" + }, + "ghcr.io/devcontainers/features/java:1": { + "version": "1.8.0", + "resolved": "ghcr.io/devcontainers/features/java@sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a", + "integrity": "sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "1.8.0", + "resolved": "ghcr.io/devcontainers/features/python@sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511", + "integrity": "sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511" + }, + "ghcr.io/jsburckhardt/devcontainer-features/uv:1": { + "version": "1.0.0", + "resolved": "ghcr.io/jsburckhardt/devcontainer-features/uv@sha256:542a0bc2203205b3c696de650ba862f280b20af3543493cc232edd9ac35791f7", + "integrity": "sha256:542a0bc2203205b3c696de650ba862f280b20af3543493cc232edd9ac35791f7" + }, + "ghcr.io/shyim/devcontainers-features/bun:0": { + "version": "0.0.1", + "resolved": "ghcr.io/shyim/devcontainers-features/bun@sha256:689eae681aa08981175829a59953ba67a7d311f6a05c15d1bbbcb2da2839827e", + "integrity": "sha256:689eae681aa08981175829a59953ba67a7d311f6a05c15d1bbbcb2da2839827e" + }, + "ghcr.io/stu-bell/devcontainer-features/claude-code:0": { + "version": "0.1.0", + "resolved": "ghcr.io/stu-bell/devcontainer-features/claude-code@sha256:f87b4da3f8648db9111cb25c6bd3817d096018d81f3fb596340f7ffdba14409a", + "integrity": "sha256:f87b4da3f8648db9111cb25c6bd3817d096018d81f3fb596340f7ffdba14409a" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..32b8244 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,74 @@ +{ + "name": "Kiou Engine Bridge", + "dockerComposeFile": [ + "compose.yaml" + ], + "service": "app", + "workspaceFolder": "/home/vscode/app", + "shutdownAction": "stopCompose", + "remoteUser": "vscode", + "remoteEnv": { + "WAKATIME_API_KEY": "${localEnv:WAKATIME_API_KEY}", + "GH_TOKEN": "${localEnv:GH_TOKEN}", + "UV_LINK_MODE": "copy", + "ANTHROPIC_BASE_URL": "${localEnv:ANTHROPIC_BASE_URL}", + "ANTHROPIC_AUTH_TOKEN": "${localEnv:ANTHROPIC_AUTH_TOKEN}" + }, + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude" + }, + "mounts": [ + "source=${env:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached,readonly", + "source=${env:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached", + "source=${env:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached,readonly", + "source=${env:HOME}/.config/gh,target=/home/vscode/.config/gh,type=bind,consistency=cached", + "source=${env:HOME}/.codex,target=/home/vscode/.codex,type=bind,consistency=cached", + "source=${env:HOME}/.gnupg,target=/home/vscode/.gnupg-host,type=bind,consistency=cached,readonly" + ], + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": true + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}, + "ghcr.io/stu-bell/devcontainer-features/claude-code:0": {}, + "ghcr.io/devcontainers/features/node:1": { + "version": "25.9.0" + }, + "ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": {}, + "ghcr.io/shyim/devcontainers-features/bun:0": {}, + "ghcr.io/devcontainers/features/java:1": { + "version": "21", + "installMaven": false, + "installGradle": false + } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + }, + "postAttachCommand": "/bin/sh .devcontainer/postAttachCommand.sh", + "postCreateCommand": "/bin/sh .devcontainer/postCreateCommand.sh", + "customizations": { + "vscode": { + "extensions": [ + "Anthropic.claude-code", + "EditorConfig.EditorConfig", + "PKief.material-icon-theme", + "antfu.file-nesting", + "bierner.markdown-preview-github-styles", + "charliermarsh.ruff", + "jebbs.markdown-extended", + "ms-python.black-formatter", + "ms-python.python", + "pHofer94.vscode-taskviewexplorer", + "redhat.vscode-yaml", + "swiftlang.swift-vscode", + "tamasfe.even-better-toml" + ], + "settings": {} + } + } +} \ No newline at end of file diff --git a/.devcontainer/postAttachCommand.sh b/.devcontainer/postAttachCommand.sh new file mode 100644 index 0000000..0ce85a8 --- /dev/null +++ b/.devcontainer/postAttachCommand.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +git config --global --unset commit.template +git config --global --add safe.directory /home/vscode/app +git config --global fetch.prune true +git config --global --add --bool push.autoSetupRemote true +git config --global commit.gpgSign false +git config --global user.signingkey $(gpg --list-secret-keys --with-colons | grep -B 3 "uid.*$(git config user.name)" | cut -d: -f5 | sed ':a;N;$!ba;s/\n//g') +git branch --merged|egrep -v '\*|develop|main|master'|xargs git branch -d \ No newline at end of file diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh new file mode 100644 index 0000000..200dbfc --- /dev/null +++ b/.devcontainer/postCreateCommand.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +sudo chown -R "$(whoami)":"$(whoami)" /home/"$(whoami)"/app/.venv +uv sync diff --git a/.devcontainer/theos/Dockerfile b/.devcontainer/theos/Dockerfile new file mode 100644 index 0000000..456d7a9 --- /dev/null +++ b/.devcontainer/theos/Dockerfile @@ -0,0 +1,50 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 + +# ── Theos + Swift 依存パッケージ ── +RUN \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + --mount=type=cache,target=/var/cache/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + make \ + perl \ + fakeroot \ + zip \ + unzip \ + xz-utils \ + lzma \ + libtinfo6 \ + libxml2 \ + libncurses6 \ + libz3-dev + +# ── Theos 本体 ── +USER vscode +ENV THEOS=/home/vscode/theos +RUN git clone --recursive https://github.com/theos/theos.git ${THEOS} + +# ── Toolchain (L1ghtmann, aarch64 対応) ── +RUN ARCH=$(uname -m) && \ + curl -fsSL "https://github.com/L1ghtmann/llvm-project/releases/latest/download/iOSToolchain-${ARCH}.tar.xz" \ + | tar xJ -C ${THEOS}/toolchain + +# ── Swift Toolchain (kabiroberai, iOS クロスコンパイル用) ── +RUN ARCH=$(uname -m) && \ + curl -fsSL "https://github.com/kabiroberai/swift-toolchain-linux/releases/download/v2.3.0/swift-5.8-ubuntu22.04-${ARCH}.tar.xz" \ + | tar xJ -C ${THEOS}/toolchain + +# ── ホスト Swift (SPM ビルド用) ── +USER root +RUN ARCH=$(uname -m) && \ + curl -fsSL "https://download.swift.org/swift-5.8.1-release/ubuntu2204-${ARCH}/swift-5.8.1-RELEASE/swift-5.8.1-RELEASE-ubuntu22.04-${ARCH}.tar.gz" \ + | tar xz --strip-components=2 -C /usr +USER vscode + +# ── iOS SDKs (15.6 + 16.5) ── +RUN curl -fsSL "https://github.com/theos/sdks/archive/master.tar.gz" \ + | tar xz -C /tmp && \ + mv /tmp/sdks-master/iPhoneOS15.6.sdk ${THEOS}/sdks/ && \ + mv /tmp/sdks-master/iPhoneOS16.5.sdk ${THEOS}/sdks/ && \ + rm -rf /tmp/sdks-master + +# ── ビルド用作業ディレクトリ ── +WORKDIR /home/vscode/app diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..43ef25f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto eol=lf + +# Binary assets — never normalize. +*.webp binary +*.png binary +*.jpg binary +*.a binary +*.dylib binary +*.deb binary diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0a65277 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +# Dependabot keeps the GitHub Actions `uses:` references and our git +# submodules up-to-date. Two ecosystems (github-actions, gitsubmodule) +# each run weekly, so the cap is one PR per ecosystem per week — +# action bumps are grouped into a single PR. +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + actions: + patterns: + - "*" + commit-message: + prefix: ci + include: scope + labels: + - dependencies + - github-actions + + - package-ecosystem: gitsubmodule + directory: "/" + schedule: + interval: weekly + commit-message: + prefix: chore + include: scope + labels: + - dependencies + - submodules diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index fd09f91..d932c74 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -1,9 +1,5 @@ name: deployment -# CD: when a v*.*.* tag is pushed, build both flavors and publish them -# as GitHub Release assets. The release name and tag are identical (no -# auto-prerelease, no draft); pre-releases ship via tags like v0.2.0-rc.1. - on: push: tags: @@ -11,109 +7,146 @@ on: workflow_dispatch: inputs: tag: - description: "Existing tag to (re)build a release for (e.g. v0.1.0)" - required: true - type: string + description: "Version tag to build (e.g. v0.2.0). Empty = use ref name." + required: false + default: "" permissions: - contents: write # required for softprops/action-gh-release + contents: write env: THEOS: ${{ github.workspace }}/theos IOS_SDK_VERSION: "16.5" + LDID_VERSION: "v2.1.5-procursus7" + LDID_SHA256: "4b8862b2fefa2cd7fa8f88cb0310779619aeaa8c72d6aff22f019b470f2fa99a" + +concurrency: + group: deployment-${{ github.ref }} + cancel-in-progress: false jobs: release: - name: build + release runs-on: ubuntu-latest + steps: - name: Resolve tag id: tag run: | if [ -n "${{ inputs.tag }}" ]; then - echo "value=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + echo "value=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" else - echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" fi - name: Checkout source at tag - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ steps.tag.outputs.value }} + submodules: recursive fetch-depth: 0 + - name: Verify control version matches tag + run: | + TAG="${{ steps.tag.outputs.value }}" + VER="${TAG#v}" + CTRL_VER=$(grep -E '^Version:' control | awk '{print $2}') + echo "Tag version : ${VER}" + echo "Control version: ${CTRL_VER}" + if [ "${VER}" != "${CTRL_VER}" ]; then + echo "::error::control Version (${CTRL_VER}) does not match tag (${VER})" + exit 1 + fi + - name: Install host build tools run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential curl git make perl ldid xz-utils \ - libplist-utils libc++-dev + build-essential curl git make perl xz-utils \ + libplist-utils libc++-dev \ + libtinfo6 libncurses6 + + - name: Install ldid + run: | + curl -L -o /tmp/ldid \ + "https://github.com/ProcursusTeam/ldid/releases/download/${LDID_VERSION}/ldid_linux_x86_64" + echo "${LDID_SHA256} /tmp/ldid" | sha256sum -c - + sudo install -m 0755 /tmp/ldid /usr/local/bin/ldid + ldid -V || ldid 2>&1 | head -1 || true - name: Cache Theos id: theos-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: - path: | - theos - key: theos-linux-${{ env.IOS_SDK_VERSION }}-v1 + path: theos + key: theos-linux-${{ env.IOS_SDK_VERSION }}-v3 - name: Install Theos if: steps.theos-cache.outputs.cache-hit != 'true' run: | git clone --recursive https://github.com/theos/theos.git "$THEOS" - mkdir -p "$THEOS/toolchain" - curl -L -o /tmp/toolchain.tar.xz \ - "https://github.com/sbingner/llvm-project/releases/download/v10.0.0-1/linux-ios-arm64e-clang-toolchain.tar.lzma" - tar -xf /tmp/toolchain.tar.xz -C "$THEOS/toolchain" - mv "$THEOS/toolchain/linux" "$THEOS/toolchain/linux2" || true + ARCH=$(uname -m) + curl -fsSL \ + "https://github.com/L1ghtmann/llvm-project/releases/latest/download/iOSToolchain-${ARCH}.tar.xz" \ + | tar xJ -C "$THEOS/toolchain" mkdir -p "$THEOS/sdks" - curl -L -o /tmp/sdk.tar.xz \ + curl -L -o /tmp/sdk.tar.gz \ "https://github.com/theos/sdks/archive/master.tar.gz" - tar -xf /tmp/sdk.tar.xz -C /tmp + tar -xf /tmp/sdk.tar.gz -C /tmp cp -R "/tmp/sdks-master/iPhoneOS${IOS_SDK_VERSION}.sdk" \ "$THEOS/sdks/" + - name: Verify Theos + run: | + test -d "$THEOS/sdks/iPhoneOS${IOS_SDK_VERSION}.sdk" + test -x "$THEOS/toolchain/linux/iphone/bin/clang" + "$THEOS/toolchain/linux/iphone/bin/clang" --version + - name: Build rootless .deb (MobileSubstrate) run: | make clean - make package + make package FINALPACKAGE=1 ls -la packages/ - name: Build jailed .dylib (Dobby static) run: | - make jailed + make jailed FINALPACKAGE=1 ls -la packages/jailed/ - - name: Sanity check jailed dylib (no external hook engine) + - name: Build binpatch .dylib (iOS 18, __DATA slot table) run: | - DYLIB=packages/jailed/KiouEngineBridge.dylib - test -f "$DYLIB" - if command -v otool >/dev/null 2>&1; then - otool -L "$DYLIB" - if otool -L "$DYLIB" | grep -E "libsubstrate|libdobby"; then - echo "::error::Jailed dylib leaks an external hook-engine dependency" - exit 1 + make binpatch FINALPACKAGE=1 + ls -la packages/binpatch/ + + - name: Sanity-check dylibs (no external hook engine) + run: | + for DYLIB in packages/jailed/KiouEngineBridge.dylib \ + packages/binpatch/KiouEngineBridge.dylib; do + test -f "$DYLIB" + if command -v otool >/dev/null 2>&1; then + otool -L "$DYLIB" + if otool -L "$DYLIB" | grep -E "libsubstrate|libdobby"; then + echo "::error::$DYLIB leaks an external hook-engine dependency" + exit 1 + fi + else + strings "$DYLIB" | grep -E "libsubstrate|libdobby\.dylib" \ + && { echo "::error::$DYLIB leaks an external hook-engine dependency"; exit 1; } \ + || echo "$DYLIB: no external hook-engine references found" fi - else - strings "$DYLIB" | grep -E "libsubstrate|libdobby\.dylib" \ - && { echo "::error::Jailed dylib leaks an external hook-engine dependency"; exit 1; } \ - || echo "no external hook-engine references found" - fi + done - name: Stage release assets id: assets run: | mkdir -p dist - # Versioned .deb file name copied verbatim out of packages/. cp packages/*.deb dist/ - # Rename the jailed dylib to include the tag so Releases makes - # it obvious which build a user is grabbing. - TAG="${{ steps.tag.outputs.value }}" - VER="${TAG#v}" + DEB=$(basename packages/*.deb .deb) cp packages/jailed/KiouEngineBridge.dylib \ - "dist/KiouEngineBridge-${VER}-jailed.dylib" + "dist/${DEB}-jailed.dylib" + cp packages/binpatch/KiouEngineBridge.dylib \ + "dist/${DEB}-binpatch.dylib" ls -la dist/ - name: Compute SHA256 checksums @@ -122,14 +155,31 @@ jobs: sha256sum * > SHA256SUMS cat SHA256SUMS + - name: Extract release notes from CHANGELOG + id: notes + run: | + TAG="${{ steps.tag.outputs.value }}" + VER="${TAG#v}" + awk -v ver="$VER" ' + $0 ~ "^## \\[" ver "\\]" { in_section = 1; print; next } + in_section && /^## \[/ { exit } + in_section { print } + ' CHANGELOG.md > release_notes.md + if [ ! -s release_notes.md ]; then + echo "(no CHANGELOG section for ${TAG}; see CHANGELOG.md for context)" \ + > release_notes.md + fi + cat release_notes.md + - name: Publish release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.tag.outputs.value }} name: ${{ steps.tag.outputs.value }} - generate_release_notes: true + body_path: release_notes.md files: | dist/*.deb dist/*.dylib dist/SHA256SUMS fail_on_unmatched_files: true + prerelease: ${{ contains(steps.tag.outputs.value, '-') }} diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 3fd1e2a..6555b53 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -1,15 +1,10 @@ name: integration -# CI: build both the rootless .deb (MobileSubstrate path) and the jailed -# .dylib (Dobby-static path) on every push to master and on every PR. -# Fails fast if the dylib accidentally picks up an external hook-engine -# dependency, since that would brick Sideloadly installs. - on: push: - branches: [master] + branches: [master, develop] pull_request: - branches: [master] + branches: [master, develop] workflow_dispatch: concurrency: @@ -19,8 +14,35 @@ concurrency: env: THEOS: ${{ github.workspace }}/theos IOS_SDK_VERSION: "16.5" + LDID_VERSION: "v2.1.5-procursus7" + LDID_SHA256: "4b8862b2fefa2cd7fa8f88cb0310779619aeaa8c72d6aff22f019b470f2fa99a" jobs: + commitlint: + name: CommitLint + if: github.event.action != 'closed' || github.event.pull_request.merged != true + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install commitlint + run: | + bun install conventional-changelog-conventionalcommits + bun install @commitlint/config-conventional@latest + bun install commitlint@latest + - name: Validate current commit (last commit) with commitlint + if: github.event_name == 'push' + run: bunx commitlint --last --verbose + - name: Validate PR commits with commitlint + if: github.event_name == 'pull_request' + run: bunx commitlint --from ${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to ${{ github.event.pull_request.head.sha }} --verbose + build: name: build (${{ matrix.flavor }}) runs-on: ubuntu-latest @@ -36,53 +58,61 @@ jobs: make_args: "jailed" artifact_name: kiouenginebridge-jailed-dylib artifact_path: packages/jailed/KiouEngineBridge.dylib + - flavor: binpatch + make_args: "binpatch" + artifact_name: kiouenginebridge-binpatch-dylib + artifact_path: packages/binpatch/KiouEngineBridge.dylib steps: - name: Checkout source - uses: actions/checkout@v4 + uses: actions/checkout@v5 + with: + submodules: recursive - name: Install host build tools run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential curl git make perl ldid xz-utils \ - libplist-utils libc++-dev + build-essential curl git make perl xz-utils \ + libplist-utils libc++-dev \ + libtinfo6 libncurses6 + + - name: Install ldid + run: | + curl -L -o /tmp/ldid \ + "https://github.com/ProcursusTeam/ldid/releases/download/${LDID_VERSION}/ldid_linux_x86_64" + echo "${LDID_SHA256} /tmp/ldid" | sha256sum -c - + sudo install -m 0755 /tmp/ldid /usr/local/bin/ldid + ldid -V || ldid 2>&1 | head -1 || true - name: Cache Theos id: theos-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: - path: | - theos - key: theos-linux-${{ env.IOS_SDK_VERSION }}-v1 + path: theos + key: theos-linux-${{ env.IOS_SDK_VERSION }}-v3 - name: Install Theos if: steps.theos-cache.outputs.cache-hit != 'true' run: | - # Theos bootstrap. Pin to upstream main; the official installer - # already handles toolchain + SDK download. git clone --recursive https://github.com/theos/theos.git "$THEOS" - # Linux toolchain (Sam Bingner's iOS arm64 cross toolchain). - mkdir -p "$THEOS/toolchain" - curl -L -o /tmp/toolchain.tar.xz \ - "https://github.com/sbingner/llvm-project/releases/download/v10.0.0-1/linux-ios-arm64e-clang-toolchain.tar.lzma" - tar -xf /tmp/toolchain.tar.xz -C "$THEOS/toolchain" - mv "$THEOS/toolchain/linux" "$THEOS/toolchain/linux2" || true + ARCH=$(uname -m) + curl -fsSL \ + "https://github.com/L1ghtmann/llvm-project/releases/latest/download/iOSToolchain-${ARCH}.tar.xz" \ + | tar xJ -C "$THEOS/toolchain" - # iOS SDK matching IOS_SDK_VERSION. mkdir -p "$THEOS/sdks" - curl -L -o /tmp/sdk.tar.xz \ + curl -L -o /tmp/sdk.tar.gz \ "https://github.com/theos/sdks/archive/master.tar.gz" - tar -xf /tmp/sdk.tar.xz -C /tmp + tar -xf /tmp/sdk.tar.gz -C /tmp cp -R "/tmp/sdks-master/iPhoneOS${IOS_SDK_VERSION}.sdk" \ "$THEOS/sdks/" - name: Verify Theos run: | - ls "$THEOS" - ls "$THEOS/sdks" test -d "$THEOS/sdks/iPhoneOS${IOS_SDK_VERSION}.sdk" + test -x "$THEOS/toolchain/linux/iphone/bin/clang" - name: Build (${{ matrix.flavor }}) run: | @@ -95,26 +125,25 @@ jobs: make package ls -la packages/ - - name: Sanity check jailed dylib (no external hook engine) - if: matrix.flavor == 'jailed' + - name: Sanity check dylib (no external hook engine) + if: matrix.flavor == 'jailed' || matrix.flavor == 'binpatch' run: | - DYLIB=packages/jailed/KiouEngineBridge.dylib + DYLIB=packages/${{ matrix.flavor }}/KiouEngineBridge.dylib test -f "$DYLIB" - # otool may be unavailable on Linux; fall back to nm-style scan. if command -v otool >/dev/null 2>&1; then otool -L "$DYLIB" if otool -L "$DYLIB" | grep -E "libsubstrate|libdobby"; then - echo "::error::Jailed dylib leaks an external hook-engine dependency" + echo "::error::$DYLIB leaks an external hook-engine dependency" exit 1 fi else strings "$DYLIB" | grep -E "libsubstrate|libdobby\.dylib" \ - && { echo "::error::Jailed dylib leaks an external hook-engine dependency"; exit 1; } \ - || echo "no external hook-engine references found" + && { echo "::error::$DYLIB leaks an external hook-engine dependency"; exit 1; } \ + || echo "$DYLIB: no external hook-engine references found" fi - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_path }} diff --git a/.gitignore b/.gitignore index 740fc84..8273606 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,509 @@ +# Created by https://www.toptal.com/developers/gitignore/api/objective-c,python,node,linux,macos,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=objective-c,python,node,linux,macos,windows + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### +# Logs (the directory itself is tracked via logs/.gitkeep further down). +logs/* +!logs/.gitkeep +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### Objective-C ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +### Objective-C Patch ### + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# The generic `lib/` rule above is meant for Python build outputs; the +# vendored Dobby static library lives under vendor/dobby/lib/ and must +# ship with the repo so jailed / binpatch builds (CI and local) can +# link it without an extra fetch step. +!vendor/dobby/lib/ +!vendor/dobby/lib/libdobby.a + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/objective-c,python,node,linux,macos,windows + +### Project-specific (KiouKifExporter / Theos) ### + # Theos build output .theos/ obj/ packages/ -# macOS metadata -.DS_Store -._* - -# Editor +# Editors not covered above .vscode/ .idea/ *.swp -# Local SSH / device IP overrides (THEOS_DEVICE_IP etc) -.env -.env.local +# Claude Code worktrees — `EnterWorktree` (and manual `git worktree add` +# checkouts) drop their working copies under .claude/worktrees/. They are +# real, separate working trees of this repo, not source. +.claude/worktrees/ + +# On-device test artifacts dropped by the operator (crash reports from +# iPhone "Analytics Data", run logs out of the sandbox, captured .kif +# samples). These are useful for debugging but are not source. Distinct +# from the Node `logs` glob above so the intent stays explicit. Keep the +# directory itself tracked via logs/.gitkeep so a fresh clone has the +# expected drop target ready, but everything else inside stays ignored. +logs/* +!logs/.gitkeep + +# Build / analysis input assets. The clean decrypted .ipa goes here +# (Makefile's KIOU_CLEAN_IPA default), and the il2cpp dump.cs + +# its index file land here too. None of these are source — they're +# heavy binary / machine-generated artifacts each developer must drop +# in by hand. +assets/dump.cs +assets/dump.cs.index.json +# *.ipa already ignored globally above. +assets/YaneuraOu +assets/nn.bin + +# Engine bridge / test scripts — not distributed with the repo. +scripts/*.py +scripts/*.sh + +# Internal development docs — not distributed with the repo. +docs/plans/ +docs/archive/ + +# Claude Code session files — local only. +.claude/plans/ +.claude/settings.local.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..10bb5cb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "shared"] + path = shared + url = https://github.com/IPA-Patch/Shared.git +[submodule "Sources/Chinlan"] + path = Sources/Chinlan + url = https://github.com/IPA-Patch/Chinlan.git diff --git a/Makefile b/Makefile index fc68fce..7b10d49 100644 --- a/Makefile +++ b/Makefile @@ -1,57 +1,122 @@ -TARGET := iphone:clang:16.5:15.0 -INSTALL_TARGET_PROCESSES = KIOU -ARCHS = arm64 -THEOS_PACKAGE_SCHEME = rootless -THEOS_DEVICE_IP = 192.168.0.49 +# =========================================================================== +# KiouEngineBridge — IPA-Patch tweak Makefile. +# +# Targets: +# make — JB rootless .deb (MSHookFunction via libsubstrate) +# make package — same, packaged +# make jailed — Dobby-static .dylib for Sideloadly injection (iOS 15+) +# make binpatch — Dobby-static .dylib for the statically-patched IPA path +# (iOS 18 sideload; the only mode that survives CSM). +# make ipa — patched IPA assembled from $(DECRYPTED_IPA) +# =========================================================================== -include $(THEOS)/makefiles/common.mk +# --------------------------------------------------------------------------- +# PROJECT VARIABLES +# --------------------------------------------------------------------------- +TWEAK_NAME := KiouEngineBridge +TWEAK_SOURCES_DIR := Sources/$(TWEAK_NAME) + +TARGET_PROCESS := KIOU +TARGET_BUNDLE_ID := com.neconome.shogi + +DECRYPTED_IPA ?= $(CURDIR)/assets/Kiou-1.0.1.ipa +IPA_RECIPE := recipes.kiouenginebridge +IPA_FRAMEWORK := UnityFramework + +BUILD_COMMIT_DEFINE := KIOU_ENGINE_BRIDGE_COMMIT + +# --------------------------------------------------------------------------- +# Theos boilerplate. +# --------------------------------------------------------------------------- +TARGET := iphone:clang:16.5:15.0 +INSTALL_TARGET_PROCESSES := $(TARGET_PROCESS) +ARCHS := arm64 +THEOS_PACKAGE_SCHEME := rootless +THEOS_DEVICE_IP := 192.168.0.49 -TWEAK_NAME = KiouEngineBridge +include $(THEOS)/makefiles/common.mk -KiouEngineBridge_FILES = $(shell find Sources/KiouEngineBridge -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp') -# Shared logging implementation lives in ./_shared/. il2cpp / hook-engine -# headers are inline-only so they don't need to be listed here. -KiouEngineBridge_FILES += _shared/kiou_logging.m +$(TWEAK_NAME)_FILES := $(shell find $(TWEAK_SOURCES_DIR) \ + \( -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp' \)) +$(TWEAK_NAME)_FILES += Sources/Chinlan/logging.m -# Build-time git short HEAD (7 chars). No -dirty suffix for now. -KIOU_ENGINE_BRIDGE_COMMIT ?= $(shell git rev-parse --short=7 HEAD 2>/dev/null || echo unknown) +BUILD_COMMIT ?= $(shell git rev-parse --short=7 HEAD 2>/dev/null || echo unknown) -KiouEngineBridge_CFLAGS = -fobjc-arc -Wno-unused-function -DKIOU_ENGINE_BRIDGE_COMMIT=\"$(KIOU_ENGINE_BRIDGE_COMMIT)\" -I_shared -KiouEngineBridge_FRAMEWORKS = Foundation +$(TWEAK_NAME)_CFLAGS := -fobjc-arc -Wno-unused-function \ + -D$(BUILD_COMMIT_DEFINE)=\"$(BUILD_COMMIT)\" \ + -ISources/Chinlan +$(TWEAK_NAME)_FRAMEWORKS := Foundation # --------------------------------------------------------------------------- -# Hook engine selection — mirrors KiouKifExporter/Makefile. +# Hook engine selection. # -# default (JB / rootless): MobileSubstrate (MSHookFunction in libsubstrate) -# JAILED=1 : Dobby, statically linked from the vendor tree -# in vendor/dobby/. +# default (JB / rootless): MobileSubstrate (MSHookFunction via libsubstrate) +# JAILED=1 : Dobby statically linked from vendor/dobby. No +# libsubstrate dependency — safe for Sideloadly / +# TrollStore injection on iOS 15–26. +# BINPATCH=1 : static binary patch + __DATA,__bss SLOT +# dispatcher. No runtime __TEXT writes, survives +# iOS 18 CSM. Implies JAILED=1. # -# vendor/dobby/{include,lib}/ is vendored verbatim so this repo builds -# standalone (without depending on a sibling KiouEditor checkout). -# _shared/kiou_hookengine.h picks the API at compile time. +# Sources/Chinlan/hookengine.h picks the API at compile time via IPA_JAILED. # --------------------------------------------------------------------------- +ifeq ($(BINPATCH),1) + JAILED := 1 + $(TWEAK_NAME)_CFLAGS += -DKIOU_BINPATCH=1 -DIPA_LOG_TO_DOCUMENTS=1 +endif + ifeq ($(JAILED),1) - KiouEngineBridge_CFLAGS += -DKIOU_JAILED=1 -Ivendor/dobby/include - KiouEngineBridge_LDFLAGS = -Lvendor/dobby/lib -ldobby -lc++ -lc++abi + $(TWEAK_NAME)_CFLAGS += -DIPA_JAILED=1 -Ivendor/dobby/include + $(TWEAK_NAME)_LDFLAGS := -Lvendor/dobby/lib -ldobby -lc++ -lc++abi +ifeq ($(BINPATCH),1) + $(TWEAK_NAME)_LDFLAGS += -Wl,-undefined,error +endif else - KiouEngineBridge_LDFLAGS = -lsubstrate + $(TWEAK_NAME)_LDFLAGS := -lsubstrate endif include $(THEOS_MAKE_PATH)/tweak.mk after-install:: - install.exec "chmod 755 /var/jb/Library/MobileSubstrate/DynamicLibraries/KiouEngineBridge.dylib" - install.exec "sleep 1; (open com.neconome.shogi 2>/dev/null || uiopen com.neconome.shogi:// 2>/dev/null || echo 'no launcher tool (uiopen/open); start KIOU manually')" + install.exec "chmod 755 /var/jb/Library/MobileSubstrate/DynamicLibraries/$(TWEAK_NAME).dylib" + install.exec "sleep 1; (open $(TARGET_BUNDLE_ID) 2>/dev/null || uiopen $(TARGET_BUNDLE_ID):// 2>/dev/null || echo 'no launcher tool; start $(TARGET_PROCESS) manually')" -# jailed distribution: rebuild with Dobby statically linked, copy into -# packages/jailed/ for Sideloadly injection. jailed:: $(MAKE) JAILED=1 clean $(MAKE) JAILED=1 all $(ECHO_NOTHING)mkdir -p packages/jailed$(ECHO_END) - $(ECHO_NOTHING)cp $(THEOS_OBJ_DIR)/KiouEngineBridge.dylib packages/jailed/KiouEngineBridge.dylib$(ECHO_END) - @echo "jailed dylib -> packages/jailed/KiouEngineBridge.dylib" - @echo "--- otool -L (must NOT list libsubstrate or libdobby) ---" - @$(THEOS)/toolchain/linux/iphone/bin/otool -L packages/jailed/KiouEngineBridge.dylib 2>/dev/null \ - || otool -L packages/jailed/KiouEngineBridge.dylib 2>/dev/null \ - || echo "(otool unavailable on host; inspect the dylib on a Mac/iOS device)" + $(ECHO_NOTHING)cp $(THEOS_OBJ_DIR)/$(TWEAK_NAME).dylib packages/jailed/$(TWEAK_NAME).dylib$(ECHO_END) + @echo "jailed dylib -> packages/jailed/$(TWEAK_NAME).dylib" + @$(THEOS)/toolchain/linux/iphone/bin/otool -L packages/jailed/$(TWEAK_NAME).dylib 2>/dev/null \ + || otool -L packages/jailed/$(TWEAK_NAME).dylib 2>/dev/null \ + || echo "(otool unavailable)" + +binpatch:: + $(MAKE) BINPATCH=1 clean + $(MAKE) BINPATCH=1 all + $(ECHO_NOTHING)mkdir -p packages/binpatch$(ECHO_END) + $(ECHO_NOTHING)cp $(THEOS_OBJ_DIR)/$(TWEAK_NAME).dylib packages/binpatch/$(TWEAK_NAME).dylib$(ECHO_END) + @echo "binpatch dylib -> packages/binpatch/$(TWEAK_NAME).dylib" + @$(THEOS)/toolchain/linux/iphone/bin/otool -L packages/binpatch/$(TWEAK_NAME).dylib 2>/dev/null \ + || otool -L packages/binpatch/$(TWEAK_NAME).dylib 2>/dev/null \ + || echo "(otool unavailable)" + +IPA_DYLIB := $(CURDIR)/packages/binpatch/$(TWEAK_NAME).dylib + +ipa:: binpatch + @echo "==> assembling patched IPA from $(DECRYPTED_IPA)" + @if [ ! -f "$(DECRYPTED_IPA)" ]; then \ + echo "error: decrypted IPA missing at $(DECRYPTED_IPA)"; \ + echo " override with: make ipa DECRYPTED_IPA=/path/to/clean.ipa"; \ + exit 1; \ + fi + @./shared/tools/build_patched_ipa.sh \ + --recipe "$(IPA_RECIPE)" \ + --framework "$(IPA_FRAMEWORK)" \ + --dylib "$(IPA_DYLIB)" \ + --input "$(DECRYPTED_IPA)" + +.PHONY: hooks +hooks:: + git config core.hooksPath scripts + @echo "git hooks now resolve under scripts/" diff --git a/README.md b/README.md index 08e9e52..923e37c 100644 --- a/README.md +++ b/README.md @@ -5,248 +5,208 @@

- Bridge KIOU to a desktop USI shogi engine (YaneuraOu - and friends) — observe the live board, ship the SFEN to the engine, - replay the engine's bestmove back into the running match.
- Runs entirely client-side; the engine sits on a LAN box you already trust.
+ Turn KIOU into a CSA match server. The tweak speaks + the standard CSA server protocol on TCP :4081, so any CSA + client can connect over LAN and play against KIOU's live board — no extra + proxy, no host-side wrapper.

- platform + version + targets KIOU + platform arch - target engine - protocol + protocol side license - status

--- Kiou Engine Bridge is the in-app half of a two-piece system: the tweak -runs inside KIOU and exposes a tiny WebSocket sink on `0.0.0.0:9527`; -a host-side bridge (your machine running YaneuraOu or any other USI -engine) connects in, speaks the standard USI protocol, and receives -`position sfen ...` / `go ...` lines built from the live KIOU board. -When the engine replies with `bestmove `, the tweak feeds that -move back into KIOU's own `TryMakeMove` / `OnPlayerMoveAsync` paths so -the on-device match advances exactly as if you had played it yourself. - -No proxy server, no cloud, no third-party service — one ~120 KB dylib -on the phone, one TCP socket to a LAN box, and the engine of your choice -on the other end. - -### Observation + scoped injection - -Kiou Engine Bridge is **read-mostly with a single narrow write path**. -The observation hooks (`Hook_LowLevelObserve`, `Hook_MatchModeObserve`, -`Hook_OnlineObserve`, `Hook_GameOrchestratorObserve`, -`Hook_GameStateStoreObserve`) only read — they latch live -`GameController` / `ShogiGameAdapter` / `OnlinePvPMode` pointers, -convert `Sunfish.Move` to USI, walk `PositionHistory` to extract SFEN. -No game-state field is mutated through these. - -The injection layer (`Inject_Move`) calls into KIOU's own move-commit -methods as function pointers: - -- `Sunfish.Move.Create` / `Move.CreateDrop` to assemble the - packed-uint32 move, -- `ShogiGameAdapter.TryMakeMove(out Move)` / - `GameController.TryMakeMove(Move)` to advance the headless engine, -- `IMatchMode.OnPlayerMoveAsync(Move, CancellationToken)` so the UI - redraws and the server (Online) sees the move. - -What the injection layer is **not** allowed to do: - -- Touch il2cpp object fields directly. The shared header - `kiou_il2cpp.h` is intentionally read-only; the `writeU8` / `writeI32` - helpers that `KiouEditor` carries in its own `Internal.h` are - deliberately **not** included here. Any future "tweak a board field" - regression must opt in explicitly — they don't sneak in via the - shared header. -- Replay anything that didn't come from the engine. Only frames the - attached WebSocket client sends as `bestmove ` (after a - matching `go`) make it into the move pipeline. - -Uninstalling the dylib returns KIOU to a fully vanilla state. - -## What you get - -Four actors, three wires. KIOU is the game itself; Bridge is this -tweak loaded into KIOU's process; Wrapper is the host-side process -that translates between WebSocket and a vanilla USI engine's stdio; -YaneuraOu (or any other USI engine) is the thinking part. - -- **KIOU <-> Bridge** — in-process: il2cpp hook callbacks for the - read side, function-pointer calls into `Move.Create` / - `OnPlayerMoveAsync` / `TryMakeMove` for the inject side. -- **Bridge <-> Wrapper** — WebSocket text frames on - `ws://:9527`, USI lines + sidecar `meta{...}` lines. -- **Wrapper <-> YaneuraOu** — stdio pipes (the way YaneuraOu - already expects to be driven). - -```mermaid -sequenceDiagram - autonumber - participant K as KIOU - participant B as Bridge (this tweak) - participant W as Wrapper (host) - participant E as YaneuraOu - - Note over K,B: in-process hooks - Note over B,W: ws://device:9527 (USI + meta) - Note over W,E: stdio pipes - - Note over B,W: USI handshake - W->>B: usi - B-->>W: id name ... / id author ... / usiok - W->>B: isready - B-->>W: readyok - W->>B: usinewgame - - Note over K,W: Match start — Bridge emits sidecar meta - K-->>B: IMatchMode.InitializeAsync (latch self / local_player) - B-->>W: meta{"type":"match_start","mode":"OnlinePvPMode","local_player":0,...} - - loop Per move while it's our turn - K-->>B: TryMakeMove observation -> SFEN / side_to_move - B->>W: position sfen - B->>W: go btime ... wtime ... - W->>E: position sfen / go ... - E-->>W: bestmove 7g7f - W->>B: bestmove 7g7f - Note over B,K: inject_apply -> Move.Create -> OnPlayerMoveAsync -> TryMakeMove - B->>K: replay move through KIOU's move pipeline - B-->>W: meta{"type":"move","usi":"7g7f","sfen_after":"...",...} - end - - K-->>B: IMatchMode.OnMatchEndAsync (result + final SFEN + GetUSIText) - B-->>W: meta{"type":"match_end","result":"win","final_sfen":"...","usi_text":"..."} -``` - -Each `meta` frame is a single line prefixed with the literal -`meta` followed by a single JSON object, so the Wrapper can demux -it from real USI traffic without parsing JSON for every line. - -## How it works - -```mermaid -flowchart TD - obs["Hook_LowLevelObserve
Hook_MatchModeObserve
Hook_OnlineObserve"] - state(["g_gameCtrlCache · g_adapterCache
g_*ModeCache · g_localPlayer*"]) - sfen["sfenFromGameController()
moveToUsi()"] +runs inside KIOU and exposes a CSA TCP server on `0.0.0.0:4081`; +a CSA client on your LAN connects in, plays through the standard +`LOGIN` / `Game_Summary` / `AGREE` / `START` handshake, then +participates in the live KIOU match by submitting CSA-format moves +(`+7776FU`) and receiving the same notifications the in-game side does. +When the client plays its move, the tweak parses it and feeds it back +into KIOU's own `TryMakeMove` / `OnPlayerMoveAsync` paths so the +on-device match advances exactly as if you had played it yourself. - obs -- "latch self / extract SFEN+USI" --> state - state --> sfen - sfen --> wsout +No proxy server, no cloud, no third-party service — one ~140 KB dylib +on the phone, one TCP socket to a LAN box. See `docs/csa_protocol.md` +for the full wire contract. - ws[("WebSocket :9527
opcode 0x1 text frames")] - wsout["Usi_Engine state machine
BOOT / HANDSHAKE / READY /
THINKING / INJECTING"] - wsout --> ws - ws -- "bestmove <usi>" --> wsin - wsin["inject_apply(usi)"] +KEB exposes the standard CSA v1.2 surface on the TCP link: - wsin --> commit["Move.Create / CreateDrop
OnPlayerMoveAsync
Adapter.TryMakeMove"] - commit --> kiou(["KIOU board state advances"]) +**KEB → client** - state --> meta["Meta_Emitter (sidecar)"] - meta --> ws -``` - -Two protocol streams share one TCP port: +| Lines | Notes | +|---|---| +| `LOGIN: OK`, `LOGOUT:completed` | session control | +| `BEGIN Game_Summary ... END Game_Summary` | full match preamble, includes `KIOU_*` extension lines | +| `START:` | after `AGREE` | +| `,T` | per-move notification, both colours | +| `#RESIGN` / `#SENNICHITE` / `#JISHOGI` / `#CHUDAN` + `#WIN` / `#LOSE` / `#DRAW` | match end | -| Stream | Direction | Wire | Purpose | -|---|---|---|---| -| **USI** | bidirectional | one USI command per text frame | drive the engine, accept `bestmove` back | -| **meta** | tweak -> host | `meta{...json}\n` per frame | match lifecycle + per-move record for KIF assembly | +**client → KEB** -The USI state machine (`Usi_Engine.m`) walks the standard -`usi` -> `usiok` -> `isready` -> `readyok` -> `usinewgame` -> `position` -> -`go` -> `bestmove` cycle; on `bestmove` it hops onto the Unity main -thread and calls `inject_apply` to commit the move through KIOU's own -move pipeline. +| Lines | Notes | +|---|---| +| `LOGIN ` | accepted unconditionally | +| `LOGOUT` | tears the session down | +| `AGREE []` / `REJECT []` | advance / decline pre-match | +| `` | client's move; injected into KIOU | +| `%TORYO` | drives `GameOrchestrator.RequestSurrender` | +| `%KACHI` / `%CHUDAN` | client learns; KIOU is not signalled | + +The full mapping (every CSA field, what KIOU exposes, what we drop) +lives in `docs/csa_compatibility.md`. The wire-level state machine and +example session are in `docs/csa_protocol.md`. + +## CSA protocol v1.2 compatibility + +KEB targets the [CSA TCP/IP server protocol +v1.2.1](http://www2.computer-shogi.org/protocol/tcp_ip_server_121.html). +The table below summarises coverage at a glance; see +`docs/csa_compatibility.md` for per-field detail. + +| Area | Status | Notes | +|---|---|---| +| Session (`LOGIN` / `LOGOUT` / liveness `\n`) | ✅ | Credentials accepted unconditionally. | +| `BEGIN Game_Summary` negotiation | ✅ | `AGREE` / `REJECT` handled; see deviation note below. | +| `BEGIN Time` block | ⚠️ partial | `Total_Time`, `Byoyomi`, `Increment` written. `Delay`, `Least_Time_Per_Move`, `Time_Roundup` omitted (KIOU does not expose them). | +| Initial position `BEGIN Position` | ✅ | Full 9×9 board + hand pieces in CSA form, derived from KIOU's live SFEN. | +| Per-turn move exchange with `,T` | ✅ | Both colours notified. `T` omitted in modes without authoritative clocks (VsAI / LocalPvP). | +| `%TORYO` (resign) | ✅ | Calls `GameOrchestrator.RequestSurrender`. | +| `%KACHI` (nyugyoku win) | ⚠️ partial | KEB learns and sends `#JISHOGI`; KIOU side is not signalled (no public declaration API yet). | +| `%CHUDAN` (abort) | ⚠️ partial | KEB sends `#CHUDAN`; KIOU is not notified. | +| `#WIN` / `#LOSE` / `#DRAW` result delivery | ✅ | | +| `#RESIGN` / `#SENNICHITE` reason markers | ✅ | | +| `#TIME_UP` / `#ILLEGAL_MOVE` reason markers | ⛔ | Emitted as `#RESIGN` — KIOU does not expose end-reason detail. | +| `To_Move` in handicap games | ⚠️ | Derived from KIOU_Sfen side-to-move; older builds hard-coded `+`. | +| Multi-client fanout | ⛔ | One client at a time; a new connect preempts the prior session. | + +**Key deviation from the spec.** KIOU's CPU starts moving as soon as the +match begins, before the client can reply with `AGREE`. KEB therefore +emits `START:` immediately alongside `Game_Summary` and skips +the `AGREE_WAIT` barrier — clients receive `START:` before their `AGREE` +is acknowledged, which Floodgate-grade clients accept silently. + +### KIOU_* extensions + +KEB inserts vendor-prefixed lines inside `Game_Summary` for data CSA has +no equivalent for. A strict CSA parser must ignore unknown keys. + +| Key | Example value | Description | +|---|---|---| +| `KIOU_Mode` | `VsAI` | Match mode: `VsAI`, `LocalPvP`, `OnlinePvP`, `RecordReplay`, `Spectate`. | +| `KIOU_StartPosition` | `Standard` | Initial position type (e.g. `HandicapLance`, `TsumeShogi`). | +| `KIOU_Sfen` | `lnsgk…` | Full SFEN of the starting position; used to set the board for non-standard starts. | +| `KIOU_Rank+` / `KIOU_Rank-` | `六段` | Player rank (Online matches). | +| `KIOU_Rate+` / `KIOU_Rate-` | `1832` | Player rate; omitted when zero. | +| `KIOU_UserId+` / `KIOU_UserId-` | `550e8400-e29b-41d4-a716-446655440000` | Player user id (UUID format); omitted when blank. | +| `KIOU_StartedAt` | `2026-06-16T09:30:03Z` | Wall-clock ISO 8601 UTC at match start. | ## Install -Pick the row that matches how your device is signed. +### Jailbroken device (rootless) -### Jailbroken (rootless — Dopamine / palera1n) +`make package install` transfers and installs the `.deb` over SSH. +Requires `openssh-server` on the device (install via Sileo/Zebra). ```sh +make package make package install THEOS_DEVICE_IP= ``` The dylib lands at `/var/jb/Library/MobileSubstrate/DynamicLibraries/KiouEngineBridge.dylib` -and is loaded by MobileSubstrate / ElleKit on next launch. Respring or -relaunch KIOU, then point your host bridge at `ws://:9527`. +and is loaded by ElleKit on next launch. Respring or relaunch KIOU, then +point your CSA client at `tcp://:4081`. + +### Jailed dylib (TrollStore) -### Sideloadly / AltStore / Apple Developer Program +TrollStore is only supported on specific iOS versions. Check the +[supported versions table](https://ios.cfw.guide/installing-trollstore/) +before proceeding. ```sh -make jailed +make JAILED=1 # -> packages/jailed/KiouEngineBridge.dylib ``` -`make jailed` rebuilds with Dobby statically linked and copies the -artifact into `packages/jailed/`. The target also runs `otool -L` and -the output must **not** mention `libsubstrate` or `libdobby`. +Stage inside the decrypted KIOU `.app/Frameworks/`, add an `LC_LOAD_DYLIB`, +and install via TrollStore. + +### Patched IPA (Sideload) + +For devices where TrollStore is unavailable. Install the patched IPA with +[Sideloadly](https://sideloadly.io/) or [AltStore](https://altstore.io/). + +Requires a **decrypted** KIOU IPA (e.g. obtained via [palera1n](https://palera.in/) + +Filza, or [TrollDecrypt](https://github.com/donato-fiore/TrollDecrypt)). The +App Store download is FairPlay-encrypted and cannot be patched directly. + +```sh +make BINPATCH=1 +# -> packages/binpatch/KiouEngineBridge.dylib +``` + +Then build the patched IPA: -In Sideloadly: +```sh +shared/tools/build_patched_ipa.sh \ + --recipe kiouenginebridge \ + --framework UnityFramework \ + --dylib packages/binpatch/KiouEngineBridge.dylib \ + --input Kiou-1.0.1.ipa +# -> Kiou-1.0.1-patched.ipa +``` -1. Drop the decrypted KIOU `.ipa` in. -2. Under **Inject dylibs**, add `packages/jailed/KiouEngineBridge.dylib`. -3. Sign with your Apple ID / certificate and install. +Unlike runtime hook engines (Substrate, Dobby, frida-gum), the static +binary patch never writes to `__TEXT` at runtime and survives the iOS 18 +Code Signing Monitor (CSM) — the binpatch flavour covers iOS 15.0 – 18.x. -The same dylib works with AltStore (drop the IPA in, add the dylib -under **Settings -> Advanced** before signing). +All three build flavours ship the full CSA protocol surface — Game_Summary, +per-move notifications, resign / draw handling. See +`docs/plans/kiou_engine_bridge_binpatch.md` § 2 for the full build matrix. ## Compatibility | | | |---|---| | **KIOU app version** | `1.0.1` (`CFBundleVersion` 11) | -| **iOS** | 15.0 – 16.5, arm64, rootless | -| **Engine wire** | standard USI protocol over WebSocket text frames | +| **KIOU minimum iOS** | 10.0 (`MinimumOSVersion` in app bundle) | +| **KiouEngineBridge minimum iOS** | 15.0 | +| **Tested on** | 15.0 – 26, arm64 | +| **Distribution** | Jailbroken `.deb`, TrollStore-injected jailed `.dylib`, Patched IPA (Sideloadly / AltStore) | +| **Engine wire** | CSA server protocol v1.2 over plain TCP (`:4081`) | -All hooks are pinned to RVAs from this exact KIOU build's -`UnityFramework`. After a KIOU update the RVAs will drift and the -tweak will silently no-op (or crash on a method whose signature -changed). **Don't install this dylib against a KIOU version other -than the one above without re-deriving every RVA first.** +All hook sites are RVA-pinned to this exact KIOU build. After a KIOU update +the RVAs will drift. ## Requirements - [Theos](https://theos.dev/) with the standard iOS toolchain installed (`$THEOS` set). Kiou Engine Bridge is pure Objective-C — no Orion, no Swift runtime. -- iOS 15.0–16.5, arm64, rootless layout. +- iOS 15.0–26, arm64. - For the jailed (sideload) path: a decrypted copy of the KIOU `.ipa`. -- A host-side bridge that speaks USI over WebSocket against - `ws://:9527`. YaneuraOu wrapped in a tiny WS adapter is - the reference setup. -## Layout +### Developer hooks -``` -Sources/KiouEngineBridge/ - Internal.h # tweak-private declarations - Tweak.m # constructor + UnityFramework dyld walk - Hook_LowLevelObserve.m # TryMakeMove / SFEN / USI extraction - Hook_MatchModeObserve.m # IMatchMode lifecycle (5 modes, 3 methods) - Hook_OnlineObserve.m # OnlinePvPMode snapshot / result observer - Hook_GameOrchestratorObserve.m# match-end auto-rematch helper - Hook_GameStateStoreObserve.m # Set*PlayerInfo capture for meta_emit - Hook_AfkSuppress.m # pin GameOrchestrator.IsAfkEnabled = false - Inject_Move.m # bestmove -> Move.Create / TryMakeMove path - Usi_Engine.m # USI state machine (Phase 2) - Server_WebSocket.m # 0.0.0.0:9527 listener + recv loop - Meta_Emitter.m # sidecar JSON stream for KIF assembly - -vendor/dobby # symlink to KiouEditor/vendor/dobby/ -../_shared/ # kiou-shared submodule (logging, il2cpp, hookengine) +```sh +make hooks ``` +Registers `scripts/` as the git hooks path so `scripts/pre-commit` fires +before every commit. When a commit touches `recipes/*.py` or +`shared/tools/`, the hook runs `tools.verify_sites` to cross-check every +`_SITES` row against `assets/dump.cs.index.json`. If the dump index is +absent (not committed to the repo) the hook exits 0 and prints a heads-up — +it is a local-only gate, not a CI requirement. + ## Where the logs go The dylib writes its own diagnostic log into the KIOU sandbox: @@ -257,40 +217,17 @@ The dylib writes its own diagnostic log into the KIOU sandbox: — which translates to `/var/mobile/Containers/Data/Application//tmp/kiouenginebridge.log` on a jailbroken device. Tail it over SSH to watch matches and the -USI handshake resolve in real time: +CSA handshake resolve in real time: ```sh ssh root@ 'tail -F /var/mobile/Containers/Data/Application/*/tmp/kiouenginebridge.log' ``` -Each match produces `[MMODE]` lifecycle lines, `[WS]` connection -events, and `[USI]` engine-state transitions interleaved with the +Each match produces `[MMODE]` lifecycle lines, `[CSA]` connection +events, and `[CSA-ENG]` engine-state transitions interleaved with the move-injection results. -## Sibling tweaks - -Kiou Engine Bridge shares its il2cpp helpers and logging plumbing with -two sister projects you can install side-by-side. All three can -coexist in the same KIOU process: - -- [**Kiou Editor**](https://github.com/IPA-Patch/KiouEditor) — the - client-side customization suite (item unlock, premium gating, engine - tuning, voice unlock, etc). -- [**Kiou Kif Exporter**](https://github.com/IPA-Patch/KiouKifExporter) — - saves every match as a standard KIF 2.0 file in the app sandbox, - ready for Files.app / AirDrop / PiyoShogi. - ## License Released under the [MIT License](LICENSE) — see the `LICENSE` file for the full text. - -### Scope of use - -Intended for **authorized penetration testing and personal research**. The -repository ships no proprietary KIOU assets and does not distribute the -IPA — sourcing a decrypted copy of KIOU for the sideload path is the -reader's responsibility. Online ranked play through this bridge can -affect your account's rating; the tweak does not gate that behavior, -so use against ranked matches only on accounts and in jurisdictions -where you have the authority to do so. diff --git a/Sources/Chinlan b/Sources/Chinlan new file mode 160000 index 0000000..9dc610c --- /dev/null +++ b/Sources/Chinlan @@ -0,0 +1 @@ +Subproject commit 9dc610cbbcf6202c246def15d0e337b7d42a5d8d diff --git a/Sources/KiouEngineBridge/BinpatchDispatcher.m b/Sources/KiouEngineBridge/BinpatchDispatcher.m new file mode 100644 index 0000000..039f321 --- /dev/null +++ b/Sources/KiouEngineBridge/BinpatchDispatcher.m @@ -0,0 +1,182 @@ +#if KIOU_BINPATCH + +#import "Internal.h" + +// =========================================================================== +// BinpatchDispatcher — binpatch flavour only. +// +// On the binpatch build every observation site is redirected by a static +// code cave to this single dispatcher. The cave preserves X0..X7, loads +// the function pointer from the reserved __DATA,__bss slot inside +// UnityFramework at `unityBase + KIOU_BR_HOOK_SLOT_RVA`, calls the +// dispatcher with the original arguments plus the per-site hook id in W6, +// then restores X0..X7 and resumes orig via the displaced prologue and +// `B orig + 4`. The dispatcher therefore implements only the pre-orig +// observation work — calling orig is the cave's job. +// +// Two pieces have to agree for the cave to ever reach this function: +// +// 1. The cave's ADRP+LDR resolves the slot at +// `unityBase + KIOU_BR_HOOK_SLOT_RVA` (see +// `recipes/kiouenginebridge.py`'s ``HOOK_SLOT_RVA`` / +// ``_build_bridge_cave_payload``). +// +// 2. ``KEBBridgeBinpatchPublish`` below stores `&dispatch_one` +// into that same address. The slot lives in UnityFramework's +// __DATA,__bss — NOT in this dylib — so the publish path needs the +// live UnityFramework base captured in ``g_unityBase``. A previous +// revision of this file mistakenly published into a dylib-local +// global, leaving the framework slot at NULL; the very first cave +// site fired ``BLR X16 == BLR 0`` and got SIGKILLed with +// CODESIGNING / Invalid Page at match start. +// =========================================================================== + +void * volatile g_inject_entry[KIOU_BR_HOOK__COUNT] = {0}; + +// Dispatcher body. Receives the original X0..X5/X7 arguments verbatim, plus +// the per-site hook id in W6. Each case forwards to the matching hook body +// in the Hook_*.m files, casting the registers to the body's declared +// parameter types. Unused parameter slots are silently dropped per AAPCS64 +// — the hook bodies only read what they need. +static void dispatch_one(void *x0, void *x1, void *x2, void *x3, void *x4, + void *x5, uint32_t hook_id, void *x7) { + (void)x4; + (void)x5; + (void)x7; + switch (hook_id) { + // InitializeAsync(self, cfg, store, adapter, ct) + // self=x0, cfg=x1, store=x2, adapter=x3, ct=x4 — we only need the + // first four; ct is dropped (passed as NULL). + case KIOU_BR_HOOK_AI_INIT: + (void)HookAiInit(x0, x1, x2, x3, x4); break; + case KIOU_BR_HOOK_CPUSTREAM_INIT: + (void)HookCpuStreamInit(x0, x1, x2, x3, x4); break; + case KIOU_BR_HOOK_LOCAL_INIT: + (void)HookLocalInit(x0, x1, x2, x3, x4); break; + case KIOU_BR_HOOK_ONLINE_INIT: + (void)HookOnlineInit(x0, x1, x2, x3, x4); break; + case KIOU_BR_HOOK_REPLAY_INIT: + (void)HookReplayInit(x0, x1, x2, x3, x4); break; + + // OnMatchStart(self) + case KIOU_BR_HOOK_AI_START: HookAiStart(x0); break; + case KIOU_BR_HOOK_CPUSTREAM_START: HookCpuStreamStart(x0); break; + case KIOU_BR_HOOK_LOCAL_START: HookLocalStart(x0); break; + case KIOU_BR_HOOK_ONLINE_START: HookOnlineStart(x0); break; + case KIOU_BR_HOOK_REPLAY_START: HookReplayStart(x0); break; + + // OnPlayerMoveAsync(self, mv, ct) + // self=x0, mv=w1 (packed uint32), ct=x2. + case KIOU_BR_HOOK_AI_OPM: + (void)HookAiOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; + case KIOU_BR_HOOK_CPUSTREAM_OPM: + (void)HookCpuStreamOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; + case KIOU_BR_HOOK_LOCAL_OPM: + (void)HookLocalOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; + case KIOU_BR_HOOK_ONLINE_OPM: + (void)HookOnlineOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; + case KIOU_BR_HOOK_REPLAY_OPM: + (void)HookReplayOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; + + // OnMatchEndAsync(self, ct) + case KIOU_BR_HOOK_AI_END: (void)HookAiEnd(x0, x1); break; + case KIOU_BR_HOOK_CPUSTREAM_END: (void)HookCpuStreamEnd(x0, x1); break; + case KIOU_BR_HOOK_LOCAL_END: (void)HookLocalEnd(x0, x1); break; + case KIOU_BR_HOOK_ONLINE_END: (void)HookOnlineEnd(x0, x1); break; + case KIOU_BR_HOOK_REPLAY_END: (void)HookReplayEnd(x0, x1); break; + + // ShogiGameAdapter.TryMakeMove(Move, out Move) + // self=x0, move=w1, outMove=x2. Return value is ignored — the cave + // resumes the original method which produces the real return. + case KIOU_BR_HOOK_ADAPTER_TRY_MAKE_MOVE_OUT: + (void)HookAdapterTryMakeMoveOut(x0, (uint32_t)(uintptr_t)x1, x2); + break; + + // UpdateAuthoritativeSnapshot(self, sfen, turn, blackTime, whiteTime, + // moveCount). + // sfen=x1, turn=w2 (int32), moveCount=w3 (int32). The two float + // arguments arrive in s0/s1 which the cave does not save and the + // dispatcher cannot reach from this C signature; pass 0.0f. The + // floats are log-only in the hook body, so the observable cost is + // a less precise "[SNAPSHOT]" timing line. + case KIOU_BR_HOOK_ONLINE_UPDATE_SNAPSHOT: + HookUpdateAuthoritativeSnapshot(x0, x1, (int32_t)(intptr_t)x2, + 0.0f, 0.0f, + (int32_t)(intptr_t)x3); + break; + case KIOU_BR_HOOK_CPUSTREAM_UPDATE_SNAPSHOT: + HookCpuStreamUpdateSnapshot(x0, x1, (int32_t)(intptr_t)x2, + 0.0f, 0.0f, + (int32_t)(intptr_t)x3); + break; + + // HandleMoveResult(self, reply) + case KIOU_BR_HOOK_ONLINE_HANDLE_RESULT: + HookHandleMoveResult(x0, x1); break; + + // GameOrchestrator.ActivateAsync(self, setup, assetLoader, ct) + case KIOU_BR_HOOK_GAMEORCH_ACTIVATE: + (void)HookGameOrchActivateAsync(x0, x1, x2, x3); break; + + // GameStateStore.SetBlackPlayerInfo(self, playerInfo) + case KIOU_BR_HOOK_GSTATE_SET_BLACK_PLAYER_INFO: + HookGStateSetBlackPlayerInfo(x0, x1); break; + + // GameStateStore.SetWhitePlayerInfo(self, playerInfo) + case KIOU_BR_HOOK_GSTATE_SET_WHITE_PLAYER_INFO: + HookGStateSetWhitePlayerInfo(x0, x1); break; + + // GameStateStore.NotifyPieceMoved(self, move, playerSide) + // self=x0, move=w1 (uint32), playerSide=w2 (int32) + case KIOU_BR_HOOK_GSTATE_NOTIFY_PIECE_MOVED: + HookGStateNotifyPieceMoved(x0, (uint32_t)(uintptr_t)x1, + (int32_t)(intptr_t)x2); break; + + default: + // Cave fired with an id outside the recipe table. Almost certainly + // a recipe / header skew — log it and return so we at least keep + // the process alive instead of falling off into orig with an + // unexpected state. + IPALog([NSString stringWithFormat: + @"[BINPATCH] unknown hook_id=%u self=%p", + (unsigned)hook_id, x0]); + break; + } +} + +void KEBBridgeBinpatchPublish(void) { + if (g_unityBase == 0) { + // Tweak.m always sets g_unityBase before reaching us. Guarding + // anyway so a mis-ordered installer call surfaces in the log + // instead of crashing on the deref below. + IPALog(@"[BINPATCH] publish skipped: g_unityBase is zero"); + return; + } + // Slot lives in UnityFramework's __DATA,__bss at the RVA the recipe + // pinned. Writing to __DATA is allowed by iOS 18 CSM; the cave's + // ADRP+LDR resolves to this exact address and BLRs the pointer + // stored here. arm64 aligned 8-byte pointer stores are atomic, so + // the cave sees either NULL (before this fires) or the fully formed + // dispatcher. + void * volatile *slot = + (void * volatile *)(g_unityBase + KIOU_BR_HOOK_SLOT_RVA); + *slot = (void *)&dispatch_one; + for (uint32_t i = 0; i < KIOU_BR_HOOK__COUNT; i++) { + g_inject_entry[i] = kiou_bridge_bypass_entry_for_hook(i); + } + IPALog([NSString stringWithFormat: + @"[BINPATCH] slot=%p (unityBase+0x%lx) published " + @"dispatcher=%p inject_entry[ai_opm]=%p inject_entry[adapter]=%p " + @"cave_start=0x%lx cave_size=%u bypass_off=0x%x count=%u", + (void *)slot, + (unsigned long)KIOU_BR_HOOK_SLOT_RVA, + (void *)&dispatch_one, + (void *)g_inject_entry[KIOU_BR_HOOK_AI_OPM], + (void *)g_inject_entry[KIOU_BR_HOOK_ADAPTER_TRY_MAKE_MOVE_OUT], + (unsigned long)KIOU_BR_CAVE_REGION_START, + (unsigned)KIOU_BR_CAVE_SIZE, + (unsigned)KIOU_BR_CAVE_BYPASS_OFFSET, + (unsigned)KIOU_BR_HOOK__COUNT]); +} + +#endif // KIOU_BINPATCH diff --git a/Sources/KiouEngineBridge/Csa_Convert.h b/Sources/KiouEngineBridge/Csa_Convert.h new file mode 100644 index 0000000..db46486 --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_Convert.h @@ -0,0 +1,230 @@ +#pragma once + +#import +#import +#import + +// =========================================================================== +// Csa_Convert — CSA protocol coordinate / piece / move / position conversion. +// +// Pure functions, Foundation-only dependency. No il2cpp, no hooks, no +// globals — every routine is a referentially transparent helper that can be +// linked into a host test binary on macOS without any of the tweak runtime +// (`Tests/CsaConvertTests.m` does exactly that). +// +// Coordinate system: +// KIOU's Move bits use Project.ShogiCore.Square encoding +// SQ11 = 0, SQ19 = 8, SQ91 = 72, SQ99 = 80 +// square = (file - 1) * 9 + (rank - 1) +// CSA writes the same square as a two-digit string "", +// each digit in 1-9. e.g. square 60 → "77" (= 7七 = USI "7g"). +// +// Move bit layout (mirrored from Hook_LowLevelObserve.m::moveToUsi): +// bit[6:0] to — destination Square (0..80) +// bit[13:7] from — origin Square (0..80), undefined when drop bit set +// bit[14] promote +// bit[15] drop +// bit[31:16] upper16 — movingPiece et al. Drop piece type lives here, +// but the exact bit layout is still under reverse +// engineering (Task 7 of the CSA migration plan). +// +// CSA piece codes (14 PSC PieceType values mapped to CSA mnemonics): +// 1 FU (Pawn) 9 TO (Promoted Pawn) +// 2 KY (Lance) 10 NY (Promoted Lance) +// 3 KE (Knight) 11 NK (Promoted Knight) +// 4 GI (Silver) 12 NG (Promoted Silver) +// 5 KA (Bishop) 13 UM (Promoted Bishop) +// 6 HI (Rook) 14 RY (Promoted Rook) +// 7 KI (Gold) +// 8 OU (King) +// +// The promoted PieceType values (9..14) are assumed to follow the PSC enum +// declaration order; they will be verified against dump.cs in Task 7. +// =========================================================================== + +// --------------------------------------------------------------------------- +// Square <-> CSA coordinate. +// --------------------------------------------------------------------------- + +// Convert a Square value (0..80) into its CSA two-character coordinate +// (`"77"` for SQ77 / file 7 rank 7). Returns nil when `square` is out of +// range — callers MUST check for nil rather than assume a default. +NSString *CsaSquareFromMoveBits(uint32_t square); + +// Inverse of CsaSquareFromMoveBits — parse a two-digit CSA coordinate and +// write the Square index (0..80) into *outSquare. Returns YES on success, +// NO on malformed input (wrong length, non-digit, file/rank out of 1..9). +// outSquare is left untouched on failure. +BOOL MoveBitsFromCsaSquare(NSString *csa, uint32_t *outSquare); + +// --------------------------------------------------------------------------- +// CSA piece code <-> PSC PieceType. +// --------------------------------------------------------------------------- + +// PSC PieceType integer (1..14) → CSA piece mnemonic. Returns nil for +// out-of-range values. The mnemonic is exactly two ASCII uppercase chars. +NSString *CsaPieceFromPscPieceType(int32_t pieceType); + +// CSA piece mnemonic → PSC PieceType integer. Returns -1 when the input is +// not one of the 14 known mnemonics. Case-sensitive (CSA always uses upper +// case). +int32_t PscPieceTypeFromCsaPiece(NSString *csa); + +// --------------------------------------------------------------------------- +// Move bits <-> CSA move text. +// +// CSA move text shape: +// ±[,T] +// "+7776FU" ordinary move, black side, T omitted +// "+7776FU,T10" same move with 10 s consumed +// "+0055FU" drop — from is literally "00", piece names the +// dropped piece type +// "+8822UM" promoting move — piece is the promoted type +// +// playerSide: +// 0 = Black (CSA `+`) +// 1 = White (CSA `-`) +// timeSpent: +// seconds consumed on the move, written as `,T`. Pass -1 to omit the +// `,T` suffix entirely (used when KIOU has not surfaced a clock for +// this move, e.g. AI / Local modes before any snapshot arrives). +// --------------------------------------------------------------------------- + +// Render a KIOU Move bits value as a CSA move line. +// +// For ordinary moves, the produced piece mnemonic reflects the promotion +// bit: if `bit[14]` is set we emit the promoted variant (FU → TO, KA → UM, +// etc). `pscPieceType` is the *unpromoted* PSC PieceType (1..8); upgrading +// to the promoted form is this function's job. +// +// For drops, `move`'s drop bit must be set and `pscPieceType` MUST be the +// dropped piece's PieceType (1..8, never a promoted form). The `from` +// coordinate is forced to "00" per CSA. +// +// Returns nil on any malformed input (bad squares, unknown piece type, +// drop bit + promote bit both set). +NSString *CsaTextFromMoveBits(uint32_t move, + int32_t pscPieceType, + int32_t playerSide, + int32_t timeSpent); + +// Parse a CSA move line into its components. The leading `±` selects +// playerSide (0/1). `,T` suffix is optional. +// +// Successful parse writes: +// *outMove — uint32 with to/from/promote/drop bits set. Upper-16 +// piece type bits are left as zero (callers that need the +// piece type should consult *outPieceType). +// *outPieceType — PSC PieceType integer (1..14). For promoting moves +// this is the *promoted* PieceType from the CSA text; +// the caller is responsible for downshifting to the +// unpromoted PieceType when invoking PSCMove_Create. +// *outPlayerSide — 0 (Black) or 1 (White). +// *outTimeSpent — seconds parsed from `,T`, or -1 if the suffix +// was absent. +// +// Returns YES on success, NO on any malformed input. All `out*` arguments +// must be non-NULL; on failure they are left untouched. +BOOL MoveBitsFromCsaText(NSString *csa, + uint32_t *outMove, + int32_t *outPieceType, + int32_t *outPlayerSide, + int32_t *outTimeSpent); + +// --------------------------------------------------------------------------- +// SFEN -> CSA position block. +// +// Produces the multi-line representation that goes inside `BEGIN Position` +// / `END Position`. Format example for the standard opening: +// +// P1-KY-KE-GI-KI-OU-KI-GI-KE-KY +// P2 * -HI * * * * * -KA * +// P3-FU-FU-FU-FU-FU-FU-FU-FU-FU +// P4 * * * * * * * * * +// P5 * * * * * * * * * +// P6 * * * * * * * * * +// P7+FU+FU+FU+FU+FU+FU+FU+FU+FU +// P8 * +KA * * * * * +HI * +// P9+KY+KE+GI+KI+OU+KI+GI+KE+KY +// P+ +// P- +// + +// +// The trailing `+` (or `-`) line is the side to move, derived from the SFEN +// side-to-move token. P+ / P- lines describe Black / White hand pieces using +// CSA's `00FU00FU` format ("00" file/rank + piece). Empty hand prints as a +// blank `P+` / `P-`. +// +// Returns nil when the SFEN is malformed (wrong number of board ranks, etc). +// The trailing newline is omitted; callers should append `\n` when slotting +// the block into the Game_Summary stream. +// --------------------------------------------------------------------------- + +NSString *CsaPositionFromSfen(NSString *sfen); + +// --------------------------------------------------------------------------- +// Helpers for reconstructing piece type from an SFEN snapshot. +// +// The Move bits surfaced by KIOU's NotifyPieceMoved hook carry the +// destination square but not (in any reverse-engineered form) the piece +// type that just landed there. Reading the post-move SFEN and pulling the +// letter sitting on the destination square is a robust workaround until the +// upper-16 layout is decoded (Task 7). +// --------------------------------------------------------------------------- + +// Read the piece occupying `square` (0..80) in `sfen`. Returns the PSC +// PieceType integer (1..14) of the piece — promoted variants land on +// 9..14. Returns -1 if the square is empty or sfen is malformed. +int32_t PscPieceTypeAtSquare(NSString *sfen, uint32_t square); + +// Find the piece type that disappeared from one player's hand between two +// SFEN snapshots. Used to recover the dropped piece type when KIOU's Move +// bits don't carry a usable upper-16 encoding for drops. Returns the PSC +// PieceType (1..7 — drops are always unpromoted) of the missing piece, or +// -1 when the hands match exactly (no drop happened) or sfen is malformed. +// +// playerSide: 0=Black (look at the uppercase hand letters), 1=White +// (lowercase hand letters). +int32_t DropPieceTypeFromHandDelta(NSString *sfenBefore, + NSString *sfenAfter, + int32_t playerSide); + +// --------------------------------------------------------------------------- +// Move legality checks. +// +// Lightweight validators KEB runs before handing a CSA-supplied move off to +// inject_apply. They don't replicate the full shogi rule engine — KIOU is +// the authority on board state — but they catch the categories of input +// that, when fed through inject, leave KIOU's internal state inconsistent +// (the 'piece bounces back' symptom from on-device testing): +// +// - dropping onto an occupied square +// - moving from an empty square +// - moving from a square whose piece doesn't match the named piece type +// - moving onto a square already holding the same side's piece +// - drop landing on a rank with no escape (pawn / lance on rank 1, +// knight on ranks 1-2; from the moving side's perspective) +// - two pawns on the same file (nifu) when dropping a pawn +// +// All four return a short ASCII reason string on rejection, or NULL when +// the move passes. `playerSide` is 0=Black, 1=White. Square indices follow +// the KIOU PSC convention (0..80). +// --------------------------------------------------------------------------- + +const char *ValidateCsaDrop(NSString *sfenBefore, + uint32_t toSquare, + int32_t pscPieceType, + int32_t playerSide); + +const char *ValidateCsaMove(NSString *sfenBefore, + uint32_t fromSquare, + uint32_t toSquare, + int32_t pscPieceType, + BOOL promote, + int32_t playerSide); + +// Convenience: given a CSA-formatted move that's missing its `,T` +// suffix, append `,T` (or return the original unchanged when +// `seconds` < 0). Used by the engine driver when it knows the time spent +// at emit time but had to build the CSA prefix earlier. +NSString *CsaTextAppendingTime(NSString *csaMove, int32_t seconds); diff --git a/Sources/KiouEngineBridge/Csa_Convert.m b/Sources/KiouEngineBridge/Csa_Convert.m new file mode 100644 index 0000000..2540d2a --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -0,0 +1,929 @@ +#import "Csa_Convert.h" + +// =========================================================================== +// Csa_Convert — implementation. +// +// Pure routines: no globals, no il2cpp, no logging. Failure modes are +// signalled via return value (nil / NO / -1) rather than NSException — this +// is what makes the file linkable into a standalone host-side test binary. +// =========================================================================== + +// --------------------------------------------------------------------------- +// CSA piece tables. +// +// Index is the PSC PieceType enum value (1..14). Index 0 is left blank so +// the table can be indexed directly by PieceType without an off-by-one. +// --------------------------------------------------------------------------- +static NSString *const kCsaPieceNames[15] = { + @"", // 0 — unused + @"FU", // 1 Pawn + @"KY", // 2 Lance + @"KE", // 3 Knight + @"GI", // 4 Silver + @"KA", // 5 Bishop + @"HI", // 6 Rook + @"KI", // 7 Gold + @"OU", // 8 King + @"TO", // 9 Promoted Pawn + @"NY", // 10 Promoted Lance + @"NK", // 11 Promoted Knight + @"NG", // 12 Promoted Silver + @"UM", // 13 Promoted Bishop + @"RY", // 14 Promoted Rook +}; + +// Map from base (unpromoted) PieceType to its promoted PieceType. 0 means +// "this piece cannot promote" — King and Gold land there. +static int32_t kPromotedPieceType[15] = { + 0, // 0 unused + 9, // 1 FU -> TO + 10, // 2 KY -> NY + 11, // 3 KE -> NK + 12, // 4 GI -> NG + 13, // 5 KA -> UM + 14, // 6 HI -> RY + 0, // 7 KI (cannot promote) + 0, // 8 OU (cannot promote) + 0, // 9..14 already promoted + 0, 0, 0, 0, 0, +}; + +// --------------------------------------------------------------------------- +// Square <-> CSA coordinate. +// +// PSC Square layout (matches Hook_LowLevelObserve.m:140-145): +// square = (file - 1) * 9 + (rank - 1) +// file_idx (1..9) = square / 9 + 1 +// rank_idx (1..9) = square % 9 + 1 +// CSA writes file first, then rank. SFEN's "g" rank corresponds to rank 7. +// --------------------------------------------------------------------------- + +NSString *CsaSquareFromMoveBits(uint32_t square) { + if (square > 80) return nil; + uint32_t file = (square / 9) + 1; + uint32_t rank = (square % 9) + 1; + return [NSString stringWithFormat:@"%u%u", file, rank]; +} + +BOOL MoveBitsFromCsaSquare(NSString *csa, uint32_t *outSquare) { + if (csa.length != 2 || !outSquare) return NO; + unichar fileCh = [csa characterAtIndex:0]; + unichar rankCh = [csa characterAtIndex:1]; + if (fileCh < '1' || fileCh > '9') return NO; + if (rankCh < '1' || rankCh > '9') return NO; + uint32_t file = (uint32_t)(fileCh - '0'); + uint32_t rank = (uint32_t)(rankCh - '0'); + *outSquare = (file - 1) * 9 + (rank - 1); + return YES; +} + +// --------------------------------------------------------------------------- +// CSA piece code <-> PSC PieceType. +// --------------------------------------------------------------------------- + +NSString *CsaPieceFromPscPieceType(int32_t pieceType) { + if (pieceType < 1 || pieceType > 14) return nil; + return kCsaPieceNames[pieceType]; +} + +int32_t PscPieceTypeFromCsaPiece(NSString *csa) { + if (csa.length != 2) return -1; + for (int32_t i = 1; i <= 14; i++) { + if ([csa isEqualToString:kCsaPieceNames[i]]) return i; + } + return -1; +} + +// --------------------------------------------------------------------------- +// Move bits <-> CSA move text. +// --------------------------------------------------------------------------- + +NSString *CsaTextFromMoveBits(uint32_t move, + int32_t pscPieceType, + int32_t playerSide, + int32_t timeSpent) { + if (playerSide != 0 && playerSide != 1) return nil; + if (pscPieceType < 1 || pscPieceType > 14) return nil; + + uint32_t to = move & 0x7F; + uint32_t from = (move >> 7) & 0x7F; + uint32_t promote = (move >> 14) & 1; + uint32_t drop = (move >> 15) & 1; + + // Cannot both drop and promote on the same move. + if (promote && drop) return nil; + + NSString *toStr = CsaSquareFromMoveBits(to); + if (!toStr) return nil; + + NSString *fromStr; + int32_t finalPieceType = pscPieceType; + if (drop) { + // CSA encodes drops with from = "00". Promoted piece types are never + // legal here — drops always introduce the unpromoted form. + if (pscPieceType > 8) return nil; + fromStr = @"00"; + } else { + fromStr = CsaSquareFromMoveBits(from); + if (!fromStr) return nil; + if (promote) { + int32_t promoted = kPromotedPieceType[pscPieceType]; + if (promoted == 0) return nil; // King/Gold can't promote + finalPieceType = promoted; + } + } + + NSString *pieceStr = CsaPieceFromPscPieceType(finalPieceType); + if (!pieceStr) return nil; + + NSString *sideStr = (playerSide == 0) ? @"+" : @"-"; + + if (timeSpent < 0) { + return [NSString stringWithFormat:@"%@%@%@%@", + sideStr, fromStr, toStr, pieceStr]; + } + return [NSString stringWithFormat:@"%@%@%@%@,T%d", + sideStr, fromStr, toStr, pieceStr, timeSpent]; +} + +BOOL MoveBitsFromCsaText(NSString *csa, + uint32_t *outMove, + int32_t *outPieceType, + int32_t *outPlayerSide, + int32_t *outTimeSpent) { + if (!outMove || !outPieceType || !outPlayerSide || !outTimeSpent) { + return NO; + } + // Minimum legal shape: "<4 coord digits><2 piece chars>" = 7 chars + if (csa.length < 7) return NO; + + unichar signCh = [csa characterAtIndex:0]; + int32_t playerSide; + if (signCh == '+') playerSide = 0; + else if (signCh == '-') playerSide = 1; + else return NO; + + NSString *fromStr = [csa substringWithRange:NSMakeRange(1, 2)]; + NSString *toStr = [csa substringWithRange:NSMakeRange(3, 2)]; + NSString *pieceStr = [csa substringWithRange:NSMakeRange(5, 2)]; + + // Optional ",T" suffix. + int32_t timeSpent = -1; + if (csa.length > 7) { + // Expect ",T" immediately after the piece mnemonic; everything else + // is malformed. + if (csa.length < 9) return NO; + if ([csa characterAtIndex:7] != ',') return NO; + if ([csa characterAtIndex:8] != 'T') return NO; + NSString *tStr = [csa substringFromIndex:9]; + if (tStr.length == 0) return NO; + NSScanner *sc = [NSScanner scannerWithString:tStr]; + int parsed = 0; + if (![sc scanInt:&parsed] || !sc.isAtEnd || parsed < 0) return NO; + timeSpent = parsed; + } + + int32_t pieceType = PscPieceTypeFromCsaPiece(pieceStr); + if (pieceType < 0) return NO; + + uint32_t to = 0; + if (!MoveBitsFromCsaSquare(toStr, &to)) return NO; + + // Drop case: from == "00", piece is the unpromoted dropped piece type. + BOOL isDrop = [fromStr isEqualToString:@"00"]; + uint32_t from = 0; + uint32_t dropBit = 0; + uint32_t promoteBit = 0; + if (isDrop) { + if (pieceType > 8) return NO; // can't drop promoted + dropBit = 1; + // from bits are undefined for drops; leave at 0. + } else { + if (!MoveBitsFromCsaSquare(fromStr, &from)) return NO; + // Promotion is signalled implicitly: if the named piece is a + // promoted form (9..14), the move is a promoting move. + if (pieceType > 8) promoteBit = 1; + } + + uint32_t move = (to & 0x7F) + | ((from & 0x7F) << 7) + | ((promoteBit & 1) << 14) + | ((dropBit & 1) << 15); + + *outMove = move; + *outPieceType = pieceType; + *outPlayerSide = playerSide; + *outTimeSpent = timeSpent; + return YES; +} + +// --------------------------------------------------------------------------- +// SFEN -> CSA position block. +// +// SFEN reminder (from the standard USI position string): +// +// Board ranks are separated by '/', and each rank is read from file 9 down +// to file 1 (left-to-right when looking at the board). A digit in a rank +// stands for that many empty squares. '+' before a letter promotes it. +// Lowercase = white, uppercase = black. The hand field uses the same +// letters; a count > 1 is prefixed (`2P`, `18p`, etc). +// +// CSA reverses the file order on the board: P1 starts at file 9 and ends +// at file 1, so the SFEN rank order maps onto CSA's `P` rows directly. +// +// Each board cell is rendered as exactly three characters so the columns +// stay aligned (a strict CSA parser doesn't require alignment, but Floodgate +// / shogi-server reference outputs do): +// +// ` * ` empty square (leading space + asterisk + trailing space) +// `+XX` black piece XX +// `-XX` white piece XX +// +// The trailing space on the rightmost cell is stripped so the line matches +// the canonical shogi-server format byte-for-byte. +// --------------------------------------------------------------------------- + +// Translate a single SFEN piece character + promotion flag into the CSA +// two-letter mnemonic. Returns nil on unknown letter. +static NSString *csa_pieceFromSfenLetter(char letter, BOOL promoted) { + int32_t base = -1; + switch (letter) { + case 'P': case 'p': base = 1; break; + case 'L': case 'l': base = 2; break; + case 'N': case 'n': base = 3; break; + case 'S': case 's': base = 4; break; + case 'B': case 'b': base = 5; break; + case 'R': case 'r': base = 6; break; + case 'G': case 'g': base = 7; break; + case 'K': case 'k': base = 8; break; + default: return nil; + } + if (promoted) { + int32_t promotedType = kPromotedPieceType[base]; + if (promotedType == 0) return nil; + base = promotedType; + } + return kCsaPieceNames[base]; +} + +// Render one SFEN board rank into a CSA `P` line body (the 18 chars +// after the leading `P` tag). Returns nil if the rank is malformed. +static NSString *csa_lineFromSfenRank(NSString *sfenRank) { + NSMutableString *out = [NSMutableString stringWithCapacity:27]; + BOOL pendingPromote = NO; + NSUInteger filled = 0; + for (NSUInteger i = 0; i < sfenRank.length; i++) { + unichar ch = [sfenRank characterAtIndex:i]; + if (ch == '+') { + pendingPromote = YES; + continue; + } + if (ch >= '1' && ch <= '9') { + int empty = (int)(ch - '0'); + for (int e = 0; e < empty; e++) { + [out appendString:@" * "]; + filled++; + } + pendingPromote = NO; + continue; + } + BOOL isBlack = (ch >= 'A' && ch <= 'Z'); + NSString *piece = csa_pieceFromSfenLetter((char)ch, pendingPromote); + if (!piece) return nil; + [out appendString:isBlack ? @"+" : @"-"]; + [out appendString:piece]; + filled++; + pendingPromote = NO; + } + if (filled != 9) return nil; + // Trim only the rightmost spaces — empty cells generated by an empty + // run at the end of the rank leave a trailing " " that needs to go, + // but the leading space in front of the first " * " is meaningful. + NSUInteger len = out.length; + while (len > 0 && [out characterAtIndex:len - 1] == ' ') len--; + if (len < out.length) { + return [out substringToIndex:len]; + } + return out; +} + +// Render the hand-pieces section into `P+...` / `P-...` lines. Returns a +// two-element array {`P+...`, `P-...`}. `sfenHand` may be `-` (empty hand) +// or a sequence like `2Pn` meaning two black pawns and one white knight. +static NSArray *csa_handLinesFromSfen(NSString *sfenHand) { + NSMutableString *black = [NSMutableString stringWithString:@"P+"]; + NSMutableString *white = [NSMutableString stringWithString:@"P-"]; + if ([sfenHand isEqualToString:@"-"]) { + return @[black, white]; + } + NSUInteger i = 0; + while (i < sfenHand.length) { + unichar ch = [sfenHand characterAtIndex:i]; + int count = 1; + if (ch >= '0' && ch <= '9') { + int n = 0; + while (i < sfenHand.length) { + unichar d = [sfenHand characterAtIndex:i]; + if (d < '0' || d > '9') break; + n = n * 10 + (int)(d - '0'); + i++; + } + if (n == 0) return nil; + count = n; + if (i >= sfenHand.length) return nil; + ch = [sfenHand characterAtIndex:i]; + } + i++; + BOOL isBlack = (ch >= 'A' && ch <= 'Z'); + NSString *piece = csa_pieceFromSfenLetter((char)ch, NO); + if (!piece) return nil; + NSMutableString *target = isBlack ? black : white; + for (int c = 0; c < count; c++) { + // CSA hand entries use "00" — file/rank zeros mean "no + // square on the board," signalling a piece in hand. + [target appendString:@"00"]; + [target appendString:piece]; + } + } + return @[black, white]; +} + +NSString *CsaPositionFromSfen(NSString *sfen) { + if (sfen.length == 0) return nil; + NSArray *parts = [sfen componentsSeparatedByString:@" "]; + if (parts.count < 3) return nil; + NSArray *ranks = [parts[0] componentsSeparatedByString:@"/"]; + if (ranks.count != 9) return nil; + + NSMutableString *out = [NSMutableString stringWithCapacity:256]; + for (NSUInteger r = 0; r < 9; r++) { + NSString *line = csa_lineFromSfenRank(ranks[r]); + if (!line) return nil; + [out appendFormat:@"P%lu%@\n", (unsigned long)(r + 1), line]; + } + + NSArray *handLines = csa_handLinesFromSfen(parts[2]); + if (!handLines) return nil; + [out appendFormat:@"%@\n%@\n", handLines[0], handLines[1]]; + + NSString *side = parts[1]; + if ([side isEqualToString:@"b"]) { + [out appendString:@"+"]; + } else if ([side isEqualToString:@"w"]) { + [out appendString:@"-"]; + } else { + return nil; + } + return out; +} + +// --------------------------------------------------------------------------- +// SFEN-square lookup helper. +// +// Walk the SFEN board until we land on the requested square and return the +// PSC PieceType of whatever sits there. Promoted variants come back as +// 9..14. Empty cells / malformed input return -1. +// --------------------------------------------------------------------------- + +int32_t PscPieceTypeAtSquare(NSString *sfen, uint32_t square) { + if (square > 80) return -1; + if (sfen.length == 0) return -1; + NSArray *parts = [sfen componentsSeparatedByString:@" "]; + if (parts.count < 1) return -1; + NSArray *ranks = [parts[0] componentsSeparatedByString:@"/"]; + if (ranks.count != 9) return -1; + + // Target rank is square % 9 (0..8 → SFEN rank a..i → board row 0..8). + uint32_t rank = square % 9; + uint32_t file = square / 9 + 1; // 1..9 + + NSString *rankStr = ranks[rank]; + // SFEN ranks list files from 9 down to 1 left-to-right. + uint32_t cursorFile = 9; + BOOL pendingPromote = NO; + for (NSUInteger i = 0; i < rankStr.length; i++) { + unichar ch = [rankStr characterAtIndex:i]; + if (ch == '+') { + pendingPromote = YES; + continue; + } + if (ch >= '1' && ch <= '9') { + uint32_t skip = (uint32_t)(ch - '0'); + if (cursorFile > file && file >= cursorFile - skip + 1 && + file <= cursorFile) { + // The target file lies inside an empty run. + return -1; + } + cursorFile -= skip; + pendingPromote = NO; + continue; + } + if (cursorFile == file) { + int32_t base = -1; + switch (ch) { + case 'P': case 'p': base = 1; break; + case 'L': case 'l': base = 2; break; + case 'N': case 'n': base = 3; break; + case 'S': case 's': base = 4; break; + case 'B': case 'b': base = 5; break; + case 'R': case 'r': base = 6; break; + case 'G': case 'g': base = 7; break; + case 'K': case 'k': base = 8; break; + default: return -1; + } + if (pendingPromote) { + int32_t promoted = kPromotedPieceType[base]; + if (promoted == 0) return -1; + base = promoted; + } + return base; + } + cursorFile--; + pendingPromote = NO; + if (cursorFile < 1) break; + } + return -1; +} + +// --------------------------------------------------------------------------- +// Hand-piece counting. SFEN hand strings look like "2P3pn" (two Black pawns, +// three white pawns, one white knight). Walk the string and accumulate per- +// PSC-PieceType counts into `outCounts[1..7]` (index 0 is unused). Returns +// NO on malformed input (unknown letter, malformed count, etc). +// +// Uppercase letters land in `outBlackCounts`, lowercase in `outWhiteCounts`. +// Empty hand ("-") returns YES with both arrays left zeroed. +// --------------------------------------------------------------------------- +static BOOL csa_parseHand(NSString *hand, + uint32_t outBlackCounts[8], + uint32_t outWhiteCounts[8]) { + for (int i = 0; i < 8; i++) { + outBlackCounts[i] = 0; + outWhiteCounts[i] = 0; + } + if (hand.length == 0) return NO; + if ([hand isEqualToString:@"-"]) return YES; + + NSUInteger i = 0; + while (i < hand.length) { + uint32_t count = 1; + unichar ch = [hand characterAtIndex:i]; + if (ch >= '0' && ch <= '9') { + uint32_t n = 0; + while (i < hand.length) { + unichar d = [hand characterAtIndex:i]; + if (d < '0' || d > '9') break; + n = n * 10 + (uint32_t)(d - '0'); + i++; + } + if (n == 0) return NO; + count = n; + if (i >= hand.length) return NO; + ch = [hand characterAtIndex:i]; + } + i++; + int32_t base = -1; + switch (ch) { + case 'P': case 'p': base = 1; break; + case 'L': case 'l': base = 2; break; + case 'N': case 'n': base = 3; break; + case 'S': case 's': base = 4; break; + case 'B': case 'b': base = 5; break; + case 'R': case 'r': base = 6; break; + case 'G': case 'g': base = 7; break; + default: return NO; + } + BOOL isBlack = (ch >= 'A' && ch <= 'Z'); + if (isBlack) outBlackCounts[base] += count; + else outWhiteCounts[base] += count; + } + return YES; +} + +int32_t DropPieceTypeFromHandDelta(NSString *sfenBefore, + NSString *sfenAfter, + int32_t playerSide) { + if (sfenBefore.length == 0 || sfenAfter.length == 0) return -1; + if (playerSide != 0 && playerSide != 1) return -1; + + NSArray *beforeParts = [sfenBefore componentsSeparatedByString:@" "]; + NSArray *afterParts = [sfenAfter componentsSeparatedByString:@" "]; + if (beforeParts.count < 3 || afterParts.count < 3) return -1; + + uint32_t beforeBlack[8], beforeWhite[8]; + uint32_t afterBlack[8], afterWhite[8]; + if (!csa_parseHand(beforeParts[2], beforeBlack, beforeWhite)) return -1; + if (!csa_parseHand(afterParts[2], afterBlack, afterWhite)) return -1; + + const uint32_t *beforeCount = (playerSide == 0) ? beforeBlack : beforeWhite; + const uint32_t *afterCount = (playerSide == 0) ? afterBlack : afterWhite; + + // Find the piece type whose count decreased by exactly 1. + int32_t found = -1; + for (int32_t pt = 1; pt <= 7; pt++) { + if (beforeCount[pt] == afterCount[pt] + 1) { + if (found != -1) return -1; // ambiguous — two pieces left the hand + found = pt; + } else if (beforeCount[pt] != afterCount[pt]) { + // Any other delta (count went up, or dropped by >1) is unusable. + return -1; + } + } + return found; +} + +// --------------------------------------------------------------------------- +// Move legality checks. The shared IPALog() emits a single line per +// rejection so the device log captures the reason without each caller +// having to format their own message. +// +// We deliberately rely on the SFEN snapshot rather than KIOU's own legal- +// move generator (which we don't have a stable RVA for). That misses +// position-specific rules KIOU still applies (uchifuzume, double check, +// etc), but it catches the cheap "obviously bad" categories that we +// observed leaving KIOU's state inconsistent on inject. +// +// piece-type letter cache for the helpers below. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Per-piece reachability check. +// +// Square encoding: sq = (file-1)*9 + (rank-1), file 1-9 (right-to-left), +// rank 1-9 (top-to-bottom in standard board view). +// dFile step = ±9 (one file left/right) +// dRank step = ±1 (one rank forward/backward) +// Black moves "forward" toward rank 1, so Black's advance is dRank = -1. +// +// The movement tables below encode every (dFile, dRank) step a given piece +// can make from the playerSide perspective. Black and White tables are kept +// separate because asymmetric pieces (FU, KY, KE, and the gold-generals +// TO/NY/NK/NG) have direction relative to side. +// +// Piece types (PSC): +// 1 FU 2 KY 3 KE 4 GI 5 KA 6 HI 7 KI 8 OU +// 9 TO 10 NY 11 NK 12 NG 13 UM 14 RY +// --------------------------------------------------------------------------- + +// A single move direction: (dFile, dRank) delta and whether it slides +// (can repeat until blocked). +typedef struct { int dFile; int dRank; BOOL slides; } MoveDir; + +// Maximum directions per piece (8 dirs × 2 for slider flag headroom). +#define MAX_DIRS 8 + +// Returns the set of move directions for `pscPieceType` from `playerSide` +// (0=Black, 1=White). Writes into `dirs` and returns the count. +// Caller provides dirs[MAX_DIRS]. +static int moveDirsForPiece(int32_t pscPieceType, int32_t playerSide, + MoveDir dirs[MAX_DIRS]) { + // Black advances toward rank 1 (dRank=-1); White toward rank 9 (dRank=+1). + int fwd = (playerSide == 0) ? -1 : +1; + int n = 0; + + switch (pscPieceType) { + case 1: // FU — one step forward + dirs[n++] = (MoveDir){0, fwd, NO}; + break; + case 2: // KY — slides forward only + dirs[n++] = (MoveDir){0, fwd, YES}; + break; + case 3: // KE — L-shape forward: 2 ranks forward, 1 file either side + dirs[n++] = (MoveDir){-9, 2*fwd, NO}; + dirs[n++] = (MoveDir){+9, 2*fwd, NO}; + break; + case 4: // GI — Silver: 5 diagonal + forward + dirs[n++] = (MoveDir){ 0, fwd, NO}; + dirs[n++] = (MoveDir){ -9, fwd, NO}; + dirs[n++] = (MoveDir){ +9, fwd, NO}; + dirs[n++] = (MoveDir){ -9, -fwd, NO}; + dirs[n++] = (MoveDir){ +9, -fwd, NO}; + break; + case 5: // KA — Bishop: 4 diagonal slides + dirs[n++] = (MoveDir){ -9, -1, YES}; + dirs[n++] = (MoveDir){ -9, +1, YES}; + dirs[n++] = (MoveDir){ +9, -1, YES}; + dirs[n++] = (MoveDir){ +9, +1, YES}; + break; + case 6: // HI — Rook: 4 orthogonal slides + dirs[n++] = (MoveDir){ 0, -1, YES}; + dirs[n++] = (MoveDir){ 0, +1, YES}; + dirs[n++] = (MoveDir){ -9, 0, YES}; + dirs[n++] = (MoveDir){ +9, 0, YES}; + break; + case 7: // KI — Gold: forward, side, backward-orthogonal + case 9: // TO — same as gold + case 10: // NY — same as gold + case 11: // NK — same as gold + case 12: // NG — same as gold + dirs[n++] = (MoveDir){ 0, fwd, NO}; + dirs[n++] = (MoveDir){ -9, fwd, NO}; + dirs[n++] = (MoveDir){ +9, fwd, NO}; + dirs[n++] = (MoveDir){ -9, 0, NO}; + dirs[n++] = (MoveDir){ +9, 0, NO}; + dirs[n++] = (MoveDir){ 0, -fwd, NO}; + break; + case 8: // OU — King: all 8 adjacent + dirs[n++] = (MoveDir){ 0, -1, NO}; + dirs[n++] = (MoveDir){ 0, +1, NO}; + dirs[n++] = (MoveDir){ -9, 0, NO}; + dirs[n++] = (MoveDir){ +9, 0, NO}; + dirs[n++] = (MoveDir){ -9, -1, NO}; + dirs[n++] = (MoveDir){ -9, +1, NO}; + dirs[n++] = (MoveDir){ +9, -1, NO}; + dirs[n++] = (MoveDir){ +9, +1, NO}; + break; + case 13: // UM — Horse: bishop slides + 4 adjacent orthogonal + dirs[n++] = (MoveDir){ -9, -1, YES}; + dirs[n++] = (MoveDir){ -9, +1, YES}; + dirs[n++] = (MoveDir){ +9, -1, YES}; + dirs[n++] = (MoveDir){ +9, +1, YES}; + dirs[n++] = (MoveDir){ 0, -1, NO}; + dirs[n++] = (MoveDir){ 0, +1, NO}; + dirs[n++] = (MoveDir){ -9, 0, NO}; + dirs[n++] = (MoveDir){ +9, 0, NO}; + break; + case 14: // RY — Dragon: rook slides + 4 adjacent diagonal + dirs[n++] = (MoveDir){ 0, -1, YES}; + dirs[n++] = (MoveDir){ 0, +1, YES}; + dirs[n++] = (MoveDir){ -9, 0, YES}; + dirs[n++] = (MoveDir){ +9, 0, YES}; + dirs[n++] = (MoveDir){ -9, -1, NO}; + dirs[n++] = (MoveDir){ -9, +1, NO}; + dirs[n++] = (MoveDir){ +9, -1, NO}; + dirs[n++] = (MoveDir){ +9, +1, NO}; + break; + default: + break; + } + return n; +} + +// Board occupancy helper: given a SFEN board string (part before first ' '), +// returns YES when `square` is occupied by any piece. Returns NO if empty. +// Caller must pass only the board part of SFEN (no spaces). +static BOOL squareOccupied(NSString *boardPart, uint32_t square) { + uint32_t file = (square / 9) + 1; // 1..9 + uint32_t rank = (square % 9) + 1; // 1..9 + NSArray *ranks = [boardPart componentsSeparatedByString:@"/"]; + if (ranks.count != 9) return NO; + NSString *rankStr = ranks[rank - 1]; + uint32_t cursorFile = 9; + for (NSUInteger i = 0; i < rankStr.length; i++) { + unichar ch = [rankStr characterAtIndex:i]; + if (ch == '+') continue; + if (ch >= '1' && ch <= '9') { + uint32_t empties = (uint32_t)(ch - '0'); + if (cursorFile - empties < file) return NO; + cursorFile -= empties; + continue; + } + // piece letter + if (cursorFile == file) return YES; + cursorFile--; + if (cursorFile < 1) break; + } + return NO; +} + +// Returns YES when `pscPieceType` can legally reach `toSquare` from +// `fromSquare` in one move, given the board occupancy in `boardPart` +// (the part of SFEN before the first space). Slider pieces are blocked +// by any intervening piece (own or opponent — only the destination +// occupation check in ValidateCsaMove distinguishes capture vs. blocked). +static BOOL pieceCanReach(int32_t pscPieceType, int32_t playerSide, + uint32_t fromSquare, uint32_t toSquare, + NSString *boardPart) { + if (fromSquare == toSquare) return NO; + + MoveDir dirs[MAX_DIRS]; + int nDirs = moveDirsForPiece(pscPieceType, playerSide, dirs); + if (nDirs == 0) return NO; + + int fromFile = (int)(fromSquare / 9) + 1; // 1..9 + int fromRank = (int)(fromSquare % 9) + 1; // 1..9 + + for (int d = 0; d < nDirs; d++) { + int cf = fromFile; + int cr = fromRank; + // Walk along this direction. + for (;;) { + cf += dirs[d].dFile / 9; // dFile is ±9 → ±1 file step + cr += dirs[d].dRank; + // Check board bounds. + if (cf < 1 || cf > 9 || cr < 1 || cr > 9) break; + uint32_t sq = (uint32_t)((cf - 1) * 9 + (cr - 1)); + if (sq == toSquare) return YES; // reached destination + if (!dirs[d].slides) break; // non-slider: only one step + // Slider: stop if something is in the way. + if (squareOccupied(boardPart, sq)) break; + } + } + return NO; +} + +const char *ValidateCsaDrop(NSString *sfenBefore, + uint32_t toSquare, + int32_t pscPieceType, + int32_t playerSide) { + if (sfenBefore.length == 0) return NULL; // no prior snapshot → don't block + if (toSquare > 80) return "to_oob"; + if (pscPieceType < 1 || pscPieceType > 7) return "drop_promoted"; + if (playerSide != 0 && playerSide != 1) return "bad_side"; + + // 1. Target square must be empty. + int32_t occupant = PscPieceTypeAtSquare(sfenBefore, toSquare); + if (occupant > 0) return "drop_on_occupied"; + + // 2. Nowhere-to-go: pawn / lance must not land on the deepest rank; + // knight must not land on the two deepest ranks. Deepest rank is + // rank 1 for Black (SQ?1), rank 9 for White (SQ?9). + uint32_t rank = (toSquare % 9) + 1; // 1..9 + uint32_t deepest = (playerSide == 0) ? 1u : 9u; + if (pscPieceType == 1 /*FU*/ || pscPieceType == 2 /*KY*/) { + if (rank == deepest) return "drop_deadend"; + } else if (pscPieceType == 3 /*KE*/) { + uint32_t blocked2 = (playerSide == 0) ? 2u : 8u; + if (rank == deepest || rank == blocked2) return "drop_deadend"; + } + + // 3. Nifu: dropping a pawn onto a file that already holds an + // unpromoted pawn of the same side is illegal. + if (pscPieceType == 1) { + uint32_t file = (toSquare / 9) + 1; + for (uint32_t r = 1; r <= 9; r++) { + uint32_t sq = (file - 1) * 9 + (r - 1); + int32_t pt = PscPieceTypeAtSquare(sfenBefore, sq); + if (pt != 1) continue; + // Determine whether that pawn belongs to the moving side. + // PscPieceTypeAtSquare strips colour, so we re-read the SFEN + // letter at this square. We can shortcut by reading the + // SFEN board and inspecting the letter's case. + // Reuse PscPieceTypeAtSquare's parser via a tiny helper. + NSArray *parts = [sfenBefore componentsSeparatedByString:@" "]; + if (parts.count < 1) break; + NSArray *ranks = [parts[0] componentsSeparatedByString:@"/"]; + if (ranks.count != 9) break; + uint32_t rIdx = r - 1; + NSString *rankStr = ranks[rIdx]; + uint32_t cursorFile = 9; + BOOL pendingPromote = NO; + BOOL hit = NO; + BOOL ourPawn = NO; + for (NSUInteger i = 0; i < rankStr.length; i++) { + unichar ch = [rankStr characterAtIndex:i]; + if (ch == '+') { pendingPromote = YES; continue; } + if (ch >= '1' && ch <= '9') { + cursorFile -= (uint32_t)(ch - '0'); + pendingPromote = NO; + continue; + } + if (cursorFile == file) { + BOOL isBlack = (ch >= 'A' && ch <= 'Z'); + BOOL isPawn = (ch == 'P' || ch == 'p'); + hit = YES; + if (isPawn && !pendingPromote) { + ourPawn = (isBlack == (playerSide == 0)); + } + break; + } + cursorFile--; + pendingPromote = NO; + if (cursorFile < 1) break; + } + if (hit && ourPawn) return "nifu"; + } + } + return NULL; +} + +const char *ValidateCsaMove(NSString *sfenBefore, + uint32_t fromSquare, + uint32_t toSquare, + int32_t pscPieceType, + BOOL promote, + int32_t playerSide) { + if (sfenBefore.length == 0) return NULL; + if (fromSquare > 80) return "from_oob"; + if (toSquare > 80) return "to_oob"; + if (pscPieceType < 1 || pscPieceType > 14) return "bad_piece"; + if (playerSide != 0 && playerSide != 1) return "bad_side"; + + int32_t fromPiece = PscPieceTypeAtSquare(sfenBefore, fromSquare); + if (fromPiece < 0) return "from_empty"; + + // The CSA piece mnemonic on the wire is the moving piece's *current* + // type (post-promotion if applicable). Allow it to match either the + // raw on-board piece or its promoted form, since promote=true means + // the piece is being upgraded as part of this move. + if (fromPiece != pscPieceType) { + // Allow promotion-in-move case: from-square holds the unpromoted + // form, CSA names the promoted form, and the promote flag is set. + if (!promote || fromPiece > 8) return "from_piece_mismatch"; + int32_t expectedPromoted = (fromPiece >= 1 && fromPiece <= 6) + ? fromPiece + 8 : 0; + if (expectedPromoted != pscPieceType) return "from_piece_mismatch"; + } + + // Promotion legality. Only six base piece types can promote + // (FU/KY/KE/GI/KA/HI = 1..6). King (8) and Gold (7) never can. A + // promotion is only legal when the move starts in, ends in, or + // crosses through the enemy camp — equivalent to: from-rank or + // to-rank is on the opponent's side of the board (Black promotes + // when either rank ≤ 3; White when either rank ≥ 7). + if (promote) { + if (fromPiece > 8) return "promote_already_promoted"; + if (fromPiece == 7 /*KI*/ || fromPiece == 8 /*OU*/) { + return "promote_unpromotable"; + } + uint32_t fromRank = (fromSquare % 9) + 1; // 1..9 + uint32_t toRank = (toSquare % 9) + 1; + BOOL inEnemyCamp; + if (playerSide == 0) { + // Black's enemy camp is rank 1-3. + inEnemyCamp = (fromRank <= 3) || (toRank <= 3); + } else { + // White's enemy camp is rank 7-9. + inEnemyCamp = (fromRank >= 7) || (toRank >= 7); + } + if (!inEnemyCamp) return "promote_outside_enemy_camp"; + } + + // Must-promote check: FU/KY cannot be left unpromoted on rank 1 (Black) + // or rank 9 (White); KE cannot be left unpromoted on ranks 1-2 (Black) + // or ranks 8-9 (White). Only applies to unpromoted pieces (fromPiece 1..8) + // making a non-promoting move. + if (!promote && fromPiece >= 1 && fromPiece <= 8) { + uint32_t toRank = (toSquare % 9) + 1; // 1..9 + if (playerSide == 0) { + // Black: rank 1 is the back rank. + if ((fromPiece == 1 /*FU*/ || fromPiece == 2 /*KY*/) && toRank == 1) + return "must_promote"; + if (fromPiece == 3 /*KE*/ && toRank <= 2) + return "must_promote"; + } else { + // White: rank 9 is the back rank. + if ((fromPiece == 1 /*FU*/ || fromPiece == 2 /*KY*/) && toRank == 9) + return "must_promote"; + if (fromPiece == 3 /*KE*/ && toRank >= 8) + return "must_promote"; + } + } + + // Per-piece reachability: does this piece type actually reach toSquare + // from fromSquare given board occupancy? Uses the unpromoted base type + // for pieces that just landed in the from-square still unpromoted + // (pscPieceType 1..8), or the promoted type for already-promoted pieces + // (9..14). The moveDirsForPiece table covers all 14 types. + { + NSArray *sfenParts = [sfenBefore componentsSeparatedByString:@" "]; + NSString *boardPart = sfenParts.count >= 1 ? sfenParts[0] : @""; + if (boardPart.length > 0) { + // Use the piece type that is actually on fromSquare (fromPiece), + // not the wire piece type, so sliding piece paths are computed + // correctly for both pre- and post-promotion pieces. + if (!pieceCanReach(fromPiece, playerSide, fromSquare, toSquare, boardPart)) { + return "unreachable"; + } + } + } + + // Can't capture your own piece. + NSArray *parts = [sfenBefore componentsSeparatedByString:@" "]; + if (parts.count >= 1) { + NSArray *ranks = [parts[0] componentsSeparatedByString:@"/"]; + if (ranks.count == 9) { + uint32_t toFile = (toSquare / 9) + 1; + uint32_t toRank = (toSquare % 9) + 1; + NSString *rankStr = ranks[toRank - 1]; + uint32_t cursorFile = 9; + for (NSUInteger i = 0; i < rankStr.length; i++) { + unichar ch = [rankStr characterAtIndex:i]; + if (ch == '+') continue; // promotion marker — colour-neutral + if (ch >= '1' && ch <= '9') { + cursorFile -= (uint32_t)(ch - '0'); + continue; + } + if (cursorFile == toFile) { + BOOL isBlack = (ch >= 'A' && ch <= 'Z'); + if (isBlack == (playerSide == 0)) { + return "to_own_piece"; + } + break; + } + cursorFile--; + if (cursorFile < 1) break; + } + } + } + return NULL; +} + +NSString *CsaTextAppendingTime(NSString *csaMove, int32_t seconds) { + if (seconds < 0 || csaMove.length == 0) return csaMove; + if ([csaMove rangeOfString:@",T"].location != NSNotFound) { + // Already has a time suffix; leave it alone. + return csaMove; + } + return [NSString stringWithFormat:@"%@,T%d", csaMove, seconds]; +} diff --git a/Sources/KiouEngineBridge/Csa_Engine.h b/Sources/KiouEngineBridge/Csa_Engine.h new file mode 100644 index 0000000..67b0d7d --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_Engine.h @@ -0,0 +1,72 @@ +#pragma once + +#import +#import + +#import "Csa_Convert.h" + +// =========================================================================== +// Csa_Engine — CSA server-side state machine for the TCP transport. +// +// KEB acts as the CSA server (KIOU is the authoritative source of board +// state and clocks). The connecting peer is a CSA engine. The driver owns +// the post-connect handshake (LOGIN -> Game_Summary -> AGREE -> START), the +// per-move exchange (`+7776FU,T10` notifications and inbound move +// submissions), and the end-of-game signalling (`#WIN` / `#LOSE` / `#DRAW`). +// +// Symbols here are the integration surface that Hook_*.m and Tweak.m call +// into. The transport (Server_CSA.m) invokes +// `CsaEngineOnTcpClient{Connected,Disconnected}` from its accept queue, and +// passes inbound lines through `CsaEngineHandleLine` via the registered +// line handler. +// =========================================================================== + +typedef enum { + CSA_STATE_BOOT = 0, // tweak loaded, no TCP client + CSA_STATE_LOGIN = 1, // client connected, awaiting LOGIN + CSA_STATE_AGREE_WAIT = 2, // Game_Summary sent, awaiting AGREE + CSA_STATE_PLAYING = 3, // START sent, in match + CSA_STATE_GAME_OVER = 4, // match ended, waiting for next or LOGOUT +} csa_state_t; + +// Install the line handler with the CSA transport. Called once at +// constructor time from Tweak.m, after KEBCsaServerStart binds the port. +void CsaEngineInstall(void); + +// Send one CSA protocol line. Appends LF and pushes through the +// KEBCsaServerPush sink — no-op when no client is attached. +void CsaEngineSendLine(NSString *line); + +// Push a multi-line block. Each line is sent verbatim with LF appended; +// empty lines are skipped to avoid CSA parsers tripping on blank input. +void CsaEngineSendBlock(NSString *block); + +// Match lifecycle. Hook_MatchModeObserve.m calls these from the same +// dispatch_async(main_queue) block that latches local_player. +void CsaEngineOnMatchStart(int32_t local_player); + +// `result` matches Usi_Engine's enum (kept for compatibility with the +// hook macros that still use usi_match_result_t). +void CsaEngineOnMatchEnd(usi_match_result_t result); + +// Per-move observation, fired from Hook_GameStateStoreObserve.m's +// NotifyPieceMoved hook. +// `move` : KIOU Move bits +// `playerSide` : the side that just moved (0=Black, 1=White) +// `sfenAfter` : post-move SFEN read off the GameController (used to +// recover the (promoted) piece type sitting on the +// destination square — the upper-16 bits of the Move +// struct hold this but their layout is still under RE) +// `blackTimeRemainSec` / `whiteTimeRemainSec`: post-move remaining clock +// values from GameStateStore (+0x80 / +0x90 + 0x20). +// Pass -1.0f when no live clock is available for that +// side (VsAI's CPU sentinel 86400s, open-seat modes, +// etc). +void CsaEngineOnMoveObserved(uint32_t move, + int32_t playerSide, + NSString *sfenAfter, + float blackTimeRemainSec, + float whiteTimeRemainSec); + +// Convenience: read the current state for debug / log filtering. +csa_state_t CsaEngineCurrentState(void); diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m new file mode 100644 index 0000000..b9a0a46 --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -0,0 +1,866 @@ +#import "Internal.h" +#import "Csa_Engine.h" + +#import +#import + +// =========================================================================== +// Csa_Engine — CSA server-side state machine. +// +// Phase summary: +// +// KEB acts as the CSA server. The connecting TCP peer is the CSA engine. +// KIOU owns board state and clocks; KEB translates KIOU's per-match +// events into the CSA protocol (`BEGIN Game_Summary`, `+7776FU,T10`, +// `#WIN`) and translates the engine's CSA submissions (`+7776FU`, +// `%TORYO`, `LOGOUT`) back into KIOU actions (Move bits injection, +// resign API, session teardown). +// +// State machine: +// +// BOOT ── tweak loaded, no TCP client. +// ↓ TCP accept +// LOGIN ── client connected, awaiting LOGIN. Any non-LOGIN line +// is logged and dropped until LOGIN arrives. +// ↓ inbound "LOGIN " → send "LOGIN: OK" +// AGREE_WAIT[*] ── set the moment OnMatchStart fires AND we have already +// sent Game_Summary. Stays AGREE_WAIT until AGREE +// arrives, then advances to PLAYING. +// ↓ inbound "AGREE [Game_ID]" → send "START:" +// PLAYING ── per-move exchange. inbound `+7776FU` / `-3334FU` +// injects into KIOU; inbound `%TORYO` triggers the +// KIOU resign API (Task 6 — stubbed for now). +// ↓ OnMatchEnd +// GAME_OVER ── result emitted (`#WIN` / `#LOSE` / `#DRAW`). The +// transport stays open; if the engine submits another +// LOGIN / AGREE pair a new match can be played without +// reconnecting. +// +// [*] AGREE_WAIT is also entered directly from LOGIN if Game_Summary has +// not yet been sent (= no KIOU match in progress at LOGIN time): we sit in +// LOGIN until OnMatchStart fires, then send Game_Summary and roll forward. +// =========================================================================== + +// --------------------------------------------------------------------------- +// State, accessed from the recv queue and the Unity main thread; an +// _Atomic int keeps writes coherent without needing a serial gate. +// --------------------------------------------------------------------------- +static _Atomic int g_csaState = CSA_STATE_BOOT; + +// Snapshot of the seat the local KIOU player holds. -1 = open-seat mode +// (LocalPvP / RecordReplay) or no live match. +static _Atomic int g_csaLocalPlayer = -1; + +// Game_Summary cache. The engine driver isn't the source of truth for any +// of the Game_Summary fields — Csa_GameInfo.m owns the MatchConfig +// reads — but it does remember the most recently-sent payload so we can +// re-emit on reconnect mid-match. +static NSString *volatile g_csaLastGameSummary = nil; +static NSString *volatile g_csaLastGameID = nil; + +// Per-move remaining-time cache, used to derive the `,T` suffix on the +// next move notification. Values are post-move remaining seconds (float) +// pulled straight off GameStateStore (+0x80 / +0x90 + 0x20) — see +// Hook_GameStateStoreObserve::HookNotifyPieceMoved for the read site. +// +// NaN means "no value cached yet" (first move of the match, or KIOU +// declined to surface a clock for that side this match). +static float volatile g_csaLastBlackRemainSec = NAN; +static float volatile g_csaLastWhiteRemainSec = NAN; + +// Previous-move SFEN. Used to detect drops by hand-delta against the +// post-move SFEN (the KIOU Move bits don't encode the dropped piece type +// in any reverse-engineered form yet — Task 7 of the migration plan). +static NSString *volatile g_csaPrevSfen = nil; + +// Wall-clock fallback. When KIOU's per-side clock is unavailable (VsAI's +// CPU side reports 86400s "no limit," NaN on the very first move, etc.) +// we measure think time the boring way: how long ago did the *opponent* +// finish their move? That delta IS this side's think time. +// +// Indexed by player side (0=Black, 1=White). Mach absolute ticks; zero +// means "no baseline yet — wait for one more move before T can be +// computed via the fallback path." +static uint64_t volatile g_csaLastMoveMachTicks[2] = {0, 0}; + +static uint32_t csa_machTicksToSec(uint64_t ticks) { + static mach_timebase_info_data_t s_tb = {0, 0}; + if (s_tb.denom == 0) mach_timebase_info(&s_tb); + if (s_tb.denom == 0) return 0; + // ticks * numer / denom -> ns; / 1e9 -> s. + uint64_t ns = (s_tb.numer == s_tb.denom) + ? ticks + : (ticks * s_tb.numer) / s_tb.denom; + return (uint32_t)(ns / 1000000000ULL); +} + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- +static const char *csa_state_name(int s) { + switch (s) { + case CSA_STATE_BOOT: return "BOOT"; + case CSA_STATE_LOGIN: return "LOGIN"; + case CSA_STATE_AGREE_WAIT: return "AGREE_WAIT"; + case CSA_STATE_PLAYING: return "PLAYING"; + case CSA_STATE_GAME_OVER: return "GAME_OVER"; + default: return "?"; + } +} + +static void csa_set_state_impl(int newState, const char *caller) { + int old = atomic_exchange(&g_csaState, newState); + if (old != newState) { + IPALog([NSString stringWithFormat:@"[CSA-ENG] state %s -> %s (from %s)", + csa_state_name(old), csa_state_name(newState), caller]); + } +} +#define csa_set_state(s) csa_set_state_impl((s), __FUNCTION__) + +// --------------------------------------------------------------------------- +// Outbound funnel — every line that goes on the wire passes through +// CsaEngineSendLine, so the log shows the engine's view of the session. +// --------------------------------------------------------------------------- +void CsaEngineSendLine(NSString *line) { + if (line.length == 0) return; + IPALog([NSString stringWithFormat:@"[CSA>] %@", line]); + KEBCsaServerPush(line); +} + +void CsaEngineSendBlock(NSString *block) { + if (block.length == 0) return; + NSArray *lines = [block componentsSeparatedByString:@"\n"]; + for (NSString *line in lines) { + if (line.length == 0) continue; + CsaEngineSendLine(line); + } +} + +csa_state_t CsaEngineCurrentState(void) { + return (csa_state_t)atomic_load(&g_csaState); +} + +// --------------------------------------------------------------------------- +// Game_Summary delivery. Csa_GameInfo.m owns the actual block builder; +// this file just pulls the cached payload (or refuses if it hasn't been +// primed by an OnMatchStart yet). +// +// Csa_GameInfo.m is introduced in Task 5; we expose two extern declarations +// here so the linker is happy on the migration build. Until Task 5 lands, +// the Csa_Stubs.m no-op variants resolve these symbols. +// --------------------------------------------------------------------------- +extern NSString *CsaBuildGameSummary(int32_t local_player, + NSString **outGameId, + NSString **outStartSfen); +extern NSString *CsaBuildMatchResult(usi_match_result_t result); + +static void csa_send_game_summary(int32_t local_player) { + NSString *gameId = nil; + NSString *startSfen = nil; + NSString *summary = CsaBuildGameSummary(local_player, &gameId, &startSfen); + if (summary.length == 0) { + IPALog(@"[CSA-ENG] CsaBuildGameSummary returned empty — " + @"deferring Game_Summary"); + return; + } + g_csaLastGameSummary = summary; + g_csaLastGameID = gameId ?: @"GAME"; + // Cache the starting SFEN so the very first engine move can be validated + // against a real board snapshot (g_csaPrevSfen is nil until the first + // NotifyPieceMoved fires, which leaves the validator blind for move 1). + if (startSfen.length > 0) { + g_csaPrevSfen = startSfen; + IPALog([NSString stringWithFormat: + @"[CSA-ENG] seeded g_csaPrevSfen from Game_Summary sfen=%@", + startSfen]); + } + CsaEngineSendBlock(summary); + // KIOU does not wait for the engine's AGREE — its CPU starts thinking + // (and committing moves) the moment OnMatchStart fires. If we sit in + // AGREE_WAIT until the engine replies, every move the CPU makes in the + // gap is dropped by CsaEngineOnMoveObserved's state check. Send START + // immediately and advance to PLAYING so observed moves flow through. + // A later inbound AGREE in PLAYING is treated as a no-op (csa_handle_agree + // logs and drops it when the state is already PLAYING). + CsaEngineSendLine([NSString stringWithFormat:@"START:%@", + g_csaLastGameID]); + csa_set_state(CSA_STATE_PLAYING); +} + +// --------------------------------------------------------------------------- +// Inbound dispatcher. Lines arrive on the CSA recv queue (single-threaded +// per Server_CSA.m's queue setup). +// --------------------------------------------------------------------------- + +static void csa_handle_login(NSString *line) { + // Standard CSA LOGIN: "LOGIN ". Reply with "LOGIN: OK" + // regardless — KEB does not validate credentials. + NSArray *parts = [line componentsSeparatedByString:@" "]; + NSString *name = (parts.count >= 2) ? parts[1] : @"engine"; + CsaEngineSendLine([NSString stringWithFormat:@"LOGIN:%@ OK", name]); + + int32_t lp = atomic_load(&g_csaLocalPlayer); + if (lp == 0 || lp == 1) { + // We already have a KIOU match in progress — push Game_Summary + // from the main queue: SfenFromGameController dereferences + // il2cpp objects and isn't safe on the CSA recv queue this + // handler runs on. + // + // Accept LOGIN in both LOGIN and PLAYING states: the connect-time + // auto-renegotiate (CsaEngineOnTcpClientConnected) races this + // handler on the main queue, so by the time this dispatch lands + // the state may already be PLAYING. Sending Game_Summary again is + // harmless — the engine treats it as the authoritative starting + // position and discards any prior state. + int32_t lpCap = lp; + dispatch_async(dispatch_get_main_queue(), ^{ + int now = atomic_load(&g_csaState); + if (now == CSA_STATE_LOGIN || now == CSA_STATE_PLAYING) { + // Send Game_Summary regardless of whether we're in LOGIN or + // PLAYING: the match_start handler races this dispatch on the + // main queue and may have already advanced state to PLAYING. + // Resending Game_Summary + START from PLAYING is harmless — + // the engine treats the last received summary as authoritative + // and the bridge's parse_game_summary breaks on the first + // START it sees. + // Do NOT reset state to LOGIN first — that flip causes the + // next move observation to be dropped. + csa_send_game_summary(lpCap); + } else { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] LOGIN renegotiate skipped: state " + @"changed to %s before main-queue dispatch", + csa_state_name(now)]); + } + }); + } else { + // No active match yet. Hold in LOGIN; the next OnMatchStart will + // trigger Game_Summary delivery. + csa_set_state(CSA_STATE_LOGIN); + } +} + +static void csa_handle_agree(NSString *line) { + (void)line; // optional suffix is accepted but not validated + int s = atomic_load(&g_csaState); + if (s == CSA_STATE_PLAYING) { + // csa_send_game_summary already shipped START and rolled us into + // PLAYING because KIOU does not wait for AGREE. A late AGREE from + // the engine is harmless — log and drop. + IPALog(@"[CSA-ENG] AGREE in PLAYING — already started, dropping"); + return; + } + if (s != CSA_STATE_AGREE_WAIT) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] AGREE in state %s — ignoring", + csa_state_name(s)]); + return; + } + NSString *gid = g_csaLastGameID ?: @"GAME"; + CsaEngineSendLine([NSString stringWithFormat:@"START:%@", gid]); + csa_set_state(CSA_STATE_PLAYING); +} + +static void csa_handle_reject(NSString *line) { + (void)line; + IPALog(@"[CSA-ENG] engine REJECTed match — staying connected"); + NSString *gid = g_csaLastGameID ?: @"GAME"; + CsaEngineSendLine([NSString stringWithFormat:@"REJECT:%@ by engine", gid]); + csa_set_state(CSA_STATE_LOGIN); +} + +// Forward decl — implemented later in this file. +static void csa_handle_move_from_engine(NSString *line); +static void csa_handle_special(NSString *line); + +static void csa_handle_logout(void) { + CsaEngineSendLine(@"LOGOUT:completed"); + KEBCsaServerClose(); + csa_set_state(CSA_STATE_BOOT); +} + +static void csa_handle_line(NSString *line) { + NSString *trimmed = [line stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmed.length == 0) { + // CSA uses bare LF (within 30 seconds) as a liveness ping. Nothing + // to do — TCP keepalive handles dead-peer detection too. + return; + } + + if ([trimmed hasPrefix:@"LOGIN "] || [trimmed isEqualToString:@"LOGIN"]) { + csa_handle_login(trimmed); + return; + } + if ([trimmed isEqualToString:@"LOGOUT"]) { + csa_handle_logout(); + return; + } + if ([trimmed hasPrefix:@"AGREE"]) { + csa_handle_agree(trimmed); + return; + } + if ([trimmed hasPrefix:@"REJECT"]) { + csa_handle_reject(trimmed); + return; + } + if ([trimmed hasPrefix:@"%"]) { + csa_handle_special(trimmed); + return; + } + if ([trimmed hasPrefix:@"+"] || [trimmed hasPrefix:@"-"]) { + csa_handle_move_from_engine(trimmed); + return; + } + IPALog([NSString stringWithFormat: + @"[CSA-ENG] ignoring unrecognised line: %@", trimmed]); +} + +// --------------------------------------------------------------------------- +// %TORYO / %KACHI / %CHUDAN. +// --------------------------------------------------------------------------- + +// Forward decl from Inject_Resign.m. Task 6 fills in real implementations; +// until then Csa_Stubs.m supplies no-op variants. +extern void InjectResign(int32_t playerSide); +extern void InjectNyugyokuDeclaration(int32_t playerSide); + +static void csa_handle_special(NSString *line) { + int s = atomic_load(&g_csaState); + if (s != CSA_STATE_PLAYING) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] special %@ in state %s — ignoring", + line, csa_state_name(s)]); + return; + } + + int32_t lp = atomic_load(&g_csaLocalPlayer); + if ([line isEqualToString:@"%TORYO"]) { + // The CSA engine resigns — the engine IS the local KIOU player, + // so the local seat surrenders. RequestSurrender always resigns the + // local player; the outcome from the engine's view is #LOSE. + InjectResign(lp); + CsaEngineSendLine(@"#RESIGN"); + CsaEngineSendLine(@"#LOSE"); + csa_set_state(CSA_STATE_GAME_OVER); + return; + } + if ([line isEqualToString:@"%KACHI"]) { + // The engine declares nyugyoku for the local seat — local wins. + InjectNyugyokuDeclaration(lp); + CsaEngineSendLine(@"#JISHOGI"); + CsaEngineSendLine(@"#WIN"); + csa_set_state(CSA_STATE_GAME_OVER); + return; + } + if ([line isEqualToString:@"%CHUDAN"]) { + IPALog(@"[CSA-ENG] %%CHUDAN received — not surfaced to KIOU"); + CsaEngineSendLine(@"#CHUDAN"); + csa_set_state(CSA_STATE_GAME_OVER); + return; + } + IPALog([NSString stringWithFormat: + @"[CSA-ENG] unknown special: %@", line]); +} + +// --------------------------------------------------------------------------- +// Engine move handling. The engine submits its move in CSA form +// (`+7776FU` / `-0055FU` etc); we parse, derive the USI string, and feed +// the existing inject pipeline so KIOU advances exactly as if the user had +// played the move locally. +// --------------------------------------------------------------------------- + +// Translate a CSA coordinate ("77") to a USI coordinate ("7g"). Returns nil +// on malformed input. +static NSString *csa_squareToUsi(NSString *csaSq) { + if (csaSq.length != 2) return nil; + unichar f = [csaSq characterAtIndex:0]; + unichar r = [csaSq characterAtIndex:1]; + if (f < '1' || f > '9') return nil; + if (r < '1' || r > '9') return nil; + char usiFile = (char)f; + char usiRank = (char)('a' + (r - '1')); + return [NSString stringWithFormat:@"%c%c", usiFile, usiRank]; +} + +static NSString *csa_pieceToUsiDropLetter(int32_t pieceType) { + switch (pieceType) { + case 1: return @"P"; + case 2: return @"L"; + case 3: return @"N"; + case 4: return @"S"; + case 5: return @"B"; + case 6: return @"R"; + case 7: return @"G"; + default: return nil; + } +} + +// Forward decl — body below; hosts the actual validator + inject_apply +// path that the dispatcher hops onto the main queue. +static void csa_apply_engine_move(NSString *line, uint32_t move, + int32_t pieceType, int32_t playerSide); + +static void csa_handle_move_from_engine(NSString *line) { + int s = atomic_load(&g_csaState); + if (s != CSA_STATE_PLAYING) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] move in state %s — ignoring: %@", + csa_state_name(s), line]); + return; + } + + uint32_t move = 0; + int32_t pieceType = 0; + int32_t playerSide = -1; + int32_t timeSpent = -1; + if (!MoveBitsFromCsaText(line, &move, &pieceType, &playerSide, &timeSpent)) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] malformed move: %@", line]); + return; + } + (void)timeSpent; // engine-reported think time is logged but unused + + IPALog([NSString stringWithFormat: + @"[CSA-ENG-DBG] parsed line=\"%@\" move=0x%x piece=%d " + @"side=%d t=%d — about to dispatch_async(main)", + line, (unsigned)move, (int)pieceType, + (int)playerSide, (int)timeSpent]); + + // Hop to the Unity main queue: inject_apply touches il2cpp methods + // that are only safe from the main thread. The CSA recv queue this + // handler runs on isn't. + NSString *lineCap = [line copy]; + dispatch_async(dispatch_get_main_queue(), ^{ + IPALog([NSString stringWithFormat: + @"[CSA-ENG-DBG] main-queue dispatch arrived for: %@", + lineCap]); + csa_apply_engine_move(lineCap, move, pieceType, playerSide); + IPALog(@"[CSA-ENG-DBG] csa_apply_engine_move returned cleanly"); + }); + IPALog(@"[CSA-ENG-DBG] dispatch_async(main) submitted, returning"); +} + +static void csa_apply_engine_move(NSString *line, uint32_t move, + int32_t pieceType, int32_t playerSide) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG-DBG] csa_apply_engine_move entered line=%@", line]); + + // Re-check the state on the main queue — a LOGOUT / match_end could + // have landed between the recv-queue dispatch and us getting picked up. + int s = atomic_load(&g_csaState); + IPALog([NSString stringWithFormat: + @"[CSA-ENG-DBG] state on main queue=%s", csa_state_name(s)]); + if (s != CSA_STATE_PLAYING) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] move state changed to %s before main " + @"dispatch — dropping: %@", + csa_state_name(s), line]); + return; + } + uint32_t drop = (move >> 15) & 1; + uint32_t promote = (move >> 14) & 1; + uint32_t from = (move >> 7) & 0x7F; + uint32_t to = move & 0x7F; + IPALog([NSString stringWithFormat: + @"[CSA-ENG-DBG] decoded from=%u to=%u promote=%u drop=%u", + from, to, promote, drop]); + + int32_t lp = atomic_load(&g_csaLocalPlayer); + // The connected CSA engine stands in for KIOU's local human player — + // it occupies the same seat (Your_Turn maps directly to lp). On + // open-seat modes (lp == -1) we accept whatever side the engine + // claims. + int32_t enginePlayer = lp; + if (enginePlayer != -1 && playerSide != enginePlayer) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] move side mismatch: engine=%d got=%d " + @"(applying anyway)", + enginePlayer, playerSide]); + } + + // (to/from/promote/drop already extracted above so the validator + // hook can inspect them before we build the USI string.) + + // MoveBitsFromCsaText flags promote=true whenever the CSA piece + // mnemonic is a promoted form (TO/NY/NK/NG/UM/RY), but in CSA a + // promoted name on the move line carries two cases: + // + // (1) the piece was unpromoted on `from` and is promoting on this move + // (2) the piece was already promoted on `from` and is just moving + // + // We disambiguate by reading the piece sitting on `from` in the + // pre-move SFEN. If it's already promoted (PieceType 9..14), the + // move is case (2) and USI must NOT carry a trailing '+'. + if (promote && !drop) { + NSString *prev = g_csaPrevSfen; + if (prev.length > 0) { + int32_t fromPiece = PscPieceTypeAtSquare(prev, from); + if (fromPiece >= 9 && fromPiece <= 14) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] promote bit cleared (from=%u " + @"already holds promoted piece %d): %@", + from, fromPiece, line]); + promote = 0; + } + } + } + IPALog(@"[CSA-ENG-DBG] promote check done — building toUsi"); + + NSString *toCsa = CsaSquareFromMoveBits(to); + NSString *toUsi = csa_squareToUsi(toCsa); + if (!toUsi) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] bad to-square in: %@", line]); + return; + } + IPALog([NSString stringWithFormat: + @"[CSA-ENG-DBG] toCsa=%@ toUsi=%@", toCsa, toUsi]); + + // Cheap legality pre-checks. The primary goal is to keep blatantly + // illegal moves (drop on occupied, move from empty, nifu, dead-end + // drops, etc) from reaching inject_apply. + // + // We use the cached g_csaPrevSfen (captured from NotifyPieceMoved) + // rather than a live read, because the live read needs il2cpp main- + // thread guarantees that earlier attempts couldn't satisfy without + // crashing the runtime. KIOU's own validation catches the + // position-specific rules anyway; the cached SFEN is enough to + // reject the obvious categories KEB cares about (occupied drops, + // dead-end drops, nifu, piece-type mismatches). + NSString *validatorSfen = g_csaPrevSfen; + + IPALog([NSString stringWithFormat: + @"[CSA-ENG-DBG] validator sfen len=%lu — running validator", + (unsigned long)validatorSfen.length]); + + if (validatorSfen.length > 0) { + const char *reason = drop + ? ValidateCsaDrop(validatorSfen, to, pieceType, playerSide) + : ValidateCsaMove(validatorSfen, from, to, pieceType, + promote ? YES : NO, playerSide); + IPALog([NSString stringWithFormat: + @"[CSA-ENG-DBG] validator result reason=%s", + reason ?: "OK"]); + if (reason) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] rejecting illegal move (reason=%s): %@", + reason, line]); + // CSA spec lets the server emit `#ILLEGAL_MOVE` on the engine's + // submission but the existing match continues. Send the marker + // so the engine knows its move was discarded; keep PLAYING so + // the engine can submit a different move. + CsaEngineSendLine(@"#ILLEGAL_MOVE"); + return; + } + } + + NSString *usi; + if (drop) { + NSString *letter = csa_pieceToUsiDropLetter(pieceType); + if (!letter) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] cannot map drop piece %d to USI: %@", + pieceType, line]); + return; + } + usi = [NSString stringWithFormat:@"%@*%@", letter, toUsi]; + } else { + NSString *fromCsa = CsaSquareFromMoveBits(from); + NSString *fromUsi = csa_squareToUsi(fromCsa); + if (!fromUsi) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] bad from-square in: %@", line]); + return; + } + usi = promote + ? [NSString stringWithFormat:@"%@%@+", fromUsi, toUsi] + : [NSString stringWithFormat:@"%@%@", fromUsi, toUsi]; + } + + IPALog([NSString stringWithFormat: + @"[CSA-ENG-DBG] built usi=%@ — calling inject_apply", usi]); + + NSString *outSfen = nil; + NSString *outErr = nil; + uint32_t outRaw = 0; + bool ok = inject_apply(usi, &outSfen, &outRaw, &outErr); + IPALog([NSString stringWithFormat: + @"[CSA-ENG] inject_apply usi=%@ ok=%d raw=0x%x err=%@ sfen=%@", + usi, (int)ok, (unsigned)outRaw, outErr ?: @"", outSfen ?: @""]); + if (!ok) { + // inject_apply already filtered out the obvious bit-level + // parse failures (empty_from, dropfmt, etc) — KEB's own + // validators caught the rest before we got here. Anything + // that still slipped through and was rejected at the + // injection layer is, by definition, illegal: tell the + // engine so it can submit a different move instead of + // assuming the previous one stuck. + CsaEngineSendLine(@"#ILLEGAL_MOVE"); + } +} + +// --------------------------------------------------------------------------- +// Match lifecycle. +// --------------------------------------------------------------------------- + +void CsaEngineOnMatchStart(int32_t local_player) { + atomic_store(&g_csaLocalPlayer, local_player); + // Reset the per-side post-move clock cache so the first move's `,T` + // delta isn't computed against a stale previous-match value. + g_csaLastBlackRemainSec = NAN; + g_csaLastWhiteRemainSec = NAN; + // Drop the previous match's SFEN so hand-delta drop detection and + // pre-move piece lookup don't carry across matches. + g_csaPrevSfen = nil; + // Bootstrap the wall-clock baseline so the first move's T falls + // back to "match-start → first move" wall-time when the live clock is + // unavailable. We seed the BLACK side because the very first move is + // always Black's (CSA's spec, and KIOU follows it). + uint64_t startMach = mach_absolute_time(); + g_csaLastMoveMachTicks[0] = startMach; + g_csaLastMoveMachTicks[1] = startMach; + + int s = atomic_load(&g_csaState); + IPALog([NSString stringWithFormat: + @"[CSA-ENG] match_start local_player=%d state=%s", + (int)local_player, csa_state_name(s)]); + + // If a client is waiting in LOGIN state, push Game_Summary right away. + // GAME_OVER: new match started while client is still connected — send + // a fresh Game_Summary so the engine can restart. + // BOOT: no client yet — g_csaLocalPlayer is now set; LOGIN handler + // will send Game_Summary when the engine connects. + // PLAYING: engine is connected and a previous Game_Summary + START have + // already been sent. The engine will send LOGIN when it reconnects, and + // the LOGIN handler sends a fresh Game_Summary at that point. Don't send + // a second Game_Summary here — that would race with the LOGIN dispatch + // and cause a PLAYING→LOGIN→PLAYING flip-flop. + if (s == CSA_STATE_LOGIN || s == CSA_STATE_GAME_OVER) { + // Defer Game_Summary by ~500ms so scene-create / GameController + // initialization finishes first. SfenFromGameController inside + // csa_send_game_summary can otherwise block on il2cpp locks + // during scene transition, triggering 0x8badf00d watchdog kill. + int32_t lpCap = local_player; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 500 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + int now = atomic_load(&g_csaState); + if (now == CSA_STATE_LOGIN || now == CSA_STATE_GAME_OVER) { + csa_send_game_summary(lpCap); + } + }); + } +} + +void CsaEngineOnMatchEnd(usi_match_result_t result) { + int s = atomic_load(&g_csaState); + IPALog([NSString stringWithFormat: + @"[CSA-ENG] match_end result=%d state=%s", + (int)result, csa_state_name(s)]); + + // Skip if already in GAME_OVER — %TORYO / %KACHI / %CHUDAN have already + // sent the result block and advanced the state; re-emitting here would + // send contradictory or duplicate #WIN/#LOSE lines to the engine. + if (s != CSA_STATE_GAME_OVER) { + NSString *resultLines = CsaBuildMatchResult(result); + if (resultLines.length > 0) { + CsaEngineSendBlock(resultLines); + } + } + + atomic_store(&g_csaLocalPlayer, -1); + csa_set_state(CSA_STATE_GAME_OVER); +} + +void CsaEngineOnMoveObserved(uint32_t move, + int32_t playerSide, + NSString *sfenAfter, + float blackTimeRemainSec, + float whiteTimeRemainSec) { + int s = atomic_load(&g_csaState); + if (s != CSA_STATE_PLAYING && s != CSA_STATE_AGREE_WAIT) { + // Outside the per-move window — don't surface moves to the engine. + // They'd confuse the CSA state machine on the engine's side. + return; + } + + // Recover the piece type from the post-move SFEN. For drops the to + // square holds the freshly placed (unpromoted) piece; for normal moves + // it holds the (possibly-promoted) moving piece. + uint32_t to = move & 0x7F; + int32_t pscPieceType = PscPieceTypeAtSquare(sfenAfter, to); + if (pscPieceType < 0) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] cannot resolve piece type at to=%u " + @"(sfen=%@) — emitting raw bits log only", + to, sfenAfter ?: @""]); + // Even when we can't emit, advance the SFEN cache so the next + // move's validator runs against the freshest board (otherwise the + // pre-inject checks fire against a stale snapshot and let bad + // moves through). + g_csaPrevSfen = [sfenAfter copy]; + return; + } + + uint32_t promote = (move >> 14) & 1; + uint32_t dropBit = (move >> 15) & 1; + BOOL isDrop = dropBit ? YES : NO; + + // KIOU's Move bits don't always set the drop bit reliably (the upper-16 + // encoding for drops is still under RE — Task 7 of the migration plan). + // Cross-check by comparing the previous SFEN's hand-piece counts: if + // the player who just moved is missing exactly one piece in hand, the + // move was a drop and that's the dropped piece type. + int32_t handDelta = DropPieceTypeFromHandDelta(g_csaPrevSfen, sfenAfter, + playerSide); + if (!isDrop && handDelta > 0) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] drop inferred from hand delta — piece=%d " + @"(move bit said normal move)", handDelta]); + isDrop = YES; + pscPieceType = handDelta; + } + + if (isDrop) { + // Drops are always unpromoted. Use the hand-delta result when + // available (more reliable than reading the to-square's piece). + if (handDelta > 0) pscPieceType = handDelta; + } else if (promote && pscPieceType >= 9 && pscPieceType <= 14) { + // For normal promoting moves, CsaTextFromMoveBits wants the + // *unpromoted* piece type and re-promotes it for display. The + // post-move SFEN at the `to` square already holds the promoted + // piece type; downshift before handing over. + // 9 TO -> 1 FU, 10 NY -> 2 KY, 11 NK -> 3 KE, 12 NG -> 4 GI, + // 13 UM -> 5 KA, 14 RY -> 6 HI. + pscPieceType = pscPieceType - 8; + } + + // Patch the drop bit into the move uint so CsaTextFromMoveBits emits + // the canonical "+0055FU" form (from = "00") regardless of whether + // KIOU's original bits had the drop flag set. + if (isDrop) { + move = (move | (1u << 15)) & ~(1u << 14); + // Clear the from field so MoveBitsFromCsaText round-trips cleanly. + move &= ~(((uint32_t)0x7F) << 7); + } + + // Compute T in seconds for this move. Two paths feed it: + // + // (a) KIOU surfaces a live per-side clock (Online + the user's side + // in VsAI / Local) — we cache the post-move remaining-time and + // subtract the previous cached value on the next move. + // + // (b) KIOU does NOT surface a live clock (VsAI's CPU side reports + // 86400.0f "no limit," NaN on the first move) — we fall back to + // a wall-clock measurement: the time since the *opponent* + // finished their move. That delta IS this side's think time, + // because by the time NotifyPieceMoved fires for this move, + // only this side has been thinking. + // + // Path (a) wins when both are available because KIOU's internal clock + // accounts for the animation / commit latency we don't see from + // wall-clock alone. Path (b) is bootstrapped from the previous + // observed move (any side) so the very first move of the match gets + // T based on the OnMatchStart-to-first-move delta. + int32_t timeSpent = -1; + float remain = (playerSide == 0) ? blackTimeRemainSec : whiteTimeRemainSec; + float volatile *cacheSlot = (playerSide == 0) + ? &g_csaLastBlackRemainSec + : &g_csaLastWhiteRemainSec; + + uint64_t nowMach = mach_absolute_time(); + int32_t opponentSide = (playerSide == 0) ? 1 : 0; + uint64_t opponentLastMach = g_csaLastMoveMachTicks[opponentSide]; + + if (remain >= 0.0f) { + // Path (a): live clock available. + float last = *cacheSlot; + if (!isnan(last) && last >= remain) { + float delta = last - remain; + timeSpent = (int32_t)delta; // round down + } + *cacheSlot = remain; + } + if (timeSpent < 0 && opponentLastMach > 0 && nowMach > opponentLastMach) { + // Path (b): wall-clock fallback. Used either when KIOU doesn't + // surface a live clock for this side (VsAI's CPU sentinel), or + // when path (a) couldn't compute a delta (this side's first move + // of the match — no previous cached value to subtract from). + timeSpent = (int32_t)csa_machTicksToSec(nowMach - opponentLastMach); + } + + // Always update the wall-clock baseline for this side so the *next* + // side's move (typically the opponent) can compute its think time. + g_csaLastMoveMachTicks[playerSide] = nowMach; + + NSString *csa = CsaTextFromMoveBits(move, pscPieceType, playerSide, + timeSpent); + if (!csa) { + IPALog([NSString stringWithFormat: + @"[CSA-ENG] CsaTextFromMoveBits returned nil for " + @"move=0x%x piece=%d side=%d drop=%d promote=%d " + @"prevSfen=\"%@\" postSfen=\"%@\"", + (unsigned)move, (int)pscPieceType, (int)playerSide, + (int)isDrop, (int)promote, + g_csaPrevSfen ?: @"", sfenAfter ?: @""]); + // Even on emit failure, advance the SFEN snapshot so the next + // hand-delta / promote check works against the latest board. + g_csaPrevSfen = [sfenAfter copy]; + return; + } + CsaEngineSendLine(csa); + // Roll forward the prev-SFEN cache so the *next* move can do + // hand-delta drop detection and pre-move piece lookup. + g_csaPrevSfen = [sfenAfter copy]; +} + +// --------------------------------------------------------------------------- +// TCP transport callbacks. Server_CSA.m calls these from the accept queue. +// --------------------------------------------------------------------------- + +void CsaEngineOnTcpClientConnected(void) { + IPALog(@"[CSA-ENG] tcp client connected"); + csa_set_state(CSA_STATE_LOGIN); + // If a KIOU match is already in progress (reconnect mid-game, or the + // engine simply attached after the user already started a CPU match), + // auto-ship Game_Summary so the engine doesn't need to send LOGIN to + // discover the current state. + // + // CsaEngineOnTcpClientConnected runs on the CSA accept queue (a GCD + // serial queue inside Server_CSA.m). csa_send_game_summary eventually + // calls SfenFromGameController, which dereferences il2cpp objects and + // MUST run on Unity's main thread — touching them off-main crashes the + // il2cpp runtime instantly (observed on-device as a KIOU restart 16s + // after `mid-match reconnect — auto-renegotiating`). + // + // Hop the renegotiation to the main queue. The state set above is + // safe to leave on LOGIN; csa_handle_login already gates the explicit- + // LOGIN path so the engine doesn't get two Game_Summary blocks if it + // races us to the wire. + // Do NOT auto-send Game_Summary here. The CSA protocol requires the + // engine to send LOGIN first; we send Game_Summary in response to that + // (see csa_handle_login). Dispatching Game_Summary from both here and + // csa_handle_login races on the main queue and causes the LOGIN handler's + // dispatch to arrive when the state is already PLAYING (set by the + // connect-time dispatch), silently dropping the second Game_Summary. + // Letting LOGIN be the sole trigger eliminates the race entirely. +} + +void CsaEngineOnTcpClientDisconnected(void) { + IPALog(@"[CSA-ENG] tcp client disconnected"); + csa_set_state(CSA_STATE_BOOT); +} + +// --------------------------------------------------------------------------- +// Installer. Run once from Tweak.m after Server_CSA.m has bound the port. +// --------------------------------------------------------------------------- +static void csa_engine_line_handler(NSString *line) { + @autoreleasepool { + csa_handle_line(line); + } +} + +void CsaEngineInstall(void) { + KEBCsaServerSetLineHandler(csa_engine_line_handler); + IPALog(@"[CSA-ENG] installed (line handler registered)"); +} diff --git a/Sources/KiouEngineBridge/Csa_GameInfo.m b/Sources/KiouEngineBridge/Csa_GameInfo.m new file mode 100644 index 0000000..13b0a8c --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_GameInfo.m @@ -0,0 +1,334 @@ +#import "Internal.h" +#import "Csa_Convert.h" + +#import + +// =========================================================================== +// Csa_GameInfo — KIOU MatchConfig / GameStateStore -> CSA `Game_Summary` +// block + `#WIN`/`#LOSE`/`#DRAW` result block. +// +// Reads the same MatchConfig / PlayerInfo / TimeControlConfig fields the +// (now-deprecated) Meta_Emitter.m walked; the offsets and string helpers +// are duplicated here verbatim so this file can own the CSA wire format +// without depending on Meta_Emitter's JSON helpers. Once Task 5 fully +// migrates the Online race-resolution path the legacy meta path is +// retired in a follow-up commit. +// +// All il2cpp reads happen on whichever thread the caller is on; the field +// accesses below only touch raw memory via the inline il2cpp helpers, so +// they're safe to invoke from the OnMatchStart dispatch_async block. +// =========================================================================== + +// --------------------------------------------------------------------------- +// State. +// +// MatchConfig pointer captured by Hook_MatchModeObserve.m's Init macro. +// Online player-info pointers captured by Hook_GameStateStoreObserve.m's +// Set*PlayerInfo hooks (matchmaking resolves the opponent identity after +// InitializeAsync runs). All three are cleared on match end. +// --------------------------------------------------------------------------- +static void *volatile g_csaMatchConfig = NULL; +static void *volatile g_csaLatestBlackPlayerInfo = NULL; +static void *volatile g_csaLatestWhitePlayerInfo = NULL; + +// --------------------------------------------------------------------------- +// Field offsets — kept in sync with Meta_Emitter.m's META_OFF_* defines. +// --------------------------------------------------------------------------- +#define CSA_OFF_MATCHCONFIG_MODE 0x10 // MatchMode (int32) +#define CSA_OFF_MATCHCONFIG_BLACK_PLAYER 0x18 // PlayerInfo* +#define CSA_OFF_MATCHCONFIG_WHITE_PLAYER 0x20 // PlayerInfo* +#define CSA_OFF_MATCHCONFIG_TIME_CONTROL 0x28 // TimeControlConfig* +#define CSA_OFF_MATCHCONFIG_START_POSITION 0x50 // InitialPositionType (int32) + +#define CSA_OFF_PLAYERINFO_USER_ID 0x10 // string +#define CSA_OFF_PLAYERINFO_NAME 0x18 // string +#define CSA_OFF_PLAYERINFO_RANK 0x20 // string +#define CSA_OFF_PLAYERINFO_RATE 0x2C // int32 + +#define CSA_OFF_TIMECONTROL_MAIN_SECONDS 0x10 // float +#define CSA_OFF_TIMECONTROL_BYOYOMI 0x14 // float +#define CSA_OFF_TIMECONTROL_INCREMENT 0x18 // float + +// --------------------------------------------------------------------------- +// Enum helpers. +// --------------------------------------------------------------------------- +static NSString *csa_matchModeName(int32_t v) { + switch (v) { + case 0: return @"VsAI"; + case 1: return @"LocalPvP"; + case 2: return @"OnlinePvP"; + case 3: return @"RecordReplay"; + case 4: return @"Spectate"; + default: return [NSString stringWithFormat:@"Unknown(%d)", (int)v]; + } +} + +static NSString *csa_initialPositionName(int32_t v) { + switch (v) { + case 0: return @"Standard"; + case 1: return @"Empty"; + case 2: return @"HandicapLance"; + case 3: return @"HandicapRightLance"; + case 4: return @"HandicapBishop"; + case 5: return @"HandicapRook"; + case 6: return @"HandicapRookLance"; + case 7: return @"Handicap2Pieces"; + case 8: return @"Handicap4Pieces"; + case 9: return @"Handicap6Pieces"; + case 10: return @"Handicap8Pieces"; + case 11: return @"Handicap10Pieces"; + case 12: return @"TsumeShogi"; + case 13: return @"TsumeShogi2Kings"; + default: return [NSString stringWithFormat:@"Unknown(%d)", (int)v]; + } +} + +// --------------------------------------------------------------------------- +// ISO 8601 helpers + Game_ID derivation. +// --------------------------------------------------------------------------- +static NSString *csa_iso8601_now(void) { + static NSISO8601DateFormatter *fmt = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + fmt = [[NSISO8601DateFormatter alloc] init]; + fmt.formatOptions = NSISO8601DateFormatWithInternetDateTime; + }); + return [fmt stringFromDate:[NSDate date]]; +} + +// Compact timestamp suitable for embedding in a Game_ID. +static NSString *csa_compactTimestamp(void) { + static NSDateFormatter *fmt = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + fmt = [[NSDateFormatter alloc] init]; + fmt.dateFormat = @"yyyyMMdd'T'HHmmss"; + fmt.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0]; + }); + return [fmt stringFromDate:[NSDate date]]; +} + +// --------------------------------------------------------------------------- +// External entry points used by Hook_MatchModeObserve.m and +// Hook_GameStateStoreObserve.m. These mirror the MetaSetMatchConfig / +// MetaOnPlayerInfoSet API surface but feed Csa_GameInfo's own state cache. +// --------------------------------------------------------------------------- +void CsaSetMatchConfig(void *cfg) { + g_csaMatchConfig = cfg; + if (!cfg) { + // Match teardown: drop the per-match player-info pointers so the + // next match doesn't inherit a stale opponent identity. + g_csaLatestBlackPlayerInfo = NULL; + g_csaLatestWhitePlayerInfo = NULL; + } +} + +void CsaOnPlayerInfoSet(int32_t side, void *playerInfo) { + if (!playerInfo) return; + if (side == 0) { + g_csaLatestBlackPlayerInfo = playerInfo; + } else if (side == 1) { + g_csaLatestWhitePlayerInfo = playerInfo; + } +} + +// --------------------------------------------------------------------------- +// PlayerInfo + TimeControl readers — emit zero/empty defaults rather than +// throwing on a null pointer. +// --------------------------------------------------------------------------- +typedef struct { + NSString *name; + NSString *rank; + NSString *userId; + int32_t rate; +} csa_player_info_t; + +static csa_player_info_t csa_readPlayerInfo(void *playerInfo) { + csa_player_info_t out = {nil, nil, nil, 0}; + if (!playerInfo) return out; + out.name = il2cppStringToNSString(readPtr(playerInfo, + CSA_OFF_PLAYERINFO_NAME)); + out.rank = il2cppStringToNSString(readPtr(playerInfo, + CSA_OFF_PLAYERINFO_RANK)); + out.userId = il2cppStringToNSString(readPtr(playerInfo, + CSA_OFF_PLAYERINFO_USER_ID)); + out.rate = readI32(playerInfo, CSA_OFF_PLAYERINFO_RATE); + return out; +} + +typedef struct { + int32_t main_seconds; + int32_t byoyomi; + int32_t increment; +} csa_time_control_t; + +static csa_time_control_t csa_readTimeControl(void *tcc) { + csa_time_control_t out = {0, 0, 0}; + if (!tcc) return out; + float main = 0, byo = 0, inc = 0; + @try { + main = *(const float *)((const uint8_t *)tcc + + CSA_OFF_TIMECONTROL_MAIN_SECONDS); + byo = *(const float *)((const uint8_t *)tcc + + CSA_OFF_TIMECONTROL_BYOYOMI); + inc = *(const float *)((const uint8_t *)tcc + + CSA_OFF_TIMECONTROL_INCREMENT); + } @catch (NSException *e) { + return out; + } + out.main_seconds = (int32_t)main; + out.byoyomi = (int32_t)byo; + out.increment = (int32_t)inc; + return out; +} + +// --------------------------------------------------------------------------- +// `Game_Summary` builder. Mode names follow the dump.cs enum (csa_matchModeName); +// every CSA-standard field is required; KIOU_* extensions sit between the +// position block and END Game_Summary so a strict CSA parser ignores them. +// --------------------------------------------------------------------------- +NSString *CsaBuildGameSummary(int32_t local_player, + NSString **outGameId, + NSString **outStartSfen) { + void *cfg = g_csaMatchConfig; + if (!cfg) { + // No MatchConfig — without it we cannot construct a meaningful + // Game_Summary. Return nil so Csa_Engine knows to wait for a real + // OnMatchStart before sending anything. + return nil; + } + + int32_t mode = readI32(cfg, CSA_OFF_MATCHCONFIG_MODE); + int32_t startPos = readI32(cfg, CSA_OFF_MATCHCONFIG_START_POSITION); + void *blackPI = g_csaLatestBlackPlayerInfo + ?: readPtr(cfg, CSA_OFF_MATCHCONFIG_BLACK_PLAYER); + void *whitePI = g_csaLatestWhitePlayerInfo + ?: readPtr(cfg, CSA_OFF_MATCHCONFIG_WHITE_PLAYER); + void *tcc = readPtr(cfg, CSA_OFF_MATCHCONFIG_TIME_CONTROL); + + csa_player_info_t blackInfo = csa_readPlayerInfo(blackPI); + csa_player_info_t whiteInfo = csa_readPlayerInfo(whitePI); + csa_time_control_t tc = csa_readTimeControl(tcc); + + NSString *gameId = [NSString stringWithFormat:@"%@-%@", + csa_compactTimestamp(), + csa_matchModeName(mode)]; + if (outGameId) *outGameId = gameId; + + NSMutableString *out = [NSMutableString stringWithCapacity:1024]; + [out appendString:@"BEGIN Game_Summary\n"]; + [out appendString:@"Protocol_Version:1.2\n"]; + [out appendString:@"Protocol_Mode:Server\n"]; + [out appendString:@"Format:Shogi 1.0\n"]; + [out appendString:@"Declaration:Jishogi 1.1\n"]; + [out appendFormat:@"Game_ID:%@\n", gameId]; + if (blackInfo.name.length > 0) { + [out appendFormat:@"Name+:%@\n", blackInfo.name]; + } + if (whiteInfo.name.length > 0) { + [out appendFormat:@"Name-:%@\n", whiteInfo.name]; + } + // your_turn / to_move. In open-seat modes local_player == -1; CSA does + // not have a "no fixed seat" notion, so default to "+" (the engine ends + // up controlling the black side). + NSString *yourTurn = (local_player == 1) ? @"-" : @"+"; + [out appendFormat:@"Your_Turn:%@\n", yourTurn]; + // To_Move reflects the actual side-to-move in the current position, + // which may be white (-) on reconnect to a mid-game position. + // Derive from SFEN (read later); default to + for now and overwrite. + NSString *sfenForToMove = SfenFromGameController(g_gameCtrlCache); + NSString *toMove = @"+"; + if (sfenForToMove.length > 0) { + NSArray *sfenParts = [sfenForToMove componentsSeparatedByString:@" "]; + if (sfenParts.count >= 2 && [sfenParts[1] isEqualToString:@"w"]) { + toMove = @"-"; + } + } + [out appendFormat:@"To_Move:%@\n", toMove]; + + [out appendString:@"BEGIN Time\n"]; + [out appendString:@"Time_Unit:1sec\n"]; + if (tc.main_seconds > 0) { + [out appendFormat:@"Total_Time:%d\n", tc.main_seconds]; + } + if (tc.byoyomi > 0) { + [out appendFormat:@"Byoyomi:%d\n", tc.byoyomi]; + } + if (tc.increment > 0) { + [out appendFormat:@"Increment:%d\n", tc.increment]; + } + [out appendString:@"END Time\n"]; + + [out appendString:@"BEGIN Position\n"]; + NSString *sfen = SfenFromGameController(g_gameCtrlCache); + if (sfen.length > 0) { + if (outStartSfen) *outStartSfen = [sfen copy]; + NSString *csaPos = CsaPositionFromSfen(sfen); + if (csaPos.length > 0) { + [out appendString:csaPos]; + [out appendString:@"\n"]; + } + } + [out appendString:@"END Position\n"]; + + // KIOU_* extensions — non-standard CSA fields preserved for richer KIF + // metadata. A strict CSA parser is required to ignore unknown keys. + // + // KIOU_Sfen: the raw SFEN of the starting position. Redundant with + // BEGIN Position but lets CSA clients reconstruct the board without + // parsing the multi-line CSA position format. + if (sfen.length > 0) { + [out appendFormat:@"KIOU_Sfen:%@\n", sfen]; + } + [out appendFormat:@"KIOU_Mode:%@\n", csa_matchModeName(mode)]; + [out appendFormat:@"KIOU_StartPosition:%@\n", + csa_initialPositionName(startPos)]; + if (blackInfo.rank.length > 0) { + [out appendFormat:@"KIOU_Rank+:%@\n", blackInfo.rank]; + } + if (blackInfo.rate > 0) { + [out appendFormat:@"KIOU_Rate+:%d\n", blackInfo.rate]; + } + if (blackInfo.userId.length > 0) { + [out appendFormat:@"KIOU_UserId+:%@\n", blackInfo.userId]; + } + if (whiteInfo.rank.length > 0) { + [out appendFormat:@"KIOU_Rank-:%@\n", whiteInfo.rank]; + } + if (whiteInfo.rate > 0) { + [out appendFormat:@"KIOU_Rate-:%d\n", whiteInfo.rate]; + } + if (whiteInfo.userId.length > 0) { + [out appendFormat:@"KIOU_UserId-:%@\n", whiteInfo.userId]; + } + [out appendFormat:@"KIOU_StartedAt:%@\n", + csa_iso8601_now() ?: @""]; + + [out appendString:@"END Game_Summary"]; + return out; +} + +// --------------------------------------------------------------------------- +// Match result builder. +// +// CSA splits the result into a reason marker (`#RESIGN`, `#TIME_UP`, +// `#ILLEGAL_MOVE`, `#SENNICHITE`, `#JISHOGI`, ...) followed by the outcome +// (`#WIN` / `#LOSE` / `#DRAW`). KEB doesn't reliably know the reason — the +// only thing inferMatchResult() can give us is win/lose/draw/unknown — so +// we conservatively emit a generic reason and the outcome. `#WIN` / `#LOSE` +// are written from the local seat's perspective, the same way CSA's spec +// describes the broadcast to each player. +// --------------------------------------------------------------------------- +NSString *CsaBuildMatchResult(usi_match_result_t result) { + switch (result) { + case USI_RESULT_WIN: + return @"#RESIGN\n#WIN"; + case USI_RESULT_LOSE: + return @"#RESIGN\n#LOSE"; + case USI_RESULT_DRAW: + return @"#SENNICHITE\n#DRAW"; + case USI_RESULT_UNKNOWN: + default: + return nil; + } +} diff --git a/Sources/KiouEngineBridge/Hook_AfkSuppress.m b/Sources/KiouEngineBridge/Hook_AfkSuppress.m index 564f8e5..4000223 100644 --- a/Sources/KiouEngineBridge/Hook_AfkSuppress.m +++ b/Sources/KiouEngineBridge/Hook_AfkSuppress.m @@ -44,10 +44,10 @@ // 600th (= ~ once every 10 seconds at 60 fps). static uint32_t g_afkCheckCount = 0; -static bool hook_IsAfkEnabled(void *self) { +static bool HookIsAfkEnabled(void *self) { uint32_t n = ++g_afkCheckCount; if (n <= 3 || (n % 600) == 0) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[AFK] IsAfkEnabled call#%u self=%p -> returning false " @"(watchdog suppressed)", n, self]); } @@ -58,14 +58,19 @@ static bool hook_IsAfkEnabled(void *self) { return false; } -void install_AfkSuppress_hook(uintptr_t unityBase) { +#if !KIOU_BINPATCH +void InstallAfkSuppressHook(uintptr_t unityBase) { uintptr_t addr = unityBase + RVA_GAMEORCH_IS_AFK_ENABLED; MSHookFunction((void *)addr, - (void *)hook_IsAfkEnabled, + (void *)HookIsAfkEnabled, (void **)&orig_IsAfkEnabled); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[AFK] hooked GameOrchestrator.IsAfkEnabled @0x%lx " @"(base+0x%x) — AFK watchdog now permanently disabled", (unsigned long)addr, (unsigned)RVA_GAMEORCH_IS_AFK_ENABLED]); } +#endif // !KIOU_BINPATCH +// On the binpatch build, IsAfkEnabled is replaced wholesale by a +// `MOVZ W0, #0; RET` inline patch in recipes/kiouenginebridge.py (PATCHES), +// so InstallAfkSuppressHook is intentionally omitted here. diff --git a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m index 0783e21..89b2ad4 100644 --- a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m @@ -66,15 +66,15 @@ typedef UniTaskRet (*GameOrch_ActivateAsync_t)(void *self, void *setup, // --------------------------------------------------------------------------- static uint32_t g_orchSeen = 0; -static UniTaskRet hook_GameOrch_ActivateAsync(void *self, void *setup, - void *assetLoader, void *ct) { +UniTaskRet HookGameOrchActivateAsync(void *self, void *setup, + void *assetLoader, void *ct) { if (g_gameOrchestratorCache != self) g_gameOrchestratorCache = self; uint32_t n = ++g_orchSeen; // Match the seen-counter cadence used by the OPM hooks: log the first // three, then every 30th. Activation only happens at scene transitions // so we'll almost never spam, but the cap is cheap insurance. if (n <= 3 || (n % 30) == 0) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GAMEORCH] ActivateAsync call#%u self=%p setup=%p", n, self, setup]); } @@ -87,12 +87,18 @@ static UniTaskRet hook_GameOrch_ActivateAsync(void *self, void *setup, // --------------------------------------------------------------------------- // Installer. Called once from Tweak.m::installUnityHooks(). // --------------------------------------------------------------------------- -void install_GameOrchestratorObserve_hook(uintptr_t unityBase) { +#if !KIOU_BINPATCH +void InstallGameOrchestratorObserveHook(uintptr_t unityBase) { uintptr_t addr = unityBase + RVA_GAMEORCH_ACTIVATE; - MSHookFunction((void *)addr, (void *)hook_GameOrch_ActivateAsync, + MSHookFunction((void *)addr, (void *)HookGameOrchActivateAsync, (void **)&orig_GameOrch_ActivateAsync); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GAMEORCH] hooked GameOrchestrator.ActivateAsync @0x%lx " @"(base+0x%lx)", (unsigned long)addr, (unsigned long)RVA_GAMEORCH_ACTIVATE]); } +#endif // !KIOU_BINPATCH +// On the binpatch build, the static cave routes GameOrchestrator.ActivateAsync +// through the SLOT-published dispatcher (see recipes/kiouenginebridge.py +// CAVE_PATCHES entry KIOU_BR_HOOK_GAMEORCH_ACTIVATE). The dispatcher will +// invoke HookGameOrchActivateAsync once Phase E wires it up. diff --git a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m index aba199a..d1fcdcb 100644 --- a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m @@ -1,122 +1,146 @@ #import "Internal.h" -// =========================================================================== -// Hook_GameStateStoreObserve — capture Set*PlayerInfo calls on the store. -// -// Why this exists: -// On Online matches, OnlinePvPMode.InitializeAsync runs BEFORE matchmaking -// resolves the opponent identity. The MatchConfig the Init hook stashes -// at that point holds the local-side placeholder name "プレイヤー" for -// both sides — there's no useful player info there yet. -// -// The real identity arrives later via two calls on the GameStateStore: -// -// GameStateStore.SetBlackPlayerInfo(PlayerInfo) RVA 0x5A2CB64 -// GameStateStore.SetWhitePlayerInfo(PlayerInfo) RVA 0x5A2CBA0 -// -// Each writes the matchmaking-resolved PlayerInfo into the corresponding -// ReactiveProperty. We hook both, stash the PlayerInfo pointer that -// passes through, and tell Meta_Emitter that side N is now ready. -// -// Meta_Emitter implements the actual "wait for both, then emit match_start" -// policy. This file's only job is to surface the pointer. -// -// What this file deliberately doesn't do: -// - Read PlayerInfo fields here. Meta_Emitter walks them when it builds -// the JSON; we just hand over the pointer. -// - Touch the inject path. SetPlayerInfo has nothing to do with moves. -// - Hook the corresponding setters on other stores (MatchConfig has its -// own set_BlackPlayer / set_WhitePlayer at dump.cs:1418157 — those -// are early-bind for CPU matches and don't fire on Online). -// =========================================================================== - +// --------------------------------------------------------------------------- +// RVAs — shared by both JB and binpatch paths. +// --------------------------------------------------------------------------- #define RVA_GAMESTATESTORE_SET_BLACK_PLAYER_INFO 0x5A2CB64 #define RVA_GAMESTATESTORE_SET_WHITE_PLAYER_INFO 0x5A2CBA0 -// NotifyPieceMoved は自分手・相手手どちらの apply 時も通る GameStateStore -// 上のチョークポイント。ADAPTER2 (Hook_LowLevelObserve.m) は自分手しか -// 通らないので、meta_emit_move の発火点はこちらに集約する。 #define RVA_GAMESTATESTORE_NOTIFY_PIECE_MOVED 0x5A2CD24 // --------------------------------------------------------------------------- -// Original (untrampolined) function pointers — chain through after stashing. -// SetPlayerInfo signatures are simple instance methods returning void with -// one PlayerInfo* argument, so no UniTask gymnastics needed. +// Trampoline pointer types — declared here so they can be used in the shared +// hook bodies below. The definitions (and their initialisers) live in the +// JB-only #else block; on binpatch the cave handles orig-chaining, so +// these pointers are never needed and not defined. // --------------------------------------------------------------------------- typedef void (*SetPlayerInfo_t)(void *self, void *playerInfo); -static SetPlayerInfo_t orig_SetBlackPlayerInfo = NULL; -static SetPlayerInfo_t orig_SetWhitePlayerInfo = NULL; +typedef void (*GState_NotifyPieceMoved_t)(void *self, uint32_t move, + int32_t playerSide); -// --------------------------------------------------------------------------- -// Hook bodies. Both share the same shape; the side argument to -// meta_on_player_info_set tells Meta_Emitter which slot was just written. -// --------------------------------------------------------------------------- -static void hook_SetBlackPlayerInfo(void *self, void *playerInfo) { - meta_on_player_info_set(/*side=*/0, playerInfo); +#if !KIOU_BINPATCH +// Defined (zero-initialised) in the JB installer section below. +extern SetPlayerInfo_t orig_SetBlackPlayerInfo; +extern SetPlayerInfo_t orig_SetWhitePlayerInfo; +extern GState_NotifyPieceMoved_t orig_NotifyPieceMoved; +#endif + +// =========================================================================== +// Hook bodies — compiled for BOTH JB and binpatch. +// +// On JB: MSHookFunction wires these as the replacement function and +// populates orig_* so the body can chain through. +// On binpatch: the cave dispatcher calls HookGState* (declared in Internal.h) +// directly; orig-chaining is handled by the cave's displaced +// prologue + `B orig+4`, so we never call orig_* here. +// =========================================================================== + +void HookGStateSetBlackPlayerInfo(void *self, void *playerInfo) { + MetaOnPlayerInfoSet(/*side=*/0, playerInfo); + CsaOnPlayerInfoSet(/*side=*/0, playerInfo); +#if !KIOU_BINPATCH if (orig_SetBlackPlayerInfo) orig_SetBlackPlayerInfo(self, playerInfo); +#else + (void)self; +#endif } -static void hook_SetWhitePlayerInfo(void *self, void *playerInfo) { - meta_on_player_info_set(/*side=*/1, playerInfo); +void HookGStateSetWhitePlayerInfo(void *self, void *playerInfo) { + MetaOnPlayerInfoSet(/*side=*/1, playerInfo); + CsaOnPlayerInfoSet(/*side=*/1, playerInfo); +#if !KIOU_BINPATCH if (orig_SetWhitePlayerInfo) orig_SetWhitePlayerInfo(self, playerInfo); +#else + (void)self; +#endif } -// --------------------------------------------------------------------------- -// GameStateStore.NotifyPieceMoved(Sunfish.Move move, PlayerSide playerSide) +// GameStateStore.NotifyPieceMoved(Move move, PlayerSide playerSide) // // arm64 ABI: // x0 = self (GameStateStore) // w1 = move (uint32, Sunfish.Move packed bits) // w2 = playerSide (int32, the side that just moved: 0=Black, 1=White) // -// 自分のクライアントが指したとき (ADAPTER2 経由) も、サーバから相手手の -// state 更新が降ってきたときも、最終的にこの NotifyPieceMoved を通る。 -// したがってここで meta_emit_move を 1 度だけ発火すれば、片肺 KIF -// 問題は解消する。meta_emit_move は引数として「次の手番」を受け取る -// 設計なので、API は崩さずに `playerSide == 0 ? 1 : 0` で flip して渡す。 -// --------------------------------------------------------------------------- -typedef void (*GameStateStore_NotifyPieceMoved_t)(void *self, - uint32_t move, - int32_t playerSide); -static GameStateStore_NotifyPieceMoved_t orig_NotifyPieceMoved = NULL; - -static void hook_NotifyPieceMoved(void *self, - uint32_t move, - int32_t playerSide) { - // Call original FIRST so the GameController applies the move and the - // live SFEN is in its post-move state when we read it back. Inject_Move - // also relies on this same ordering (NotifyPieceMoved → ApplyImpl), so - // we keep the original side-effect chain intact. +// Both the local client's own moves (ADAPTER2 path) and incoming opponent +// moves (server state update path) pass through this chokepoint, making it +// the single authoritative site for move observation — both for the CSA +// engine driver (CsaEngineOnMoveObserved) and for the legacy meta sidecar +// (MetaEmitMove, a no-op on binpatch). +// +// On JB: orig is called first so the GameController has already applied the +// move and the live SFEN is post-move by the time we read it back. +// On binpatch: the cave runs the displaced prologue (which is the orig +// instruction) before calling the dispatcher, so the same ordering holds. +void HookGStateNotifyPieceMoved(void *self, uint32_t move, int32_t playerSide) { +#if !KIOU_BINPATCH if (orig_NotifyPieceMoved) orig_NotifyPieceMoved(self, move, playerSide); +#endif - // sfen は g_gameCtrlCache 経由で読む。ADAPTER2 が live セッションで - // 1 度でも走っていれば NULL ではない。相手手側 (ADAPTER2 を通らない) - // でも、初手以降は同じ GameController インスタンスが使い回されるので - // キャッシュは有効。 - NSString *sfen = sfenFromGameController(g_gameCtrlCache); + NSString *sfen = SfenFromGameController(g_gameCtrlCache); NSString *usi = moveToUsi((SfMove)move); - // playerSide はこの NotifyPieceMoved 呼び出しの「指した側」。 - // meta_emit_move は「次の手番」を引数に取るので flip して渡す。 - int32_t nextSide = (playerSide == 0) ? 1 - : (playerSide == 1) ? 0 - : -1; - file_log([NSString stringWithFormat: + // GameStateStore keeps two ReactiveProperty clocks at offsets + // 0x80 (black) / 0x90 (white). The R3/UniRx box stores the current + // value at +0x20. Online matches keep these in sync with the server; + // VsAI / Local use them for the on-screen clock. Treat ≥ 86340 s + // (= 24 h − 60 s) as "no limit" and pass -1 so CSA omits the T field. + float blackRemain = -1.0f; + float whiteRemain = -1.0f; + { + void *bRP = readPtr(self, 0x80); + void *wRP = readPtr(self, 0x90); + if (bRP) { + float v = *(const float *)((const uint8_t *)bRP + 0x20); + if (v > 0.0f && v < 86340.0f) blackRemain = v; + } + if (wRP) { + float v = *(const float *)((const uint8_t *)wRP + 0x20); + if (v > 0.0f && v < 86340.0f) whiteRemain = v; + } + } + + IPALog([NSString stringWithFormat: @"[GSTATE-MOVE] NotifyPieceMoved self=%p moved_side=%d " @"usi=\"%@\" sfen=\"%@\"", self, (int)playerSide, usi ?: @"", sfen ?: @""]); - meta_emit_move(usi, sfen, nextSide); + + // MetaEmitMove is a no-op on binpatch (Meta_Emitter is dropped). + int32_t nextSide = (playerSide == 0) ? 1 : (playerSide == 1) ? 0 : -1; + MetaEmitMove(usi, sfen, nextSide); + + // CSA engine driver: raw move bits + post-move SFEN + remaining clocks + // → ships a `+7776FU,T10`-style notification to the connected engine. + CsaEngineOnMoveObserved((uint32_t)move, playerSide, sfen, + blackRemain, whiteRemain); } -// --------------------------------------------------------------------------- -// Installer. Called once from Tweak.m::installUnityHooks(). -// --------------------------------------------------------------------------- -void install_GameStateStoreObserve_hook(uintptr_t unityBase) { +// =========================================================================== +// Build-flavour-specific: installer + MetaOnPlayerInfoSet stub. +// =========================================================================== + +#if KIOU_BINPATCH + +// Cave dispatcher wires HookGState* at patch time; no runtime installation +// needed. MetaOnPlayerInfoSet is a no-op because Meta_Emitter is dropped on +// the binpatch build. +void InstallGameStateStoreObserveHook(uintptr_t unityBase) { (void)unityBase; } +void MetaOnPlayerInfoSet(int32_t side, void *playerInfo) { + (void)side; (void)playerInfo; +} + +#else // !KIOU_BINPATCH — JB / rootless build + +SetPlayerInfo_t orig_SetBlackPlayerInfo = NULL; +SetPlayerInfo_t orig_SetWhitePlayerInfo = NULL; +GState_NotifyPieceMoved_t orig_NotifyPieceMoved = NULL; + +void InstallGameStateStoreObserveHook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_GAMESTATESTORE_SET_BLACK_PLAYER_INFO; MSHookFunction((void *)addr, - (void *)hook_SetBlackPlayerInfo, + (void *)HookGStateSetBlackPlayerInfo, (void **)&orig_SetBlackPlayerInfo); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.SetBlackPlayerInfo " @"@0x%lx (base+0x%x)", (unsigned long)addr, @@ -125,9 +149,9 @@ void install_GameStateStoreObserve_hook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_GAMESTATESTORE_SET_WHITE_PLAYER_INFO; MSHookFunction((void *)addr, - (void *)hook_SetWhitePlayerInfo, + (void *)HookGStateSetWhitePlayerInfo, (void **)&orig_SetWhitePlayerInfo); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.SetWhitePlayerInfo " @"@0x%lx (base+0x%x)", (unsigned long)addr, @@ -136,12 +160,14 @@ void install_GameStateStoreObserve_hook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_GAMESTATESTORE_NOTIFY_PIECE_MOVED; MSHookFunction((void *)addr, - (void *)hook_NotifyPieceMoved, + (void *)HookGStateNotifyPieceMoved, (void **)&orig_NotifyPieceMoved); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.NotifyPieceMoved " @"@0x%lx (base+0x%x)", (unsigned long)addr, (unsigned)RVA_GAMESTATESTORE_NOTIFY_PIECE_MOVED]); } } + +#endif // !KIOU_BINPATCH diff --git a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m index 8488f96..f4d1b5d 100644 --- a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m +++ b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m @@ -92,7 +92,7 @@ // Exported (declared in Internal.h) so Hook_GameStateStoreObserve.m can // read the live SFEN after NotifyPieceMoved without re-implementing the // PositionHistory walk. -NSString *sfenFromGameController(void *gameCtrl) { +NSString *SfenFromGameController(void *gameCtrl) { if (!g_Position_ToSFEN) return nil; void *pos = latestPositionFromGameController(gameCtrl); if (!pos) return nil; @@ -112,7 +112,7 @@ // // Exported (declared in Internal.h) so Meta_Emitter.m can pull the // snapshot right before it ships match_end. -NSString *usiTextFromGameController(void *gameCtrl) { +NSString *UsiTextFromGameController(void *gameCtrl) { if (!g_GameCtrl_GetUSIText) return nil; if (!ptrLooksValid(gameCtrl)) return nil; @try { @@ -200,7 +200,7 @@ // --------------------------------------------------------------------------- // ShogiGameAdapter.TryMakeMove(Move, out Move) // --------------------------------------------------------------------------- -static bool hook_AdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { +bool HookAdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { // Update the injection-side cache before the original runs. Order matters: // we want g_adapterCache to be non-NULL as soon as anything goes through // this path, and we want g_gameCtrlCache to point at the same Adapter's @@ -211,50 +211,47 @@ static bool hook_AdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { if (gcSeen && g_gameCtrlCache != gcSeen) g_gameCtrlCache = gcSeen; g_lastAdapterEvtUs = mach_absolute_time(); - bool ok = orig_AdapterTryMakeMoveOut - ? orig_AdapterTryMakeMoveOut(self, move, outMove) : false; - void *gameCtrl = readPtr(self, ADAPTER_OFF_GAME_CONTROLLER); - NSString *sfen = sfenFromGameController(gameCtrl); - SfMove executed = 0; - if (ptrLooksValid(outMove)) { - executed = (SfMove)(uint32_t)readI32(outMove, 0); - } - NSString *usi = moveToUsi(move); - file_log([NSString stringWithFormat: - @"[ADAPTER2] TryMakeMove self=%p ok=%d " - @"usi=\"%@\" argMove={%@} outMove=0x%x sfen_after=\"%@\"", - self, (int)ok, usi ?: @"", describeMoveBits(move), - (unsigned)executed, sfen ?: @""]); + // Run the original synchronously on the JB build (no-op on binpatch + // because the cave handles orig via the displaced prologue + B orig+4). + // The return value flows back to the caller verbatim — KIOU's caller + // checks it to know whether the move actually committed. + bool ok = KIOU_CALL_ORIG_RET(bool, orig_AdapterTryMakeMoveOut, + self, move, outMove); + + // orig has completed by this point on JB (synchronous call above) and + // will complete on binpatch before the deferred block fires (the cave's + // `B orig+4` lands inside the current main-runloop iteration). Either + // way the dispatched block observes the post-move PositionHistory. + // + // outMove cannot be read from inside the deferred block: the caller's + // stack frame is gone by then. Instead we copy `move` by value (it's a + // packed uint32_t) and reconstruct the USI string from it via moveToUsi + // inside the block. The post-move SFEN walks PositionHistory[size-1] + // off the cached GameController, which is post-orig truth regardless of + // outMove. + void *selfCap = self; + uint32_t mv_copy = (uint32_t)move; + dispatch_async(dispatch_get_main_queue(), ^{ + void *gameCtrl = readPtr(selfCap, ADAPTER_OFF_GAME_CONTROLLER); + NSString *sfen = SfenFromGameController(gameCtrl); + NSString *usi = moveToUsi((SfMove)mv_copy); + IPALog([NSString stringWithFormat: + @"[ADAPTER2] TryMakeMove self=%p " + @"usi=\"%@\" argMove={%@} sfen_after=\"%@\"", + selfCap, usi ?: @"", describeMoveBits((SfMove)mv_copy), + sfen ?: @""]); + + // Move observation is handled by Hook_GameStateStoreObserve.m's + // NotifyPieceMoved hook, which covers both sides. Nothing to do here. + }); - // Phase 2: forward the observation to the USI engine driver. It decides - // whether the next side to move is ours (= time to ask YaneuraOu for a - // bestmove) or the opponent's (= just sit and wait). - if (ok && sfen) { - // Pull `side_to_move` straight from the SFEN's "b"/"w" token rather - // than reach into Position internals — keeps this hook lean and - // avoids the il2cpp call back into Position fields from the Unity - // thread we're already on. - int32_t sideToMove = -1; - NSArray *parts = [sfen componentsSeparatedByString:@" "]; - if (parts.count >= 2) { - NSString *side = parts[1]; - if ([side isEqualToString:@"b"]) sideToMove = 0; - else if ([side isEqualToString:@"w"]) sideToMove = 1; - } - usi_engine_on_move_observed(usi, sfen, sideToMove); - // meta_emit_move はここでは出さない — ADAPTER2 は「自分のクライアント - // が指した手」しか通らない (オンライン対戦では相手手はサーバ state - // 経由で来る) ので、ここで出すと自分側 ply のみの片肺 KIF になる。 - // 両手番分の meta は Hook_GameStateStoreObserve.m の - // NotifyPieceMoved フックに集約してある。 - } return ok; } // --------------------------------------------------------------------------- // Project.ShogiCore.GameController.TryMakeMove(Move) // --------------------------------------------------------------------------- -static bool hook_GameCtrlTryMakeMove(void *self, SfMove move) { +static bool HookGameCtrlTryMakeMove(void *self, SfMove move) { // Cache the GameController self pointer for the injection path. We don't // know an Adapter from here, so leave g_adapterCache alone — Inject_Move // can fall back to gamectrl-only routing if it never saw an adapter. @@ -263,9 +260,9 @@ static bool hook_GameCtrlTryMakeMove(void *self, SfMove move) { bool ok = orig_GameCtrlTryMakeMove ? orig_GameCtrlTryMakeMove(self, move) : false; - NSString *sfen = sfenFromGameController(self); + NSString *sfen = SfenFromGameController(self); NSString *usi = moveToUsi(move); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GAMECTRL] TryMakeMove self=%p ok=%d usi=\"%@\" move={%@} sfen_after=\"%@\"", self, (int)ok, usi ?: @"", describeMoveBits(move), sfen ?: @""]); // Phase 2 deliberately does NOT notify the USI engine from here — @@ -275,10 +272,14 @@ static bool hook_GameCtrlTryMakeMove(void *self, SfMove move) { } // --------------------------------------------------------------------------- -// Installer. Wires the three hooks plus the NativeFunction-style trampolines -// we use to call ToSFEN / GetUSIText from within them. +// Installer. Resolves NativeFunction-style trampolines (ToSFEN / GetUSIText / +// Move.ToStringSFEN) — needed by BOTH builds, because Inject_Move and the +// observation hooks both call them as function pointers. Then, on the JB +// build only, installs the two MSHookFunction site hooks; on the binpatch +// build the symbol-pointer resolves remain but the hook wires are +// orchestrated by the static cave + SLOT dispatcher. // --------------------------------------------------------------------------- -void install_LowLevelObserve_hook(uintptr_t unityBase) { +void InstallLowLevelObserveHook(uintptr_t unityBase) { g_Position_ToSFEN = (Position_ToSFEN_t)(void *)(unityBase + RVA_POSITION_TO_SFEN); g_GameCtrl_GetUSIText = @@ -286,12 +287,13 @@ void install_LowLevelObserve_hook(uintptr_t unityBase) { g_Move_ToStringSFEN = (Move_ToStringSFEN_t)(void *)(unityBase + RVA_SUNFISH_MOVE_TO_STRING_SFEN); +#if !KIOU_BINPATCH { uintptr_t addr = unityBase + RVA_ADAPTER_TRY_MAKE_MOVE_OUT; MSHookFunction((void *)addr, - (void *)hook_AdapterTryMakeMoveOut, + (void *)HookAdapterTryMakeMoveOut, (void **)&orig_AdapterTryMakeMoveOut); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[LOWLEVEL] hooked ShogiGameAdapter.TryMakeMove(Move,out) " @"@0x%lx (base+0x%x)", (unsigned long)addr, @@ -300,11 +302,24 @@ void install_LowLevelObserve_hook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_GAMECTRL_TRY_MAKE_MOVE; MSHookFunction((void *)addr, - (void *)hook_GameCtrlTryMakeMove, + (void *)HookGameCtrlTryMakeMove, (void **)&orig_GameCtrlTryMakeMove); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[LOWLEVEL] hooked GameController.TryMakeMove " @"@0x%lx (base+0x%x)", (unsigned long)addr, (unsigned)RVA_GAMECTRL_TRY_MAKE_MOVE]); } +#else + // On binpatch: + // ShogiGameAdapter.TryMakeMove(Move,out) → routed via cave entry + // KIOU_BR_HOOK_ADAPTER_TRY_MAKE_MOVE_OUT in + // recipes/kiouenginebridge.py. + // GameController.TryMakeMove(Move) → NOT in CAVE_PATCHES. It was a + // log-only hook on the JB build, and its observation is fully + // covered by HookAdapterTryMakeMoveOut for every move that + // reaches the board. Dropping it on binpatch saves a cave and + // keeps the hook surface tight. + IPALog(@"[LOWLEVEL] binpatch build — site hooks driven by cave/SLOT, " + @"symbol pointers resolved."); +#endif // !KIOU_BINPATCH } diff --git a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m index 62c5fe7..916b989 100644 --- a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m +++ b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m @@ -1,6 +1,7 @@ #import "Internal.h" #import +#import // =========================================================================== // Hook_MatchModeObserve — capture every IMatchMode.OnPlayerMoveAsync entry. @@ -113,28 +114,41 @@ // convention doesn't matter. typedef UniTaskRet (*InitializeAsync_t)(void *self, void *cfg, void *stateStore, void *gameAdapter, void *ct); -static InitializeAsync_t orig_AI_Init = NULL; -static InitializeAsync_t orig_CPUStream_Init = NULL; -static InitializeAsync_t orig_Local_Init = NULL; -static InitializeAsync_t orig_Online_Init = NULL; -static InitializeAsync_t orig_Replay_Init = NULL; +// `__attribute__((unused))` suppresses -Wunused-variable on the binpatch +// build where KIOU_CALL_ORIG_RET expands to ((UniTaskRet){0}) and never +// evaluates the pointer argument. +static InitializeAsync_t orig_AI_Init __attribute__((unused)) = NULL; +static InitializeAsync_t orig_CPUStream_Init __attribute__((unused)) = NULL; +static InitializeAsync_t orig_Local_Init __attribute__((unused)) = NULL; +static InitializeAsync_t orig_Online_Init __attribute__((unused)) = NULL; +static InitializeAsync_t orig_Replay_Init __attribute__((unused)) = NULL; // OnMatchEndAsync(CT) -> UniTask. Same shape as OnPlayerMoveAsync but // without the Move argument. typedef UniTaskRet (*OnMatchEndAsync_t)(void *self, void *ct); -static OnMatchEndAsync_t orig_AI_End = NULL; -static OnMatchEndAsync_t orig_CPUStream_End = NULL; -static OnMatchEndAsync_t orig_Local_End = NULL; -static OnMatchEndAsync_t orig_Online_End = NULL; -static OnMatchEndAsync_t orig_Replay_End = NULL; +static OnMatchEndAsync_t orig_AI_End __attribute__((unused)) = NULL; +static OnMatchEndAsync_t orig_CPUStream_End __attribute__((unused)) = NULL; +static OnMatchEndAsync_t orig_Local_End __attribute__((unused)) = NULL; +static OnMatchEndAsync_t orig_Online_End __attribute__((unused)) = NULL; +static OnMatchEndAsync_t orig_Replay_End __attribute__((unused)) = NULL; // OnMatchStart() -> void. Truly synchronous; no UniTask gymnastics. +// +// __attribute__((unused)) is needed because on the binpatch build the +// KIOU_CALL_ORIG_VOID(ORIG_VAR, self) inside DEFINE_START_HOOK expands to +// ((void)0), leaving these five `orig_*_Start` storage slots unreferenced. +// MSHookFunction's installer writes through their addresses on the JB +// build (the `(void **)&orig_..._Start` argument in the entries[] table), +// so they're not actually unused at runtime in that flavour — and on +// binpatch they're simply spare slots. `((unused))` tells -Werror to stay +// quiet for both shapes without forcing us to gate the declarations +// themselves with #if. typedef void (*OnMatchStart_t)(void *self); -static OnMatchStart_t orig_AI_Start = NULL; -static OnMatchStart_t orig_CPUStream_Start = NULL; -static OnMatchStart_t orig_Local_Start = NULL; -static OnMatchStart_t orig_Online_Start = NULL; -static OnMatchStart_t orig_Replay_Start = NULL; +static OnMatchStart_t orig_AI_Start __attribute__((unused)) = NULL; +static OnMatchStart_t orig_CPUStream_Start __attribute__((unused)) = NULL; +static OnMatchStart_t orig_Local_Start __attribute__((unused)) = NULL; +static OnMatchStart_t orig_Online_Start __attribute__((unused)) = NULL; +static OnMatchStart_t orig_Replay_Start __attribute__((unused)) = NULL; // First-touch logging counters so we don't spam the log file every move. // Log the first three calls per mode and every 30th after that. @@ -155,33 +169,37 @@ static inline BOOL shouldLog(uint32_t n) { // to return void here would corrupt the caller's await frame. // --------------------------------------------------------------------------- -#define DEFINE_OPM_HOOK(MODE_LOWER, MODE_TAG, CACHE_VAR, TS_VAR, SEEN_VAR, ORIG_VAR) \ - static UniTaskRet hook_##MODE_LOWER##_OPM(void *self, uint32_t mv, void *ct) { \ +#define DEFINE_OPM_HOOK(MODE_PASCAL, MODE_TAG, CACHE_VAR, TS_VAR, SEEN_VAR, ORIG_VAR) \ + UniTaskRet Hook##MODE_PASCAL##Opm(void *self, uint32_t mv, void *ct) { \ if ((CACHE_VAR) != self) (CACHE_VAR) = self; \ (TS_VAR) = mach_absolute_time(); \ uint32_t n = ++(SEEN_VAR); \ if (shouldLog(n)) { \ - file_log([NSString stringWithFormat: \ + IPALog([NSString stringWithFormat: \ @"[MMODE] " MODE_TAG " OPM call#%u self=%p move=0x%x", \ n, self, (unsigned)mv]); \ } \ - if (ORIG_VAR) return (ORIG_VAR)(self, mv, ct); \ - return (UniTaskRet){ NULL, NULL }; \ + /* On binpatch KIOU_CALL_ORIG_RET is a no-op: the cave runs orig via */ \ + /* the displaced prologue + `B orig+4` after the dispatcher returns. */ \ + /* On JB the trampoline installed by MSHookFunction is in ORIG_VAR. */ \ + /* Never call ORIG_VAR directly on binpatch: it points at the patched */ \ + /* instruction (now `B `), which would recurse infinitely. */ \ + return KIOU_CALL_ORIG_RET(UniTaskRet, ORIG_VAR, self, mv, ct); \ } -DEFINE_OPM_HOOK(ai, "AIMatchMode", g_aiMatchModeCache, +DEFINE_OPM_HOOK(Ai, "AIMatchMode", g_aiMatchModeCache, g_lastAiMatchEvtUs, g_aiSeen, orig_AIMatchMode_OnPlayerMoveAsync) -DEFINE_OPM_HOOK(cpustream, "CPUStreamMode", g_cpuStreamModeCache, +DEFINE_OPM_HOOK(CpuStream, "CPUStreamMode", g_cpuStreamModeCache, g_lastCpuStreamEvtUs, g_cpuStreamSeen, orig_CPUStreamMode_OnPlayerMoveAsync) -DEFINE_OPM_HOOK(local, "LocalPvPMode", g_localPvPModeCache, +DEFINE_OPM_HOOK(Local, "LocalPvPMode", g_localPvPModeCache, g_lastLocalPvPEvtUs, g_localPvPSeen, orig_LocalPvPMode_OnPlayerMoveAsync) -DEFINE_OPM_HOOK(online, "OnlinePvPMode", g_onlineModeCache, +DEFINE_OPM_HOOK(Online, "OnlinePvPMode", g_onlineModeCache, g_lastOnlineEvtUs, g_onlinePMSeen, orig_OnlinePvPMode_OnPlayerMoveAsync) -DEFINE_OPM_HOOK(replay, "RecordReplayMode", g_recordReplayModeCache, +DEFINE_OPM_HOOK(Replay, "RecordReplayMode", g_recordReplayModeCache, g_lastRecordReplayEvtUs, g_recordReplaySeen, orig_RecordReplayMode_OnPlayerMoveAsync) @@ -201,10 +219,10 @@ static inline BOOL shouldLog(uint32_t n) { // dump. // --------------------------------------------------------------------------- -#define DEFINE_INIT_HOOK(MODE_LOWER, MODE_TAG, CACHE_VAR, ORIG_VAR) \ - static UniTaskRet hook_##MODE_LOWER##_Init(void *self, void *cfg, \ - void *store, void *adapter, \ - void *ct) { \ +#define DEFINE_INIT_HOOK(MODE_PASCAL, MODE_TAG, CACHE_VAR, ORIG_VAR) \ + UniTaskRet Hook##MODE_PASCAL##Init(void *self, void *cfg, \ + void *store, void *adapter, \ + void *ct) { \ if ((CACHE_VAR) != self) (CACHE_VAR) = self; \ /* Capture the adapter the moment IMatchMode.InitializeAsync hands */ \ /* it over. Without this, g_adapterCache stays NULL until the first */ \ @@ -216,15 +234,18 @@ static inline BOOL shouldLog(uint32_t n) { void *gc = readPtr(adapter, KIOU_ADAPTER_OFF_GAME_CONTROLLER); \ if (gc && g_gameCtrlCache != gc) g_gameCtrlCache = gc; \ } \ - /* Stash MatchConfig so Meta_Emitter can read player names, time */ \ - /* control, and mode at match_start time. The cfg pointer is stable */ \ - /* for the lifetime of the match — il2cpp's Boehm GC won't move it, */ \ - /* and IMatchMode keeps a strong ref through _matchConfig (Online) */ \ - /* or via the captured arg itself (the simpler modes). */ \ - meta_set_match_config(cfg); \ - UniTaskRet ret = { NULL, NULL }; \ - if (ORIG_VAR) ret = (ORIG_VAR)(self, cfg, store, adapter, ct); \ - file_log([NSString stringWithFormat: \ + /* Stash MatchConfig so Csa_GameInfo (and the legacy Meta_Emitter) */ \ + /* can read player names, time control, and mode at match_start time. */ \ + /* The cfg pointer is stable for the lifetime of the match — il2cpp's */ \ + /* Boehm GC won't move it, and IMatchMode keeps a strong ref through */ \ + /* _matchConfig (Online) or via the captured arg itself (the simpler */ \ + /* modes). */ \ + MetaSetMatchConfig(cfg); \ + CsaSetMatchConfig(cfg); \ + /* On binpatch KIOU_CALL_ORIG_RET is a no-op (cave handles orig). */ \ + UniTaskRet ret = \ + KIOU_CALL_ORIG_RET(UniTaskRet, ORIG_VAR, self, cfg, store, adapter, ct); \ + IPALog([NSString stringWithFormat: \ @"[MMODE] " MODE_TAG " Init self=%p store=%p adapter=%p cfg=%p", \ self, store, adapter, cfg]); \ return ret; \ @@ -233,15 +254,15 @@ static inline BOOL shouldLog(uint32_t n) { // Init only caches the self pointer — `_localPlayer` lives behind an // async assignment we can't see synchronously, so it gets read in // OnMatchStart below. -DEFINE_INIT_HOOK(ai, "AIMatchMode", g_aiMatchModeCache, +DEFINE_INIT_HOOK(Ai, "AIMatchMode", g_aiMatchModeCache, orig_AI_Init) -DEFINE_INIT_HOOK(cpustream, "CPUStreamMode", g_cpuStreamModeCache, +DEFINE_INIT_HOOK(CpuStream, "CPUStreamMode", g_cpuStreamModeCache, orig_CPUStream_Init) -DEFINE_INIT_HOOK(local, "LocalPvPMode", g_localPvPModeCache, +DEFINE_INIT_HOOK(Local, "LocalPvPMode", g_localPvPModeCache, orig_Local_Init) -DEFINE_INIT_HOOK(online, "OnlinePvPMode", g_onlineModeCache, +DEFINE_INIT_HOOK(Online, "OnlinePvPMode", g_onlineModeCache, orig_Online_Init) -DEFINE_INIT_HOOK(replay, "RecordReplayMode", g_recordReplayModeCache, +DEFINE_INIT_HOOK(Replay, "RecordReplayMode", g_recordReplayModeCache, orig_Replay_Init) #undef DEFINE_INIT_HOOK @@ -258,45 +279,58 @@ static inline BOOL shouldLog(uint32_t n) { // we just call through. // --------------------------------------------------------------------------- -#define DEFINE_START_HOOK(MODE_LOWER, MODE_TAG, CACHE_VAR, LP_CACHE, LP_OFFSET, ORIG_VAR) \ - static void hook_##MODE_LOWER##_Start(void *self) { \ +// The hook body uses KIOU_CALL_ORIG_VOID to run orig before the deferred +// block — on the JB build that drives the real OnMatchStart synchronously +// (without it, MSHookFunction would replace the function wholesale); on the +// binpatch build it expands to (void)0 because the cave already runs the +// displaced prologue + `B orig+4` outside this function. Either way orig is +// guaranteed to have run by the time the dispatched block fires on the next +// main-runloop spin, so `_localPlayer` is populated when the block reads it. +// +// We capture `self` and `lp_offset` by value into the block (both are POD — +// `self` is a void *, no ARC retain). The LP_CACHE / IPALog / usi / +// meta calls all happen inside the deferred block so they observe the +// post-orig state. +#define DEFINE_START_HOOK(MODE_PASCAL, MODE_TAG, CACHE_VAR, LP_CACHE, LP_OFFSET, ORIG_VAR) \ + void Hook##MODE_PASCAL##Start(void *self) { \ if ((CACHE_VAR) != self) (CACHE_VAR) = self; \ - if (ORIG_VAR) (ORIG_VAR)(self); \ - int32_t lp = -1; \ - if ((LP_OFFSET) != 0 && self) { \ - lp = readI32(self, (LP_OFFSET)); \ - (LP_CACHE) = lp; \ - } \ - file_log([NSString stringWithFormat: \ - @"[MMODE] " MODE_TAG " Start self=%p localPlayer=%d", \ - self, (int)lp]); \ - /* Tell the USI engine driver that a match just started so it can */ \ - /* prep its state machine and (when we're the side to move first) */ \ - /* kickstart a position+go without waiting for the first observed */ \ - /* opponent move. */ \ - usi_engine_on_match_start(lp); \ - /* Emit the match_start meta line so the bridge can begin assembling */ \ - /* its KIF header (player names, mode, time control). MatchConfig is */ \ - /* already cached from the InitializeAsync hook above, and lp is what */ \ - /* we've just read — same call site keeps the two notifications in */ \ - /* lockstep. */ \ - meta_emit_match_start(lp); \ + KIOU_CALL_ORIG_VOID(ORIG_VAR, self); \ + void *selfCap = self; \ + uintptr_t lpOffsetCap = (uintptr_t)(LP_OFFSET); \ + dispatch_async(dispatch_get_main_queue(), ^{ \ + int32_t lp = -1; \ + if (lpOffsetCap != 0 && selfCap) { \ + lp = readI32(selfCap, lpOffsetCap); \ + (LP_CACHE) = lp; \ + } \ + IPALog([NSString stringWithFormat: \ + @"[MMODE] " MODE_TAG " Start self=%p localPlayer=%d", \ + selfCap, (int)lp]); \ + /* Notify the CSA engine driver so it can ship Game_Summary to the */ \ + /* connected engine (or arm for the next AGREE). Csa_GameInfo reads */ \ + /* MatchConfig + GameStateStore.Set*PlayerInfo to fill the block. */ \ + CsaEngineOnMatchStart(lp); \ + MetaEmitMatchStart(lp); \ + /* Prevent the screen from dimming while a match is active. */ \ + /* Already on the main queue here. */ \ + [UIApplication sharedApplication].idleTimerDisabled = YES; \ + }); \ } // AIMatchMode / CPUStreamMode / OnlinePvPMode capture _localPlayer. -DEFINE_START_HOOK(ai, "AIMatchMode", g_aiMatchModeCache, +DEFINE_START_HOOK(Ai, "AIMatchMode", g_aiMatchModeCache, g_aiLocalPlayer, OFF_AI_LOCALPLAYER, orig_AI_Start) -DEFINE_START_HOOK(cpustream, "CPUStreamMode", g_cpuStreamModeCache, +DEFINE_START_HOOK(CpuStream, "CPUStreamMode", g_cpuStreamModeCache, g_cpuStreamLocalPlayer, OFF_CPUSTREAM_LOCALPLAYER, orig_CPUStream_Start) -DEFINE_START_HOOK(online, "OnlinePvPMode", g_onlineModeCache, +DEFINE_START_HOOK(Online, "OnlinePvPMode", g_onlineModeCache, g_onlineLocalPlayer, OFF_ONLINE_LOCALPLAYER, orig_Online_Start) // LocalPvPMode / RecordReplayMode have no `_localPlayer` — pass offset 0 // and the macro skips the read. static int32_t volatile g_unusedLocalPlayerSlot = -1; -DEFINE_START_HOOK(local, "LocalPvPMode", g_localPvPModeCache, +DEFINE_START_HOOK(Local, "LocalPvPMode", g_localPvPModeCache, g_unusedLocalPlayerSlot, 0, orig_Local_Start) -DEFINE_START_HOOK(replay, "RecordReplayMode", g_recordReplayModeCache, +DEFINE_START_HOOK(Replay, "RecordReplayMode", g_recordReplayModeCache, g_unusedLocalPlayerSlot, 0, orig_Replay_Start) #undef DEFINE_START_HOOK @@ -406,7 +440,7 @@ static int32_t readCpuStrengthFromOrchestrator(void) { // int32 value @4. uint8_t hasValue = readU8(params, OFF_GAMEPARAMS_CPU_STRENGTH); int32_t value = readI32(params, OFF_GAMEPARAMS_CPU_STRENGTH + 4); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[REMATCH] readCpuStrength: orch=%p setup=%p params=%p " @"hasValue=%u value=%d", orch, setup, params, (unsigned)hasValue, (int)value]); @@ -421,12 +455,12 @@ static int32_t readCpuStrengthFromOrchestrator(void) { // RecordReplay). Anything else is logged + a rematch kick is scheduled. static void scheduleAutoRematch(const char *modeTag) { if (!modeTag) { - file_log(@"[REMATCH] skipped: mode opts out"); + IPALog(@"[REMATCH] skipped: mode opts out"); return; } bool isOnline = (strcmp(modeTag, "online") == 0); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[REMATCH] scheduling auto-rematch mode=%s orch=%p", modeTag, g_gameOrchestratorCache]); @@ -439,7 +473,7 @@ static void scheduleAutoRematch(const char *modeTag) { dispatch_get_main_queue(), ^{ void *orch = g_gameOrchestratorCache; if (!orch || g_unityBase == 0) { - file_log(@"[REMATCH] step1 skipped: no orchestrator/unityBase"); + IPALog(@"[REMATCH] step1 skipped: no orchestrator/unityBase"); return; } GameOrch_OnEndSequenceCompleted_t fn = @@ -447,11 +481,11 @@ static void scheduleAutoRematch(const char *modeTag) { (g_unityBase + RVA_GAMEORCH_ON_END_SEQUENCE_COMPLETED); @try { fn(orch); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[REMATCH] step1: OnEndSequenceCompleted invoked " @"orch=%p", orch]); } @catch (NSException *e) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[REMATCH] step1 threw: %@", e]); } }); @@ -463,7 +497,7 @@ static void scheduleAutoRematch(const char *modeTag) { (int64_t)(5.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ if (g_unityBase == 0) { - file_log(@"[REMATCH] step2 skipped: no unityBase"); + IPALog(@"[REMATCH] step2 skipped: no unityBase"); return; } if (isOnline) { @@ -471,18 +505,18 @@ static void scheduleAutoRematch(const char *modeTag) { (g_unityBase + RVA_MATCHING_START_RANK); @try { (void)fn(RANK_RULE_BULLET3MIN, false, NULL); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[REMATCH] step2: StartRankMatchingAsync " @"rule=Bullet3Min(%d)", RANK_RULE_BULLET3MIN]); } @catch (NSException *e) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[REMATCH] step2 (online) threw: %@", e]); } } else { int32_t strength = readCpuStrengthFromOrchestrator(); if (strength < 0) { strength = CPU_STRENGTH_NORMAL; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[REMATCH] step2: CPU strength unreadable, " @"falling back to Normal(%d)", strength]); } @@ -490,20 +524,20 @@ static void scheduleAutoRematch(const char *modeTag) { (g_unityBase + RVA_CPU_MATCH_START_FREE); @try { (void)fn(strength, false, NULL); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[REMATCH] step2: StartCpuFreeMatchAsync " @"strength=%d", (int)strength]); } @catch (NSException *e) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[REMATCH] step2 (cpu) threw: %@", e]); } } }); } -#define DEFINE_END_HOOK(MODE_LOWER, MODE_TAG, CACHE_VAR, LP_CACHE, HAS_LP, \ +#define DEFINE_END_HOOK(MODE_PASCAL, MODE_TAG, CACHE_VAR, LP_CACHE, HAS_LP, \ REMATCH_TAG, ORIG_VAR) \ - static UniTaskRet hook_##MODE_LOWER##_End(void *self, void *ct) { \ + UniTaskRet Hook##MODE_PASCAL##End(void *self, void *ct) { \ /* Snapshot localPlayer BEFORE clearing it so we can infer the result. */ \ int32_t lpSnapshot = (HAS_LP) ? (LP_CACHE) : -1; \ usi_match_result_t result = inferMatchResult(lpSnapshot); \ @@ -512,8 +546,8 @@ static void scheduleAutoRematch(const char *modeTag) { NSString *finalSfen = inject_currentSfen(); \ /* Pull the full game record straight off the GameController while it's */ \ /* still live. Bridge 側で Match.finish のグランドトゥルースに使う。 */ \ - NSString *usiText = usiTextFromGameController(g_gameCtrlCache); \ - file_log([NSString stringWithFormat: \ + NSString *usiText = UsiTextFromGameController(g_gameCtrlCache); \ + IPALog([NSString stringWithFormat: \ @"[MMODE] " MODE_TAG " End self=%p localPlayer=%d " \ @"result=%d sfen=\"%@\"", \ self, (int)lpSnapshot, (int)result, finalSfen ?: @""]); \ @@ -522,98 +556,130 @@ static void scheduleAutoRematch(const char *modeTag) { /* Whichever match owned the cached SFEN is over now. Clear it so the */ \ /* next match doesn't inherit a stale board on its first injection. */ \ g_authoritativeSfenString = NULL; \ - /* Roll the USI engine state machine back to READY (after shipping */ \ - /* `gameover` to the bridge) so a new game's usinewgame is sent */ \ - /* before the next position+go. */ \ - usi_engine_on_match_end(result); \ - /* Emit the match_end meta line so the bridge can finalize its KIF */ \ - /* assembly. After this point we drop the MatchConfig cache so the */ \ - /* next match's meta_match_start reads fresh. */ \ - meta_emit_match_end(result, finalSfen, usiText); \ - meta_set_match_config(NULL); \ + /* Reset time cache so the next match doesn't inherit stale values. */ \ + g_latestBlackTimeSec = 0.0f; \ + g_latestWhiteTimeSec = 0.0f; \ + /* Surface the result to the CSA engine driver so it can emit */ \ + /* #REASON + #WIN/#LOSE/#DRAW before the next match resets state. */ \ + CsaEngineOnMatchEnd(result); \ + MetaEmitMatchEnd(result, finalSfen, usiText); \ + MetaSetMatchConfig(NULL); \ + CsaSetMatchConfig(NULL); \ /* Schedule the auto-rematch sequence. REMATCH_TAG = NULL opts out. */ \ scheduleAutoRematch(REMATCH_TAG); \ - if (ORIG_VAR) return (ORIG_VAR)(self, ct); \ - return (UniTaskRet){ NULL, NULL }; \ + /* Re-enable the idle timer now that the match has ended. The start */ \ + /* hook set it on the main queue, so mirror that here. */ \ + dispatch_async(dispatch_get_main_queue(), ^{ \ + [UIApplication sharedApplication].idleTimerDisabled = NO; \ + }); \ + /* On binpatch KIOU_CALL_ORIG_RET is a no-op (cave handles orig). */ \ + return KIOU_CALL_ORIG_RET(UniTaskRet, ORIG_VAR, self, ct); \ } -DEFINE_END_HOOK(ai, "AIMatchMode", g_aiMatchModeCache, +DEFINE_END_HOOK(Ai, "AIMatchMode", g_aiMatchModeCache, g_aiLocalPlayer, true, "ai_cpu", orig_AI_End) -DEFINE_END_HOOK(cpustream, "CPUStreamMode", g_cpuStreamModeCache, +DEFINE_END_HOOK(CpuStream, "CPUStreamMode", g_cpuStreamModeCache, g_cpuStreamLocalPlayer, true, "ai_cpu", orig_CPUStream_End) -DEFINE_END_HOOK(online, "OnlinePvPMode", g_onlineModeCache, +DEFINE_END_HOOK(Online, "OnlinePvPMode", g_onlineModeCache, g_onlineLocalPlayer, true, "online", orig_Online_End) -DEFINE_END_HOOK(local, "LocalPvPMode", g_localPvPModeCache, +DEFINE_END_HOOK(Local, "LocalPvPMode", g_localPvPModeCache, g_unusedLocalPlayerSlot, false, NULL, orig_Local_End) -DEFINE_END_HOOK(replay, "RecordReplayMode", g_recordReplayModeCache, +DEFINE_END_HOOK(Replay, "RecordReplayMode", g_recordReplayModeCache, g_unusedLocalPlayerSlot, false, NULL, orig_Replay_End) #undef DEFINE_END_HOOK // --------------------------------------------------------------------------- -// Installer. Wires up all five hooks. +// Installer. Wires up all 20 hooks (5 modes × {Init / Start / OPM / End}). +// On the binpatch build all 20 sites are routed by the static cave + SLOT +// dispatcher (KIOU_BR_HOOK_*_INIT / _START / _OPM / _END in +// recipes/kiouenginebridge.py), so the installer is omitted there. // --------------------------------------------------------------------------- -void install_MatchModeObserve_hook(uintptr_t unityBase) { +#if !KIOU_BINPATCH +void InstallMatchModeObserveHook(uintptr_t unityBase) { struct { const char *tag; const char *what; uintptr_t rva; void *hook; void **origSlot; } entries[] = { // OnPlayerMoveAsync — confirms the mode self pointer + populates // freshness timestamps used by the route picker. { "AIMatchMode", "OnPlayerMoveAsync", RVA_AI_OPM, - (void *)hook_ai_OPM, (void **)&orig_AIMatchMode_OnPlayerMoveAsync }, + (void *)HookAiOpm, (void **)&orig_AIMatchMode_OnPlayerMoveAsync }, { "CPUStreamMode", "OnPlayerMoveAsync", RVA_CPUSTREAM_OPM, - (void *)hook_cpustream_OPM, (void **)&orig_CPUStreamMode_OnPlayerMoveAsync }, + (void *)HookCpuStreamOpm, (void **)&orig_CPUStreamMode_OnPlayerMoveAsync }, { "LocalPvPMode", "OnPlayerMoveAsync", RVA_LOCAL_OPM, - (void *)hook_local_OPM, (void **)&orig_LocalPvPMode_OnPlayerMoveAsync }, + (void *)HookLocalOpm, (void **)&orig_LocalPvPMode_OnPlayerMoveAsync }, { "OnlinePvPMode", "OnPlayerMoveAsync", RVA_ONLINE_OPM, - (void *)hook_online_OPM, (void **)&orig_OnlinePvPMode_OnPlayerMoveAsync }, + (void *)HookOnlineOpm, (void **)&orig_OnlinePvPMode_OnPlayerMoveAsync }, { "RecordReplayMode", "OnPlayerMoveAsync", RVA_RECORDREPLAY_OPM, - (void *)hook_replay_OPM, (void **)&orig_RecordReplayMode_OnPlayerMoveAsync }, + (void *)HookReplayOpm, (void **)&orig_RecordReplayMode_OnPlayerMoveAsync }, // InitializeAsync — primary cache population, plus _localPlayer // capture on the seat-fixed modes. { "AIMatchMode", "InitializeAsync", RVA_AI_INIT, - (void *)hook_ai_Init, (void **)&orig_AI_Init }, + (void *)HookAiInit, (void **)&orig_AI_Init }, { "CPUStreamMode", "InitializeAsync", RVA_CPUSTREAM_INIT, - (void *)hook_cpustream_Init, (void **)&orig_CPUStream_Init }, + (void *)HookCpuStreamInit, (void **)&orig_CPUStream_Init }, { "LocalPvPMode", "InitializeAsync", RVA_LOCAL_INIT, - (void *)hook_local_Init, (void **)&orig_Local_Init }, + (void *)HookLocalInit, (void **)&orig_Local_Init }, { "OnlinePvPMode", "InitializeAsync", RVA_ONLINE_INIT, - (void *)hook_online_Init, (void **)&orig_Online_Init }, + (void *)HookOnlineInit, (void **)&orig_Online_Init }, { "RecordReplayMode", "InitializeAsync", RVA_RECORDREPLAY_INIT, - (void *)hook_replay_Init, (void **)&orig_Replay_Init }, + (void *)HookReplayInit, (void **)&orig_Replay_Init }, // OnMatchEndAsync — clear the cache so cross-match dispatch can't // dispatch on a stale mode pointer. { "AIMatchMode", "OnMatchEndAsync", RVA_AI_END, - (void *)hook_ai_End, (void **)&orig_AI_End }, + (void *)HookAiEnd, (void **)&orig_AI_End }, { "CPUStreamMode", "OnMatchEndAsync", RVA_CPUSTREAM_END, - (void *)hook_cpustream_End, (void **)&orig_CPUStream_End }, + (void *)HookCpuStreamEnd, (void **)&orig_CPUStream_End }, { "LocalPvPMode", "OnMatchEndAsync", RVA_LOCAL_END, - (void *)hook_local_End, (void **)&orig_Local_End }, + (void *)HookLocalEnd, (void **)&orig_Local_End }, { "OnlinePvPMode", "OnMatchEndAsync", RVA_ONLINE_END, - (void *)hook_online_End, (void **)&orig_Online_End }, + (void *)HookOnlineEnd, (void **)&orig_Online_End }, { "RecordReplayMode", "OnMatchEndAsync", RVA_RECORDREPLAY_END, - (void *)hook_replay_End, (void **)&orig_Replay_End }, + (void *)HookReplayEnd, (void **)&orig_Replay_End }, // OnMatchStart — synchronous prelude to live play; this is when we // read _localPlayer reliably for the seat-fixed modes. { "AIMatchMode", "OnMatchStart", RVA_AI_START, - (void *)hook_ai_Start, (void **)&orig_AI_Start }, + (void *)HookAiStart, (void **)&orig_AI_Start }, { "CPUStreamMode", "OnMatchStart", RVA_CPUSTREAM_START, - (void *)hook_cpustream_Start, (void **)&orig_CPUStream_Start }, + (void *)HookCpuStreamStart, (void **)&orig_CPUStream_Start }, { "LocalPvPMode", "OnMatchStart", RVA_LOCAL_START, - (void *)hook_local_Start, (void **)&orig_Local_Start }, + (void *)HookLocalStart, (void **)&orig_Local_Start }, { "OnlinePvPMode", "OnMatchStart", RVA_ONLINE_START, - (void *)hook_online_Start, (void **)&orig_Online_Start }, + (void *)HookOnlineStart, (void **)&orig_Online_Start }, { "RecordReplayMode", "OnMatchStart", RVA_RECORDREPLAY_START, - (void *)hook_replay_Start, (void **)&orig_Replay_Start }, + (void *)HookReplayStart, (void **)&orig_Replay_Start }, }; for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { uintptr_t addr = unityBase + entries[i].rva; MSHookFunction((void *)addr, entries[i].hook, entries[i].origSlot); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[MMODE] hooked %s.%s @0x%lx (base+0x%lx)", entries[i].tag, entries[i].what, (unsigned long)addr, (unsigned long)entries[i].rva]); } } +#else // KIOU_BINPATCH +// On the binpatch build the static cave + SLOT dispatcher drives every hook +// body, so MSHookFunction is never called. +// +// Importantly we MUST NOT write `unityBase + RVA_*` into `orig_*` here: that +// address is the patched first instruction (`B `), so calling it from +// the inject path would re-enter the dispatcher and loop. The bypass entry +// for binpatch injection is the cave tail (cave + KIOU_BR_CAVE_BYPASS_OFFSET), +// computed by `kiou_bridge_bypass_entry_for_hook()` and exposed via +// `KIOU_BR_BINPATCH_ORIG_OR_BYPASS(orig_*, hook_id, type)` -- the macro +// returns `g_inject_entry[hook_id]` when `orig_*` is NULL, which is the +// right call target on this build. +// +// Leaving `orig_*` at NULL is therefore the correct (and required) state on +// binpatch. The installer is kept as a no-op so Tweak.m can dispatch it +// unconditionally and the log explicitly records that we're on the +// bypass-entry path. +void InstallMatchModeObserveHook(uintptr_t unityBase) { + (void)unityBase; + IPALog(@"[MMODE] binpatch: install is a no-op — orig_* OPM slots stay " + @"NULL so the bypass-entry path in inject_pickRoute() is taken."); +} +#endif // !KIOU_BINPATCH diff --git a/Sources/KiouEngineBridge/Hook_OnlineObserve.m b/Sources/KiouEngineBridge/Hook_OnlineObserve.m index 1666776..214c2a3 100644 --- a/Sources/KiouEngineBridge/Hook_OnlineObserve.m +++ b/Sources/KiouEngineBridge/Hook_OnlineObserve.m @@ -39,6 +39,13 @@ #define RVA_ONLINE_HANDLE_RESULT 0x5A0CBD0 #define RVA_CPUSTREAM_UPDATE_SNAPSHOT 0x59EB0E0 // CPUStreamMode counterpart +// Latest server-authoritative remaining time, cached from +// UpdateAuthoritativeSnapshot (Online) and CpuStream_UpdateSnapshot. +// Exported via Internal.h so Meta_Emitter can embed them in meta_move. +// 0.0f means "no snapshot received yet this match". +float volatile g_latestBlackTimeSec = 0.0f; +float volatile g_latestWhiteTimeSec = 0.0f; + // --------------------------------------------------------------------------- // (A) UpdateAuthoritativeSnapshot // @@ -58,12 +65,12 @@ typedef void (*UpdateAuthoritativeSnapshot_t)(void *self, int32_t moveCount); static UpdateAuthoritativeSnapshot_t orig_UpdateAuthoritativeSnapshot = NULL; -static void hook_UpdateAuthoritativeSnapshot(void *self, - void *sfenStr, - int32_t turn, - float blackTimeSec, - float whiteTimeSec, - int32_t moveCount) { +void HookUpdateAuthoritativeSnapshot(void *self, + void *sfenStr, + int32_t turn, + float blackTimeSec, + float whiteTimeSec, + int32_t moveCount) { // The fact that we just received an authoritative snapshot is the // strongest "this is an online match" signal we have. Cache the // OnlinePvPMode self so Inject_Move can route to OnPlayerMoveAsync if @@ -71,9 +78,11 @@ static void hook_UpdateAuthoritativeSnapshot(void *self, if (g_onlineModeCache != self) g_onlineModeCache = self; g_lastOnlineEvtUs = mach_absolute_time(); if (sfenStr) g_authoritativeSfenString = sfenStr; + g_latestBlackTimeSec = blackTimeSec; + g_latestWhiteTimeSec = whiteTimeSec; NSString *sfen = il2cppStringToNSString(sfenStr); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[SNAPSHOT] online turn=%d move_count=%d " @"black=%.2fs white=%.2fs sfen=\"%@\"", (int)turn, (int)moveCount, @@ -81,7 +90,7 @@ static void hook_UpdateAuthoritativeSnapshot(void *self, sfen ?: @""]); // Phase 2 doesn't surface snapshots over WS — Usi_Engine relies on // ADAPTER2 observations (which fire whenever the local engine applies - // a move) for turn tracking. The file_log line above is enough to + // a move) for turn tracking. The IPALog line above is enough to // debug authoritative-state drift after the fact. (void)sfen; if (orig_UpdateAuthoritativeSnapshot) { @@ -112,7 +121,7 @@ static void hook_UpdateAuthoritativeSnapshot(void *self, static uint32_t g_handleResultCount = 0; -static void hook_HandleMoveResult(void *self, void *reply) { +void HookHandleMoveResult(void *self, void *reply) { if (g_onlineModeCache != self) g_onlineModeCache = self; g_lastOnlineEvtUs = mach_absolute_time(); @@ -120,12 +129,12 @@ static void hook_HandleMoveResult(void *self, void *reply) { if (n <= 3 || (n % 30) == 0) { // Log the first three replies in full and then sample every 30th to // keep the file from ballooning during long matches. - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[RESULT] online HandleMoveResult call#%u self=%p reply=%p", n, self, reply]); } // Phase 2: HandleMoveResult is server-side bookkeeping; the USI engine - // doesn't need to see it. file_log keeps a sampled trace for postmortems. + // doesn't need to see it. IPALog keeps a sampled trace for postmortems. if (orig_HandleMoveResult) orig_HandleMoveResult(self, reply); } @@ -138,25 +147,27 @@ static void hook_HandleMoveResult(void *self, void *reply) { // --------------------------------------------------------------------------- static UpdateAuthoritativeSnapshot_t orig_CpuStream_UpdateSnapshot = NULL; -static void hook_CpuStream_UpdateSnapshot(void *self, - void *sfenStr, - int32_t turn, - float blackTimeSec, - float whiteTimeSec, - int32_t moveCount) { +void HookCpuStreamUpdateSnapshot(void *self, + void *sfenStr, + int32_t turn, + float blackTimeSec, + float whiteTimeSec, + int32_t moveCount) { if (g_cpuStreamModeCache != self) g_cpuStreamModeCache = self; g_lastCpuStreamEvtUs = mach_absolute_time(); if (sfenStr) g_authoritativeSfenString = sfenStr; + g_latestBlackTimeSec = blackTimeSec; + g_latestWhiteTimeSec = whiteTimeSec; NSString *sfen = il2cppStringToNSString(sfenStr); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[SNAPSHOT] cpu_stream turn=%d move_count=%d " @"black=%.2fs white=%.2fs sfen=\"%@\"", (int)turn, (int)moveCount, (double)blackTimeSec, (double)whiteTimeSec, sfen ?: @""]); - // Phase 2: see comment in hook_UpdateAuthoritativeSnapshot — snapshots - // stay file_log-only and the USI engine tracks turns via ADAPTER2. + // Phase 2: see comment in HookUpdateAuthoritativeSnapshot — snapshots + // stay IPALog-only and the USI engine tracks turns via ADAPTER2. (void)sfen; if (orig_CpuStream_UpdateSnapshot) { orig_CpuStream_UpdateSnapshot(self, sfenStr, turn, @@ -168,12 +179,13 @@ static void hook_CpuStream_UpdateSnapshot(void *self, // --------------------------------------------------------------------------- // Installer. // --------------------------------------------------------------------------- -void install_OnlineObserve_hook(uintptr_t unityBase) { +#if !KIOU_BINPATCH +void InstallOnlineObserveHook(uintptr_t unityBase) { uintptr_t addrSnap = unityBase + RVA_ONLINE_UPDATE_SNAPSHOT; MSHookFunction((void *)addrSnap, - (void *)hook_UpdateAuthoritativeSnapshot, + (void *)HookUpdateAuthoritativeSnapshot, (void **)&orig_UpdateAuthoritativeSnapshot); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[ONLINE] hooked OnlinePvPMode.UpdateAuthoritativeSnapshot " @"@0x%lx (base+0x%x)", (unsigned long)addrSnap, @@ -181,9 +193,9 @@ void install_OnlineObserve_hook(uintptr_t unityBase) { uintptr_t addrRes = unityBase + RVA_ONLINE_HANDLE_RESULT; MSHookFunction((void *)addrRes, - (void *)hook_HandleMoveResult, + (void *)HookHandleMoveResult, (void **)&orig_HandleMoveResult); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[ONLINE] hooked OnlinePvPMode.HandleMoveResult " @"@0x%lx (base+0x%x)", (unsigned long)addrRes, @@ -191,11 +203,16 @@ void install_OnlineObserve_hook(uintptr_t unityBase) { uintptr_t addrCpuSnap = unityBase + RVA_CPUSTREAM_UPDATE_SNAPSHOT; MSHookFunction((void *)addrCpuSnap, - (void *)hook_CpuStream_UpdateSnapshot, + (void *)HookCpuStreamUpdateSnapshot, (void **)&orig_CpuStream_UpdateSnapshot); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[ONLINE] hooked CPUStreamMode.UpdateAuthoritativeSnapshot " @"@0x%lx (base+0x%x)", (unsigned long)addrCpuSnap, (unsigned)RVA_CPUSTREAM_UPDATE_SNAPSHOT]); } +#endif // !KIOU_BINPATCH +// On the binpatch build, the three Online/CPUStream observation sites are +// routed via the static cave + SLOT dispatcher +// (KIOU_BR_HOOK_ONLINE_UPDATE_SNAPSHOT / _ONLINE_HANDLE_RESULT / +// _CPUSTREAM_UPDATE_SNAPSHOT in recipes/kiouenginebridge.py). diff --git a/Sources/KiouEngineBridge/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index 46e33e9..5106edf 100644 --- a/Sources/KiouEngineBridge/Inject_Move.m +++ b/Sources/KiouEngineBridge/Inject_Move.m @@ -58,7 +58,7 @@ // completes before the board snaps to the post-move state. Tunable — // raise it if the animation visibly clips, lower it if responsiveness // suffers across rapid back-to-back injections. -#define INJECT_ANIMATION_DELAY_SEC 0.40 +#define INJECT_ANIMATION_DELAY_SEC 0.0 // _stateStore field offsets per IMatchMode concrete implementor. Verified // against dump.cs:1419817 (AI), 1420396 (CPUStream), 1421565 (Online), @@ -163,7 +163,7 @@ typedef UniTaskRet (*BoardPresenter_PlayMoveAnimationAsync_t)(void *self, // way CPU games do — the previous env/flag-file gate is wired off. // // What this enables: -// - usi_engine_on_match_start fires with the OnlinePvPMode's _localPlayer, +// - UsiEngineOnMatchStart fires with the OnlinePvPMode's _localPlayer, // so the engine knows which seat to think for. // - Injection's route picker treats online_opm as eligible just like the // CPU-side routes, so a bestmove from the WASM engine lands through @@ -388,7 +388,7 @@ static int inject_pscPieceTypeFromUsiPiece(char c) { sfen = il2cppStringToNSString(strPtr); } @catch (NSException *e) { } } - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT-DBG] resolvePosition via GameCtrl pos=%p " @"sfen=\"%@\"", pos, sfen ?: @""]); return pos; @@ -403,13 +403,13 @@ static int inject_pscPieceTypeFromUsiPiece(char c) { @try { void *built = g_Position_CreateFromSFEN(sfenStr); if (built && outFromSfen) *outFromSfen = true; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT-DBG] resolvePosition via SFEN strPtr=%p " @"built=%p sfen=\"%@\"", sfenStr, built, sfenDisplay ?: @""]); if (built) return built; } @catch (NSException *e) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT-DBG] resolvePosition: CreateFromSFEN threw " @"sfen=\"%@\" exc=%@", sfenDisplay ?: @"", e]); @@ -424,18 +424,18 @@ static int inject_pscPieceTypeFromUsiPiece(char c) { @try { void *built = g_Position_CreateByType(0); if (built && outFromSfen) *outFromSfen = true; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT-DBG] resolvePosition via standard opening " @"built=%p (no GameCtrl, no authoritativeSfen)", built]); if (built) return built; } @catch (NSException *e) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT-DBG] resolvePosition: CreateByType threw " @"exc=%@", e]); } } - file_log(@"[INJECT-DBG] resolvePosition: all paths exhausted"); + IPALog(@"[INJECT-DBG] resolvePosition: all paths exhausted"); return NULL; } @@ -530,7 +530,7 @@ static bool inject_buildMove(const char *usi, uint32_t *outMove, } *outMove = g_PSCMove_Create(fromSq, toSq, pieceType, promote, 0); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT-DBG] buildMove usi=\"%s\" fromSq=%d toSq=%d " @"pieceAtFrom=0x%x pieceType=%d promote=%d => raw=0x%x", usi, (int)fromSq, (int)toSq, @@ -579,24 +579,25 @@ static bool inject_buildMove(const char *usi, uint32_t *outMove, // a match-end hook (Phase 2) to clear these caches, the worst case is that // we try to dispatch on a stale pointer. That's why we still prefer the // freshest observed timestamp when multiple mode caches are populated — -// it's an ordering hint, not a gate. Adapter / GameCtrl fallbacks remain -// for the rare case where injection is requested before any OPM has fired -// (the UI won't redraw but at least the engine state advances). +// it's an ordering hint, not a gate. Branch F reconstructs per-site bypass +// trampolines from the fixed cave geometry, so binpatch injection can call +// OPM / Adapter without re-entering the dispatcher cave. static kiou_route_t inject_pickRoute(void) { // Pick the mode whose OPM observation timestamp is the newest, treating // "never observed" (ts == 0) as infinitely old. The actual route call - // still requires (cache != NULL && orig != NULL). - struct { void *cache; OnPlayerMoveAsync_t orig; uint64_t ts; + // only requires a live cached receiver; on binpatch the callable can be + // the reconstructed cave-bypass entry even when orig_* is NULL. + struct { void *cache; bool callable; uint64_t ts; kiou_route_t route; bool gated; } modes[] = { - { g_aiMatchModeCache, orig_AIMatchMode_OnPlayerMoveAsync, + { g_aiMatchModeCache, KIOU_BR_BINPATCH_AI_OPM_CALLABLE() != NULL, g_lastAiMatchEvtUs, KIOU_ROUTE_AI_OPM, false }, - { g_localPvPModeCache, orig_LocalPvPMode_OnPlayerMoveAsync, + { g_localPvPModeCache, KIOU_BR_BINPATCH_LOCAL_OPM_CALLABLE() != NULL, g_lastLocalPvPEvtUs, KIOU_ROUTE_LOCAL_OPM, false }, - { g_cpuStreamModeCache, orig_CPUStreamMode_OnPlayerMoveAsync, + { g_cpuStreamModeCache, KIOU_BR_BINPATCH_CPUSTREAM_OPM_CALLABLE() != NULL, g_lastCpuStreamEvtUs, KIOU_ROUTE_CPUSTREAM_OPM, false }, - { g_recordReplayModeCache, orig_RecordReplayMode_OnPlayerMoveAsync, + { g_recordReplayModeCache, KIOU_BR_BINPATCH_REPLAY_OPM_CALLABLE() != NULL, g_lastRecordReplayEvtUs, KIOU_ROUTE_REPLAY_OPM, false }, - { g_onlineModeCache, orig_OnlinePvPMode_OnPlayerMoveAsync, + { g_onlineModeCache, KIOU_BR_BINPATCH_ONLINE_OPM_CALLABLE() != NULL, g_lastOnlineEvtUs, KIOU_ROUTE_ONLINE_OPM, !g_onlineServerSendAllowed }, }; @@ -604,7 +605,7 @@ static kiou_route_t inject_pickRoute(void) { kiou_route_t best = KIOU_ROUTE_NONE; uint64_t bestTs = 0; for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) { - if (!modes[i].cache || !modes[i].orig) continue; + if (!modes[i].cache || !modes[i].callable) continue; if (modes[i].gated) continue; if (modes[i].ts < bestTs) continue; // older than current best bestTs = modes[i].ts; @@ -613,16 +614,19 @@ static kiou_route_t inject_pickRoute(void) { if (best != KIOU_ROUTE_NONE) return best; // No OPM cache live — fall back to headless engine writes so the - // bridge can at least drive the SFEN forward. - if (g_adapterCache && orig_AdapterTryMakeMoveOut) return KIOU_ROUTE_ADAPTER; - if (g_gameCtrlCache && orig_GameCtrlTryMakeMove) return KIOU_ROUTE_GAMECTRL; + // bridge can at least drive the SFEN forward. GameCtrl stays JB-only: + // binpatch does not publish a bypass entry for it. + if (g_adapterCache && KIOU_BR_BINPATCH_ADAPTER_CALLABLE()) { + return KIOU_ROUTE_ADAPTER; + } + if (g_gameCtrlCache && orig_GameCtrlTryMakeMove) return KIOU_ROUTE_GAMECTRL; return KIOU_ROUTE_NONE; } // --------------------------------------------------------------------------- // Ring buffer for recent injections. Single producer (the recv-queue handler // after the main-thread dispatch returns), so a simple mutex is overkill, -// but kiou_inject_dumpRecent() can be called from any thread (signal handler +// but KEBInjectDumpRecent() can be called from any thread (signal handler // etc.), so a lock is the cheapest correct option. // --------------------------------------------------------------------------- static kiou_inject_record_t g_ring[KIOU_INJECT_RING_SIZE]; @@ -638,18 +642,18 @@ static void inject_pushRecord(const kiou_inject_record_t *rec) { pthread_mutex_unlock(&g_ringMu); } -void kiou_inject_dumpRecent(void) { +void KEBInjectDumpRecent(void) { pthread_mutex_lock(&g_ringMu); size_t count = g_ringCount; size_t head = g_ringHead; - file_log([NSString stringWithFormat:@"[INJECT] === recent (%zu) ===", + IPALog([NSString stringWithFormat:@"[INJECT] === recent (%zu) ===", count]); for (size_t i = 0; i < count; i++) { // Walk from oldest to newest. size_t idx = (head + KIOU_INJECT_RING_SIZE - count + i) % KIOU_INJECT_RING_SIZE; kiou_inject_record_t *r = &g_ring[idx]; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT] t=%llu usi=\"%s\" route=%s ok=%d " @"raw=0x%x err=\"%s\" sfen=\"%s\"", (unsigned long long)r->ts_us, @@ -766,21 +770,45 @@ static void inject_runOnMain(const char *usi, uint32_t move, void *self = (SELF); \ OnPlayerMoveAsync_t fn = (ORIG); \ if (!self || !fn) { err = "no_session"; break; } \ + IPALog([NSString stringWithFormat: \ + @"[INJECT-DBG] route=%s callable=%p orig=%p " \ + @"bypass=%p", \ + inject_routeName(route), (void *)fn, \ + (void *)(ORIG), \ + (void *)((ORIG) ? NULL : fn)]); \ + \ (void)fn(self, move, NULL); \ ok = true; \ executed = move; \ /* Locally apply the same move so the headless engine and */ \ /* GameStateStore advance — disasm confirms OPM alone won't */\ - /* do it. Failures here are benign (the move might already */ \ - /* have been applied by an earlier HandleMoveResult) — we */ \ - /* keep ok=true because the OPM call already succeeded. */ \ - if (g_adapterCache && orig_AdapterTryMakeMoveOut) { \ - uint32_t outMv = 0; \ - bool tryOk = orig_AdapterTryMakeMoveOut( \ - (void *)g_adapterCache, move, &outMv); \ - file_log([NSString stringWithFormat: \ - @"[INJECT-DBG] local TryMakeMove tryOk=%d " \ - @"outMv=0x%x", (int)tryOk, (unsigned)outMv]); \ + /* do it (it forwards to the server stream and waits for */ \ + /* HandleMoveResult). On binpatch orig_AdapterTryMakeMoveOut */\ + /* stays NULL by design (see Hook_MatchModeObserve.m's */ \ + /* binpatch installer) and the cave-bypass entry has to be */ \ + /* used instead. KIOU_BR_BINPATCH_ADAPTER_CALLABLE() returns */ \ + /* orig_* on JB and g_inject_entry[ADAPTER] on binpatch. */ \ + /* Failures here are benign (the move might already have */ \ + /* been applied by an earlier HandleMoveResult) — we keep */ \ + /* ok=true because the OPM call already succeeded. */ \ + { \ + Adapter_TryMakeMove_Out_t adapterFn = \ + KIOU_BR_BINPATCH_ADAPTER_CALLABLE(); \ + if (g_adapterCache && adapterFn) { \ + uint32_t outMv = 0; \ + bool tryOk = adapterFn( \ + (void *)g_adapterCache, move, &outMv); \ + IPALog([NSString stringWithFormat: \ + @"[INJECT-DBG] local TryMakeMove tryOk=%d " \ + @"outMv=0x%x adapter=%p", \ + (int)tryOk, (unsigned)outMv, \ + (void *)adapterFn]); \ + } else { \ + IPALog([NSString stringWithFormat: \ + @"[INJECT-DBG] local TryMakeMove skipped: " \ + @"adapterCache=%p adapterFn=%p", \ + g_adapterCache, (void *)adapterFn]); \ + } \ } \ /* Flip the side-to-move ReactiveProperty so the "whose */ \ /* turn" UI advances. Skipped for open-seat modes */ \ @@ -803,12 +831,12 @@ static void inject_runOnMain(const char *usi, uint32_t move, g_GameStateStore_NotifyPieceMoved( \ store, move, \ (int32_t)(LOCAL_PLAYER)); \ - file_log([NSString stringWithFormat: \ + IPALog([NSString stringWithFormat: \ @"[INJECT-DBG] NotifyPieceMoved " \ @"store=%p player=%d", \ store, (int)(LOCAL_PLAYER)]); \ } @catch (NSException *e) { \ - file_log([NSString stringWithFormat: \ + IPALog([NSString stringWithFormat: \ @"[INJECT-DBG] NotifyPieceMoved " \ @"threw %@", e]); \ } \ @@ -825,61 +853,67 @@ static void inject_runOnMain(const char *usi, uint32_t move, @try { \ g_GameStateStore_NotifyStateSynced( \ store, pos); \ - file_log([NSString stringWithFormat: \ + IPALog([NSString stringWithFormat: \ @"[INJECT-DBG] " \ @"NotifyStateSynced store=%p " \ @"pos=%p", store, pos]); \ } @catch (NSException *e) { \ - file_log([NSString stringWithFormat: \ + IPALog([NSString stringWithFormat: \ @"[INJECT-DBG] " \ @"NotifyStateSynced threw %@", \ e]); \ } \ } else { \ - file_log(@"[INJECT-DBG] " \ + IPALog(@"[INJECT-DBG] " \ @"NotifyStateSynced skipped: no " \ @"latest pos"); \ } \ } \ } else { \ - file_log(@"[INJECT-DBG] Notify* skipped: no store"); \ + IPALog(@"[INJECT-DBG] Notify* skipped: no store"); \ } \ } \ } while (0) switch (route) { case KIOU_ROUTE_AI_OPM: - CALL_OPM(g_aiMatchModeCache, orig_AIMatchMode_OnPlayerMoveAsync, + CALL_OPM(g_aiMatchModeCache, KIOU_BR_BINPATCH_AI_OPM_CALLABLE(), OFF_AI_STATESTORE, g_aiLocalPlayer); break; case KIOU_ROUTE_CPUSTREAM_OPM: - CALL_OPM(g_cpuStreamModeCache, orig_CPUStreamMode_OnPlayerMoveAsync, + CALL_OPM(g_cpuStreamModeCache, KIOU_BR_BINPATCH_CPUSTREAM_OPM_CALLABLE(), OFF_CPUSTREAM_STATESTORE, g_cpuStreamLocalPlayer); break; case KIOU_ROUTE_LOCAL_OPM: // LocalPvP has no fixed seat — pass -1 to suppress the // NotifyPieceMoved call (we can't tell which side this // injection represents). - CALL_OPM(g_localPvPModeCache, orig_LocalPvPMode_OnPlayerMoveAsync, + CALL_OPM(g_localPvPModeCache, KIOU_BR_BINPATCH_LOCAL_OPM_CALLABLE(), OFF_LOCAL_STATESTORE, -1); break; case KIOU_ROUTE_ONLINE_OPM: - CALL_OPM(g_onlineModeCache, orig_OnlinePvPMode_OnPlayerMoveAsync, + CALL_OPM(g_onlineModeCache, KIOU_BR_BINPATCH_ONLINE_OPM_CALLABLE(), OFF_ONLINE_STATESTORE, g_onlineLocalPlayer); break; case KIOU_ROUTE_REPLAY_OPM: // RecordReplay also has no fixed seat — same treatment. - CALL_OPM(g_recordReplayModeCache, orig_RecordReplayMode_OnPlayerMoveAsync, + CALL_OPM(g_recordReplayModeCache, KIOU_BR_BINPATCH_REPLAY_OPM_CALLABLE(), OFF_REPLAY_STATESTORE, -1); break; case KIOU_ROUTE_ADAPTER: { void *self = g_adapterCache; + Adapter_TryMakeMove_Out_t fn = KIOU_BR_BINPATCH_ADAPTER_CALLABLE(); uint32_t outMv = 0; - if (!self || !orig_AdapterTryMakeMoveOut) { + if (!self || !fn) { err = "no_session"; break; } - ok = orig_AdapterTryMakeMoveOut(self, move, &outMv); + IPALog([NSString stringWithFormat: + @"[INJECT-DBG] route=adapter callable=%p orig=%p bypass=%p", + (void *)fn, + (void *)orig_AdapterTryMakeMoveOut, + (void *)(orig_AdapterTryMakeMoveOut ? NULL : fn)]); + ok = fn(self, move, &outMv); executed = outMv; if (!ok) err = "no_legal"; break; @@ -922,14 +956,14 @@ static void inject_runOnMain(const char *usi, uint32_t move, strncpy(rec.error, err, KIOU_INJECT_ROUTE_MAX - 1); inject_pushRecord(&rec); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT] usi=\"%s\" route=%s ok=%d raw=0x%x err=\"%s\" " @"sfen=\"%@\"", usi, inject_routeName(route), (int)ok, (unsigned)executed, err, sfen ?: @""]); // Hand the outcome back to the Usi_Engine.m caller (or whichever - // future caller wants it). The ring buffer / file_log entries above + // future caller wants it). The ring buffer / IPALog entries above // remain regardless, so debugging still works without a consumer. if (outOk) *outOk = ok; if (outRaw) *outRaw = executed; @@ -978,7 +1012,7 @@ bool inject_apply(NSString *usi, memcpy(rec.usi_in, usiCstr, copyN); rec.usi_in[copyN] = '\0'; inject_pushRecord(&rec); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT] skip usi=\"%s\" reason=%s", rec.usi_in, rec.error]); if (outErr) *outErr = [NSString stringWithUTF8String:rec.error]; @@ -989,7 +1023,16 @@ bool inject_apply(NSString *usi, // inject_buildMove calls Project.ShogiCore.Position.CreateFromSFEN // (and Position.GetPiece) via NativeFunction; calling those off the // main thread crashes the il2cpp runtime instantly. Box them all into - // dispatch_sync calls. + // dispatch_sync calls — but only when the caller isn't already on + // the main queue (Csa_Engine's recv-queue dispatch lands here on + // main, and dispatch_sync(main_queue) from main is a deadlock). +#define KIOU_RUN_ON_MAIN_SYNC(block) do { \ + if ([NSThread isMainThread]) { \ + block(); \ + } else { \ + dispatch_sync(dispatch_get_main_queue(), block); \ + } \ +} while (0) __block uint32_t move = 0; __block const char *buildErr = NULL; __block bool buildOk = false; @@ -998,7 +1041,7 @@ bool inject_apply(NSString *usi, __block int32_t humanSideOut = -1; NSString *usiBox = [[NSString alloc] initWithUTF8String:usiTok]; - dispatch_sync(dispatch_get_main_queue(), ^{ + KIOU_RUN_ON_MAIN_SYNC(^{ const char *usiInner = [usiBox UTF8String]; buildOk = inject_buildMove(usiInner, &move, &buildErr); if (!buildOk) return; @@ -1016,7 +1059,7 @@ bool inject_apply(NSString *usi, if (!buildOk) { __block NSString *currentSfen = nil; - dispatch_sync(dispatch_get_main_queue(), ^{ + KIOU_RUN_ON_MAIN_SYNC(^{ currentSfen = inject_sfenFromCachedGameCtrl(); }); @@ -1031,7 +1074,7 @@ bool inject_apply(NSString *usi, strncpy(rec.sfen_after, cstr, KIOU_INJECT_SFEN_MAX - 1); } inject_pushRecord(&rec); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT] parse_fail usi=\"%s\" err=%s sfen=\"%@\"", usiTok, rec.error, currentSfen ?: @""]); if (outSfenAfter) *outSfenAfter = currentSfen; @@ -1049,7 +1092,7 @@ bool inject_apply(NSString *usi, strncpy(rec.error, "not_your_turn", KIOU_INJECT_ROUTE_MAX - 1); rec.move_raw = move; inject_pushRecord(&rec); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT] not_your_turn usi=\"%s\" raw=0x%x " @"current=%d human=%d route=%s", usiTok, (unsigned)move, @@ -1069,7 +1112,7 @@ bool inject_apply(NSString *usi, strncpy(rec.error, "no_session", KIOU_INJECT_ROUTE_MAX - 1); rec.move_raw = move; inject_pushRecord(&rec); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT] no_session usi=\"%s\" raw=0x%x", usiTok, (unsigned)move]); if (outRaw) *outRaw = move; @@ -1086,33 +1129,36 @@ bool inject_apply(NSString *usi, // the actual mutation. Animation runs in parallel with our wait and // wraps up around the time we resume. if (g_BoardPresenter_PlayMoveAnimationAsync && g_gameOrchestratorCache) { - dispatch_sync(dispatch_get_main_queue(), ^{ + KIOU_RUN_ON_MAIN_SYNC(^{ void *boardPresenter = readPtr((void *)g_gameOrchestratorCache, OFF_GAMEORCH_BOARD_PRESENTER); if (!boardPresenter) { - file_log(@"[INJECT-DBG] PlayMoveAnimation skipped: " + IPALog(@"[INJECT-DBG] PlayMoveAnimation skipped: " @"no BoardPresenter on orch"); return; } @try { (void)g_BoardPresenter_PlayMoveAnimationAsync(boardPresenter, move, NULL); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT-DBG] PlayMoveAnimation fired " @"presenter=%p move=0x%x", boardPresenter, (unsigned)move]); } @catch (NSException *e) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT-DBG] PlayMoveAnimation threw: %@", e]); } }); - // Sleep on the WS recv queue (NOT the main thread) so the - // animation actually runs while we wait. usleep here is safe - // because inject_apply is called from the WS recv queue, not - // from a Unity callback. - usleep((useconds_t)(INJECT_ANIMATION_DELAY_SEC * 1000000.0)); + // Sleep off the main thread so the animation can actually run. + // If inject_apply was called from the main thread (e.g. from + // the CSA recv-queue dispatch), sleeping here would block the + // UI entirely. Skip the delay in that case — INJECT_ANIMATION_DELAY_SEC + // is 0.0 anyway, so this guard is a safety net for future increases. + if (![NSThread isMainThread]) { + usleep((useconds_t)(INJECT_ANIMATION_DELAY_SEC * 1000000.0)); + } } else { - file_log(@"[INJECT-DBG] PlayMoveAnimation skipped: " + IPALog(@"[INJECT-DBG] PlayMoveAnimation skipped: " @"fn or orch cache missing"); } @@ -1121,7 +1167,7 @@ bool inject_apply(NSString *usi, __block uint32_t runExecuted = 0; __block NSString *runSfen = nil; __block NSString *runErr = nil; - dispatch_sync(dispatch_get_main_queue(), ^{ + KIOU_RUN_ON_MAIN_SYNC(^{ inject_runOnMain([usiBox UTF8String], move, route, &runOk, &runExecuted, &runSfen, &runErr); }); @@ -1145,9 +1191,9 @@ bool inject_apply(NSString *usi, return inject_sfenFromCachedGameCtrl(); } -void install_Inject_hook(uintptr_t unityBase) { +void InstallInjectHook(uintptr_t unityBase) { if (g_SunfishMoveDrop) { - file_log(@"[INJECT] install: already initialized, skipping"); + IPALog(@"[INJECT] install: already initialized, skipping"); return; } @@ -1181,7 +1227,7 @@ void install_Inject_hook(uintptr_t unityBase) { // Inject_Move is now a pure helper that Usi_Engine calls into via // inject_apply(). - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT] installed: create@0x%lx createDrop@0x%lx " @"getPiece@0x%lx getPieceType@0x%lx fromSFEN@0x%lx " @"notifyPieceMoved@0x%lx notifyStateSynced@0x%lx " diff --git a/Sources/KiouEngineBridge/Inject_Resign.m b/Sources/KiouEngineBridge/Inject_Resign.m new file mode 100644 index 0000000..612e12b --- /dev/null +++ b/Sources/KiouEngineBridge/Inject_Resign.m @@ -0,0 +1,69 @@ +#import "Internal.h" + +// =========================================================================== +// Inject_Resign — bridge CSA's %TORYO / %KACHI submissions back to KIOU. +// +// CSA fires %TORYO when the connected engine resigns — meaning the local +// KIOU side wins the match. KIOU's own surrender entry point is +// GameOrchestrator.RequestSurrender (RVA 0x594A91C, void method, no args) +// which kicks off the same end-of-match flow the in-game "投了" button +// triggers. +// +// Because the CSA engine is "the other player" we can't selectively resign +// the engine's seat — RequestSurrender always surrenders the LOCAL player. +// That's fine for the most common case (CPU vs engine), but in OnlinePvP +// with the user as black and an engine ghosting on the server side it +// would surrender the wrong seat. Until the CSA path is exercised against +// every match mode we accept that limitation; CSA's engine resignation in +// VsAI maps cleanly to "the user wins by the engine's resignation," which +// is what RequestSurrender produces if the user is the loser (it then ends +// up with the right outcome from CSA's perspective because we swap WIN / +// LOSE before sending the result block). +// +// Nyugyoku declaration (%KACHI) has no first-class KIOU API in dump.cs +// today; we log the request and let the CSA session terminate normally +// without driving KIOU. Task 7's follow-up will revisit this once the +// declaration surface is reverse-engineered. +// =========================================================================== + +#define RVA_GAME_ORCHESTRATOR_REQUEST_SURRENDER 0x594A91C + +typedef void (*GameOrchestratorRequestSurrender_t)(void *self); +static GameOrchestratorRequestSurrender_t g_RequestSurrender = NULL; + +static void resolve_request_surrender(void) { + if (g_RequestSurrender) return; + if (g_unityBase == 0) return; + g_RequestSurrender = (GameOrchestratorRequestSurrender_t) + (void *)(g_unityBase + RVA_GAME_ORCHESTRATOR_REQUEST_SURRENDER); +} + +void InjectResign(int32_t playerSide) { + resolve_request_surrender(); + void *orch = g_gameOrchestratorCache; + if (!orch || !g_RequestSurrender) { + IPALog([NSString stringWithFormat: + @"[RESIGN] cannot resign player=%d: orch=%p fn=%p", + (int)playerSide, orch, g_RequestSurrender]); + return; + } + IPALog([NSString stringWithFormat: + @"[RESIGN] invoking GameOrchestrator.RequestSurrender " + @"(player=%d, orch=%p)", + (int)playerSide, orch]); + dispatch_async(dispatch_get_main_queue(), ^{ + @try { + g_RequestSurrender(orch); + } @catch (NSException *e) { + IPALog([NSString stringWithFormat: + @"[RESIGN] threw: %@", e]); + } + }); +} + +void InjectNyugyokuDeclaration(int32_t playerSide) { + IPALog([NSString stringWithFormat: + @"[RESIGN] %%KACHI for player=%d — no KIOU API surfaced yet, " + @"only the CSA session learns of the declaration", + (int)playerSide]); +} diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index b9d7dbe..e6202a4 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -4,77 +4,89 @@ #import #import -#import "kiou_il2cpp.h" -#import "kiou_hookengine.h" -#import "kiou_logging.h" +#import "il2cpp.h" +#import "hookengine.h" +#import "logging.h" // =========================================================================== // Internal.h — KiouEngineBridge-private declarations. // -// This tweak primarily observes KIOU state and pushes SFEN / USI strings out -// over a WebSocket sink. As of the move-injection phase it also accepts -// inbound text frames from the host (typically "bestmove " lines from a -// USI engine bridge) and replays them into the game's TryMakeMove path so the -// local board advances exactly as if the user had played the move. +// KiouEngineBridge embeds a CSA server (TCP :4081) inside the KIOU process. +// Observation hooks latch live GameController / ShogiGameAdapter / +// OnlinePvPMode pointers and convert Sunfish.Move to CSA notation; +// the injection layer feeds CSA moves back into KIOU's own TryMakeMove / +// OnPlayerMoveAsync paths so the on-device match advances exactly as if the +// user had played the move. // // What the injection layer is allowed to do: // - Call il2cpp-generated methods as function pointers (TryMakeMove, -// Sunfish.Move.Drop, Position.ToSFEN, OnlinePvPMode.OnPlayerMoveAsync). -// - Cache the `self` pointer observed flowing through TryMakeMove / -// UpdateAuthoritativeSnapshot hooks so it can be reused as receiver. +// Sunfish.Move.Create, Position.ToSFEN, OnlinePvPMode.OnPlayerMoveAsync). +// - Cache the `self` pointer observed via hooks for later injection calls. // // What the injection layer is NOT allowed to do: -// - Touch il2cpp object fields directly. The shared header -// `kiou_il2cpp.h` is intentionally read-only, and the write-side helpers -// (writeU8 / writeI32) that KiouEditor declares in its own Internal.h -// are still NOT included here. Any future "let's tweak a board field" -// regression must opt in explicitly by adding those helpers — they -// don't sneak in via the shared header. +// - Touch il2cpp object fields directly. il2cpp.h is read-only; write-side +// helpers (writeU8 / writeI32) are deliberately excluded. // -// Online ratings games: by default an injected move goes through the local -// GameController only (route = "gamectrl") and is reverted by the next -// server-authoritative snapshot. Forwarding the move to the server via -// OnlinePvPMode.OnPlayerMoveAsync is gated behind BOTH an environment -// variable AND a flag file on disk so a host that only knows how to send -// `bestmove ` cannot trip the ratings-impacting path by accident. -// See Inject_Move.m for the exact gate. +// Hook installers — one per feature module, wired by Tweak.m: // -// Hook installers are added per feature module: -// -// install_OnlineObserve_hook (Hook_OnlineObserve.m) -// install_LowLevelObserve_hook (Hook_LowLevelObserve.m) -// install_MatchModeObserve_hook (Hook_MatchModeObserve.m) -// install_Inject_hook (Inject_Move.m) -// usi_engine_install (Usi_Engine.m, Phase 2 — USI client) -// -// Tweak.m wires them up the same way KiouEditor/Tweak.m does — scan dyld -// for UnityFramework, dispatch each installer once with the base address. -// -// Phase 2 architecture (USI mode): -// The tweak acts as a USI CLIENT (= USI User in the USI spec). YaneuraOu -// is the USI ENGINE — it connects to us as a WebSocket client and we -// drive it with `usi` / `isready` / `usinewgame` / `position sfen ...` / -// `go ...`. When the observation hooks see that it's our turn, we ship -// the current SFEN to YaneuraOu, wait for its `bestmove `, and feed -// that move back into KIOU via the Phase 1 injection path. The match -// continues until [MMODE] OnMatchEndAsync clears the cache. +// InstallOnlineObserveHook (Hook_OnlineObserve.m) +// InstallLowLevelObserveHook (Hook_LowLevelObserve.m) +// InstallMatchModeObserveHook (Hook_MatchModeObserve.m) +// InstallGameStateStoreObserveHook (Hook_GameStateStoreObserve.m) +// InstallGameOrchestratorObserveHook (Hook_GameOrchestratorObserve.m) +// InstallAfkSuppressHook (Hook_AfkSuppress.m) +// InstallInjectHook (Inject_Move.m) // =========================================================================== #ifndef KIOU_ENGINE_BRIDGE_COMMIT #define KIOU_ENGINE_BRIDGE_COMMIT "unknown" #endif +// --------------------------------------------------------------------------- +// orig() invocation policy. +// +// On the JB build, MSHookFunction installs a trampoline at the target site; +// the target function body NEVER runs unless our hook explicitly calls +// orig(args). Forgetting to do so silently turns the hook into a wholesale +// replacement — bad news for sites like ShogiGameAdapter.TryMakeMove whose +// side effects (appending to _positionHistory, committing the move) are the +// reason callers invoke it. +// +// On the binpatch build, the static cave runs the displaced prologue +// instruction and then branches to orig + 4 verbatim — so orig is already +// going to execute, and a second call from the hook body would double-run +// the target. The hook must NOT call orig in that case. +// +// KIOU_CALL_ORIG_VOID / KIOU_CALL_ORIG_RET hide the distinction. Hook bodies +// uniformly write `KIOU_CALL_ORIG_VOID(orig, self, ...)` / etc.; the macro +// expands to the original call on JB and to a no-op on binpatch. +// +// Use the _RET variant when the original returns a value the hook (or the +// caller) needs. `RET_T` is the return type; on binpatch the macro returns +// a value-initialised RET_T (i.e. `(RET_T){0}`), which the caller never +// actually consumes because the cave's `B orig + 4` re-enters the real +// function and that return value is what the caller sees. +// --------------------------------------------------------------------------- +#if KIOU_BINPATCH +# define KIOU_CALL_ORIG_VOID(ORIG, ...) ((void)0) +# define KIOU_CALL_ORIG_RET(RET_T, ORIG, ...) ((RET_T){0}) +#else +# define KIOU_CALL_ORIG_VOID(ORIG, ...) \ + do { if ((ORIG)) (ORIG)(__VA_ARGS__); } while (0) +# define KIOU_CALL_ORIG_RET(RET_T, ORIG, ...) \ + ((ORIG) ? (ORIG)(__VA_ARGS__) : (RET_T){0}) +#endif + // --------------------------------------------------------------------------- // Per-module hook installers. Tweak.m calls each one once UnityFramework has // shown up; each installer guards itself if invoked twice. // --------------------------------------------------------------------------- -void install_OnlineObserve_hook(uintptr_t unityBase); -void install_LowLevelObserve_hook(uintptr_t unityBase); -void install_MatchModeObserve_hook(uintptr_t unityBase); -void install_Inject_hook(uintptr_t unityBase); -void install_AfkSuppress_hook(uintptr_t unityBase); -void install_GameOrchestratorObserve_hook(uintptr_t unityBase); -void usi_engine_install(void); +void InstallOnlineObserveHook(uintptr_t unityBase); +void InstallLowLevelObserveHook(uintptr_t unityBase); +void InstallMatchModeObserveHook(uintptr_t unityBase); +void InstallInjectHook(uintptr_t unityBase); +void InstallAfkSuppressHook(uintptr_t unityBase); +void InstallGameOrchestratorObserveHook(uintptr_t unityBase); // UnityFramework base address captured at install time. Exposed so the // match-end auto-rematch path can resolve static il2cpp methods @@ -89,20 +101,25 @@ extern uintptr_t g_unityBase; extern void *volatile g_gameOrchestratorCache; // --------------------------------------------------------------------------- -// WebSocket server (Server_WebSocket.m). Boot once at constructor time, -// then any hook can push a single JSON-encoded line to whichever host is -// currently connected. No-op when no host is attached. +// CSA TCP server (Server_CSA.m). Boot once at constructor time, then any +// hook can push a single CSA-protocol line to whichever engine is currently +// connected. No-op when no engine is attached. // --------------------------------------------------------------------------- -void kiou_ws_server_start(uint16_t port); -void kiou_ws_server_push(NSString *json); +void KEBCsaServerStart(uint16_t port); +void KEBCsaServerPush(NSString *line); -// Register a callback for inbound TEXT frames (opcode 0x1). The handler is +// Tear down the current TCP client (if any). Used by the CSA engine driver +// when an inbound `LOGOUT` arrives, or to force a teardown on shutdown. +// Idempotent. Runs the actual close on the accept queue so it composes +// safely with concurrent KEBCsaServerPush calls. +void KEBCsaServerClose(void); + +// Register a callback for inbound LF-terminated lines. The handler is // invoked on the recv queue (a serial dispatch queue, NOT the main thread). -// `data` is NOT null-terminated; treat it as a length-bounded byte slice and -// copy what you need before returning — the buffer is freed by the recv loop -// immediately after the handler returns. Replace by passing NULL. -typedef void (*kiou_ws_text_handler_t)(const char *data, size_t len); -void kiou_ws_server_set_text_handler(kiou_ws_text_handler_t fn); +// `line` is already UTF-8-decoded with CR/LF terminators stripped; the +// caller may retain it freely. Replace by passing NULL. +typedef void (*kiou_csa_line_handler_t)(NSString *line); +void KEBCsaServerSetLineHandler(kiou_csa_line_handler_t fn); // --------------------------------------------------------------------------- // Observation-side instance cache, populated by Hook_LowLevelObserve.m and @@ -162,6 +179,13 @@ extern uint64_t volatile g_lastCpuStreamEvtUs; // CPUStreamMode.OnPlayerMoveAs extern uint64_t volatile g_lastLocalPvPEvtUs; // LocalPvPMode.OnPlayerMoveAsync extern uint64_t volatile g_lastRecordReplayEvtUs;// RecordReplayMode.OnPlayerMoveAsync +// Latest server-authoritative remaining time (seconds). Updated by +// HookUpdateAuthoritativeSnapshot (Online) and HookCpuStreamUpdateSnapshot. +// 0.0f means no snapshot this match yet (AI / Local modes never receive one). +// Cleared on OnMatchEndAsync. +extern float volatile g_latestBlackTimeSec; +extern float volatile g_latestWhiteTimeSec; + // --------------------------------------------------------------------------- // Original (untrampolined) function pointers captured by hook installers. // Inject_Move.m calls these directly to advance the position without @@ -229,11 +253,11 @@ typedef struct { // Dump the most recent ring contents into the shared file log. Intended for // manual debugging (e.g. fired from a SIGUSR1 handler or at unload). Safe to // call from any thread. -void kiou_inject_dumpRecent(void); +void KEBInjectDumpRecent(void); // --------------------------------------------------------------------------- -// Injection bridge — called by Usi_Engine.m when YaneuraOu sends us a -// `bestmove `. Returns true if the move was injected successfully +// Injection bridge — called by Csa_Engine.m when the connected engine sends +// a move. Returns true if the move was injected successfully // (= OPM + Adapter.TryMakeMove path succeeded). The returned `outSfenAfter` // and `outRaw` are populated with the post-injection SFEN and Move uint32 // for logging convenience; both may be nil/0 on failure. Internally @@ -249,38 +273,8 @@ bool inject_apply(NSString *usi, // because it touches il2cpp accessors. NSString *inject_currentSfen(void); -// --------------------------------------------------------------------------- -// Usi_Engine.m — the USI-client state machine that drives YaneuraOu and -// feeds its bestmove back into KIOU. -// --------------------------------------------------------------------------- - -typedef enum { - USI_STATE_BOOT = 0, // tweak loaded, no ws client yet - USI_STATE_HANDSHAKE = 1, // ws client connected, awaiting usiok - USI_STATE_READY = 2, // readyok received, ready for new game - USI_STATE_THINKING = 3, // go sent, awaiting bestmove - USI_STATE_INJECTING = 4, // bestmove received, applying to KIOU -} usi_state_t; - -// Notified by Hook_LowLevelObserve.m::hook_AdapterTryMakeMoveOut every time -// a move lands on the board. `usi` is the move that was just applied, -// `sfen_after` is the resulting position, `side_to_move` is the side that -// will move next (0=Black, 1=White). The engine compares side_to_move -// against the cached local-player side to decide whether to send a new -// `position` + `go` to YaneuraOu. -void usi_engine_on_move_observed(NSString *usi, - NSString *sfen_after, - int32_t side_to_move); - -// Match lifecycle hooks. `local_player` is 0 (Black) or 1 (White), or -1 -// when the seat isn't fixed (LocalPvP / RecordReplay). -void usi_engine_on_match_start(int32_t local_player); - -// `result` is 0 (we won), 1 (we lost), 2 (draw), or -1 (unknown — no -// gameover is sent to the bridge in that case). The seat-fixed modes -// (AI / CPUStream / Online) pass 0/1/2 based on the final SFEN's -// side-to-move vs the cached local-player seat; the open-seat modes -// (LocalPvP / RecordReplay) pass -1 to suppress the notification. +// Match result type — used by Hook_MatchModeObserve.m, Csa_Engine.m, +// Csa_GameInfo.m, and Meta_Emitter.m. typedef enum { USI_RESULT_UNKNOWN = -1, USI_RESULT_WIN = 0, @@ -288,12 +282,81 @@ typedef enum { USI_RESULT_DRAW = 2, } usi_match_result_t; -void usi_engine_on_match_end(usi_match_result_t result); +// --------------------------------------------------------------------------- +// Csa_Engine.m — the CSA-protocol server-side state machine that drives +// the connected engine through Game_Summary / AGREE / per-move exchange / +// gameover. The state-machine + integration surface lives in Csa_Engine.h; +// the symbols below are the bare lifecycle callbacks Server_CSA.m needs. +// --------------------------------------------------------------------------- + +// Lifecycle callbacks invoked by Server_CSA.m from the accept queue. +void CsaEngineOnTcpClientConnected(void); +void CsaEngineOnTcpClientDisconnected(void); + +// Constructor-time installer — registers the inbound-line handler with +// Server_CSA.m. Call once from Tweak.m after KEBCsaServerStart binds. +void CsaEngineInstall(void); + +// Match-lifecycle callbacks. Hook_MatchModeObserve.m forwards from its +// existing dispatch_async(main_queue) block. +void CsaEngineOnMatchStart(int32_t local_player); +void CsaEngineOnMatchEnd(usi_match_result_t result); + +// Per-move notification, fired from Hook_GameStateStoreObserve.m. +// `move` : KIOU Move bits +// `playerSide` : the side that just moved (0=Black, 1=White) +// `sfenAfter` : post-move SFEN snapshot +// `blackTimeRemainSec` / `whiteTimeRemainSec`: post-move remaining +// clock values read straight off GameStateStore (+0x80 / +0x90 + +// 0x20). Pass -1.0f when no live clock is available for that side +// (VsAI's CPU side uses 86400s sentinel, open-seat modes don't +// surface clocks, etc). +void CsaEngineOnMoveObserved(uint32_t move, + int32_t playerSide, + NSString *sfenAfter, + float blackTimeRemainSec, + float whiteTimeRemainSec); + +// --------------------------------------------------------------------------- +// Csa_GameInfo.m — MatchConfig / PlayerInfo readers + CSA Game_Summary / +// result block builders. +// --------------------------------------------------------------------------- + +// Stash the MatchConfig pointer that Hook_MatchModeObserve.m's Init hook +// already cached for the legacy meta path. Called from the same Init macro +// alongside MetaSetMatchConfig. +void CsaSetMatchConfig(void *cfg); + +// Latest Online player-info pointer, captured from +// GameStateStore.Set{Black,White}PlayerInfo. side: 0=Black, 1=White. +void CsaOnPlayerInfoSet(int32_t side, void *playerInfo); + +// Build the multi-line `BEGIN Game_Summary ... END Game_Summary` payload. +// `local_player` is the seat the user holds (0=Black, 1=White, -1 for +// open-seat modes). On success the derived Game_ID is written into +// `*outGameId` (may be NULL). Returns nil when MatchConfig has not been +// captured yet — the caller (Csa_Engine.m) defers Game_Summary delivery +// in that case. +// outGameId receives the Game_ID string (never nil on success). +// outStartSfen receives the SFEN of the starting position (nil when +// SfenFromGameController was unavailable). Callers may cache this as the +// initial g_csaPrevSfen so first-move validators have a board snapshot. +NSString *CsaBuildGameSummary(int32_t local_player, + NSString **outGameId, + NSString **outStartSfen); + +// Build the CSA `#REASON` + `#OUTCOME` pair (e.g. `"#RESIGN\n#WIN"`). +// Returns nil for unknown results so the engine driver can suppress the +// result block when the outcome cannot be inferred. +NSString *CsaBuildMatchResult(usi_match_result_t result); -// WS client connection lifecycle. Server_WebSocket.m calls these from the -// accept queue so the engine can drive the handshake. -void usi_engine_on_ws_client_connected(void); -void usi_engine_on_ws_client_disconnected(void); +// --------------------------------------------------------------------------- +// Inject_Resign.m — invoke KIOU's resign / nyugyoku-declaration APIs when +// the CSA engine submits `%TORYO` / `%KACHI`. Stubbed in Csa_Stubs.m until +// Task 6 of the CSA migration plan lands. +// --------------------------------------------------------------------------- +void InjectResign(int32_t playerSide); +void InjectNyugyokuDeclaration(int32_t playerSide); // --------------------------------------------------------------------------- // Meta_Emitter.m — 1-line JSON metadata stream that runs alongside the USI @@ -305,18 +368,16 @@ void usi_engine_on_ws_client_disconnected(void); // Stash the MatchConfig that InitializeAsync passes in. Called from // Hook_MatchModeObserve's Init hook with the cfg arg, and from the End hook -// with NULL to clear it. Subsequent meta_emit_match_start reads off this. -void meta_set_match_config(void *cfg); +// with NULL to clear it. Subsequent MetaEmitMatchStart reads off this. +void MetaSetMatchConfig(void *cfg); // Emit "meta {type:match_start, ...}". Called right after OnMatchStart // latches the local-player seat (so we can carry it in the payload). -void meta_emit_match_start(int32_t local_player); +void MetaEmitMatchStart(int32_t local_player); -// Emit "meta {type:move, ...}". Called from Hook_LowLevelObserve's adapter -// observation, right alongside usi_engine_on_move_observed. side_to_move is -// the side whose turn it is NEXT — we flip to "who just moved" in the -// payload. -void meta_emit_move(NSString *usi, NSString *sfen_after, int32_t side_to_move); +// Emit "meta {type:move, ...}". Called from Hook_GameStateStoreObserve. +// side_to_move is the side whose turn it is NEXT. +void MetaEmitMove(NSString *usi, NSString *sfen_after, int32_t side_to_move); // Emit "meta {type:match_end, ...}". Called from Hook_MatchModeObserve's // END_HOOK after the result has been inferred. final_sfen is the SFEN read @@ -324,7 +385,7 @@ void meta_emit_move(NSString *usi, NSString *sfen_after, int32_t side_to_move); // the full game record (GameController.GetUSIText) — bridge 側でこれが // 入っていれば、これまで積んだ move 経路の Record を上書きして // グランドトゥルースとして使う。 -void meta_emit_match_end(usi_match_result_t result, +void MetaEmitMatchEnd(usi_match_result_t result, NSString *final_sfen, NSString *usi_text); @@ -333,10 +394,243 @@ void meta_emit_match_end(usi_match_result_t result, // If a match_start emit is pending and BOTH sides are now in, this fires // match_start with the store-supplied PlayerInfo. CPU matches typically // don't reach this — the 1.5s OnMatchStart fallback timer covers them. -void meta_on_player_info_set(int32_t side, void *playerInfo); +void MetaOnPlayerInfoSet(int32_t side, void *playerInfo); // Installer for the GameStateStore.Set*PlayerInfo hooks. -void install_GameStateStoreObserve_hook(uintptr_t unityBase); +void InstallGameStateStoreObserveHook(uintptr_t unityBase); + +// --------------------------------------------------------------------------- +// Static binpatch dispatcher (binpatch build only). +// +// In the binpatch flavour, every hook site is redirected by a code cave to a +// single dispatcher function published into a reserved __DATA,__bss SLOT +// inside UnityFramework. The cave preserves X0-X7, materialises the slot +// address from `unityBase + KIOU_BR_HOOK_SLOT_RVA`, loads the function +// pointer, stuffs the per-site hook id into W6, calls the dispatcher, then +// restores X0-X7 and resumes orig via the displaced prologue + `B orig+4`. +// +// W6 is used for the hook id (not W2) so the dispatcher can forward the +// real call-site arguments in X0-X5/X7 to the hook function bodies; several +// Bridge sites carry a real argument in X2 (`OnPlayerMoveAsync`'s ct, +// `UpdateAuthoritativeSnapshot`'s turn, `Adapter.TryMakeMove(Move, out)`'s +// out pointer). +// +// Hook function bodies live unchanged in their respective Hook_*.m files — +// the dispatcher just maps hook_id back to the right hook_(self, ...) +// call. See docs/plans/kiou_engine_bridge_binpatch.md § 5 for the contract. +// --------------------------------------------------------------------------- + +// RVA of the 8-byte slot the recipe reserves inside UnityFramework's +// __DATA,__bss. MUST match `HOOK_SLOT_RVA` in recipes/kiouenginebridge.py; +// if one moves, both move together (the recipe pins the slot at patch +// time, this header pins where the dylib publishes its dispatcher). +#define KIOU_BR_HOOK_SLOT_RVA 0x8F90CC0 + +// Reserved sibling RVA for a future in-framework inject-entry table. +// Branch F currently reconstructs bypass entries dylib-locally from cave +// geometry, but we still mirror the recipe's reserved address here so the +// reservation stays visible on both sides. +#define KIOU_BR_INJECT_ENTRY_TABLE_RVA 0x8F90C00 + +// Binpatch cave geometry. MUST mirror recipes/kiouenginebridge.py. +// Every cave is a fixed 84-byte payload allocated contiguously from the +// CAVE_REGION start in declaration order. The cave layout ends with: +// cave+0x48: LDP X29, X30, [SP], #0x90 (epilogue's stack restore) +// cave+0x4C: (the site's original first 4 bytes) +// cave+0x50: B (PC-relative branch back into the +// original method just past its +// replaced first insn) +// Branch F's injection path calls into `cave + 0x4C`, i.e. straight into the +// displaced prologue followed by the branch back to `orig + 4`, so it +// bypasses the dispatcher AND avoids running the epilogue's LDP (which would +// trash the inject path's own frame). Calling cave+0x48 would pop the wrong +// X29/X30 pair off the caller's stack and corrupt the frame pointer. +#define KIOU_BR_CAVE_REGION_START 0x826A000 +#define KIOU_BR_CAVE_SIZE 84 +#define KIOU_BR_CAVE_BYPASS_OFFSET 0x4C + + +enum kiou_bridge_hook_id { + KIOU_BR_HOOK_AI_INIT = 0, + KIOU_BR_HOOK_CPUSTREAM_INIT, + KIOU_BR_HOOK_LOCAL_INIT, + KIOU_BR_HOOK_ONLINE_INIT, + KIOU_BR_HOOK_REPLAY_INIT, + + KIOU_BR_HOOK_AI_START, + KIOU_BR_HOOK_CPUSTREAM_START, + KIOU_BR_HOOK_LOCAL_START, + KIOU_BR_HOOK_ONLINE_START, + KIOU_BR_HOOK_REPLAY_START, + + KIOU_BR_HOOK_AI_OPM, + KIOU_BR_HOOK_CPUSTREAM_OPM, + KIOU_BR_HOOK_LOCAL_OPM, + KIOU_BR_HOOK_ONLINE_OPM, + KIOU_BR_HOOK_REPLAY_OPM, + + KIOU_BR_HOOK_AI_END, + KIOU_BR_HOOK_CPUSTREAM_END, + KIOU_BR_HOOK_LOCAL_END, + KIOU_BR_HOOK_ONLINE_END, + KIOU_BR_HOOK_REPLAY_END, + + KIOU_BR_HOOK_ADAPTER_TRY_MAKE_MOVE_OUT, + KIOU_BR_HOOK_ONLINE_UPDATE_SNAPSHOT, + KIOU_BR_HOOK_ONLINE_HANDLE_RESULT, + KIOU_BR_HOOK_CPUSTREAM_UPDATE_SNAPSHOT, + KIOU_BR_HOOK_GAMEORCH_ACTIVATE, + + KIOU_BR_HOOK_GSTATE_SET_BLACK_PLAYER_INFO, + KIOU_BR_HOOK_GSTATE_SET_WHITE_PLAYER_INFO, + KIOU_BR_HOOK_GSTATE_NOTIFY_PIECE_MOVED, + + KIOU_BR_HOOK__COUNT, +}; + +// g_inject_entry is binpatch-only — it's populated by +// KEBBridgeBinpatchPublish() with per-site cave-bypass entry pointers, +// so injection on binpatch can call the original OPM body without +// re-entering the dispatcher cave. On JB the trampolines installed by +// MSHookFunction already provide that bypass via `orig_*`, so the array +// is not defined and the helper macros short-circuit to `(ORIG)`. +#if KIOU_BINPATCH +extern void * volatile g_inject_entry[KIOU_BR_HOOK__COUNT]; + +// Return the fixed-allocation-order cave-bypass entry for one hook id. +// This assumes cave i lives at `CAVE_REGION_START + i * CAVE_SIZE`; if the +// recipe ever switches to a non-uniform allocator, update both sides. +static inline void *kiou_bridge_bypass_entry_for_hook(uint32_t hook_id) { + if (hook_id >= KIOU_BR_HOOK__COUNT) return NULL; + return (void *)(g_unityBase + KIOU_BR_CAVE_REGION_START + + (uintptr_t)hook_id * KIOU_BR_CAVE_SIZE + + KIOU_BR_CAVE_BYPASS_OFFSET); +} + +#define KIOU_BR_BINPATCH_ORIG_OR_BYPASS(ORIG, HOOK_ID, TYPE) \ + ((ORIG) ? (ORIG) : (TYPE)g_inject_entry[(HOOK_ID)]) + +#define KIOU_BR_BINPATCH_INJECT_CALLABLES_READY() \ + (g_inject_entry[KIOU_BR_HOOK_AI_OPM] && \ + g_inject_entry[KIOU_BR_HOOK_CPUSTREAM_OPM] && \ + g_inject_entry[KIOU_BR_HOOK_LOCAL_OPM] && \ + g_inject_entry[KIOU_BR_HOOK_ONLINE_OPM] && \ + g_inject_entry[KIOU_BR_HOOK_REPLAY_OPM] && \ + g_inject_entry[KIOU_BR_HOOK_ADAPTER_TRY_MAKE_MOVE_OUT]) +#else +// On JB the only callable is the trampoline that MSHookFunction wrote into +// `orig_*`. The bypass-entry path is binpatch-only, so the helper macros +// collapse to `(ORIG)`. +#define KIOU_BR_BINPATCH_ORIG_OR_BYPASS(ORIG, HOOK_ID, TYPE) (ORIG) +#define KIOU_BR_BINPATCH_INJECT_CALLABLES_READY() 1 +#endif + +#define KIOU_BR_BINPATCH_ADAPTER_CALLABLE() \ + KIOU_BR_BINPATCH_ORIG_OR_BYPASS(orig_AdapterTryMakeMoveOut, \ + KIOU_BR_HOOK_ADAPTER_TRY_MAKE_MOVE_OUT, \ + Adapter_TryMakeMove_Out_t) + +#define KIOU_BR_BINPATCH_AI_OPM_CALLABLE() \ + KIOU_BR_BINPATCH_ORIG_OR_BYPASS(orig_AIMatchMode_OnPlayerMoveAsync, \ + KIOU_BR_HOOK_AI_OPM, \ + OnPlayerMoveAsync_t) + +#define KIOU_BR_BINPATCH_CPUSTREAM_OPM_CALLABLE() \ + KIOU_BR_BINPATCH_ORIG_OR_BYPASS(orig_CPUStreamMode_OnPlayerMoveAsync, \ + KIOU_BR_HOOK_CPUSTREAM_OPM, \ + OnPlayerMoveAsync_t) + +#define KIOU_BR_BINPATCH_LOCAL_OPM_CALLABLE() \ + KIOU_BR_BINPATCH_ORIG_OR_BYPASS(orig_LocalPvPMode_OnPlayerMoveAsync, \ + KIOU_BR_HOOK_LOCAL_OPM, \ + OnPlayerMoveAsync_t) + +#define KIOU_BR_BINPATCH_ONLINE_OPM_CALLABLE() \ + KIOU_BR_BINPATCH_ORIG_OR_BYPASS(orig_OnlinePvPMode_OnPlayerMoveAsync, \ + KIOU_BR_HOOK_ONLINE_OPM, \ + OnPlayerMoveAsync_t) + +#define KIOU_BR_BINPATCH_REPLAY_OPM_CALLABLE() \ + KIOU_BR_BINPATCH_ORIG_OR_BYPASS(orig_RecordReplayMode_OnPlayerMoveAsync, \ + KIOU_BR_HOOK_REPLAY_OPM, \ + OnPlayerMoveAsync_t) + +#define KIOU_BR_EXPECTED_CAVE_COUNT KIOU_BR_HOOK__COUNT + +#if KIOU_BINPATCH +_Static_assert(KIOU_BR_CAVE_SIZE == 84, "Branch F assumes 84-byte caves"); +_Static_assert(KIOU_BR_CAVE_BYPASS_OFFSET == 0x4C, + "Branch F assumes bypass entry at cave+0x4C " + "(displaced prologue followed by B orig+4)"); +#endif + + +// Dispatcher signature. Called from the cave with whatever was in X0-X5/X7 +// at the original call site, plus a hook id loaded by the cave's +// `MOVZ W6, #imm`. The trailing void* x7 parameter ensures hook_id lands in +// W6 under AAPCS64 (8 integer-class parameters fill X0..X7 in order); X4, +// X5, X7 are placeholders for hooks that have extra register arguments. +typedef void (*kiou_bridge_dispatcher_t)(void *x0, void *x1, void *x2, + void *x3, void *x4, void *x5, + uint32_t hook_id, void *x7); + +// Constructor helper. binpatch Tweak.m calls this exactly once after +// UnityFramework is mapped, in place of all install_*_hook calls. +// Publishes the dispatcher pointer into the slot at +// `g_unityBase + KIOU_BR_HOOK_SLOT_RVA` inside UnityFramework's +// __DATA,__bss. The dylib does NOT host its own copy of the slot — the +// cave reads from the framework's __bss, so the dispatcher pointer must +// live there. +void KEBBridgeBinpatchPublish(void); + +// --------------------------------------------------------------------------- +// Hook function bodies reached from the binpatch dispatcher. Defined in +// their respective Hook_*.m files; the dispatcher forwards each cave call +// to the matching body. Declared here so the dispatcher TU sees them. +// +// The bodies are written for the JB build (they call orig via +// KIOU_CALL_ORIG_*); on binpatch KIOU_CALL_ORIG_* expands to a no-op and +// orig runs via the cave's displaced prologue + `B orig+4` after the +// dispatcher returns. +// --------------------------------------------------------------------------- +UniTaskRet HookAiInit(void *self, void *cfg, void *store, void *adapter, void *ct); +UniTaskRet HookCpuStreamInit(void *self, void *cfg, void *store, void *adapter, void *ct); +UniTaskRet HookLocalInit(void *self, void *cfg, void *store, void *adapter, void *ct); +UniTaskRet HookOnlineInit(void *self, void *cfg, void *store, void *adapter, void *ct); +UniTaskRet HookReplayInit(void *self, void *cfg, void *store, void *adapter, void *ct); + +void HookAiStart(void *self); +void HookCpuStreamStart(void *self); +void HookLocalStart(void *self); +void HookOnlineStart(void *self); +void HookReplayStart(void *self); + +UniTaskRet HookAiOpm(void *self, uint32_t mv, void *ct); +UniTaskRet HookCpuStreamOpm(void *self, uint32_t mv, void *ct); +UniTaskRet HookLocalOpm(void *self, uint32_t mv, void *ct); +UniTaskRet HookOnlineOpm(void *self, uint32_t mv, void *ct); +UniTaskRet HookReplayOpm(void *self, uint32_t mv, void *ct); + +UniTaskRet HookAiEnd(void *self, void *ct); +UniTaskRet HookCpuStreamEnd(void *self, void *ct); +UniTaskRet HookLocalEnd(void *self, void *ct); +UniTaskRet HookOnlineEnd(void *self, void *ct); +UniTaskRet HookReplayEnd(void *self, void *ct); + +bool HookAdapterTryMakeMoveOut(void *self, uint32_t move, void *outMove); +void HookUpdateAuthoritativeSnapshot(void *self, void *sfenStr, int32_t turn, + float blackTimeSec, float whiteTimeSec, + int32_t moveCount); +void HookHandleMoveResult(void *self, void *reply); +void HookCpuStreamUpdateSnapshot(void *self, void *sfenStr, int32_t turn, + float blackTimeSec, float whiteTimeSec, + int32_t moveCount); +UniTaskRet HookGameOrchActivateAsync(void *self, void *setup, + void *assetLoader, void *ct); + +void HookGStateSetBlackPlayerInfo(void *self, void *playerInfo); +void HookGStateSetWhitePlayerInfo(void *self, void *playerInfo); +void HookGStateNotifyPieceMoved(void *self, uint32_t move, int32_t playerSide); // --------------------------------------------------------------------------- // SfMove + low-level helpers, exported so observation hooks living in other @@ -353,15 +647,11 @@ NSString *moveToUsi(SfMove m); // Read the live SFEN of a GameController by walking its PositionHistory and // calling Position.ToSFEN. Returns nil on any failure. Implementation in // Hook_LowLevelObserve.m. -NSString *sfenFromGameController(void *gameCtrl); +NSString *SfenFromGameController(void *gameCtrl); // Read the full game-record text via GameController.GetUSIText. Returns nil // on any failure. Used by Meta_Emitter to attach the authoritative game // record to the match_end meta payload. Implementation in // Hook_LowLevelObserve.m. -NSString *usiTextFromGameController(void *gameCtrl); +NSString *UsiTextFromGameController(void *gameCtrl); -// Send a literal USI line out to the engine (without trailing newline — -// the helper adds one). Safe to call from any thread; serializes onto the -// WS accept queue via kiou_ws_server_push. -void usi_engine_send_line(NSString *line); diff --git a/Sources/KiouEngineBridge/Meta_Emitter.m b/Sources/KiouEngineBridge/Meta_Emitter.m index c731b0e..5e88b9b 100644 --- a/Sources/KiouEngineBridge/Meta_Emitter.m +++ b/Sources/KiouEngineBridge/Meta_Emitter.m @@ -1,5 +1,22 @@ #import "Internal.h" +#if KIOU_BINPATCH +// The meta sidecar is dropped on the binpatch flavour +// (docs/plans/kiou_engine_bridge_binpatch.md § 2). Provide no-op stubs so +// the Hook_*.m / Tweak.m call sites can compile without an #if guard at +// every reference. +void MetaSetMatchConfig(void *cfg) { (void)cfg; } +void MetaEmitMatchStart(int32_t local_player) { (void)local_player; } +void MetaEmitMove(NSString *usi, NSString *sfen_after, int32_t side_to_move) { + (void)usi; (void)sfen_after; (void)side_to_move; +} +void MetaEmitMatchEnd(usi_match_result_t result, + NSString *final_sfen, + NSString *usi_text) { + (void)result; (void)final_sfen; (void)usi_text; +} +#else + #import // =========================================================================== @@ -19,7 +36,7 @@ // the local-player seat (so we know whose side is which) // move — once per move, fired from Hook_LowLevelObserve's // Adapter.TryMakeMove(out) observation (same site as -// usi_engine_on_move_observed) +// UsiEngineOnMoveObserved) // match_end — once per match, from Hook_MatchModeObserve's END_HOOK // (after the inferred win/lose has been computed) // - No retries, no buffering across reconnects. If no bridge is attached @@ -80,7 +97,7 @@ // in match_start instead of MatchConfig when present. // // Volatile because writers are Unity-side hook callbacks and the reader -// runs on whichever queue meta_emit_match_start ends up on. +// runs on whichever queue MetaEmitMatchStart ends up on. static void *volatile g_metaLatestBlackPlayerInfo = NULL; static void *volatile g_metaLatestWhitePlayerInfo = NULL; @@ -272,19 +289,19 @@ static void meta_emit_dict(NSDictionary *payload) { options:0 error:&err]; if (!json) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[META] serialize failed: %@", err.localizedDescription ?: @"?"]); return; } NSString *body = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding]; if (!body) { - file_log(@"[META] serialize: utf-8 decode failed"); + IPALog(@"[META] serialize: utf-8 decode failed"); return; } NSString *line = [NSString stringWithFormat:@"meta %@\n", body]; - file_log([NSString stringWithFormat:@"[META>] %@", body]); - kiou_ws_server_push(line); + IPALog([NSString stringWithFormat:@"[META>] %@", body]); + KEBWsServerPush(line); } // --------------------------------------------------------------------------- @@ -335,7 +352,7 @@ static void meta_do_emit_match_start(const char *trigger) { d[@"black"] = meta_playerDict(blackPI); d[@"white"] = meta_playerDict(whitePI); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[META] match_start emit trigger=%s black_src=%s " @"white_src=%s", trigger, @@ -360,7 +377,7 @@ static void meta_do_emit_match_start(const char *trigger) { // // Whoever wins clears g_metaMatchStartPending so the loser bails. // --------------------------------------------------------------------------- -void meta_emit_match_start(int32_t local_player) { +void MetaEmitMatchStart(int32_t local_player) { g_metaPendingLocalPlayer = local_player; g_metaMatchStartPending = true; // Reset the captured PlayerInfo from any previous match before we wait @@ -370,7 +387,7 @@ void meta_emit_match_start(int32_t local_player) { g_metaLatestBlackPlayerInfo = NULL; g_metaLatestWhitePlayerInfo = NULL; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[META] match_start armed local_player=%d, " @"waiting for Set*PlayerInfo (1.5s fallback)", (int)local_player]); @@ -394,7 +411,7 @@ void meta_emit_match_start(int32_t local_player) { // bails. Late writes after emit are kept in the cache (so a follow-up // stat surface could re-read them) but don't re-emit. // --------------------------------------------------------------------------- -void meta_on_player_info_set(int32_t side, void *playerInfo) { +void MetaOnPlayerInfoSet(int32_t side, void *playerInfo) { if (!playerInfo) return; if (side == 0) { g_metaLatestBlackPlayerInfo = playerInfo; @@ -403,7 +420,7 @@ void meta_on_player_info_set(int32_t side, void *playerInfo) { } else { return; } - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[META] PlayerInfo captured side=%d pi=%p " @"(black=%p white=%p pending=%d)", (int)side, playerInfo, @@ -421,12 +438,12 @@ void meta_on_player_info_set(int32_t side, void *playerInfo) { } // --------------------------------------------------------------------------- -// move. Called from the same observation site as usi_engine_on_move_observed +// move. Called from the same observation site as UsiEngineOnMoveObserved // — Hook_LowLevelObserve's AdapterTryMakeMoveOut. `usi` is the move that just // landed; `sfen_after` is the resulting position; `side_to_move` is the side // whose turn it is NEXT (= opposite of who just moved). // --------------------------------------------------------------------------- -void meta_emit_move(NSString *usi, NSString *sfen_after, int32_t side_to_move) { +void MetaEmitMove(NSString *usi, NSString *sfen_after, int32_t side_to_move) { if (usi.length == 0) return; uint64_t now = mach_absolute_time(); @@ -473,10 +490,18 @@ void meta_emit_move(NSString *usi, NSString *sfen_after, int32_t side_to_move) { d[@"usi"] = usi; d[@"elapsed_ms"] = @(elapsedMs); d[@"sfen_after"] = sfen_after ?: (id)[NSNull null]; - // Remaining-time fields intentionally omitted for the MVP: GameStateStore's - // _blackTimeRemaining / _whiteTimeRemaining are ReactiveProperty - // whose internal layout dump.cs doesn't pin down. We'll add them once - // the layout is confirmed against a live device, separately. + // Remaining time from the latest server-authoritative snapshot + // (UpdateAuthoritativeSnapshot for Online / CPUStream modes). 0.0f + // means no snapshot has arrived this match — AI and Local modes never + // receive one, so we emit null for those. The values lag by at most one + // server tick relative to the move that just landed, which is acceptable + // since this is the same authoritative source the server uses for + // adjudication. (ReactiveProperty walking was the original plan + // but its layout is still unverified; snapshot args are the simpler path.) + float bTime = g_latestBlackTimeSec; + float wTime = g_latestWhiteTimeSec; + d[@"black_time_sec"] = (bTime > 0.0f) ? @(bTime) : (id)[NSNull null]; + d[@"white_time_sec"] = (wTime > 0.0f) ? @(wTime) : (id)[NSNull null]; meta_emit_dict(d); } @@ -486,7 +511,7 @@ void meta_emit_move(NSString *usi, NSString *sfen_after, int32_t side_to_move) { // state (the caller pulls it via inject_currentSfen — easier than rereading // the cache here). // --------------------------------------------------------------------------- -void meta_emit_match_end(usi_match_result_t result, +void MetaEmitMatchEnd(usi_match_result_t result, NSString *final_sfen, NSString *usi_text) { NSMutableDictionary *d = [NSMutableDictionary dictionary]; @@ -496,7 +521,7 @@ void meta_emit_match_end(usi_match_result_t result, d[@"total_moves"] = @(g_metaPlyCounter); d[@"final_sfen"] = final_sfen ?: (id)[NSNull null]; // GameController.GetUSIText の生戻り値。bridge 側で「startpos moves ...」 - // または「sfen ... moves ...」として解釈して、これまで meta_emit_move で + // または「sfen ... moves ...」として解釈して、これまで MetaEmitMove で // 積んできた Record を上書きするグランドトゥルースに使う。差分ベースで // 累積する経路だと飛び手 / drop の駒種未確定 / 重複発火などで誤差が // 入る余地があるので、対局終了時にここで一発で確定させる。 @@ -510,7 +535,7 @@ void meta_emit_match_end(usi_match_result_t result, // MatchConfig stash / clear. Called by Hook_MatchModeObserve's Init hook // with the cfg arg, and by the End hook with NULL. // --------------------------------------------------------------------------- -void meta_set_match_config(void *cfg) { +void MetaSetMatchConfig(void *cfg) { g_metaMatchConfig = cfg; if (!cfg) { // Clearing means the match is over (or the next one hasn't started). @@ -531,3 +556,5 @@ void meta_set_match_config(void *cfg) { g_metaPendingLocalPlayer = -1; } } + +#endif // !KIOU_BINPATCH diff --git a/Sources/KiouEngineBridge/Server_CSA.m b/Sources/KiouEngineBridge/Server_CSA.m new file mode 100644 index 0000000..96555bd --- /dev/null +++ b/Sources/KiouEngineBridge/Server_CSA.m @@ -0,0 +1,327 @@ +#import "Internal.h" + +#import +#import +#import +#import +#import +#import +#import +#import + +// =========================================================================== +// Server_CSA — minimal CSA server protocol transport, one client at a time. +// +// Replaces Server_WebSocket.m as part of the CSA migration +// (docs/plans/kiou_engine_bridge_csa_migration.md). The shape is the same as +// the deprecated WebSocket server (single client, GCD accept/recv queue +// split, SO_KEEPALIVE for fast dead-peer detection) but the wire format is +// dramatically simpler — CSA is a raw line-oriented TCP protocol with no +// framing, no upgrade handshake, no masking, no compression. +// +// What it implements: +// - Listen on 0.0.0.0: via GCD dispatch source. +// - One concurrent client. A second incoming connection preempts the +// stale one — same new-client-wins policy as the WS server. +// - LF-terminated UTF-8 lines in both directions. Lines may also be +// CRLF-terminated on the inbound side; we tolerate either. +// - A serial GCD queue funnels every KEBCsaServerPush() through one +// producer-consumer slot. If the queue length crosses a soft cap we +// drop the new line and log a [CSA] warning. +// +// What it deliberately doesn't do: +// - TLS. CSA's TCP transport is unencrypted by design. +// - LOGIN authentication. The CSA engine driver (Csa_Engine.m) accepts +// any LOGIN line and replies with `LOGIN: OK`; this server only +// handles the transport. +// - Multi-client fan-out. +// +// All log lines are tagged [CSA] in the shared kiouenginebridge.log. +// =========================================================================== + +// --------------------------------------------------------------------------- +// Tunables. +// --------------------------------------------------------------------------- +#define CSA_QUEUE_DROP_THRESHOLD 128 +#define CSA_RECV_CHUNK 4096 +#define CSA_LINE_MAX 65536 // hard cap on a single line + +// --------------------------------------------------------------------------- +// Module state. Assumes one server per process (one KEBCsaServerStart call). +// --------------------------------------------------------------------------- +static dispatch_queue_t g_acceptQueue = NULL; +static dispatch_queue_t g_recvQueue = NULL; +static dispatch_source_t g_listenSrc = NULL; +static int g_listenFd = -1; +static _Atomic int g_clientFd = -1; +static _Atomic uint32_t g_pendingSends = 0; + +static kiou_csa_line_handler_t g_lineHandler = NULL; + +// --------------------------------------------------------------------------- +// Socket plumbing — mirrors Server_WebSocket.m's helpers with the WS-side +// nomenclature swapped to CSA. The semantics are identical: we want +// non-blocking accepts, tight keepalive intervals, and blocking I/O on the +// client fd so the recv-queue loop has a clean stream. +// --------------------------------------------------------------------------- +static void csa_set_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags >= 0) fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +static void csa_set_keepalive(int fd) { + int on = 1; + (void)setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)); + int idle = 5, intvl = 3, count = 3; + (void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPALIVE, &idle, sizeof(idle)); + (void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl)); + (void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); +} + +static BOOL csa_send_all(int fd, const uint8_t *buf, size_t len) { + size_t off = 0; + while (off < len) { + ssize_t n = send(fd, buf + off, len - off, 0); + if (n < 0) { + if (errno == EINTR) continue; + return NO; + } + if (n == 0) return NO; + off += (size_t)n; + } + return YES; +} + +static void csa_close_client(void) { + int fd = atomic_exchange(&g_clientFd, -1); + bool wasUp = (fd >= 0); + if (fd >= 0) { + close(fd); + } + atomic_store(&g_pendingSends, 0); + if (wasUp) { + // Let the CSA engine driver reset its state machine. Symbol always + // resolves because Csa_Stubs.m provides a no-op until Task 4 lands + // the real driver. + CsaEngineOnTcpClientDisconnected(); + } +} + +// --------------------------------------------------------------------------- +// Inbound line loop — read bytes off the wire, slice on LF, dispatch one +// trimmed line at a time. CRLF is normalized to LF before dispatch so the +// handler can treat its input as POSIX text. +// --------------------------------------------------------------------------- +static void csa_client_recv_loop(int fd) { + NSMutableData *acc = [NSMutableData dataWithCapacity:CSA_RECV_CHUNK]; + uint8_t chunk[CSA_RECV_CHUNK]; + + while (1) { + ssize_t n = recv(fd, chunk, sizeof(chunk), 0); + if (n < 0) { + if (errno == EINTR) continue; + IPALog([NSString stringWithFormat: + @"[CSA] recv errno=%d, exiting loop", errno]); + break; + } + if (n == 0) { + IPALog(@"[CSA] peer closed connection"); + break; + } + + [acc appendBytes:chunk length:(NSUInteger)n]; + if (acc.length > CSA_LINE_MAX) { + IPALog([NSString stringWithFormat: + @"[CSA] line buffer overflowed %d bytes, closing", + CSA_LINE_MAX]); + break; + } + + // Drain every complete line currently in the buffer. A CSA line is + // terminated by LF; CR is tolerated by stripping it from the end of + // the slice before dispatching. + while (1) { + const uint8_t *bytes = (const uint8_t *)acc.bytes; + NSUInteger len = acc.length; + NSUInteger nl = NSNotFound; + for (NSUInteger i = 0; i < len; i++) { + if (bytes[i] == '\n') { nl = i; break; } + } + if (nl == NSNotFound) break; + + NSUInteger lineLen = nl; + if (lineLen > 0 && bytes[lineLen - 1] == '\r') lineLen--; + NSString *line = (lineLen == 0) + ? @"" + : [[NSString alloc] initWithBytes:bytes + length:lineLen + encoding:NSUTF8StringEncoding]; + // Drop the line + its terminator from the buffer. + [acc replaceBytesInRange:NSMakeRange(0, nl + 1) + withBytes:NULL + length:0]; + if (line == nil) { + IPALog(@"[CSA] dropped non-UTF8 line"); + continue; + } + IPALog([NSString stringWithFormat:@"[CSA<] %@", line]); + if (g_lineHandler) g_lineHandler(line); + } + } + + IPALog(@"[CSA] client recv loop exited"); + dispatch_async(g_acceptQueue, ^{ + csa_close_client(); + }); +} + +// --------------------------------------------------------------------------- +// accept(): new-client-wins, then hand the fd off to the recv queue. +// --------------------------------------------------------------------------- +static void csa_handle_accept(void) { + struct sockaddr_in peer; + socklen_t peerLen = sizeof(peer); + int fd = accept(g_listenFd, (struct sockaddr *)&peer, &peerLen); + if (fd < 0) { + if (errno != EAGAIN && errno != EWOULDBLOCK) { + IPALog([NSString stringWithFormat: + @"[CSA] accept errno=%d", errno]); + } + return; + } + + char ip[INET_ADDRSTRLEN] = {0}; + inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip)); + + // New-client-wins. A stale recv loop parked on a dead fd would + // otherwise reject the new peer; CSA engines tend to reconnect after + // crashes, and we want the most recent connect to be authoritative. + if (atomic_load(&g_clientFd) >= 0) { + IPALog([NSString stringWithFormat: + @"[CSA] preempt: closing prior client fd=%d to make room " + @"for %s:%u", + g_clientFd, ip, (unsigned)ntohs(peer.sin_port)]); + csa_close_client(); + } + + IPALog([NSString stringWithFormat:@"[CSA] accepted from %s:%u fd=%d", + ip, (unsigned)ntohs(peer.sin_port), fd]); + + // Darwin propagates O_NONBLOCK from the listen socket to the accept + // fd; flip it back so the recv loop can block on its own queue. + int flags = fcntl(fd, F_GETFL, 0); + if (flags >= 0) fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); + + csa_set_keepalive(fd); + atomic_store(&g_clientFd, fd); + CsaEngineOnTcpClientConnected(); + + dispatch_async(g_recvQueue, ^{ + csa_client_recv_loop(fd); + }); +} + +// --------------------------------------------------------------------------- +// Public API. +// --------------------------------------------------------------------------- +void KEBCsaServerStart(uint16_t port) { + if (g_listenFd >= 0) { + IPALog(@"[CSA] server already running"); + return; + } + + g_acceptQueue = dispatch_queue_create("io.kiou.csa.tcp.accept", + DISPATCH_QUEUE_SERIAL); + g_recvQueue = dispatch_queue_create("io.kiou.csa.tcp.recv", + DISPATCH_QUEUE_SERIAL); + + int s = socket(AF_INET, SOCK_STREAM, 0); + if (s < 0) { + IPALog([NSString stringWithFormat:@"[CSA] socket errno=%d", errno]); + return; + } + + int one = 1; + setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_ANY); + if (bind(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + IPALog([NSString stringWithFormat:@"[CSA] bind errno=%d port=%u", + errno, (unsigned)port]); + close(s); + return; + } + if (listen(s, 4) < 0) { + IPALog([NSString stringWithFormat:@"[CSA] listen errno=%d", errno]); + close(s); + return; + } + csa_set_nonblock(s); + g_listenFd = s; + + g_listenSrc = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, + (uintptr_t)s, 0, g_acceptQueue); + dispatch_source_set_event_handler(g_listenSrc, ^{ + csa_handle_accept(); + }); + dispatch_resume(g_listenSrc); + + IPALog([NSString stringWithFormat:@"[CSA] listening on 0.0.0.0:%u", + (unsigned)port]); +} + +void KEBCsaServerSetLineHandler(kiou_csa_line_handler_t fn) { + // Pointer write is atomic on arm64; the worst-case race is a single + // recv-loop iteration reading the previous handler. Csa_Engine.m + // installs its handler at constructor time before any client can + // connect, so the race is theoretical. + g_lineHandler = fn; +} + +void KEBCsaServerPush(NSString *line) { + if (!line) return; + if (!g_acceptQueue) return; // server never started + if (atomic_load(&g_clientFd) < 0) return; // no client attached + + uint32_t pending = atomic_load(&g_pendingSends); + if (pending >= CSA_QUEUE_DROP_THRESHOLD) { + if ((pending % 32) == 0) { + IPALog([NSString stringWithFormat: + @"[CSA] drop: backlog=%u", pending]); + } + return; + } + + atomic_fetch_add(&g_pendingSends, 1); + NSString *withNewline = [line hasSuffix:@"\n"] + ? [line copy] + : [line stringByAppendingString:@"\n"]; + + dispatch_async(g_acceptQueue, ^{ + int fd = atomic_load(&g_clientFd); + if (fd < 0) { + atomic_fetch_sub(&g_pendingSends, 1); + return; + } + NSData *data = [withNewline dataUsingEncoding:NSUTF8StringEncoding]; + BOOL ok = csa_send_all(fd, data.bytes, data.length); + atomic_fetch_sub(&g_pendingSends, 1); + if (!ok) { + IPALog(@"[CSA] send failed, dropping client"); + csa_close_client(); + } + }); +} + +void KEBCsaServerClose(void) { + if (!g_acceptQueue) return; + dispatch_async(g_acceptQueue, ^{ + if (atomic_load(&g_clientFd) >= 0) { + IPALog(@"[CSA] KEBCsaServerClose: tearing down client"); + csa_close_client(); + } + }); +} diff --git a/Sources/KiouEngineBridge/Server_WebSocket.m b/Sources/KiouEngineBridge/Server_WebSocket.m deleted file mode 100644 index f3b1b3c..0000000 --- a/Sources/KiouEngineBridge/Server_WebSocket.m +++ /dev/null @@ -1,562 +0,0 @@ -#import "Internal.h" - -#import -#import -#import -#import -#import -#import -#import -#import - -// =========================================================================== -// Server_WebSocket — minimal RFC 6455 server, one client at a time. -// -// Lives entirely inside the tweak process. Listens on 0.0.0.0: and lets -// a single host (the TypeScript bridge running on a Mac / Linux box on the -// same LAN) connect. Every observed move / snapshot is shipped as one JSON -// object inside a single text frame. No fragmentation, no compression. -// -// What it implements: -// - Listen socket via GCD dispatch source (DISPATCH_SOURCE_TYPE_READ on -// a non-blocking accepting socket). -// - One concurrent client. A second incoming connection is accepted only -// to immediately close it with HTTP 409 so the network stack stops -// half-opening the TCP handshake. -// - HTTP/1.1 Upgrade handshake on the only accepted connection. Verifies -// Sec-WebSocket-Key, replies with the SHA-1+base64 accept token. -// - Text frames out (opcode 0x1, FIN=1, mask=0). Two-byte and eight-byte -// extended payload-length forms are both supported. -// - Inbound Ping (0x9) → Pong (0xA), inbound Close (0x8) → tear down. Any -// other inbound opcode is logged and dropped. -// - A serial GCD queue funnels every kiou_ws_server_push() through one -// producer-consumer slot. If the queue length crosses a soft cap we -// drop the oldest pending frame and log a [WS] warning. This keeps -// a stalled host from back-pressuring the Unity main thread. -// -// What it deliberately doesn't do: -// - TLS. The tweak runs on a LAN; the upstream host is trusted. No certs -// to ship, no entitlement to coax. -// - Per-message-deflate. tsshogi-friendly text frames are tiny. -// - Multi-client fan-out. One bridge, one debugger — keep it boring. -// - Backpressure / flow control beyond the drop policy above. -// -// All log lines tagged [WS] land in the shared kiouenginebridge.log via -// file_log() so a quiet socket is still debuggable from outside. -// =========================================================================== - -// --------------------------------------------------------------------------- -// Tunables. Cap is deliberately low — the realistic event rate is ~2 / sec. -// --------------------------------------------------------------------------- -#define WS_QUEUE_DROP_THRESHOLD 128 -#define WS_HANDSHAKE_MAX_BYTES 8192 -#define WS_RECV_CHUNK 2048 -#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - -// --------------------------------------------------------------------------- -// Singleton-ish state. The whole module assumes at most one server per -// process, which matches the way Tweak.m calls kiou_ws_server_start() once. -// --------------------------------------------------------------------------- -static dispatch_queue_t g_acceptQueue = NULL; // serial, handshake + writes -static dispatch_queue_t g_recvQueue = NULL; // serial, blocking client reads -static dispatch_source_t g_listenSrc = NULL; // listen-fd readable source -static int g_listenFd = -1; -static int g_clientFd = -1; // -1 = no client -static BOOL g_clientHandshakeDone = NO; -static NSUInteger g_pendingSends = 0; // best-effort backlog gauge - -// Inbound text-frame callback. Inject_Move.m self-registers via -// kiou_ws_server_set_text_handler() at constructor time. Reads happen on -// g_recvQueue; the setter just stores the function pointer (8-byte aligned -// pointer writes are atomic on arm64 so we don't bother with a barrier). -static kiou_ws_text_handler_t g_textHandler = NULL; - -// --------------------------------------------------------------------------- -// Helpers — socket plumbing -// --------------------------------------------------------------------------- -static void ws_set_nonblock(int fd) { - int flags = fcntl(fd, F_GETFL, 0); - if (flags >= 0) fcntl(fd, F_SETFL, flags | O_NONBLOCK); -} - -// Enable SO_KEEPALIVE with aggressive timers so a silently-dead peer -// (= bridge SIGKILL'd, network cable yanked) is detected within ~15s -// rather than the Darwin default of ~2 hours. Without this the recv -// loop can sit blocked on a peer that's been gone forever, with -// g_clientFd still held, which means the next bridge connect attempt -// gets refused with the "Already serving someone" 409 below. -// -// TCP_KEEPALIVE is Darwin's name for the idle-time-before-probe knob. -// TCP_KEEPINTVL is the interval between probes once started. -// TCP_KEEPCNT is how many lost probes count as "peer is dead". -// 5s idle + 3s × 3 probes = peer death detected ~15s after a dirty drop. -// -// All four setsockopt calls are best-effort — if the kernel refuses one -// the worst case is we fall back to the OS default, which is "slow but -// works". -static void ws_set_keepalive(int fd) { - int on = 1; - (void)setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)); - int idle = 5; // seconds of idle before the first probe - int intvl = 3; // seconds between subsequent probes - int count = 3; // probes lost before declaring the peer dead - (void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPALIVE, &idle, sizeof(idle)); - (void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl)); - (void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); -} - -static BOOL ws_send_all(int fd, const uint8_t *buf, size_t len) { - size_t off = 0; - while (off < len) { - ssize_t n = send(fd, buf + off, len - off, 0); - if (n < 0) { - if (errno == EINTR) continue; - return NO; - } - if (n == 0) return NO; - off += (size_t)n; - } - return YES; -} - -// Blocking read of exactly `len` bytes. Returns NO on EOF / error. -static BOOL ws_recv_all(int fd, uint8_t *buf, size_t len) { - size_t off = 0; - while (off < len) { - ssize_t n = recv(fd, buf + off, len - off, 0); - if (n < 0) { - if (errno == EINTR) continue; - return NO; - } - if (n == 0) return NO; - off += (size_t)n; - } - return YES; -} - -static void ws_close_client(void) { - bool wasUp = (g_clientFd >= 0); - if (g_clientFd >= 0) { - close(g_clientFd); - g_clientFd = -1; - } - g_clientHandshakeDone = NO; - g_pendingSends = 0; - if (wasUp) { - // Phase 2: let the USI engine driver reset its state machine. Safe - // to call even if no engine module is wired (the symbol is always - // linked, since the same translation unit defines an empty stub - // when Usi_Engine.m is absent — not the case in this build). - usi_engine_on_ws_client_disconnected(); - } -} - -// --------------------------------------------------------------------------- -// Handshake — parse "Sec-WebSocket-Key: " out of the request and reply -// with the matching accept token. Permissive about headers we don't care -// about; we only need the key. -// --------------------------------------------------------------------------- -static NSString *ws_compute_accept(NSString *key) { - NSString *combo = [key stringByAppendingString:@WS_GUID]; - const char *cstr = combo.UTF8String; - unsigned char digest[CC_SHA1_DIGEST_LENGTH]; - CC_SHA1(cstr, (CC_LONG)strlen(cstr), digest); - NSData *data = [NSData dataWithBytes:digest length:CC_SHA1_DIGEST_LENGTH]; - return [data base64EncodedStringWithOptions:0]; -} - -static NSString *ws_extract_key(NSString *request) { - NSArray *lines = [request componentsSeparatedByString:@"\r\n"]; - for (NSString *line in lines) { - NSRange colon = [line rangeOfString:@":"]; - if (colon.location == NSNotFound) continue; - NSString *name = [[line substringToIndex:colon.location] - stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceCharacterSet]]; - if ([name caseInsensitiveCompare:@"Sec-WebSocket-Key"] != NSOrderedSame) continue; - NSString *value = [[line substringFromIndex:colon.location + 1] - stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceCharacterSet]]; - return value; - } - return nil; -} - -// Slurp the HTTP request up to and including the blank line. Cap at -// WS_HANDSHAKE_MAX_BYTES so a confused / hostile peer can't keep us reading. -static NSString *ws_read_handshake(int fd) { - NSMutableData *acc = [NSMutableData data]; - uint8_t chunk[WS_RECV_CHUNK]; - while (acc.length < WS_HANDSHAKE_MAX_BYTES) { - ssize_t n = recv(fd, chunk, sizeof(chunk), 0); - if (n < 0) { - if (errno == EINTR) continue; - return nil; - } - if (n == 0) return nil; - [acc appendBytes:chunk length:(NSUInteger)n]; - if ([acc rangeOfData:[@"\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding] - options:0 - range:NSMakeRange(0, acc.length)].location != NSNotFound) { - break; - } - } - return [[NSString alloc] initWithData:acc encoding:NSUTF8StringEncoding]; -} - -static BOOL ws_perform_handshake(int fd) { - NSString *request = ws_read_handshake(fd); - if (!request) { - file_log(@"[WS] handshake: client closed before request"); - return NO; - } - NSString *key = ws_extract_key(request); - if (!key) { - file_log(@"[WS] handshake: missing Sec-WebSocket-Key"); - const char *bad = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; - ws_send_all(fd, (const uint8_t *)bad, strlen(bad)); - return NO; - } - NSString *accept = ws_compute_accept(key); - NSString *response = [NSString stringWithFormat: - @"HTTP/1.1 101 Switching Protocols\r\n" - @"Upgrade: websocket\r\n" - @"Connection: Upgrade\r\n" - @"Sec-WebSocket-Accept: %@\r\n" - @"\r\n", accept]; - const char *bytes = response.UTF8String; - return ws_send_all(fd, (const uint8_t *)bytes, strlen(bytes)); -} - -// --------------------------------------------------------------------------- -// Frame emit — unmasked, opcode 0x1, FIN=1. -// --------------------------------------------------------------------------- -static BOOL ws_send_text(int fd, NSString *text) { - NSData *payload = [text dataUsingEncoding:NSUTF8StringEncoding]; - NSUInteger len = payload.length; - - uint8_t header[10]; - size_t headerLen = 0; - header[0] = 0x81; // FIN=1, opcode=text - if (len < 126) { - header[1] = (uint8_t)len; - headerLen = 2; - } else if (len < 65536) { - header[1] = 126; - header[2] = (uint8_t)((len >> 8) & 0xFF); - header[3] = (uint8_t)(len & 0xFF); - headerLen = 4; - } else { - header[1] = 127; - uint64_t l64 = (uint64_t)len; - for (int i = 0; i < 8; i++) { - header[2 + i] = (uint8_t)((l64 >> (8 * (7 - i))) & 0xFF); - } - headerLen = 10; - } - if (!ws_send_all(fd, header, headerLen)) { - file_log([NSString stringWithFormat: - @"[WS-DBG] ws_send_text header send FAILED len=%lu errno=%d", - (unsigned long)len, errno]); - return NO; - } - BOOL ok = ws_send_all(fd, payload.bytes, len); - if (!ok) { - file_log([NSString stringWithFormat: - @"[WS-DBG] ws_send_text body send FAILED len=%lu errno=%d", - (unsigned long)len, errno]); - } - return ok; -} - -static BOOL ws_send_pong(int fd, const uint8_t *payload, size_t len) { - uint8_t header[4]; - size_t headerLen; - header[0] = 0x8A; // FIN=1, opcode=pong - if (len < 126) { - header[1] = (uint8_t)len; - headerLen = 2; - } else { - // Pings shouldn't carry > 125 bytes per spec, but be defensive. - header[1] = 126; - header[2] = (uint8_t)((len >> 8) & 0xFF); - header[3] = (uint8_t)(len & 0xFF); - headerLen = 4; - } - if (!ws_send_all(fd, header, headerLen)) return NO; - if (len == 0) return YES; - return ws_send_all(fd, payload, len); -} - -// --------------------------------------------------------------------------- -// Inbound frame loop — runs on g_recvQueue while a client is attached. -// We expect Ping / Close / occasional small text frames from the host. The -// only mandatory work is responding to Ping and reacting to Close. -// --------------------------------------------------------------------------- -static void ws_client_recv_loop(int fd) { - while (1) { - uint8_t hdr[2]; - if (!ws_recv_all(fd, hdr, 2)) { - file_log(@"[WS-DBG] recv hdr failed (EOF or error)"); - break; - } - BOOL fin = (hdr[0] & 0x80) != 0; - uint8_t op = hdr[0] & 0x0F; - BOOL masked = (hdr[1] & 0x80) != 0; - uint64_t plen = hdr[1] & 0x7F; - (void)fin; - file_log([NSString stringWithFormat: - @"[WS-DBG] frame op=0x%x fin=%d masked=%d plen=%llu", - op, (int)fin, (int)masked, plen]); - - if (plen == 126) { - uint8_t ex[2]; - if (!ws_recv_all(fd, ex, 2)) break; - plen = ((uint64_t)ex[0] << 8) | ex[1]; - } else if (plen == 127) { - uint8_t ex[8]; - if (!ws_recv_all(fd, ex, 8)) break; - plen = 0; - for (int i = 0; i < 8; i++) plen = (plen << 8) | ex[i]; - } - - uint8_t mask[4] = {0}; - if (masked) { - if (!ws_recv_all(fd, mask, 4)) break; - } - - // Cap inbound payloads — we never need more than control-frame sized - // input. A megabyte ceiling kills runaway peers without making us - // worry about heap blowup. - if (plen > (1 << 20)) { - file_log([NSString stringWithFormat: - @"[WS] oversize inbound frame plen=%llu, closing", - plen]); - break; - } - - uint8_t *body = NULL; - if (plen > 0) { - body = (uint8_t *)malloc((size_t)plen); - if (!body) break; - if (!ws_recv_all(fd, body, (size_t)plen)) { free(body); break; } - if (masked) { - for (uint64_t i = 0; i < plen; i++) body[i] ^= mask[i & 3]; - } - } - - if (op == 0x8) { // close - file_log(@"[WS-DBG] received CLOSE from client, exiting loop"); - if (body) free(body); - break; - } else if (op == 0x9) { // ping - // Marshal the pong onto the accept queue — same queue that - // ws_send_text uses — so we don't interleave a pong frame - // halfway through an outbound text frame. Without this both - // ws_send_text (from kiou_ws_server_push -> accept queue) and - // ws_send_pong (from this recv queue) could be writing the same - // fd concurrently, fragmenting frames and tripping client-side - // protocol parsers (= Python websockets sees a corrupt frame - // after its 20s keepalive ping and closes the connection). - file_log(@"[WS-DBG] ping received, scheduling pong"); - NSData *pingPayload = (plen > 0 && body) - ? [NSData dataWithBytes:body length:(NSUInteger)plen] - : nil; - int captured_fd = fd; - dispatch_async(g_acceptQueue, ^{ - if (g_clientFd != captured_fd) return; - ws_send_pong(captured_fd, - pingPayload.bytes, - (size_t)pingPayload.length); - }); - } else if (op == 0x1) { // text - // The bridge sends raw USI lines here ("bestmove 7g7f", - // "bestmove resign"). Hand them to whichever handler the - // injection module installed; if nobody's listening, drop - // silently so a stray frame is a no-op rather than an error. - if (g_textHandler && body && plen > 0) { - g_textHandler((const char *)body, (size_t)plen); - } - } else if (op == 0x2 || op == 0xA) { - // binary / pong — informational; the bridge never sends these. - } else { - file_log([NSString stringWithFormat: - @"[WS] dropping inbound opcode 0x%x", op]); - } - if (body) free(body); - } - - file_log(@"[WS] client recv loop exited"); - dispatch_async(g_acceptQueue, ^{ - ws_close_client(); - }); -} - -// --------------------------------------------------------------------------- -// Listen-source handler — accept one connection, run the handshake on the -// accept queue, then hand the fd off to the recv queue's blocking loop. -// --------------------------------------------------------------------------- -static void ws_handle_accept(void) { - struct sockaddr_in peer; - socklen_t peerLen = sizeof(peer); - int fd = accept(g_listenFd, (struct sockaddr *)&peer, &peerLen); - if (fd < 0) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { - file_log([NSString stringWithFormat:@"[WS] accept errno=%d", errno]); - } - return; - } - - char ip[INET_ADDRSTRLEN] = {0}; - inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip)); - - // New-client-wins policy. The previous "1 client only, second one - // gets 409" rule made bridge restarts painful: a Ctrl-C'd bridge can - // leave the TCP session in TIME_WAIT on its side, the kernel here - // hasn't yet noticed EOF, the recv loop is still parked on a dead - // fd, and the freshly-spawned bridge eats a 409. Since the bridge - // is the only legitimate WS client (control of the engine — - // observers are out of scope on this socket), an incoming connect - // is unambiguous evidence that the previous holder is gone. Close - // the old fd from the accept queue (same queue ws_close_client - // runs on — no cross-queue race), then carry on accepting the new - // peer. - if (g_clientFd >= 0) { - file_log([NSString stringWithFormat: - @"[WS] preempt: closing prior client fd=%d to make room " - @"for %s:%u", - g_clientFd, ip, (unsigned)ntohs(peer.sin_port)]); - ws_close_client(); - } - - file_log([NSString stringWithFormat:@"[WS] accepted from %s:%u fd=%d", - ip, (unsigned)ntohs(peer.sin_port), fd]); - - // accept() inherits the non-blocking flag from the listen socket on Darwin, - // which breaks our blocking ws_recv_all loop (it returns immediately with - // EAGAIN and we mistake that for EOF). Force the client fd back to - // blocking I/O — the recv loop lives on its own dispatch queue, so - // blocking there is fine. - int flags = fcntl(fd, F_GETFL, 0); - if (flags >= 0) fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); - - // SO_KEEPALIVE + tight intervals: dead peers detected in ~15s - // instead of the Darwin default 2-hour idle. Keeps the recv loop - // from blocking forever on a bridge that vanished without sending - // a Close frame. - ws_set_keepalive(fd); - - if (!ws_perform_handshake(fd)) { - close(fd); - return; - } - g_clientFd = fd; - g_clientHandshakeDone = YES; - file_log(@"[WS] handshake OK"); - // Phase 2: hand off to the USI engine driver. It owns the post-handshake - // protocol (sending `usi`, awaiting `usiok`, etc.). Server_WebSocket is - // back to being a plain transport. - usi_engine_on_ws_client_connected(); - - // Drain inbound on a separate queue so the accept queue is never blocked - // by a slow / silent peer. - dispatch_async(g_recvQueue, ^{ - ws_client_recv_loop(fd); - }); -} - -// --------------------------------------------------------------------------- -// Public API. -// --------------------------------------------------------------------------- -void kiou_ws_server_start(uint16_t port) { - if (g_listenFd >= 0) { - file_log(@"[WS] server already running"); - return; - } - - g_acceptQueue = dispatch_queue_create("io.kiou.usi.ws.accept", - DISPATCH_QUEUE_SERIAL); - g_recvQueue = dispatch_queue_create("io.kiou.usi.ws.recv", - DISPATCH_QUEUE_SERIAL); - - int s = socket(AF_INET, SOCK_STREAM, 0); - if (s < 0) { - file_log([NSString stringWithFormat:@"[WS] socket errno=%d", errno]); - return; - } - - int one = 1; - setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); - - struct sockaddr_in addr = {0}; - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - addr.sin_addr.s_addr = htonl(INADDR_ANY); - if (bind(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - file_log([NSString stringWithFormat:@"[WS] bind errno=%d port=%u", - errno, (unsigned)port]); - close(s); - return; - } - if (listen(s, 4) < 0) { - file_log([NSString stringWithFormat:@"[WS] listen errno=%d", errno]); - close(s); - return; - } - ws_set_nonblock(s); - g_listenFd = s; - - g_listenSrc = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, - (uintptr_t)s, 0, g_acceptQueue); - dispatch_source_set_event_handler(g_listenSrc, ^{ - ws_handle_accept(); - }); - dispatch_resume(g_listenSrc); - - file_log([NSString stringWithFormat:@"[WS] listening on 0.0.0.0:%u", - (unsigned)port]); -} - -void kiou_ws_server_set_text_handler(kiou_ws_text_handler_t fn) { - // Pointer write — atomic on arm64. No barrier needed; the worst-case - // race is a single recv-loop iteration reading the previous handler, - // which is fine because handlers are always installed before the bridge - // can finish its TCP handshake. - g_textHandler = fn; -} - -void kiou_ws_server_push(NSString *json) { - if (!json) return; - if (!g_acceptQueue) return; // server never started - - // Cheap shortcut: no client means nothing to do. We still allow the log - // sinks to record everything; the WS path is purely additive. - if (g_clientFd < 0 || !g_clientHandshakeDone) return; - - if (g_pendingSends >= WS_QUEUE_DROP_THRESHOLD) { - // Back-pressure: don't pile up. file_log only every now and then or - // a stuck host floods the disk. - if ((g_pendingSends % 32) == 0) { - file_log([NSString stringWithFormat: - @"[WS] drop: backlog=%lu", - (unsigned long)g_pendingSends]); - } - return; - } - - g_pendingSends++; - NSString *copy = [json copy]; - dispatch_async(g_acceptQueue, ^{ - int fd = g_clientFd; - if (fd < 0) { - g_pendingSends--; - return; - } - BOOL ok = ws_send_text(fd, copy); - g_pendingSends--; - if (!ok) { - file_log(@"[WS] send failed, dropping client"); - ws_close_client(); - } - }); -} diff --git a/Sources/KiouEngineBridge/Tweak.m b/Sources/KiouEngineBridge/Tweak.m index d12871a..fc155c9 100644 --- a/Sources/KiouEngineBridge/Tweak.m +++ b/Sources/KiouEngineBridge/Tweak.m @@ -47,34 +47,58 @@ static void installUnityHooks(void) { g_unityBase = unityBase; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"UnityFramework base=0x%lx (%s)", (unsigned long)unityBase, unityName ? unityName : "?"]); - install_OnlineObserve_hook(unityBase); - install_LowLevelObserve_hook(unityBase); - install_MatchModeObserve_hook(unityBase); +#if KIOU_BINPATCH + // On the binpatch build, every observation hook is wired by the static + // cave at app launch (recipes/kiouenginebridge.py); all we need at + // runtime is to publish the dispatcher pointer into the __DATA,__bss + // SLOT so the cave's ADRP+LDR resolves to a real function pointer. + // Inject_Move is still a symbol-only resolver (no MSHookFunction) so it + // runs in both flavours. USI engine init must come after Inject_Move so + // inject_apply is wired before the WS handler can reach it. + KEBBridgeBinpatchPublish(); + InstallLowLevelObserveHook(unityBase); // symbol pointer resolves only + // No-op on binpatch (see Hook_MatchModeObserve.m's binpatch installer). + // orig_*OnPlayerMoveAsync is intentionally left NULL so the route picker + // falls through to KIOU_BR_BINPATCH_ORIG_OR_BYPASS, which returns the + // per-site cave-bypass entry (cave + KIOU_BR_CAVE_BYPASS_OFFSET). The + // inject path then calls that bypass entry — calling orig_* directly + // would re-enter the dispatcher cave because unityBase+RVA is the + // patched `B ` instruction. + InstallMatchModeObserveHook(unityBase); + InstallInjectHook(unityBase); + // CSA engine driver. Must come AFTER InstallInjectHook so inject_apply + // is fully wired before the CSA recv queue can dispatch into it. + CsaEngineInstall(); +#else + InstallOnlineObserveHook(unityBase); + InstallLowLevelObserveHook(unityBase); + InstallMatchModeObserveHook(unityBase); // Inject_Move needs the observation hooks above already in place so it // can lean on their `orig_*` pointers and self caches. - install_Inject_hook(unityBase); + InstallInjectHook(unityBase); // Pin GameOrchestrator.IsAfkEnabled to false so the "tap within 15s" // popup never spawns during long engine thinking. Independent of all // other hooks; install order doesn't matter for it. - install_AfkSuppress_hook(unityBase); + InstallAfkSuppressHook(unityBase); // Capture the GameOrchestrator instance the moment GameScene calls // ActivateAsync. The match-end auto-rematch path needs this `self` to // invoke OnEndSequenceCompleted on it. - install_GameOrchestratorObserve_hook(unityBase); + InstallGameOrchestratorObserveHook(unityBase); // Capture GameStateStore.Set*PlayerInfo so Meta_Emitter can emit // match_start with the matchmaking-resolved opponent identity on // Online matches (MatchConfig alone holds placeholders there). - install_GameStateStoreObserve_hook(unityBase); - // Phase 2: USI engine driver. Must come AFTER install_Inject_hook so - // inject_apply is fully wired before the WS handler can call into it. - usi_engine_install(); + InstallGameStateStoreObserveHook(unityBase); + // CSA engine driver. Must come AFTER InstallInjectHook so inject_apply + // is fully wired before the CSA recv queue can dispatch into it. + CsaEngineInstall(); +#endif g_unityHooked = YES; - file_log(@"=== KiouEngineBridge: all hooks installed ==="); + IPALog(@"=== KiouEngineBridge: all hooks installed ==="); } static void retryInstallHooks(void) { @@ -89,14 +113,30 @@ static void retryInstallHooks(void) { } __attribute__((constructor)) static void init(void) { - logging_init("com.neconome.shogi.kiouenginebridge"); - file_log(@"=== KiouEngineBridge loaded ==="); - file_log([NSString stringWithFormat:@"build commit=%s", KIOU_ENGINE_BRIDGE_COMMIT]); - - // Bring the WebSocket sink up as early as possible. It binds 0.0.0.0:9527 - // and just sits there until a host connects — no host attached means - // every kiou_ws_server_push() call below is a no-op. - kiou_ws_server_start(9527); + IPALoggingInit("com.neconome.shogi.kiouenginebridge"); + IPALog(@"=== KiouEngineBridge loaded ==="); + // Build identity so a stray log file can be matched back to the exact + // dylib that wrote it. Flavor distinguishes JB (libsubstrate) / jailed + // (Dobby-static) / binpatch (static cave + SLOT dispatcher). +#if KIOU_BINPATCH + static const char *const kBuildFlavor = "binpatch"; +#elif IPA_JAILED + static const char *const kBuildFlavor = "jailed"; +#else + static const char *const kBuildFlavor = "jb"; +#endif + IPALog([NSString stringWithFormat: + @"build commit=%s flavor=%s built=%s %s", + KIOU_ENGINE_BRIDGE_COMMIT, kBuildFlavor, + __DATE__, __TIME__]); + + // CSA migration Task 3: bind the CSA TCP server on 0.0.0.0:4081 as + // early as possible. Without a client attached, every KEBCsaServerPush + // call below is a silent no-op. The Csa_Engine state machine (Task 4) + // installs its line handler against KEBCsaServerSetLineHandler so + // inbound LOGIN / AGREE / move / %TORYO lines route through to the + // driver as soon as the engine connects. + KEBCsaServerStart(4081); // UnityFramework is almost certainly not mapped yet at constructor time. installUnityHooks(); @@ -106,5 +146,5 @@ static void retryInstallHooks(void) { retryInstallHooks(); }); - file_log(@"=== KiouEngineBridge constructor done ==="); + IPALog(@"=== KiouEngineBridge constructor done ==="); } diff --git a/Sources/KiouEngineBridge/Usi_Engine.m b/Sources/KiouEngineBridge/Usi_Engine.m deleted file mode 100644 index 7f35877..0000000 --- a/Sources/KiouEngineBridge/Usi_Engine.m +++ /dev/null @@ -1,462 +0,0 @@ -#import "Internal.h" - -#import -#import - -// =========================================================================== -// Usi_Engine — KiouEngineBridge as a USI client driving an external engine. -// -// Phase 2 architecture: -// tweak (us) = pure translator between Kiou Engine (the in-app -// il2cpp CPU) and the external USI engine. We own -// the position view (= KIOU's internal state) and -// hand it off as a USI `position` line. We do NOT -// configure the engine and we do NOT drive its -// thinking — that's the bridge's job. -// bridge (TypeScript) = sits between us and the USI engine. Owns all -// engine setup (`setoption ...`) and thinking -// cadence (`go ...`). Forwards our handshake + -// `position` lines into the engine, and forwards -// the engine's `bestmove` back to us. -// USI engine = whatever the bridge spawns (YaneuraOu w/ Suisho5 -// NNUE, etc). We never speak to it directly. -// -// Flow (one half-move): -// -// KIOU's opponent makes a move -// ↓ Hook_LowLevelObserve::hook_AdapterTryMakeMoveOut fires -// ↓ usi_engine_on_move_observed(usi, sfen_after, side_to_move) -// ↓ side_to_move == g_LocalPlayer ? -// yes → usi_engine_send_line("position sfen ") -// state = THINKING -// (bridge sees the position line and triggers the engine to -// think with its own `go` cadence) -// no → (do nothing, wait for the next move) -// -// YaneuraOu thinks, sends "bestmove 7g7f" -// ↓ usi_engine_handle_inbound_line("bestmove 7g7f") -// ↓ state = INJECTING -// ↓ inject_apply("7g7f") — uses the Phase 1 OPM + Adapter pipeline -// ↓ state = READY (the inject itself fires hook_AdapterTryMakeMoveOut -// again, which sees side_to_move != localPlayer and -// skips, so we don't loop on our own injection) -// -// What this file does NOT do: -// * Generate moves. The whole point is to delegate to a real engine. -// * Touch il2cpp directly. inject_apply does that on the main thread. -// * Block the recv queue while waiting for bestmove. state is atomic and -// observation hooks fire on Unity threads, completely independent of -// the ws recv path. -// =========================================================================== - -// --------------------------------------------------------------------------- -// Tweak is just a translator between Kiou Engine (in-app il2cpp CPU) and -// the external USI engine. Thinking parameters live in two places: -// - YaneuraOu side : bridge sends `setoption ...` before usiok -// - Kiou Engine : decided inside the app, not our concern -// We send `position sfen ...` on the tweak side; the bridge observes that -// line and injects whatever `go ...` it wants into the engine. Thus tweak -// itself never emits a `go` line. -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// State. All access goes through stdatomic so the recv queue (where USI -// lines come in) and the Unity main thread (where hook callbacks fire) -// don't trip over each other. The state machine is small enough that a -// single _Atomic int suffices. -// --------------------------------------------------------------------------- -static _Atomic int g_usiState = USI_STATE_BOOT; - -// Last `info string` (just the value, not the whole line) — useful when -// debugging engine behavior. Single-writer single-reader so a plain -// pointer is fine. -static NSString *g_lastInfoString = nil; - -// Seat assignment for the live match. -1 = no fixed seat (LocalPvP / -// RecordReplay) or no live match. -static _Atomic int g_localPlayerSide = -1; - -// "We expect an inject to fire this exact usi" — set when we send -// `position`+`go`, used to suppress the post-inject reentry into the -// hook callback (the inject itself causes hook_AdapterTryMakeMoveOut to -// run again, but we don't want THAT to trigger another `position`+`go`). -static NSString *g_expectedNextUsi = nil; - -// --------------------------------------------------------------------------- -// Outbound — funnel all USI lines through one helper so the log shows -// exactly what the engine sees. -// --------------------------------------------------------------------------- -void usi_engine_send_line(NSString *line) { - if (line.length == 0) return; - NSString *withNewline = [line hasSuffix:@"\n"] - ? line - : [line stringByAppendingString:@"\n"]; - file_log([NSString stringWithFormat:@"[USI>] %@", - [line stringByReplacingOccurrencesOfString:@"\n" - withString:@"\\n"]]); - kiou_ws_server_push(withNewline); -} - -// --------------------------------------------------------------------------- -// State helpers. -// --------------------------------------------------------------------------- -static const char *usi_state_name(int s) { - switch (s) { - case USI_STATE_BOOT: return "BOOT"; - case USI_STATE_HANDSHAKE: return "HANDSHAKE"; - case USI_STATE_READY: return "READY"; - case USI_STATE_THINKING: return "THINKING"; - case USI_STATE_INJECTING: return "INJECTING"; - default: return "?"; - } -} - -static void usi_set_state(int newState) { - int old = atomic_exchange(&g_usiState, newState); - if (old != newState) { - file_log([NSString stringWithFormat:@"[USI] state %s -> %s", - usi_state_name(old), usi_state_name(newState)]); - } -} - -// Forward decl — defined further down once usi_engine_request_thinking -// is in scope. Both match_start and the readyok handler call this. -static void usi_engine_try_kick_on_main(NSString *tag); - -// --------------------------------------------------------------------------- -// Match lifecycle. Called from Hook_MatchModeObserve.m. -// --------------------------------------------------------------------------- -void usi_engine_on_match_start(int32_t local_player) { - atomic_store(&g_localPlayerSide, local_player); - file_log([NSString stringWithFormat: - @"[USI] match_start local_player=%d state=%s", - (int)local_player, - usi_state_name(atomic_load(&g_usiState))]); - - // NOTE: we do NOT send `usinewgame` here. The readyok handler already - // sent one when the bridge connected, and the engine sits in "ready - // for a new game" until the first `position` arrives. Sending a second - // `usinewgame` confused the trace (two back-to-back ones in the log) - // and bought us nothing — USI engines don't need re-priming per match. - - // First-move kick: if we're the side to move at match start (e.g. - // playing sente against the in-app CPU), no observation will fire - // until after our move. Read the current SFEN and, if it's our turn, - // ship it to the bridge so YaneuraOu can answer. - // - // 0.5s delay lets KIOU's mode code finish wiring the GameController - // and authoritative SFEN — at t=0 inject_currentSfen() often returns - // empty because nothing has populated GameCtrl yet. - if (local_player == 0 || local_player == 1) { - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(0.5 * NSEC_PER_SEC)), - dispatch_get_main_queue(), ^{ - usi_engine_try_kick_on_main(@"match_start"); - }); - } -} - -void usi_engine_on_match_end(usi_match_result_t result) { - // Tell the bridge the match is over BEFORE we touch our own state. - // The USI spec says the user (us) sends `gameover {win|lose|draw}` to - // the engine when the match ends; the bridge in turn forwards it to - // YaneuraOu so it can run its end-of-game bookkeeping (clear its - // think state, free its position, etc) before the next `position` + - // `go` arrives. We omit the gameover line for USI_RESULT_UNKNOWN - // (open-seat modes where we can't tell the outcome) — sending a - // wrong win/lose to the engine is worse than sending nothing. - NSString *resultWord = nil; - switch (result) { - case USI_RESULT_WIN: resultWord = @"win"; break; - case USI_RESULT_LOSE: resultWord = @"lose"; break; - case USI_RESULT_DRAW: resultWord = @"draw"; break; - case USI_RESULT_UNKNOWN: - default: - break; - } - if (resultWord) { - usi_engine_send_line([NSString stringWithFormat:@"gameover %@", - resultWord]); - } else { - file_log(@"[USI] match_end: result unknown, suppressing gameover"); - } - - atomic_store(&g_localPlayerSide, -1); - file_log([NSString stringWithFormat: - @"[USI] match_end result=%@ — resetting state", - resultWord ?: @""]); - // Drop back to READY (not BOOT) — the engine is still connected and - // through its handshake; we just want a fresh game next time. - int s = atomic_load(&g_usiState); - if (s != USI_STATE_BOOT && s != USI_STATE_HANDSHAKE) { - usi_set_state(USI_STATE_READY); - } - g_expectedNextUsi = nil; -} - -// --------------------------------------------------------------------------- -// Outbound: send `position sfen ...` only. The bridge observes the -// position line on the WS, then writes its own `go` to the engine's -// stdin (the engine is on the bridge side, not the tweak side, so -// nothing crosses the WS for go). Thinking limits live entirely in the -// engine via `setoption ...` configured by the bridge at startup -// (DepthLimit, etc). Once the line goes out we flip to THINKING and -// wait for `bestmove` to come back over the WS. -// --------------------------------------------------------------------------- -static void usi_engine_request_thinking(NSString *sfen) { - if (sfen.length == 0) { - file_log(@"[USI] request_thinking skipped: empty sfen"); - return; - } - usi_engine_send_line([NSString stringWithFormat:@"position sfen %@", - sfen]); - usi_set_state(USI_STATE_THINKING); -} - -// Try to kick a position+think for the current board, given that we may -// already know the seat and have a usable SFEN reachable from the il2cpp -// helpers. Used both right after `readyok` (in case the bridge connected -// mid-game) and right after match_start (in case we're sente and the -// opponent will never trigger an ADAPTER2 observation for us). Must run -// on the main thread — inject_currentSfen() touches il2cpp accessors. -// -// `tag` is just for the file log so it's obvious which path called us. -static void usi_engine_try_kick_on_main(NSString *tag) { - int seat = atomic_load(&g_localPlayerSide); - if (seat != 0 && seat != 1) { - file_log([NSString stringWithFormat: - @"[USI] kick(%@): no fixed seat yet, " - @"waiting for first observation", tag]); - return; - } - NSString *sfen = inject_currentSfen(); - if (sfen.length == 0) { - file_log([NSString stringWithFormat: - @"[USI] kick(%@): no SFEN available yet", tag]); - return; - } - // Inspect the side-to-move character at sfen[1] (after the board). - int32_t sideToMove = -1; - NSArray *parts = [sfen componentsSeparatedByString:@" "]; - if (parts.count >= 2) { - NSString *s = parts[1]; - if ([s isEqualToString:@"b"]) sideToMove = 0; - else if ([s isEqualToString:@"w"]) sideToMove = 1; - } - if (sideToMove != seat) { - file_log([NSString stringWithFormat: - @"[USI] kick(%@): not our turn (side=%d seat=%d)", - tag, (int)sideToMove, (int)seat]); - return; - } - if (atomic_load(&g_usiState) != USI_STATE_READY) { - file_log([NSString stringWithFormat: - @"[USI] kick(%@): state not READY (%s), skipping", - tag, usi_state_name(atomic_load(&g_usiState))]); - return; - } - file_log([NSString stringWithFormat: - @"[USI] kick(%@): starting thinking with current board", tag]); - usi_engine_request_thinking(sfen); -} - -// --------------------------------------------------------------------------- -// Observation callback: every time the game's adapter applies a move, we -// look at whose turn is next. If it's ours, ask the engine to think. -// --------------------------------------------------------------------------- -void usi_engine_on_move_observed(NSString *usi, - NSString *sfen_after, - int32_t side_to_move) { - int s = atomic_load(&g_usiState); - int seat = atomic_load(&g_localPlayerSide); - - file_log([NSString stringWithFormat: - @"[USI] observed usi=%@ side_to_move=%d local=%d state=%s " - @"expected=%@", - usi ?: @"", - (int)side_to_move, (int)seat, - usi_state_name(s), - g_expectedNextUsi ?: @""]); - - // Any observation while we're in INJECTING means the inject's flow has - // played out (either KIOU applied our move and the opponent already - // replied, or the user nudged the board themselves). Either way, the - // bestmove cycle is over — snap back to READY so the next request - // isn't skipped. We do this regardless of whose turn is next. - // - // The original "wait for ADAPTER2 to echo our own move via - // g_expectedNextUsi" design didn't pan out because the inject path - // calls orig_AdapterTryMakeMoveOut directly, which bypasses our hook - // trampoline, so the echo never fires. - if (s == USI_STATE_INJECTING) { - g_expectedNextUsi = nil; - usi_set_state(USI_STATE_READY); - s = USI_STATE_READY; - } - - if (seat != 0 && seat != 1) return; // no fixed seat - if (side_to_move != seat) return; // opponent's turn next - - if (s != USI_STATE_READY) { - // Already thinking or in an odd state — don't pile on a second - // `go` before bestmove comes back. - file_log([NSString stringWithFormat: - @"[USI] skip request: state=%s", - usi_state_name(s)]); - return; - } - - if (sfen_after.length == 0) { - file_log(@"[USI] skip request: empty sfen_after"); - return; - } - usi_engine_request_thinking(sfen_after); -} - -// --------------------------------------------------------------------------- -// Inbound handling — split USI lines, dispatch on the first token. -// --------------------------------------------------------------------------- -static NSString *usi_first_token(NSString *line) { - NSRange r = [line rangeOfCharacterFromSet: - [NSCharacterSet whitespaceCharacterSet]]; - if (r.location == NSNotFound) return line; - return [line substringToIndex:r.location]; -} - -static NSString *usi_rest_after(NSString *line, NSString *token) { - if (line.length <= token.length) return @""; - NSString *tail = [line substringFromIndex:token.length]; - return [tail stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceCharacterSet]]; -} - -// Per-line dispatcher. Returns nothing — replies are sent via -// usi_engine_send_line. -static void usi_engine_handle_line(NSString *line) { - NSString *trimmed = [line stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceAndNewlineCharacterSet]]; - if (trimmed.length == 0) return; - file_log([NSString stringWithFormat:@"[USI<] %@", trimmed]); - - NSString *cmd = usi_first_token(trimmed); - - if ([cmd isEqualToString:@"id"]) { - // engine identification; just log - return; - } - if ([cmd isEqualToString:@"option"]) { - // engine option; just log - return; - } - if ([cmd isEqualToString:@"usiok"]) { - // engine done announcing itself — request readiness - usi_engine_send_line(@"isready"); - return; - } - if ([cmd isEqualToString:@"readyok"]) { - usi_engine_send_line(@"usinewgame"); - usi_set_state(USI_STATE_READY); - // If the bridge connected mid-game (user already in a CPU match - // before launching us), no observation will fire until the next - // move plays. Kick a position right now if we already know the - // seat and it's our turn. - dispatch_async(dispatch_get_main_queue(), ^{ - usi_engine_try_kick_on_main(@"post-readyok"); - }); - return; - } - if ([cmd isEqualToString:@"info"]) { - // info pv ... / info string ... — keep last info string handy - NSString *rest = usi_rest_after(trimmed, @"info"); - if ([rest hasPrefix:@"string "]) { - g_lastInfoString = [rest substringFromIndex:7]; - } - return; - } - if ([cmd isEqualToString:@"bestmove"]) { - NSString *rest = usi_rest_after(trimmed, @"bestmove"); - NSString *mv = usi_first_token(rest); - if (mv.length == 0) { - file_log(@"[USI] bestmove with no arg, ignoring"); - return; - } - if ([mv isEqualToString:@"resign"] || - [mv isEqualToString:@"(none)"] || - [mv isEqualToString:@"win"]) { - file_log([NSString stringWithFormat: - @"[USI] engine returned %@, no injection", mv]); - usi_set_state(USI_STATE_READY); - return; - } - usi_set_state(USI_STATE_INJECTING); - g_expectedNextUsi = mv; - NSString *sfen = nil; - NSString *err = nil; - uint32_t raw = 0; - bool ok = inject_apply(mv, &sfen, &raw, &err); - file_log([NSString stringWithFormat: - @"[USI] inject_apply usi=%@ ok=%d raw=0x%x err=%@ sfen=%@", - mv, (int)ok, (unsigned)raw, err ?: @"", sfen ?: @""]); - // Originally we wanted to wait for the post-inject ADAPTER2 echo to - // tick state back to READY, but the inject path calls - // orig_AdapterTryMakeMoveOut directly (= no hook re-entry), so the - // echo never fires. Instead we leave state at INJECTING and let - // usi_engine_on_move_observed flip it to READY the moment KIOU's - // opponent moves and it becomes our turn again. That observation is - // the natural cue that this turn is over. - // On a failed inject there's no waiting to do, so reset directly. - if (!ok) { - g_expectedNextUsi = nil; - usi_set_state(USI_STATE_READY); - } - return; - } - // Anything else (e.g. an echo from a misbehaving engine): drop quietly. - file_log([NSString stringWithFormat:@"[USI] ignored inbound: %@", - trimmed]); -} - -// Recv handler — called from Server_WebSocket.m. The buffer may contain -// multiple newline-terminated USI lines; split and dispatch each one. -static void usi_engine_text_handler(const char *data, size_t len) { - if (!data || len == 0) return; - NSData *raw = [NSData dataWithBytes:data length:len]; - NSString *whole = [[NSString alloc] initWithData:raw - encoding:NSUTF8StringEncoding]; - if (!whole) return; - // Split on \r\n / \n / \r so we tolerate any line ending the engine - // happens to use. - NSCharacterSet *nl = [NSCharacterSet characterSetWithCharactersInString:@"\r\n"]; - NSArray *lines = [whole componentsSeparatedByCharactersInSet:nl]; - for (NSString *line in lines) { - if (line.length == 0) continue; - usi_engine_handle_line(line); - } -} - -// --------------------------------------------------------------------------- -// WS lifecycle hooks. Server_WebSocket.m calls these from the accept queue -// the moment a peer finishes the WebSocket upgrade (connected) or the -// recv loop exits for any reason (disconnected). -// --------------------------------------------------------------------------- -void usi_engine_on_ws_client_connected(void) { - file_log(@"[USI] ws client connected; starting USI handshake"); - usi_set_state(USI_STATE_HANDSHAKE); - g_expectedNextUsi = nil; - usi_engine_send_line(@"usi"); -} - -void usi_engine_on_ws_client_disconnected(void) { - file_log(@"[USI] ws client disconnected"); - usi_set_state(USI_STATE_BOOT); - g_expectedNextUsi = nil; -} - -// --------------------------------------------------------------------------- -// Installer. Called once from Tweak.m's installUnityHooks() after the -// observation hooks are in place. -// --------------------------------------------------------------------------- -void usi_engine_install(void) { - kiou_ws_server_set_text_handler(usi_engine_text_handler); - file_log(@"[USI] engine installed (text handler registered)"); -} diff --git a/_shared/kiou_hookengine.h b/_shared/kiou_hookengine.h deleted file mode 100644 index 33af6ac..0000000 --- a/_shared/kiou_hookengine.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -// =========================================================================== -// kiou_hookengine.h — MSHookFunction <-> Dobby shim. -// -// JB / rootless builds (default): MobileSubstrate's MSHookFunction is live -// in libsubstrate, linked at runtime. -// Jailed (Sideloadly-injected) builds: Dobby, statically linked from -// vendor/dobby/lib/libdobby.a so the .dylib -// has zero external hook-engine dependency. -// -// The shim below maps MSHookFunction(...) onto DobbyHook(...) when KIOU_JAILED -// is defined at compile time, so every Hook_*.m stays untouched between the -// two distribution modes. -// -// Each tweak's Makefile sets -DKIOU_JAILED=1 via `make JAILED=1`. -// =========================================================================== - -#if KIOU_JAILED -#import "dobby.h" -// MSHookFunction returns void; DobbyHook returns int. Cast the result away so -// the call site keeps the original void-expression shape. -#define MSHookFunction(sym, repl, orig) \ - ((void)DobbyHook((void *)(sym), (void *)(repl), (void **)(orig))) -#else -#import -#endif diff --git a/_shared/kiou_il2cpp.h b/_shared/kiou_il2cpp.h deleted file mode 100644 index 9ce5a77..0000000 --- a/_shared/kiou_il2cpp.h +++ /dev/null @@ -1,79 +0,0 @@ -#pragma once - -#import -#import - -// =========================================================================== -// kiou_il2cpp.h — read-only il2cpp object helpers shared across KIOU tweaks. -// -// Every tweak that pokes at il2cpp objects (KiouEditor, KiouUSIProxy, ...) -// needs the same pointer-validation + struct-field readers. Sharing them as -// `static inline` keeps the call sites in each translation unit inlined and -// avoids any linker plumbing — each .m that imports this header gets its own -// private copy. -// -// Object layout assumptions (verified against KIOU 1.0.1 build 11): -// -// RepeatedField : +0x10 array ptr, +0x18 count -// il2cpp array : element[0] at arrayPtr + 0x20, refs 8-byte spaced -// il2cpp string : +0x10 length (UTF-16 code units), +0x14 char[] -// -// DELIBERATELY READ-ONLY: writeU8 / writeI32 live in each tweak's own -// Internal.h, not here. This makes it physically impossible for an -// observation-only tweak (KiouUSIProxy) to accidentally mutate il2cpp memory -// just by including the shared header — if you need to write, opt in -// explicitly per-tweak. -// =========================================================================== - -static inline BOOL ptrLooksValid(const void *p) { - uintptr_t v = (uintptr_t)p; - if (v == 0) return NO; - if (v < 0x1000) return NO; - if (v >= 0x0001000000000000ULL) return NO; - return YES; -} - -static inline int32_t readI32(const void *base, uintptr_t off) { - if (!ptrLooksValid(base)) return 0; - return *(const int32_t *)((const uint8_t *)base + off); -} - -static inline uint8_t readU8(const void *base, uintptr_t off) { - if (!ptrLooksValid(base)) return 0; - return *(const uint8_t *)((const uint8_t *)base + off); -} - -static inline void *readPtr(const void *base, uintptr_t off) { - if (!ptrLooksValid(base)) return NULL; - void *p = *(void *const *)((const uint8_t *)base + off); - return ptrLooksValid(p) ? p : NULL; -} - -static inline BOOL readRepeatedField(const void *obj, uintptr_t fieldOff, - void **outArrayPtr, int32_t *outCount) { - *outArrayPtr = NULL; - *outCount = 0; - void *rf = readPtr(obj, fieldOff); - if (!rf) return NO; - void *arr = readPtr(rf, 0x10); - int32_t count = readI32(rf, 0x18); - if (count < 0 || count > 100000) return NO; - if (count > 0 && !arr) return NO; - *outArrayPtr = arr; - *outCount = count; - return YES; -} - -static inline void *readArrayElem(const void *arrayPtr, int32_t index) { - if (!ptrLooksValid(arrayPtr)) return NULL; - if (index < 0) return NULL; - return readPtr(arrayPtr, 0x20 + (uintptr_t)index * 8); -} - -static inline NSString *il2cppStringToNSString(const void *s) { - if (!ptrLooksValid(s)) return nil; - int32_t len = *(const int32_t *)((const uint8_t *)s + 0x10); - if (len < 0 || len > 0x10000) return nil; - const unichar *chars = (const unichar *)((const uint8_t *)s + 0x14); - return [NSString stringWithCharacters:chars length:(NSUInteger)len]; -} diff --git a/_shared/kiou_logging.h b/_shared/kiou_logging.h deleted file mode 100644 index 75ab615..0000000 --- a/_shared/kiou_logging.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#import - -// =========================================================================== -// kiou_logging.h — NSLog + os_log + sandbox file log destination. -// -// Implementation in kiou_logging.m. Each tweak picks its own os_log subsystem -// at init so console output stays distinguishable when several tweaks are -// loaded into the same process: -// -// KiouEditor : "com.neconome.shogi.kioueditor" -// KiouUSIProxy : "com.neconome.shogi.kiouusiproxy" -// KiouKifExporter : "com.neconome.shogi.kioukifexporter" -// -// kiou_logging.m derives a short tag from the subsystem (the last dot- -// separated segment) and prepends it to each NSLog line. -// -// File log destination: NSTemporaryDirectory() + ".log", where -// the basename comes from the short tag derived above. This is the app -// sandbox's tmp/ directory — readable from host via -// `/var/mobile/Containers/Data/Application//tmp/.log`. -// -// Why no root-accessible destination: rootless tweaks run as the host app -// (`mobile`), which can't write to `/var/tmp/`. The old API took a second -// `logFile` argument that was meant to be a root-readable mirror; under -// rootless that write always failed (silently swallowed by the -// implementation), so the API has been simplified to drop it. -// -// Calls before logging_init() fall back to NSLog only; the file/os_log -// destinations come up once logging_init() has run. -// =========================================================================== - -void file_log(NSString *msg); -void logging_init(const char *subsystem); diff --git a/_shared/kiou_logging.m b/_shared/kiou_logging.m deleted file mode 100644 index 6e4de17..0000000 --- a/_shared/kiou_logging.m +++ /dev/null @@ -1,69 +0,0 @@ -#import "kiou_logging.h" -#import - -// =========================================================================== -// kiou_logging.m — implementation backing kiou_logging.h. -// -// Three destinations on every file_log(): -// * NSLog — Console.app, always on -// * os_log — unified logging, subsystem-scoped -// * g_logSandbox file — NSTemporaryDirectory()/.log, append-only -// -// `` is the short tag derived from the subsystem (last dot segment), -// e.g. "kioukifexporter".log. Resolves under the host app's sandbox: -// /var/mobile/Containers/Data/Application//tmp/.log -// -// The sandbox file write is best-effort and silently swallows exceptions -// so a flaky filesystem can't take down the host process. -// =========================================================================== - -static os_log_t g_log = NULL; -static NSString *g_logSandbox = nil; -static NSString *g_tag = @"kiou"; - -static void file_log_path(NSString *path, NSString *msg) { - if (!path) return; - @try { - NSDateFormatter *df = [[NSDateFormatter alloc] init]; - df.dateFormat = @"HH:mm:ss.SSS"; - NSString *line = [NSString stringWithFormat:@"%@ %@\n", - [df stringFromDate:[NSDate date]], msg]; - NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:path]; - if (!fh) { - [line writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:nil]; - } else { - [fh seekToEndOfFile]; - [fh writeData:[line dataUsingEncoding:NSUTF8StringEncoding]]; - [fh closeFile]; - } - } @catch (NSException *e) {} -} - -void file_log(NSString *msg) { - NSLog(@"[%@] %@", g_tag, msg); - if (g_log) { - os_log(g_log, "%{public}s", msg.UTF8String); - } - if (g_logSandbox) file_log_path(g_logSandbox, msg); -} - -void logging_init(const char *subsystem) { - if (!subsystem) return; - - g_log = os_log_create(subsystem, "tweak"); - - // Derive a short tag (e.g. "kioueditor") from the last dot-separated - // segment of the subsystem. The tag is reused for the sandbox log - // filename so multiple tweaks loaded into the same process don't - // clobber each other's files. - NSString *sub = [NSString stringWithUTF8String:subsystem]; - NSArray *parts = [sub componentsSeparatedByString:@"."]; - if (parts.count > 0) { - NSString *last = [parts lastObject]; - if (last.length > 0) g_tag = last; - } - - NSString *filename = [g_tag stringByAppendingString:@".log"]; - g_logSandbox = [NSTemporaryDirectory() - stringByAppendingPathComponent:filename]; -} diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/csa_compatibility.md b/docs/csa_compatibility.md new file mode 100644 index 0000000..b881f02 --- /dev/null +++ b/docs/csa_compatibility.md @@ -0,0 +1,169 @@ +# KIOU-KEB CSA compatibility + +This document maps every command in the CSA server protocol v1.2.1 onto +KEB's behaviour. The authoritative spec is the wire-format contract in +`docs/csa_protocol.md`; if this document and that one disagree, the wire +contract wins. The authoritative protocol reference is the CSA spec: +. + +Statuses: + +- ✅ supported — KEB handles the command per the CSA spec. +- ⚠️ partial — KEB handles a subset of the command's semantics. Notes + describe exactly what. +- ⛔ omitted — KEB intentionally ignores the command. Reason listed. + +## Session management + +| CSA concept | Wire | KEB behaviour | Status | +|---|---|---|---| +| Connect | TCP to `:4081` | Single concurrent client. New connect preempts a stale session. | ✅ | +| Login | `LOGIN ` | Accepted unconditionally. Reply: `LOGIN: OK`. | ⚠️ no authentication | +| Logout | `LOGOUT` / `LOGOUT:completed` | Replies `LOGOUT:completed`, closes the socket. | ✅ | +| Keepalive | bare LF (≥ 30 s interval) | Logged and ignored. TCP keepalive does the actual liveness check. | ✅ | + +## Match negotiation + +CSA defines a strict pre-match handshake: server emits `BEGIN +Game_Summary`, engine replies `AGREE` or `REJECT`, server emits `START`. +KEB follows it exactly, with the local KIOU side filling the server role. + +| CSA field | Wire | KEB behaviour | Status | +|---|---|---|---| +| Protocol version | `Protocol_Version:1.2` | Hard-coded `1.2`. | ✅ | +| Protocol mode | `Protocol_Mode:Server` | Always `Server`. | ✅ | +| Format | `Format:Shogi 1.0` | Always `Shogi 1.0`. | ✅ | +| Declaration | `Declaration:Jishogi 1.1` | Always advertised — the engine may submit `%KACHI`. | ✅ | +| Game ID | `Game_ID:` | `-`. | ✅ | +| Black name | `Name+:` | From `MatchConfig.BlackPlayer` or `GameStateStore.SetBlackPlayerInfo` (Online matchmaking). Omitted when blank. | ✅ | +| White name | `Name-:` | Same as Black. | ✅ | +| Local seat | `Your_Turn:+` / `Your_Turn:-` | Mapped from KIOU's `_localPlayer`. Open-seat modes default to `+`. | ✅ | +| First to move | `To_Move:+` | Hard-coded `+` (KIOU always starts on Black's move). | ⚠️ no handicap-aware override yet | +| Max moves | `Max_Moves:` | — | ⛔ KIOU does not expose a hard move cap. | +| Rematch on draw | `Rematch_On_Draw:NO` | — | ⛔ omitted. | +| Engine accept | `AGREE []` | Accepted but treated as a no-op when already PLAYING (KEB sends `START` immediately after `Game_Summary` — see note below). | ⚠️ AGREE arrives late | +| Engine reject | `REJECT []` | Sends `REJECT: by engine`, drops back to LOGIN. KIOU side stays in match. | ✅ | +| Match start | `START:` | Emitted immediately after `Game_Summary` without waiting for `AGREE`, because KIOU's CPU starts committing moves the moment `OnMatchStart` fires. | ⚠️ pre-emptive START | + +## Time control + +CSA's `BEGIN Time ... END Time` block can express far more than KIOU +surfaces. KEB writes only the fields it can faithfully fill. + +| CSA field | Wire | KEB behaviour | Status | +|---|---|---|---| +| Time unit | `Time_Unit:1sec` | Always `1sec`. KIOU works in seconds. | ✅ | +| Total time | `Total_Time:` | `MatchConfig.TimeControlConfig.main_seconds`. Omitted when zero / unreadable. | ✅ | +| Byoyomi | `Byoyomi:` | `TimeControlConfig.byoyomi`. Omitted when zero. | ✅ | +| Increment | `Increment:` | `TimeControlConfig.increment`. Omitted when zero. | ✅ | +| Delay | `Delay:` | — | ⛔ KIOU does not expose Delay. | +| Min time per move | `Least_Time_Per_Move:` | — | ⛔ KIOU does not expose this. | +| Time roundup | `Time_Roundup:YES` | — | ⛔ KIOU does not expose this. | + +## Initial position + +KEB always writes the `BEGIN Position` block in CSA's standard form, +derived from KIOU's live SFEN via `Csa_Convert::CsaPositionFromSfen`. + +| Concept | KEB behaviour | Status | +|---|---|---| +| Board cells `P1`..`P9` | 9 rows, 3 chars per square (` * `, `+XX`, `-XX`). Trailing column spaces trimmed. | ✅ | +| Hand pieces `P+` / `P-` | CSA `00` format. Order follows the SFEN hand string. | ✅ | +| Side-to-move | `+` for black, `-` for white. | ✅ | +| `N+:` / `N-:` (player tags inside Position) | — | ⛔ KEB writes player names in `Name+` / `Name-` instead. | +| `AL` (all pieces) | — | ⛔ Not used. | + +## Per-turn exchange + +This is the core CSA loop: server notifies each engine of every move with +the consumed time, and the engine submits its own move when its turn +arrives. KEB follows the same pattern from both directions. + +| CSA concept | Wire | KEB behaviour | Status | +|---|---|---|---| +| Notify move | `,T` | Emitted from `Hook_GameStateStoreObserve::HookNotifyPieceMoved` for every KIOU move (both sides). `,T` derived from the snapshot-delta on Online/CPUStream; omitted in modes without authoritative clocks. | ✅ | +| Engine submits move | `` | Parsed via `MoveBitsFromCsaText`, translated to USI, fed into `inject_apply`. The `,T` suffix is accepted but ignored (KIOU keeps its own clock). | ✅ | +| Engine resigns | `%TORYO` | Sends `#RESIGN` + `#LOSE`, calls `GameOrchestrator.RequestSurrender` for the local seat. The engine controls the local player, so `%TORYO` means the local seat surrenders. | ⚠️ surrenders the local seat | +| Engine nyugyoku win | `%KACHI` | Sends `#JISHOGI` + `#WIN`, advances to GAME_OVER. KIOU side is not signalled. | ⚠️ engine learns; KIOU stays | +| Engine pauses | `%CHUDAN` | Sends `#CHUDAN`, advances to GAME_OVER. KIOU is not signalled. | ⚠️ engine learns; KIOU stays | +| Liveness ping | bare LF | Logged and ignored. | ✅ | + +## Result delivery + +CSA splits the result into a reason marker followed by the outcome. KEB +cannot distinguish all reasons KIOU may have for ending a match, so it +emits the closest match plus the outcome. + +| CSA reason | KEB emits | Status | +|---|---|---| +| `#RESIGN` | Win / lose: KEB sends `#RESIGN` and the outcome. | ✅ | +| `#TIME_UP` | — | ⛔ no separate signal; we fall back to `#RESIGN`. | +| `#ILLEGAL_MOVE` | — | ⛔ no separate signal; we fall back to `#RESIGN`. | +| `#SENNICHITE` | Draw: KEB sends `#SENNICHITE` + `#DRAW`. | ✅ | +| `#OUTE_SENNICHITE` | — | ⛔ no separate signal; we fall back to `#SENNICHITE`. | +| `#JISHOGI` | Sent in response to `%KACHI`. | ⚠️ engine-initiated only | +| `#TSUMI` | — | ⛔ no separate signal. | +| `#MAX_MOVES` / `#CENSORED` | — | ⛔ KIOU does not expose a move-cap end. | +| `#CHUDAN` | Sent in response to `%CHUDAN`. | ⚠️ engine-initiated only | + +| CSA outcome | KEB behaviour | Status | +|---|---|---| +| `#WIN` | Sent when the local seat won. | ✅ | +| `#LOSE` | Sent when the local seat lost. | ✅ | +| `#DRAW` | Sent for sennichite. | ✅ | +| `#CENSORED` | — | ⛔ Not surfaced. | + +## KIOU_* extensions + +CSA does not have first-class fields for the metadata KIOU carries about +its matches (mode, handicap setup, rate, user id, wall-clock start time). +KEB ships these as `KIOU_*` lines inside `Game_Summary`; a strict CSA +parser is required to ignore unknown keys. + +| Key | Value | Notes | +|---|---|---| +| `KIOU_Mode` | `VsAI` \| `LocalPvP` \| `OnlinePvP` \| `RecordReplay` \| `Spectate` | | +| `KIOU_StartPosition` | `Standard` \| `HandicapLance` \| ... \| `TsumeShogi` | | +| `KIOU_Rank+` / `KIOU_Rank-` | string | Often surfaced on Online matches. | +| `KIOU_Rate+` / `KIOU_Rate-` | integer | Omitted when zero. | +| `KIOU_UserId+` / `KIOU_UserId-` | string | Omitted when blank. | +| `KIOU_StartedAt` | ISO 8601 UTC | KEB wall-clock at OnMatchStart. | + +## Scope boundary + +The following CSA / Floodgate features are intentionally outside KEB's +scope as of this revision: + +- **shogi-server extensions** (`CHALLENGE`, custom rating tags, lobby + messages) — KEB only implements the v1.2 server protocol surface. +- **`%KACHI` actually ending KIOU's match** — needs a Task 7 follow-up + once a reverse-engineered nyugyoku declaration API is found. +- **`#TIME_UP` / `#ILLEGAL_MOVE` distinct from `#RESIGN`** — KIOU does + not expose end-reason details to the bridge. +- **Multi-client fanout** — only one engine attached at a time. +- **Encrypted transport** — CSA itself is plaintext; if you need TLS, + terminate it externally and pass plaintext on the loopback. + +## Verifying against a real engine + +`shogi-server`'s reference Ruby clients (`csa.rb`, `usi.rb`) are the +easiest way to sanity-check the wire. From any host on the same network +as the KIOU device: + +```sh +nc 4081 +LOGIN test pass +... (Game_Summary lines arrive) +AGREE +... (KEB sends START + first move notification) +``` + +The full test loop with a real CSA engine (Apery, 技巧, YaneuraOu in CSA +mode) is captured in the migration plan's Verification section: +`docs/plans/kiou_engine_bridge_csa_migration.md`. + +## Related documents + +- `docs/csa_protocol.md` — wire-level contract and state machine. +- `docs/plans/kiou_engine_bridge_csa_migration.md` — migration plan. +- `docs/archive/usi_compatibility.md` — superseded USI compatibility doc. diff --git a/docs/csa_protocol.md b/docs/csa_protocol.md new file mode 100644 index 0000000..aa9227c --- /dev/null +++ b/docs/csa_protocol.md @@ -0,0 +1,289 @@ +# KiouEngineBridge CSA protocol + +KiouEngineBridge (KEB) is the iOS-side dylib that turns KIOU into a **CSA +match server** — embedded inside the KIOU process rather than running as a +standalone service, but speaking the same TCP/IP protocol Floodgate and +shogi-server use. A CSA engine connects, plays one or more matches against +the KIOU side, and disconnects when finished. + +Mental model: + +``` +KIOU (authoritative board state and clocks) + ↕ in-process hooks + KEB (CSA match server on TCP :4081) + ↕ CSA protocol v1.2 over plain TCP + CSA engine (Floodgate-grade or anything that speaks the CSA wire format) +``` + +KIOU decides the match conditions (time control, handicap, opponent +identity). KEB reads those conditions via observation hooks and announces +them through the standard CSA `Game_Summary` block. The engine returns +`AGREE`, plays the match through the per-move exchange (`+7776FU,T10` from +KEB, `+7776FU` or `%TORYO` from the engine), and learns the result via +`#WIN` / `#LOSE` / `#DRAW`. + +This document is the wire-level contract between KEB and the connecting +engine. The authoritative spec for everything not extended here is the CSA +server protocol v1.2.1: . + +Source of truth pointers: + +- `Sources/KiouEngineBridge/Server_CSA.m` — TCP transport. +- `Sources/KiouEngineBridge/Csa_Engine.m` — protocol state machine. +- `Sources/KiouEngineBridge/Csa_GameInfo.m` — `Game_Summary` / result builder. +- `Sources/KiouEngineBridge/Csa_Convert.m` — coordinate / piece / move / + position conversion. Pinned regression tests in + `tests/test_csa_convert_expectations.py`. + +## Transport + +- **TCP, plaintext, port 4081.** Bound to `0.0.0.0` from + `Sources/KiouEngineBridge/Tweak.m::init` via `KEBCsaServerStart(4081)`. + No TLS, no Bonjour. CSA's wire protocol is unencrypted by design; the + model is "tweak and engine live on the same trusted network." +- **One client at a time.** A second incoming connection preempts the + prior session (new-client-wins): if the previous engine vanished without + closing its socket, the next connect attempt boots it out and takes over. +- **Line-oriented UTF-8.** Lines are terminated by `\n` outbound. Inbound + CR/LF is tolerated. Lines may be up to 64 KiB; longer buffers terminate + the session. +- **TCP keepalive.** `SO_KEEPALIVE` on, idle 5 s, interval 3 s, count 3 + — a silently dead engine is reaped in roughly 15 s. +- **Backpressure.** Outbound lines go through a serial GCD queue. If the + in-flight backlog exceeds 128 lines the oldest pending line is dropped + and a `[CSA]` warning is logged. + +## Protocol state machine + +KEB's state machine sits in `Sources/KiouEngineBridge/Csa_Engine.m`: + +``` +BOOT + ↓ TCP accept +LOGIN + ↓ inbound "LOGIN " → send "LOGIN: OK" + ↓ (if KIOU match already in progress) → send "BEGIN Game_Summary ..." + + "START:" + (KIOU does not wait for AGREE) +PLAYING + ↓ inbound move "+7776FU" → inject into KIOU + ↓ KIOU move observed → send "+7776FU,T10" + ↓ inbound "%TORYO" → GameOrchestrator.RequestSurrender + ↓ KIOU match end → send "#REASON" + "#WIN/LOSE/DRAW" +GAME_OVER + ↓ inbound "LOGIN ..." (next match) → back to LOGIN + ↓ inbound "LOGOUT" → send "LOGOUT:completed", close +``` + +**Important deviation from the CSA spec.** KIOU's match-start event fires +its in-game CPU immediately; KEB has no way to pause KIOU until the +engine has replied with `AGREE`. To prevent the CPU's first moves from +being dropped while we sit in `AGREE_WAIT`, KEB emits `START:` +*together with* `Game_Summary` and advances straight to `PLAYING`. A +later inbound `AGREE` from the engine is logged and silently dropped — +the engine sees `START:` arrive without an explicit acknowledgement of +its `AGREE`, which most CSA clients accept without complaint. + +The `AGREE_WAIT` state still exists in the enum but is now reserved for +future use (e.g. a build flavour that does pause KIOU during the +handshake); in the live path the state machine goes `LOGIN → PLAYING` +directly. + +## CSA commands handled + +### KEB → engine (outbound) + +| Line | When | Notes | +|---|---|---| +| `LOGIN: OK` | inbound `LOGIN` | Any credentials are accepted. | +| `LOGOUT:completed` | inbound `LOGOUT` | KEB then closes the TCP socket. | +| `BEGIN Game_Summary ... END Game_Summary` | match start | See `Game_Summary` schema below. | +| `START:` | inbound `AGREE` after Game_Summary | | +| `REJECT: by engine` | inbound `REJECT` | Session stays open; engine may issue another LOGIN. | +| `,T` | KIOU NotifyPieceMoved fires | Both colors are echoed (sign + black, − white). `T` is computed from the snapshot delta and is omitted in modes without authoritative clocks. | +| `#RESIGN`, `#TIME_UP`, `#ILLEGAL_MOVE`, `#SENNICHITE`, `#OUTE_SENNICHITE`, `#JISHOGI`, `#MAX_MOVES`, `#CHUDAN` | match end | KEB picks the reason marker that best matches the inferred outcome (currently `#RESIGN` for win/lose and `#SENNICHITE` for draw — see `CsaBuildMatchResult` in Csa_GameInfo.m). | +| `#WIN` / `#LOSE` / `#DRAW` / `#CENSORED` | match end | Outcome from the engine's seat perspective. | + +### engine → KEB (inbound) + +| Line | Action | +|---|---| +| `LOGIN []` | Reply with `LOGIN: OK`. Advance to LOGIN. Push Game_Summary immediately if KIOU is mid-match. | +| `LOGOUT` | Reply with `LOGOUT:completed`, close socket, return to BOOT. | +| `AGREE []` | While in AGREE_WAIT: send `START:` and advance to PLAYING. Otherwise log + drop. | +| `REJECT []` | Log; reply with `REJECT: by engine`; return to LOGIN. KIOU side is not affected. | +| `[,T]` | Parse via `Csa_Convert::MoveBitsFromCsaText` → translate to USI → call `inject_apply`. The `,T` suffix is logged but otherwise unused (KIOU runs its own clock). | +| `%TORYO` | Send `#RESIGN` + `#WIN`, advance to GAME_OVER, schedule `GameOrchestrator.RequestSurrender` on the main thread (see `Inject_Resign.m`). | +| `%KACHI` | Send `#JISHOGI` + `#WIN`, advance to GAME_OVER. No corresponding KIOU declaration API has been surfaced yet — see Task 7 of the migration plan. | +| `%CHUDAN` | Send `#CHUDAN`, advance to GAME_OVER. KIOU is not notified. | +| bare `\n` (CSA liveness ping) | No-op; TCP keepalive handles dead-peer detection separately. | +| anything else | Logged as `[CSA-ENG] ignoring unrecognised line: ...`, otherwise dropped. | + +## `Game_Summary` schema + +KEB emits the block right after a KIOU match starts (or right after the +LOGIN reply if the engine connects mid-match). All standard CSA fields are +written; KIOU-specific extensions live between the position block and +`END Game_Summary` so a strict CSA parser ignores them. + +``` +BEGIN Game_Summary +Protocol_Version:1.2 +Protocol_Mode:Server +Format:Shogi 1.0 +Declaration:Jishogi 1.1 +Game_ID:20260616T093003-VsAI +Name+:プレイヤー +Name-:KIOU CPU (Normal) +Your_Turn:+ +To_Move:+ +BEGIN Time +Time_Unit:1sec +Total_Time:600 +Byoyomi:30 +END Time +BEGIN Position +P1-KY-KE-GI-KI-OU-KI-GI-KE-KY +P2 * -HI * * * * * -KA * +P3-FU-FU-FU-FU-FU-FU-FU-FU-FU +P4 * * * * * * * * * +P5 * * * * * * * * * +P6 * * * * * * * * * +P7+FU+FU+FU+FU+FU+FU+FU+FU+FU +P8 * +KA * * * * * +HI * +P9+KY+KE+GI+KI+OU+KI+GI+KE+KY +P+ +P- ++ +END Position +KIOU_Mode:VsAI +KIOU_StartPosition:Standard +KIOU_Rank+:六段 +KIOU_Rate+:1832 +KIOU_UserId+:abc123 +KIOU_StartedAt:2026-06-16T09:30:03Z +END Game_Summary +``` + +### KIOU_* extension lines + +KEB ships data CSA has no equivalent for as `KIOU_:` lines. +Engines should ignore any unknown `KIOU_*` key. Defined keys: + +| Key | Value | Notes | +|---|---|---| +| `KIOU_Mode` | `VsAI` \| `LocalPvP` \| `OnlinePvP` \| `RecordReplay` \| `Spectate` \| `Unknown()` | KIOU match mode (`MatchMode` enum). | +| `KIOU_StartPosition` | `Standard` \| `HandicapLance` \| ... \| `TsumeShogi` | Initial position type. | +| `KIOU_Rank+` / `KIOU_Rank-` | string | Player rank if surfaced (Online matches usually have it). | +| `KIOU_Rate+` / `KIOU_Rate-` | integer | Player rate. Omitted when zero / unknown. | +| `KIOU_UserId+` / `KIOU_UserId-` | string | Player user id. Omitted when blank. | +| `KIOU_StartedAt` | ISO 8601 UTC | KEB wall-clock at match start. | + +Fields that have no value are omitted entirely rather than written as +empty strings, so a parser can rely on "key present → value valid." + +### Time control + +CSA's `BEGIN Time` block carries only the fields KIOU actually exposes +(`Total_Time`, `Byoyomi`, `Increment`). `Delay`, `Least_Time_Per_Move`, and +`Time_Roundup` are never written. KEB does not currently honour +`Time_Unit:1msec` either — KIOU works in seconds and that's what we ship. + +The `,T` field on move lines is computed from the difference between +consecutive authoritative snapshots (`Hook_OnlineObserve::g_latestBlackTimeSec` / +`g_latestWhiteTimeSec`). VsAI and LocalPvP modes do not surface +authoritative clocks, so the suffix is omitted in those modes. + +## End-of-match + +KEB sends a `#REASON` line followed by `#WIN` / `#LOSE` / `#DRAW` whenever +KIOU's match-end hook fires with a resolved outcome: + +| Inferred outcome | `#REASON` | Outcome | +|---|---|---| +| Local seat wins (engine resigns or times out) | `#RESIGN` | `#WIN` | +| Local seat loses | `#RESIGN` | `#LOSE` | +| Draw (sennichite or similar) | `#SENNICHITE` | `#DRAW` | +| Unknown (open-seat modes) | — | (no result block sent) | + +After the block KEB transitions to `GAME_OVER`. The TCP session stays +open; a fresh LOGIN advances back to `LOGIN` and the next KIOU match's +`Game_Summary` will roll the engine into another game. + +## Example session (VsAI) + +Engine perspective, from connect through one move to match end. `>` is +engine→KEB, `<` is KEB→engine. Trailing `\n`s are shown for clarity. + +``` +> LOGIN test pass\n +< LOGIN:test OK\n +< BEGIN Game_Summary\n +< Protocol_Version:1.2\n +< Protocol_Mode:Server\n +< Format:Shogi 1.0\n +< Declaration:Jishogi 1.1\n +< Game_ID:20260616T093003-VsAI\n +< Name+:プレイヤー\n +< Name-:KIOU CPU (Normal)\n +< Your_Turn:+\n +< To_Move:+\n +< BEGIN Time\n +< Time_Unit:1sec\n +< Total_Time:600\n +< Byoyomi:30\n +< END Time\n +< BEGIN Position\n +< P1-KY-KE-GI-KI-OU-KI-GI-KE-KY\n +< P2 * -HI * * * * * -KA *\n +< P3-FU-FU-FU-FU-FU-FU-FU-FU-FU\n +< P4 * * * * * * * * *\n +< P5 * * * * * * * * *\n +< P6 * * * * * * * * *\n +< P7+FU+FU+FU+FU+FU+FU+FU+FU+FU\n +< P8 * +KA * * * * * +HI *\n +< P9+KY+KE+GI+KI+OU+KI+GI+KE+KY\n +< P+\n +< P-\n +< +\n +< END Position\n +< KIOU_Mode:VsAI\n +< KIOU_StartPosition:Standard\n +< KIOU_StartedAt:2026-06-16T09:30:03Z\n +< END Game_Summary\n +> AGREE\n +< START:20260616T093003-VsAI\n +> +7776FU\n +< +7776FU\n +< -3334FU\n +> +2726FU\n +< +2726FU\n +... +> %TORYO\n +< #RESIGN\n +< #WIN\n +> LOGOUT\n +< LOGOUT:completed\n +``` + +## Build-flavour matrix + +Hooks responsible for the CSA wire are identical across all three build +flavours. Time control / result inference is uniform too. + +| Build | Distribution | Notes | +|---|---|---| +| `make` (JB / rootless) | jailbroken iOS 15.0 – 16.5 | MobileSubstrate hooks. | +| `make JAILED=1` | sideloaded iOS 15.0 – 17.x via Sideloadly | Dobby static link. | +| `make BINPATCH=1` | iOS 15.0 – 18.x via Sideloadly / TrollStore | Static binary patch + `__DATA,__bss` SLOT dispatcher, survives iOS 18 CSM. | + +## Related documents + +- `docs/csa_compatibility.md` — full table of CSA commands KEB supports vs + ignores vs cannot map to KIOU. +- `docs/plans/kiou_engine_bridge_csa_migration.md` — the migration plan + that produced this protocol surface. +- `docs/plans/kiou_engine_bridge_binpatch.md` — binpatch build mechanics. +- `docs/archive/usi_bridge_protocol.md` — the legacy USI WebSocket + protocol KEB spoke before this migration. diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e0896e5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "kiouenginebridge-tools" +version = "0.1.0" +description = "Kiou Engine Bridge recipe driving the IPA-Patch/Shared static-patch toolchain." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "lief>=0.17.6", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "ruff>=0.15.9", +] + +[tool.pytest.ini_options] +# shared/ hosts the IPA-Patch/Shared submodule whose tools/ package is +# imported as `tools.encode`, `tools.machoops`, etc. recipes/ lives at +# the repo root so its modules import as `recipes.kioukifexporter`. +# Tests for the toolchain itself live in shared/tests/; this repo only +# runs them via the submodule (`make -C shared test`). +pythonpath = [".", "shared"] + +[tool.ruff] +# shared/ is a submodule; do not lint it here. Its CI runs ruff on its +# own checkout. +extend-exclude = ["shared"] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = [ + "E501", +] diff --git a/recipes/__init__.py b/recipes/__init__.py new file mode 100644 index 0000000..ced2abf --- /dev/null +++ b/recipes/__init__.py @@ -0,0 +1,23 @@ +"""Per-target patch recipes. + +A recipe is a Python module exposing the following module-level names +that ``tools.patch_macho`` discovers and applies: + + - ``TARGET_BASENAME`` : str, the Mach-O filename this recipe targets + (used as a sanity check, e.g. ``UnityFramework``). + - ``DYLIB_PATH`` : str, the @executable_path/... dylib path + inserted via ``LC_LOAD_DYLIB``. + - ``HOOK_SLOT_RVA`` : int, the 8-byte __DATA,__bss slot the dylib + constructor publishes the hook function pointer + into. Validated against the live binary at + patch time; mismatches abort. + - ``CAVE_REGION`` : ``(start, end)`` file-offset tuple for the + r-x zero-fill range cave payloads are carved + from. + - ``PATCHES`` : list of inline single-instruction + replacements; usually empty. + - ``CAVE_PATCHES`` : list of ``(site_off, expected, build_payload, + label)`` cave-routed redirects. + +See ``tools.recipes.kioukifexporter`` for a worked example. +""" diff --git a/recipes/kiouenginebridge.py b/recipes/kiouenginebridge.py new file mode 100644 index 0000000..8c1aec4 --- /dev/null +++ b/recipes/kiouenginebridge.py @@ -0,0 +1,453 @@ +"""Recipe for KiouEngineBridge — Phase C binpatch. + +Patches UnityFramework so that every observation/injection site +KiouEngineBridge cares about (the 5 IMatchMode methods × 4 verbs plus +the GameOrchestrator / OnlinePvP / CPUStream snapshot sites) calls into +``KiouEngineBridge.dylib`` for the binpatch flavour. The dylib is loaded +automatically via ``LC_LOAD_DYLIB``; its constructor publishes a single +dispatcher function pointer into a reserved ``__bss`` slot, and each +cave loads that pointer and calls it with a per-site ``hook_id`` in W6. + +How the patch chain works (see ``docs/plans/kiou_engine_bridge_binpatch.md`` +for the full design): + + 1. Add an ``LC_LOAD_DYLIB`` pointing at + ``@executable_path/Frameworks/KiouEngineBridge.dylib`` so dyld + auto-loads the Bridge dylib on app launch. + 2. Reserve an 8-byte slot in ``__bss`` (the SLOT) that the dylib + constructor fills with its dispatcher function pointer. Writing to + ``__DATA`` does not trigger CSM on iOS 18. + 3. For every Bridge observation site (21 entries) replace the + prologue's first 4 bytes with ``B ``. Each cave saves + registers, materialises the SLOT address, loads the dispatcher, + stuffs the site's ``hook_id`` into W6, calls the dispatcher, + restores registers, runs the displaced prologue verbatim, and + branches to ``orig + 4``. ``W6`` is used instead of ``W2`` because + several Bridge hook sites (``OnPlayerMoveAsync``, + ``UpdateAuthoritativeSnapshot``, ``Adapter.TryMakeMove``) carry a + real argument in ``X2``; routing the hook id through ``X6`` keeps + ``X2`` available for forwarding to the dispatcher. + 4. The single inline patch is ``IsAfkEnabled``: an 8-byte + ``MOVZ W0, #0; RET`` replacement that turns the AFK check into a + constant ``false``. The patch clobbers the second instruction of + the original prologue (``STP X29, X30, [SP, #0x10]``) but execution + never reaches it because the RET fires immediately. + +This recipe is consumed by ``tools.patch_macho`` together with the +generic primitives in ``tools.encode``, ``tools.machoops``, and +``tools.caves``. KiouKifExporter consumes the front half of the +``__oslogstring`` zero-fill (``0x8268024 .. 0x826A000``); Bridge takes +the back half (``0x826A000 .. 0x826C000``) so both recipes can be +applied to the same UnityFramework without colliding. +""" + +from __future__ import annotations + +from tools.encode import ( + add_x_imm, + adrp, + b_imm, + blr_x, + ldp_off_x, + ldp_post_x, + ldr_x_imm, + mov_w0_imm_ret, + movz_w_imm, + stp_off_x, + stp_pre_x, +) + + +# --------------------------------------------------------------------------- +# Target identification +# --------------------------------------------------------------------------- + +TARGET_BASENAME = "UnityFramework" +DYLIB_PATH = "@executable_path/Frameworks/KiouEngineBridge.dylib" + + +# --------------------------------------------------------------------------- +# Code-cave region. +# +# UnityFramework's ``__TEXT,__oslogstring`` ends with a multi-KB zero-fill +# inside the same r-x mapping as every other instruction. KiouKifExporter +# carves caves out of the front of that range (``0x8268024 .. 0x826C000``, +# 5 × 84 B = 420 B used of 16 348 B available). Bridge needs ~21 caves at +# 84 B each = 1764 B. To keep the two recipes region-disjoint so they can +# coexist on the same Mach-O, Bridge claims the **back half** of the +# zero-fill (``0x826A000 .. 0x826C000``, 8 KB) and KifExporter keeps the +# front. See docs/plans/kiou_engine_bridge_binpatch.md § 8 for the +# partition rationale. +# --------------------------------------------------------------------------- + +CAVE_REGION = (0x826A000, 0x826C000) # (start, end exclusive) + + +# --------------------------------------------------------------------------- +# Hook slot. +# +# The dylib constructor publishes its dispatcher function pointer into +# this 8-byte slot inside __DATA,__bss. ``reserve_hook_slot()`` against +# the freshly extracted Kiou-1.0.1 build 11 UnityFramework returns +# ``0x8F90CD0`` (the section tail) — that's the slot KifExporter pins. +# Bridge needs a different slot at least 8 bytes away; we subtract 16 +# bytes to land at ``0x8F90CC0`` (validated via ``assert_slot_in_bss``). +# If a future UnityFramework changes the __bss layout, re-run +# reserve_hook_slot() and update PROBED_HOOK_SLOT_RVA plus this sibling +# constant. +# --------------------------------------------------------------------------- + +INJECT_ENTRY_TABLE_RVA = 0x8F90C00 +HOOK_SLOT_RVA = 0x8F90CC0 +PROBED_HOOK_SLOT_RVA = 0x8F90CD0 + +# Must stay inside __DATA,__bss and leave room for at least 32 8-byte entries. +# Runtime code reconstructs the actual bypass entry VAs from the cave geometry, +# but we still pin and validate the sibling table RVA here so future recipe / +# dylib changes have a reviewed address reservation to target. +PROBED_INJECT_ENTRY_TABLE_RVA = INJECT_ENTRY_TABLE_RVA + + +# --------------------------------------------------------------------------- +# Hook ID enum — must mirror ``enum kiou_bridge_hook_id`` in +# Sources/KiouEngineBridge/Internal.h. The dispatcher in +# BinpatchDispatcher.m switches on this value, so the recipe's caves and +# the dylib's dispatcher MUST agree on the integer mapping. Phase B owns +# the enum declaration; this dict is the recipe-side mirror so the recipe +# can be authored before Phase B's header lands. If you renumber the +# enum, renumber this dict in lockstep. +# --------------------------------------------------------------------------- + +_HOOK_IDS: dict[str, int] = { + "KIOU_BR_HOOK_AI_INIT": 0, + "KIOU_BR_HOOK_CPUSTREAM_INIT": 1, + "KIOU_BR_HOOK_LOCAL_INIT": 2, + "KIOU_BR_HOOK_ONLINE_INIT": 3, + "KIOU_BR_HOOK_REPLAY_INIT": 4, + "KIOU_BR_HOOK_AI_START": 5, + "KIOU_BR_HOOK_CPUSTREAM_START": 6, + "KIOU_BR_HOOK_LOCAL_START": 7, + "KIOU_BR_HOOK_ONLINE_START": 8, + "KIOU_BR_HOOK_REPLAY_START": 9, + "KIOU_BR_HOOK_AI_OPM": 10, + "KIOU_BR_HOOK_CPUSTREAM_OPM": 11, + "KIOU_BR_HOOK_LOCAL_OPM": 12, + "KIOU_BR_HOOK_ONLINE_OPM": 13, + "KIOU_BR_HOOK_REPLAY_OPM": 14, + "KIOU_BR_HOOK_AI_END": 15, + "KIOU_BR_HOOK_CPUSTREAM_END": 16, + "KIOU_BR_HOOK_LOCAL_END": 17, + "KIOU_BR_HOOK_ONLINE_END": 18, + "KIOU_BR_HOOK_REPLAY_END": 19, + "KIOU_BR_HOOK_ADAPTER_TRY_MAKE_MOVE_OUT": 20, + "KIOU_BR_HOOK_ONLINE_UPDATE_SNAPSHOT": 21, + "KIOU_BR_HOOK_ONLINE_HANDLE_RESULT": 22, + "KIOU_BR_HOOK_CPUSTREAM_UPDATE_SNAPSHOT": 23, + "KIOU_BR_HOOK_GAMEORCH_ACTIVATE": 24, + "KIOU_BR_HOOK_GSTATE_SET_BLACK_PLAYER_INFO": 25, + "KIOU_BR_HOOK_GSTATE_SET_WHITE_PLAYER_INFO": 26, + "KIOU_BR_HOOK_GSTATE_NOTIFY_PIECE_MOVED": 27, +} + + +# --------------------------------------------------------------------------- +# Cave payload builder. +# +# Cave shape (21 insns = 84 bytes), see +# docs/plans/kiou_engine_bridge_binpatch.md § 6 Phase C: +# +# STP X29, X30, [SP, #-0x90]! +# STP X19, X20, [SP, #0x10] +# STP X21, X22, [SP, #0x20] +# STP X0, X1, [SP, #0x30] ; save args (self, arg1) +# STP X2, X3, [SP, #0x40] +# STP X4, X5, [SP, #0x50] +# STP X6, X7, [SP, #0x60] ; (X6 saved before we clobber it) +# MOV X29, SP +# ADRP X16, page(SLOT) +# LDR X16, [X16, #lo12(SLOT)] +# MOVZ W6, #hook_id ; pass hook id via W6 (no real arg lives there) +# BLR X16 ; dispatcher(x0..x5, hook_id_in_x6, x7) via SLOT +# LDP X6, X7, [SP, #0x60] ; restore X6/X7 before resuming orig +# LDP X4, X5, [SP, #0x50] +# LDP X2, X3, [SP, #0x40] +# LDP X0, X1, [SP, #0x30] +# LDP X21, X22, [SP, #0x20] +# LDP X19, X20, [SP, #0x10] +# LDP X29, X30, [SP], #0x90 +# ; verbatim, must be PC-independent +# B +# +# Hook id is loaded into W6 instead of W2 because several Bridge hook +# sites carry a real argument in X2 (``OnPlayerMoveAsync(self, mv, ct)`` +# uses X2 for ``ct``; ``UpdateAuthoritativeSnapshot`` uses W2 for +# ``turn``; ``Adapter.TryMakeMove(Move, out)`` uses X2 for the out +# pointer). The dispatcher needs to forward those values to the C hook +# bodies, so the cave must keep X2 holding the real call-site argument +# when ``BLR X16`` is executed. X6 is unused at entry by every site in +# this recipe — all of them take at most six integer-class arguments. +# X6/X7 are still saved/restored across the BLR so the displaced +# prologue and ``B orig+4`` resume with the original register state. +# --------------------------------------------------------------------------- + +CAVE_PAYLOAD_SIZE = 84 # 21 instructions + + +def _build_bridge_cave_payload( + orig_va: int, slot_va: int, displaced_insn: bytes, hook_id: int +): + """Return a ``build_payload(cave_va) -> bytes`` closure for one site. + + Parameters + ---------- + orig_va : int + VA of the prologue instruction that will be replaced with + ``B ``. The cave trampolines back to ``orig_va + 4`` + after executing the displaced prologue insn locally. + slot_va : int + VA of the 8-byte __bss slot the dylib constructor publishes the + dispatcher pointer into. + displaced_insn : bytes + The 4 prologue bytes about to be overwritten. Must be + PC-independent (STP pre-index, SUB SP, or RET). + hook_id : int + Identifier from ``enum kiou_bridge_hook_id``. Loaded into W6 + before BLR so the dispatcher can switch on it without + clobbering any caller-supplied argument register. + """ + if len(displaced_insn) != 4: + raise ValueError( + f"displaced_insn must be exactly 4 bytes; got {len(displaced_insn)}" + ) + if not (0 <= hook_id <= 0xFFFF): + raise ValueError(f"hook_id out of MOVZ 16-bit range: {hook_id}") + + def build(cave_va: int) -> bytes: + out = bytearray() + cur = cave_va + + def emit(insn: bytes) -> None: + nonlocal cur + out.extend(insn) + cur += 4 + + # --- prologue: save LR, callee-saved scratch, and arg registers --- + emit(stp_pre_x(29, 30, 31, -0x90)) + emit(stp_off_x(19, 20, 31, 0x10)) + emit(stp_off_x(21, 22, 31, 0x20)) + emit(stp_off_x(0, 1, 31, 0x30)) + emit(stp_off_x(2, 3, 31, 0x40)) + emit(stp_off_x(4, 5, 31, 0x50)) + emit(stp_off_x(6, 7, 31, 0x60)) + # MOV X29, SP. arm64 has no register-to-register MOV that touches + # SP; the canonical encoding is `ADD X29, SP, #0`, which the + # disassembler renders as `MOV X29, SP`. + emit(add_x_imm(29, 31, 0)) + + # --- materialize SLOT address; load the published dispatcher pointer --- + emit(adrp(16, cur, slot_va)) + emit(ldr_x_imm(16, 16, slot_va & 0xFFF)) + + # --- pass the hook id to the dispatcher via X6 --- + emit(movz_w_imm(6, hook_id)) + + emit(blr_x(16)) + + # --- restore --- + emit(ldp_off_x(6, 7, 31, 0x60)) + emit(ldp_off_x(4, 5, 31, 0x50)) + emit(ldp_off_x(2, 3, 31, 0x40)) + emit(ldp_off_x(0, 1, 31, 0x30)) + emit(ldp_off_x(21, 22, 31, 0x20)) + emit(ldp_off_x(19, 20, 31, 0x10)) + emit(ldp_post_x(29, 30, 31, 0x90)) + + # --- execute the displaced prologue insn verbatim --- + emit(displaced_insn) + + # --- branch to (orig + 4) --- + emit(b_imm(cur, orig_va + 4)) + + if len(out) != CAVE_PAYLOAD_SIZE: + raise AssertionError( + f"cave payload wrong size: got {len(out)}, expected {CAVE_PAYLOAD_SIZE}" + ) + return bytes(out) + + return build + + +# --------------------------------------------------------------------------- +# PATCHES — inline single-instruction (or short-multi-instruction) +# replacements. +# +# IsAfkEnabled (RVA 0x59455D4) — replace wholesale with ``MOVZ W0, #0; RET`` +# so the AFK check returns false unconditionally. The original first +# 8 bytes are: +# +# 0x59455D4: f44fbea9 STP X20, X19, [SP, #-0x20]! +# 0x59455D8: fd7b01a9 STP X29, X30, [SP, #0x10] +# +# The 8-byte replacement clobbers both. That's safe: the RET at offset +# +4 fires before the clobbered STP X29/X30 could ever execute, and +# nothing else in the function reaches those four bytes (they're the +# prologue's second instruction and we've replaced the entry point with +# a return). No PC-relative instruction lives in that 4-byte slot — the +# top byte 0xA9 marks it as an STP pre/offset, which is PC-independent +# even if it did execute. Recorded here so a future reader knows what +# was clobbered. +# --------------------------------------------------------------------------- + +_AFK_SITE = 0x59455D4 +_AFK_ORIG_8 = bytes.fromhex("f44fbea9fd7b01a9") +_AFK_NEW_8 = mov_w0_imm_ret(0) + +PATCHES: list = [ + ( + _AFK_SITE, + _AFK_ORIG_8, + _AFK_NEW_8, + "IsAfkEnabled: return false (MOVZ W0,#0; RET), clobbers STP X29,X30,[SP,#0x10]", + ), +] + + +# --------------------------------------------------------------------------- +# CAVE_PATCHES — each entry redirects a 4-byte site instruction to a cave. +# +# Per-site tuple: (RVA, prologue hex, hook_id name, label). +# +# Verified bytes-on-disk against the freshly extracted Kiou-1.0.1 build +# 11 UnityFramework on 2026-06-15. Every prologue listed below is +# PC-independent (top byte is 0xa9 = STP off/pre, 0x6d = STP D-reg, 0xd1 +# = SUB SP/MOV imm, or 0xd6 = RET), so each can be relocated into its +# cave verbatim. +# +# The OnMatchStart row for LocalPvPMode uses prologue ``c0035fd6`` (RET). +# That's the on-disk first instruction of the method — the il2cpp +# compilation emits a synchronous void thunk that just returns. Placing +# RET inside the cave means the post-dispatcher branch back to +# ``orig + 4`` is unreachable, which is fine: the method's job is already +# done by the time RET fires, and the dispatcher captured everything it +# needs in the latch. (Caves are still 84 bytes; the trailing ``B orig+4`` +# encoding is emitted but never executed.) +# +# Allocation order in this list = allocation order in the cave region. +# That ordering MUST stay stable so re-runs land cave bytes at the exact +# same addresses (the "already patched" SKIP path matches the site AND +# the cave content byte-for-byte). +# +# Branch F relies on that stability for injection bypass trampolines. The +# dylib reconstructs per-site "skip the dispatcher" entries as: +# +# cave_va + 0x4C = unityBase + CAVE_REGION_START + i * 84 + 0x4C +# +# where i is the allocation index in this table. The cave tail's last three +# instructions are: +# +# +0x48 LDP X29, X30, [SP], #0x90 ; epilogue's stack restore +# +0x4C ; the original method's first 4 B +# +0x50 B ; branch back into the original +# +# So calling +0x4C runs only the displaced prologue and the branch back to +# orig+4, bypassing both the dispatcher and the epilogue's LDP (which would +# pop the WRONG X29/X30 pair off the inject path's stack and corrupt the +# frame pointer). If this recipe ever moves away from a uniform 84-byte +# allocator or reorders `_BRIDGE_SITES`, the dylib-side recomputation in +# BinpatchDispatcher.m / Inject_Move.m must be updated in lockstep. +# --------------------------------------------------------------------------- + +_BRIDGE_SITES: list[tuple[int, str, str, str]] = [ + # OnMatchEndAsync × 5 + (0x59E5958, "f657bda9", "KIOU_BR_HOOK_AI_END", "AIMatchMode.OnMatchEndAsync"), + (0x59EC818, "ff8301d1", "KIOU_BR_HOOK_CPUSTREAM_END", "CPUStreamMode.OnMatchEndAsync"), + (0x59FF8F8, "f44fbea9", "KIOU_BR_HOOK_LOCAL_END", "LocalPvPMode.OnMatchEndAsync"), + (0x5A0139C, "ff8301d1", "KIOU_BR_HOOK_ONLINE_END", "OnlinePvPMode.OnMatchEndAsync"), + (0x5A2B564, "f85fbca9", "KIOU_BR_HOOK_REPLAY_END", "RecordReplayMode.OnMatchEndAsync"), + + # InitializeAsync × 5 + (0x59E4E0C, "e923ba6d", "KIOU_BR_HOOK_AI_INIT", "AIMatchMode.InitializeAsync"), + (0x59E7B48, "ff8302d1", "KIOU_BR_HOOK_CPUSTREAM_INIT", "CPUStreamMode.InitializeAsync"), + (0x59FF7B0, "f657bda9", "KIOU_BR_HOOK_LOCAL_INIT", "LocalPvPMode.InitializeAsync"), + (0x5A00E90, "ff8302d1", "KIOU_BR_HOOK_ONLINE_INIT", "OnlinePvPMode.InitializeAsync"), + (0x5A2ADD0, "ff0301d1", "KIOU_BR_HOOK_REPLAY_INIT", "RecordReplayMode.InitializeAsync"), + + # OnPlayerMoveAsync × 5 + (0x59E5268, "ffc301d1", "KIOU_BR_HOOK_AI_OPM", "AIMatchMode.OnPlayerMoveAsync"), + (0x59E886C, "ffc301d1", "KIOU_BR_HOOK_CPUSTREAM_OPM", "CPUStreamMode.OnPlayerMoveAsync"), + (0x59FF87C, "f44fbea9", "KIOU_BR_HOOK_LOCAL_OPM", "LocalPvPMode.OnPlayerMoveAsync"), + (0x5A012D8, "ffc301d1", "KIOU_BR_HOOK_ONLINE_OPM", "OnlinePvPMode.OnPlayerMoveAsync"), + (0x5A2B3EC, "ff0301d1", "KIOU_BR_HOOK_REPLAY_OPM", "RecordReplayMode.OnPlayerMoveAsync"), + + # OnMatchStart × 5 — prologue bytes captured on 2026-06-15 from the + # extracted Kiou-1.0.1 build 11 UnityFramework. LocalPvPMode's + # OnMatchStart is a synchronous void thunk that compiles to a bare + # RET (top byte 0xd6); the other four are STP pre-index frame setups. + (0x59E5000, "f85fbca9", "KIOU_BR_HOOK_AI_START", "AIMatchMode.OnMatchStart"), + (0x59E7D64, "fa67bba9", "KIOU_BR_HOOK_CPUSTREAM_START", "CPUStreamMode.OnMatchStart"), + (0x59FF878, "c0035fd6", "KIOU_BR_HOOK_LOCAL_START", "LocalPvPMode.OnMatchStart"), + (0x59FFE3C, "f657bda9", "KIOU_BR_HOOK_ONLINE_START", "OnlinePvPMode.OnMatchStart"), + (0x5A2B36C, "f657bda9", "KIOU_BR_HOOK_REPLAY_START", "RecordReplayMode.OnMatchStart"), + + # Single-site observation hooks + (0x59D0DFC, "ff8301d1", "KIOU_BR_HOOK_ADAPTER_TRY_MAKE_MOVE_OUT", "ShogiGameAdapter.TryMakeMove(Move,out)"), + (0x5A0A64C, "e923bc6d", "KIOU_BR_HOOK_ONLINE_UPDATE_SNAPSHOT", "OnlinePvPMode.UpdateAuthoritativeSnapshot"), + (0x5A0CBD0, "ff0302d1", "KIOU_BR_HOOK_ONLINE_HANDLE_RESULT", "OnlinePvPMode.HandleMoveResult"), + (0x59EB0E0, "e923bc6d", "KIOU_BR_HOOK_CPUSTREAM_UPDATE_SNAPSHOT", "CPUStreamMode.UpdateAuthoritativeSnapshot"), + (0x5944E84, "ff4302d1", "KIOU_BR_HOOK_GAMEORCH_ACTIVATE", "GameOrchestrator.ActivateAsync"), + + # GameStateStore hooks — move observation (CSA) + player identity (Online) + (0x5A2CB64, "f44fbea9", "KIOU_BR_HOOK_GSTATE_SET_BLACK_PLAYER_INFO", "GameStateStore.SetBlackPlayerInfo"), + (0x5A2CBA0, "f44fbea9", "KIOU_BR_HOOK_GSTATE_SET_WHITE_PLAYER_INFO", "GameStateStore.SetWhitePlayerInfo"), + (0x5A2CD24, "ff4301d1", "KIOU_BR_HOOK_GSTATE_NOTIFY_PIECE_MOVED", "GameStateStore.NotifyPieceMoved"), +] + + +CAVE_PATCHES: list = [ + ( + site, + bytes.fromhex(prologue_hex), + _build_bridge_cave_payload( + site, HOOK_SLOT_RVA, bytes.fromhex(prologue_hex), _HOOK_IDS[hook_id_name] + ), + f"{label}: route to Bridge cave ({hook_id_name})", + ) + for site, prologue_hex, hook_id_name, label in _BRIDGE_SITES +] + + +# --------------------------------------------------------------------------- +# Info.plist additions. +# +# Bridge talks to its host bridge over plain TCP on port 9527 — it does +# not use Bonjour / mDNS, so iOS 14+'s NSLocalNetworkUsageDescription +# permission gate is not triggered. The plist keys below are only for +# Files.app access to the app sandbox, so the Common logging helper can +# expose the binpatch log from Documents when IPA_LOG_TO_DOCUMENTS=1 is +# enabled by the consumer build. +# +# See docs/plans/kiou_engine_bridge_binpatch.md § 8 for the local-network +# rationale and the Phase C measurement that confirmed iOS 18 does not pop +# the local-network gate for plain ``0.0.0.0:9527`` listeners. +# --------------------------------------------------------------------------- + +PLIST_KEYS: dict = { + "UIFileSharingEnabled": True, + "LSSupportsOpeningDocumentsInPlace": True, +} + +# --------------------------------------------------------------------------- +# _SITES — verify_sites-compatible view of _BRIDGE_SITES. +# +# tools.verify_sites expects a flat iterable of +# (slot_index, site_rva, prologue_hex_str, label) +# which matches the (rva, prologue_hex, hook_id_name, label) shape of +# _BRIDGE_SITES once we substitute the slot_index with the _HOOK_IDS value. +# Exposing this alias lets ``make hooks`` / scripts/pre-commit run the +# same cross-check gate as KiouEditor / KiouKifExporter without changing +# the upstream verify_sites contract. +# --------------------------------------------------------------------------- +_SITES: list[tuple[int, int, str, str]] = [ + (_HOOK_IDS[hook_id_name], site_rva, prologue_hex, label) + for site_rva, prologue_hex, hook_id_name, label in _BRIDGE_SITES +] diff --git a/recipes/kioukifexporter.py b/recipes/kioukifexporter.py new file mode 100644 index 0000000..404d4d4 --- /dev/null +++ b/recipes/kioukifexporter.py @@ -0,0 +1,265 @@ +"""Recipe for KiouKifExporter — Phase 1.5 binpatch. + +Patches UnityFramework so that every ``IMatchMode.OnMatchEndAsync`` +entry calls into ``KiouKifExporter.dylib`` for KIF auto-export, and the +dylib is loaded automatically via ``LC_LOAD_DYLIB``. + +How the patch chain works (see ``docs/plans/kiou_kif_exporter_binpatch.md`` +for the full design): + + 1. Add an ``LC_LOAD_DYLIB`` pointing at + ``@executable_path/Frameworks/KiouKifExporter.dylib`` so dyld + auto-loads the export hook on app launch. + 2. Reserve an 8-byte slot in ``__bss`` (the SLOT) that the dylib + constructor fills with its hook function pointer. Writing to + ``__DATA`` does not trigger CSM on iOS 18. + 3. For every ``IMatchMode.OnMatchEndAsync`` entry (5 modes), replace + the prologue's first 4 bytes with ``B ``. + 4. The cave preserves caller registers, calls the hook through the + SLOT, restores registers, executes the displaced prologue + instruction, and branches to ``orig + 4``. + +This recipe is consumed by ``tools.patch_macho`` together with the +generic primitives in ``tools.encode``, ``tools.machoops``, and +``tools.caves``. +""" + +from __future__ import annotations + +from tools.encode import ( + add_x_imm, + adrp, + b_imm, + blr_x, + ldp_off_x, + ldp_post_x, + ldr_x_imm, + movz_w_imm, + stp_off_x, + stp_pre_x, +) + + +# --------------------------------------------------------------------------- +# Target identification +# --------------------------------------------------------------------------- + +TARGET_BASENAME = "UnityFramework" +DYLIB_PATH = "@executable_path/Frameworks/KiouKifExporter.dylib" + + +# --------------------------------------------------------------------------- +# Code-cave region. +# +# UnityFramework's ``__TEXT,__oslogstring`` ends with a multi-KB zero-fill +# inside the same r-x mapping as every other instruction. Cave payloads +# are carved out of that range. Pinned against the freshly extracted +# Kiou-1.0.1 build 11 UnityFramework: +# +# - The last non-zero byte of __oslogstring sits at file offset 0x8268023. +# - __TEXT ends (exclusive) at file offset 0x826C000. +# - The whole 0x8268024 .. 0x826C000 range is zero-filled and read-execute +# mapped, so it is safe to populate with arm64 instructions and branch +# into without any segment edits. +# +# KiouEditor uses the same range; if both tools are applied to the same +# binary the leader allocates KiouEditor first and KiouKifExporter second. +# Per-cave size is 84 bytes; five caves consume 420 bytes total, well +# below the 16348-byte budget. +# --------------------------------------------------------------------------- + +CAVE_REGION = (0x8268024, 0x826C000) # (start, end exclusive) + + +# --------------------------------------------------------------------------- +# Hook slot. +# +# The dylib constructor publishes its hook function pointer into this +# 8-byte slot inside __DATA,__bss. ``reserve_hook_slot()`` derives it +# from the live binary; we hard-code it here so the cave's ADRP+LDR +# encoding is deterministic and the post-patch idempotency check works +# without re-parsing the Mach-O. If a future UnityFramework changes the +# __bss layout, re-run reserve_hook_slot() and update this constant. +# --------------------------------------------------------------------------- + +HOOK_SLOT_RVA = 0x8F90CD0 + + +# --------------------------------------------------------------------------- +# Cave payload builder. +# +# Cave shape (21 insns = 84 bytes), see +# docs/plans/kiou_kif_exporter_binpatch.md sec 4.3: +# +# STP X29, X30, [SP, #-0x90]! +# STP X19, X20, [SP, #0x10] +# STP X21, X22, [SP, #0x20] +# STP X0, X1, [SP, #0x30] ; save args (self, ct) +# STP X2, X3, [SP, #0x40] +# STP X4, X5, [SP, #0x50] +# STP X6, X7, [SP, #0x60] +# MOV X29, SP +# ADRP X16, page(SLOT) +# LDR X16, [X16, #lo12(SLOT)] +# MOVZ W2, #mode_index ; pass the mode index as the third arg +# BLR X16 ; call hook(self, ct, mode_index) via SLOT +# LDP X6, X7, [SP, #0x60] +# LDP X4, X5, [SP, #0x50] +# LDP X2, X3, [SP, #0x40] +# LDP X0, X1, [SP, #0x30] +# LDP X21, X22, [SP, #0x20] +# LDP X19, X20, [SP, #0x10] +# LDP X29, X30, [SP], #0x90 +# ; verbatim, must be PC-independent +# B +# --------------------------------------------------------------------------- + +CAVE_PAYLOAD_SIZE = 84 # 21 instructions + + +def _build_match_end_cave_payload( + orig_va: int, slot_va: int, displaced_insn: bytes, mode_index: int +): + """Return a ``build_payload(cave_va) -> bytes`` closure for one mode. + + Parameters + ---------- + orig_va : int + VA of the OnMatchEndAsync prologue instruction that will be + replaced with ``B ``. The cave trampolines back to + ``orig_va + 4`` after executing the displaced prologue insn + locally. + slot_va : int + VA of the 8-byte __bss slot the dylib constructor publishes the + hook function pointer into. + displaced_insn : bytes + The 4 prologue bytes about to be overwritten. Must be + PC-independent (STP pre-index, SUB SP, etc.). + mode_index : int + Identifier for the IMatchMode concrete subclass this cave + serves. Loaded into X2 (third arg) before BLR so the dylib hook + can pick the correct ``_gameAdapter`` field offset without + guessing. + """ + if len(displaced_insn) != 4: + raise ValueError( + f"displaced_insn must be exactly 4 bytes; got {len(displaced_insn)}" + ) + if not (0 <= mode_index <= 0xFFFF): + raise ValueError(f"mode_index out of MOVZ 16-bit range: {mode_index}") + + def build(cave_va: int) -> bytes: + out = bytearray() + cur = cave_va + + def emit(insn: bytes) -> None: + nonlocal cur + out.extend(insn) + cur += 4 + + # --- prologue: save LR, callee-saved scratch, and arg registers --- + emit(stp_pre_x(29, 30, 31, -0x90)) + emit(stp_off_x(19, 20, 31, 0x10)) + emit(stp_off_x(21, 22, 31, 0x20)) + emit(stp_off_x(0, 1, 31, 0x30)) + emit(stp_off_x(2, 3, 31, 0x40)) + emit(stp_off_x(4, 5, 31, 0x50)) + emit(stp_off_x(6, 7, 31, 0x60)) + # MOV X29, SP. arm64 has no register-to-register MOV that touches + # SP; `MOV Xd, Xm` (ORR Xd, XZR, Xm) treats Rn=31 as XZR, not SP. + # The canonical encoding for "X29 = SP" is `ADD X29, SP, #0`, + # which the disassembler renders as `MOV X29, SP`. + emit(add_x_imm(29, 31, 0)) + + # --- materialize SLOT address; load the published hook pointer --- + emit(adrp(16, cur, slot_va)) + emit(ldr_x_imm(16, 16, slot_va & 0xFFF)) + + # --- pass the mode index to the hook via X2 --- + emit(movz_w_imm(2, mode_index)) + + emit(blr_x(16)) + + # --- restore --- + emit(ldp_off_x(6, 7, 31, 0x60)) + emit(ldp_off_x(4, 5, 31, 0x50)) + emit(ldp_off_x(2, 3, 31, 0x40)) + emit(ldp_off_x(0, 1, 31, 0x30)) + emit(ldp_off_x(21, 22, 31, 0x20)) + emit(ldp_off_x(19, 20, 31, 0x10)) + emit(ldp_post_x(29, 30, 31, 0x90)) + + # --- execute the displaced prologue insn verbatim --- + emit(displaced_insn) + + # --- branch to (orig + 4) --- + emit(b_imm(cur, orig_va + 4)) + + if len(out) != CAVE_PAYLOAD_SIZE: + raise AssertionError( + f"cave payload wrong size: got {len(out)}, expected {CAVE_PAYLOAD_SIZE}" + ) + return bytes(out) + + return build + + +# --------------------------------------------------------------------------- +# PATCHES — inline single-instruction replacements. +# +# Phase 1 (KIF auto-export) does not need any inline patches — the only +# behaviour change is hook installation, which is handled by CAVE_PATCHES. +# --------------------------------------------------------------------------- + +PATCHES: list = [] + + +# --------------------------------------------------------------------------- +# CAVE_PATCHES — each entry redirects a 4-byte site instruction to a cave. +# +# The five IMatchMode.OnMatchEndAsync sites. Each prologue is a single +# PC-independent arm64 instruction (STP pre-index or SUB SP), so we can +# safely relocate it verbatim into the cave. Verified bytes-on-disk +# against the clean Kiou-1.0.1 build 11 UnityFramework on 2026-06-14. +# +# The mode_index column MUST stay in sync with the KIOU_BINPATCH_MODE_* +# enum in Sources/KiouKifExporter/Internal.h. The cave loads it into X2 +# (third arg) so the dylib hook can look up the right _gameAdapter +# offset without guessing. +# --------------------------------------------------------------------------- + +_MATCH_END_SITES: list[tuple[int, str, int, str]] = [ + (0x59E5958, "f657bda9", 0, "AIMatchMode.OnMatchEndAsync"), + (0x59EC818, "ff8301d1", 1, "CPUStreamMode.OnMatchEndAsync"), + (0x59FF8F8, "f44fbea9", 2, "LocalPvPMode.OnMatchEndAsync"), + (0x5A0139C, "ff8301d1", 3, "OnlinePvPMode.OnMatchEndAsync"), + (0x5A2B564, "f85fbca9", 4, "RecordReplayMode.OnMatchEndAsync"), +] + + +CAVE_PATCHES: list = [ + ( + site, + bytes.fromhex(prologue_hex), + _build_match_end_cave_payload( + site, HOOK_SLOT_RVA, bytes.fromhex(prologue_hex), mode_index + ), + f"{label}: route to KIF cave", + ) + for site, prologue_hex, mode_index, label in _MATCH_END_SITES +] + + +# --------------------------------------------------------------------------- +# Info.plist additions for sandbox-Documents visibility through Files.app. +# +# The patched IPA pipeline reads this dict and writes each key into the +# bundle's Info.plist. Setting both flags is what makes "On My iPhone -> +# " expose the sandbox so the operator can read KIF files and the +# diagnostic log from Files.app. +# --------------------------------------------------------------------------- + +PLIST_KEYS: dict = { + "UIFileSharingEnabled": True, + "LSSupportsOpeningDocumentsInPlace": True, +} diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 0000000..8a44a03 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,71 @@ +#!/usr/bin/env sh +# IPA-Patch / KiouEngineBridge pre-commit hook. +# +# Runs the recipe<->dump cross-check before any commit that touches a +# recipe (recipes/.py) or the recipe-driving tools (shared/tools/). +# The dump index is NOT in the repository (see analysis-artifact-layers: +# raw binaries / machine-generated dumps / curated specs -- only the last +# one is git-tracked), so this check can only run on a workstation that +# has the operator's local copy of assets/dump.cs.index.json. +# +# Without the dump index this hook is a no-op (prints a heads-up and +# exits 0): a committer who never edits recipes shouldn't be required +# to keep a large il2cpp dump around. CI does not run this check for the +# same reason; the build job is the server-side gate that catches recipes +# whose CAVE_REGION / prologue is wrong (it fails the patch_macho step). +# +# When several recipes coexist in this repo (e.g. recipes/kiouenginebridge.py +# alongside a future recipes/kioukifexporter.py), each staged recipe is +# verified against the dump separately, so editing the "wrong" recipe +# cannot give a false-positive pass via a hard-coded recipe name. Touching +# only shared/tools/ (no recipes/*.py in the diff) falls back to the +# DEFAULT_RECIPE so the tooling change is still smoke-tested. +# +# Install with: +# make hooks +# which is a one-liner around `git config core.hooksPath scripts`. + +set -e + +# Recipes / tooling files staged in this commit. +STAGED=$(git diff --cached --name-only) +RECIPE_FILES=$(printf '%s\n' "$STAGED" | grep -E '^recipes/[^/]+\.py$' || true) +TOOLING_HIT=$(printf '%s\n' "$STAGED" | grep -E '^shared/tools/' || true) + +if [ -z "$RECIPE_FILES" ] && [ -z "$TOOLING_HIT" ]; then + # No recipe / tooling churn in this commit; nothing to verify. + exit 0 +fi + +INDEX=assets/dump.cs.index.json +if [ ! -f "$INDEX" ]; then + echo "pre-commit: $INDEX not present, skipping recipe<->dump verify." + echo " (drop assets/dump.cs.index.json in place to enable this gate)" + exit 0 +fi + +# Default recipe used when only shared/tools/ changed (no recipes/*.py in +# the diff). Override via VERIFY_SITES_RECIPE for special-case workflows. +DEFAULT_RECIPE="${VERIFY_SITES_RECIPE:-recipes.kiouenginebridge}" + +# Map every staged recipes/.py to the module path recipes.; +# if only tooling changed, fall back to DEFAULT_RECIPE so the change is +# still smoke-tested against at least one recipe. +if [ -n "$RECIPE_FILES" ]; then + RECIPE_MODULES=$(printf '%s\n' "$RECIPE_FILES" \ + | sed -E 's|^recipes/([^/]+)\.py$|recipes.\1|') +else + RECIPE_MODULES="$DEFAULT_RECIPE" +fi + +FAIL=0 +for RECIPE in $RECIPE_MODULES; do + echo "pre-commit: cross-checking $RECIPE against $INDEX ..." + if ! PYTHONPATH=shared:. python3 -m tools.verify_sites \ + --recipe "$RECIPE" \ + --index "$INDEX" ; then + FAIL=1 + fi +done + +exit $FAIL diff --git a/shared b/shared new file mode 160000 index 0000000..d355270 --- /dev/null +++ b/shared @@ -0,0 +1 @@ +Subproject commit d355270aa4c8fe29fef3c9bfb7fe45c14bfba903 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_csa_convert_expectations.py b/tests/test_csa_convert_expectations.py new file mode 100644 index 0000000..f4d9211 --- /dev/null +++ b/tests/test_csa_convert_expectations.py @@ -0,0 +1,532 @@ +"""Pinned expectations for the CSA conversion library (Csa_Convert.{h,m}). + +The C/ObjC implementation lives in ``Sources/KiouEngineBridge/Csa_Convert.m`` +and is exercised on-device against KIOU. We can't link Foundation against a +Linux CI runner, so this test module ports the algorithms to pure Python and +pins the expected outputs against the same inputs the ObjC code receives. + +A future macOS-only test harness (``Tests/CsaConvertTests.m``) will run the +same vectors through the real implementation. Until then, this module is the +authoritative regression net for: + + - Square <-> CSA coordinate (square 60 <-> "77") + - PSC PieceType <-> CSA piece mnemonic (FU..RY, 14 types) + - Move bits <-> CSA move text ("+7776FU", "+0055FU", "+8822UM", ",T10") + - SFEN -> CSA position block (P1..P9 + P+ / P- hand + side) + +If the Python port and the ObjC implementation ever diverge, the diff is +the bug — both should agree on every value in this file. +""" + +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# Python reference implementation. Mirrors Csa_Convert.m verbatim. +# --------------------------------------------------------------------------- + +CSA_PIECE_NAMES = [ + "", # 0 — unused + "FU", # 1 + "KY", # 2 + "KE", # 3 + "GI", # 4 + "KA", # 5 + "HI", # 6 + "KI", # 7 + "OU", # 8 + "TO", # 9 + "NY", # 10 + "NK", # 11 + "NG", # 12 + "UM", # 13 + "RY", # 14 +] + +PROMOTED_PIECE_TYPE = [0, 9, 10, 11, 12, 13, 14, 0, 0, 0, 0, 0, 0, 0, 0] + + +def csa_square_from_move_bits(square: int) -> str | None: + if not (0 <= square <= 80): + return None + f = square // 9 + 1 + r = square % 9 + 1 + return f"{f}{r}" + + +def move_bits_from_csa_square(csa: str) -> int | None: + if len(csa) != 2 or not csa.isdigit(): + return None + f, r = int(csa[0]), int(csa[1]) + if not (1 <= f <= 9 and 1 <= r <= 9): + return None + return (f - 1) * 9 + (r - 1) + + +def csa_piece_from_psc(pt: int) -> str | None: + if not (1 <= pt <= 14): + return None + return CSA_PIECE_NAMES[pt] + + +def psc_from_csa_piece(s: str) -> int: + if len(s) != 2: + return -1 + for i in range(1, 15): + if CSA_PIECE_NAMES[i] == s: + return i + return -1 + + +def encode_move_bits(to: int, frm: int = 0, promote: bool = False, + drop: bool = False) -> int: + return ((to & 0x7F) + | ((frm & 0x7F) << 7) + | ((1 if promote else 0) << 14) + | ((1 if drop else 0) << 15)) + + +def csa_text_from_move_bits(move: int, psc_piece_type: int, player_side: int, + time_spent: int) -> str | None: + if player_side not in (0, 1): + return None + if not (1 <= psc_piece_type <= 14): + return None + to = move & 0x7F + frm = (move >> 7) & 0x7F + promote = bool((move >> 14) & 1) + drop = bool((move >> 15) & 1) + if promote and drop: + return None + to_str = csa_square_from_move_bits(to) + if not to_str: + return None + if drop: + if psc_piece_type > 8: + return None + from_str = "00" + final = psc_piece_type + else: + from_str = csa_square_from_move_bits(frm) + if not from_str: + return None + final = psc_piece_type + if promote: + promoted = PROMOTED_PIECE_TYPE[psc_piece_type] + if promoted == 0: + return None + final = promoted + piece_str = csa_piece_from_psc(final) + if not piece_str: + return None + sign = "+" if player_side == 0 else "-" + if time_spent < 0: + return f"{sign}{from_str}{to_str}{piece_str}" + return f"{sign}{from_str}{to_str}{piece_str},T{time_spent}" + + +def move_bits_from_csa_text(csa: str): + """Return (move, piece_type, player_side, time_spent) or None.""" + if len(csa) < 7: + return None + sign = csa[0] + if sign == "+": + side = 0 + elif sign == "-": + side = 1 + else: + return None + from_str = csa[1:3] + to_str = csa[3:5] + piece_str = csa[5:7] + time_spent = -1 + if len(csa) > 7: + if len(csa) < 9 or csa[7] != "," or csa[8] != "T": + return None + t_str = csa[9:] + if not t_str.isdigit(): + return None + time_spent = int(t_str) + pt = psc_from_csa_piece(piece_str) + if pt < 0: + return None + to = move_bits_from_csa_square(to_str) + if to is None: + return None + is_drop = from_str == "00" + frm = 0 + drop = False + promote = False + if is_drop: + if pt > 8: + return None + drop = True + else: + frm = move_bits_from_csa_square(from_str) + if frm is None: + return None + if pt > 8: + promote = True + move = encode_move_bits(to, frm, promote, drop) + return move, pt, side, time_spent + + +def csa_text_appending_time(csa_move: str, seconds: int) -> str: + if seconds < 0 or not csa_move: + return csa_move + if ",T" in csa_move: + return csa_move + return f"{csa_move},T{seconds}" + + +def sfen_piece_to_psc(letter: str, promoted: bool) -> int | None: + base_map = { + "p": 1, "l": 2, "n": 3, "s": 4, + "b": 5, "r": 6, "g": 7, "k": 8, + } + base = base_map.get(letter.lower()) + if base is None: + return None + if promoted: + promoted_type = PROMOTED_PIECE_TYPE[base] + if promoted_type == 0: + return None + return promoted_type + return base + + +def csa_line_from_sfen_rank(sfen_rank: str) -> str | None: + # CSA's `P` line packs each square into exactly three characters so + # the columns align: ` *` for empty, `+XX` / `-XX` for occupied. Note + # the empty cell uses a leading SPACE, not nothing, so each cell still + # spans three columns when followed by a piece on the next square. + out = [] + pending_promote = False + filled = 0 + for ch in sfen_rank: + if ch == "+": + pending_promote = True + continue + if ch.isdigit() and ch != "0": + empty = int(ch) + for _ in range(empty): + out.append(" * ") + filled += 1 + pending_promote = False + continue + is_black = ch.isupper() + pt = sfen_piece_to_psc(ch, pending_promote) + if pt is None: + return None + out.append("+" if is_black else "-") + out.append(CSA_PIECE_NAMES[pt]) + filled += 1 + pending_promote = False + if filled != 9: + return None + # Collapse the trailing column padding so the line is rstripped at the + # right edge but interior alignment survives. CSA reference outputs + # carry a single trailing space after the last `*` when the rightmost + # cell is empty (e.g. "P2 * -HI * * * * * -KA *"), but the canonical + # shogi-server output trims it. Match the trimmed form. + return "".join(out).rstrip() + + +def csa_position_from_sfen(sfen: str) -> str | None: + if not sfen: + return None + parts = sfen.split() + if len(parts) < 3: + return None + ranks = parts[0].split("/") + if len(ranks) != 9: + return None + lines = [] + for i, rank in enumerate(ranks, start=1): + body = csa_line_from_sfen_rank(rank) + if body is None: + return None + lines.append(f"P{i}{body}") + # Hand pieces. + black = ["P+"] + white = ["P-"] + hand = parts[2] + if hand != "-": + i = 0 + while i < len(hand): + count = 1 + if hand[i].isdigit(): + n_str = "" + while i < len(hand) and hand[i].isdigit(): + n_str += hand[i] + i += 1 + count = int(n_str) + ch = hand[i] + i += 1 + is_black = ch.isupper() + pt = sfen_piece_to_psc(ch, False) + if pt is None: + return None + target = black if is_black else white + for _ in range(count): + target.append("00") + target.append(CSA_PIECE_NAMES[pt]) + lines.append("".join(black)) + lines.append("".join(white)) + if parts[1] == "b": + lines.append("+") + elif parts[1] == "w": + lines.append("-") + else: + return None + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Pinned vectors. +# --------------------------------------------------------------------------- + +SFEN_INITIAL = ( + "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1" +) + + +# --- coordinate conversion -------------------------------------------------- + +class TestSquareConversion: + @pytest.mark.parametrize("square, expected", [ + (0, "11"), + (8, "19"), + (60, "77"), + (72, "91"), + (80, "99"), + ]) + def test_square_to_csa(self, square, expected): + assert csa_square_from_move_bits(square) == expected + + def test_square_out_of_range(self): + assert csa_square_from_move_bits(81) is None + assert csa_square_from_move_bits(-1 & 0xFF) is None + + @pytest.mark.parametrize("csa, expected", [ + ("11", 0), + ("19", 8), + ("77", 60), + ("91", 72), + ("99", 80), + ]) + def test_csa_to_square(self, csa, expected): + assert move_bits_from_csa_square(csa) == expected + + @pytest.mark.parametrize("bad", ["", "1", "111", "0a", "a0", "01", "10"]) + def test_csa_square_rejects_bad_input(self, bad): + assert move_bits_from_csa_square(bad) is None + + def test_roundtrip(self): + for sq in range(81): + assert move_bits_from_csa_square( + csa_square_from_move_bits(sq)) == sq + + +# --- piece conversion ------------------------------------------------------- + +class TestPieceConversion: + @pytest.mark.parametrize("pt, csa", [ + (1, "FU"), (2, "KY"), (3, "KE"), (4, "GI"), + (5, "KA"), (6, "HI"), (7, "KI"), (8, "OU"), + (9, "TO"), (10, "NY"), (11, "NK"), (12, "NG"), + (13, "UM"), (14, "RY"), + ]) + def test_psc_to_csa(self, pt, csa): + assert csa_piece_from_psc(pt) == csa + + def test_psc_out_of_range(self): + assert csa_piece_from_psc(0) is None + assert csa_piece_from_psc(15) is None + assert csa_piece_from_psc(-1) is None + + @pytest.mark.parametrize("csa, pt", [ + ("FU", 1), ("KY", 2), ("KE", 3), ("GI", 4), + ("KA", 5), ("HI", 6), ("KI", 7), ("OU", 8), + ("TO", 9), ("NY", 10), ("NK", 11), ("NG", 12), + ("UM", 13), ("RY", 14), + ]) + def test_csa_to_psc(self, csa, pt): + assert psc_from_csa_piece(csa) == pt + + @pytest.mark.parametrize("bad", ["", "F", "FUU", "fu", "XX"]) + def test_csa_piece_rejects_bad_input(self, bad): + assert psc_from_csa_piece(bad) == -1 + + +# --- move bits <-> CSA text ------------------------------------------------- + +class TestMoveText: + def test_ordinary_move_no_time(self): + # 7g7f: SQ77 (60) -> SQ76 (59), pawn (FU) + move = encode_move_bits(to=59, frm=60) + assert csa_text_from_move_bits(move, 1, 0, -1) == "+7776FU" + + def test_ordinary_move_with_time(self): + move = encode_move_bits(to=59, frm=60) + assert csa_text_from_move_bits(move, 1, 0, 10) == "+7776FU,T10" + + def test_white_move(self): + # 3c3d: SQ33 (20) -> SQ34 (21), pawn (downward = white) + move = encode_move_bits(to=21, frm=20) + assert csa_text_from_move_bits(move, 1, 1, 8) == "-3334FU,T8" + + def test_promoted_move(self): + # 8h2b+ : KA promoting in enemy camp. + # SQ88 = (8-1)*9 + (8-1) = 70; SQ22 = (2-1)*9 + (2-1) = 10 + move = encode_move_bits(to=10, frm=70, promote=True) + assert csa_text_from_move_bits(move, 5, 0, -1) == "+8822UM" + + def test_drop_move(self): + # P*5e -> drop pawn at SQ55 = (5-1)*9 + (5-1) = 40 + move = encode_move_bits(to=40, drop=True) + assert csa_text_from_move_bits(move, 1, 0, 5) == "+0055FU,T5" + + def test_drop_promoted_rejected(self): + # Drop of a promoted piece type is illegal in CSA. + move = encode_move_bits(to=40, drop=True) + assert csa_text_from_move_bits(move, 9, 0, -1) is None + + def test_invalid_side(self): + move = encode_move_bits(to=0, frm=1) + assert csa_text_from_move_bits(move, 1, 2, -1) is None + + def test_promote_and_drop_rejected(self): + move = encode_move_bits(to=40, drop=True, promote=True) + assert csa_text_from_move_bits(move, 1, 0, -1) is None + + def test_king_cannot_promote(self): + move = encode_move_bits(to=40, frm=41, promote=True) + assert csa_text_from_move_bits(move, 8, 0, -1) is None + + def test_gold_cannot_promote(self): + move = encode_move_bits(to=40, frm=41, promote=True) + assert csa_text_from_move_bits(move, 7, 0, -1) is None + + +# --- CSA text parsing ------------------------------------------------------- + +class TestMoveParse: + def test_ordinary(self): + result = move_bits_from_csa_text("+7776FU") + assert result is not None + move, pt, side, t = result + assert side == 0 + assert pt == 1 + assert (move & 0x7F) == 59 + assert ((move >> 7) & 0x7F) == 60 + assert ((move >> 14) & 1) == 0 + assert ((move >> 15) & 1) == 0 + assert t == -1 + + def test_with_time(self): + result = move_bits_from_csa_text("-3334FU,T8") + assert result is not None + _, _, side, t = result + assert side == 1 + assert t == 8 + + def test_drop(self): + result = move_bits_from_csa_text("+0055FU,T5") + assert result is not None + move, pt, side, t = result + assert ((move >> 15) & 1) == 1 + assert (move & 0x7F) == 40 + assert pt == 1 + assert side == 0 + assert t == 5 + + def test_promotion(self): + # Promoted piece in CSA: piece mnemonic is the promoted form + result = move_bits_from_csa_text("+8822UM") + assert result is not None + move, pt, _, _ = result + # promote bit should be set + assert ((move >> 14) & 1) == 1 + # piece type returned is the promoted form (UM = 13) + assert pt == 13 + + @pytest.mark.parametrize("bad", [ + "", "+7776F", "*7776FU", "+777FU", "+77a6FU", "+7776FU,", + "+7776FU,T", "+7776FU,Tabc", "+7776XX", + ]) + def test_rejects_bad_input(self, bad): + assert move_bits_from_csa_text(bad) is None + + def test_drop_promoted_rejected_on_parse(self): + # CSA "from = 00" with a promoted-form piece name should not parse. + assert move_bits_from_csa_text("+0055TO") is None + + +# --- SFEN -> CSA position --------------------------------------------------- + +INITIAL_CSA_POSITION = ( + "P1-KY-KE-GI-KI-OU-KI-GI-KE-KY\n" + "P2 * -HI * * * * * -KA *\n" + "P3-FU-FU-FU-FU-FU-FU-FU-FU-FU\n" + "P4 * * * * * * * * *\n" + "P5 * * * * * * * * *\n" + "P6 * * * * * * * * *\n" + "P7+FU+FU+FU+FU+FU+FU+FU+FU+FU\n" + "P8 * +KA * * * * * +HI *\n" + "P9+KY+KE+GI+KI+OU+KI+GI+KE+KY\n" + "P+\n" + "P-\n" + "+" +) + + +class TestCsaPosition: + def test_initial_position(self): + assert csa_position_from_sfen(SFEN_INITIAL) == INITIAL_CSA_POSITION + + def test_white_to_move(self): + sfen = ("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL " + "w - 1") + result = csa_position_from_sfen(sfen) + assert result is not None + assert result.endswith("\nP-\n-") + + def test_hand_pieces(self): + # Black holds 2 pawns and a knight, white holds a bishop. + sfen = ("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL " + "b 2PNb 1") + result = csa_position_from_sfen(sfen) + assert result is not None + assert "P+00FU00FU00KE" in result + assert "P-00KA" in result + + def test_promoted_piece_in_board(self): + # An SFEN row with +P (promoted pawn = TO) somewhere on the board. + sfen = ("lnsgkgsnl/1r5b1/ppppppppp/9/4+P4/9/PPPP1PPPP/1B5R1/LNSGKGSNL " + "b - 1") + result = csa_position_from_sfen(sfen) + assert result is not None + # Row 5 should contain +TO at the centre square (file 5). + assert "P5 * * * * +TO * * * *" in result + + def test_malformed_rejected(self): + assert csa_position_from_sfen("") is None + assert csa_position_from_sfen("xxx") is None + # 8 ranks instead of 9 + assert csa_position_from_sfen("9/9/9/9/9/9/9/9 b - 1") is None + + +# --- helper ----------------------------------------------------------------- + +class TestAppendTime: + def test_appends_when_missing(self): + assert csa_text_appending_time("+7776FU", 10) == "+7776FU,T10" + + def test_keeps_existing(self): + assert csa_text_appending_time("+7776FU,T5", 10) == "+7776FU,T5" + + def test_negative_passes_through(self): + assert csa_text_appending_time("+7776FU", -1) == "+7776FU" diff --git a/tests/test_recipes_kiouenginebridge.py b/tests/test_recipes_kiouenginebridge.py new file mode 100644 index 0000000..e3bbddc --- /dev/null +++ b/tests/test_recipes_kiouenginebridge.py @@ -0,0 +1,80 @@ +"""Structural smoke test for ``recipes.kiouenginebridge``. + +Asserts the recipe exports the symbols ``patch_macho`` expects and that +the table counts match the migration plan. Does NOT run +``apply_patches`` against a real binary — that's an integration step +that depends on a clean UnityFramework not shipped in this repo. +""" + +from __future__ import annotations + +import importlib + + +def _load(): + return importlib.import_module("recipes.kiouenginebridge") + + +def test_target_basename(): + r = _load() + assert r.TARGET_BASENAME == "UnityFramework" + + +def test_dylib_path(): + r = _load() + assert r.DYLIB_PATH == "@executable_path/Frameworks/KiouEngineBridge.dylib" + + +def test_hook_slot_rva_is_eight_byte_aligned(): + r = _load() + assert r.HOOK_SLOT_RVA % 8 == 0 + # Plan § 8: Bridge slot is placed 16 bytes ahead of KifExporter's + # 0x8F90CD0 so the two recipes can coexist on the same Mach-O. + assert r.HOOK_SLOT_RVA == 0x8F90CC0 + + +def test_cave_region_partition(): + r = _load() + start, end = r.CAVE_REGION + assert start < end + # Plan § 8: Bridge owns the back half of __oslogstring's zero-fill, + # KifExporter owns the front. Two recipes must be region-disjoint + # in practice (their entries may overlap declared ranges, but + # actual cave allocations must not collide — see § 8 caveat). + assert start == 0x826A000 + assert end == 0x826C000 + + +def test_inline_patch_count(): + r = _load() + # IsAfkEnabled is the only inline byte patch in Phase 1. + assert len(r.PATCHES) == 1 + + +def test_cave_patch_count(): + r = _load() + # 25 cave-routed sites (Init × 5, Start × 5, OPM × 5, End × 5, plus + # Adapter.TryMakeMove(Move,out), Online.UpdateAuthoritativeSnapshot, + # Online.HandleMoveResult, CPUStream.UpdateAuthoritativeSnapshot, + # GameOrchestrator.ActivateAsync). See plan § 3. + assert len(r.CAVE_PATCHES) == 25 + + +def test_cave_budget_fits(): + r = _load() + # 25 × 84 B caves = 2100 B, well below the 8 KB Bridge partition. + cave_count = len(r.CAVE_PATCHES) + assert cave_count * 84 <= (r.CAVE_REGION[1] - r.CAVE_REGION[0]) + + +def test_plist_keys_file_sharing(): + r = _load() + # Bridge needs the Files.app keys so the binpatch log path under + # Documents (IPA_LOG_TO_DOCUMENTS=1 in the binpatch Makefile target) + # is reachable from the host. WebSocket on 0.0.0.0:9527 does not + # need any Bonjour / NSLocalNetworkUsageDescription entry, so the + # plist additions are only these two. + assert r.PLIST_KEYS == { + "UIFileSharingEnabled": True, + "LSSupportsOpeningDocumentsInPlace": True, + } diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..847abc2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,153 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "kiouenginebridge-tools" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "lief" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "lief", specifier = ">=0.17.6" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.15.9" }, +] + +[[package]] +name = "lief" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/b9/6b27bff4676de0db4231ca585ed35bc6e13f5430c1bbf0ad0e9d2e9f552f/lief-0.17.6.tar.gz", hash = "sha256:c2164243f152e82c49b0ccd606155b758644f4b1ee221f0dbd4da055469a922f", size = 7171, upload-time = "2026-03-18T06:59:43.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/29/e7a0dabcb853867da70fda2b397012dd3d9ef4994ab7e8bd21f248bea64b/lief-0.17.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5a19642e42578fe0b701bd86b10dd7e86d69c35c67d25ac1433f72410a7c2bb", size = 2997018, upload-time = "2026-03-18T06:57:53.686Z" }, + { url = "https://files.pythonhosted.org/packages/97/1b/ad22e3e18b462ad3e3737cd07f01b88fbaf59c84cec66c665832a48441e2/lief-0.17.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:e1ded9ee9b5184b5753e4823343e3550a623d34f5407cb2f8d7918e17856d860", size = 3106901, upload-time = "2026-03-18T06:57:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/d80506bad2b23d7010ab7c23e81c6c9b099b2860136fd2ae724a2ab2b820/lief-0.17.6-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:e29552f52749249c9b05041d96d9156de20207d745916d599b4eb49ee7a8e1bf", size = 3666809, upload-time = "2026-03-18T06:57:57.105Z" }, + { url = "https://files.pythonhosted.org/packages/f4/99/db752fef6c3455c7612a46a834f37a955e329af60546f0e82510653144cd/lief-0.17.6-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:e186ac1ea8a5f4729c4b8d2b7f2fe6c55dbf1eddd8bc15fa4d19ed08dfa6cc54", size = 3473955, upload-time = "2026-03-18T06:57:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9f/77ca67789fda7fee355c8b4e6c58c0717fa4c5c3c5a4272777eb993df172/lief-0.17.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1df9b22f3851de7d0e86a8731ad07e47ca562ebe430605d90aecfcd6d20125d0", size = 3402099, upload-time = "2026-03-18T06:58:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/82/43/859b6fbd1914d71e20047308719765956856a6f1f19bbbdac44311cd9eda/lief-0.17.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6484de5a7053c1b7022cb93f41450532f93daaf6b5ce6421c682b87fd2cd2122", size = 3589071, upload-time = "2026-03-18T06:58:02.685Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/7a042746ca0ac66f17b5dfe9ec3258cdf6bf84d0ba13a23315605038d52e/lief-0.17.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04b07e91213ce345febb4698efd310c6745f48190a1d7ce5dd0e7b306839362d", size = 3931684, upload-time = "2026-03-18T06:58:05.038Z" }, + { url = "https://files.pythonhosted.org/packages/21/18/dbe8944ce3a809885ff87afe474c1be3081f1deb264e84a79c5c05b4a6e3/lief-0.17.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5df55be6cd29c654b8a205846d67637955063ad0cfd83875451f339cf623b101", size = 3703617, upload-time = "2026-03-18T06:58:06.814Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/c83222badede13959735f3b253fb52d231327b5386d6fd2cf9e3d4b83933/lief-0.17.6-cp312-cp312-win32.whl", hash = "sha256:0aca84f35ec67854ffdb38a23b1848cb214df3e3f95eb7579bac3107e9f68cc8", size = 3446288, upload-time = "2026-03-18T06:58:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/cd49540bb32fde031e612044d1345c381e79e5b0729b47ca6b9694a47071/lief-0.17.6-cp312-cp312-win_amd64.whl", hash = "sha256:5a34d651eb82e24a113f837b1a961d23e155be41d72bf39a37407854c6597a8b", size = 3639449, upload-time = "2026-03-18T06:58:10.821Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/c557b6757b72cfaea89a432e9d0a5ea669970ef9ae8086a17d1f73274156/lief-0.17.6-cp312-cp312-win_arm64.whl", hash = "sha256:234a422fe7158e755ac0acdd0bfdfd41f75392dad9dac147dd3b9c7a9f1a6811", size = 3461129, upload-time = "2026-03-18T06:58:12.651Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/285c39e29bf7ecf5045f5aef1344419d23ae4c729671157406988570ce02/lief-0.17.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7384abed26200f8c6cb50ca9cedac70e7452e85fe72e82d4c5e9050c78eff0ae", size = 2990524, upload-time = "2026-03-18T06:58:14.172Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/afc7124787b4ae13d84135341d4721da9ed8f699a940bc7e2851e6d25c62/lief-0.17.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:7b7759b443745d0e5211d87723c0a84c4a74364ef6194cc8f8d315d98d117648", size = 3106720, upload-time = "2026-03-18T06:58:16.04Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/8aca6e7e70d68cdbd6aecf2adbb88bef1194d5657794c522a09cb0ddafd2/lief-0.17.6-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:3e59a64012a602772270aa1a930cff9c39cddca42f0ca5d7f1959f4dd951f38e", size = 3666563, upload-time = "2026-03-18T06:58:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/80/c1/5bdfd614a740f4bd22c20abb4c3b352f082fe75980ea73e6d4fccf356f37/lief-0.17.6-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:f764c77c848cf7478623e754099f50699d5e23b5bc4a34ce68cd20af7e0b5541", size = 3473626, upload-time = "2026-03-18T06:58:20.048Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f5/bf4be32af7b1892a8a1a2d3edb75a6a418548801548f39f3f879a6d3be73/lief-0.17.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:520e5f8a7b1e2487630e27639751d9fb13c94205fed72d358a87994e44a73815", size = 3402364, upload-time = "2026-03-18T06:58:21.871Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c5/84aa0c62636d0e0e9754cbab77ab9bb21b306e0be707475045b3d4dc5947/lief-0.17.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dbfe15d3d21d389857dac8cedc04f03f8ef98c5503e5e147a34480ecbf351826", size = 3589949, upload-time = "2026-03-18T06:58:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/e3/69/d5444ef2ec27adb777f4c08248a934dfdc2c65c1e4f66fbe10faf65fa01f/lief-0.17.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de5716279c82640359fe59137ec0572a1ed9859051c1d901de593d6e0e99d9c8", size = 3931688, upload-time = "2026-03-18T06:58:25.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/2b8083800a6bc9f157a40ed30b53e6ca81147af565de46623891a45336b0/lief-0.17.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ef618117ec33665697e3d1fe9c15fac8d6c42e2eeaf4aca9c31ea12fdb056c67", size = 3703589, upload-time = "2026-03-18T06:58:27.27Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ea/51e90c58b40bc316307f2081160ab6100b9e7d177fde3225b441085defd2/lief-0.17.6-cp313-cp313-win32.whl", hash = "sha256:2f669d5b4e63c6e66cac48e07d0f23436bf898ec9d0630016d23250e2eb43d28", size = 3446184, upload-time = "2026-03-18T06:58:28.981Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/8ddc48c492f3295637a6aa13f07c67387d80c815ee779443938689dc59b4/lief-0.17.6-cp313-cp313-win_amd64.whl", hash = "sha256:51b6c5932d4f36d61fb17fe783d9e1bfba33ec1d72b3d07486c96e6f548781ff", size = 3639310, upload-time = "2026-03-18T06:58:31.162Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bf/b7802b6578ca3a6506aaac6696ac1e8de500419fee3cd288184e82a8c2aa/lief-0.17.6-cp313-cp313-win_arm64.whl", hash = "sha256:6d4eb8adce400af52cc174ac5cbe40ab10b9df5824193975d12e2d4f85b298a3", size = 3461166, upload-time = "2026-03-18T06:58:32.975Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2f/1a0a116cdf4f0e9f1846e34676deed3b1275c432cde8b3aafd4ff9a77175/lief-0.17.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af39643ab79ae644d2063a2ef93de908e61a8f40e37b155683c477c1928e6c64", size = 2990571, upload-time = "2026-03-18T06:58:35.046Z" }, + { url = "https://files.pythonhosted.org/packages/76/bd/1bc1c1e364c06b74b9f16089f05622a29ed995fa8b5aba86e0a9fe5500a0/lief-0.17.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:c8129a70bc73e04fd9db4f49f386d4336a3a78ceef07c83ca74f9cf464c03c22", size = 3108044, upload-time = "2026-03-18T06:58:36.871Z" }, + { url = "https://files.pythonhosted.org/packages/80/bd/9fab6a9388fec0eec6fc975a1f49e2ff12dd4d75bfebc05d824a612c732c/lief-0.17.6-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:b5885e8a422066f3691b9707045b85d9728eaba621991def0b4e0044b0b0b063", size = 3671717, upload-time = "2026-03-18T06:58:39.111Z" }, + { url = "https://files.pythonhosted.org/packages/86/fe/470e32a95d0d95dccee560405cc217b9a739fa96a9536e08515ca3a8df44/lief-0.17.6-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:6324add89c366607a6d652553e4cac6309e952ca638c24f38a8b00331f064a50", size = 3473999, upload-time = "2026-03-18T06:58:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0f/1dcc499697f747a9b8f5274659774a1e6529aa180d59a297619842bf458f/lief-0.17.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:365bf48528339a0d9a5c993b0a54f5c3bb8fcd11ca85797c79f9ae6179777492", size = 3403400, upload-time = "2026-03-18T06:58:42.647Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3a/7e39b5dc0c393142a72e4272c6c812d01eb9e45411f3a9afb88157389aa4/lief-0.17.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3bd852c4d934d9c8357d6b9491db85e6722bc0076249f8b23a205a8912a85ed5", size = 3589670, upload-time = "2026-03-18T06:58:44.707Z" }, + { url = "https://files.pythonhosted.org/packages/de/7b/b0ffc4a08860f3499d915c4efab20f5abde6722d659c5391332d06957679/lief-0.17.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8561a156ccea562e200e5bde0db8070785e3194fcd0ddf9109c8470970978076", size = 3931468, upload-time = "2026-03-18T06:58:46.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/25/0992398b5ce911e29bf7d6a3e1724259358aebe5da543044d3b9ad9b4ffa/lief-0.17.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a74f792564a5e69915d08530618d79aa1fd8b5e7b72513fac765e1106c63f57a", size = 3705396, upload-time = "2026-03-18T06:58:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/8f/51/f79886a906eee3a5abc58dc30da1e6c22c41c868e0df446fb280ff2344cb/lief-0.17.6-cp314-cp314-win32.whl", hash = "sha256:503fd8df6425a6c0386df9ca6e4f4ce29d07d268f0620ee1d4059eb4d48c2562", size = 3446331, upload-time = "2026-03-18T06:58:50.52Z" }, + { url = "https://files.pythonhosted.org/packages/83/6f/d396dae3808a35699c4be74e356d3e05f58e150fc14b49c39f0bacb62e11/lief-0.17.6-cp314-cp314-win_amd64.whl", hash = "sha256:918ea953830ecf348e5a8d9cf0b1a178035d6d4032bf2a9aa1dc72483e06b3a1", size = 3638021, upload-time = "2026-03-18T06:58:52.275Z" }, + { url = "https://files.pythonhosted.org/packages/e3/4c/02df1befee243e4c14bf5740c391178ba4f7b4602ff08936da170341afe9/lief-0.17.6-cp314-cp314-win_arm64.whl", hash = "sha256:7dcefa6467f0f0d75413a10e7869e488344347f0c67eff5bc49ec216714f0674", size = 3462306, upload-time = "2026-03-18T06:58:54.937Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +]