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.
-
+
+
+
-
-
+
-
---
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" },
+]