From ecd48ef8676d83dd1cac6eb503dadea517804de0 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Mon, 15 Jun 2026 19:45:08 +0900 Subject: [PATCH 01/51] feat(bridge): scaffold static binpatch hook path alongside the JB build (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A + B + C of the migration plan in docs/plans/kiou_engine_bridge_binpatch.md. Behaviour-preserving on the jailbroken (MobileSubstrate) build; the binpatch build is wired up but not yet exercised — the dispatcher is a stub and Phase D (gating sweep, dispatcher wiring, README) follows in a separate PR. Phase A — defer post-orig reads onto the main queue - Hook_MatchModeObserve.m's DEFINE_START_HOOK now latches `self`, returns, and reads `_localPlayer` plus calls usi_engine_on_match_start / meta_emit_match_start inside dispatch_async(main, ...). - Hook_LowLevelObserve.m's hook_AdapterTryMakeMoveOut latches the adapter and game-controller caches, then defers sfenFromGameController / moveToUsi / usi_engine_on_move_observed to the main queue. - Neither hook calls `orig` directly anymore — MSHookFunction's trampoline still runs orig under the JB build, and the future cave will do it under the binpatch build. The same hook body now works under both. Phase B — binpatch dispatcher scaffolding - Internal.h gains enum kiou_bridge_hook_id (25 ids, 1:1 with cave sites), a dispatcher typedef, an extern slot, and a publish() prototype. No #if guard — the symbols are cheap and harmless in the JB build. - Sources/KiouEngineBridge/BinpatchDispatcher.m is a new file gated on #if KIOU_BINPATCH. Constructor publishes a stub dispatcher into g_kiou_bridge_hook_slot; the stub file_log's the hook_id so we can verify SLOT plumbing before wiring real hook bodies in Phase D. - Makefile loses the `jailed::` target and the JAILED/dobby branch, gains a `BINPATCH=1` branch (-DKIOU_BINPATCH=1 -Wl,-undefined,error, no -lsubstrate) and a `binpatch::` target that copies to packages/binpatch/ and runs otool -L for libsubstrate/libdobby leak detection. - vendor/dobby/ removed. Phase C — patch_macho recipe - recipes/kiouenginebridge.py: 25 cave entries (OnMatchEndAsync × 5, InitializeAsync × 5, OnPlayerMoveAsync × 5, OnMatchStart × 5, Adapter.TryMakeMove(Move,out), Online.UpdateAuthoritativeSnapshot, Online.HandleMoveResult, CPUStream.UpdateAuthoritativeSnapshot, GameOrchestrator.ActivateAsync) + 1 inline IsAfkEnabled patch (8-byte MOVZ W0,#0 + RET). - HOOK_SLOT_RVA = 0x8F90CC0 (reserve_hook_slot tail minus 16, validated in __DATA,__bss; placed 16 bytes ahead of KifExporter's 0x8F90CD0 so the two recipes can coexist on the same Mach-O). - CAVE_REGION = (0x826A000, 0x826C000) — the back half of __TEXT,__oslogstring's zero-fill, partitioned away from KifExporter's (0x8268024, 0x826C000) front-half allocation. 2100 B used of 8192 B. - recipes/kioukifexporter.py copied verbatim from KifExporter so this repo can drive both recipes through `shared/tools/patch_macho.py`. - All 26 entries (1 inline + 25 cave) verified with apply_patches(verify_only=True) against the clean Kiou-1.0.1 build 11 UnityFramework extracted from tmp/Kiou-1.0.1.ipa. Infra - pyproject.toml + uv.lock for the Python recipes package (lief, pytest, ruff). PYTHONPATH layered as `shared/` (the IPA-Patch/Shared submodule) + `.` so `from tools.encode import ...` resolves alongside `from recipes.kioukifexporter import ...`. - shared/ submodule added (IPA-Patch/Shared) — hosts tools/encode.py, tools/machoops.py, tools/caves.py, tools/patch_macho.py. - Sources/Common submodule added (IPA-Patch/Common) — the eventual successor to _shared/ for the Obj-C runtime headers. Not yet consumed by sources; migration happens in a later PR. - .gitattributes pins binary extensions for the IPA-Patch repo set. - .devcontainer/ provides Theos + Ghidra + ipsw + radare2 + jadx for the reverse-engineering loop. - docs/plans/kiou_engine_bridge_binpatch.md and docs/plans/kiou_kif_exporter_binpatch.md document Phase A..D and the patched-hook design that both projects share. Phase D will gate Meta_Emitter.m / Hook_GameStateStoreObserve.m / hook_GameCtrlTryMakeMove behind #if !KIOU_BINPATCH, wire the dispatcher stub to real hook bodies, and rewrite README for iOS 15.0 – 18.x coverage on the binpatch flavour. Co-authored-by: Claude Opus 4.8 (1M context) --- .devcontainer/Dockerfile | 104 ++++ .devcontainer/compose.yaml | 15 + .devcontainer/devcontainer-lock.json | 49 ++ .devcontainer/devcontainer.json | 80 +++ .devcontainer/postAttachCommand.sh | 9 + .devcontainer/postCreateCommand.sh | 4 + .devcontainer/theos/Dockerfile | 50 ++ .gitattributes | 9 + .gitignore | 474 +++++++++++++++++- .gitmodules | 6 + Makefile | 40 +- Sources/Common | 1 + Sources/KiouEngineBridge/BinpatchDispatcher.m | 26 + .../KiouEngineBridge/Hook_LowLevelObserve.m | 92 ++-- .../KiouEngineBridge/Hook_MatchModeObserve.m | 55 +- Sources/KiouEngineBridge/Internal.h | 64 +++ pyproject.toml | 36 ++ recipes/__init__.py | 23 + recipes/kiouenginebridge.py | 389 ++++++++++++++ recipes/kioukifexporter.py | 265 ++++++++++ shared | 1 + uv.lock | 153 ++++++ vendor/dobby/include/dobby.h | 154 ------ vendor/dobby/lib/libdobby.a | Bin 196992 -> 0 bytes 24 files changed, 1863 insertions(+), 236 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/compose.yaml create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/postAttachCommand.sh create mode 100644 .devcontainer/postCreateCommand.sh create mode 100644 .devcontainer/theos/Dockerfile create mode 100644 .gitattributes create mode 100644 .gitmodules create mode 160000 Sources/Common create mode 100644 Sources/KiouEngineBridge/BinpatchDispatcher.m create mode 100644 pyproject.toml create mode 100644 recipes/__init__.py create mode 100644 recipes/kiouenginebridge.py create mode 100644 recipes/kioukifexporter.py create mode 160000 shared create mode 100644 uv.lock delete mode 100644 vendor/dobby/include/dobby.h delete mode 100644 vendor/dobby/lib/libdobby.a 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..2d0f59b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,80 @@ +{ + "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", + "source=${env:HOME}/.gitconfig,target=/home/vscode/.gitconfig,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", + "GitHub.copilot", + "GitHub.copilot-chat", + "PKief.material-icon-theme", + "antfu.file-nesting", + "bierner.markdown-mermaid", + "bierner.markdown-preview-github-styles", + "charliermarsh.ruff", + "eamodio.gitlens", + "jebbs.markdown-extended", + "ms-python.black-formatter", + "ms-python.python", + "pHofer94.vscode-taskviewexplorer", + "redhat.vscode-yaml", + "swiftlang.swift-vscode", + "tamasfe.even-better-toml", + "vsls-contrib.gitdoc" + ], + "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/.gitignore b/.gitignore index 740fc84..d518dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,475 @@ +# 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 +logs +*.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 + +# 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 +# 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. +logs/ + +tmp/ + +# Claude Code session state (per-machine permissions, plans, transcripts). +.claude/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d644e1f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "shared"] + path = shared + url = https://github.com/IPA-Patch/Shared.git +[submodule "Sources/Common"] + path = Sources/Common + url = https://github.com/IPA-Patch/Common.git diff --git a/Makefile b/Makefile index fc68fce..3aba7c4 100644 --- a/Makefile +++ b/Makefile @@ -20,19 +20,23 @@ KiouEngineBridge_CFLAGS = -fobjc-arc -Wno-unused-function -DKIOU_ENGINE_BRIDGE_C KiouEngineBridge_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/. +# BINPATCH=1 : static binary patch + __DATA,__bss SLOT +# dispatcher. No runtime __TEXT writes, so works +# on iOS 18 CSM (Sideloadly / TrollStore / +# AltStore / Apple Developer Program). The hook +# redirection lives in the patched UnityFramework +# cave; this dylib only publishes a function +# pointer into the reserved slot at constructor +# time. See docs/plans/kiou_engine_bridge_binpatch.md. # -# 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. # --------------------------------------------------------------------------- -ifeq ($(JAILED),1) - KiouEngineBridge_CFLAGS += -DKIOU_JAILED=1 -Ivendor/dobby/include - KiouEngineBridge_LDFLAGS = -Lvendor/dobby/lib -ldobby -lc++ -lc++abi +ifeq ($(BINPATCH),1) + KiouEngineBridge_CFLAGS += -DKIOU_BINPATCH=1 + KiouEngineBridge_LDFLAGS = -Wl,-undefined,error else KiouEngineBridge_LDFLAGS = -lsubstrate endif @@ -43,15 +47,15 @@ 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')" -# 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" +# binpatch distribution: rebuild with KIOU_BINPATCH=1, drop libsubstrate, copy +# into packages/binpatch/ for the build_patched_ipa.sh pipeline. +binpatch:: + $(MAKE) BINPATCH=1 clean + $(MAKE) BINPATCH=1 all + $(ECHO_NOTHING)mkdir -p packages/binpatch$(ECHO_END) + $(ECHO_NOTHING)cp $(THEOS_OBJ_DIR)/KiouEngineBridge.dylib packages/binpatch/KiouEngineBridge.dylib$(ECHO_END) + @echo "binpatch dylib -> packages/binpatch/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 \ + @$(THEOS)/toolchain/linux/iphone/bin/otool -L packages/binpatch/KiouEngineBridge.dylib 2>/dev/null \ + || otool -L packages/binpatch/KiouEngineBridge.dylib 2>/dev/null \ || echo "(otool unavailable on host; inspect the dylib on a Mac/iOS device)" diff --git a/Sources/Common b/Sources/Common new file mode 160000 index 0000000..1d07963 --- /dev/null +++ b/Sources/Common @@ -0,0 +1 @@ +Subproject commit 1d07963b78f62523ac0ebd139c5d59959923ab90 diff --git a/Sources/KiouEngineBridge/BinpatchDispatcher.m b/Sources/KiouEngineBridge/BinpatchDispatcher.m new file mode 100644 index 0000000..ea2e187 --- /dev/null +++ b/Sources/KiouEngineBridge/BinpatchDispatcher.m @@ -0,0 +1,26 @@ +#if KIOU_BINPATCH + +#import "Internal.h" + +kiou_bridge_dispatcher_t volatile g_kiou_bridge_hook_slot = NULL; + +static void kiou_bridge_dispatcher(void *self, void *arg1, uint32_t hook_id) { + // Stub. Phase D wires this up to the real hook_ functions in the + // respective Hook_*.m files (likely by un-`static`-ing them and adding + // forward declarations in Internal.h). + file_log([NSString stringWithFormat: + @"[BINPATCH] dispatch hook_id=%u self=%p arg1=%p (stub)", + (unsigned)hook_id, self, arg1]); + (void)hook_id; +} + +void kiou_bridge_binpatch_publish(void) { + // arm64 8-byte aligned pointer store is atomic; the cave's ADRP+LDR will + // see either NULL (before this fires) or a fully-formed dispatcher. + g_kiou_bridge_hook_slot = &kiou_bridge_dispatcher; + file_log([NSString stringWithFormat: + @"[BINPATCH] slot=%p published dispatcher=%p", + (void *)&g_kiou_bridge_hook_slot, &kiou_bridge_dispatcher]); +} + +#endif // KIOU_BINPATCH diff --git a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m index 8488f96..92e55dc 100644 --- a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m +++ b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m @@ -211,44 +211,64 @@ 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 ?: @""]); + // The hook function body no longer calls orig. On the JB build the + // MSHookFunction trampoline runs orig; on the binpatch build the static + // cave runs the displaced prologue and branches to orig + 4. Either way, + // orig completes synchronously inside the current main-runloop iteration, + // so a block dispatched to the main queue here runs AFTER orig — by then + // PositionHistory has the post-move position appended. + // + // 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); + file_log([NSString stringWithFormat: + @"[ADAPTER2] TryMakeMove self=%p " + @"usi=\"%@\" argMove={%@} sfen_after=\"%@\"", + selfCap, usi ?: @"", describeMoveBits((SfMove)mv_copy), + sfen ?: @""]); - // 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; + // 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 (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 フックに集約してある。 } - usi_engine_on_move_observed(usi, sfen, sideToMove); - // meta_emit_move はここでは出さない — ADAPTER2 は「自分のクライアント - // が指した手」しか通らない (オンライン対戦では相手手はサーバ state - // 経由で来る) ので、ここで出すと自分側 ply のみの片肺 KIF になる。 - // 両手番分の meta は Hook_GameStateStoreObserve.m の - // NotifyPieceMoved フックに集約してある。 - } - return ok; + }); + + // orig runs outside this function — driven by the install wrapper on + // the JB build, and by the static cave on the binpatch build. The + // return value the caller sees is orig's, not ours; this function's + // return is unused on both paths. Return false as a defensive default + // for the (unreachable on either build) case where the wrapper / cave + // forwards our return through. + (void)outMove; + return false; } // --------------------------------------------------------------------------- diff --git a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m index 62c5fe7..f2ee3fb 100644 --- a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m +++ b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m @@ -258,29 +258,44 @@ static inline BOOL shouldLog(uint32_t n) { // we just call through. // --------------------------------------------------------------------------- +// The hook function body no longer calls orig itself. On the JB build the +// MSHookFunction trampoline runs orig before/after our hook depending on how +// the trampoline is wired; on the binpatch build the static cave runs the +// displaced prologue and branches to orig + 4. Either way, orig completes +// synchronously inside the same main-runloop iteration that entered this +// hook, so a block dispatched to dispatch_get_main_queue() here runs AFTER +// orig has finished and `_localPlayer` is populated. +// +// We capture `self` and `lp_offset` by value into the block (both are POD — +// `self` is a void *, no ARC retain). The LP_CACHE / file_log / usi / +// meta calls all happen inside the deferred block so they observe the +// post-orig state. #define DEFINE_START_HOOK(MODE_LOWER, MODE_TAG, CACHE_VAR, LP_CACHE, LP_OFFSET, ORIG_VAR) \ static void hook_##MODE_LOWER##_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); \ + 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; \ + } \ + file_log([NSString stringWithFormat: \ + @"[MMODE] " MODE_TAG " Start self=%p localPlayer=%d", \ + selfCap, (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); \ + }); \ } // AIMatchMode / CPUStreamMode / OnlinePvPMode capture _localPlayer. diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index b9d7dbe..24a94b4 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -338,6 +338,70 @@ void meta_on_player_info_set(int32_t side, void *playerInfo); // Installer for the GameStateStore.Set*PlayerInfo hooks. void install_GameStateStoreObserve_hook(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. +// The cave passes (self, arg1, arg2, hook_id) where hook_id is a 16-bit value +// from `enum kiou_bridge_hook_id` and arg1/arg2 are the next two argument +// registers (X1, X2 at the time of the original BL). +// +// 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. +// --------------------------------------------------------------------------- + +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__COUNT, +}; + +// Dispatcher signature. Called from the cave with whatever was in +// (X0, X1, X2) at the original call site, plus a hook_id loaded by the cave's +// `MOVZ W2, #imm` (so for the binpatch build, the third real argument is +// clobbered — none of our hook sites use a third pointer arg). +typedef void (*kiou_bridge_dispatcher_t)(void *self, void *arg1, uint32_t hook_id); + +// Published into the __DATA,__bss slot by BinpatchDispatcher.m's constructor. +// The recipe pins the slot's VA at patch time; the cave's ADRP+LDR uses that +// VA to load this pointer at runtime. +extern kiou_bridge_dispatcher_t volatile g_kiou_bridge_hook_slot; + +// 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. +void kiou_bridge_binpatch_publish(void); + // --------------------------------------------------------------------------- // SfMove + low-level helpers, exported so observation hooks living in other // files (Hook_GameStateStoreObserve.m's NotifyPieceMoved) can reuse the same 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..8a9eab2 --- /dev/null +++ b/recipes/kiouenginebridge.py @@ -0,0 +1,389 @@ +"""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 W2. + +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 W2, calls the dispatcher, + restores registers, runs the displaced prologue verbatim, and + branches to ``orig + 4``. + 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 this constant. +# --------------------------------------------------------------------------- + +HOOK_SLOT_RVA = 0x8F90CC0 + + +# --------------------------------------------------------------------------- +# 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, +} + + +# --------------------------------------------------------------------------- +# Cave payload builder. +# +# Cave shape (21 insns = 84 bytes), see +# docs/plans/kiou_engine_bridge_binpatch.md § 6 Phase C / mirror of the +# KifExporter cave shape: +# +# 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] ; (arg2 lives in X2 until we overwrite it) +# STP X4, X5, [SP, #0x50] +# STP X6, X7, [SP, #0x60] +# MOV X29, SP +# ADRP X16, page(SLOT) +# LDR X16, [X16, #lo12(SLOT)] +# MOVZ W2, #hook_id ; clobber X2/X3 caller-saved arg with hook id +# BLR X16 ; call dispatcher(self, arg1, hook_id) via SLOT +# LDP X6, X7, [SP, #0x60] +# LDP X4, X5, [SP, #0x50] +# LDP X2, X3, [SP, #0x40] ; restore the real X2 the original method needs +# 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 +# +# Because we save / restore X2-X3 as a pair around the BLR, methods whose +# original signature uses X2 as a real argument (e.g. the InitializeAsync +# 4-arg signature) get their X2 back before we resume the displaced +# prologue and orig + 4. The hook_id only lives in X2 for the dispatcher +# call itself. +# --------------------------------------------------------------------------- + +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 W2 + (third arg) before BLR so the dispatcher can switch on it. + """ + 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 X2 --- + emit(movz_w_imm(2, 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). +# --------------------------------------------------------------------------- + +_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"), +] + + +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. No plist keys are required for the +# Bridge binpatch flavour. (KifExporter sets UIFileSharingEnabled / +# LSSupportsOpeningDocumentsInPlace independently when both recipes are +# applied to the same IPA.) +# +# See docs/plans/kiou_engine_bridge_binpatch.md § 8 for the 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 = {} 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/shared b/shared new file mode 160000 index 0000000..5824c66 --- /dev/null +++ b/shared @@ -0,0 +1 @@ +Subproject commit 5824c668cf5e866beddef5b51d59fbdc856a4140 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" }, +] diff --git a/vendor/dobby/include/dobby.h b/vendor/dobby/include/dobby.h deleted file mode 100644 index d7eec86..0000000 --- a/vendor/dobby/include/dobby.h +++ /dev/null @@ -1,154 +0,0 @@ -#ifndef dobby_h -#define dobby_h - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include -#include - -typedef uintptr_t addr_t; -typedef uint32_t addr32_t; -typedef uint64_t addr64_t; - -typedef void *asm_func_t; - -#if defined(__arm__) -typedef struct { - uint32_t dummy_0; - uint32_t dummy_1; - - uint32_t dummy_2; - uint32_t sp; - - union { - uint32_t r[13]; - struct { - uint32_t r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12; - } regs; - } general; - - uint32_t lr; -} DobbyRegisterContext; -#elif defined(__arm64__) || defined(__aarch64__) -#define ARM64_TMP_REG_NDX_0 17 - -typedef union _FPReg { - __int128_t q; - struct { - double d1; - double d2; - } d; - struct { - float f1; - float f2; - float f3; - float f4; - } f; -} FPReg; - -// register context -typedef struct { - uint64_t dmmpy_0; // dummy placeholder - uint64_t sp; - - uint64_t dmmpy_1; // dummy placeholder - union { - uint64_t x[29]; - struct { - uint64_t x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13, x14, x15, x16, x17, x18, x19, x20, x21, x22, - x23, x24, x25, x26, x27, x28; - } regs; - } general; - - uint64_t fp; - uint64_t lr; - - union { - FPReg q[32]; - struct { - FPReg q0, q1, q2, q3, q4, q5, q6, q7; - // [!!! READ ME !!!] - // for Arm64, can't access q8 - q31, unless you enable full floating-point register pack - FPReg q8, q9, q10, q11, q12, q13, q14, q15, q16, q17, q18, q19, q20, q21, q22, q23, q24, q25, q26, q27, q28, q29, - q30, q31; - } regs; - } floating; -} DobbyRegisterContext; -#elif defined(_M_IX86) || defined(__i386__) -typedef struct _RegisterContext { - uint32_t dummy_0; - uint32_t esp; - - uint32_t dummy_1; - uint32_t flags; - - union { - struct { - uint32_t eax, ebx, ecx, edx, ebp, esp, edi, esi; - } regs; - } general; - -} DobbyRegisterContext; -#elif defined(_M_X64) || defined(__x86_64__) -typedef struct { - union { - struct { - uint64_t rax, rbx, rcx, rdx, rbp, rsp, rdi, rsi, r8, r9, r10, r11, r12, r13, r14, r15; - } regs; - } general; - - uint64_t dummy_0; - uint64_t flags; - uint64_t ret; -} DobbyRegisterContext; -#endif - -#define install_hook_name(name, fn_ret_t, fn_args_t...) \ - static fn_ret_t fake_##name(fn_args_t); \ - static fn_ret_t (*orig_##name)(fn_args_t); \ - /* __attribute__((constructor)) */ static void install_hook_##name(void *sym_addr) { \ - DobbyHook(sym_addr, (void *)fake_##name, (void **)&orig_##name); \ - return; \ - } \ - fn_ret_t fake_##name(fn_args_t) - -// memory code patch -int DobbyCodePatch(void *address, uint8_t *buffer, uint32_t buffer_size); - -// function inline hook -int DobbyHook(void *address, void *fake_func, void **out_origin_func); - -// dynamic binary instruction instrument -// for Arm64, can't access q8 - q31, unless enable full floating-point register pack -typedef void (*dobby_instrument_callback_t)(void *address, DobbyRegisterContext *ctx); -int DobbyInstrument(void *address, dobby_instrument_callback_t pre_handler); - -// destroy and restore code patch -int DobbyDestroy(void *address); - -const char *DobbyGetVersion(); - -// symbol resolver -void *DobbySymbolResolver(const char *image_name, const char *symbol_name); - -// import table replace -int DobbyImportTableReplace(char *image_name, char *symbol_name, void *fake_func, void **orig_func); - -// for arm, Arm64, try use b xxx instead of ldr absolute indirect branch -// for x86, x64, always use absolute indirect jump -void dobby_set_near_trampoline(bool enable); - -// register callback for alloc near code block -typedef addr_t (*dobby_alloc_near_code_callback_t)(uint32_t size, addr_t pos, size_t range); -void dobby_register_alloc_near_code_callback(dobby_alloc_near_code_callback_t handler); - -void dobby_set_options(bool enable_near_trampoline, dobby_alloc_near_code_callback_t alloc_near_code_callback); - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/vendor/dobby/lib/libdobby.a b/vendor/dobby/lib/libdobby.a deleted file mode 100644 index c42d798588985af7f2a617a30e81af2078ae1abc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 196992 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 mGBPcO5ShpAFQjl^CRsE2i;xSmN_ Date: Mon, 15 Jun 2026 22:22:57 +0000 Subject: [PATCH 02/51] =?UTF-8?q?feat(bridge):=20phase=20D=20=E2=80=94=20g?= =?UTF-8?q?ate=20binpatch=20build,=20hot-fix=20MSHookFunction=20orig=20cal?= =?UTF-8?q?ls=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase D of the migration plan in docs/plans/kiou_engine_bridge_binpatch.md. Same source tree, two flavours: default (JB / MobileSubstrate / iOS 15.0–16.5): full feature set including meta sidecar. BINPATCH=1 (Sideloadly / TrollStore / AltStore / ADP / iOS 18.x): meta sidecar dropped, hook bodies driven by the static cave + __DATA,__bss SLOT dispatcher (stub for now; Phase E wires it up). ### Hot fix from PR #1 The Phase A refactor in PR #1 removed the `orig()` calls from `DEFINE_START_HOOK × 5` and `hook_AdapterTryMakeMoveOut` on the assumption that MSHookFunction's trampoline runs the original synchronously around our hook. That was wrong: MSHookFunction replaces the call site, so the original body only runs when the hook explicitly calls `orig`. PR #1 therefore broke the JB build — `Adapter.TryMakeMove` never appended the post-move position to `_positionHistory`, and KIOU's board stopped advancing. This PR adds `KIOU_CALL_ORIG_VOID(orig, …)` and `KIOU_CALL_ORIG_RET(RET_T, orig, …)` macros to `Internal.h`. On the JB build they expand to `if (orig) orig(…)`; on the binpatch build they expand to a no-op (the cave already runs orig via the displaced prologue + `B orig+4`, calling orig from the hook would double-execute it). The two affected hooks now use these macros so a single hook body works under both build flavours. ### Gating sweep - `install_*_hook` bodies in Hook_AfkSuppress.m, Hook_GameOrchestratorObserve.m, Hook_LowLevelObserve.m, Hook_MatchModeObserve.m, Hook_OnlineObserve.m are wrapped in `#if !KIOU_BINPATCH`. The `LowLevelObserve` installer keeps the NativeFunction-style symbol-pointer resolves (`g_Position_ToSFEN` etc.) outside the gate — Inject_Move and the observation hooks both need them on both builds. - `Hook_GameStateStoreObserve.m` and `Meta_Emitter.m` are wrapped in `#if KIOU_BINPATCH { /* no-op stubs */ } #else { /* impl */ } #endif`. Stubs cover `install_GameStateStoreObserve_hook`, `meta_set_match_config`, `meta_emit_match_start`, `meta_emit_move`, `meta_emit_match_end`, `meta_on_player_info_set`, so every call site links cleanly under both flavours without per-call `#if` guards. - `Tweak.m`'s `installUnityHooks()` branches between the JB cascade and the binpatch path (`kiou_bridge_binpatch_publish()` + `install_LowLevelObserve_hook` for symbol resolves + `install_Inject_hook` + `usi_engine_install()`). - `_shared/kiou_hookengine.h` no longer pulls in `` on binpatch (the legacy `KIOU_JAILED` Dobby shim is gone — removed in PR #1). It's now a thin `#if !KIOU_BINPATCH` gate around the Substrate import. ### docs/plans § 4 ("Why post-orig reads can move to the main queue") rewritten to correct the MSHookFunction misreading from PR #1's plan and to document the new `KIOU_CALL_ORIG_*` macros. § 6 Phase D step list updated. A new § 6 Phase E section spells out the remaining work for end-to-end binpatch coverage: 1. Move the cave-payload hook_id register from W2 to W6 because W2 collides with the third argument register on several Bridge sites (`OnPlayerMoveAsync`'s `ct`, `Adapter.TryMakeMove`'s `outMove`, `UpdateAuthoritativeSnapshot`'s `turn`). 2. Replace `BinpatchDispatcher.m`'s file_log stub with a real switch on `hook_id` dispatching to each `hook_` body. 3. Un-`static` the dispatched hook function bodies and add forward declarations to `Internal.h`. 4. Sideload + verify end-to-end against iOS 18. After Phase E lands, the README's `iOS 15.0–18.x` promise is real; today it's "structural only — cave + SLOT publish work, but the dispatcher is a stub." ### README (produced by the parallel Phase D agent earlier) - Platform badge bumped to `iOS 15.0–18.x`. - Compatibility table iOS row split into the JB rootless (15.0–16.5) and non-JB binpatch (15.0–18.x) flavours. - Install § "Sideloadly / AltStore / Apple Developer Program" renamed to "Non-JB (Sideloadly / TrollStore / AltStore / Apple Developer Program)" and rewritten around `make binpatch` + `shared/tools/build_patched_ipa.sh`. Notes that the binpatch build drops the meta sidecar. - Layout: the `vendor/dobby` line is gone (PR #1), `BinpatchDispatcher.m` is mentioned. - "Sibling tweaks" gains a line explaining KifExporter and Bridge share the static-binpatch toolchain and can layer the same IPA. - New "Plan documents" subsection linking `docs/plans/kiou_engine_bridge_binpatch.md` and `docs/plans/kiou_kif_exporter_binpatch.md`. ### tests `tests/test_recipes_kiouenginebridge.py` adds 8 structural smoke tests against `recipes/kiouenginebridge.py` — TARGET_BASENAME, DYLIB_PATH, HOOK_SLOT_RVA alignment + value, CAVE_REGION partition, inline patch count, cave patch count, cave budget, PLIST_KEYS empty. All 8 pass via `uv run pytest tests/`. ### .gitignore Added `assets/` alongside `tmp/` — the IPA + il2cpp dump.cs landed under `assets/` instead of `tmp/` for one run, so both root names should be ignored. Co-authored-by: Claude Opus 4.8 (1M context) --- .gitignore | 12 ++- README.md | 59 ++++++++++----- Sources/KiouEngineBridge/Hook_AfkSuppress.m | 5 ++ .../Hook_GameOrchestratorObserve.m | 6 ++ .../Hook_GameStateStoreObserve.m | 14 ++++ .../KiouEngineBridge/Hook_LowLevelObserve.m | 48 ++++++++---- .../KiouEngineBridge/Hook_MatchModeObserve.m | 22 ++++-- Sources/KiouEngineBridge/Hook_OnlineObserve.m | 6 ++ Sources/KiouEngineBridge/Internal.h | 35 +++++++++ Sources/KiouEngineBridge/Meta_Emitter.m | 19 +++++ Sources/KiouEngineBridge/Tweak.m | 14 ++++ _shared/kiou_hookengine.h | 30 ++++---- tests/__init__.py | 0 tests/test_recipes_kiouenginebridge.py | 75 +++++++++++++++++++ 14 files changed, 282 insertions(+), 63 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_recipes_kiouenginebridge.py diff --git a/.gitignore b/.gitignore index d518dc4..be3c27b 100644 --- a/.gitignore +++ b/.gitignore @@ -469,7 +469,11 @@ packages/ # from the Node `logs` glob above so the intent stays explicit. logs/ -tmp/ - -# Claude Code session state (per-machine permissions, plans, transcripts). -.claude/ +# 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. diff --git a/README.md b/README.md index 08e9e52..fbf9fd2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

- platform + platform arch target engine @@ -181,32 +181,42 @@ The dylib lands at `/var/jb/Library/MobileSubstrate/DynamicLibraries/KiouEngineB and is loaded by MobileSubstrate / ElleKit on next launch. Respring or relaunch KIOU, then point your host bridge at `ws://:9527`. -### Sideloadly / AltStore / Apple Developer Program +### Non-JB (Sideloadly / TrollStore / AltStore / Apple Developer Program) ```sh -make jailed -# -> packages/jailed/KiouEngineBridge.dylib +make binpatch +# -> packages/binpatch/KiouEngineBridge.dylib + +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 ``` -`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`. - -In Sideloadly: - -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. - -The same dylib works with AltStore (drop the IPA in, add the dylib -under **Settings -> Advanced** before signing). +The patched IPA can be installed via TrollStore (direct), Sideloadly / +AltStore (sign with Apple ID), or the Apple Developer Program (sign +with a paid cert). Unlike runtime hook engines (Substrate, Dobby, +frida-gum), the static binary patch never writes to `__TEXT` at +runtime and therefore survives the iOS 18 Code Signing Monitor (CSM) — +the binpatch flavour covers iOS 15.0 – 18.x. + +**Note:** the binpatch build omits the `meta` sidecar lines. KIF +assembly on the host bridge depends on `meta {…}` lines emitted by the +JB build's `GameStateStore` observation hooks. Non-JB users get USI +handshake + position/go/bestmove and can still play through YaneuraOu, +but no KIF record is assembled. 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 | +| **iOS (JB rootless build)** | 15.0 – 16.5, arm64, rootless | +| **iOS (non-JB binpatch build)** | 15.0 – 18.x, arm64 | | **Engine wire** | standard USI protocol over WebSocket text frames | All hooks are pinned to RVAs from this exact KIOU build's @@ -242,8 +252,8 @@ Sources/KiouEngineBridge/ 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 + BinpatchDispatcher.m # binpatch-only: publishes hook dispatcher into __bss SLOT -vendor/dobby # symlink to KiouEditor/vendor/dobby/ ../_shared/ # kiou-shared submodule (logging, il2cpp, hookengine) ``` @@ -280,6 +290,19 @@ coexist in the same KIOU process: saves every match as a standard KIF 2.0 file in the app sandbox, ready for Files.app / AirDrop / PiyoShogi. +Bridge and KifExporter use the same static-binpatch toolchain +(`shared/tools/patch_macho.py`) and can be applied to the same IPA — +their cave regions are partitioned +(`docs/plans/kiou_kif_exporter_binpatch.md` § 8). Stack both dylibs in +Sideloadly to ship a single patched IPA that does both jobs. + +## Plan documents + +- [`docs/plans/kiou_engine_bridge_binpatch.md`](docs/plans/kiou_engine_bridge_binpatch.md) — + this project's static-binpatch migration plan. +- [`docs/plans/kiou_kif_exporter_binpatch.md`](docs/plans/kiou_kif_exporter_binpatch.md) — + the sibling KifExporter recipe that pioneered the same approach. + ## License Released under the [MIT License](LICENSE) — see the `LICENSE` file for the diff --git a/Sources/KiouEngineBridge/Hook_AfkSuppress.m b/Sources/KiouEngineBridge/Hook_AfkSuppress.m index 564f8e5..abf599f 100644 --- a/Sources/KiouEngineBridge/Hook_AfkSuppress.m +++ b/Sources/KiouEngineBridge/Hook_AfkSuppress.m @@ -58,6 +58,7 @@ static bool hook_IsAfkEnabled(void *self) { return false; } +#if !KIOU_BINPATCH void install_AfkSuppress_hook(uintptr_t unityBase) { uintptr_t addr = unityBase + RVA_GAMEORCH_IS_AFK_ENABLED; MSHookFunction((void *)addr, @@ -69,3 +70,7 @@ void install_AfkSuppress_hook(uintptr_t unityBase) { (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 install_AfkSuppress_hook is intentionally omitted here. diff --git a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m index 0783e21..e306455 100644 --- a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m @@ -87,6 +87,7 @@ static UniTaskRet hook_GameOrch_ActivateAsync(void *self, void *setup, // --------------------------------------------------------------------------- // Installer. Called once from Tweak.m::installUnityHooks(). // --------------------------------------------------------------------------- +#if !KIOU_BINPATCH void install_GameOrchestratorObserve_hook(uintptr_t unityBase) { uintptr_t addr = unityBase + RVA_GAMEORCH_ACTIVATE; MSHookFunction((void *)addr, (void *)hook_GameOrch_ActivateAsync, @@ -96,3 +97,8 @@ void install_GameOrchestratorObserve_hook(uintptr_t unityBase) { @"(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 hook_GameOrch_ActivateAsync once Phase E wires it up. diff --git a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m index aba199a..3d15d6d 100644 --- a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m @@ -1,5 +1,17 @@ #import "Internal.h" +#if KIOU_BINPATCH +// This entire module is meta-sidecar-only. The binpatch flavour drops the +// meta sidecar (see docs/plans/kiou_engine_bridge_binpatch.md § 2), so the +// file compiles to nothing. install_GameStateStoreObserve_hook is a no-op +// shim defined at the bottom of this #if block so Tweak.m doesn't need its +// own #if to skip the call — keeping the constructor wiring uniform. +void install_GameStateStoreObserve_hook(uintptr_t unityBase) { (void)unityBase; } +void meta_on_player_info_set(int32_t side, void *playerInfo) { + (void)side; (void)playerInfo; +} +#else + // =========================================================================== // Hook_GameStateStoreObserve — capture Set*PlayerInfo calls on the store. // @@ -145,3 +157,5 @@ void install_GameStateStoreObserve_hook(uintptr_t unityBase) { (unsigned)RVA_GAMESTATESTORE_NOTIFY_PIECE_MOVED]); } } + +#endif // !KIOU_BINPATCH diff --git a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m index 92e55dc..ab4b0f7 100644 --- a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m +++ b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m @@ -211,12 +211,17 @@ static bool hook_AdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { if (gcSeen && g_gameCtrlCache != gcSeen) g_gameCtrlCache = gcSeen; g_lastAdapterEvtUs = mach_absolute_time(); - // The hook function body no longer calls orig. On the JB build the - // MSHookFunction trampoline runs orig; on the binpatch build the static - // cave runs the displaced prologue and branches to orig + 4. Either way, - // orig completes synchronously inside the current main-runloop iteration, - // so a block dispatched to the main queue here runs AFTER orig — by then - // PositionHistory has the post-move position appended. + // 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 @@ -261,14 +266,7 @@ static bool hook_AdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { } }); - // orig runs outside this function — driven by the install wrapper on - // the JB build, and by the static cave on the binpatch build. The - // return value the caller sees is orig's, not ours; this function's - // return is unused on both paths. Return false as a defensive default - // for the (unreachable on either build) case where the wrapper / cave - // forwards our return through. - (void)outMove; - return false; + return ok; } // --------------------------------------------------------------------------- @@ -295,8 +293,12 @@ 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) { g_Position_ToSFEN = @@ -306,6 +308,7 @@ 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, @@ -327,4 +330,17 @@ void install_LowLevelObserve_hook(uintptr_t unityBase) { @"@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 hook_AdapterTryMakeMoveOut for every move that + // reaches the board. Dropping it on binpatch saves a cave and + // keeps the hook surface tight. + file_log(@"[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 f2ee3fb..3e1efaf 100644 --- a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m +++ b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m @@ -258,13 +258,13 @@ static inline BOOL shouldLog(uint32_t n) { // we just call through. // --------------------------------------------------------------------------- -// The hook function body no longer calls orig itself. On the JB build the -// MSHookFunction trampoline runs orig before/after our hook depending on how -// the trampoline is wired; on the binpatch build the static cave runs the -// displaced prologue and branches to orig + 4. Either way, orig completes -// synchronously inside the same main-runloop iteration that entered this -// hook, so a block dispatched to dispatch_get_main_queue() here runs AFTER -// orig has finished and `_localPlayer` is populated. +// 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 / file_log / usi / @@ -273,6 +273,7 @@ static inline BOOL shouldLog(uint32_t n) { #define DEFINE_START_HOOK(MODE_LOWER, MODE_TAG, CACHE_VAR, LP_CACHE, LP_OFFSET, ORIG_VAR) \ static void hook_##MODE_LOWER##_Start(void *self) { \ if ((CACHE_VAR) != self) (CACHE_VAR) = self; \ + KIOU_CALL_ORIG_VOID(ORIG_VAR, self); \ void *selfCap = self; \ uintptr_t lpOffsetCap = (uintptr_t)(LP_OFFSET); \ dispatch_async(dispatch_get_main_queue(), ^{ \ @@ -566,8 +567,12 @@ static void scheduleAutoRematch(const char *modeTag) { #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. // --------------------------------------------------------------------------- +#if !KIOU_BINPATCH void install_MatchModeObserve_hook(uintptr_t unityBase) { struct { const char *tag; const char *what; uintptr_t rva; void *hook; void **origSlot; } entries[] = { @@ -632,3 +637,4 @@ void install_MatchModeObserve_hook(uintptr_t unityBase) { (unsigned long)addr, (unsigned long)entries[i].rva]); } } +#endif // !KIOU_BINPATCH diff --git a/Sources/KiouEngineBridge/Hook_OnlineObserve.m b/Sources/KiouEngineBridge/Hook_OnlineObserve.m index 1666776..6a6a063 100644 --- a/Sources/KiouEngineBridge/Hook_OnlineObserve.m +++ b/Sources/KiouEngineBridge/Hook_OnlineObserve.m @@ -168,6 +168,7 @@ static void hook_CpuStream_UpdateSnapshot(void *self, // --------------------------------------------------------------------------- // Installer. // --------------------------------------------------------------------------- +#if !KIOU_BINPATCH void install_OnlineObserve_hook(uintptr_t unityBase) { uintptr_t addrSnap = unityBase + RVA_ONLINE_UPDATE_SNAPSHOT; MSHookFunction((void *)addrSnap, @@ -199,3 +200,8 @@ void install_OnlineObserve_hook(uintptr_t unityBase) { (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/Internal.h b/Sources/KiouEngineBridge/Internal.h index 24a94b4..131e751 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -64,6 +64,41 @@ #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. diff --git a/Sources/KiouEngineBridge/Meta_Emitter.m b/Sources/KiouEngineBridge/Meta_Emitter.m index c731b0e..f30f8c9 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 meta_set_match_config(void *cfg) { (void)cfg; } +void meta_emit_match_start(int32_t local_player) { (void)local_player; } +void meta_emit_move(NSString *usi, NSString *sfen_after, int32_t side_to_move) { + (void)usi; (void)sfen_after; (void)side_to_move; +} +void meta_emit_match_end(usi_match_result_t result, + NSString *final_sfen, + NSString *usi_text) { + (void)result; (void)final_sfen; (void)usi_text; +} +#else + #import // =========================================================================== @@ -531,3 +548,5 @@ void meta_set_match_config(void *cfg) { g_metaPendingLocalPlayer = -1; } } + +#endif // !KIOU_BINPATCH diff --git a/Sources/KiouEngineBridge/Tweak.m b/Sources/KiouEngineBridge/Tweak.m index d12871a..668f9a4 100644 --- a/Sources/KiouEngineBridge/Tweak.m +++ b/Sources/KiouEngineBridge/Tweak.m @@ -51,6 +51,19 @@ static void installUnityHooks(void) { @"UnityFramework base=0x%lx (%s)", (unsigned long)unityBase, unityName ? unityName : "?"]); +#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. + kiou_bridge_binpatch_publish(); + install_LowLevelObserve_hook(unityBase); // symbol pointer resolves only + install_Inject_hook(unityBase); + usi_engine_install(); +#else install_OnlineObserve_hook(unityBase); install_LowLevelObserve_hook(unityBase); install_MatchModeObserve_hook(unityBase); @@ -72,6 +85,7 @@ static void installUnityHooks(void) { // 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(); +#endif g_unityHooked = YES; file_log(@"=== KiouEngineBridge: all hooks installed ==="); diff --git a/_shared/kiou_hookengine.h b/_shared/kiou_hookengine.h index 33af6ac..d8edebd 100644 --- a/_shared/kiou_hookengine.h +++ b/_shared/kiou_hookengine.h @@ -1,27 +1,23 @@ #pragma once // =========================================================================== -// kiou_hookengine.h — MSHookFunction <-> Dobby shim. +// kiou_hookengine.h — MSHookFunction visibility 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. +// in libsubstrate, linked at runtime. This +// header pulls in . +// Binpatch builds (KIOU_BINPATCH): no runtime hook engine — every site is +// redirected by a static cave to a SLOT- +// published dispatcher. There are no +// MSHookFunction references in the source +// (every install_*_hook body is gated on +// #if !KIOU_BINPATCH), so +// isn't required and we skip it. // -// 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`. +// The legacy KIOU_JAILED / Dobby branch is gone — the binpatch build +// supersedes it for non-jailbroken distribution. // =========================================================================== -#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 +#if !KIOU_BINPATCH #import #endif diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_recipes_kiouenginebridge.py b/tests/test_recipes_kiouenginebridge.py new file mode 100644 index 0000000..54a00b4 --- /dev/null +++ b/tests/test_recipes_kiouenginebridge.py @@ -0,0 +1,75 @@ +"""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_empty_for_bridge(): + r = _load() + # Bridge is TCP-only (Server_WebSocket.m on 0.0.0.0:9527), no Bonjour, + # so no Info.plist additions are needed. KifExporter is the recipe + # that ships UIFileSharingEnabled / LSSupportsOpeningDocumentsInPlace. + assert r.PLIST_KEYS == {} From 983fcb367dbd2c642afe942eff91c3abce046556 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 01:37:00 +0900 Subject: [PATCH 03/51] build(make): add `make ipa` end-to-end patched-IPA pipeline target (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps `make binpatch` + `shared/tools/build_patched_ipa.sh` in a single target so the non-jailbroken distribution flow becomes: make ipa The default input is `assets/Kiou-1.0.1.ipa` (under the .gitignored `assets/` root so a clean KIOU IPA can sit alongside the source tree without being checked in). Override with `make ipa KIOU_CLEAN_IPA=/path/to/clean.ipa`. The pipeline: 1. Builds packages/binpatch/KiouEngineBridge.dylib (BINPATCH=1). 2. Runs shared/tools/build_patched_ipa.sh which: - extracts the clean IPA - patches Payload/.app/Frameworks/UnityFramework.framework/ UnityFramework using recipes.kiouenginebridge - applies recipe PLIST_KEYS to Info.plist (none for Bridge today) - drops KiouEngineBridge.dylib alongside the framework - re-zips into packages/ipa/KiouEngineBridge-binpatch.ipa Existing `make` (JB / libsubstrate) and `make binpatch` (non-JB dylib only) targets unchanged — three flavours coexist: - `make package install THEOS_DEVICE_IP=` — JB rootless deb deploy - `make binpatch` — non-JB dylib alone - `make ipa` — non-JB end-to-end IPA Co-authored-by: Claude Opus 4.8 (1M context) --- Makefile | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Makefile b/Makefile index 3aba7c4..b269c56 100644 --- a/Makefile +++ b/Makefile @@ -59,3 +59,34 @@ binpatch:: @$(THEOS)/toolchain/linux/iphone/bin/otool -L packages/binpatch/KiouEngineBridge.dylib 2>/dev/null \ || otool -L packages/binpatch/KiouEngineBridge.dylib 2>/dev/null \ || echo "(otool unavailable on host; inspect the dylib on a Mac/iOS device)" + +# --------------------------------------------------------------------------- +# Full patched-IPA pipeline (Phase 1.5 distribution unit). +# +# Builds the binpatch dylib and runs shared/tools/build_patched_ipa.sh to +# produce a TrollStore / Sideloadly / AltStore / Apple Developer Program +# ready IPA. The user supplies a decrypted clean KIOU IPA via +# KIOU_CLEAN_IPA; the pipeline is driven by recipes/kiouenginebridge.py. +# +# This target NEVER redistributes a clean KIOU IPA — supply your own +# decrypted copy. Defaults to assets/Kiou-1.0.1.ipa (which is .gitignored) +# so a casual `make ipa` works in the dev container after the operator +# drops the IPA there. +# --------------------------------------------------------------------------- +KIOU_CLEAN_IPA ?= $(PWD)/assets/Kiou-1.0.1.ipa +KIOU_IPA_RECIPE := recipes.kiouenginebridge +KIOU_IPA_FRAMEWORK := UnityFramework +KIOU_IPA_DYLIB := $(PWD)/packages/binpatch/KiouEngineBridge.dylib + +ipa:: binpatch + @echo "==> assembling patched IPA from $(KIOU_CLEAN_IPA)" + @if [ ! -f "$(KIOU_CLEAN_IPA)" ]; then \ + echo "error: clean KIOU IPA missing at $(KIOU_CLEAN_IPA)"; \ + echo " override with: make ipa KIOU_CLEAN_IPA=/path/to/clean.ipa"; \ + exit 1; \ + fi + @./shared/tools/build_patched_ipa.sh \ + --recipe "$(KIOU_IPA_RECIPE)" \ + --framework "$(KIOU_IPA_FRAMEWORK)" \ + --dylib "$(KIOU_IPA_DYLIB)" \ + --input "$(KIOU_CLEAN_IPA)" From 7b4d9c12b758f6313b90efe572dc16c63c042e92 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Mon, 15 Jun 2026 22:23:29 +0000 Subject: [PATCH 04/51] chore: update shared submodules Update Common and Shared to their latest upstream revisions and include the current local bridge/devcontainer adjustments. Co-authored-by: Claude Opus 4.8 (1M context) --- .devcontainer/devcontainer.json | 2 +- Sources/Common | 2 +- .../KiouEngineBridge/Hook_MatchModeObserve.m | 20 ++++++++++++++----- shared | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2d0f59b..47bc06b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ "GH_TOKEN": "${localEnv:GH_TOKEN}", "UV_LINK_MODE": "copy", "ANTHROPIC_BASE_URL": "${localEnv:ANTHROPIC_BASE_URL}", - "ANTHROPIC_AUTH_TOKEN": "${localEnv:ANTHROPIC_AUTH_TOKEN}", + "ANTHROPIC_AUTH_TOKEN": "${localEnv:ANTHROPIC_AUTH_TOKEN}" }, "containerEnv": { "CLAUDE_CONFIG_DIR": "/home/vscode/.claude" diff --git a/Sources/Common b/Sources/Common index 1d07963..f19e9fe 160000 --- a/Sources/Common +++ b/Sources/Common @@ -1 +1 @@ -Subproject commit 1d07963b78f62523ac0ebd139c5d59959923ab90 +Subproject commit f19e9feb1ac27020584e290c102485639418bba8 diff --git a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m index 3e1efaf..7d6f12b 100644 --- a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m +++ b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m @@ -129,12 +129,22 @@ typedef UniTaskRet (*InitializeAsync_t)(void *self, void *cfg, void *stateStore, static OnMatchEndAsync_t orig_Replay_End = 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. diff --git a/shared b/shared index 5824c66..faa1ca3 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit 5824c668cf5e866beddef5b51d59fbdc856a4140 +Subproject commit faa1ca3c1ea595d2006c20c6884444d8f99efaac From b1c03d8589d9bdda0563d88670dd0ffc748c9670 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Mon, 15 Jun 2026 22:23:59 +0000 Subject: [PATCH 05/51] refactor: use Common shared headers Squashes five small follow-ups into a single refactor: - Replace the legacy _shared directory with the Sources/Common submodule for logging and il2cpp/hookengine headers. - Enable binpatch IPA plist updates: define the Bridge recipe plist keys consumed by the patched IPA pipeline and route binpatch logs to Documents through Common logging. - Pin the Bridge hook slot probe: track the probed __bss tail separately from Bridge's sibling hook slot so the IPA patcher can keep its layout drift check without warning on the intentional offset. - Add assets/.gitkeep so the empty assets/ directory is tracked. - Track .claude and logs directories via .gitkeep placeholders so a fresh clone has them ready; logs/ contents stay ignored, only the directory itself is tracked. Tests: make clean all; make BINPATCH=1 clean all; make ipa Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/.gitkeep | 0 .gitignore | 12 +++-- Makefile | 12 ++--- README.md | 5 +- Sources/KiouEngineBridge/Internal.h | 8 +-- _shared/kiou_hookengine.h | 23 --------- _shared/kiou_il2cpp.h | 79 ----------------------------- _shared/kiou_logging.h | 35 ------------- _shared/kiou_logging.m | 69 ------------------------- assets/.gitkeep | 0 logs/.gitkeep | 0 recipes/kiouenginebridge.py | 23 +++++---- shared | 2 +- 13 files changed, 36 insertions(+), 232 deletions(-) create mode 100644 .claude/.gitkeep delete mode 100644 _shared/kiou_hookengine.h delete mode 100644 _shared/kiou_il2cpp.h delete mode 100644 _shared/kiou_logging.h delete mode 100644 _shared/kiou_logging.m create mode 100644 assets/.gitkeep create mode 100644 logs/.gitkeep diff --git a/.claude/.gitkeep b/.claude/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index be3c27b..fb9e324 100644 --- a/.gitignore +++ b/.gitignore @@ -50,8 +50,9 @@ Temporary Items *.icloud ### Node ### -# Logs -logs +# Logs (the directory itself is tracked via logs/.gitkeep further down). +logs/* +!logs/.gitkeep *.log npm-debug.log* yarn-debug.log* @@ -466,8 +467,11 @@ packages/ # 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. -logs/ +# 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 + diff --git a/Makefile b/Makefile index b269c56..45d3ba3 100644 --- a/Makefile +++ b/Makefile @@ -9,14 +9,14 @@ include $(THEOS)/makefiles/common.mk TWEAK_NAME = KiouEngineBridge 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 +# Shared logging implementation lives in Sources/Common. il2cpp helpers are +# inline-only, so they don't need to be listed here. +KiouEngineBridge_FILES += Sources/Common/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) -KiouEngineBridge_CFLAGS = -fobjc-arc -Wno-unused-function -DKIOU_ENGINE_BRIDGE_COMMIT=\"$(KIOU_ENGINE_BRIDGE_COMMIT)\" -I_shared +KiouEngineBridge_CFLAGS = -fobjc-arc -Wno-unused-function -DKIOU_ENGINE_BRIDGE_COMMIT=\"$(KIOU_ENGINE_BRIDGE_COMMIT)\" -ISources/Common KiouEngineBridge_FRAMEWORKS = Foundation # --------------------------------------------------------------------------- @@ -32,10 +32,10 @@ KiouEngineBridge_FRAMEWORKS = Foundation # pointer into the reserved slot at constructor # time. See docs/plans/kiou_engine_bridge_binpatch.md. # -# _shared/kiou_hookengine.h picks the API at compile time. +# Sources/Common/hookengine.h picks the API at compile time. # --------------------------------------------------------------------------- ifeq ($(BINPATCH),1) - KiouEngineBridge_CFLAGS += -DKIOU_BINPATCH=1 + KiouEngineBridge_CFLAGS += -DKIOU_BINPATCH=1 -DIPA_LOG_TO_DOCUMENTS=1 KiouEngineBridge_LDFLAGS = -Wl,-undefined,error else KiouEngineBridge_LDFLAGS = -lsubstrate diff --git a/README.md b/README.md index fbf9fd2..8a905bc 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ methods as function pointers: 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` + `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 @@ -254,7 +254,8 @@ Sources/KiouEngineBridge/ Meta_Emitter.m # sidecar JSON stream for KIF assembly BinpatchDispatcher.m # binpatch-only: publishes hook dispatcher into __bss SLOT -../_shared/ # kiou-shared submodule (logging, il2cpp, hookengine) +Sources/Common/ # IPA-Patch/Common submodule (logging, il2cpp, hookengine) +shared/ # IPA-Patch/Shared submodule (binpatch tooling) ``` ## Where the logs go diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index 131e751..45079d1 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -4,9 +4,9 @@ #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. @@ -25,7 +25,7 @@ // // 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 +// `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 diff --git a/_shared/kiou_hookengine.h b/_shared/kiou_hookengine.h deleted file mode 100644 index d8edebd..0000000 --- a/_shared/kiou_hookengine.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -// =========================================================================== -// kiou_hookengine.h — MSHookFunction visibility shim. -// -// JB / rootless builds (default): MobileSubstrate's MSHookFunction is live -// in libsubstrate, linked at runtime. This -// header pulls in . -// Binpatch builds (KIOU_BINPATCH): no runtime hook engine — every site is -// redirected by a static cave to a SLOT- -// published dispatcher. There are no -// MSHookFunction references in the source -// (every install_*_hook body is gated on -// #if !KIOU_BINPATCH), so -// isn't required and we skip it. -// -// The legacy KIOU_JAILED / Dobby branch is gone — the binpatch build -// supersedes it for non-jailbroken distribution. -// =========================================================================== - -#if !KIOU_BINPATCH -#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/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recipes/kiouenginebridge.py b/recipes/kiouenginebridge.py index 8a9eab2..9cf6516 100644 --- a/recipes/kiouenginebridge.py +++ b/recipes/kiouenginebridge.py @@ -89,10 +89,12 @@ # 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 this constant. +# reserve_hook_slot() and update PROBED_HOOK_SLOT_RVA plus this sibling +# constant. # --------------------------------------------------------------------------- HOOK_SLOT_RVA = 0x8F90CC0 +PROBED_HOOK_SLOT_RVA = 0x8F90CD0 # --------------------------------------------------------------------------- @@ -376,14 +378,17 @@ def emit(insn: bytes) -> None: # # 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. No plist keys are required for the -# Bridge binpatch flavour. (KifExporter sets UIFileSharingEnabled / -# LSSupportsOpeningDocumentsInPlace independently when both recipes are -# applied to the same IPA.) +# 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 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. +# 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 = {} +PLIST_KEYS: dict = { + "UIFileSharingEnabled": True, + "LSSupportsOpeningDocumentsInPlace": True, +} diff --git a/shared b/shared index faa1ca3..d805c36 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit faa1ca3c1ea595d2006c20c6884444d8f99efaac +Subproject commit d805c365a6b70f6d77b9ea1353d8a87f9c288ae1 From 19f80865eb0973a75d1118cc77f38282435b5bbf Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Mon, 15 Jun 2026 22:24:15 +0000 Subject: [PATCH 06/51] fix(binpatch): phase E ABI fix and protocol doc (working baseline) --- Sources/KiouEngineBridge/BinpatchDispatcher.m | 158 ++++++++- .../Hook_GameOrchestratorObserve.m | 4 +- .../KiouEngineBridge/Hook_LowLevelObserve.m | 2 +- .../KiouEngineBridge/Hook_MatchModeObserve.m | 12 +- Sources/KiouEngineBridge/Hook_OnlineObserve.m | 26 +- Sources/KiouEngineBridge/Internal.h | 92 +++++- docs/bridge_protocol.md | 306 ++++++++++++++++++ recipes/kiouenginebridge.py | 49 +-- tests/test_recipes_kiouenginebridge.py | 15 +- 9 files changed, 587 insertions(+), 77 deletions(-) create mode 100644 docs/bridge_protocol.md diff --git a/Sources/KiouEngineBridge/BinpatchDispatcher.m b/Sources/KiouEngineBridge/BinpatchDispatcher.m index ea2e187..a853ed8 100644 --- a/Sources/KiouEngineBridge/BinpatchDispatcher.m +++ b/Sources/KiouEngineBridge/BinpatchDispatcher.m @@ -2,25 +2,155 @@ #import "Internal.h" -kiou_bridge_dispatcher_t volatile g_kiou_bridge_hook_slot = NULL; +// =========================================================================== +// 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. ``kiou_bridge_binpatch_publish`` 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. +// =========================================================================== -static void kiou_bridge_dispatcher(void *self, void *arg1, uint32_t hook_id) { - // Stub. Phase D wires this up to the real hook_ functions in the - // respective Hook_*.m files (likely by un-`static`-ing them and adding - // forward declarations in Internal.h). - file_log([NSString stringWithFormat: - @"[BINPATCH] dispatch hook_id=%u self=%p arg1=%p (stub)", - (unsigned)hook_id, self, arg1]); - (void)hook_id; +// 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)hook_ai_Init(x0, x1, x2, x3, x4); break; + case KIOU_BR_HOOK_CPUSTREAM_INIT: + (void)hook_cpustream_Init(x0, x1, x2, x3, x4); break; + case KIOU_BR_HOOK_LOCAL_INIT: + (void)hook_local_Init(x0, x1, x2, x3, x4); break; + case KIOU_BR_HOOK_ONLINE_INIT: + (void)hook_online_Init(x0, x1, x2, x3, x4); break; + case KIOU_BR_HOOK_REPLAY_INIT: + (void)hook_replay_Init(x0, x1, x2, x3, x4); break; + + // OnMatchStart(self) + case KIOU_BR_HOOK_AI_START: hook_ai_Start(x0); break; + case KIOU_BR_HOOK_CPUSTREAM_START: hook_cpustream_Start(x0); break; + case KIOU_BR_HOOK_LOCAL_START: hook_local_Start(x0); break; + case KIOU_BR_HOOK_ONLINE_START: hook_online_Start(x0); break; + case KIOU_BR_HOOK_REPLAY_START: hook_replay_Start(x0); break; + + // OnPlayerMoveAsync(self, mv, ct) + // self=x0, mv=w1 (packed uint32), ct=x2. + case KIOU_BR_HOOK_AI_OPM: + (void)hook_ai_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + case KIOU_BR_HOOK_CPUSTREAM_OPM: + (void)hook_cpustream_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + case KIOU_BR_HOOK_LOCAL_OPM: + (void)hook_local_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + case KIOU_BR_HOOK_ONLINE_OPM: + (void)hook_online_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + case KIOU_BR_HOOK_REPLAY_OPM: + (void)hook_replay_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + + // OnMatchEndAsync(self, ct) + case KIOU_BR_HOOK_AI_END: (void)hook_ai_End(x0, x1); break; + case KIOU_BR_HOOK_CPUSTREAM_END: (void)hook_cpustream_End(x0, x1); break; + case KIOU_BR_HOOK_LOCAL_END: (void)hook_local_End(x0, x1); break; + case KIOU_BR_HOOK_ONLINE_END: (void)hook_online_End(x0, x1); break; + case KIOU_BR_HOOK_REPLAY_END: (void)hook_replay_End(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)hook_AdapterTryMakeMoveOut(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: + hook_UpdateAuthoritativeSnapshot(x0, x1, (int32_t)(intptr_t)x2, + 0.0f, 0.0f, + (int32_t)(intptr_t)x3); + break; + case KIOU_BR_HOOK_CPUSTREAM_UPDATE_SNAPSHOT: + hook_CpuStream_UpdateSnapshot(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: + hook_HandleMoveResult(x0, x1); break; + + // GameOrchestrator.ActivateAsync(self, setup, assetLoader, ct) + case KIOU_BR_HOOK_GAMEORCH_ACTIVATE: + (void)hook_GameOrch_ActivateAsync(x0, x1, x2, x3); 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. + file_log([NSString stringWithFormat: + @"[BINPATCH] unknown hook_id=%u self=%p", + (unsigned)hook_id, x0]); + break; + } } void kiou_bridge_binpatch_publish(void) { - // arm64 8-byte aligned pointer store is atomic; the cave's ADRP+LDR will - // see either NULL (before this fires) or a fully-formed dispatcher. - g_kiou_bridge_hook_slot = &kiou_bridge_dispatcher; + 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. + file_log(@"[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; file_log([NSString stringWithFormat: - @"[BINPATCH] slot=%p published dispatcher=%p", - (void *)&g_kiou_bridge_hook_slot, &kiou_bridge_dispatcher]); + @"[BINPATCH] slot=%p (unityBase+0x%lx) published " + @"dispatcher=%p", + (void *)slot, + (unsigned long)KIOU_BR_HOOK_SLOT_RVA, + (void *)&dispatch_one]); } #endif // KIOU_BINPATCH diff --git a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m index e306455..14e0706 100644 --- a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m @@ -66,8 +66,8 @@ 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 hook_GameOrch_ActivateAsync(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 diff --git a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m index ab4b0f7..b54392b 100644 --- a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m +++ b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m @@ -200,7 +200,7 @@ // --------------------------------------------------------------------------- // ShogiGameAdapter.TryMakeMove(Move, out Move) // --------------------------------------------------------------------------- -static bool hook_AdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { +bool hook_AdapterTryMakeMoveOut(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 diff --git a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m index 7d6f12b..9968659 100644 --- a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m +++ b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m @@ -166,7 +166,7 @@ static inline BOOL shouldLog(uint32_t n) { // --------------------------------------------------------------------------- #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) { \ + UniTaskRet hook_##MODE_LOWER##_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); \ @@ -212,9 +212,9 @@ static inline BOOL shouldLog(uint32_t n) { // --------------------------------------------------------------------------- #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) { \ + UniTaskRet hook_##MODE_LOWER##_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 */ \ @@ -281,7 +281,7 @@ static inline BOOL shouldLog(uint32_t n) { // meta calls all happen inside the deferred block so they observe the // post-orig state. #define DEFINE_START_HOOK(MODE_LOWER, MODE_TAG, CACHE_VAR, LP_CACHE, LP_OFFSET, ORIG_VAR) \ - static void hook_##MODE_LOWER##_Start(void *self) { \ + void hook_##MODE_LOWER##_Start(void *self) { \ if ((CACHE_VAR) != self) (CACHE_VAR) = self; \ KIOU_CALL_ORIG_VOID(ORIG_VAR, self); \ void *selfCap = self; \ @@ -529,7 +529,7 @@ static void scheduleAutoRematch(const char *modeTag) { #define DEFINE_END_HOOK(MODE_LOWER, 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_LOWER##_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); \ diff --git a/Sources/KiouEngineBridge/Hook_OnlineObserve.m b/Sources/KiouEngineBridge/Hook_OnlineObserve.m index 6a6a063..4e6d872 100644 --- a/Sources/KiouEngineBridge/Hook_OnlineObserve.m +++ b/Sources/KiouEngineBridge/Hook_OnlineObserve.m @@ -58,12 +58,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 hook_UpdateAuthoritativeSnapshot(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 @@ -112,7 +112,7 @@ static void hook_UpdateAuthoritativeSnapshot(void *self, static uint32_t g_handleResultCount = 0; -static void hook_HandleMoveResult(void *self, void *reply) { +void hook_HandleMoveResult(void *self, void *reply) { if (g_onlineModeCache != self) g_onlineModeCache = self; g_lastOnlineEvtUs = mach_absolute_time(); @@ -138,12 +138,12 @@ 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 hook_CpuStream_UpdateSnapshot(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; diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index 45079d1..9c7df00 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -377,16 +377,29 @@ void install_GameStateStoreObserve_hook(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. -// The cave passes (self, arg1, arg2, hook_id) where hook_id is a 16-bit value -// from `enum kiou_bridge_hook_id` and arg1/arg2 are the next two argument -// registers (X1, X2 at the time of the original BL). +// 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 + enum kiou_bridge_hook_id { KIOU_BR_HOOK_AI_INIT = 0, KIOU_BR_HOOK_CPUSTREAM_INIT, @@ -421,22 +434,69 @@ enum kiou_bridge_hook_id { KIOU_BR_HOOK__COUNT, }; -// Dispatcher signature. Called from the cave with whatever was in -// (X0, X1, X2) at the original call site, plus a hook_id loaded by the cave's -// `MOVZ W2, #imm` (so for the binpatch build, the third real argument is -// clobbered — none of our hook sites use a third pointer arg). -typedef void (*kiou_bridge_dispatcher_t)(void *self, void *arg1, uint32_t hook_id); - -// Published into the __DATA,__bss slot by BinpatchDispatcher.m's constructor. -// The recipe pins the slot's VA at patch time; the cave's ADRP+LDR uses that -// VA to load this pointer at runtime. -extern kiou_bridge_dispatcher_t volatile g_kiou_bridge_hook_slot; +// 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. +// 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 kiou_bridge_binpatch_publish(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 hook_ai_Init(void *self, void *cfg, void *store, void *adapter, void *ct); +UniTaskRet hook_cpustream_Init(void *self, void *cfg, void *store, void *adapter, void *ct); +UniTaskRet hook_local_Init(void *self, void *cfg, void *store, void *adapter, void *ct); +UniTaskRet hook_online_Init(void *self, void *cfg, void *store, void *adapter, void *ct); +UniTaskRet hook_replay_Init(void *self, void *cfg, void *store, void *adapter, void *ct); + +void hook_ai_Start(void *self); +void hook_cpustream_Start(void *self); +void hook_local_Start(void *self); +void hook_online_Start(void *self); +void hook_replay_Start(void *self); + +UniTaskRet hook_ai_OPM(void *self, uint32_t mv, void *ct); +UniTaskRet hook_cpustream_OPM(void *self, uint32_t mv, void *ct); +UniTaskRet hook_local_OPM(void *self, uint32_t mv, void *ct); +UniTaskRet hook_online_OPM(void *self, uint32_t mv, void *ct); +UniTaskRet hook_replay_OPM(void *self, uint32_t mv, void *ct); + +UniTaskRet hook_ai_End(void *self, void *ct); +UniTaskRet hook_cpustream_End(void *self, void *ct); +UniTaskRet hook_local_End(void *self, void *ct); +UniTaskRet hook_online_End(void *self, void *ct); +UniTaskRet hook_replay_End(void *self, void *ct); + +bool hook_AdapterTryMakeMoveOut(void *self, uint32_t move, void *outMove); +void hook_UpdateAuthoritativeSnapshot(void *self, void *sfenStr, int32_t turn, + float blackTimeSec, float whiteTimeSec, + int32_t moveCount); +void hook_HandleMoveResult(void *self, void *reply); +void hook_CpuStream_UpdateSnapshot(void *self, void *sfenStr, int32_t turn, + float blackTimeSec, float whiteTimeSec, + int32_t moveCount); +UniTaskRet hook_GameOrch_ActivateAsync(void *self, void *setup, + void *assetLoader, void *ct); + // --------------------------------------------------------------------------- // SfMove + low-level helpers, exported so observation hooks living in other // files (Hook_GameStateStoreObserve.m's NotifyPieceMoved) can reuse the same diff --git a/docs/bridge_protocol.md b/docs/bridge_protocol.md new file mode 100644 index 0000000..6735b34 --- /dev/null +++ b/docs/bridge_protocol.md @@ -0,0 +1,306 @@ +# KiouEngineBridge wire protocol + +KiouEngineBridge is the iOS-side dylib that turns KIOU into a USI client. +It opens a WebSocket server inside the KIOU process and waits for an +external "bridge" host (the TypeScript bridge in the companion repo, in +practice) to connect. Everything the host needs to drive a match — USI +control lines plus best-effort metadata — flows over that single +connection. + +This document is the contract between the dylib and the host. It is the +authoritative description of what bytes go on the wire; if the C source +and this file disagree, the C source wins and this file is the bug. + +Source of truth pointers: + +- `Sources/KiouEngineBridge/Server_WebSocket.m` — RFC 6455 server. +- `Sources/KiouEngineBridge/Usi_Engine.m` — USI half of the protocol. +- `Sources/KiouEngineBridge/Meta_Emitter.m` — `meta` JSON half (JB build + only; the binpatch build links the empty stubs at the top of the same + file). + +## Transport + +- **TCP, plaintext, port 9527.** Bound to `0.0.0.0` from + `Sources/KiouEngineBridge/Tweak.m:113` via + `kiou_ws_server_start(9527)`. No TLS, no auth, no Bonjour / + `NSLocalNetworkUsageDescription`. The model is "tweak and host live on + the same trusted LAN." +- **WebSocket, RFC 6455 minimal subset.** Client sends a normal + `HTTP/1.1 Upgrade: websocket` with a `Sec-WebSocket-Key`; server + replies with the standard SHA-1 + base64 accept token. Sub-protocols + and extensions are ignored if offered. +- **One client at a time.** A second incoming TCP connection is accepted + only to be immediately closed with `HTTP/1.1 409 Conflict` so the + kernel stops half-opening the handshake. The current client must + close (or be detected dead via TCP keepalive) before a new one can + attach. +- **Frame types used.** + - Outbound (dylib → host): `0x1` text frames, FIN=1, unmasked. Two- + and eight-byte extended payload-length forms are both supported. + - Inbound (host → dylib): `0x1` text frames, FIN=1, masked per spec. + `0x9` Ping → server replies with `0xA` Pong. `0x8` Close → server + tears the client down. Anything else is logged at `[WS-DBG]` and + dropped. +- **TCP keepalive.** `SO_KEEPALIVE` on, idle 5 s, interval 3 s, count 3 + — a silently dead host is reaped in roughly 15 s so the next connect + attempt is not refused with 409. +- **Backpressure.** Outbound frames go through a serial GCD queue. If + the in-flight backlog exceeds 128 frames the oldest pending frame is + dropped and a `[WS]` warning is logged. The protocol is best-effort — + the host MUST be able to recover state from observation lines alone + and MUST NOT assume the dylib will retransmit anything. + +## Logical channels + +Every text frame is exactly one line. The dylib classifies its +**outbound** traffic into two channels by the first token of the line; +the **inbound** side only carries USI lines. + +| Direction | Channel | Frame shape | Defined in | +|---|---|---|---| +| dylib → host | USI | one USI command, e.g. `usi\n`, `position sfen ...\n` | `Usi_Engine.m::usi_engine_send_line` | +| dylib → host | meta | `meta \n` where `` is one canonical JSON object | `Meta_Emitter.m::meta_emit_dict` | +| host → dylib | USI | one USI command, lines split on `\r\n` / `\n` / `\r` | `Usi_Engine.m::usi_engine_text_handler` | + +There is no separate channel for control or for errors. If the dylib +needs to tell the host something the host can't act on from the existing +channels, it goes into the on-device log only (`tmp/kiouenginebridge.log` +on the jailbroken build; Documents on the binpatch build) — not over the +WebSocket. + +## USI channel — state machine + +The dylib is the **USI client** in USI-spec terms; the host's USI engine +(e.g. YaneuraOu) is the **engine**. The dylib never speaks `setoption` +or `go` — it only emits position lines and observes `bestmove` replies. +The host owns engine setup and the `go` cadence. + +States are tracked atomically in `Usi_Engine.m::g_usiState`: + +``` +BOOT + ↓ WS client finishes upgrade → send "usi" +HANDSHAKE + ↓ inbound "usiok" → send "isready" + ↓ inbound "readyok" → send "usinewgame" +READY + ↓ observation says it is our turn → send "position sfen ..." +THINKING + ↓ inbound "bestmove " → inject move locally +INJECTING + ↓ next observation arrives → back to READY +``` + +Notes: + +- `usinewgame` is only sent once, on `readyok`. Subsequent matches + reuse the same engine session. +- On WebSocket disconnect the state resets to `BOOT`. +- A match start where we already know the seat and it is our turn + triggers a 500 ms-delayed "kick" so the engine gets a `position` + even before any opponent move fires an observation. See + `usi_engine_try_kick_on_main`. + +### dylib → host (USI) + +| Line | When | Notes | +|---|---|---| +| `usi` | WS client connected, state → `HANDSHAKE` | Standard USI handshake. | +| `isready` | inbound `usiok` | | +| `usinewgame` | inbound `readyok` | One per WebSocket session, not per match. | +| `position sfen ` | observation says it is our turn AND state is `READY` | `` is the full SFEN after the opponent's move. The dylib never appends `moves ...` — the bridge / engine treats this as the absolute position. | +| `gameover {win\|lose\|draw}` | match ends with a known result | Suppressed when the result is unknown (`USI_RESULT_UNKNOWN`, e.g. open-seat modes). | + +The dylib does NOT emit `go`. The host is expected to observe +`position sfen ...` on the WebSocket and drive its engine with whatever +`go ...` it wants (`go btime ... wtime ...`, `go movetime ...`, etc). + +### host → dylib (USI) + +The dylib only acts on the first token of each inbound line. Lines may +be batched in a single text frame; the dylib splits on any of +`\r\n`, `\n`, `\r`. + +| First token | Action | +|---|---| +| `id` | logged, otherwise ignored | +| `option` | logged, otherwise ignored | +| `usiok` | reply `isready` | +| `readyok` | reply `usinewgame`, state → `READY`, kick if it is already our turn | +| `info` | last `info string ...` is cached for debugging; everything else ignored | +| `bestmove resign` / `bestmove (none)` / `bestmove win` | no injection, state → `READY` | +| `bestmove ` | state → `INJECTING`, call `inject_apply()`, then wait for the next observation to revert to `READY` | +| anything else | logged at `[USI] ignored inbound:`, otherwise dropped | + +`` for `bestmove` is a normal USI move string (`7g7f`, `8h2b+`, +`P*5e`). The dylib applies it through KIOU's own +`ShogiGameAdapter.TryMakeMove` path, so it has to be legal in the +current KIOU position. + +There is no inbound `quit` handling — the host disconnects the WS to +shut the session down. + +## meta channel — JSON event stream + +`meta` lines are best-effort match metadata that the host uses to build +a KIF or stat surface. **They are not delivered on the binpatch build** — +the binpatch flavour links no-op stubs in `Meta_Emitter.m` lines 3-18, +so a host running against a sideloaded KIOU sees only the USI channel. +On the jailbroken build the host can rely on the events below. + +Each line has the shape: + +``` +meta {"type":"...","...":...}\n +``` + +The leading literal `meta ` is the channel tag. Everything after the +first space is canonical JSON produced by `NSJSONSerialization`. There +are exactly three event types today. + +### `match_start` + +Emitted once per match. On Online matches a 1.5 s grace period waits +for `GameStateStore.SetBlackPlayerInfo` / `SetWhitePlayerInfo` to land +the matchmaking-resolved opponent; on CPU / LocalPvP matches it fires +from a fallback timer. Schema: + +```json +{ + "type": "match_start", + "mode": "VsAI" | "LocalPvP" | "OnlinePvP" | "RecordReplay" | "Spectate" | "Unknown()", + "started_at": "2026-06-15T20:43:11Z", + "local_player": "b" | "w" | null, + "start_position": "Standard" | "HandicapLance" | "HandicapBishop" | ... | "TsumeShogi" | "Unknown()", + "time_control": { + "main_seconds": 600, + "byoyomi_seconds": 30, + "increment_seconds": 0 + }, + "black": { + "name": "プレイヤー", + "rank": "六段", + "rate": 1832, + "user_id": "abc123" + }, + "white": { + "name": null, + "rank": null, + "rate": null, + "user_id": null + } +} +``` + +- `local_player` is `null` for open-seat modes (LocalPvP, RecordReplay). +- `time_control` keys are always present; values are `null` if the + underlying `TimeControlConfig` was unreadable. +- `black` / `white` use the same skeleton in both directions; each + field is `null` if the source PlayerInfo did not supply it. +- `started_at` is ISO 8601 UTC. + +### `move` + +Emitted once per applied move, as seen by +`ShogiGameAdapter.TryMakeMove(Move, out)`. The hook deliberately runs +on the main queue after the original returns, so `sfen_after` is the +post-move authoritative SFEN. + +```json +{ + "type": "move", + "ply": 17, + "side": "b" | "w" | null, + "usi": "7g7f", + "elapsed_ms": 4321, + "sfen_after": "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1" +} +``` + +- `ply` prefers the SFEN's trailing move-number (1-based, minus one for + "just played"); falls back to an internal counter if the SFEN does + not parse. +- `side` is the side that just moved (the opposite of the SFEN's side + to move). `null` if the dylib can't tell. +- `elapsed_ms` is wall-clock between consecutive `move` emits, not + engine think time. +- Remaining-time fields are intentionally NOT in the MVP schema; they + will be added once the `ReactiveProperty` layout is pinned. + +### `match_end` + +Emitted from `Hook_MatchModeObserve`'s `OnMatchEndAsync` after the +result has been inferred from the side-to-move of the final SFEN +versus the cached `local_player`. + +```json +{ + "type": "match_end", + "ended_at": "2026-06-15T20:51:02Z", + "result": "win" | "lose" | "draw" | "unknown", + "total_moves": 73, + "final_sfen": "lnsgk...", + "usi_text": "startpos moves 7g7f 3c3d 2g2f ..." +} +``` + +- `result` is `unknown` for open-seat modes and for any match where + the dylib can't compare side-to-move against a fixed local seat. +- `usi_text` is `GameController.GetUSIText`'s raw return value. The + host should treat it as the authoritative record and use it to + rewrite whatever it accumulated from `move` events — `move` deltas + can drift on jump-moves, drops, and duplicate observation fires. + +## Build-flavour matrix + +| Build | USI channel | meta channel | Distribution | +|---|---|---|---| +| `make package install` (JB / rootless) | available | available | jailbroken iOS 15.0 – 16.5 | +| `make binpatch` + `make ipa` | available | **no-op** (stubs in `Meta_Emitter.m`) | sideloaded iOS 15.0 – 18.x via Sideloadly / TrollStore / AltStore / Apple Developer Program | + +Hosts that want KIF assembly today need the jailbroken build. The +binpatch build's USI half is enough to drive the engine end of a CPU / +Online match, but the host has to live without the structured `meta` +stream and reconstruct what it can from the on-device log file. + +## Example session + +Host's perspective, from connect to one engine reply, on the JB build. +Lines starting with `>` are sent by the host, `<` by the dylib. +`\n`s and the leading `meta ` prefix are shown literally. + +``` +< usi\n +> id name YaneuraOu NNUE 7.6.3\n +> option name USI_Hash type spin default 16\n +> usiok\n +< isready\n +> readyok\n +< usinewgame\n +< meta {"type":"match_start","mode":"VsAI","started_at":"2026-06-15T20:43:11Z","local_player":"b","start_position":"Standard","time_control":{"main_seconds":600,"byoyomi_seconds":30,"increment_seconds":0},"black":{"name":"プレイヤー","rank":null,"rate":null,"user_id":null},"white":{"name":"KIOU CPU (Normal)","rank":null,"rate":null,"user_id":null}}\n +< position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1\n +> info depth 12 score cp 32 pv 7g7f\n +> bestmove 7g7f\n +< meta {"type":"move","ply":1,"side":"b","usi":"7g7f","elapsed_ms":4321,"sfen_after":"lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PP1PPPPPP/1B5R1/LNSGKGSNL w - 2"}\n +... +< meta {"type":"match_end","ended_at":"2026-06-15T20:51:02Z","result":"win","total_moves":73,"final_sfen":"...","usi_text":"startpos moves 7g7f 3c3d ..."}\n +< gameover win\n +``` + +Note that the `match_start` meta line lands BEFORE the first +`position sfen ...`. The host can use that ordering as a cue that a new +KIF is starting; it doesn't have to infer it from move numbers. + +## Compatibility notes + +- The protocol is unversioned. New fields may be added to existing + `meta` events without notice; the host MUST ignore unknown keys and + MUST NOT pin on the field order. +- New `meta` event `type`s may appear in the future. The host MUST drop + unknown types silently rather than disconnecting. +- The USI channel is whatever USI engines already speak; the dylib + only emits the subset listed above, but the host is free to send any + USI line — unknown first-tokens are logged and dropped, never + errored. This means a future dylib can start handling more inbound + tokens without coordinating a host upgrade. diff --git a/recipes/kiouenginebridge.py b/recipes/kiouenginebridge.py index 9cf6516..ab824d0 100644 --- a/recipes/kiouenginebridge.py +++ b/recipes/kiouenginebridge.py @@ -6,7 +6,7 @@ ``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 W2. +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): @@ -20,9 +20,13 @@ 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 W2, calls 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``. + 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 @@ -140,24 +144,23 @@ # Cave payload builder. # # Cave shape (21 insns = 84 bytes), see -# docs/plans/kiou_engine_bridge_binpatch.md § 6 Phase C / mirror of the -# KifExporter cave shape: +# 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] ; (arg2 lives in X2 until we overwrite it) +# STP X2, X3, [SP, #0x40] # STP X4, X5, [SP, #0x50] -# STP X6, X7, [SP, #0x60] +# STP X6, X7, [SP, #0x60] ; (X6 saved before we clobber it) # MOV X29, SP # ADRP X16, page(SLOT) # LDR X16, [X16, #lo12(SLOT)] -# MOVZ W2, #hook_id ; clobber X2/X3 caller-saved arg with hook id -# BLR X16 ; call dispatcher(self, arg1, hook_id) via SLOT -# LDP X6, X7, [SP, #0x60] +# 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] ; restore the real X2 the original method needs +# LDP X2, X3, [SP, #0x40] # LDP X0, X1, [SP, #0x30] # LDP X21, X22, [SP, #0x20] # LDP X19, X20, [SP, #0x10] @@ -165,11 +168,16 @@ # ; verbatim, must be PC-independent # B # -# Because we save / restore X2-X3 as a pair around the BLR, methods whose -# original signature uses X2 as a real argument (e.g. the InitializeAsync -# 4-arg signature) get their X2 back before we resume the displaced -# prologue and orig + 4. The hook_id only lives in X2 for the dispatcher -# call itself. +# 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 @@ -193,8 +201,9 @@ def _build_bridge_cave_payload( 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 W2 - (third arg) before BLR so the dispatcher can switch on it. + 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( @@ -229,8 +238,8 @@ def emit(insn: bytes) -> None: emit(adrp(16, cur, slot_va)) emit(ldr_x_imm(16, 16, slot_va & 0xFFF)) - # --- pass the hook id to the dispatcher via X2 --- - emit(movz_w_imm(2, hook_id)) + # --- pass the hook id to the dispatcher via X6 --- + emit(movz_w_imm(6, hook_id)) emit(blr_x(16)) diff --git a/tests/test_recipes_kiouenginebridge.py b/tests/test_recipes_kiouenginebridge.py index 54a00b4..e3bbddc 100644 --- a/tests/test_recipes_kiouenginebridge.py +++ b/tests/test_recipes_kiouenginebridge.py @@ -67,9 +67,14 @@ def test_cave_budget_fits(): assert cave_count * 84 <= (r.CAVE_REGION[1] - r.CAVE_REGION[0]) -def test_plist_keys_empty_for_bridge(): +def test_plist_keys_file_sharing(): r = _load() - # Bridge is TCP-only (Server_WebSocket.m on 0.0.0.0:9527), no Bonjour, - # so no Info.plist additions are needed. KifExporter is the recipe - # that ships UIFileSharingEnabled / LSSupportsOpeningDocumentsInPlace. - assert r.PLIST_KEYS == {} + # 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, + } From c578d082c713e87663140701cc6f38a90bdd2c53 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Mon, 15 Jun 2026 20:33:00 +0000 Subject: [PATCH 07/51] chore: bump Shared submodule to rewritten master --- shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared b/shared index d805c36..ab0279e 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit d805c365a6b70f6d77b9ea1353d8a87f9c288ae1 +Subproject commit ab0279e33a6de39fc00246f06b4a3bae3cf35f80 From 28c4857c98abf9eca0d609c9146f13e17e01daa4 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 06:53:39 +0900 Subject: [PATCH 08/51] ci: add commitlint, run integration on develop, fix theos toolchain Squashed: see PR #6 for the full message and rationale. --- .commitlintrc.yaml | 23 ++++++ .devcontainer/devcontainer.json | 3 +- .github/workflows/integration.yaml | 123 +++++++++++++++++++---------- .gitignore | 5 ++ Sources/Common | 2 +- shared | 2 +- 6 files changed, 114 insertions(+), 44 deletions(-) create mode 100644 .commitlintrc.yaml 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/devcontainer.json b/.devcontainer/devcontainer.json index 47bc06b..f40f274 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,8 +23,7 @@ "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", - "source=${env:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,consistency=cached,readonly" + "source=${env:HOME}/.gnupg,target=/home/vscode/.gnupg-host,type=bind,consistency=cached,readonly" ], "features": { "ghcr.io/devcontainers/features/common-utils:2": { diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 3fd1e2a..c270a30 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -1,15 +1,16 @@ 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. +# .dylib (Dobby-static path) on every push to master/develop and on every +# PR targeting either. 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: @@ -21,6 +22,37 @@ env: IOS_SDK_VERSION: "16.5" 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: + # commitlint walks --from ~, so we need the PR + # commit history available locally. The default fetch-depth: 1 + # leaves the parent revision out of the working tree and + # `git rev-parse` fails with ambiguous argument. + fetch-depth: 0 + - name: Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install commitlint + # .commitlintrc.yaml extends "@commitlint/config-conventional", + # which is a separate package from commitlint itself. Without it + # commitlint exits 1 with "Cannot find module". + 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 @@ -28,25 +60,51 @@ jobs: fail-fast: false matrix: include: + # Only the substrate (rootless .deb) flavor is wired up here. + # A `jailed` flavor existed in the matrix originally, but the + # corresponding `make jailed` target and vendor/dobby static + # library were never added to this repo (KiouKifExporter has + # them; we don't yet). Reintroduce a jailed entry once Dobby + # is vendored and a `jailed::` target lands in the Makefile. - flavor: substrate make_args: "" artifact_name: kiouenginebridge-substrate-deb artifact_path: packages/*.deb - - flavor: jailed - make_args: "jailed" - artifact_name: kiouenginebridge-jailed-dylib - artifact_path: packages/jailed/KiouEngineBridge.dylib steps: - name: Checkout source uses: actions/checkout@v4 + with: + # Sources/Common is a submodule and the build references files + # inside it (logging.m, binpatch.m, ...). The default + # submodules: false leaves that path empty and the build fails + # at the very first preprocessing pass. + submodules: recursive - name: Install host build tools run: | + # Mirrors KiouKifExporter's CI: ldid is NOT in the noble apt + # repos and is installed separately below. libtinfo6 / libncurses6 + # are the runtime deps the Procursus ldid binary links against. 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 + # apt has no ldid on Ubuntu noble (24.04), so pull the prebuilt + # Linux x86_64 binary from the ProcursusTeam release. SHA256 + # pinned so a release-asset swap can't silently swap our signer. + env: + LDID_VERSION: "v2.1.5-procursus7" + LDID_SHA256: "4b8862b2fefa2cd7fa8f88cb0310779619aeaa8c72d6aff22f019b470f2fa99a" + 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 @@ -54,27 +112,30 @@ jobs: with: path: | theos - key: theos-linux-${{ env.IOS_SDK_VERSION }}-v1 + # Bump on toolchain layout changes (see Install Theos comment). + 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. + # Mirrors KiouKifExporter's working CI. + # Theos itself. 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 + # iOS cross-toolchain from L1ghtmann/llvm-project. The tarball + # already lays out the linux/iphone/ directory under + # $THEOS/toolchain, which is what Theos's makefiles expect -- + # no rename needed. + 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/" @@ -95,24 +156,6 @@ jobs: make package ls -la packages/ - - name: Sanity check jailed dylib (no external hook engine) - if: matrix.flavor == 'jailed' - run: | - DYLIB=packages/jailed/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" - 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" - fi - - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index fb9e324..ebf7d60 100644 --- a/.gitignore +++ b/.gitignore @@ -464,6 +464,11 @@ packages/ .idea/ *.swp +# 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 diff --git a/Sources/Common b/Sources/Common index f19e9fe..671451c 160000 --- a/Sources/Common +++ b/Sources/Common @@ -1 +1 @@ -Subproject commit f19e9feb1ac27020584e290c102485639418bba8 +Subproject commit 671451c5af1cc72928e5f202c8a2d296ae65d3a1 diff --git a/shared b/shared index ab0279e..d355270 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit ab0279e33a6de39fc00246f06b4a3bae3cf35f80 +Subproject commit d355270aa4c8fe29fef3c9bfb7fe45c14bfba903 From 45af7bd72ec9903dddb24a362651643e91cb54c3 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 07:00:12 +0900 Subject: [PATCH 09/51] feat: enable jailed build flavor with vendored dobby Squashed: see PR #8 for the full message. --- .github/workflows/integration.yaml | 28 ++++-- .gitignore | 4 + Makefile | 35 ++++++- vendor/dobby/include/dobby.h | 154 +++++++++++++++++++++++++++++ vendor/dobby/lib/libdobby.a | Bin 0 -> 196992 bytes 5 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 vendor/dobby/include/dobby.h create mode 100644 vendor/dobby/lib/libdobby.a diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index c270a30..781b591 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -60,16 +60,14 @@ jobs: fail-fast: false matrix: include: - # Only the substrate (rootless .deb) flavor is wired up here. - # A `jailed` flavor existed in the matrix originally, but the - # corresponding `make jailed` target and vendor/dobby static - # library were never added to this repo (KiouKifExporter has - # them; we don't yet). Reintroduce a jailed entry once Dobby - # is vendored and a `jailed::` target lands in the Makefile. - flavor: substrate make_args: "" artifact_name: kiouenginebridge-substrate-deb artifact_path: packages/*.deb + - flavor: jailed + make_args: "jailed" + artifact_name: kiouenginebridge-jailed-dylib + artifact_path: packages/jailed/KiouEngineBridge.dylib steps: - name: Checkout source @@ -156,6 +154,24 @@ jobs: make package ls -la packages/ + - name: Sanity check jailed dylib (no external hook engine) + if: matrix.flavor == 'jailed' + run: | + DYLIB=packages/jailed/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" + 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" + fi + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index ebf7d60..ff4bffa 100644 --- a/.gitignore +++ b/.gitignore @@ -273,6 +273,10 @@ downloads/ eggs/ .eggs/ lib/ +# Python's lib/ rule above sweeps up vendor/dobby/lib/libdobby.a. Re-include +# the whole vendored Dobby tree so its prebuilt static archive stays tracked. +!vendor/dobby/lib/ +!vendor/dobby/lib/** lib64/ parts/ sdist/ diff --git a/Makefile b/Makefile index 45d3ba3..04f1c1b 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,10 @@ KiouEngineBridge_FRAMEWORKS = Foundation # Hook engine selection. # # default (JB / rootless): MobileSubstrate (MSHookFunction in libsubstrate) +# JAILED=1 : Dobby statically linked from vendor/dobby. No +# libsubstrate dependency, so the resulting +# .dylib can be injected via Sideloadly into a +# non-jailbroken iOS 15-17 install. # BINPATCH=1 : static binary patch + __DATA,__bss SLOT # dispatcher. No runtime __TEXT writes, so works # on iOS 18 CSM (Sideloadly / TrollStore / @@ -31,12 +35,24 @@ KiouEngineBridge_FRAMEWORKS = Foundation # cave; this dylib only publishes a function # pointer into the reserved slot at constructor # time. See docs/plans/kiou_engine_bridge_binpatch.md. +# Implies JAILED=1 so libsubstrate is dropped. # -# Sources/Common/hookengine.h picks the API at compile time. +# Sources/Common/hookengine.h picks the API at compile time via IPA_JAILED. # --------------------------------------------------------------------------- ifeq ($(BINPATCH),1) + JAILED := 1 KiouEngineBridge_CFLAGS += -DKIOU_BINPATCH=1 -DIPA_LOG_TO_DOCUMENTS=1 - KiouEngineBridge_LDFLAGS = -Wl,-undefined,error +endif + +ifeq ($(JAILED),1) + KiouEngineBridge_CFLAGS += -DIPA_JAILED=1 -Ivendor/dobby/include + # Dobby is C++; pull in libc++ for __cxa_guard_*, __cxa_pure_virtual, etc. + KiouEngineBridge_LDFLAGS := -Lvendor/dobby/lib -ldobby -lc++ -lc++abi +ifeq ($(BINPATCH),1) + # binpatch path never actually invokes Dobby at runtime but keeps the + # same link shape; refuse to silently fall back to a host hook engine. + KiouEngineBridge_LDFLAGS += -Wl,-undefined,error +endif else KiouEngineBridge_LDFLAGS = -lsubstrate endif @@ -47,6 +63,21 @@ 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')" +# jailed distribution: rebuild with Dobby statically linked, then copy the +# resulting .dylib into packages/jailed/ for Sideloadly injection. Verifies +# the final binary has no libsubstrate / libdobby external dep (libdobby.a +# is bundled as a static archive, so it must not appear in the load list). +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)" + # binpatch distribution: rebuild with KIOU_BINPATCH=1, drop libsubstrate, copy # into packages/binpatch/ for the build_patched_ipa.sh pipeline. binpatch:: diff --git a/vendor/dobby/include/dobby.h b/vendor/dobby/include/dobby.h new file mode 100644 index 0000000..d7eec86 --- /dev/null +++ b/vendor/dobby/include/dobby.h @@ -0,0 +1,154 @@ +#ifndef dobby_h +#define dobby_h + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +typedef uintptr_t addr_t; +typedef uint32_t addr32_t; +typedef uint64_t addr64_t; + +typedef void *asm_func_t; + +#if defined(__arm__) +typedef struct { + uint32_t dummy_0; + uint32_t dummy_1; + + uint32_t dummy_2; + uint32_t sp; + + union { + uint32_t r[13]; + struct { + uint32_t r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12; + } regs; + } general; + + uint32_t lr; +} DobbyRegisterContext; +#elif defined(__arm64__) || defined(__aarch64__) +#define ARM64_TMP_REG_NDX_0 17 + +typedef union _FPReg { + __int128_t q; + struct { + double d1; + double d2; + } d; + struct { + float f1; + float f2; + float f3; + float f4; + } f; +} FPReg; + +// register context +typedef struct { + uint64_t dmmpy_0; // dummy placeholder + uint64_t sp; + + uint64_t dmmpy_1; // dummy placeholder + union { + uint64_t x[29]; + struct { + uint64_t x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13, x14, x15, x16, x17, x18, x19, x20, x21, x22, + x23, x24, x25, x26, x27, x28; + } regs; + } general; + + uint64_t fp; + uint64_t lr; + + union { + FPReg q[32]; + struct { + FPReg q0, q1, q2, q3, q4, q5, q6, q7; + // [!!! READ ME !!!] + // for Arm64, can't access q8 - q31, unless you enable full floating-point register pack + FPReg q8, q9, q10, q11, q12, q13, q14, q15, q16, q17, q18, q19, q20, q21, q22, q23, q24, q25, q26, q27, q28, q29, + q30, q31; + } regs; + } floating; +} DobbyRegisterContext; +#elif defined(_M_IX86) || defined(__i386__) +typedef struct _RegisterContext { + uint32_t dummy_0; + uint32_t esp; + + uint32_t dummy_1; + uint32_t flags; + + union { + struct { + uint32_t eax, ebx, ecx, edx, ebp, esp, edi, esi; + } regs; + } general; + +} DobbyRegisterContext; +#elif defined(_M_X64) || defined(__x86_64__) +typedef struct { + union { + struct { + uint64_t rax, rbx, rcx, rdx, rbp, rsp, rdi, rsi, r8, r9, r10, r11, r12, r13, r14, r15; + } regs; + } general; + + uint64_t dummy_0; + uint64_t flags; + uint64_t ret; +} DobbyRegisterContext; +#endif + +#define install_hook_name(name, fn_ret_t, fn_args_t...) \ + static fn_ret_t fake_##name(fn_args_t); \ + static fn_ret_t (*orig_##name)(fn_args_t); \ + /* __attribute__((constructor)) */ static void install_hook_##name(void *sym_addr) { \ + DobbyHook(sym_addr, (void *)fake_##name, (void **)&orig_##name); \ + return; \ + } \ + fn_ret_t fake_##name(fn_args_t) + +// memory code patch +int DobbyCodePatch(void *address, uint8_t *buffer, uint32_t buffer_size); + +// function inline hook +int DobbyHook(void *address, void *fake_func, void **out_origin_func); + +// dynamic binary instruction instrument +// for Arm64, can't access q8 - q31, unless enable full floating-point register pack +typedef void (*dobby_instrument_callback_t)(void *address, DobbyRegisterContext *ctx); +int DobbyInstrument(void *address, dobby_instrument_callback_t pre_handler); + +// destroy and restore code patch +int DobbyDestroy(void *address); + +const char *DobbyGetVersion(); + +// symbol resolver +void *DobbySymbolResolver(const char *image_name, const char *symbol_name); + +// import table replace +int DobbyImportTableReplace(char *image_name, char *symbol_name, void *fake_func, void **orig_func); + +// for arm, Arm64, try use b xxx instead of ldr absolute indirect branch +// for x86, x64, always use absolute indirect jump +void dobby_set_near_trampoline(bool enable); + +// register callback for alloc near code block +typedef addr_t (*dobby_alloc_near_code_callback_t)(uint32_t size, addr_t pos, size_t range); +void dobby_register_alloc_near_code_callback(dobby_alloc_near_code_callback_t handler); + +void dobby_set_options(bool enable_near_trampoline, dobby_alloc_near_code_callback_t alloc_near_code_callback); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/vendor/dobby/lib/libdobby.a b/vendor/dobby/lib/libdobby.a new file mode 100644 index 0000000000000000000000000000000000000000..c42d798588985af7f2a617a30e81af2078ae1abc GIT binary patch literal 196992 zcmeFa3w%^nmM^?d6_3h8o=FJmbOP!~z)DhikxY}O@)808B8f`ddQU1hN889bhq|EU*|JEW(FI3uBhXsXZ)rE?aTymrlAETwlUv- z?Xyqi)X7^=XgKGWIxG9N_TFnh*V^mswP2Ye?DTvq`5IeZL4NkoV_RBkE3_3A6qICV z+w$`f6n~c#6=i2H%`YfbVmBqra&E)7*HqWs$cCQYDa!x7PR6dozXaC{xKY5J0`3*? zZ2>zAPX!EAjpWu=GC|s}j&F;1&V@ zQoz3v@LvR+iaJrcMgjj)z<&nZSS_)%sR~>xU{t_I1bkM&g90W^V{G5861zsgS^;GN zHwpMZ1^ib5KL*ThlUT-d1uheCy@2-!xKqIW0`?0y!@}5(?Gjrk;GY5Zeph1a1q=Z0 z`d5kFE8t%UxJ$sj0=^~SzYEBclyr*)yiLG+1bkA!e-Q9@0$!D@q$?Bf4gvp{fPDf= zDN4L61uPTr+X6ly;QthGpMbvv+&I<5P6{|JRdFv7utC5M0e1-af`Fzgl=y1}d{n?+ z3HTo1?!_iHe})2A3m6vgF9rOGfQJP9LcsKyO1eA&Zxiq@1l%p)&jtLYfWHy&zX12! zP3#K+(`PB}Zwh#mfNcW)cLCo5?7P*(-WTvgz+R_`&6usgA^}$k=o0V=0rv~|zJPP) zDCrsmi~yeeo{2p!;2r^gB4CezhXwpMz-`Z<9st$2ky zu-@$pIvr7WRnX(&zH&dj7B3-14fU7d>2sk~u*c5riEo%$%*9Y69 z-au=;uf5e9SmOvdTHWELA?}LQP^`7K{4jErozabQw3Pm(0@U#A*4k{ffv7v|bcdqB zaJ8+bLrGDLas|ViDttV0Fl;My(7$SFYy2X$K3*KdM9Nig&ppMCu)n0pR@B%YZVpyN zB5r@P&mAsY6YQv2+mJ8YY*qC)uXne4BPe6dCSJC9s-;9jP|H?Q=S8r?SLX^Vq!KZg zf-ajPQC~@i+lkuMqO$c7ce^WC=V*5Oh$=0zHxP-+QDi2U@ftc1v8JXh)E@E3%?@W< z&HB|11v1ZAkFMTU#9Oo45p{5kUkXVx8<|u=-D` z=tSsnyWbs%%3-C)$}VptRuZR`yIZXECN~!VCu&(cD;M3~)t@2m0PMuqOk4PH5X9UyRa6j1{a1HrK<5QyS(8f>nSJLni zSMA1>8^o%~T`PmZHt}z5d@iO&ve`mDEI6K^&*ct(mEF)*65nfxHP2RDE!F_xq3%D1 z#VoL?JxSd`xoG(xHGWBjGwSUaJ$z|>*d20&;}MqD257M%BB^C78S1Q!jxc68H8aFj z*D^J3L1nww=h9VTgm|OkJm(eY+&U?CiNg>i?hT7;0@3iMu{TPEd_*_iVjE6P>S#)U zj&$hXLBBs3(06~2BjCbT5W(${XwWb7-5C}YM;HlQZaE(AqQylHi%sINcupjwjU1De zh_v2tEJMRa8x#yhy}>|)$*mYj4s1q;M?TkWNKt(_=yXRS>)QiSuitGeK-XFmbhZ23 zYaAi9kLe>7QzX78uXAj|{%MqWWpP|J^{bsk^tHBg+N0}xg{@E&TPd+_mukzutKI8t zlOw@!w5I+>FE-CMxuICD@p^e#CWoyI12pQT_LvB+GLVP%7{P$9P>Gv)+bEm)No{7S zxA9&A`W7`iB5qr8qbKZkxZ>OQf`TabQ2VKTci0gMfr+UnzNWO=?Q^#h7skmlGK!Ka zU__LOr&!wHj;?k1kwmop$cW3T+QVUN#A{um7cE`aP#^XNqQ(48sqaymOZb=E5)Y_X zI#ubq2BkOJ3Xx|$_UGP6gf~=&-&Udo4f%3p@gzkzhTSM)2j9`Jje9S}#O?+uhli9_ zC~T;^W?bB3Mpd?)Lphe#gI8|w-lHx8D2mdG#SKyPJVBheZ&g8@t~$Rvse+BS#C^pw z%Xxg2!Lb$kL*ZZ)oT%*ehkU3$9}?$x`!gw?ML!tpCp84B&{*LrhtuOm<(=e^ox*in zL8-b}6JIs7j9w^K&Q`=%4=M~7w$$87a+mdvK&xBGG0yw)JL+tNUW8duSt*CFxsz&w z@m5AmP9=50R>*^je8JX^no>CG+#PNo2DliF@~sY z5m7?+fUszglA>lTs>87t5k+e1ot)kZ>D27B^%HYb1J8K9LLbhnjj>VC&lF>C0<=B6 zpjatMix)eoL=t+vRBOJR6c%V=`S~tqgvcHxa)B+vJF7C?aH%Gdsoqwi_a_<^6ejq3 zBfJkeynzwoD>9~n;)E2wAa!R>t;h&zcpiy4V+$tp`a-znQKTHhBXG3DCMyxj67nPr z+BO>@-(j7izP=P341}2_MfG>qgH*~x=XK%$jvvFt&+BZOlRD+pj-S^R=GzL?^E!tr zkF`Qdo9lGu1_e@p|MS^@et}~ToUkb`9Br}vs zo*4abl*S)fCSj3q0LXG<&4$JyxBfpBpT@sa^FPuzd!xia2v1qAtGi{5Hiq)QTJ#a{ zB}B&B=XVk3I0`-$&!36<{#x}&z%t}0z`vRJ;V2Rh4r-?0umumf$v+ckQ#cl69zZ)F z0ISE39`dIjJt|aOlXzR5P8+jE!`_h3&A4>lijAw!ZS@8tw&L9U+Z28s6(CLv1TJoCD2Sc0J6s+J1`y_GK}7;V>pkvp&`R@;*Wtqs z(uM!6%@JhX(h{=LG8J|>qvgSX5<47h54dnhgMvjppe`3DSR<}BstE0y%WWmOMTiSt z7_)}A+3WL0H(Bp$2Xa&s*g;NExXl{H+G53~%h?8WNYLSpsIk3Vf)TNLye^kJV2wn9 z2-QKm_&^V9S#S>=y%q;9pL~ct;^lqPCFoLw4FT4eLhz+ zZy_`p_y#nxKjiiCK95cqF3*elLwQ`%me)+eAQ~_XnMZ={I9{VDEYB14yYo6CH2>s9 zJ?>y6FB%N`oE~)7JWL|(8xbQC#lOvYP9L_XdA34j#JSud3<7t+xrujcR<*{_=Draf zJd!8G8oaf0kxQPEVV)DCCokUfhyIIC=D8SDkv4D0+8$_+fCLx^*q!+uAuDo2(bqJd zmu!&9CUi=^c@dbs@CgTl(UAy(K@rLG)_Z~h_qqm*isD@OFzeToq?%Q&TT@?KS0h79 zrFLy?W9_=N4a{22s@GLkep{|vw_-)@+7)tjZ9_$6T@3|P-dtN(E#F$(xKeIhU$Lfs zU0v`ArmoA=Ya-Fy62fTmif^ze)sJg=ehi}w?Jlg;Bfsj}x-nntGYE_Pyb?HT( zmCB@@cXPxYhM-7aU>YAoDDrfB(ZtHdt6a_2R@YSCyaN3xiWRrH96#rhCFmM7J)gmUu8`BTyfx6iWQh%HLJKBG@JUo z*e5Vs8Bz}|Q%qT&68u1L+Cr&I7sB{rhdHPPQy%({l`4w)PhABg-qwJ!Uh%~^9F2Mr zSCs)fG;BmFj#<# zodviw_x~G9iW0Z~hWuYJScd%nk4pZpbYx@uZ*2cB-|fFTnSaH*e>EFp^KWeab*BWz z=HJ--YtIA>`Ttic{~z&t{ZaIP3d+jxB|kk)$#nA43QYp7_RqP>{cSqAzv{kJ<^K8S zN$x*O=>0SqdOs7_`$_D;#;)Jk^&RyTXNVo4--IwaVztGtCUpRdqb>CxX`?#Z4 zL*4)KQTNyH%HuQnSGe!9Cy2kN=(T(zu*o zxz31xPLDe{#CMz(>(^*ie$G|yAJ*;vRrjU3|G(=z?f>hA+<&(r_n)}je~b=o90C}J z0GIqBz=-`n568H9BZjC`{rO=MM$BUT%nk0r;2sR_!QdV)3+~~fJU5sW|6nW3FHb+f{b)eXKhXU&7P{moTRCH(`+_!VzLQpbhu*$xmH!usi-ASgx78z zzM+`Pf3l1(%5f{c7k5~-2k3ULM0~vhAAQG8kL4Spd^afd7Hp3ai1&E+Eu9qj=(~4% zGJ$WbXT0w%P6>RQMM36|gKxC^mU2-Ds^cuwYb7A{5xReA1KfWKxE~Q#qA|dh#qVE& znGQxjnE7B=;YVSUeNM0Ye`5oirPx3f>;Blx_OeuaKXB}9GAXdvJa_NF=8D+qb+7K} zG0X1_i_gw2-k{lh`19bj*k`TNV`pVc?2}uQkXJIwloA_w&D4F`EbZmxvUIMea((n5 z>)y72b%WeHvL*XK?^SGIaLbg~U>55iG@D|c4Ay@q%HEc2@ApXd{#wa$$e(0Cuw1&L z?bYS1@$^h~;QJ2NJ+JeJ>ocX!^=Q7d?SobCliaxf0^}M}xuKj0yDfWKLeNB&C@X3HkbXzNu|L zEN6|Dikz;iCHsNxbEnN&F3oG(I+ZnUzxx{vIqv%(`!I++zw3I;A|-XDNy%Mox>DDy zd!<+wWBpn6rdVbVE9+gt`j1vg-M!}Yy+n(?CHDT~h4ur34eY@2Jd~w^byGMGYrQci zX*Qd^T$Rg}1eiW`gyfvCo^^ZWTw(jItB;lQ8~9SKDTn#z@$^wRCd=x0{j{Uk|_STjn<`kFmQds2`8e4<^%(Pop1e>yd+colAek z9GkXi(YE!A7VQaJB-Wn^x^JI1eNJ-E|HIo66J=v^r}BTr%&(yQz--0{c9Tv}pX*AT6#KNMqLTaJ0{ zZ$p!w*Yy42_&N3mY<<$S{VR5k^Tdss_DM$jBxmo&Sebkqy@5IOT>1&Nki3nyuef5) zxs;=J%DI(et|-Ut=%Wk7|6|SD)Nj1H{`vA3gY(9l?Bq@@gy-C6 zf6P0T^`Dkl(Q&NX$FYuLU$A*d-1sETEo=bu<0vBoOY;q*-V0X1Fpi@{EF_4eKVHy_h4fG-ItcTNc-v zS^ui*D`HqfPjrF?CunX3F2C2ee} zky;)*^h;T~7kal46YV7e=VXs$N>~{Z_<258fEVUa*_?m=@Og_9^83 zS?m8sxP*8n#5ZG4I3+eQu z&EDTUhxR(k{zu&(Y@EjW@6KQcJP4zCZzJOU#w6W!eBRSnKJu-r8V=l_v^d9{yg0dj zFU`e^lhDt-w^Z`Ih-Z$n7a@96-lNg`?s(pb^zK03?V7wh5O4eaDGfR0SIx;WCoN8H z{4vk-ja6T9o?m?Dop_!bzOFpmhVs09C{Jn!TIYzi1DmVS_EqQumFNo<=qrE6`H(@g zboi57C4`yKFVHt$BmPC&n?d}De*au&idnGNw_p!_u$f{1jQ(mSXi>&2#^xKybI^=^ z^mOdiXdjJm>aPdas(ouNZ{G;|)|PnRn#D+pD~9DPv6CqrZ#xeFyD(Q1q>L z5l@{Px1&wp0Djuj9*p;+^Oui0CsIC?Khc!(8Q5F}x>bUv6&Q08A9I6M(qZiw`xxVE za8QaZ0zHp{&*=lrKsV6Etjre=zOt;z)LE{!57JKx&jK-r^p1jOw6U*_`J~|-8SEt=HL*j-G4Fkde(>~GcB%?~-hrxjmP}>czW==cu}4nb_s(`xa#u1-?z+Ox zy1$1#+waV&dv`y)<@uh6mp}jfR@VKG`2B7+JLSM!zC}tsu^Kbc15&fsNCLVeztmJu)a8I*zQZC2Z^9t7Z94~u%yzFN9 z>B}C|l>NsODEm?DuWm!xsXWQ%v_mmLXIjTnq@+V9W~}b|(TtR?)bGk?eGg}yrFQ%) z)Vm#RwFh;h_InX|Qu`e=r5{3@9?k--Up2Ev+S{wNUj4BB?2?BWmIw<^Pi?hrMrK!Z zA&XHvdXriIcDRWqly@@nPM*1{>ya7BT`9dRd=2W$zaBDH=<>T~RKoLlNqbIG(DN z@ia#nPdVsc@$rQG)bX@kQ+DlmqPa&Yd-{lF--CR4yNI&W-XcCG1g)qZd`!&2n3$8o z>ye6jH1T?*?tNO+gO3fAGd?!Z7Q3gtvMd|)df7B~-x+*OI{aCy3G1gBbN&>}{Zp}K zPKymZD0P2|d7joysu$7QaeZdu$`-#B9)~*PIz&3$e$0n{b_ehsCy;GQ0W^cOl~TUPJ4LvQIw!j6L?W$#S9s z_0I=fZN=<%LN?z?tVh6q}%}se(n$c$YVzAK<_b&$qU%0vAhOSEBq_Q&9TM}zhzo3N# zKdQ3_{xSR(&fa#Vx@Tl&We-gIN=iQ;>m%(k*fUa2Rth@+|NdLSZ+wbz+xO=^vE`r> z)fMY#w;6Rf21t7Z$Zym3OnWs(@hDCY=$?tStPk@M=DqGd$P{5#8}o%Wogzo$akk25 zQ)3l5NuWUrXs|>|Nm-3w8))#MIeGP+QgZTYDQR^I>U<|?AWNxj7|XnEaTJkOT(P7p z8DZ+aoyv5S%Y1J{KdC`~sYbu4!oI6AhJM7?_|JpCfLnsU3E^hM!@hho`V{s6XT;u| zWVRk=Iy4V!RvPvYPn%39C|{aWs2vH?STunL&S7bX$`Fs%xIY8jV4jCLIuqmF-Y=v7 z=U`1ro}StDoq>vX9+`1f7x`_+I@~nE`kalmSG&gj7VD^feSQ#r`ZaE{>$3xV;I~kZ zun~V&whjb*#eYhInQ&fb?!>B&MA3n%N(CavA1ZGGWq_Z4f&3>$M`wc?SI1BN%`ZT z3;W27_+CR<_cO*^_kW4>zxq<^euieRVBt(1$g4W=e*9q=9j_u zvTh6bZ^$#dEd#%?tTZt+k;0GD{L_QEMuK|*`19rWve4=!tZ;DKc{CuBdmb(xwTlje?{h@;9qwtT^2TQFCt z;S`_3)UzYX3qStY1-u}@dI{Pa8S_Bg&_Faa5HDX1#FC;>br6NR$XBd|IDP>PO+-Tz z(a=ORG!ZWgO~gG=YJxtAwMf8+<}sEHcTa`_QvoS_e>!8}d#%-gP^q#m1EhFGfKYj} zW(xmVfKV~BP7&^1*kw|>sDK*)311x`rB4MUd>gRICI1@$$^WT2iaP|z%QIVXzX_!* ziuXQD(<*oUEM{uA> z@m+v~=XOBCvr_n5IxNMX$fM7f?N2%X8g8-$x6+~ui&lzs{zmE$B1F9~lQAi1XiqDieqU{)yJ4S?jI z2}toyL-z@N{niQo8+slx_zg(J>_47C^#J zoq^(gG*yXr5RmeJ2#~_>1-uT>14wx6fP^PoxL=2`g5tddNb&XnQoK$;iq|CEpF@~J z@o*NzW*j3QrT^sUrNWq=df3hd!s`R1{Hg`SSmJIB3l3igT!8R>fEWsOln$_bxA5Nu zcrE;QDzKamgps!HQ3aMq0YSRDkOIp$0A2%p^?}p=URK6s|r$5%|>lVEW+UPhozc zXE}Zpe?K)ao?>2}9@dA+m!6XX|1MF!U%-#zQ@^94dfQC33k-1 zh2>?Nt*ZX!_3l;};&q2>bg7nB;XXE4Mz@vJDLUYFu5e}85pa5xm@Zg_hr;x2qg$xA z*=otyozo3#oZ+~!cUv*6nFqt0DqsQA>ClGg4L48D#5;7YH#%ftI!%_>xWlb(*9vZ1 zEs|JHTVaDcTJH|~y%DlzS_A9$wi2aKL%xYgN=0eiHKRlg-0c^(s)=m%j;PZE47^CK z?XdU?T{p|Ex<%PZ>ERaVr=a-_Xku9e3Y z<4{8lqr9z$qu;%a6kiha9U*qY{ruf~@Kb&h7YFA;G~S*ShY zk;zamnYJ$&8Od6ktv=#zcLg~H8|vCZb%@GQ3>cG$37;|RcDYj~+*u`*IaaaP>2cDz_*Q?GBc*RQ$4#ieHr9=NE~K zdb8;n_kJ&1zA*bS%J=zLR2n>vFieO$ow-3}Q;fI%JAH_+7|EnqHhy>@+hnEcCYt*s zz8)fh5uPI=j_RHcKl(vj#Gi?Nr!S(%@u_aWPR;*d$C|xSy8cpl%5q)ZEo-zfl>gPD ztK}OWL-Cm?9|7Gp{`^`+JPN`B_>*iD;xRlpw3q^^C?0Z?Kb;dmY{>Qj+5rLL-<6X; z{peAl;+n+!veC2Ac5H*2Ft`bWn}|XrE2NM3Pdh$xgBOaY(5^k!$fk(Z<94{*VXMm> zf*}Aee8RzCl;wGXex0qfh^sA<=dJey1MYPVwvyc9T=;xlNvc`Zx;6E+bv1Hz&5gBd zYa46Vt!rK>)<}#>!WV}NS!_!4EmfNhc}Q1F@O6;42wt<|2F42eU3nDo~IL8-Vl z81}_+=1eMJ0ommy$$jqmJcoFN6przze1qz~RQZM;QQvbthu950Upiwr31|m2&LOhS2(pVt#&287tSv5gb9<}R z8T5y|J_ns3T3z07c^-7)^Qwc*&6{`&xvbtGx7!^K2g6pM*AF8Jwlbvd@J7BWMWL|x zy2h%g-dkPx)*)iO+Z***#iuIPmexSK)7s$<)5j>~MY#pJHf9Y3t-+AvE-d1qAa4#T zxZknS8r>9fTOB@cYoOd#!jQ}fjNW_PR$nkkhmk`b5nl*fBK{QOt^MxIN^V8DgNKuL zht_>PxuN``s)-QPx+nO-3VoB1_=y3}ZSvn26qOY!<~y(tr_V6L?S6M4Dum#l9l%F*!aYQt1k`6ddmGzC1A9aQ`W{u)c~Ws` z;du5oP6~XAwh&_n1-=bXA);~$^`kh-arK}m5gvh-9Ra?vo^kb{1inXAJ*aW;jiv{c zi$YKxJ*bx*kkFD|)E>C;{W8nM_k|XCYWWfRpMKPL=<`Fh58>YlKf~;2e9iVY67!e# z(%rQd3+}#T?1j!bxa$@7HZf`Wdt1L@$9=Cuwm(oo+!b|c-9t6uMmwJoFGan$u_ttc zIW?Hm%YZo*-${!vLB$~wZE{qfu}8`IV^Pn!AQy~L{Ss<;25u>-jBT<_VWB*9Ncrm@1r3|_sbBZdvOT94oLSS?E}PBB>Y|+!1CR~e-|LG zY~lCf0P=fraJ3V^7YC5vivw1L--`pt@5RBrHvC>3@D%)B96)|A4&_rV@Yxl}@5KRY z!tcca(W8odGoGx z1n+I8M@`dBpG%#1pO((T=L4w^?>^H<@OjJhF5U-CZ{q!&X&>jr2Ef>9THSI0B2L*84AmFpb9tG1DNI!ma>b2N<))YT zjV~9~#V(1rpp3muV*C;ho%G1%jfB3I`&w=r_Y#kZU-j{&FZtm83io?V>KwcGcd;KMqyA!;y04VS#Ap)o#vSMsGl zZ^$EW*oZh^VuhhRCX}fmLNIRcIKRi^h6q8`4^VU&oL^2)Ag&>>t`K81>MF<4wIMT*Y5}xtS_YVquyF@Cx4qpN?IBspr0gi1 z9&ma5M@>k&9il1m-^8E9^Lp5tyP*04JT)8E3*ohkD~5A3V;U8C6fJ~Pwazu+0zTjX}Wm3S0p zfjvFK`RPKkjhM;peD}j{AKCfNf&IV5T07ssfi8INcD|8sA-D5AH|6y8Wam4L+xgxJ zyTnh&?Gh_C7RjdEC&>30(i}A_HUoPFJOi7Ir>|!Rs2*hJ`v;&4=BYG-J`{*>=pLIdr=lj zL-q>Mr@BY8mulFDR@!PTJJd-SAIC91CSo6Yv~fL>{n$Mi1B(oM%yIj%Uf32i>@gem zn8|LfVUHQQhL^cL=09dVO1}P+UC{461iPT@dAN7r$M;VcrM*uSZmj3J{DO_yBSp9r z^)wTu!^S-$R%fW487C&jiAnr0{Jh>UGVwDLRn}(QEn?g)a$Y|wG)_&7QEbap}hX@C?zRY0=GxB%{6z=eSHy&tly zdsl(wF94GL#9e@teg`1sbFXkW0usJD;a@8JcS=e;A0X*{_A-7JQ1`w9%Z~u^aspEN zodWs2t^i|)V+dFr<>7%(P@!p;GEG#wdOxuO`qiH+v{(Np{ zn#uIO)Jr<8Q;$qVob+eYr<*#{AHusY?IXnLP5S`v*V7K7Ak@BE{nfY0jh}ihbU^3+ z8M)C{)N`JH)w%mnFVas{&siQvdlNpguS(Am{79d*Pq?3_^l+=^Jbz7YP5QqgHwd7f zvph*|G>CdmLi(XvH{`Bt2F8$j?jcjS#yu5o(pRN^K~E6z$)01cC=cnYYV~1*!VSQm zoA9RaPQ}Sqi}Y&w>6nGWOF<)gKIi%Mup=llJ%1zI+fZkEw&O?fH*lA7-w>k2pD*yA z*8NMJdldq;;l(=l!#elxbm=LrHvJM^{7-b@Bim=3u%*RO^lr=6!j#@~*=lKtGHrMgk$6L`GYXOk*(-mIl9CGm0Z|xhVMBVbr?tW(Lor zI`L9&C-6Mj4=h&g2Tn?lU=;mgT#I7p7h?(;_KbIKt`0*FE*rQ zhO|tPmZ?+w`MFt3vws*%saeg`&@VRhiw*tau`dg|Xzuqi^ouWF{o>L10`_9&ujsPo4U&AUm>8;m~9STwYQ}Jp1J2n3! z{e`guXz&FW3148Gq8q2^VYee{HTVL-7hH%lbeQc>~a+57su}d_PBi^ zcR2Abl5^z;0(20GN8P`x?n{*)@QeDMt38gD_<#+^&ukZ<9Z>r{8~M|Z9u+FCNxUyb zz1oBhY@87oX9SlYFHq$RMxbOiOpK2Urt%Vo$5(bC$s|f(oE{jb2gd1vaeDAonGCsz z^lUFiK4CKU`wH_*`&S8*9| z70UPi+WQx*#3yL@2;4Q1;Qku;ib;LT=PQQJxm9gc6ka^~^9jDkrFv#75$OAd zMVfjmzC&-)%b|zvtDr}9tNes|pZ7$+Z+KKe!}0NA;D!x;CBr1|Pgn6A6!={D(1P&M zJ>^uV;rFMz98rge3?R$Z6^#``?hV8!;_)be<)cvQw;RGM0gQJ)d5^$H{fM4j0-pt! zb5l75+rq=>HQx6eZwY(?usxh;@#Mq)8Sgvq!vfLWq9Czx@Qrsrc_!**M=7XoxOat- z-FC8Peh>u6geP^Qdi?02|LI442zS1GDV?qheLuNW&-#1J@_Y3AW4>2&U~@(6^txB~ zpc!y0%>{feZT&psYaQcjoy+uVonidu&`DcKkw!=FtNnaulBF9{;c%;bXfL_Jlc{{F+JXPnuUFQaad=B=<3zoKCQkKiV&T9>I(j{t$^LTyWxmhEJ4K!PxYG8aq z-~Sbr*-CiC?51SrFumUMFFy54mP_CIogDqZekHHMS0pN6SkpmV(6O~`X(BE6Ufy@87db-<7BMT7;-j4 z&SuEj3_05+FJ~jU5XrH&0peWT_mBeb=v4g6eSi~{!;zfpIm9Qq)^5PjIb(g%3ID;>dGk;c6w^+*=eL((?*^q5|U1a6P%xwuU3A=5TunD86}kEE5yy-#k0 zseJBhI(M0HpH-5x9|Ds6Z5#aQ`CAHyTjguXehA6qRKD~lAmA{nym$Yk(ic-@ro|`zg7Buah#u z16w&v{L9@$7p?oG&iyYsx86RxZ?uC#T)uh^*N$R`qo%{fl1WHj=)Y zg#W};!~Z>y(?6i-M2+%A)-vhYa+=)E5ix!qKp7G>&zZUquFA5@!gKs?f z-9glg=3c5Bt$o>eT@Sbe#??AeajpCg>oGrwq9UOAXm9qVmm*^|9e>Hderwo*5@piUT=z}fi&MY z+52%$^diNf|H*GBAmZ(?KrWaer5)<4V%<#2=(1RlUdlN1n=ja@qYrL-g3|ON?5H_y z?=f@6UY<{t`Rtk3%xCfc4*U?7v3DWzrLfZ|!x@z8DAK6$qwlfUCsk5+FMd*|5)XFF ziuO!nH)RdXyzBTD9+bL2HJkQ+tkM$sS?;wzo+Zsa@c?6u>8$hZx%h2{-rmofS@)oc-F0Bj51tqt zWU;}Stbfr=du$`KoX9pYlp&LsiD-b{_%^4}iwb zM1{l}vw=SiX^xrAdq;|cLEU&9ZGf#&TFwI2O}6HnkmnM#U*IPdId=m3lk5l9NLQqM z7r*DHvYS3Kr>=fZN=<%LN?z?tVh6q}%}se(n$czfy>Or4fnN>`zHoEJ4PBLrPU236 zdP|}m`*|JMP6>WgXAk^i_${2h?aE&Bd|ppxR@(Bch&vx`W0BzZjFgj=!VbW{|5k|= zeTuf~`}3aIa?pwD+Gl3nX4K&rU@yWjPSf^Gdo@P!C{7RPo(WlXAN5hh>oc)_%$I42 z>6E_6<7}1Brp79Al0btL&|ry_lCm1VHqhWhbMoptrR3z*Qqt-a)cH=(K$cS5yrOL} zWuz5XEa^%{STE>H?LuWbivBdP`9}1U8uXWH^qZ>KXRVbn^rLR-PoD>W0k;Hy6T;1i zhjFKK_H&FF>8ngF5Z8Dvpd_B|b{nU;Gv!wJBrfIA(hov1V zL%bx6#XkewV4m0Y$c)S`Z?e5#mXc27T)`TXr)PG3XQ1MpM`m2rMSj~;S!2`3_r1gW z6560?p*@yGeKCbKLe_t1$pUtGi-Vo|Z4zsI)Wi-^|M?K{p5Dq%JqSPVK-D`-rm}9| zf8PJtBd6|rXS*r6E14yCU14Y4->YQZzcZ)q-Tm;E=X)Mr{`~V>S@%EU_q*Bblw)wP zaf_6C!ZDpSehc+j&N5E?`ho91L3JqA)ZrFhhm3d~j!$8YznqqlP={Lhsdb>aOs&Ii zOJW_I$m=;?hxB+I%<$9KA*QLrk0(%vqsV_T>Tm|MS!_-_6a&p^Y`rp-H7gBZ~gL4f%PdvHstf(oZZvS(fuWl{$`;=N{zQjyz)`Pi>hg z-xTC~V#eyOHYu~~M>A5o+K_K5^7U!*ZAZR)W@PYue-3*6(~Nmtlt1=bjeATPhcM<2 zXQAJ`hWTYN)&d#*#fkqd=r1Xn{<26)?)oLt{|a=@sbJmzCHhOY=r1|cXEgm~EBZdR zc^Jn@Xv2)XwV-Jdr)fH;$H3+qjH7Cdrz(uAN{la>zyF@k--hltdfb6LMMWt?6ar&pK!=@s;v!RX=)!I~+c z2WKBw!~HB6MuI27uu}L_fC#dZycD7@Yc?Q-X97YbX8jxjGV(tS2)U;fJT!OT3rOiU z2)GQ8@Z|$?{1E(6x~BlI1MCF+Q$RW|K@sYr3M_8~TmXMNAY^OS8vr3+vt|P#|7QuJ zD0Np0H$k||X8~S|cnoj>U@!Kj*WjG)eFffe1aKkT?<(+)w*Vmzs(TX`s{SwXvdHf!B5P0ah z4L=H3pHmc$^lJH|BAogOJ^zd!g@*+G&A=m2@o*B4Yu%sd+&Q}VbT*=me~r$4yDt8V zy70Za@bBx~|E6>QPM6-P3;$T>hBKjjUAl0ZKPXQ8k^OGJGqh=f=bNSL8kD2V(t1a$ zyTN;pyXK-h`1HxSI0_nm)XC33o9}UlgH+kI@v7Jg8{E-)ci8WZM7+U3O?`*oR-)8$ z$Tu-bX@w8AjT}*T&4^Kvy$=?UkW1ihoUb~C9=^uxxr5GKtAowWo2p&2p_gDs#Z;Vbh;$+X@{-7!a?LY(Lfb zn<-vF@d|gq9R|6Ep{ws`kU1*#AiU02*xcUI0<%TlK*Sx6*47skN4)-!&n=U6DF@6M zDWngE<>=Z5xh(FiX(*6uYC7uSS)!n*ZIm1H;aA^KEZ47Yw8;%sN@fN5>x1o492VC5 z+FQMWHI4u}b9iZpyCOAIpcS`_`w@sQ>JODLdY{8b9+N$9w&fR%ac?_Lo6)m!gYnVL z^ykgA4D8oT0CYB6s&NmcRQ|Jwuplp6{K` zo*#V8e0X5eLdvRWL^pL$D`^+6S6YI`4 zEq@PT-M!Q8{REGu*ny)0xGI6O3b?D0M-B425qYja-YZdtT9joK%Cs6~t3w&r;M`^{ z&UMU$2X&}GT`Hgd_pGYtQ8#|pGbRB<;6^~uu#VP!z;f~ioUs{DyN_xoHwdow8yX_C z?@@`Lt#enwJyX;0w+gqKo`N*N=wQyE-yaObCwpN`!Q%+He7L`U%usoB-5hnUR&6sx z{Cu9KS*L){JvRQ%M{)Qw+4-cTOsKDDz8BN3Q(QeqGpCF+pLjHcp!uXvlw~APEIhY~ zG^$&jPiRcgGtv2^NQut%+GtG0pJDndW6vil9NaOMgF($K3S?`U6$OBL9xM(If%BZbBg9W#+jk7l6tj#!UQ_tEkCe*u^)cHNvh4ZX7Z^+snXpg`Ou|Mc)_qqL! zkk#c5!A$X*Mpm`P(dNF<>vKo)T*REqk*Fi;b>_Of;k-z&J?wPnHHIDj5Vy!K?s=-x zYyArU5lUd3ry1vI#(A1?o^~0Wrxjvzs^v4R>L9P2d*fp8d z{ljC}M^x7nl?qeL&KH>)L$pdF6MnA95R>H?Se^Biqs{DlN zzEt@M!#?5#^$j$)L7azS{CQOja*6$V#fa1{nuaT#zG7pHy1 zbJb(g-Zf?DF)94gc=sf!=S_wl(*@)sR6Qo;COE42f}=!-r2)T=?JG9D#~QOH0V= z3_>T-;f$6C14`_0usz_y#{gEpBkEz+s3Qzx8rF!b&D!A((-$G-wvyZ;#DxxT2}_b_BbZljprzivII;UzFQ1%p#CI0b`KxC}UjOIeTU zeDVvb-jaImpt>*Ba|c6@=>qZ#dbJ3HZ!oM+8`h^2zDfYIFid;s!toCy4p@AqZjEdL zOYC;I++pz%h8I5JU@*$^JVC!ZuOs4w03a{waR(!LV96tS-g-|k;9l1Np1e30KFs>{ zB&lXq>(H8<9-t!=Dbx3+=qo2gz`S@~_bZrzF%wQE<%)wK;3m31`~PN{4rd!~n#j8uw>zvs+v;K^qR4C5h_P+tD?c6&*^b^ z19_0pv~SGwhNy$(HNzt}S0!s6(B*a5CWdjuewx=F34?Hu_oz50hD={PPcRZiNt-dc z9f8(7TVZZ~ZvF@;l~#0)RrAry8K>;JLQd22)SP@_Uj79cxLycdIvozF=+j1)I?yT+v`S zV!hiN^;l7rjVOz?1$A`BMSexO1-WQ$U*lF(m{q~PGE$r z5hA@HeM~mw5%JOY-xnbtaUS)Uw48(@cR$zj2UTxL@% zM`3Uj!#Ijj2Flm3#{}+X)Up^{g>gEe;}i_8LdQxQT*W29Ra}&O#iYKoE?Qc`?`_J~ z+&`v%o{CLa{N}Nd_=(BfJErRSsP~SkZk3-XegDENAD2|L|kJ{;fJ_nvxag21y!0jw7{oQo<8pF{wC z%3vVEi^c;W$F69s7>YA1Rr$Y3cx}VgsD!Tvg;QxJf;a%aG3|DS1U|Y;ksjFdOL+86 zIG)|kjRGIdjr5!p<)cgL370UJ$;0S1-o1L;1U{Po_@g3L-8wj)J}PX>@n^ayh%^qq zVdH&@l7Q}Mr2l52UevZ!H@cU0Kip-2D;MC0L7`nA=yH4d(VdTr@WXwtlYTC=-OkD8 z|H-X?3)^QB?xHopCM9ffn%E1lceoei-+{Z|yR)w&+kQzW?6C1Rw|~oH6qW{ihmT~C zeZaXVvf&TAZQW#Bu7caA>zp&Nxe_=lU{CEeZbNMmYyu+AVX^^8w&mK=?fqMjwg-0E z$iLT|yqD|>E&Eo6V>$BeL>$wp`yN9>?2N&F+#c`swSAjON zH17m1Pfnz3iemGUY<$B0!-1?ttRI*8u4A1KEMuJyE}J)t^`}pPpRmdL3F4ilxHF}* zS*ff)E1Mml`dZMYmU`G|O=JC^ezxT?#m`=L5Lck~n&-j(q+;K+X9nxv4co0+KTKq} z3t6#$c?32j)92dz=M_>L%;RkU!^!=W{+MtdBs-c{*p)QW^7nr5r>y_rLi+)fp$P4S zev)W2br3cziG~rzUfA{xd%p+md>VJWqFs03*M0OF^iR>oqoo}*ONT$XRYLzmf8Gqc zwXd;m`&6R6w3le7v={3hneJ&PdSUN0p+5_{la0uc=$;0;&$SHEoowM|-7Ccs=-tR- zS)ezS-4mw?`YmYkGtk5Xn)LB2Xo)6OD0?NwPDKoL;q*8&Dh-CmrSX|IEJz`cMw?&c?iANkTul z#uOXeg1fje4;`yg+WPcNd;iYq?1eMvYo9K$ANU0A*^8fQ&-ZU&&$n+Bd%kJtH#>1( zH^ryAV86rd`Nk6L`6{+oReQda-C?D$EWcTyO$miz^S$7m+ zPm>MK?pr?lA#9J59ayrZI&V7b-VXbmWRvvx4A=mkHm|D;Hegj-qqXqM9>E4|FKojm zX>7ng1DmjV8?aUI)7wv-tPR)&up?WCI*=XP{SW-$2`cwel>3uuRPGF3?j+Rb4cL8E z%T2acsXkuV8&%7mVEYwy(w5x`+qc7Pzrs&n_DS1*O=gX-!Rqd#f z4b`#ir@jdLr__E2P3dvFsjp)0!hUER+p(F#cIta2aB!Y}A4Fq_O|kc5U%O)gY$((G(UigZF%QNpn6uI_XDy;Ri}oLw z$EhD#`1)!uvzTcuO^dIkP4Tr<;|IIF>fWUtkEJsZxTx&X=k>+OJ$71V7|VENU6kXu`40YdKmEJ294>r#cAAl&7jBN3JJ zT>+m3Bs{wnSl$Fk`01>O{2PTocp6Sevg=0aKL8{;9swjez79xqd;yT?xKDxQy8)>@ z1he7)lyDP-yPWK^62997yaAB%U8KPB(-ODE6-}kx#?;yKY*8u;11(wq}F_rf@1(rW6!gq`Cr$qQJ5q>WqwL94( z=IsH9uJEi2kjj0#a1$i=2EY=;Un%?v!oU1Y?B6N;paRRE0wlaU1@sA6Eg+q3Q8+O_ z1U~@G1AGe*VcbqJVEIeJe;?pn_&=w>^4-FJ7a(K=+}J!&+}<$Yi1vnU$fq9Z z<{)030=d0m%Ad+d`0NVg_J(0knA;l$#QZ+qZ<^o2 z`*rg{^K{dmDbG!T1>>o^r^5R2v?J4C33>Xn)2Evrwd}A=HyyFOkN2Y~J5r{bPN$qr zv6$YP`|e!e&v-KfX)~U~dq>7jytidMiuXt9CsBBM%Bj%kBkHr1+^B$h?)eR!`wFTY zI?^t2j`=6#MjZ9r&`ECmdNIGzlS%0jUp@DnLvG9s>bYeqxi#shksEWFdTz*xiaAd` z_k-;%xm_E7vw0*oZjQxKo+f1obIiQ2zzo72q?nF9z(8fGI zjbD1+1%9$;+$ri?2Y<4Mj6TJmPT>|#wjKXa_)esw=V}ptQn>#GeiXi2xc?yBJB0fV z;oc?OKNIf#R1rKF8$5kT(6?FO@1#IH`;d;xtJ3=k5xzkQW@QwP{2`O&&#kC;)|8%j zdwff{?Ws!mwSr!qqC8b_6aF279(^L5<~MqtM|z?s&2RL421xenXg;IoAmK;)Z6bXx z+#-mFKZty^?hKv#apb2Bhde&PjkPPm4Owu4`y(XQrr)K{U+1>y^1n}qZ?-Nz;nw26 zM;9K_xoJJp#%FQ+(A8uRsoIUx`k-Ph+7|LToNiChhp+dz5onjMUKoR3S_>V4aJwJo z|AeJoTgi~&q(*+jj%qj>aY+|%WiZ$_ZrqYu`k>3{4#m+HS9>G4l*j22X*F2&B`Bz* z&yaNShG8tJa7MiyakJLL!k5;E-62PK^auqu)!OlT~mXd1}1iVFuS_--j-0ZaY zAWG)!vK(WU!38aFOSa;@MWQAJ>rMOfM%)AXtAY>z~Pe#LOJ%xyT+hg-60 z8dYy|I?kpuSy<~vOKR-W54Er6vkz#QQP|stc zw(56c!wTz!o#|$Ly2!{bwdmS%Sg|54yF?!xZqi%6t??Gy@X2TiF?!^~xnkM699#WWNax08yI+|fdTOZ<+eZCHVuG5b% zCj)NP&W_vP?8Z%1u3W`R8WqA735qtO;!nn?cf5P9wQ0M5aSd4qt%&2(I}%yn9*BDV?jc*Hv_Ord=SI8=%cf98&ut^-^wFLtXoG=g z5v9SSx~JoR^+)~_)pP3+>onn`Y!8AdTR{jWspqERIY?>oklq)s1~$>y?~Z_PY(2Ma zS5PAC;}pR5VWTXnEPN8dczS8d&RUcONf*sdOs^;TczS8se5cNNHI8T{@KN~=qP~Pn zY`5Z>jHj2DFYu{r`37D2;)ujQ*FQK2y^!nKfvBl-{pscG09d6RFCraSleE6;p-(iG&F(`uh3z)k4;+QQ$FXNv z_irC(-5)@Q=HEURdP62w+!wJQ@O&{?+*gHkNo>xCe_a_n{v#IayNVs4vK+leiQ5N% z())scKjHllXRQ=Iuz5A=unPJ)wb0KobN!r&>SAQ$jD5GMr+iN`^f8iHf40z zicZFE)aU2Ti8>j_EQ(IXhaXEnz4PYp|9;iY|MvTw`&K=r2I}Z1UIBU+9ddipJ zTzfUjXMcF>vK*9+@G81}Y$xHgY)N`@o5{lU2nfFyXW8VR1pn=(B(8hWX;SnFPN3Xv zDEDfVd)0lbpInV{-#M_VYn7DQm4Y;@Zc2HQZT;6}DO=^UsSb8n$@hOP>oceBeOl-Q zMnQKA;`ZHd;rWvO%39D$tNSq$eQpJPNFRjC_J*KO5_CUQoe-5is$NK~jz(JD5IfpV zp;7vTXw-Lq(vy2YBhov_xmBSN#(G@mgYrI(I57>qNcZC-(8~vU)q!4Z_tib=1HHNi zR(G{Y^SV-zrtRibPOntZ>k81zhjwaunCNxI-j@Zv+6BE1f?iYxq8HZ}LElQ~e`wzq z;rFsBgSW3)e&B~r z>F_fV>F@%KYwDAj;EG>-@V2x2p%3vl)9eTOF%}E)8=P(udKl2h-wIty9_D1-q+`~EJ|>wH^RzrDpH0Z~ zi@{Oz%oKQz4h%MemT8o~8TqSuQoZi}!(cJxxt(-X(5EZ|Rqy1S{EPKTpZ`_Za$nL_ z=Bex^tc%cVQD|X8+fce5q!~%)Ozj7Q50Y-H33HzrbKn%r<5Ob;(AlK9b-qn;&-`B&+hdy7g}qJxt26AeUera^KRxqXd+cRX z=7|c7JM^)%bfh_EHt!uNP7mUAEChW)Z|WaMp?|9O0n%4JD%!gjee~0Xv$_X?_Z85X z;!2{g^djxR<{H$i8uhD+os}zNsBbsQnMUhS4t}Jk+k5@fSCX#HpxJcz^PnmAS*saw zry$K#q@4zuOvl`cg)FM z7Sz$(V0qGmb%*Bi80J9l^sBm-K%Xy(!fsO52+VJdAEGTzp#405l9I4ic>bF`)|Uc3 zV(2C}f!=39(|+hl_L?(6-}!NRmtl-$;yA`Z1Rm7#Wv<;nkn`7Zk3SnT+8 ztbsRCnOOga(7B}WZ3y3m`6>%(d(m(DOqrwBdlBkQ`qvie>Mp8-1$9VUFzYbd_pqXS zHPi=o!jIaV^vk2@NdC?k1b7KhfUe3}_{0Mr4W6}DXd#r`t4E~u-+(z(m zEB8{Fa?mccCVKgrmC?2GKFgCoM;XD3r73+aJzoD8QFo#NtzXo(1DjW14Bm*bSc5TH zjj>r3`#gwoY&P+I+FAJ}geB^R%go$%C;HmQ7_XmTyq<>sEVTj62Q+US!~F4E%oEgy zNpIggh25kLJ25!;Kd{G`{TtKVwl>TuTK(;_^6NrJ`WGmRgfb1EI|r+>57S(sej7k@ z>3qxwci(S+JQMV&aFssUYaUwDwwN;7py$|l5aCx!^A7dhpZVlfnEz=n{Fk8PX3*od z>Fg9koNV(|T}P32w`uu%*@J^65tNhSl)%3i@s6Ty>OARap?rqi+Yqm5Vd;}c5l@ZN zM|zT&Tfvv5^?@cC_t_t(HapW|c~ZeQ)Hc+oGqBfOV9w|wdZ{=FFOxF5w0LPA1|6X5 zoH+k})>?t~`+H8qKW5!Vm9L_;1iuUNxZ3^xO}LmWF0rPY<=cJkQ43>rI&uF~H0%u_ zk|XRdDY7a$XSl}7R!o;}F{>MwSXqP3cQPx#vmVz%4c%PN^#n+WW^MLHN#0rRcZA#M z4zm_6TN~yLAtK4==&mZGI<|vs|w(9e3ZbCYt5`JUSK5;HcXIY=n)!vgqOS?q5j6fyn5VHhcV^9IqLQ0 zRXf6W^dbKg@LoU)-w235Ym;zS16~LBwSW{aP593O#GjS)daeUJ0!ZoK z0;G7a1LCi)M}g)4e|z5o9#wVkyY@_g$pZ))LWqxqhc_FzcH=|NUQU?e*Skul0Ye7Qi_8HvrCpd|;gp@9_fixl%yZ z$0}!8ia^#k5s=|aK-4831}Je5)*!Ez`&Gc{@Sm-40XQ4@QS>$QJEFsT_5w2g^MHK* zDTVLSVMYZY(_f+>?j_ER1pN;Jf_3C%I$%jjc|X967Uj?MX27560ZMtlslXe614?;6 zbbT&pAe3X2a(A&vFAsP){Bw0E<^7ls%fa+qI+XH$U~x)$KR_w(2YQ{9_XEs`MLh6x zq`V)~!&qgyQr?d^@{~9shMMHEp(!D+cWC;N>5!zG{x1F-q8pAe$X*xR5Zo9%!PXIc5dV!~yTTw*7kvhC-;O?x|0B^y@xM2E9|{Qie}H@) z_VZFc&fvFVZykX5pEHc_=*H1%=x4B?>I5`kNb^oTD(o^;>F#peyTU8Qf-Ck1gC9;D>Uwkv$A z!j~{U!YMCH_XAwS@0OSlza@@1dsKNbxA0Z+J0}n`e%uoNM@#r`E%*Y|$Nc;?mhhdH z@HZ^sm~I1}XIkd;!YuJ;T5t%wcxx()t07Vbc^3)YtNR7XQUcfINvSxR4dNbEHCu9v zwm|yEke{-~OJ+Q|!N0Aj+K6P9J@JcBC4E7{Re`cplq(ucf(n8(xeIkl1jMV*6!`hY zs+9DTx2U!pQb%j6D0WkMpA>FdVU_$!nyx&Lh81D@e9}m_=B~`j z%yqkKH?DE#xC6%w`Lo@LX>R1Z zsjA!qi6~3XNT>0zQd85F0l~r$Q1|4%9;ZUq zmTgAAk6;fh3Uix?#RZ48il{SBT++Ay?_ijIpT=k4415Cad8yCEsO6+M@6$0onIuGH zX3=ASoYe64ww9`NxUQh{;af?sC@d}ptIkMrLxn0m<5{jphjSyz4Hc>Min0Et`{4-b zB_j^&qBrwExuICpD^`Uwo(uR5fQ|Sta!tz5Y91~+exi9H3Uzg;7xQ^sgjp5{3G5O-iz^|zhQk0BqN{pkfy zZm2MWKhpvk750_U$A*6w$~4o#t)cHN+9p~ow&w?R9>^c3WC3D=#LJy_U$gbOQ{c%N z&n|);p=Gx1`OEQavG!9F{Q-%NU1cjKlJ!;IO!`H<}GPH1|k^L4c&6pTs7DboWCF!rmY0@y-$jQvhcH zr=DsoU?(8*E-Nq66@a6;1G$sb@m z(v=yHknzG5PRMvKVj;==TL78=Gdh&?eCA&VocV7BWd3o0*dI!|JNEV!+$#d!!@VGA zx=sMn@twrMpP3Y7kGeJ%3hi2p1`@h$mkoq=+eQ4pYsXdswlMH#)=QsF9Bs3dfpFIo z$5p8CX$UjpQ49x>z}OEv7;eIU1e|o`oAu&t3uL1tB2*N9wDibJbPQei?KbY5K-yFZD%qII5CIHFJmq^yA+X;P~Gh<}Zi zzIbCz4b--CN*Jx$D-(1VdA&6tLBb@xdwq3Hl^3TS_6I^kn9^nL%4&petXyA2DF4kh zNSeROuzr@2;no$j6xc`ZT&=IA2EK?6mE9Dl5)hTbO@2v`KX~$VLigYuyAaL40^Ot& zy9d_Ss2rIM{RHsA(@IWO<3g!$yYH*P{?1dA4VrhNJ8cO%VI3-$Eekq$5(^f~RvGX_ z16AtSoILl)St3QgMy3xtICsY4g5&#b%ze_s z@)xD}b8%|EHE@GIOh`9cJz8U4WqO$EB%PCGz=ZE3Fr3>x{CN>ZI)~H)d7!^CJqMWN zgQqvV9_<-E3|FlK5rvPjsM`gl1vr(R-)|&6+NV@u_+6*#P~|&{ z`Z6s=hL&Oc8;KsiN2O=X?Uer*&W%J5Z&m44se&9EA-!Z|#$4&Lb~iw z`gx=SgWh`8uk2zU^%@=oq)t*6>=R?`H>N`mZ6U5M`?SMR+SQM4%1W(w*r(OMXM@de z=tM+nS05%_+t7pfEp#0~r%iG}R|NV8PhPEEUBAsH&OQ|Fy08m68n4(Qmp>ovy1xjz zY0P&heL{!g9ZZJ~Al9OdOHl@g4LZQM$JKJ-8*bBiWY-?vYlW7LCbGCynl=cxmS_{b+^xU%c%=w~1xUj9&ZG-%6uU2*2Z zrS}|5Z8Z6&*OBGuKwd^&7i|@-i+GNJ3dhiuybZ=P{74(eeS|?HH;+Z?QrFr*f2IxP z?K@yYdHc=e%fvQ3$R)LGSG*F^9zJbKGu>^UiB|NVKhJ)9(||-?cF&g_Ajs1$Jv19!%>CLp$i7j3(*0v%R^yOU&Cp{c{4ku;Iu>Z<^ zNQW6dKx_eXNgoHy@B;Gr3P8p$1;my;w?u~-g@B;>N;|uN83n)@KTn4lO94R#uUMkP zdyZgfI0N`09o|y`Nc#5@1+P^QYh&qu5Rml$y@2qO^7MciEz18XK+xBvJU!r`^7Po+ zN_l!fDNj%SLLQ#O8ePiM14?;%=EHI@J(mupJU!^~Ql1`gP~H9`~_KkK%d6 zvEPt5Auf|V{obH`uvps>bP)gNf_m`Z6?7W^ok1t@e>CXrAcwSn8)QFdJ7hZo(*H?Y z7a0JyPW(S)uY=Xwi#8wr-yU}yR&QS&*Ma|i+F7gEm6`sKOz&Rd}_+rzrdpg>yVHKiV0kS8+-xE8xl9@ zIPvOvBfn_ksEa{I2_=q!Y0yDdGJO+1j^P;423^C&a1-8x{Aq7>7YhS-0vGuLJ|b}M z;XcOh6Aq(3@T2^Dq70R~HxgH<_-7U0D1WAkf47Rj_Xj_}`){~2<0QM7@$D9TuI2ei z3%=HZw^?wY0qHv|;UKdPBIf>AluRGOq^GouxoXS$HB}Y)p4zGkD4LU+yE-XFhAak2 zHIHUjvyDPd}>y-7?>Vm}9cEG{^FvxLFplOC48gPfS* zj`JBf1ILsh<*3y(fPV6NHhX0h`DZ-`*$4?&H%t#xk!S%?lMo+CAA#XUqVpQ*sA7pO z3 z5QpI#VL(`*xbo~@3_h^Oh(}|@qc1n{Xo&fK9<0sOmX}pKH&$+}g&`YVPYs-XXNjj8 z)@5!h5Q}dsD)-z112eS=CA1F@6}BR8S@FD*vYLe2s*Nz!o-n`whM96SM9zOTve6Q; zc;#(*Il0;HCE2&+tjHRytwDl;oLn*mvOABq*Gv#W?nVgusAaOy`d}nIO z+Tyg-l$jP%$vi8bP*z!7v9Sd9fpSCw76=J4orHO+tu5ZF17ycog61XQ>k5VfWFM?a z8373uRqL>*SvMfU8ko`d!mg0M=&=M3(dm|0fk`A-)|r7N@>h9@Sg|BK>yBH66H*zn z?9MWsHzE>BtJZrGHbF|G#FOAH^;Fd+)RvUjCY0rsR#kddu1Z=kFLfS#5;oS>pkBpA z75yzan#8y?EG~sHr3BbW-?$ms)p~KeCZV{ZsB&FG(tO&38YD@*EsH;2IsFx1IUnzX zwj zRb8|JcB`tZWOuOO>tRmIyQSLWEUGA5SDBHtK;W@rqzK^}XGK*NXP|zM+6u0UbQ{rF z139@aB`g6n@5jGI91A65z4sLh&V3~J$Hw}NYcDgN3!M8`wX73gQ#gQhTzm2II|>iE z%j9C3N}Q++1T01`_am1qZuz)yoAxo>9t^VV=emEsQizPD8_CXmQeyf7ec@jiVmTSW zEv=pfipdOWZ~ksB+GF(^bNzovF2Hr%`d>zZ6WnIC&^K_dTX`AnI@!=N+j z(*hw@Rb}bV@o9EAUZKca)bE2YM`l50Kc1-4Yf)Z%Ea~;f)bB=;yE&@T11U$kr4XVu zJ8Kt?BzJRMrDwcXc}S1-J%jdRT1wo-pT$V~u`ZRK@h%lw%I8l=-Hfy!JEhX|se%-Z zkX|y<<-4!9!$7$k3Q-rTaK^(9%SCLiaNY@oC4IHkxg? z=QAQmhqUp02b-K@&e*yZCowzjIGcNB5m)b@B47)gd%H~Rk&EZ~*pT;OV zZBaWQzY`^8QwGExug4uWPRJyq#F;mW zILV`kvtSf)ZW=|LuMLa?>iJu8JYgIXMuv!ehhQ%|3U-a}!WfM3J(nL*Wt$H@@b(`9 z_u*nZzB~A=wF@<7sgn&JOZ(K5qBSy1^iIP#?Hyvw#$n8!u!+OLU$L#?n8ozj_zvW) z^zo?6gBfGih}#-4W`~FqFlL8{6EJ4|vS+Fd4Bh((*w76h)`l+U6KmVu`G>w&BH(`- zwta`4vnIHX_1R(n-5%6-9`=gQL5`5R`n_nwYYIivZs^-bhdObc-l>Jw->!u&-))=N-MIa+<|Nz1`uDUc z-N%xenqzI@-JSD&%`pNt)+dUBk7wYV0WGXs!`#QRo~+hm;EF{L{eZ8KR+>i&5C6U}`n)4_Px1rfgD z!Y?4xdBPsrwhXePeOgGj9rmR^MwyNx{r6xO|Fxu@&5Oj8?xe(~=Ck&swj`T?-RF?D z6H{Gjw%@zcupT|ONZ86h)}Cq>;?d?ev_`bOIC}!+hE2F8-rK@&zmPOu94kY;GcQb7 zIPXH=zC#m5Q^cp=d9ns|S%f|d+rGPbH`?w5WKhGVxY|FmhaJZEBjPF8Eiktc$2!M# z3Brtfwn-uIC$x#n`7GK#6y?92X@uJ*g-o41F<*N?P#cc^H;0DAxsy=z6q`-%rC>vU&GB$pDO$9oMU+2!Ze(RU_Mu& zRGq6p$a7O(|G{X~~&dOS-seT;K&S{~l^QpdxKUV7<4(ewtckEe*UMX+zb z4dbZ@?=kk=IIkeG``@?!T{G*DW~#%TvJR2{I-J5e1HT*}8Bm8D_!)KJH?dHi1JokV z8tSh@G4g^wBT-Sp#$I5r% z&6Ypx>Y4kn=y4!)RxexEzrgqOjSr*AW^*Rog=N{y_5$E`{syxkQX1+YP zr+X65=!vR-VNz&)Ir0rdz7?i?HzMCXlOkol|A_Yb^(4yKPwmFMSg^+yc^L2Gu^5b- z*YQ1@qeUKeW4z$(-U6Q85^5SRaau_IFY)}pp`GJ1MHA*OIbLGbc!}qjF^!i8F|UBG z0DJwx=)=gi9JFb$Y}2W-J%+QnKivDq_Y8A(ZwzGCx%PkWanX}j;%bexM;-3M_pub; z$EEx}Ze1$RIa$c_I=+c{{$Iq|=TIij&C$5{4Vx*)f+pt!&K1UZbfImr)*98hRDZyH z@;1tVHJ^S~RK9-Z#s0Of)_9a@sPmyNU<~fY*&wATliNPM`#F5qcG{u`jsFdtC&jRM zyl+N&H+?(g={xYw@jRzib;eZd_^I_bW6q%8PMnVw`{2?G9MgyJ?l|CAit|A@9v=)5 z1;{so`Cqf+hs{4k+UB)I{I(qbIbZU;D3w0X_p+w{Dc2g9ugdJvhna^1dCYN(CO6u9 z7V7k&&G%%;wveX>JCCaZ=W%hKTZZ|levLTB^}|PVCO+MeKJn@GShp17Ot4Cv3HFm< z(c6ME%dkE>U}wIEy71Z6R=e7SCgf!MMr9+`@0#aPO139nAA?|2QA{ zY_W|f$FJ2Gue(bh<81%I`hCrrzJg|~>;Eh3qHxZaIxUYs1f3S~5^zbo7)n>=b{wl1 zYm0dY@&2aW4T;H{V+SkYl_+;G6k&Z#@?cEz;EI<#$gv#Rp0L7GRHGl>+E)^BmRQ)ZBPCx{r#_KIZ z5&s5+D6lRI0$&~bTjvolF0oE?UIYu2_(4G8*Fv*`_(?F+I;YSpHP~m$N4t|3Se@Nk56<(J^bm(o>QNEL>m_kQ zTqZrLgEJ4!gbviqyKq8D-Ry?h(7u{=8c!Xa^)~(w%{qer7iaCAbpYehEU(p09An0i&uOvX&BSpT z@;3i!!6VSW)StQ=oj^C6I2zWFe|Z9FGru6rQ*?>MakZck=yowZJa5R~JWky7d>wHN zTtoh4J8?`nhWv`upEBWF8IA@uwcaglnYrZ#PHIyrqhP|V&Mz@H z=qRng;S`h#kJKeoCf<_MZuL|`+|KJ^YxM6Mv*f89>tXCoN<#b9(qOZ#(y-q4#p;$> z^vQX9lc|qe1c?d~fa%wFV>^yErCF60u zCyF2<>-Gko`mri`$P-CY)rlmF15Z3zpo`Y#40(?VU-(69yT>ODOrzN7@`E=10-Or`fr?O zOEKq&SX^*eTljU`q=)72#W3BYj?cguI6e_vr}CRG-Du@sV= z2n;upe5sL+Y&1Ny2L(|{8%YGfk>pGB)WaF-spCe)6t1f8Pje)B(8pAI#@sy>-`wHc zNb;ckj?-O_{RUm+28t`QH&GZqFr*S&9=F0bSeuJ#yBD-VNg%{j;j(Eb^Dm*=l`HJ!I< zO`U-FBKl=(HbFk%`;e`p+Gq>=(mJx4Y601wsDg1Ip;= zvT0p0u&Wjm+bh%Q)a4$@C$;8lP5+lSX2r+9eBBht|4iv#>}#+1+0Ojju;*&Zza92; z#ihbum0#NStMcg$q5qbg8UN-@uK14@*;-F8)LKu?6|HD{X)~<*8Ug!edfEPf`n_YS zC-XaGst@>31L~vKqb{FudH+7r$U+;P=zApp1Mo3roxkf!N57_EAJA9_9^fI!tD>75 zXP{4Fh3`qm@qtfu0?z}NX_Z2*0?+L8PIC1Yu#FMMc$Br`{Y=C&(l10DBY#5lr;-m@ zjPqQwaGpzMYmfWj9@@Vftp7iOoSM-z zA+fGj6k!{61uDz-fBnYTRAX$aE0;}W)ps;l26EZ=C#t{>oAww>Y0Ng-pI0w-jHP5K z2LIU=Z^u|lY}(JSmm7}#7WO>O9xT$ad&)hh!wm8l$hUbF5c{Fr{W{EeMi0+@N{1PF zfQ+|P56@kq!;CaQ?4jiOB;@;?!~zq#DNgbcun&{xl8}G27dZJz&jW&-o7(~idcE^0 zKx|EONp}UzXi(wYvmsu_RzTAGO8~Lfcis&Mnz`gNph}Xz0GN>mf96B_GQ*<)QM6pr zp8+$lKak;j0m;931`xCYd2R{g`+y^A?p7UUlmarn0zl@AvdDG=Ak3Ix6R=vg)rZqc zcG;ft6We#$pR!M|9Sk~zKpG{RVB0_LRs5eD*Q0O1dH%GSzFvX!NIy6B>suHOoO~m? zdlkM(;oRerKE71p-%#QAE1Y~5K3}i!TH=Ur&==T_=KSs;j>;MPd)f*!BlBy3|6AgW4?nsVT*R@*m2S4ew<`RHz)i^s)jxV< z{Bxsc4u9E#@37$iVZr&V`S~CqW}Je2X8ec+f7F7vTkzK`xNzgxV{h4dPu1FzA{@F6 z`r?9I7<%y3ELc@qRO6AyesJmUslhQ2UUyYB4GiFHYS{iVVx}N`Q+662%k^yXRN%0T zyyEPn1p_ul71DSWa?~^M6NJ}RRg<`D5P3@~YPZPKIwY-EhY9!EwG|s{OXVRP z@Py&8e#&nDok8nedtVLIvITc&tbsau!(ucHp~XcoVX@w?hVNOw##2(_DVe96I59WN zV3i$}tuDRx{kFMuOI}Ix*~H7HOD;@K(be>03qyF2NR_+TYd+$5FuEk?QW1*_4s(s) z2BGw@{815x?zkHF25!(L4f5D%>5{IH{`8pSaZJiL&}#r)Qt8pdz5|_AkKRZpT!vy! z$47*7q2VNB(m((~_rq}Y`hBeF<*9PbP%+|2h#SU@MCaU~(lh9sIhOQPbNX`_iO%^G zm7ej=f7g!4o%4|q(o06Vq+jag9!Te8Ain_&XS_80;rk_eFu+{! zG}Afpw49IWhi{%r6E~OP2BU5L96ETPfYx-5^hCRDPFk#J!kCDN0v+f)bO1Xb3qKXQ zUw_xvx6iS|y?+yQzIkRa^{kLJ&QTUAhoE~M^KETI5p?Z9pMqYOPS@+a)R)=`dGf*N zUQecQ&hUL&Es_1s+14xNtlP49@-Km0~({mpjK^!8P*_7%|Koh0_w zRE_uLPX=rR3XgmZzG$4-a2s?>;)BKQ ziyjmky3ht~pY??wzPBuGS#W5*ON)8uk9hVhWak%Ylgc}w`?yF8FaOZ?!{$ZWM&b&G!Cfw)WCD;_9c}AbKy(WV>G5hOsQ^qHKE?`p)VH zM#BI<^x*(M-ynWin{DWt1zNMMuDNTLz*!#AZCDFH-zU25{9MtCVXxoE==uxNAM{}? zCv^X>h~Cra@1fdrFY0#cg(a==&|U4G?CPzAu2T-~f1w4}FPrM>jn_gBe+Yd+wJ=fK~O%ogDOnI~!c{j#FcZ6qA#GuXVp>y}q3!=5>r&{YDr-|Nc(Z?6;jyVs^ z)S9M&W{h>7Jf8yV(#BV8`j~!TmS|c%L7eS`4$EeY=}6$D*V5*J9Ls%uXeZ*jF1Q0_ zxadetsg;ulI<6Vu}lBbtI+OW0i2`tvM)-L0YU#SodV*Lq8@R`+*my*m*W zm#*;DTJNO?mgC)8hIcFn@7hwlbGNo$th%KY@1W#|40RriBM9vWz1M$~^9Ro)*P#FI z%a4N27~dDi1Xp{M-BFJ@WJ;ecvb>P`XLj&UJYvHP>ffR5jdpMOtS@!?Vpsc?&-zlP z&H^6=?QiIBcR&~Sl`O3(eS+9Py_gTthW&HL5|n*0%ASR?XQJ%?D(Tz~;Y#0w=i>s(baC?gUyGIn=^YOC%W3-k@eEr9C?|&(+VoELfS`apgFb=mYY*$0Mok?(-;5VSM((&s`S zx?;NP$%YxfY`*O}F~xmJY&iXT19XJL+LpZredE4G3xhS$%evzksUH_C0-vkX9Bs?4 z6HVoai#hYjN0HVxExP7I%t;O{qz&(#wX`4s&TM&IfrA{ zE}9@`*Y?=djvmwyt2GV^K zdFk~y(%gi4?nd2MALxL$HN4goJz2Kx#QFo8qx<!>lb0HvmDN^&)EA2Xd_pr28ebd-NXr`^|VuCKNJeLJ*c7uIQ* zV{Djfu#Q+6gtf@H)=P^utVQ&>kLwUykXTUqHOv>7KmG~hvNS)lck`W@z0=j)k-oTp zO?4CI4(QYEi^G~K67%Hctf@*duasR+-E(}e?K_)=7F~}ueZ)rLknMqGX@ss{ub;Q2 z)@l2?sH;ED^(4NZXI<#$Qt(+z!J~#$V*OychN!pv2CPB*B8`YX_65+wbkoi81@v+vGc46nS*TeDYZ=dT2v<_cs4nzx%{ ztiUl=;FZe?9M&vpf_xA3e?Rf%Pvj3?euiUA!!f4e7}IdTo{IA!@Px?!D^+kW2!h0^k45-Z5G)9ye672aU9}+O* z3~(fsds>GXpg&=2BXY@?#4qDo{PFoHKDx82=0)ij;d= zhZ&v9pZrM_E%&$%Gma?#Lx3n^?m-=9qyVC-xrsW=co(Z!@(;w?I%s_;WJA;BbQC>4Y@Ot9747&S#3w|H+ zBY)1Id(UP#@U7@4x~U4^q44nvryd~PdDMgPf1vQ6Df~wYpUn7Z0E14?_B7}BI^(0< z2A%vFq-nX@No4!>E|*WokoXXi90a3=+adDJvu6Wpzsux{!ii0=MDE=+?Y`D9Se@#$LbYH zN&Ypu+nYRJ8YcF7+@8%mZQfG?6VyD%hbO=1=`6p%2a@%0ca46~kHPa}{NyCVQ^lc0 zZnsw*QzLnJ#p|mL&Yp$OrqBV*HNCJ#DUF1S)>QC#p4I8zqOuD2eMJ@JdCN<2oIFY~ zu#!nBfo1leY9Cm$#KAHdJZ?%^WtlfSFDZqkbC-IGO29u{kg{e~l2ma>O2&EhCGL$C zo;69yX?c3lSS}S%J0KviS5j(WyF5Fu7!{HnK%Dmzc<|4FeoRW{BV=cJJ(Vn3B5^Qd zQ7YqMzivd}F+sz#9;qFXq-#e={fNu57rrnlG0o7AkOvE>!DS4dngDGHF{r(82i|WH ziwll?$<27jhjRa^7=Lkm2F}0@z9b>tX!(*as`r}dG23tu-O10wWb7j_oZCITy>Ndz z>H+FEGrgnuSjjRCNUvmA``*V@xqL<#XVp%=t-8cSiE)k2Pgps)H#XkX3T;yv-jhHmmfExvj-gzW$i{-AH`S zEh@cARgh;#NN*%QXE^HRLe^|2@;PbGd={V&NGv+W%;zKzniu!YebwoA_)jXsMleR@%XSQ0HTk2et3BzI`VC$iCjH z%wF(E_Vtx(y?t-m+WU&Z*Ri|O!IxTyHB94b(2~hRTKb4c>$i)k+sCW}t+9?e63{_7 z0h^mP&`ep5PSC^OUmV)>!TivsllD;XUG#8Kp!3t`tStssx|0y^xHPPfP427q9?V&)mmtudN}UPBX2n_KKpyewvOK) zis|&*H=IlRhS!ES#bm^ueGYV5@`T9CcHp`ZDI18ZQ4XeAcDtTW*|^XF`Fx6Zp$_T( z0QMf4*PU#a7`;zyu-p0}c*9$eCwN}H-Q`+qT+H+UyM(3px>~zn6E{YKePyFxF#d+_ zmuS6T5QqK3JbwebliqKM);QHC-Q}6Br|~YE>)r{TQ5S4}TI*`ql8&P-Y4Cv^u)WAS zJ5c9{8R5q;7LMsWhiU#XvI~CT)%xufGM_r+y%cp^j=FY-!rrf`p0qnyns3xILe>+u zv)j(gdXkUcpEk$dJGlCAE`c0>s-Cn#*gY$=bt?Fj%rDMlhj6gAUwp2wpv&iba_Kh5 z)5Fy{CnmZ+6*eeYC)T?c@5{Y+1V0@WAN;h>7F93LIe@zTvK{o zXuU())^*h9pp38Brq%y5c)gs@4Os!>9b+1-;{n?>>K&sE$6GNU+=6)_8}q{w%oB^j zU(W&`)h2l>#=JCW!?W>PqC;47D&H z4IK%e0O(^?P>&4o9?(NXEc~AW#82vv0cQB{ z$9%ki%;%ec)H`zll8;6GFwlpb;ea4*K2M1Jq&+$;n4Q%B0S(D{4y#o9pV47Pm%^$4 zGXvqT0g}N8_&+r9$ixY@S0{Gh|HX-Wsk$%?ooeP+y#qh;hYWh> z{~(Syvh! zmiXr^;meV(`T2jb;2SK@ms#*@E$QvGgg;;j-(X39rzMpkm>t0mhjIjL5vxar5KQica7vX*)Ck``F~$=HE$EpzOODzJZarc><@PYi7eE*VO9 zw^fyZdvse-b@nDEzalAhaHDSS;>|^FbzH97VtI`^bNKn$Z|>E(Nek9l(owe1R!OE` zzRuID>1Wk>i)zbJ@3mDdg<)Q8en4HzY9-qV=N%7HQy5ztC7%yAr)sO*x;Z%XgVb6Z zj3I}D+?u;GD>K*auHCrCo#P&Ap{#d{p3qtxI&R@Tm6b_VL>VhRph9SVBd(Wx#zCqz zfb*A^msVL(R_g`3E$`mEyiqeDCI3)1a;>Lg?ci3`lIG`ctfT^>J}3f>wPgpGRtsQg z4#%YAiNHhKO=Bo@4+E7D@$OvidBG{ki55M?K{^6V!0PIGReWcydWrll?B@#`iwh3l z6v-2p$RK|+!gNo5kLe5?Q>NszVQQ9cwEVvoHE%LKc?-skWElj)UgSx|At)AwH1n(QxuQ&peAZ}3 zpqJ&${Hi{CDELkGwxzH&mhGe`HheT=Tl}%?%>DL|j@2jeOafrjH^o&kwu$8{ z#pL=MraaLc8fp^odTX3=JpFtPZKp(Y% zPHG3=1hVyZ4feV8c7RTzJR6es&Q5?g1pBv=Z$W-Y*Fwm^LwqJe+uht5EK;GDBI)a} znw<2d(f*`k zvkx*&eefZ40oe!C&G;w_x)4Y=6n!B30(}7gvz=)FH&eI8A4U70Mcfu#6Vd+RXn&j! zS^hhmAvt5pW6c8X9}YMJ?cb+`qWv4u{tx2p#Hk^RpgYy6-EseeXnzdj2-X|x!-(I> zGU@$rK%3UxYKxTpplOfEei*I~*d{t3Lg{Iu-g>_T>Qh~;()FpXU`hO;CfqUV?ih9V z%T3)Ka%HF%B!4k!{h?k_tcjdB&&8>WmpMy3)v&1ZIZk-_%RDl6j!c5pfSZL#J1-%S z?r#aMt=d>q>`BNfDlYd_mL%L!>#4zl-(!gtm_&kQ-{#B_30S))EU8+vW{Yvg3+?@{ zSdyJ}$E_GoW9P`gGF2sr$74pnoNNEqhMRp!;1-9BCQ*uuX_(#wRiyn?qw?wT|H?{Aa)^ zXMGWnj&kB=xohr=5bSrIxHIF07QDcM=UVX97My9B(<6PR$V<{ipxkis=Mc_zuisd? zz6es;>*c?0ZK=4Ts@8AssCYfjo~rbt10=NFtLG=1KBgd{Rn=@UC@4xIoYa<5oh*`^ zYKj~vm~EoT1XgS$Qbw{AlQzsHNqb33@K}l;k8H<%L82k296$<^W|Iu+#6bE4_r=ul z5V5%6NS}Zl;*jnriPCUsdG=~raSFW?7?2UYqAKG8(j>!(fPtJH`)T(R_^=F&voIfUGa4nY@YO| zH6;CE0&KqTuz?n%%l%T$@>Ar|iA#h1cGPhf?+qDU-qUXn?4ifZ5b5ZO#&`|qYQs(k z=GEs4*cbT~SFcXTh=8phosJ>wJqt;n z(CHXcO>~TDZU0Z%VIm!)19FBeC-?R&3%&#U97wOu<~YnV%D1f%>0a2Ese{cU_|?;1 z4axy>g72!?fHGfMKRsX9ANv-P?lLgn$N>6@xX@QXSwPCeG2g%I?c28z`R%z8_Eu1b zGUU4t`32++n>a`}zYcU3S(fWjt`K!U6E<}wz()HH8)!v?(oe zhp)j)D91|&c3j`nQml`oJv+YDgSqFGQH>qg0wJvp{K|bSV<}`5gJJ6?xU8(lp#>kN ze5_F}ltYe5jx7hqR^4ZPudqF>;RDO7WXh2T+MqXhgRA#Z*tE}qt(ryJ)!nB+JGv2l z@_Flkmsi%onrHR@x1J|4C`*e2Ha4%pgpOm(#%LcYPm z)jLN79}X^brRSh6gQGAGAREu~u}PQop$t54-l6QA;9ZFbjzApPKAHQPd%s%?m9nI? z9nv4i_f`&U(Uc0j_pxYa+_M~<59Z8p#naAD{FIRTq2kO_>?SOKu0XRAgMF39NLXVe ztS>hSYp}QCFCv^cc*bMosxiUDF>=+IVB!@on3xCJ5aso52Al#pKc1Hhc+YG=>=B&} z*jO@rGa$pEdnUsR0U1vF4Pdo9DcgW8r#z(!7KM| z;DFLjJmm*FfKy%&i+S?#oh^Xa(n@;|vYfzWIdyms_qr?xfQ%PpPrKP=x4*3&*WT5F z?B{}df-VLH+4qlsb$kc+y&oYnvmD*a@FV@qpc`~C+=RbR9BpCH0q(Kjv}ZxNIAdSG z!h!>p^tBYEN!Npm^tO`s}xi$(xNh!9Xl9C!JgEmM6$+@a7 z@|GfkPLTwesTh<=U33V=cB_>F&nbs7g5pw7ak;zFPi<6(eww7r0Pl mGBPcO5ShpAFQjl^CRsE2i;xSmN_ Date: Tue, 16 Jun 2026 07:06:44 +0900 Subject: [PATCH 10/51] ci: bump actions/checkout to v5 and add dependabot Squashed: see PR #7 for the full message and Copilot review reply. --- .github/dependabot.yml | 31 ++++++++++++++++++++++++++++++ .github/workflows/deployment.yaml | 2 +- .github/workflows/integration.yaml | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 .github/dependabot.yml 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..8fdd199 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -37,7 +37,7 @@ jobs: fi - name: Checkout source at tag - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ steps.tag.outputs.value }} fetch-depth: 0 diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 781b591..d0cd305 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -71,7 +71,7 @@ jobs: steps: - name: Checkout source - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: # Sources/Common is a submodule and the build references files # inside it (logging.m, binpatch.m, ...). The default From a4a34ad657882cbb734d5916257f7de5ab4a0667 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 07:35:25 +0900 Subject: [PATCH 11/51] chore: sync .gitignore dobby comment with IPA-Patch/KiouEditor Squashed: see PR #10. --- .gitignore | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ff4bffa..5b17844 100644 --- a/.gitignore +++ b/.gitignore @@ -273,10 +273,6 @@ downloads/ eggs/ .eggs/ lib/ -# Python's lib/ rule above sweeps up vendor/dobby/lib/libdobby.a. Re-include -# the whole vendored Dobby tree so its prebuilt static archive stays tracked. -!vendor/dobby/lib/ -!vendor/dobby/lib/** lib64/ parts/ sdist/ @@ -288,6 +284,13 @@ share/python-wheels/ *.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. From 38527d44cc57e4c693d879ab97fd1c2c2eb41c80 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 17:38:13 +0900 Subject: [PATCH 12/51] chore: align deployment workflow with integration and add dev hooks - sync deployment.yaml with integration.yaml (submodules, ldid, toolchain, cache key) - add control-vs-tag version verification step - bump GitHub Actions to latest majors (checkout v6 / cache v5 / upload-artifact v7 / action-gh-release v3) - add scripts/pre-commit + make hooks (KiouEditor parity) - pre-commit verifies every staged recipe instead of a hard-coded one - add _SITES alias in recipes/kiouenginebridge.py to satisfy verify_sites contract - add Versioning section and developer-hooks docs to README --- .github/workflows/deployment.yaml | 76 +++++++++++++++++++++++++----- .github/workflows/integration.yaml | 8 ++-- Makefile | 13 +++++ README.md | 40 +++++++++++++++- recipes/kiouenginebridge.py | 16 +++++++ scripts/pre-commit | 71 ++++++++++++++++++++++++++++ 6 files changed, 206 insertions(+), 18 deletions(-) create mode 100755 scripts/pre-commit diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index 8fdd199..a12f844 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -37,44 +37,94 @@ jobs: fi - name: Checkout source at tag - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ steps.tag.outputs.value }} fetch-depth: 0 + # Sources/Common is a submodule (IPA-Patch/Shared); the build + # references logging.m and binpatch.m from inside it. + submodules: recursive + + - name: Verify control version matches tag + # Catch the "bumped the tag but forgot to update control" class of + # mistake before the build wastes several minutes producing a .deb + # whose embedded version disagrees with the GitHub Release name. + # control's Version field must equal the tag without the leading "v". + 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: | + # Mirrors integration.yaml: ldid is NOT in the noble apt repos + # and is installed separately below. libtinfo6 / libncurses6 are + # the runtime deps the Procursus ldid binary links against. 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 + # apt has no ldid on Ubuntu noble (24.04), so pull the prebuilt + # Linux x86_64 binary from the ProcursusTeam release. SHA256 + # pinned so a release-asset swap can't silently swap our signer. + env: + LDID_VERSION: "v2.1.5-procursus7" + LDID_SHA256: "4b8862b2fefa2cd7fa8f88cb0310779619aeaa8c72d6aff22f019b470f2fa99a" + 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 + # Bump on toolchain layout changes (see Install Theos comment). + key: theos-linux-${{ env.IOS_SDK_VERSION }}-v3 - name: Install Theos if: steps.theos-cache.outputs.cache-hit != 'true' run: | + # Mirrors integration.yaml. + # Theos itself. 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 + # iOS cross-toolchain from L1ghtmann/llvm-project. The tarball + # already lays out the linux/iphone/ directory under + # $THEOS/toolchain, which is what Theos's makefiles expect -- + # no rename needed. + 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" + - name: Build rootless .deb (MobileSubstrate) run: | make clean @@ -123,7 +173,7 @@ jobs: cat SHA256SUMS - name: Publish release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.tag.outputs.value }} name: ${{ steps.tag.outputs.value }} diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index d0cd305..7186e07 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: # commitlint walks --from ~, so we need the PR # commit history available locally. The default fetch-depth: 1 @@ -71,7 +71,7 @@ jobs: steps: - name: Checkout source - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: # Sources/Common is a submodule and the build references files # inside it (logging.m, binpatch.m, ...). The default @@ -106,7 +106,7 @@ jobs: - name: Cache Theos id: theos-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | theos @@ -173,7 +173,7 @@ jobs: 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/Makefile b/Makefile index 04f1c1b..8d218ff 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,8 @@ endif include $(THEOS_MAKE_PATH)/tweak.mk +.PHONY: jailed binpatch ipa hooks + 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')" @@ -121,3 +123,14 @@ ipa:: binpatch --framework "$(KIOU_IPA_FRAMEWORK)" \ --dylib "$(KIOU_IPA_DYLIB)" \ --input "$(KIOU_CLEAN_IPA)" + +# --------------------------------------------------------------------------- +# Developer hooks. Point core.hooksPath at scripts/ so scripts/pre-commit +# fires before every commit. The hook runs the recipe<->dump cross-check +# (verify_sites) when a commit touches recipes/ or shared/tools/, and is +# a no-op otherwise -- including on workstations without the local dump +# index. See scripts/pre-commit for the full contract. +# --------------------------------------------------------------------------- +hooks:: + git config core.hooksPath scripts + @echo "git hooks now resolve under scripts/ (pre-commit installed)" diff --git a/README.md b/README.md index 8a905bc..3618d74 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@

+ version + targets KIOU platform arch - target engine protocol side @@ -225,6 +226,28 @@ 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.** +## Versioning + +Kiou Engine Bridge uses **its own [SemVer](https://semver.org/)** numbering, +independent of the KIOU app's version. The two never share a digit. + +| Field | What it means | +|---|---| +| `MAJOR` | Bumped on a breaking change to the wire protocol, distribution shape, or hook interface (e.g. dropping a build flavor). | +| `MINOR` | New observation hook, new injection path, new wire-protocol feature. | +| `PATCH` | Bug / crash / wiring fix with no user-visible behaviour change. | + +The **target KIOU app version** is pinned separately in the +[Compatibility](#compatibility) table above and in `recipes/kiouenginebridge.py`'s +RVA table. When KIOU itself updates, every hook site is re-derived against +the new `UnityFramework`, and that re-port lands as a PATCH or MINOR — +never a MAJOR just because the host bumped. + +Releases are tagged `vMAJOR.MINOR.PATCH` on the repo. The dylib also +embeds the short git commit hash (read at build time into +`KIOU_ENGINE_BRIDGE_COMMIT`) so the exact build behind a sideloaded copy +is always recoverable. + ## Requirements - [Theos](https://theos.dev/) with the standard iOS toolchain installed @@ -256,8 +279,23 @@ Sources/KiouEngineBridge/ Sources/Common/ # IPA-Patch/Common submodule (logging, il2cpp, hookengine) shared/ # IPA-Patch/Shared submodule (binpatch tooling) +recipes/kiouenginebridge.py # static-patch site table + cave payload builder +scripts/pre-commit # recipe<->dump cross-check hook (install with `make hooks`) ``` +### Developer hooks + +```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: diff --git a/recipes/kiouenginebridge.py b/recipes/kiouenginebridge.py index ab824d0..958747d 100644 --- a/recipes/kiouenginebridge.py +++ b/recipes/kiouenginebridge.py @@ -401,3 +401,19 @@ def emit(insn: bytes) -> None: "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/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 From 3ae3927c08bbab1466aea8477828662ccbfc26c2 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 20:21:28 +0900 Subject: [PATCH 13/51] fix(inject-F): restore cave-bypass entry for binpatch injection Binpatch injection on iOS 18 was failing in two ways: 1. orig_*OnPlayerMoveAsync was left NULL by the binpatch installer, so inject_pickRoute() rejected every mode as 'no_session'. 2. After resolving orig_* from the RVA table, those pointers landed on the patched 'B ' instruction and the inject path re-entered the dispatcher (infinite recursion / crash, then a silent revert). This PR restores the F-trampoline (cherry-picked from ba1465f + 0259cf8): per-site cave-bypass entries published into g_inject_entry[] at constructor time, exposed via KIOU_BR_BINPATCH_ORIG_OR_BYPASS macros. - install_MatchModeObserve_hook is a no-op on binpatch so orig_* stays NULL and the bypass-entry path is taken. - Local Adapter.TryMakeMove in CALL_OPM now routes through KIOU_BR_BINPATCH_ADAPTER_CALLABLE() so the headless engine actually advances on binpatch (CPUStream's OPM body only forwards to the server and waits for HandleMoveResult; without the local apply the next snapshot reverts the board). - Drop an accidental double fn(self, move, NULL) call that fired the dispatcher twice per injection. - Startup log now includes build flavor and __DATE__ __TIME__ so a stray log can be matched to the exact dylib that wrote it. Verified end-to-end on KIOU 1.0.1 CPUStreamMode: injection lands, board holds across the next snapshot, opponent replies arrive normally. --- Sources/KiouEngineBridge/BinpatchDispatcher.m | 16 ++- .../KiouEngineBridge/Hook_MatchModeObserve.m | 62 ++++++++--- Sources/KiouEngineBridge/Inject_Move.m | 96 +++++++++++------ Sources/KiouEngineBridge/Internal.h | 101 ++++++++++++++++++ Sources/KiouEngineBridge/Tweak.m | 23 +++- recipes/kiouenginebridge.py | 26 +++++ 6 files changed, 274 insertions(+), 50 deletions(-) diff --git a/Sources/KiouEngineBridge/BinpatchDispatcher.m b/Sources/KiouEngineBridge/BinpatchDispatcher.m index a853ed8..57685fb 100644 --- a/Sources/KiouEngineBridge/BinpatchDispatcher.m +++ b/Sources/KiouEngineBridge/BinpatchDispatcher.m @@ -31,6 +31,8 @@ // 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 @@ -145,12 +147,22 @@ void kiou_bridge_binpatch_publish(void) { 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); + } file_log([NSString stringWithFormat: @"[BINPATCH] slot=%p (unityBase+0x%lx) published " - @"dispatcher=%p", + @"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 *)&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/Hook_MatchModeObserve.m b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m index 9968659..a7fd219 100644 --- a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m +++ b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m @@ -113,20 +113,23 @@ // 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. // @@ -175,8 +178,12 @@ static inline BOOL shouldLog(uint32_t n) { @"[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, @@ -232,8 +239,9 @@ static inline BOOL shouldLog(uint32_t n) { /* 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); \ + /* 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); \ file_log([NSString stringWithFormat: \ @"[MMODE] " MODE_TAG " Init self=%p store=%p adapter=%p cfg=%p", \ self, store, adapter, cfg]); \ @@ -559,8 +567,8 @@ static void scheduleAutoRematch(const char *modeTag) { meta_set_match_config(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 }; \ + /* 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, @@ -647,4 +655,26 @@ void install_MatchModeObserve_hook(uintptr_t unityBase) { (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 install_MatchModeObserve_hook(uintptr_t unityBase) { + (void)unityBase; + file_log(@"[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/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index 46e33e9..baf85cb 100644 --- a/Sources/KiouEngineBridge/Inject_Move.m +++ b/Sources/KiouEngineBridge/Inject_Move.m @@ -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,9 +614,12 @@ 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; } @@ -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; } \ + file_log([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); \ + file_log([NSString stringWithFormat: \ + @"[INJECT-DBG] local TryMakeMove tryOk=%d " \ + @"outMv=0x%x adapter=%p", \ + (int)tryOk, (unsigned)outMv, \ + (void *)adapterFn]); \ + } else { \ + file_log([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 */ \ @@ -849,37 +877,43 @@ static void inject_runOnMain(const char *usi, uint32_t move, 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); + file_log([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; diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index 9c7df00..e12eb6e 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -400,6 +400,30 @@ void install_GameStateStoreObserve_hook(uintptr_t unityBase); // 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, @@ -434,6 +458,83 @@ enum kiou_bridge_hook_id { KIOU_BR_HOOK__COUNT, }; +// g_inject_entry is binpatch-only — it's populated by +// kiou_bridge_binpatch_publish() 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 diff --git a/Sources/KiouEngineBridge/Tweak.m b/Sources/KiouEngineBridge/Tweak.m index 668f9a4..eab5d55 100644 --- a/Sources/KiouEngineBridge/Tweak.m +++ b/Sources/KiouEngineBridge/Tweak.m @@ -61,6 +61,14 @@ static void installUnityHooks(void) { // inject_apply is wired before the WS handler can reach it. kiou_bridge_binpatch_publish(); install_LowLevelObserve_hook(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. + install_MatchModeObserve_hook(unityBase); install_Inject_hook(unityBase); usi_engine_install(); #else @@ -105,7 +113,20 @@ 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]); + // 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 + file_log([NSString stringWithFormat: + @"build commit=%s flavor=%s built=%s %s", + KIOU_ENGINE_BRIDGE_COMMIT, kBuildFlavor, + __DATE__, __TIME__]); // 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 diff --git a/recipes/kiouenginebridge.py b/recipes/kiouenginebridge.py index 958747d..b86bf7d 100644 --- a/recipes/kiouenginebridge.py +++ b/recipes/kiouenginebridge.py @@ -97,9 +97,16 @@ # 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 @@ -326,6 +333,25 @@ def emit(insn: bytes) -> None: # 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]] = [ From 0f9cf358387e57d665c519603aabe1b39cfacd80 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 20:57:46 +0000 Subject: [PATCH 14/51] refactor: rename public functions to PascalCase and overhaul protocol docs - Rename all public/exported C functions from snake_case to PascalCase (ObjC convention) across all KiouEngineBridge source files: hook_* -> Hook*, install_*_hook -> Install*Hook, kiou_ws_* -> KiouWs*, kiou_inject_* -> KiouInject*, usi_engine_* -> UsiEngine*, meta_* -> Meta*, sfenFromGameController -> SfenFromGameController, usiTextFromGameController -> UsiTextFromGameController - Update macro definitions in Hook_MatchModeObserve.m to generate PascalCase function names via MODE_PASCAL token concatenation - Static (file-internal) functions retain snake_case per C convention Protocol documentation overhaul: - Reframe KEB as a USI match server (analogous to Floodgate/shogi-server) rather than a USI GUI; KIOU owns board state and clocks - Replace JSON meta sidecar with extended USI line protocol: meta BEGIN_GAME/END_GAME block (match info, time control, player names) meta move +7g7f T10 (per-move, CSA-style: side + usi + time spent) BEGIN_RESULT/END_RESULT removed (gameover on USI channel is sufficient) - Add CSA protocol correspondence table to docs/usi-compatibility.md - Document go command detail: clock-aware (btime/wtime/byoyomi) vs movetime fallback; usinewgame policy (per match, not per WS session) - Add stop/quit inbound handling semantics - Add wrapper-mediated deployment section Co-Authored-By: Claude Opus 4.8 (1M context) --- .devcontainer/devcontainer.json | 7 +- README.md | 36 +- Sources/KiouEngineBridge/BinpatchDispatcher.m | 54 +- Sources/KiouEngineBridge/Hook_AfkSuppress.m | 8 +- .../Hook_GameOrchestratorObserve.m | 8 +- .../Hook_GameStateStoreObserve.m | 38 +- .../KiouEngineBridge/Hook_LowLevelObserve.m | 24 +- .../KiouEngineBridge/Hook_MatchModeObserve.m | 126 +++-- Sources/KiouEngineBridge/Hook_OnlineObserve.m | 27 +- Sources/KiouEngineBridge/Inject_Move.m | 8 +- Sources/KiouEngineBridge/Internal.h | 137 ++--- Sources/KiouEngineBridge/Meta_Emitter.m | 44 +- Sources/KiouEngineBridge/Server_WebSocket.m | 18 +- Sources/KiouEngineBridge/Tweak.m | 32 +- Sources/KiouEngineBridge/Usi_Engine.m | 42 +- docs/bridge_protocol.md | 492 ++++++++++++------ docs/usi-compatibility.md | 294 +++++++++++ 17 files changed, 954 insertions(+), 441 deletions(-) create mode 100644 docs/usi-compatibility.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f40f274..32b8244 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -56,22 +56,17 @@ "extensions": [ "Anthropic.claude-code", "EditorConfig.EditorConfig", - "GitHub.copilot", - "GitHub.copilot-chat", "PKief.material-icon-theme", "antfu.file-nesting", - "bierner.markdown-mermaid", "bierner.markdown-preview-github-styles", "charliermarsh.ruff", - "eamodio.gitlens", "jebbs.markdown-extended", "ms-python.black-formatter", "ms-python.python", "pHofer94.vscode-taskviewexplorer", "redhat.vscode-yaml", "swiftlang.swift-vscode", - "tamasfe.even-better-toml", - "vsls-contrib.gitdoc" + "tamasfe.even-better-toml" ], "settings": {} } diff --git a/README.md b/README.md index 3618d74..16a44e5 100644 --- a/README.md +++ b/README.md @@ -159,14 +159,38 @@ Two protocol streams share one TCP port: | Stream | Direction | Wire | Purpose | |---|---|---|---| -| **USI** | bidirectional | one USI command per text frame | drive the engine, accept `bestmove` back | +| **USI** | bidirectional | one USI command per text frame | carry the tweak-owned handshake, `position sfen ...`, `bestmove ...`, and end-of-game notifications | | **meta** | tweak -> host | `meta{...json}\n` per frame | match lifecycle + per-move record for KIF assembly | -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. +The USI state machine in `Sources/KiouEngineBridge/Usi_Engine.m` +currently owns this surface on the WebSocket link: + +| Command | Direction | Status | Notes | +|---|---|---|---| +| `usi` | tweak -> peer | supported | sent on WS connect | +| `id name ...` / `id author ...` | peer -> tweak | observed | logged, not interpreted | +| `option ...` | peer -> tweak | observed | logged, not interpreted | +| `usiok` | peer -> tweak | supported | advances handshake to `isready` | +| `isready` | tweak -> peer | supported | sent after `usiok` | +| `readyok` | peer -> tweak | supported | advances to READY and triggers `usinewgame` | +| `usinewgame` | tweak -> peer | supported | sent once the peer reports ready | +| `position sfen ` | tweak -> peer | supported | emitted when observation says it is our turn | +| `info ...` | peer -> tweak | partially supported | `info string ...` is cached; other forms are logged and ignored | +| `bestmove ` | peer -> tweak | supported | injected through KIOU's normal move pipeline | +| `gameover win|lose|draw` | tweak -> peer | supported | sent on match end when the result is known | +| `setoption ...` | either | not handled by the tweak | expected to live on the host peer / wrapper side | +| `go ...` | either | not handled by the tweak | current design leaves think-limit policy to the host peer / wrapper side | +| `stop` | peer -> tweak | not supported yet | currently ignored | +| `ponderhit` | peer -> tweak | not supported yet | current flow does not expose ponder mode | +| `quit` | peer -> tweak | not supported yet | current flow treats WS disconnect as session end | +| `position startpos moves ...` | either | not emitted | outbound path uses `position sfen ...` from KIOU's live board snapshot | +| `bestmove ponder ` | peer -> tweak | partial | only the primary bestmove token is consumed | + +YaneuraOu-specific extensions such as `go depth`, `go nodes`, +`go movetime`, `go rtime`, `getoption`, `bench`, `moves`, `side`, and +other debug / benchmark commands are intentionally **out of scope** for +this tweak-level protocol surface. If you need them, implement them in +your host-side peer or wrapper and translate them there. ## Install diff --git a/Sources/KiouEngineBridge/BinpatchDispatcher.m b/Sources/KiouEngineBridge/BinpatchDispatcher.m index 57685fb..e0c199d 100644 --- a/Sources/KiouEngineBridge/BinpatchDispatcher.m +++ b/Sources/KiouEngineBridge/BinpatchDispatcher.m @@ -21,7 +21,7 @@ // `recipes/kiouenginebridge.py`'s ``HOOK_SLOT_RVA`` / // ``_build_bridge_cave_payload``). // -// 2. ``kiou_bridge_binpatch_publish`` below stores `&dispatch_one` +// 2. ``KiouBridgeBinpatchPublish`` 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 @@ -48,48 +48,48 @@ static void dispatch_one(void *x0, void *x1, void *x2, void *x3, void *x4, // 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)hook_ai_Init(x0, x1, x2, x3, x4); break; + (void)HookAiInit(x0, x1, x2, x3, x4); break; case KIOU_BR_HOOK_CPUSTREAM_INIT: - (void)hook_cpustream_Init(x0, x1, x2, x3, x4); break; + (void)HookCpuStreamInit(x0, x1, x2, x3, x4); break; case KIOU_BR_HOOK_LOCAL_INIT: - (void)hook_local_Init(x0, x1, x2, x3, x4); break; + (void)HookLocalInit(x0, x1, x2, x3, x4); break; case KIOU_BR_HOOK_ONLINE_INIT: - (void)hook_online_Init(x0, x1, x2, x3, x4); break; + (void)HookOnlineInit(x0, x1, x2, x3, x4); break; case KIOU_BR_HOOK_REPLAY_INIT: - (void)hook_replay_Init(x0, x1, x2, x3, x4); break; + (void)HookReplayInit(x0, x1, x2, x3, x4); break; // OnMatchStart(self) - case KIOU_BR_HOOK_AI_START: hook_ai_Start(x0); break; - case KIOU_BR_HOOK_CPUSTREAM_START: hook_cpustream_Start(x0); break; - case KIOU_BR_HOOK_LOCAL_START: hook_local_Start(x0); break; - case KIOU_BR_HOOK_ONLINE_START: hook_online_Start(x0); break; - case KIOU_BR_HOOK_REPLAY_START: hook_replay_Start(x0); break; + 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)hook_ai_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + (void)HookAiOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; case KIOU_BR_HOOK_CPUSTREAM_OPM: - (void)hook_cpustream_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + (void)HookCpuStreamOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; case KIOU_BR_HOOK_LOCAL_OPM: - (void)hook_local_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + (void)HookLocalOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; case KIOU_BR_HOOK_ONLINE_OPM: - (void)hook_online_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + (void)HookOnlineOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; case KIOU_BR_HOOK_REPLAY_OPM: - (void)hook_replay_OPM(x0, (uint32_t)(uintptr_t)x1, x2); break; + (void)HookReplayOpm(x0, (uint32_t)(uintptr_t)x1, x2); break; // OnMatchEndAsync(self, ct) - case KIOU_BR_HOOK_AI_END: (void)hook_ai_End(x0, x1); break; - case KIOU_BR_HOOK_CPUSTREAM_END: (void)hook_cpustream_End(x0, x1); break; - case KIOU_BR_HOOK_LOCAL_END: (void)hook_local_End(x0, x1); break; - case KIOU_BR_HOOK_ONLINE_END: (void)hook_online_End(x0, x1); break; - case KIOU_BR_HOOK_REPLAY_END: (void)hook_replay_End(x0, x1); break; + 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)hook_AdapterTryMakeMoveOut(x0, (uint32_t)(uintptr_t)x1, x2); + (void)HookAdapterTryMakeMoveOut(x0, (uint32_t)(uintptr_t)x1, x2); break; // UpdateAuthoritativeSnapshot(self, sfen, turn, blackTime, whiteTime, @@ -100,23 +100,23 @@ static void dispatch_one(void *x0, void *x1, void *x2, void *x3, void *x4, // 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: - hook_UpdateAuthoritativeSnapshot(x0, x1, (int32_t)(intptr_t)x2, + 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: - hook_CpuStream_UpdateSnapshot(x0, x1, (int32_t)(intptr_t)x2, + 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: - hook_HandleMoveResult(x0, x1); break; + HookHandleMoveResult(x0, x1); break; // GameOrchestrator.ActivateAsync(self, setup, assetLoader, ct) case KIOU_BR_HOOK_GAMEORCH_ACTIVATE: - (void)hook_GameOrch_ActivateAsync(x0, x1, x2, x3); break; + (void)HookGameOrchActivateAsync(x0, x1, x2, x3); break; default: // Cave fired with an id outside the recipe table. Almost certainly @@ -130,7 +130,7 @@ static void dispatch_one(void *x0, void *x1, void *x2, void *x3, void *x4, } } -void kiou_bridge_binpatch_publish(void) { +void KiouBridgeBinpatchPublish(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 diff --git a/Sources/KiouEngineBridge/Hook_AfkSuppress.m b/Sources/KiouEngineBridge/Hook_AfkSuppress.m index abf599f..49cff57 100644 --- a/Sources/KiouEngineBridge/Hook_AfkSuppress.m +++ b/Sources/KiouEngineBridge/Hook_AfkSuppress.m @@ -44,7 +44,7 @@ // 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: @@ -59,10 +59,10 @@ static bool hook_IsAfkEnabled(void *self) { } #if !KIOU_BINPATCH -void install_AfkSuppress_hook(uintptr_t unityBase) { +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: @"[AFK] hooked GameOrchestrator.IsAfkEnabled @0x%lx " @@ -73,4 +73,4 @@ void install_AfkSuppress_hook(uintptr_t unityBase) { #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 install_AfkSuppress_hook is intentionally omitted here. +// so InstallAfkSuppressHook is intentionally omitted here. diff --git a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m index 14e0706..61efe5c 100644 --- a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m @@ -66,7 +66,7 @@ typedef UniTaskRet (*GameOrch_ActivateAsync_t)(void *self, void *setup, // --------------------------------------------------------------------------- static uint32_t g_orchSeen = 0; -UniTaskRet hook_GameOrch_ActivateAsync(void *self, void *setup, +UniTaskRet HookGameOrchActivateAsync(void *self, void *setup, void *assetLoader, void *ct) { if (g_gameOrchestratorCache != self) g_gameOrchestratorCache = self; uint32_t n = ++g_orchSeen; @@ -88,9 +88,9 @@ UniTaskRet hook_GameOrch_ActivateAsync(void *self, void *setup, // Installer. Called once from Tweak.m::installUnityHooks(). // --------------------------------------------------------------------------- #if !KIOU_BINPATCH -void install_GameOrchestratorObserve_hook(uintptr_t unityBase) { +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: @"[GAMEORCH] hooked GameOrchestrator.ActivateAsync @0x%lx " @@ -101,4 +101,4 @@ void install_GameOrchestratorObserve_hook(uintptr_t unityBase) { // 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 hook_GameOrch_ActivateAsync once Phase E wires it up. +// invoke HookGameOrchActivateAsync once Phase E wires it up. diff --git a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m index 3d15d6d..65f415f 100644 --- a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m @@ -3,11 +3,11 @@ #if KIOU_BINPATCH // This entire module is meta-sidecar-only. The binpatch flavour drops the // meta sidecar (see docs/plans/kiou_engine_bridge_binpatch.md § 2), so the -// file compiles to nothing. install_GameStateStoreObserve_hook is a no-op +// file compiles to nothing. InstallGameStateStoreObserveHook is a no-op // shim defined at the bottom of this #if block so Tweak.m doesn't need its // own #if to skip the call — keeping the constructor wiring uniform. -void install_GameStateStoreObserve_hook(uintptr_t unityBase) { (void)unityBase; } -void meta_on_player_info_set(int32_t side, void *playerInfo) { +void InstallGameStateStoreObserveHook(uintptr_t unityBase) { (void)unityBase; } +void MetaOnPlayerInfoSet(int32_t side, void *playerInfo) { (void)side; (void)playerInfo; } #else @@ -46,7 +46,7 @@ void meta_on_player_info_set(int32_t side, void *playerInfo) { #define RVA_GAMESTATESTORE_SET_WHITE_PLAYER_INFO 0x5A2CBA0 // NotifyPieceMoved は自分手・相手手どちらの apply 時も通る GameStateStore // 上のチョークポイント。ADAPTER2 (Hook_LowLevelObserve.m) は自分手しか -// 通らないので、meta_emit_move の発火点はこちらに集約する。 +// 通らないので、MetaEmitMove の発火点はこちらに集約する。 #define RVA_GAMESTATESTORE_NOTIFY_PIECE_MOVED 0x5A2CD24 // --------------------------------------------------------------------------- @@ -60,15 +60,15 @@ void meta_on_player_info_set(int32_t side, void *playerInfo) { // --------------------------------------------------------------------------- // Hook bodies. Both share the same shape; the side argument to -// meta_on_player_info_set tells Meta_Emitter which slot was just written. +// MetaOnPlayerInfoSet tells Meta_Emitter which slot was just written. // --------------------------------------------------------------------------- -static void hook_SetBlackPlayerInfo(void *self, void *playerInfo) { - meta_on_player_info_set(/*side=*/0, playerInfo); +static void HookSetBlackPlayerInfo(void *self, void *playerInfo) { + MetaOnPlayerInfoSet(/*side=*/0, playerInfo); if (orig_SetBlackPlayerInfo) orig_SetBlackPlayerInfo(self, playerInfo); } -static void hook_SetWhitePlayerInfo(void *self, void *playerInfo) { - meta_on_player_info_set(/*side=*/1, playerInfo); +static void HookSetWhitePlayerInfo(void *self, void *playerInfo) { + MetaOnPlayerInfoSet(/*side=*/1, playerInfo); if (orig_SetWhitePlayerInfo) orig_SetWhitePlayerInfo(self, playerInfo); } @@ -82,8 +82,8 @@ static void hook_SetWhitePlayerInfo(void *self, void *playerInfo) { // // 自分のクライアントが指したとき (ADAPTER2 経由) も、サーバから相手手の // state 更新が降ってきたときも、最終的にこの NotifyPieceMoved を通る。 -// したがってここで meta_emit_move を 1 度だけ発火すれば、片肺 KIF -// 問題は解消する。meta_emit_move は引数として「次の手番」を受け取る +// したがってここで MetaEmitMove を 1 度だけ発火すれば、片肺 KIF +// 問題は解消する。MetaEmitMove は引数として「次の手番」を受け取る // 設計なので、API は崩さずに `playerSide == 0 ? 1 : 0` で flip して渡す。 // --------------------------------------------------------------------------- typedef void (*GameStateStore_NotifyPieceMoved_t)(void *self, @@ -91,7 +91,7 @@ typedef void (*GameStateStore_NotifyPieceMoved_t)(void *self, int32_t playerSide); static GameStateStore_NotifyPieceMoved_t orig_NotifyPieceMoved = NULL; -static void hook_NotifyPieceMoved(void *self, +static void HookNotifyPieceMoved(void *self, uint32_t move, int32_t playerSide) { // Call original FIRST so the GameController applies the move and the @@ -104,11 +104,11 @@ static void hook_NotifyPieceMoved(void *self, // 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 して渡す。 + // MetaEmitMove は「次の手番」を引数に取るので flip して渡す。 int32_t nextSide = (playerSide == 0) ? 1 : (playerSide == 1) ? 0 : -1; @@ -116,17 +116,17 @@ static void hook_NotifyPieceMoved(void *self, @"[GSTATE-MOVE] NotifyPieceMoved self=%p moved_side=%d " @"usi=\"%@\" sfen=\"%@\"", self, (int)playerSide, usi ?: @"", sfen ?: @""]); - meta_emit_move(usi, sfen, nextSide); + MetaEmitMove(usi, sfen, nextSide); } // --------------------------------------------------------------------------- // Installer. Called once from Tweak.m::installUnityHooks(). // --------------------------------------------------------------------------- -void install_GameStateStoreObserve_hook(uintptr_t unityBase) { +void InstallGameStateStoreObserveHook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_GAMESTATESTORE_SET_BLACK_PLAYER_INFO; MSHookFunction((void *)addr, - (void *)hook_SetBlackPlayerInfo, + (void *)HookSetBlackPlayerInfo, (void **)&orig_SetBlackPlayerInfo); file_log([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.SetBlackPlayerInfo " @@ -137,7 +137,7 @@ void install_GameStateStoreObserve_hook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_GAMESTATESTORE_SET_WHITE_PLAYER_INFO; MSHookFunction((void *)addr, - (void *)hook_SetWhitePlayerInfo, + (void *)HookSetWhitePlayerInfo, (void **)&orig_SetWhitePlayerInfo); file_log([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.SetWhitePlayerInfo " @@ -148,7 +148,7 @@ void install_GameStateStoreObserve_hook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_GAMESTATESTORE_NOTIFY_PIECE_MOVED; MSHookFunction((void *)addr, - (void *)hook_NotifyPieceMoved, + (void *)HookNotifyPieceMoved, (void **)&orig_NotifyPieceMoved); file_log([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.NotifyPieceMoved " diff --git a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m index b54392b..760c23f 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) // --------------------------------------------------------------------------- -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 @@ -233,7 +233,7 @@ bool hook_AdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { 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 *sfen = SfenFromGameController(gameCtrl); NSString *usi = moveToUsi((SfMove)mv_copy); file_log([NSString stringWithFormat: @"[ADAPTER2] TryMakeMove self=%p " @@ -257,8 +257,8 @@ bool hook_AdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { 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 は「自分のクライアント + UsiEngineOnMoveObserved(usi, sfen, sideToMove); + // MetaEmitMove はここでは出さない — ADAPTER2 は「自分のクライアント // が指した手」しか通らない (オンライン対戦では相手手はサーバ state // 経由で来る) ので、ここで出すと自分側 ply のみの片肺 KIF になる。 // 両手番分の meta は Hook_GameStateStoreObserve.m の @@ -272,7 +272,7 @@ bool hook_AdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { // --------------------------------------------------------------------------- // 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. @@ -281,7 +281,7 @@ 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: @"[GAMECTRL] TryMakeMove self=%p ok=%d usi=\"%@\" move={%@} sfen_after=\"%@\"", @@ -300,7 +300,7 @@ static bool hook_GameCtrlTryMakeMove(void *self, SfMove move) { // 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 = @@ -312,7 +312,7 @@ void install_LowLevelObserve_hook(uintptr_t unityBase) { { 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: @"[LOWLEVEL] hooked ShogiGameAdapter.TryMakeMove(Move,out) " @@ -323,7 +323,7 @@ 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: @"[LOWLEVEL] hooked GameController.TryMakeMove " @@ -337,7 +337,7 @@ void install_LowLevelObserve_hook(uintptr_t unityBase) { // 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 hook_AdapterTryMakeMoveOut for every move that + // covered by HookAdapterTryMakeMoveOut for every move that // reaches the board. Dropping it on binpatch saves a cave and // keeps the hook surface tight. file_log(@"[LOWLEVEL] binpatch build — site hooks driven by cave/SLOT, " diff --git a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m index a7fd219..a5d38b1 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. @@ -168,8 +169,8 @@ 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) \ - 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); \ @@ -186,19 +187,19 @@ static inline BOOL shouldLog(uint32_t n) { 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) @@ -218,8 +219,8 @@ static inline BOOL shouldLog(uint32_t n) { // dump. // --------------------------------------------------------------------------- -#define DEFINE_INIT_HOOK(MODE_LOWER, MODE_TAG, CACHE_VAR, ORIG_VAR) \ - UniTaskRet hook_##MODE_LOWER##_Init(void *self, void *cfg, \ +#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; \ @@ -238,7 +239,7 @@ static inline BOOL shouldLog(uint32_t n) { /* 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); \ + MetaSetMatchConfig(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); \ @@ -251,15 +252,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 @@ -288,8 +289,8 @@ static inline BOOL shouldLog(uint32_t n) { // `self` is a void *, no ARC retain). The LP_CACHE / file_log / usi / // meta calls all happen inside the deferred block so they observe the // post-orig state. -#define DEFINE_START_HOOK(MODE_LOWER, MODE_TAG, CACHE_VAR, LP_CACHE, LP_OFFSET, ORIG_VAR) \ - void hook_##MODE_LOWER##_Start(void *self) { \ +#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; \ KIOU_CALL_ORIG_VOID(ORIG_VAR, self); \ void *selfCap = self; \ @@ -307,30 +308,33 @@ static inline BOOL shouldLog(uint32_t n) { /* 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); \ + UsiEngineOnMatchStart(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); \ + 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 @@ -535,9 +539,9 @@ static void scheduleAutoRematch(const char *modeTag) { }); } -#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) \ - 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); \ @@ -546,7 +550,7 @@ 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); \ + NSString *usiText = UsiTextFromGameController(g_gameCtrlCache); \ file_log([NSString stringWithFormat: \ @"[MMODE] " MODE_TAG " End self=%p localPlayer=%d " \ @"result=%d sfen=\"%@\"", \ @@ -556,30 +560,38 @@ 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; \ + /* Reset time cache so the next match doesn't inherit stale values. */ \ + g_latestBlackTimeSec = 0.0f; \ + g_latestWhiteTimeSec = 0.0f; \ /* 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); \ + UsiEngineOnMatchEnd(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); \ + MetaEmitMatchEnd(result, finalSfen, usiText); \ + MetaSetMatchConfig(NULL); \ /* Schedule the auto-rematch sequence. REMATCH_TAG = NULL opts out. */ \ scheduleAutoRematch(REMATCH_TAG); \ + /* 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 @@ -591,60 +603,60 @@ static void scheduleAutoRematch(const char *modeTag) { // recipes/kiouenginebridge.py), so the installer is omitted there. // --------------------------------------------------------------------------- #if !KIOU_BINPATCH -void install_MatchModeObserve_hook(uintptr_t unityBase) { +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; @@ -672,7 +684,7 @@ void install_MatchModeObserve_hook(uintptr_t unityBase) { // 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 install_MatchModeObserve_hook(uintptr_t unityBase) { +void InstallMatchModeObserveHook(uintptr_t unityBase) { (void)unityBase; file_log(@"[MMODE] binpatch: install is a no-op — orig_* OPM slots stay " @"NULL so the bypass-entry path in inject_pickRoute() is taken."); diff --git a/Sources/KiouEngineBridge/Hook_OnlineObserve.m b/Sources/KiouEngineBridge/Hook_OnlineObserve.m index 4e6d872..bda3f5a 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,7 +65,7 @@ typedef void (*UpdateAuthoritativeSnapshot_t)(void *self, int32_t moveCount); static UpdateAuthoritativeSnapshot_t orig_UpdateAuthoritativeSnapshot = NULL; -void hook_UpdateAuthoritativeSnapshot(void *self, +void HookUpdateAuthoritativeSnapshot(void *self, void *sfenStr, int32_t turn, float blackTimeSec, @@ -71,6 +78,8 @@ 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: @@ -112,7 +121,7 @@ void hook_UpdateAuthoritativeSnapshot(void *self, static uint32_t g_handleResultCount = 0; -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(); @@ -138,7 +147,7 @@ void hook_HandleMoveResult(void *self, void *reply) { // --------------------------------------------------------------------------- static UpdateAuthoritativeSnapshot_t orig_CpuStream_UpdateSnapshot = NULL; -void hook_CpuStream_UpdateSnapshot(void *self, +void HookCpuStreamUpdateSnapshot(void *self, void *sfenStr, int32_t turn, float blackTimeSec, @@ -147,6 +156,8 @@ void hook_CpuStream_UpdateSnapshot(void *self, 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: @@ -155,7 +166,7 @@ void hook_CpuStream_UpdateSnapshot(void *self, (int)turn, (int)moveCount, (double)blackTimeSec, (double)whiteTimeSec, sfen ?: @""]); - // Phase 2: see comment in hook_UpdateAuthoritativeSnapshot — snapshots + // Phase 2: see comment in HookUpdateAuthoritativeSnapshot — snapshots // stay file_log-only and the USI engine tracks turns via ADAPTER2. (void)sfen; if (orig_CpuStream_UpdateSnapshot) { @@ -169,10 +180,10 @@ void hook_CpuStream_UpdateSnapshot(void *self, // Installer. // --------------------------------------------------------------------------- #if !KIOU_BINPATCH -void install_OnlineObserve_hook(uintptr_t unityBase) { +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: @"[ONLINE] hooked OnlinePvPMode.UpdateAuthoritativeSnapshot " @@ -182,7 +193,7 @@ 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: @"[ONLINE] hooked OnlinePvPMode.HandleMoveResult " @@ -192,7 +203,7 @@ 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: @"[ONLINE] hooked CPUStreamMode.UpdateAuthoritativeSnapshot " diff --git a/Sources/KiouEngineBridge/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index baf85cb..66a9b97 100644 --- a/Sources/KiouEngineBridge/Inject_Move.m +++ b/Sources/KiouEngineBridge/Inject_Move.m @@ -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 @@ -626,7 +626,7 @@ static kiou_route_t inject_pickRoute(void) { // --------------------------------------------------------------------------- // 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 KiouInjectDumpRecent() 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]; @@ -642,7 +642,7 @@ static void inject_pushRecord(const kiou_inject_record_t *rec) { pthread_mutex_unlock(&g_ringMu); } -void kiou_inject_dumpRecent(void) { +void KiouInjectDumpRecent(void) { pthread_mutex_lock(&g_ringMu); size_t count = g_ringCount; size_t head = g_ringHead; @@ -1179,7 +1179,7 @@ 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"); return; diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index e12eb6e..4182fe4 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -41,11 +41,11 @@ // // 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) +// InstallOnlineObserveHook (Hook_OnlineObserve.m) +// InstallLowLevelObserveHook (Hook_LowLevelObserve.m) +// InstallMatchModeObserveHook (Hook_MatchModeObserve.m) +// InstallInjectHook (Inject_Move.m) +// UsiEngineInstall (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. @@ -103,13 +103,13 @@ // 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); +void UsiEngineInstall(void); // UnityFramework base address captured at install time. Exposed so the // match-end auto-rematch path can resolve static il2cpp methods @@ -128,8 +128,8 @@ extern void *volatile g_gameOrchestratorCache; // then any hook can push a single JSON-encoded line to whichever host is // currently connected. No-op when no host is attached. // --------------------------------------------------------------------------- -void kiou_ws_server_start(uint16_t port); -void kiou_ws_server_push(NSString *json); +void KiouWsServerStart(uint16_t port); +void KiouWsServerPush(NSString *json); // Register a callback for inbound TEXT frames (opcode 0x1). The handler is // invoked on the recv queue (a serial dispatch queue, NOT the main thread). @@ -137,7 +137,7 @@ void kiou_ws_server_push(NSString *json); // 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); +void KiouWsServerSetTextHandler(kiou_ws_text_handler_t fn); // --------------------------------------------------------------------------- // Observation-side instance cache, populated by Hook_LowLevelObserve.m and @@ -197,6 +197,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 @@ -264,7 +271,7 @@ 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 KiouInjectDumpRecent(void); // --------------------------------------------------------------------------- // Injection bridge — called by Usi_Engine.m when YaneuraOu sends us a @@ -297,19 +304,19 @@ typedef enum { USI_STATE_INJECTING = 4, // bestmove received, applying to KIOU } usi_state_t; -// Notified by Hook_LowLevelObserve.m::hook_AdapterTryMakeMoveOut every time +// Notified by Hook_LowLevelObserve.m::HookAdapterTryMakeMoveOut 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, +void UsiEngineOnMoveObserved(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); +void UsiEngineOnMatchStart(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 @@ -323,12 +330,12 @@ typedef enum { USI_RESULT_DRAW = 2, } usi_match_result_t; -void usi_engine_on_match_end(usi_match_result_t result); +void UsiEngineOnMatchEnd(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); +void UsiEngineOnWsClientConnected(void); +void UsiEngineOnWsClientDisconnected(void); // --------------------------------------------------------------------------- // Meta_Emitter.m — 1-line JSON metadata stream that runs alongside the USI @@ -340,18 +347,18 @@ 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 +// observation, right alongside UsiEngineOnMoveObserved. 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); +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 @@ -359,7 +366,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); @@ -368,10 +375,10 @@ 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). @@ -459,7 +466,7 @@ enum kiou_bridge_hook_id { }; // g_inject_entry is binpatch-only — it's populated by -// kiou_bridge_binpatch_publish() with per-site cave-bypass entry pointers, +// KiouBridgeBinpatchPublish() 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 @@ -551,7 +558,7 @@ typedef void (*kiou_bridge_dispatcher_t)(void *x0, void *x1, void *x2, // __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 kiou_bridge_binpatch_publish(void); +void KiouBridgeBinpatchPublish(void); // --------------------------------------------------------------------------- // Hook function bodies reached from the binpatch dispatcher. Defined in @@ -563,39 +570,39 @@ void kiou_bridge_binpatch_publish(void); // orig runs via the cave's displaced prologue + `B orig+4` after the // dispatcher returns. // --------------------------------------------------------------------------- -UniTaskRet hook_ai_Init(void *self, void *cfg, void *store, void *adapter, void *ct); -UniTaskRet hook_cpustream_Init(void *self, void *cfg, void *store, void *adapter, void *ct); -UniTaskRet hook_local_Init(void *self, void *cfg, void *store, void *adapter, void *ct); -UniTaskRet hook_online_Init(void *self, void *cfg, void *store, void *adapter, void *ct); -UniTaskRet hook_replay_Init(void *self, void *cfg, void *store, void *adapter, void *ct); - -void hook_ai_Start(void *self); -void hook_cpustream_Start(void *self); -void hook_local_Start(void *self); -void hook_online_Start(void *self); -void hook_replay_Start(void *self); - -UniTaskRet hook_ai_OPM(void *self, uint32_t mv, void *ct); -UniTaskRet hook_cpustream_OPM(void *self, uint32_t mv, void *ct); -UniTaskRet hook_local_OPM(void *self, uint32_t mv, void *ct); -UniTaskRet hook_online_OPM(void *self, uint32_t mv, void *ct); -UniTaskRet hook_replay_OPM(void *self, uint32_t mv, void *ct); - -UniTaskRet hook_ai_End(void *self, void *ct); -UniTaskRet hook_cpustream_End(void *self, void *ct); -UniTaskRet hook_local_End(void *self, void *ct); -UniTaskRet hook_online_End(void *self, void *ct); -UniTaskRet hook_replay_End(void *self, void *ct); - -bool hook_AdapterTryMakeMoveOut(void *self, uint32_t move, void *outMove); -void hook_UpdateAuthoritativeSnapshot(void *self, void *sfenStr, int32_t turn, +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 hook_HandleMoveResult(void *self, void *reply); -void hook_CpuStream_UpdateSnapshot(void *self, void *sfenStr, int32_t turn, +void HookHandleMoveResult(void *self, void *reply); +void HookCpuStreamUpdateSnapshot(void *self, void *sfenStr, int32_t turn, float blackTimeSec, float whiteTimeSec, int32_t moveCount); -UniTaskRet hook_GameOrch_ActivateAsync(void *self, void *setup, +UniTaskRet HookGameOrchActivateAsync(void *self, void *setup, void *assetLoader, void *ct); // --------------------------------------------------------------------------- @@ -613,15 +620,15 @@ 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); +// WS accept queue via KiouWsServerPush. +void UsiEngineSendLine(NSString *line); diff --git a/Sources/KiouEngineBridge/Meta_Emitter.m b/Sources/KiouEngineBridge/Meta_Emitter.m index f30f8c9..a7886b5 100644 --- a/Sources/KiouEngineBridge/Meta_Emitter.m +++ b/Sources/KiouEngineBridge/Meta_Emitter.m @@ -5,12 +5,12 @@ // (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 meta_set_match_config(void *cfg) { (void)cfg; } -void meta_emit_match_start(int32_t local_player) { (void)local_player; } -void meta_emit_move(NSString *usi, NSString *sfen_after, int32_t side_to_move) { +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 meta_emit_match_end(usi_match_result_t result, +void MetaEmitMatchEnd(usi_match_result_t result, NSString *final_sfen, NSString *usi_text) { (void)result; (void)final_sfen; (void)usi_text; @@ -36,7 +36,7 @@ void meta_emit_match_end(usi_match_result_t result, // 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 @@ -97,7 +97,7 @@ void meta_emit_match_end(usi_match_result_t result, // 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; @@ -301,7 +301,7 @@ static void meta_emit_dict(NSDictionary *payload) { } NSString *line = [NSString stringWithFormat:@"meta %@\n", body]; file_log([NSString stringWithFormat:@"[META>] %@", body]); - kiou_ws_server_push(line); + KiouWsServerPush(line); } // --------------------------------------------------------------------------- @@ -377,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 @@ -411,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; @@ -438,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(); @@ -490,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); } @@ -503,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]; @@ -513,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 の駒種未確定 / 重複発火などで誤差が // 入る余地があるので、対局終了時にここで一発で確定させる。 @@ -527,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). diff --git a/Sources/KiouEngineBridge/Server_WebSocket.m b/Sources/KiouEngineBridge/Server_WebSocket.m index f3b1b3c..10f812b 100644 --- a/Sources/KiouEngineBridge/Server_WebSocket.m +++ b/Sources/KiouEngineBridge/Server_WebSocket.m @@ -29,7 +29,7 @@ // 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 +// - A serial GCD queue funnels every KiouWsServerPush() 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. @@ -55,7 +55,7 @@ // --------------------------------------------------------------------------- // 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. +// process, which matches the way Tweak.m calls KiouWsServerStart() once. // --------------------------------------------------------------------------- static dispatch_queue_t g_acceptQueue = NULL; // serial, handshake + writes static dispatch_queue_t g_recvQueue = NULL; // serial, blocking client reads @@ -66,7 +66,7 @@ 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 +// KiouWsServerSetTextHandler() 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; @@ -147,7 +147,7 @@ static void ws_close_client(void) { // 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(); + UsiEngineOnWsClientDisconnected(); } } @@ -353,7 +353,7 @@ static void ws_client_recv_loop(int fd) { // 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_text (from KiouWsServerPush -> 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 @@ -456,7 +456,7 @@ static void ws_handle_accept(void) { // 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(); + UsiEngineOnWsClientConnected(); // Drain inbound on a separate queue so the accept queue is never blocked // by a slow / silent peer. @@ -468,7 +468,7 @@ static void ws_handle_accept(void) { // --------------------------------------------------------------------------- // Public API. // --------------------------------------------------------------------------- -void kiou_ws_server_start(uint16_t port) { +void KiouWsServerStart(uint16_t port) { if (g_listenFd >= 0) { file_log(@"[WS] server already running"); return; @@ -517,7 +517,7 @@ void kiou_ws_server_start(uint16_t port) { (unsigned)port]); } -void kiou_ws_server_set_text_handler(kiou_ws_text_handler_t fn) { +void KiouWsServerSetTextHandler(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 @@ -525,7 +525,7 @@ void kiou_ws_server_set_text_handler(kiou_ws_text_handler_t fn) { g_textHandler = fn; } -void kiou_ws_server_push(NSString *json) { +void KiouWsServerPush(NSString *json) { if (!json) return; if (!g_acceptQueue) return; // server never started diff --git a/Sources/KiouEngineBridge/Tweak.m b/Sources/KiouEngineBridge/Tweak.m index eab5d55..30bc455 100644 --- a/Sources/KiouEngineBridge/Tweak.m +++ b/Sources/KiouEngineBridge/Tweak.m @@ -59,8 +59,8 @@ static void installUnityHooks(void) { // 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. - kiou_bridge_binpatch_publish(); - install_LowLevelObserve_hook(unityBase); // symbol pointer resolves only + KiouBridgeBinpatchPublish(); + 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 @@ -68,31 +68,31 @@ static void installUnityHooks(void) { // inject path then calls that bypass entry — calling orig_* directly // would re-enter the dispatcher cave because unityBase+RVA is the // patched `B ` instruction. - install_MatchModeObserve_hook(unityBase); - install_Inject_hook(unityBase); - usi_engine_install(); + InstallMatchModeObserveHook(unityBase); + InstallInjectHook(unityBase); + UsiEngineInstall(); #else - install_OnlineObserve_hook(unityBase); - install_LowLevelObserve_hook(unityBase); - install_MatchModeObserve_hook(unityBase); + 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 + InstallGameStateStoreObserveHook(unityBase); + // Phase 2: USI engine driver. Must come AFTER InstallInjectHook so // inject_apply is fully wired before the WS handler can call into it. - usi_engine_install(); + UsiEngineInstall(); #endif g_unityHooked = YES; @@ -130,8 +130,8 @@ static void retryInstallHooks(void) { // 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); + // every KiouWsServerPush() call below is a no-op. + KiouWsServerStart(9527); // UnityFramework is almost certainly not mapped yet at constructor time. installUnityHooks(); diff --git a/Sources/KiouEngineBridge/Usi_Engine.m b/Sources/KiouEngineBridge/Usi_Engine.m index 7f35877..0191854 100644 --- a/Sources/KiouEngineBridge/Usi_Engine.m +++ b/Sources/KiouEngineBridge/Usi_Engine.m @@ -24,10 +24,10 @@ // 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) +// ↓ Hook_LowLevelObserve::HookAdapterTryMakeMoveOut fires +// ↓ UsiEngineOnMoveObserved(usi, sfen_after, side_to_move) // ↓ side_to_move == g_LocalPlayer ? -// yes → usi_engine_send_line("position sfen ") +// yes → UsiEngineSendLine("position sfen ") // state = THINKING // (bridge sees the position line and triggers the engine to // think with its own `go` cadence) @@ -37,7 +37,7 @@ // ↓ 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 +// ↓ state = READY (the inject itself fires HookAdapterTryMakeMoveOut // again, which sees side_to_move != localPlayer and // skips, so we don't loop on our own injection) // @@ -78,7 +78,7 @@ // "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 +// hook callback (the inject itself causes HookAdapterTryMakeMoveOut to // run again, but we don't want THAT to trigger another `position`+`go`). static NSString *g_expectedNextUsi = nil; @@ -86,7 +86,7 @@ // Outbound — funnel all USI lines through one helper so the log shows // exactly what the engine sees. // --------------------------------------------------------------------------- -void usi_engine_send_line(NSString *line) { +void UsiEngineSendLine(NSString *line) { if (line.length == 0) return; NSString *withNewline = [line hasSuffix:@"\n"] ? line @@ -94,7 +94,7 @@ void usi_engine_send_line(NSString *line) { file_log([NSString stringWithFormat:@"[USI>] %@", [line stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]]); - kiou_ws_server_push(withNewline); + KiouWsServerPush(withNewline); } // --------------------------------------------------------------------------- @@ -126,7 +126,7 @@ static void usi_set_state(int newState) { // --------------------------------------------------------------------------- // Match lifecycle. Called from Hook_MatchModeObserve.m. // --------------------------------------------------------------------------- -void usi_engine_on_match_start(int32_t local_player) { +void UsiEngineOnMatchStart(int32_t local_player) { atomic_store(&g_localPlayerSide, local_player); file_log([NSString stringWithFormat: @"[USI] match_start local_player=%d state=%s", @@ -156,7 +156,7 @@ void usi_engine_on_match_start(int32_t local_player) { } } -void usi_engine_on_match_end(usi_match_result_t result) { +void UsiEngineOnMatchEnd(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 @@ -175,7 +175,7 @@ void usi_engine_on_match_end(usi_match_result_t result) { break; } if (resultWord) { - usi_engine_send_line([NSString stringWithFormat:@"gameover %@", + UsiEngineSendLine([NSString stringWithFormat:@"gameover %@", resultWord]); } else { file_log(@"[USI] match_end: result unknown, suppressing gameover"); @@ -208,7 +208,7 @@ static void usi_engine_request_thinking(NSString *sfen) { file_log(@"[USI] request_thinking skipped: empty sfen"); return; } - usi_engine_send_line([NSString stringWithFormat:@"position sfen %@", + UsiEngineSendLine([NSString stringWithFormat:@"position sfen %@", sfen]); usi_set_state(USI_STATE_THINKING); } @@ -264,7 +264,7 @@ static void usi_engine_try_kick_on_main(NSString *tag) { // 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, +void UsiEngineOnMoveObserved(NSString *usi, NSString *sfen_after, int32_t side_to_move) { int s = atomic_load(&g_usiState); @@ -331,7 +331,7 @@ void usi_engine_on_move_observed(NSString *usi, } // Per-line dispatcher. Returns nothing — replies are sent via -// usi_engine_send_line. +// UsiEngineSendLine. static void usi_engine_handle_line(NSString *line) { NSString *trimmed = [line stringByTrimmingCharactersInSet: [NSCharacterSet whitespaceAndNewlineCharacterSet]]; @@ -350,11 +350,11 @@ static void usi_engine_handle_line(NSString *line) { } if ([cmd isEqualToString:@"usiok"]) { // engine done announcing itself — request readiness - usi_engine_send_line(@"isready"); + UsiEngineSendLine(@"isready"); return; } if ([cmd isEqualToString:@"readyok"]) { - usi_engine_send_line(@"usinewgame"); + UsiEngineSendLine(@"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 @@ -401,7 +401,7 @@ static void usi_engine_handle_line(NSString *line) { // 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 + // UsiEngineOnMoveObserved 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. @@ -439,14 +439,14 @@ static void usi_engine_text_handler(const char *data, size_t len) { // 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) { +void UsiEngineOnWsClientConnected(void) { file_log(@"[USI] ws client connected; starting USI handshake"); usi_set_state(USI_STATE_HANDSHAKE); g_expectedNextUsi = nil; - usi_engine_send_line(@"usi"); + UsiEngineSendLine(@"usi"); } -void usi_engine_on_ws_client_disconnected(void) { +void UsiEngineOnWsClientDisconnected(void) { file_log(@"[USI] ws client disconnected"); usi_set_state(USI_STATE_BOOT); g_expectedNextUsi = nil; @@ -456,7 +456,7 @@ void usi_engine_on_ws_client_disconnected(void) { // 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); +void UsiEngineInstall(void) { + KiouWsServerSetTextHandler(usi_engine_text_handler); file_log(@"[USI] engine installed (text handler registered)"); } diff --git a/docs/bridge_protocol.md b/docs/bridge_protocol.md index 6735b34..9bc1617 100644 --- a/docs/bridge_protocol.md +++ b/docs/bridge_protocol.md @@ -1,11 +1,41 @@ # KiouEngineBridge wire protocol -KiouEngineBridge is the iOS-side dylib that turns KIOU into a USI client. -It opens a WebSocket server inside the KIOU process and waits for an -external "bridge" host (the TypeScript bridge in the companion repo, in -practice) to connect. Everything the host needs to drive a match — USI -control lines plus best-effort metadata — flows over that single -connection. +KiouEngineBridge (KEB) is the iOS-side dylib that turns KIOU into a +**USI match server** — analogous in role to Floodgate or shogi-server, +but embedded inside the KIOU process rather than running as a standalone +service. + +The mental model: + +``` +KIOU (authoritative source of board state and clocks) + ↕ in-process hooks + KEB (USI match server — negotiates the match, delivers position + clocks) + ↕ USI over WebSocket :9527 + Engine (connects when ready to play; just needs to return bestmove) +``` + +KIOU decides the match conditions (time control, handicap, opponents). +KEB reads those conditions via hooks and opens a WebSocket server that +announces them through the standard USI protocol. An engine that wants +to play connects, exchanges the USI handshake, and from that point +receives `position sfen ...` and `go btime/wtime/byoyomi` on every +turn — exactly the same two pieces of information Floodgate delivers to +each engine in a CSA match. The engine returns `bestmove`; KEB feeds +that move back into KIOU via the injection path. + +Because KIOU is the authority on both board state and clocks, KEB has +no way to offer the engine a pre-negotiated time control before the +match starts — the engine learns the time budget from the first `go` +line of the first turn, not from `setoption` or a pre-game declaration. +A wrapper that wants to translate this into engine-specific search limits +(`go depth`, `go nodes`, custom byoyomi policy) can sit between KEB and +the raw engine process; KEB makes no distinction. + +This document is the contract between the dylib and the connecting +engine (or wrapper). It is the authoritative description of what bytes +go on the wire; if the C source and this file disagree, the C source +wins and this file is the bug. This document is the contract between the dylib and the host. It is the authoritative description of what bytes go on the wire; if the C source @@ -59,10 +89,15 @@ the **inbound** side only carries USI lines. | Direction | Channel | Frame shape | Defined in | |---|---|---|---| -| dylib → host | USI | one USI command, e.g. `usi\n`, `position sfen ...\n` | `Usi_Engine.m::usi_engine_send_line` | -| dylib → host | meta | `meta \n` where `` is one canonical JSON object | `Meta_Emitter.m::meta_emit_dict` | +| dylib → host | USI | one USI command per line, e.g. `usi\n`, `position sfen ...\n` | `Usi_Engine.m::usi_engine_send_line` | +| dylib → host | meta (extended USI) | `meta []\n` — one line per field, block-structured | `Meta_Emitter.m` (JB build only) | | host → dylib | USI | one USI command, lines split on `\r\n` / `\n` / `\r` | `Usi_Engine.m::usi_engine_text_handler` | +Both channels use the same line-oriented format: first token is the +command or `meta`, remaining tokens are the payload. The `meta` prefix +is the channel tag; the engine parser can skip any line starting with +`meta` without breaking the USI state machine. + There is no separate channel for control or for errors. If the dylib needs to tell the host something the host can't act on from the existing channels, it goes into the on-device log only (`tmp/kiouenginebridge.log` @@ -71,66 +106,134 @@ WebSocket. ## USI channel — state machine -The dylib is the **USI client** in USI-spec terms; the host's USI engine -(e.g. YaneuraOu) is the **engine**. The dylib never speaks `setoption` -or `go` — it only emits position lines and observes `bestmove` replies. -The host owns engine setup and the `go` cadence. +KEB acts as the **match-server side** of the USI protocol. The +connecting peer is a USI engine. KEB drives the full game loop — it +emits `usi`, `isready`, `usinewgame`, `position sfen ...`, `go ...`, +`stop`, `gameover`, and `quit`. The engine is expected to respond +according to the standard USI engine protocol. + +`setoption` is not emitted by KEB. Match conditions are set by KIOU +before KEB is even aware of them; there is no opportunity for KEB to +pre-negotiate engine options before the match starts. If the engine +requires option configuration, a wrapper should handle that (see § +Wrapper-mediated deployment). States are tracked atomically in `Usi_Engine.m::g_usiState`: ``` BOOT - ↓ WS client finishes upgrade → send "usi" + ↓ WS client finishes upgrade → send "usi" HANDSHAKE - ↓ inbound "usiok" → send "isready" - ↓ inbound "readyok" → send "usinewgame" + ↓ inbound "usiok" → send "isready" + ↓ inbound "readyok" → (no usinewgame here — see Notes) READY + ↓ usi_engine_on_match_start fires → send "usinewgame" ↓ observation says it is our turn → send "position sfen ..." + send "go ..." THINKING - ↓ inbound "bestmove " → inject move locally + ↓ inbound "bestmove " → inject move locally INJECTING - ↓ next observation arrives → back to READY + ↓ next observation arrives → back to READY ``` Notes: -- `usinewgame` is only sent once, on `readyok`. Subsequent matches - reuse the same engine session. +- `usinewgame` is sent once per match start (`usi_engine_on_match_start`), + not once per WebSocket session. On `readyok` KEB does NOT send + `usinewgame` — it waits for the first match-start event. This avoids a + double send on the first match while still priming the engine at each + subsequent match. - On WebSocket disconnect the state resets to `BOOT`. -- A match start where we already know the seat and it is our turn - triggers a 500 ms-delayed "kick" so the engine gets a `position` +- A match start where KEB already knows the seat and it is our turn + triggers a 500 ms-delayed "kick" so the engine gets a `position` + `go` even before any opponent move fires an observation. See `usi_engine_try_kick_on_main`. -### dylib → host (USI) +### dylib → peer (USI) | Line | When | Notes | |---|---|---| -| `usi` | WS client connected, state → `HANDSHAKE` | Standard USI handshake. | +| `usi` | WS client connected, state → `HANDSHAKE` | Standard USI handshake opener. | | `isready` | inbound `usiok` | | -| `usinewgame` | inbound `readyok` | One per WebSocket session, not per match. | -| `position sfen ` | observation says it is our turn AND state is `READY` | `` is the full SFEN after the opponent's move. The dylib never appends `moves ...` — the bridge / engine treats this as the absolute position. | +| `usinewgame` | `usi_engine_on_match_start` fires AND state is `READY` | One per match, not per WebSocket session. | +| `position sfen ` | observation says it is our turn AND state is `READY` | `` is the full SFEN after the opponent's move. KEB never appends `moves ...` — the engine treats this as an absolute position. | +| `go ...` | immediately after each `position sfen ...` | See § `go` command detail below. | +| `stop` | (reserved) | Not yet emitted by KEB in this version; see § `stop`. | | `gameover {win\|lose\|draw}` | match ends with a known result | Suppressed when the result is unknown (`USI_RESULT_UNKNOWN`, e.g. open-seat modes). | +| `quit` | (reserved) | See § `quit`. | + +#### `go` command detail + +KEB emits `go` immediately after each `position sfen ...`, in the same +turn decision that triggered the `position` line. + +The exact form of `go` depends on what clock information is available +from KIOU at the time the line is sent: + +**Clock-aware form** — used when KIOU has emitted at least one +authoritative snapshot this match (Online and CPUStream modes via +`UpdateAuthoritativeSnapshot`): + +``` +go btime wtime [byoyomi ] [binc ] [winc ] +``` + +- `btime` / `wtime` come from `g_latestBlackTimeSec` / + `g_latestWhiteTimeSec` (floats, converted to milliseconds). +- `byoyomi` is included when `time_control.byoyomi_seconds` from the + cached `MatchConfig.TimeControlConfig` is non-zero. +- `binc` / `winc` are included when + `time_control.increment_seconds` is non-zero. +- Parameters whose value is zero are omitted. + +**Fallback form** — used when no authoritative snapshot has arrived +yet (VsAI / LocalPvP / RecordReplay modes, or the first turn in Online +before the first snapshot lands): + +``` +go movetime +``` + +The movetime value defaults to `30000` (30 seconds) and is not yet +runtime-configurable. -The dylib does NOT emit `go`. The host is expected to observe -`position sfen ...` on the WebSocket and drive its engine with whatever -`go ...` it wants (`go btime ... wtime ...`, `go movetime ...`, etc). +The peer must handle both forms. A wrapper that wants to translate to +engine-specific time management may do so transparently. -### host → dylib (USI) +#### `stop` (outbound, reserved) -The dylib only acts on the first token of each inbound line. Lines may -be batched in a single text frame; the dylib splits on any of -`\r\n`, `\n`, `\r`. +KEB does not yet emit `stop` spontaneously. The command is reserved for +a future version where KEB needs to abort an in-flight engine search +before injecting a result (e.g. on sudden match end or operator resign). + +A peer that handles `stop` correctly (answering with `bestmove`) +is forward-compatible with this planned addition. + +#### `quit` (outbound, reserved) + +KEB does not yet emit `quit` spontaneously. The command is reserved so +that a future teardown path can signal clean session end before closing +the WebSocket. Peers SHOULD handle `quit` by closing their engine +process and disconnecting. + +### peer → dylib (USI) + +KEB only acts on the first token of each inbound line. Lines may be +batched in a single text frame; the dylib splits on any of `\r\n`, +`\n`, `\r`. | First token | Action | |---|---| | `id` | logged, otherwise ignored | | `option` | logged, otherwise ignored | | `usiok` | reply `isready` | -| `readyok` | reply `usinewgame`, state → `READY`, kick if it is already our turn | -| `info` | last `info string ...` is cached for debugging; everything else ignored | +| `readyok` | state → `READY`; kick if it is already our turn (does NOT send `usinewgame` — that waits for match start) | +| `info` | last `info string ...` is cached for debugging; everything else logged and ignored | | `bestmove resign` / `bestmove (none)` / `bestmove win` | no injection, state → `READY` | | `bestmove ` | state → `INJECTING`, call `inject_apply()`, then wait for the next observation to revert to `READY` | +| `bestmove ponder ` | same as above; ponder token is discarded | +| `stop` | if state is `THINKING`: logged (KEB waits for `bestmove` as normal — the peer is expected to reply with `bestmove` after receiving `stop`); otherwise logged and ignored | +| `quit` | KEB closes the WebSocket connection and resets state to `BOOT` | | anything else | logged at `[USI] ignored inbound:`, otherwise dropped | `` for `bestmove` is a normal USI move string (`7g7f`, `8h2b+`, @@ -138,119 +241,110 @@ be batched in a single text frame; the dylib splits on any of `ShogiGameAdapter.TryMakeMove` path, so it has to be legal in the current KIOU position. -There is no inbound `quit` handling — the host disconnects the WS to -shut the session down. +#### `setoption` (inbound — not handled) + +KEB does not process `setoption`. If the peer is a wrapper that forwards +engine options, it should send them to the engine directly rather than +over the WebSocket connection to KEB. Unrecognised first tokens +(including `setoption`) are logged and dropped. -## meta channel — JSON event stream +## meta channel — extended USI line stream -`meta` lines are best-effort match metadata that the host uses to build -a KIF or stat surface. **They are not delivered on the binpatch build** — -the binpatch flavour links no-op stubs in `Meta_Emitter.m` lines 3-18, -so a host running against a sideloaded KIOU sees only the USI channel. -On the jailbroken build the host can rely on the events below. +`meta` lines carry match metadata as an extended USI protocol. +**They are not delivered on the binpatch build** — the binpatch flavour +links no-op stubs in `Meta_Emitter.m` lines 3-18, so a host running +against a sideloaded KIOU sees only the USI channel. On the jailbroken +build the host can rely on the events below. -Each line has the shape: +### Syntax + +Every `meta` line follows the same line-oriented format as standard USI: ``` -meta {"type":"...","...":...}\n +meta []\n ``` -The leading literal `meta ` is the channel tag. Everything after the -first space is canonical JSON produced by `NSJSONSerialization`. There -are exactly three event types today. - -### `match_start` - -Emitted once per match. On Online matches a 1.5 s grace period waits -for `GameStateStore.SetBlackPlayerInfo` / `SetWhitePlayerInfo` to land -the matchmaking-resolved opponent; on CPU / LocalPvP matches it fires -from a fallback timer. Schema: - -```json -{ - "type": "match_start", - "mode": "VsAI" | "LocalPvP" | "OnlinePvP" | "RecordReplay" | "Spectate" | "Unknown()", - "started_at": "2026-06-15T20:43:11Z", - "local_player": "b" | "w" | null, - "start_position": "Standard" | "HandicapLance" | "HandicapBishop" | ... | "TsumeShogi" | "Unknown()", - "time_control": { - "main_seconds": 600, - "byoyomi_seconds": 30, - "increment_seconds": 0 - }, - "black": { - "name": "プレイヤー", - "rank": "六段", - "rate": 1832, - "user_id": "abc123" - }, - "white": { - "name": null, - "rank": null, - "rate": null, - "user_id": null - } -} -``` +- First token is always `meta`. +- Second token is the subcommand name. +- Remaining tokens (to end of line) are the value. Values may contain + spaces (e.g. player names); the parser MUST take everything after the + subcommand token as the value, not just the first word. +- Lines are grouped into **blocks** delimited by `meta BEGIN_` + and `meta END_`. All lines of a block arrive consecutively + with no interleaved USI lines. +- A parser that does not recognise a subcommand MUST skip the line + silently. + +### `BEGIN_GAME` / `END_GAME` block + +Emitted once per match, before `usinewgame`. On Online matches a 1.5 s +grace period waits for `GameStateStore.SetBlackPlayerInfo` / +`SetWhitePlayerInfo` to land the matchmaking-resolved opponent; on CPU / +LocalPvP matches it fires from a fallback timer. -- `local_player` is `null` for open-seat modes (LocalPvP, RecordReplay). -- `time_control` keys are always present; values are `null` if the - underlying `TimeControlConfig` was unreadable. -- `black` / `white` use the same skeleton in both directions; each - field is `null` if the source PlayerInfo did not supply it. -- `started_at` is ISO 8601 UTC. - -### `move` - -Emitted once per applied move, as seen by -`ShogiGameAdapter.TryMakeMove(Move, out)`. The hook deliberately runs -on the main queue after the original returns, so `sfen_after` is the -post-move authoritative SFEN. - -```json -{ - "type": "move", - "ply": 17, - "side": "b" | "w" | null, - "usi": "7g7f", - "elapsed_ms": 4321, - "sfen_after": "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1" -} ``` +meta BEGIN_GAME +meta protocol_version 1.0 +meta game_id 20260615T204311-VsAI +meta mode VsAI +meta started_at 2026-06-15T20:43:11Z +meta start_position Standard +meta your_turn b +meta name+ プレイヤー +meta name- KIOU CPU (Normal) +meta time_unit 1sec +meta total_time 600 +meta byoyomi 30 +meta increment 0 +meta END_GAME +``` + +| Subcommand | Value | Notes | +|---|---|---| +| `protocol_version` | `1.0` | KEB extended USI protocol version. | +| `game_id` | `-` | Unique per match; derived from `started_at` and mode. | +| `mode` | `VsAI` \| `LocalPvP` \| `OnlinePvP` \| `RecordReplay` \| `Spectate` | KIOU match mode. | +| `started_at` | ISO 8601 UTC | | +| `start_position` | `Standard` \| `HandicapLance` \| … \| `TsumeShogi` | Initial position type. | +| `your_turn` | `b` \| `w` \| `-` | Local player's seat. `-` for open-seat modes. | +| `name+` | string (rest of line) | Black player name. Omitted if unknown. | +| `name-` | string (rest of line) | White player name. Omitted if unknown. | +| `rank+` | string | Black player rank. Omitted if unknown. | +| `rank-` | string | White player rank. Omitted if unknown. | +| `rate+` | integer | Black player rate. Omitted if unknown. | +| `rate-` | integer | White player rate. Omitted if unknown. | +| `user_id+` | string | Black player user ID. Omitted if unknown. | +| `user_id-` | string | White player user ID. Omitted if unknown. | +| `time_unit` | `1sec` | Always seconds; `go` uses milliseconds. | +| `total_time` | integer (seconds) | Initial main time per player. Omitted if unlimited or unreadable. | +| `byoyomi` | integer (seconds) | Byoyomi per move. Omitted if zero. | +| `increment` | integer (seconds) | Increment per move. Omitted if zero. | + +### `meta move` line + +Emitted once per applied move, from `ShogiGameAdapter.TryMakeMove(Move, +out)`. Modelled directly on the CSA per-move notification (`+7776FU,T10`). -- `ply` prefers the SFEN's trailing move-number (1-based, minus one for - "just played"); falls back to an internal counter if the SFEN does - not parse. -- `side` is the side that just moved (the opposite of the SFEN's side - to move). `null` if the dylib can't tell. -- `elapsed_ms` is wall-clock between consecutive `move` emits, not - engine think time. -- Remaining-time fields are intentionally NOT in the MVP schema; they - will be added once the `ReactiveProperty` layout is pinned. - -### `match_end` - -Emitted from `Hook_MatchModeObserve`'s `OnMatchEndAsync` after the -result has been inferred from the side-to-move of the final SFEN -versus the cached `local_player`. - -```json -{ - "type": "match_end", - "ended_at": "2026-06-15T20:51:02Z", - "result": "win" | "lose" | "draw" | "unknown", - "total_moves": 73, - "final_sfen": "lnsgk...", - "usi_text": "startpos moves 7g7f 3c3d 2g2f ..." -} ``` +meta move +7g7f T10 +``` + +| Token | Value | Notes | +|---|---|---| +| `+` \| `-` | side | `+` = black just moved, `-` = white just moved. | +| USI move | e.g. `7g7f`, `8h2b+`, `P*5e` | The move that was applied. | +| `T` | integer (seconds) | Time spent on this move, derived from the difference between consecutive authoritative snapshots (`g_latestBlackTimeSec` / `g_latestWhiteTimeSec`). Omitted in AI / Local modes where KIOU does not issue snapshots. | -- `result` is `unknown` for open-seat modes and for any match where - the dylib can't compare side-to-move against a fixed local seat. -- `usi_text` is `GameController.GetUSIText`'s raw return value. The - host should treat it as the authoritative record and use it to - rewrite whatever it accumulated from `move` events — `move` deltas - can drift on jump-moves, drops, and duplicate observation fires. +The receiver can compute remaining time as `total_time - Σ T` (summing +only the moves belonging to that side), exactly as in the CSA protocol. +The `go btime/wtime` line KEB sends on the next turn carries the same +remaining time directly for engines that prefer not to track it +themselves. + +Match end is signalled by `gameover` on the USI channel. No `meta` +block is emitted at match end in the current version. A `BEGIN_RESULT` +block may be added in a future version if the host needs structured +end-of-game data (final SFEN, full USI text, etc.). ## Build-flavour matrix @@ -264,43 +358,111 @@ binpatch build's USI half is enough to drive the engine end of a CPU / Online match, but the host has to live without the structured `meta` stream and reconstruct what it can from the on-device log file. +## Wrapper-mediated deployment + +A wrapper is an optional process that sits between KEB and a raw USI +engine. KEB makes no distinction — from its perspective the peer is +always a USI engine, whether or not a wrapper is in the path. + +Typical wrapper responsibilities: + +- Launching the engine as a child process and bridging stdio ↔ WebSocket. +- Injecting `setoption` lines at startup (KEB does not do this). +- Translating KEB's `go btime/wtime/...` into engine-specific variants + (`go depth`, `go nodes`, custom byoyomi policies). +- Logging or recording raw engine I/O. + +``` +KEB ←—— WebSocket (USI) ——→ Wrapper ←—— stdio ——→ USI engine +``` + +A wrapper is NOT required if the peer is already a conforming USI +engine that can accept `go btime/wtime/byoyomi` directly. + ## Example session -Host's perspective, from connect to one engine reply, on the JB build. -Lines starting with `>` are sent by the host, `<` by the dylib. -`\n`s and the leading `meta ` prefix are shown literally. +Peer's perspective (peer = wrapper or raw engine), from connect to one +engine reply, on the JB build. Lines starting with `>` are sent by the +peer, `<` by KEB. `\n`s and the leading `meta ` prefix are shown +literally. ``` -< usi\n -> id name YaneuraOu NNUE 7.6.3\n -> option name USI_Hash type spin default 16\n -> usiok\n -< isready\n -> readyok\n -< usinewgame\n -< meta {"type":"match_start","mode":"VsAI","started_at":"2026-06-15T20:43:11Z","local_player":"b","start_position":"Standard","time_control":{"main_seconds":600,"byoyomi_seconds":30,"increment_seconds":0},"black":{"name":"プレイヤー","rank":null,"rate":null,"user_id":null},"white":{"name":"KIOU CPU (Normal)","rank":null,"rate":null,"user_id":null}}\n -< position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1\n -> info depth 12 score cp 32 pv 7g7f\n -> bestmove 7g7f\n -< meta {"type":"move","ply":1,"side":"b","usi":"7g7f","elapsed_ms":4321,"sfen_after":"lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PP1PPPPPP/1B5R1/LNSGKGSNL w - 2"}\n +< usi +> id name YaneuraOu NNUE 7.6.3 +> option name USI_Hash type spin default 16 +> usiok +< isready +> readyok +(state = READY; no usinewgame yet — KEB waits for match start) +< meta BEGIN_GAME +< meta protocol_version 1.0 +< meta game_id 20260615T204311-VsAI +< meta mode VsAI +< meta started_at 2026-06-15T20:43:11Z +< meta start_position Standard +< meta your_turn b +< meta name+ プレイヤー +< meta name- KIOU CPU (Normal) +< meta time_unit 1sec +< meta total_time 600 +< meta byoyomi 30 +< meta END_GAME +< usinewgame +< position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1 +< go movetime 30000 +> info depth 12 score cp 32 pv 7g7f +> bestmove 7g7f +< meta move +7g7f T10 +... +< gameover win +``` + +The VsAI example above shows the `go movetime` fallback because KIOU +does not send authoritative clock snapshots in that mode. `black_time_sec` +/ `white_time_sec` are also omitted from `BEGIN_MOVE` for the same reason. + +For an Online match the `go` line and move block carry live clock values: + +``` +... +> readyok +(state = READY) +< meta BEGIN_GAME +< meta protocol_version 1.0 +< meta game_id 20260615T204311-OnlinePvP +< meta mode OnlinePvP +< meta your_turn b +< meta name+ プレイヤー +< meta name- 対戦相手 +< meta time_unit 1sec +< meta total_time 600 +< meta byoyomi 30 +< meta END_GAME +< usinewgame +< position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1 +< go btime 600000 wtime 600000 byoyomi 30000 +> bestmove 7g7f +< meta move +7g7f T10 ... -< meta {"type":"match_end","ended_at":"2026-06-15T20:51:02Z","result":"win","total_moves":73,"final_sfen":"...","usi_text":"startpos moves 7g7f 3c3d ..."}\n -< gameover win\n +< gameover win ``` -Note that the `match_start` meta line lands BEFORE the first -`position sfen ...`. The host can use that ordering as a cue that a new -KIF is starting; it doesn't have to infer it from move numbers. +Note that the `BEGIN_GAME` block always arrives before `usinewgame` and +the first `position sfen`. The engine can use that ordering as a cue +that a new game is starting; it does not need to infer it from move +numbers. ## Compatibility notes -- The protocol is unversioned. New fields may be added to existing - `meta` events without notice; the host MUST ignore unknown keys and - MUST NOT pin on the field order. -- New `meta` event `type`s may appear in the future. The host MUST drop - unknown types silently rather than disconnecting. -- The USI channel is whatever USI engines already speak; the dylib - only emits the subset listed above, but the host is free to send any - USI line — unknown first-tokens are logged and dropped, never - errored. This means a future dylib can start handling more inbound - tokens without coordinating a host upgrade. +- The `meta` protocol version is carried in `meta protocol_version` + inside each `BEGIN_GAME` block. Parsers MUST ignore unknown + subcommands within a block and MUST NOT error on unrecognised + `BEGIN_` / `END_` pairs — both will appear in future + versions. +- Fields within a block may be added in future versions without a + version bump. Parsers MUST NOT require all fields to be present. +- The USI channel is standard USI. KEB only emits the subset listed + above, but the host is free to send any USI line — unknown + first-tokens are logged and dropped, never errored. A future KEB + version can handle more inbound tokens without requiring a host + upgrade. diff --git a/docs/usi-compatibility.md b/docs/usi-compatibility.md new file mode 100644 index 0000000..c30c9be --- /dev/null +++ b/docs/usi-compatibility.md @@ -0,0 +1,294 @@ +# KIOU-KEB USI compatibility + +This document explains the KIOU-KEB role in the USI protocol and which +commands are supported. The authoritative wire contract is `docs/bridge_protocol.md`; +if this file and that document disagree, `bridge_protocol.md` wins. + +Primary implementation sources: + +- `Sources/KiouEngineBridge/Usi_Engine.m` +- `Sources/KiouEngineBridge/Server_WebSocket.m` +- `Sources/KiouEngineBridge/Meta_Emitter.m` + +## Role model + +KEB is a **USI match server** embedded inside the KIOU process — similar +in role to Floodgate or shogi-server. KIOU is the authoritative source +of board state and clocks. An engine (or a wrapper in front of one) +connects to KEB over WebSocket and receives `position` + `go` each turn, +exactly as it would from any other USI match server. + +```mermaid +flowchart LR + K[KIOU\nboard state / clocks] + B[KIOU-KEB\nUSI match server] + P[USI engine\nor wrapper] + U[Operator\nlaunch / configure engine] + + K <-->|observe / inject\nin-process| B + B <-->|USI + meta\nWebSocket :9527| P + U -->|configure / launch| P +``` + +The engine does not need to know or care that there is a KIOU process +behind KEB. It connects, plays, and receives `bestmove` feedback in +standard USI. + +### Why KEB cannot pre-negotiate time control + +In a typical USI GUI the time control is known before the match starts +and can be communicated via `setoption`. KEB has no such opportunity: +match conditions are set by KIOU and only become available to KEB +after `OnMatchStart` fires. The engine therefore learns the time budget +from the first `go btime/wtime/byoyomi` line of the first turn, not +from a pre-match declaration. A wrapper that needs to translate this +into engine-specific search limits should observe the `go` line and +remap it accordingly. + +## CSA protocol correspondence + +KEB's design is conceptually modelled on the CSA server protocol +(v1.2.1, `https://www.computer-shogi.org/protocol/tcp_ip_server_121.html`). +The table below maps each CSA concept to the KEB equivalent and notes +where reproduction is impossible given KIOU's constraints. + +### Session management + +| CSA concept | CSA line | KEB equivalent | Status | +|---|---|---|---| +| Connect | TCP to port 4081 | WebSocket to port 9527 | ✅ different transport, same idea | +| Login | `LOGIN name pass` | WS upgrade (no auth) | ⚠️ omitted — no auth in KEB | +| Logout | `LOGOUT` / `LOGOUT:completed` | `quit` / WS disconnect | ✅ partial | + +### Match negotiation + +CSA servers send a `BEGIN Game_Summary ... END Game_Summary` block +before the match starts so engines can inspect time control and initial +position before agreeing. KEB has no equivalent pre-match block because +match conditions are not known until `OnMatchStart` fires inside KIOU. +Instead, the same information is delivered in two ways: + +- **`meta match_start`** (JB build only) — JSON sidecar with player + names, time control settings, mode, and initial position type. Arrives + before the first `position sfen`. +- **First `go` line** — carries the live `btime`/`wtime`/`byoyomi` + values for the opening position. + +| CSA field | CSA line | KEB equivalent | Status | +|---|---|---|---| +| Protocol version | `Protocol_Version:1.2` | — | ⛔ omitted | +| Game ID | `Game_ID:...` | — | ⛔ omitted | +| Black player name | `Name+:YaneuraOu` | `meta match_start: black.name` | ✅ JB build | +| White player name | `Name-:Kristallweizen` | `meta match_start: white.name` | ✅ JB build | +| Your seat | `Your_Turn:+` | `meta match_start: local_player` | ✅ JB build | +| First to move | `To_Move:+` | SFEN side-to-move in first `position` | ✅ | +| Max moves | `Max_Moves:256` | — | ⛔ KIOU limit not exposed | +| Rematch on draw | `Rematch_On_Draw:NO` | — | ⛔ omitted | +| Engine agree/reject | `AGREE` / `REJECT` | — | ⛔ omitted — engine just connects and waits | +| Match start signal | `START:` | `usinewgame` | ✅ | + +### Time control + +CSA's `BEGIN Time ... END Time` block can express a rich variety of +clocks. KEB delivers the same information via `go` parameters each turn, +derived from `MatchConfig.TimeControlConfig` and the live snapshot cache. + +| CSA field | CSA line | KEB equivalent | Status | +|---|---|---|---| +| Time unit | `Time_Unit:1sec` | implicit (ms in `go`) | ✅ always ms | +| Total (main) time | `Total_Time:600` | `btime` / `wtime` in `go` | ✅ when snapshot available | +| Byoyomi | `Byoyomi:30` | `byoyomi` in `go` | ✅ | +| Increment | `Increment:10` | `binc` / `winc` in `go` | ✅ | +| Delay | `Delay:5` | — | ⛔ not exposed by KIOU | +| Min time per move | `Least_Time_Per_Move:1` | — | ⛔ not exposed by KIOU | +| Time roundup | `Time_Roundup:YES` | — | ⛔ not exposed by KIOU | +| Unlimited time | (all fields omitted) | `go movetime 30000` (fallback) | ⚠️ treated as movetime fallback | + +### Per-turn exchange + +This is the core of the CSA protocol: server notifies each engine of the +opponent's move, engine replies with its own move. + +| CSA concept | CSA direction / line | KEB equivalent | Status | +|---|---|---|---| +| Position after opponent's move | `+7776FU,T10` (incremental) | `position sfen ...` (full snapshot) | ✅ different encoding | +| Remaining / consumed time per turn | `,T10` suffix on move line | `btime` / `wtime` in `go` | ✅ remaining (not consumed) | +| Engine submits move | `+7776FU` | `bestmove 7g7f` | ✅ | +| Engine resigns | `%TORYO` | `bestmove resign` | ✅ | +| Engine claims win (nyugyoku) | `%KACHI` | `bestmove win` | ✅ | +| Engine requests abort | `%CHUDAN` | — | ⛔ not supported | +| Keepalive / heartbeat | LF (≥30 s interval) | WS Ping/Pong frames | ✅ handled by transport | + +### Match result + +| CSA result | CSA lines | KEB equivalent | Status | +|---|---|---|---| +| Resign | `#RESIGN` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ✅ | +| Time up | `#TIME_UP` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ⚠️ result only, no TIME_UP signal | +| Illegal move | `#ILLEGAL_MOVE` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ⚠️ result only; KIOU handles detection | +| Sennichite (draw) | `#SENNICHITE` + `#DRAW` | `gameover draw` | ✅ | +| Oute-sennichite | `#OUTE_SENNICHITE` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ⚠️ result only | +| Nyugyoku win | `#JISHOGI` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ⚠️ result only | +| Max moves reached | `#MAX_MOVES` + `#CENSORED` | `gameover draw` (KIOU-dependent) | ⚠️ result only | +| Server abort | `#CHUDAN` | — | ⛔ not surfaced | +| Unknown / open-seat | — | (gameover suppressed) | ✅ | + +## Standard USI flow + +```mermaid +sequenceDiagram + autonumber + participant K as KIOU + participant B as KIOU-KEB\n(match server) + participant P as USI engine + + P->>B: WebSocket connect + + Note over B,P: USI handshake (KEB initiates) + B->>P: usi + P-->>B: id name ... + P-->>B: id author ... + P-->>B: option ... + P-->>B: usiok + B->>P: isready + P-->>B: readyok + Note over B,P: state = READY (no usinewgame yet) + + Note over K,B: Match start + K-->>B: OnMatchStart → local_player latched + B-->>P: meta BEGIN_GAME / ... / meta END_GAME + B->>P: usinewgame + + loop When it becomes our turn + K-->>B: TryMakeMove observation → SFEN / side_to_move + B->>P: position sfen + B->>P: go btime wtime byoyomi + P-->>B: info ... + P-->>B: bestmove 7g7f + Note over B,K: inject_apply → KIOU's move pipeline + B->>K: replay move + B-->>P: meta move +7g7f T10 + end + + K-->>B: OnMatchEndAsync + B->>P: gameover win|lose|draw +``` + +## Wrapper-mediated deployment (optional) + +A wrapper sits between KEB and the raw engine process. From KEB's +perspective, the wrapper is just the peer — the protocol is identical. +Typical wrapper responsibilities are engine spawning, `setoption` +injection, and `go` policy translation. + +```mermaid +sequenceDiagram + autonumber + participant B as KIOU-KEB\n(match server) + participant W as Wrapper + participant E as USI engine + + B->>W: usi + W->>E: usi + E-->>W: id / option / usiok + W-->>B: id / option / usiok + + B->>W: isready + W->>E: isready + E-->>W: readyok + W-->>B: readyok + + B->>W: usinewgame + W->>E: usinewgame + + B->>W: position sfen + B->>W: go btime 540000 wtime 570000 byoyomi 30000 + W->>E: position sfen + W->>E: go btime 540000 wtime 570000 byoyomi 30000 + Note over W,E: wrapper may remap to go depth / go nodes / etc. + + E-->>W: info ... + E-->>W: bestmove 7g7f + W-->>B: info ... + W-->>B: bestmove 7g7f +``` + +## Supported USI surface + +Implemented in `Sources/KiouEngineBridge/Usi_Engine.m`. + +### KEB → engine (outbound) + +| Command | Status | Notes | +|---|---|---| +| `usi` | ✅ supported | Sent on WS connect. | +| `isready` | ✅ supported | Sent on inbound `usiok`. | +| `usinewgame` | ✅ supported | Sent once per match start (not per WS session). | +| `position sfen ` | ✅ supported | Emitted when KIOU observation says it is our turn. | +| `go btime wtime [byoyomi ] [binc ] [winc ]` | ✅ supported | Emitted immediately after `position`, when authoritative clocks are available (Online / CPUStream). | +| `go movetime 30000` | ✅ supported | Fallback when authoritative clocks are not available (VsAI / Local modes, or first turn before snapshot arrives). | +| `gameover win\|lose\|draw` | ✅ supported | Sent on match end when the result is known. Suppressed for open-seat modes. | +| `stop` | 🔜 reserved | Not yet emitted. Planned for forced abort (e.g. operator resign mid-think). | +| `quit` | 🔜 reserved | Not yet emitted. Planned for clean session teardown. | + +### engine → KEB (inbound) + +| Command | Status | Notes | +|---|---|---| +| `id name ...` / `id author ...` | ✅ observed | Logged; not interpreted. | +| `option ...` | ✅ observed | Logged; not interpreted. | +| `usiok` | ✅ supported | Advances handshake; triggers `isready`. | +| `readyok` | ✅ supported | State → READY; triggers post-readyok kick if already our turn. Does NOT send `usinewgame`. | +| `info string ...` | ✅ partial | Value cached for debugging. | +| `info` (other forms) | ✅ partial | Logged; otherwise ignored. | +| `bestmove ` | ✅ supported | Injected through KIOU's normal move pipeline. | +| `bestmove resign` / `(none)` / `win` | ✅ supported | No injection; state → READY. | +| `bestmove ponder ` | ✅ partial | Primary move injected; ponder token discarded. | +| `stop` | ✅ supported | If state is THINKING: logged, KEB waits for `bestmove` as normal. Otherwise ignored. | +| `quit` | ✅ supported | KEB closes WS and resets to BOOT. | +| `setoption ...` | ❌ not handled | Logged and dropped. Configure engine options in the wrapper. | +| `ponderhit` | ❌ not supported | No ponder mode exposed. | +| anything else | — | Logged at `[USI] ignored inbound:`; dropped. | + +## `go` command detail + +KEB emits `go` immediately after each `position sfen ...`, in the same +turn decision that triggered the `position` line. + +**Clock-aware form** (Online and CPUStream modes, once a snapshot has arrived): + +``` +go btime wtime [byoyomi ] [binc ] [winc ] +``` + +- `btime` / `wtime` come from the most recent `UpdateAuthoritativeSnapshot` + call (`g_latestBlackTimeSec` / `g_latestWhiteTimeSec`, converted to ms). +- `byoyomi` / `binc` / `winc` come from `MatchConfig.TimeControlConfig` + (`byoyomi_seconds` / `increment_seconds`). Parameters whose value is + zero are omitted. + +**Fallback form** (VsAI / LocalPvP / RecordReplay, or before the first +snapshot lands in Online): + +``` +go movetime 30000 +``` + +The movetime value is 30 000 ms and is not yet runtime-configurable. + +## Scope boundary + +YaneuraOu-specific extensions (`go depth`, `go nodes`, `go rtime`, +`getoption`, `bench`, `moves`, `side`, etc.) and standard commands not +listed above (`debug`, `register`, `ponderhit`) are intentionally out of +scope for the KEB-level protocol surface. + +If you need those, implement them in the wrapper layer and translate +to/from the `go btime/wtime/byoyomi` form that KEB emits. + +## Related documents + +- `docs/bridge_protocol.md` — authoritative wire contract and full example + sessions. +- `README.md` — project overview and install/build notes. +- CSA server protocol v1.2.1 — `https://www.computer-shogi.org/protocol/tcp_ip_server_121.html` From e2beb063664344e2afa4bc9b90b24f51a5b19fff Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 20:59:01 +0000 Subject: [PATCH 15/51] refactor: rename Kiou-prefixed functions to Keb prefix KiouWsServer* -> KebWsServer* KiouInjectDumpRecent -> KebInjectDumpRecent KiouBridgeBinpatchPublish -> KebBridgeBinpatchPublish Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/BinpatchDispatcher.m | 4 ++-- Sources/KiouEngineBridge/Inject_Move.m | 4 ++-- Sources/KiouEngineBridge/Internal.h | 14 +++++++------- Sources/KiouEngineBridge/Meta_Emitter.m | 2 +- Sources/KiouEngineBridge/Server_WebSocket.m | 14 +++++++------- Sources/KiouEngineBridge/Tweak.m | 6 +++--- Sources/KiouEngineBridge/Usi_Engine.m | 4 ++-- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Sources/KiouEngineBridge/BinpatchDispatcher.m b/Sources/KiouEngineBridge/BinpatchDispatcher.m index e0c199d..1955638 100644 --- a/Sources/KiouEngineBridge/BinpatchDispatcher.m +++ b/Sources/KiouEngineBridge/BinpatchDispatcher.m @@ -21,7 +21,7 @@ // `recipes/kiouenginebridge.py`'s ``HOOK_SLOT_RVA`` / // ``_build_bridge_cave_payload``). // -// 2. ``KiouBridgeBinpatchPublish`` below stores `&dispatch_one` +// 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 @@ -130,7 +130,7 @@ static void dispatch_one(void *x0, void *x1, void *x2, void *x3, void *x4, } } -void KiouBridgeBinpatchPublish(void) { +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 diff --git a/Sources/KiouEngineBridge/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index 66a9b97..80a2a37 100644 --- a/Sources/KiouEngineBridge/Inject_Move.m +++ b/Sources/KiouEngineBridge/Inject_Move.m @@ -626,7 +626,7 @@ static kiou_route_t inject_pickRoute(void) { // --------------------------------------------------------------------------- // Ring buffer for recent injections. Single producer (the recv-queue handler // after the main-thread dispatch returns), so a simple mutex is overkill, -// but KiouInjectDumpRecent() 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]; @@ -642,7 +642,7 @@ static void inject_pushRecord(const kiou_inject_record_t *rec) { pthread_mutex_unlock(&g_ringMu); } -void KiouInjectDumpRecent(void) { +void KebInjectDumpRecent(void) { pthread_mutex_lock(&g_ringMu); size_t count = g_ringCount; size_t head = g_ringHead; diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index 4182fe4..653ce5b 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -128,8 +128,8 @@ extern void *volatile g_gameOrchestratorCache; // then any hook can push a single JSON-encoded line to whichever host is // currently connected. No-op when no host is attached. // --------------------------------------------------------------------------- -void KiouWsServerStart(uint16_t port); -void KiouWsServerPush(NSString *json); +void KebWsServerStart(uint16_t port); +void KebWsServerPush(NSString *json); // Register a callback for inbound TEXT frames (opcode 0x1). The handler is // invoked on the recv queue (a serial dispatch queue, NOT the main thread). @@ -137,7 +137,7 @@ void KiouWsServerPush(NSString *json); // 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 KiouWsServerSetTextHandler(kiou_ws_text_handler_t fn); +void KebWsServerSetTextHandler(kiou_ws_text_handler_t fn); // --------------------------------------------------------------------------- // Observation-side instance cache, populated by Hook_LowLevelObserve.m and @@ -271,7 +271,7 @@ 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 KiouInjectDumpRecent(void); +void KebInjectDumpRecent(void); // --------------------------------------------------------------------------- // Injection bridge — called by Usi_Engine.m when YaneuraOu sends us a @@ -466,7 +466,7 @@ enum kiou_bridge_hook_id { }; // g_inject_entry is binpatch-only — it's populated by -// KiouBridgeBinpatchPublish() with per-site cave-bypass entry pointers, +// 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 @@ -558,7 +558,7 @@ typedef void (*kiou_bridge_dispatcher_t)(void *x0, void *x1, void *x2, // __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 KiouBridgeBinpatchPublish(void); +void KebBridgeBinpatchPublish(void); // --------------------------------------------------------------------------- // Hook function bodies reached from the binpatch dispatcher. Defined in @@ -630,5 +630,5 @@ 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 KiouWsServerPush. +// WS accept queue via KebWsServerPush. void UsiEngineSendLine(NSString *line); diff --git a/Sources/KiouEngineBridge/Meta_Emitter.m b/Sources/KiouEngineBridge/Meta_Emitter.m index a7886b5..1462d9f 100644 --- a/Sources/KiouEngineBridge/Meta_Emitter.m +++ b/Sources/KiouEngineBridge/Meta_Emitter.m @@ -301,7 +301,7 @@ static void meta_emit_dict(NSDictionary *payload) { } NSString *line = [NSString stringWithFormat:@"meta %@\n", body]; file_log([NSString stringWithFormat:@"[META>] %@", body]); - KiouWsServerPush(line); + KebWsServerPush(line); } // --------------------------------------------------------------------------- diff --git a/Sources/KiouEngineBridge/Server_WebSocket.m b/Sources/KiouEngineBridge/Server_WebSocket.m index 10f812b..7f6a1e9 100644 --- a/Sources/KiouEngineBridge/Server_WebSocket.m +++ b/Sources/KiouEngineBridge/Server_WebSocket.m @@ -29,7 +29,7 @@ // 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 KiouWsServerPush() through one +// - A serial GCD queue funnels every KebWsServerPush() 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. @@ -55,7 +55,7 @@ // --------------------------------------------------------------------------- // Singleton-ish state. The whole module assumes at most one server per -// process, which matches the way Tweak.m calls KiouWsServerStart() once. +// process, which matches the way Tweak.m calls KebWsServerStart() once. // --------------------------------------------------------------------------- static dispatch_queue_t g_acceptQueue = NULL; // serial, handshake + writes static dispatch_queue_t g_recvQueue = NULL; // serial, blocking client reads @@ -66,7 +66,7 @@ static NSUInteger g_pendingSends = 0; // best-effort backlog gauge // Inbound text-frame callback. Inject_Move.m self-registers via -// KiouWsServerSetTextHandler() at constructor time. Reads happen on +// KebWsServerSetTextHandler() 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; @@ -353,7 +353,7 @@ static void ws_client_recv_loop(int fd) { // 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 KiouWsServerPush -> accept queue) and + // ws_send_text (from KebWsServerPush -> 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 @@ -468,7 +468,7 @@ static void ws_handle_accept(void) { // --------------------------------------------------------------------------- // Public API. // --------------------------------------------------------------------------- -void KiouWsServerStart(uint16_t port) { +void KebWsServerStart(uint16_t port) { if (g_listenFd >= 0) { file_log(@"[WS] server already running"); return; @@ -517,7 +517,7 @@ void KiouWsServerStart(uint16_t port) { (unsigned)port]); } -void KiouWsServerSetTextHandler(kiou_ws_text_handler_t fn) { +void KebWsServerSetTextHandler(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 @@ -525,7 +525,7 @@ void KiouWsServerSetTextHandler(kiou_ws_text_handler_t fn) { g_textHandler = fn; } -void KiouWsServerPush(NSString *json) { +void KebWsServerPush(NSString *json) { if (!json) return; if (!g_acceptQueue) return; // server never started diff --git a/Sources/KiouEngineBridge/Tweak.m b/Sources/KiouEngineBridge/Tweak.m index 30bc455..744c18a 100644 --- a/Sources/KiouEngineBridge/Tweak.m +++ b/Sources/KiouEngineBridge/Tweak.m @@ -59,7 +59,7 @@ static void installUnityHooks(void) { // 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. - KiouBridgeBinpatchPublish(); + 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 @@ -130,8 +130,8 @@ static void retryInstallHooks(void) { // 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 KiouWsServerPush() call below is a no-op. - KiouWsServerStart(9527); + // every KebWsServerPush() call below is a no-op. + KebWsServerStart(9527); // UnityFramework is almost certainly not mapped yet at constructor time. installUnityHooks(); diff --git a/Sources/KiouEngineBridge/Usi_Engine.m b/Sources/KiouEngineBridge/Usi_Engine.m index 0191854..b406e3a 100644 --- a/Sources/KiouEngineBridge/Usi_Engine.m +++ b/Sources/KiouEngineBridge/Usi_Engine.m @@ -94,7 +94,7 @@ void UsiEngineSendLine(NSString *line) { file_log([NSString stringWithFormat:@"[USI>] %@", [line stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]]); - KiouWsServerPush(withNewline); + KebWsServerPush(withNewline); } // --------------------------------------------------------------------------- @@ -457,6 +457,6 @@ void UsiEngineOnWsClientDisconnected(void) { // observation hooks are in place. // --------------------------------------------------------------------------- void UsiEngineInstall(void) { - KiouWsServerSetTextHandler(usi_engine_text_handler); + KebWsServerSetTextHandler(usi_engine_text_handler); file_log(@"[USI] engine installed (text handler registered)"); } From 8488537ce35f9e3973a1a0939d818cc682b8ceee Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 21:04:11 +0000 Subject: [PATCH 16/51] refactor: change Keb prefix to KEB (ObjC convention) KebWsServer* -> KEBWsServer* KebInjectDumpRecent -> KEBInjectDumpRecent KebBridgeBinpatchPublish -> KEBBridgeBinpatchPublish Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/BinpatchDispatcher.m | 4 ++-- Sources/KiouEngineBridge/Inject_Move.m | 4 ++-- Sources/KiouEngineBridge/Internal.h | 14 +++++++------- Sources/KiouEngineBridge/Meta_Emitter.m | 2 +- Sources/KiouEngineBridge/Server_WebSocket.m | 14 +++++++------- Sources/KiouEngineBridge/Tweak.m | 6 +++--- Sources/KiouEngineBridge/Usi_Engine.m | 4 ++-- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Sources/KiouEngineBridge/BinpatchDispatcher.m b/Sources/KiouEngineBridge/BinpatchDispatcher.m index 1955638..eb18e68 100644 --- a/Sources/KiouEngineBridge/BinpatchDispatcher.m +++ b/Sources/KiouEngineBridge/BinpatchDispatcher.m @@ -21,7 +21,7 @@ // `recipes/kiouenginebridge.py`'s ``HOOK_SLOT_RVA`` / // ``_build_bridge_cave_payload``). // -// 2. ``KebBridgeBinpatchPublish`` below stores `&dispatch_one` +// 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 @@ -130,7 +130,7 @@ static void dispatch_one(void *x0, void *x1, void *x2, void *x3, void *x4, } } -void KebBridgeBinpatchPublish(void) { +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 diff --git a/Sources/KiouEngineBridge/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index 80a2a37..32d1196 100644 --- a/Sources/KiouEngineBridge/Inject_Move.m +++ b/Sources/KiouEngineBridge/Inject_Move.m @@ -626,7 +626,7 @@ static kiou_route_t inject_pickRoute(void) { // --------------------------------------------------------------------------- // Ring buffer for recent injections. Single producer (the recv-queue handler // after the main-thread dispatch returns), so a simple mutex is overkill, -// but KebInjectDumpRecent() 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]; @@ -642,7 +642,7 @@ static void inject_pushRecord(const kiou_inject_record_t *rec) { pthread_mutex_unlock(&g_ringMu); } -void KebInjectDumpRecent(void) { +void KEBInjectDumpRecent(void) { pthread_mutex_lock(&g_ringMu); size_t count = g_ringCount; size_t head = g_ringHead; diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index 653ce5b..2bce639 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -128,8 +128,8 @@ extern void *volatile g_gameOrchestratorCache; // then any hook can push a single JSON-encoded line to whichever host is // currently connected. No-op when no host is attached. // --------------------------------------------------------------------------- -void KebWsServerStart(uint16_t port); -void KebWsServerPush(NSString *json); +void KEBWsServerStart(uint16_t port); +void KEBWsServerPush(NSString *json); // Register a callback for inbound TEXT frames (opcode 0x1). The handler is // invoked on the recv queue (a serial dispatch queue, NOT the main thread). @@ -137,7 +137,7 @@ void KebWsServerPush(NSString *json); // 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 KebWsServerSetTextHandler(kiou_ws_text_handler_t fn); +void KEBWsServerSetTextHandler(kiou_ws_text_handler_t fn); // --------------------------------------------------------------------------- // Observation-side instance cache, populated by Hook_LowLevelObserve.m and @@ -271,7 +271,7 @@ 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 KebInjectDumpRecent(void); +void KEBInjectDumpRecent(void); // --------------------------------------------------------------------------- // Injection bridge — called by Usi_Engine.m when YaneuraOu sends us a @@ -466,7 +466,7 @@ enum kiou_bridge_hook_id { }; // g_inject_entry is binpatch-only — it's populated by -// KebBridgeBinpatchPublish() with per-site cave-bypass entry pointers, +// 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 @@ -558,7 +558,7 @@ typedef void (*kiou_bridge_dispatcher_t)(void *x0, void *x1, void *x2, // __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); +void KEBBridgeBinpatchPublish(void); // --------------------------------------------------------------------------- // Hook function bodies reached from the binpatch dispatcher. Defined in @@ -630,5 +630,5 @@ 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 KebWsServerPush. +// WS accept queue via KEBWsServerPush. void UsiEngineSendLine(NSString *line); diff --git a/Sources/KiouEngineBridge/Meta_Emitter.m b/Sources/KiouEngineBridge/Meta_Emitter.m index 1462d9f..598c107 100644 --- a/Sources/KiouEngineBridge/Meta_Emitter.m +++ b/Sources/KiouEngineBridge/Meta_Emitter.m @@ -301,7 +301,7 @@ static void meta_emit_dict(NSDictionary *payload) { } NSString *line = [NSString stringWithFormat:@"meta %@\n", body]; file_log([NSString stringWithFormat:@"[META>] %@", body]); - KebWsServerPush(line); + KEBWsServerPush(line); } // --------------------------------------------------------------------------- diff --git a/Sources/KiouEngineBridge/Server_WebSocket.m b/Sources/KiouEngineBridge/Server_WebSocket.m index 7f6a1e9..96773ae 100644 --- a/Sources/KiouEngineBridge/Server_WebSocket.m +++ b/Sources/KiouEngineBridge/Server_WebSocket.m @@ -29,7 +29,7 @@ // 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 KebWsServerPush() through one +// - A serial GCD queue funnels every KEBWsServerPush() 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. @@ -55,7 +55,7 @@ // --------------------------------------------------------------------------- // Singleton-ish state. The whole module assumes at most one server per -// process, which matches the way Tweak.m calls KebWsServerStart() once. +// process, which matches the way Tweak.m calls KEBWsServerStart() once. // --------------------------------------------------------------------------- static dispatch_queue_t g_acceptQueue = NULL; // serial, handshake + writes static dispatch_queue_t g_recvQueue = NULL; // serial, blocking client reads @@ -66,7 +66,7 @@ static NSUInteger g_pendingSends = 0; // best-effort backlog gauge // Inbound text-frame callback. Inject_Move.m self-registers via -// KebWsServerSetTextHandler() at constructor time. Reads happen on +// KEBWsServerSetTextHandler() 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; @@ -353,7 +353,7 @@ static void ws_client_recv_loop(int fd) { // 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 KebWsServerPush -> accept queue) and + // ws_send_text (from KEBWsServerPush -> 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 @@ -468,7 +468,7 @@ static void ws_handle_accept(void) { // --------------------------------------------------------------------------- // Public API. // --------------------------------------------------------------------------- -void KebWsServerStart(uint16_t port) { +void KEBWsServerStart(uint16_t port) { if (g_listenFd >= 0) { file_log(@"[WS] server already running"); return; @@ -517,7 +517,7 @@ void KebWsServerStart(uint16_t port) { (unsigned)port]); } -void KebWsServerSetTextHandler(kiou_ws_text_handler_t fn) { +void KEBWsServerSetTextHandler(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 @@ -525,7 +525,7 @@ void KebWsServerSetTextHandler(kiou_ws_text_handler_t fn) { g_textHandler = fn; } -void KebWsServerPush(NSString *json) { +void KEBWsServerPush(NSString *json) { if (!json) return; if (!g_acceptQueue) return; // server never started diff --git a/Sources/KiouEngineBridge/Tweak.m b/Sources/KiouEngineBridge/Tweak.m index 744c18a..4a78561 100644 --- a/Sources/KiouEngineBridge/Tweak.m +++ b/Sources/KiouEngineBridge/Tweak.m @@ -59,7 +59,7 @@ static void installUnityHooks(void) { // 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(); + 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 @@ -130,8 +130,8 @@ static void retryInstallHooks(void) { // 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 KebWsServerPush() call below is a no-op. - KebWsServerStart(9527); + // every KEBWsServerPush() call below is a no-op. + KEBWsServerStart(9527); // UnityFramework is almost certainly not mapped yet at constructor time. installUnityHooks(); diff --git a/Sources/KiouEngineBridge/Usi_Engine.m b/Sources/KiouEngineBridge/Usi_Engine.m index b406e3a..d89b5ae 100644 --- a/Sources/KiouEngineBridge/Usi_Engine.m +++ b/Sources/KiouEngineBridge/Usi_Engine.m @@ -94,7 +94,7 @@ void UsiEngineSendLine(NSString *line) { file_log([NSString stringWithFormat:@"[USI>] %@", [line stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]]); - KebWsServerPush(withNewline); + KEBWsServerPush(withNewline); } // --------------------------------------------------------------------------- @@ -457,6 +457,6 @@ void UsiEngineOnWsClientDisconnected(void) { // observation hooks are in place. // --------------------------------------------------------------------------- void UsiEngineInstall(void) { - KebWsServerSetTextHandler(usi_engine_text_handler); + KEBWsServerSetTextHandler(usi_engine_text_handler); file_log(@"[USI] engine installed (text handler registered)"); } From cee317245a3bad6fcd2bc53fa21b8a2c8891ad59 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 21:14:35 +0000 Subject: [PATCH 17/51] docs: simplify extended USI protocol design - Remove BEGIN_GAME/END_GAME block delimiters; usinewgame now signals end of meta block naturally - Rename 'meta move' to standalone 'move' command (not meta, not USI) - meta lines are now flat key-value pairs before usinewgame - Update CSA correspondence table: protocol_version, game_id, name+/-, your_turn now mapped to meta lines; move notification mapped to 'move' - Update all example sessions and sequence diagrams accordingly Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/bridge_protocol.md | 86 ++++++++++++++------------------------- docs/usi-compatibility.md | 26 ++++++------ 2 files changed, 44 insertions(+), 68 deletions(-) diff --git a/docs/bridge_protocol.md b/docs/bridge_protocol.md index 9bc1617..c749ed1 100644 --- a/docs/bridge_protocol.md +++ b/docs/bridge_protocol.md @@ -248,42 +248,26 @@ engine options, it should send them to the engine directly rather than over the WebSocket connection to KEB. Unrecognised first tokens (including `setoption`) are logged and dropped. -## meta channel — extended USI line stream +## Extended USI lines (JB build only) -`meta` lines carry match metadata as an extended USI protocol. -**They are not delivered on the binpatch build** — the binpatch flavour -links no-op stubs in `Meta_Emitter.m` lines 3-18, so a host running -against a sideloaded KIOU sees only the USI channel. On the jailbroken -build the host can rely on the events below. +KEB emits two kinds of extended USI lines that are not part of the +standard USI spec. **They are not delivered on the binpatch build** — +the binpatch flavour links no-op stubs in `Meta_Emitter.m` lines 3-18. -### Syntax +Both use the same line-oriented format as standard USI (one command per +line, space-separated tokens). A parser that does not recognise the +first token MUST skip the line silently — this keeps the extended lines +transparent to standard USI engine parsers. -Every `meta` line follows the same line-oriented format as standard USI: +### `meta` lines — match metadata -``` -meta []\n -``` - -- First token is always `meta`. -- Second token is the subcommand name. -- Remaining tokens (to end of line) are the value. Values may contain - spaces (e.g. player names); the parser MUST take everything after the - subcommand token as the value, not just the first word. -- Lines are grouped into **blocks** delimited by `meta BEGIN_` - and `meta END_`. All lines of a block arrive consecutively - with no interleaved USI lines. -- A parser that does not recognise a subcommand MUST skip the line - silently. - -### `BEGIN_GAME` / `END_GAME` block - -Emitted once per match, before `usinewgame`. On Online matches a 1.5 s -grace period waits for `GameStateStore.SetBlackPlayerInfo` / -`SetWhitePlayerInfo` to land the matchmaking-resolved opponent; on CPU / -LocalPvP matches it fires from a fallback timer. +`meta` lines carry per-match information that has no equivalent in +standard USI. They are emitted as a flat sequence before `usinewgame`; +`usinewgame` itself signals the end of the metadata block. Values may +contain spaces (e.g. player names); the parser MUST treat everything +after the subcommand token as the value. ``` -meta BEGIN_GAME meta protocol_version 1.0 meta game_id 20260615T204311-VsAI meta mode VsAI @@ -295,14 +279,13 @@ meta name- KIOU CPU (Normal) meta time_unit 1sec meta total_time 600 meta byoyomi 30 -meta increment 0 -meta END_GAME +usinewgame ``` | Subcommand | Value | Notes | |---|---|---| | `protocol_version` | `1.0` | KEB extended USI protocol version. | -| `game_id` | `-` | Unique per match; derived from `started_at` and mode. | +| `game_id` | `-` | Unique per match. | | `mode` | `VsAI` \| `LocalPvP` \| `OnlinePvP` \| `RecordReplay` \| `Spectate` | KIOU match mode. | | `started_at` | ISO 8601 UTC | | | `start_position` | `Standard` \| `HandicapLance` \| … \| `TsumeShogi` | Initial position type. | @@ -320,31 +303,27 @@ meta END_GAME | `byoyomi` | integer (seconds) | Byoyomi per move. Omitted if zero. | | `increment` | integer (seconds) | Increment per move. Omitted if zero. | -### `meta move` line +### `move` lines — per-move notification -Emitted once per applied move, from `ShogiGameAdapter.TryMakeMove(Move, -out)`. Modelled directly on the CSA per-move notification (`+7776FU,T10`). +`move` lines notify the peer of each committed move. Modelled directly +on the CSA per-move notification (`+7776FU,T10`). ``` -meta move +7g7f T10 +move +7g7f T10 ``` | Token | Value | Notes | |---|---|---| | `+` \| `-` | side | `+` = black just moved, `-` = white just moved. | | USI move | e.g. `7g7f`, `8h2b+`, `P*5e` | The move that was applied. | -| `T` | integer (seconds) | Time spent on this move, derived from the difference between consecutive authoritative snapshots (`g_latestBlackTimeSec` / `g_latestWhiteTimeSec`). Omitted in AI / Local modes where KIOU does not issue snapshots. | +| `T` | integer (seconds) | Time spent on this move, derived from the difference between consecutive authoritative snapshots. Omitted in AI / Local modes where KIOU does not issue snapshots. | The receiver can compute remaining time as `total_time - Σ T` (summing -only the moves belonging to that side), exactly as in the CSA protocol. -The `go btime/wtime` line KEB sends on the next turn carries the same -remaining time directly for engines that prefer not to track it -themselves. +only the moves for that side), exactly as in the CSA protocol. The +`go btime/wtime` line KEB sends on the next turn carries the same +remaining time directly for engines that prefer not to track it. -Match end is signalled by `gameover` on the USI channel. No `meta` -block is emitted at match end in the current version. A `BEGIN_RESULT` -block may be added in a future version if the host needs structured -end-of-game data (final SFEN, full USI text, etc.). +Match end is signalled by `gameover` on the USI channel. ## Build-flavour matrix @@ -394,7 +373,6 @@ literally. < isready > readyok (state = READY; no usinewgame yet — KEB waits for match start) -< meta BEGIN_GAME < meta protocol_version 1.0 < meta game_id 20260615T204311-VsAI < meta mode VsAI @@ -406,13 +384,12 @@ literally. < meta time_unit 1sec < meta total_time 600 < meta byoyomi 30 -< meta END_GAME < usinewgame < position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1 < go movetime 30000 > info depth 12 score cp 32 pv 7g7f > bestmove 7g7f -< meta move +7g7f T10 +< move +7g7f T10 ... < gameover win ``` @@ -427,7 +404,6 @@ For an Online match the `go` line and move block carry live clock values: ... > readyok (state = READY) -< meta BEGIN_GAME < meta protocol_version 1.0 < meta game_id 20260615T204311-OnlinePvP < meta mode OnlinePvP @@ -437,20 +413,18 @@ For an Online match the `go` line and move block carry live clock values: < meta time_unit 1sec < meta total_time 600 < meta byoyomi 30 -< meta END_GAME < usinewgame < position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1 < go btime 600000 wtime 600000 byoyomi 30000 > bestmove 7g7f -< meta move +7g7f T10 +< move +7g7f T10 ... < gameover win ``` -Note that the `BEGIN_GAME` block always arrives before `usinewgame` and -the first `position sfen`. The engine can use that ordering as a cue -that a new game is starting; it does not need to infer it from move -numbers. +Note that `meta` lines always arrive before `usinewgame` and the first +`position sfen`. The engine can use `usinewgame` as the cue that a new +game is starting and that the metadata block is complete. ## Compatibility notes diff --git a/docs/usi-compatibility.md b/docs/usi-compatibility.md index c30c9be..1e329d7 100644 --- a/docs/usi-compatibility.md +++ b/docs/usi-compatibility.md @@ -68,19 +68,20 @@ position before agreeing. KEB has no equivalent pre-match block because match conditions are not known until `OnMatchStart` fires inside KIOU. Instead, the same information is delivered in two ways: -- **`meta match_start`** (JB build only) — JSON sidecar with player - names, time control settings, mode, and initial position type. Arrives - before the first `position sfen`. -- **First `go` line** — carries the live `btime`/`wtime`/`byoyomi` - values for the opening position. +- **`meta` lines** (JB build only) — flat sequence of extended USI + lines with player names, time control settings, mode, and initial + position type. Arrive before `usinewgame`, which signals the end of + the metadata. +- **`go` line** — carries the live `btime`/`wtime`/`byoyomi` values + each turn. | CSA field | CSA line | KEB equivalent | Status | |---|---|---|---| -| Protocol version | `Protocol_Version:1.2` | — | ⛔ omitted | -| Game ID | `Game_ID:...` | — | ⛔ omitted | -| Black player name | `Name+:YaneuraOu` | `meta match_start: black.name` | ✅ JB build | -| White player name | `Name-:Kristallweizen` | `meta match_start: white.name` | ✅ JB build | -| Your seat | `Your_Turn:+` | `meta match_start: local_player` | ✅ JB build | +| Protocol version | `Protocol_Version:1.2` | `meta protocol_version 1.0` | ✅ JB build | +| Game ID | `Game_ID:...` | `meta game_id ...` | ✅ JB build | +| Black player name | `Name+:YaneuraOu` | `meta name+ ...` | ✅ JB build | +| White player name | `Name-:Kristallweizen` | `meta name- ...` | ✅ JB build | +| Your seat | `Your_Turn:+` | `meta your_turn b\|w\|-` | ✅ JB build | | First to move | `To_Move:+` | SFEN side-to-move in first `position` | ✅ | | Max moves | `Max_Moves:256` | — | ⛔ KIOU limit not exposed | | Rematch on draw | `Rematch_On_Draw:NO` | — | ⛔ omitted | @@ -112,6 +113,7 @@ opponent's move, engine replies with its own move. | CSA concept | CSA direction / line | KEB equivalent | Status | |---|---|---|---| | Position after opponent's move | `+7776FU,T10` (incremental) | `position sfen ...` (full snapshot) | ✅ different encoding | +| Move notification to peer | `+7776FU,T10` | `move +7g7f T10` | ✅ JB build | | Remaining / consumed time per turn | `,T10` suffix on move line | `btime` / `wtime` in `go` | ✅ remaining (not consumed) | | Engine submits move | `+7776FU` | `bestmove 7g7f` | ✅ | | Engine resigns | `%TORYO` | `bestmove resign` | ✅ | @@ -156,7 +158,7 @@ sequenceDiagram Note over K,B: Match start K-->>B: OnMatchStart → local_player latched - B-->>P: meta BEGIN_GAME / ... / meta END_GAME + B-->>P: meta protocol_version / meta name+ / meta byoyomi / ... B->>P: usinewgame loop When it becomes our turn @@ -167,7 +169,7 @@ sequenceDiagram P-->>B: bestmove 7g7f Note over B,K: inject_apply → KIOU's move pipeline B->>K: replay move - B-->>P: meta move +7g7f T10 + B-->>P: move +7g7f T10 end K-->>B: OnMatchEndAsync From 65aba53bb87fdd8fa3744a603b57458fc5083fdc Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 21:23:37 +0000 Subject: [PATCH 18/51] docs: use PascalCase for meta subcommand names (YaneuraOu convention) Follow the same naming style as YaneuraOu extension options (USI_Hash, BookFile, etc.) for all meta subcommands: protocol_version -> ProtocolVersion game_id -> GameId mode -> Mode started_at -> StartedAt start_position -> StartPosition your_turn -> YourTurn name+/- -> Name+/- rank+/- -> Rank+/- rate+/- -> Rate+/- user_id+/- -> UserId+/- time_unit -> TimeUnit total_time -> TotalTime byoyomi -> Byoyomi increment -> Increment Update all example sessions and CSA correspondence tables. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/bridge_protocol.md | 101 ++++++++++++++++++++------------------ docs/usi-compatibility.md | 18 +++---- 2 files changed, 61 insertions(+), 58 deletions(-) diff --git a/docs/bridge_protocol.md b/docs/bridge_protocol.md index c749ed1..92d8eed 100644 --- a/docs/bridge_protocol.md +++ b/docs/bridge_protocol.md @@ -267,41 +267,44 @@ standard USI. They are emitted as a flat sequence before `usinewgame`; contain spaces (e.g. player names); the parser MUST treat everything after the subcommand token as the value. +Subcommand names follow the same PascalCase convention as YaneuraOu +extension option names (`USI_Hash`, `BookFile`, etc.). + ``` -meta protocol_version 1.0 -meta game_id 20260615T204311-VsAI -meta mode VsAI -meta started_at 2026-06-15T20:43:11Z -meta start_position Standard -meta your_turn b -meta name+ プレイヤー -meta name- KIOU CPU (Normal) -meta time_unit 1sec -meta total_time 600 -meta byoyomi 30 +meta ProtocolVersion 1.0 +meta GameId 20260615T204311-VsAI +meta Mode VsAI +meta StartedAt 2026-06-15T20:43:11Z +meta StartPosition Standard +meta YourTurn b +meta Name+ プレイヤー +meta Name- KIOU CPU (Normal) +meta TimeUnit 1sec +meta TotalTime 600 +meta Byoyomi 30 usinewgame ``` | Subcommand | Value | Notes | |---|---|---| -| `protocol_version` | `1.0` | KEB extended USI protocol version. | -| `game_id` | `-` | Unique per match. | -| `mode` | `VsAI` \| `LocalPvP` \| `OnlinePvP` \| `RecordReplay` \| `Spectate` | KIOU match mode. | -| `started_at` | ISO 8601 UTC | | -| `start_position` | `Standard` \| `HandicapLance` \| … \| `TsumeShogi` | Initial position type. | -| `your_turn` | `b` \| `w` \| `-` | Local player's seat. `-` for open-seat modes. | -| `name+` | string (rest of line) | Black player name. Omitted if unknown. | -| `name-` | string (rest of line) | White player name. Omitted if unknown. | -| `rank+` | string | Black player rank. Omitted if unknown. | -| `rank-` | string | White player rank. Omitted if unknown. | -| `rate+` | integer | Black player rate. Omitted if unknown. | -| `rate-` | integer | White player rate. Omitted if unknown. | -| `user_id+` | string | Black player user ID. Omitted if unknown. | -| `user_id-` | string | White player user ID. Omitted if unknown. | -| `time_unit` | `1sec` | Always seconds; `go` uses milliseconds. | -| `total_time` | integer (seconds) | Initial main time per player. Omitted if unlimited or unreadable. | -| `byoyomi` | integer (seconds) | Byoyomi per move. Omitted if zero. | -| `increment` | integer (seconds) | Increment per move. Omitted if zero. | +| `ProtocolVersion` | `1.0` | KEB extended USI protocol version. | +| `GameId` | `-` | Unique per match. | +| `Mode` | `VsAI` \| `LocalPvP` \| `OnlinePvP` \| `RecordReplay` \| `Spectate` | KIOU match mode. | +| `StartedAt` | ISO 8601 UTC | | +| `StartPosition` | `Standard` \| `HandicapLance` \| … \| `TsumeShogi` | Initial position type. | +| `YourTurn` | `b` \| `w` \| `-` | Local player's seat. `-` for open-seat modes. | +| `Name+` | string (rest of line) | Black player name. Omitted if unknown. | +| `Name-` | string (rest of line) | White player name. Omitted if unknown. | +| `Rank+` | string | Black player rank. Omitted if unknown. | +| `Rank-` | string | White player rank. Omitted if unknown. | +| `Rate+` | integer | Black player rate. Omitted if unknown. | +| `Rate-` | integer | White player rate. Omitted if unknown. | +| `UserId+` | string | Black player user ID. Omitted if unknown. | +| `UserId-` | string | White player user ID. Omitted if unknown. | +| `TimeUnit` | `1sec` | Always seconds; `go` uses milliseconds. | +| `TotalTime` | integer (seconds) | Initial main time per player. Omitted if unlimited or unreadable. | +| `Byoyomi` | integer (seconds) | Byoyomi per move. Omitted if zero. | +| `Increment` | integer (seconds) | Increment per move. Omitted if zero. | ### `move` lines — per-move notification @@ -373,17 +376,17 @@ literally. < isready > readyok (state = READY; no usinewgame yet — KEB waits for match start) -< meta protocol_version 1.0 -< meta game_id 20260615T204311-VsAI -< meta mode VsAI -< meta started_at 2026-06-15T20:43:11Z -< meta start_position Standard -< meta your_turn b -< meta name+ プレイヤー -< meta name- KIOU CPU (Normal) -< meta time_unit 1sec -< meta total_time 600 -< meta byoyomi 30 +< meta ProtocolVersion 1.0 +< meta GameId 20260615T204311-VsAI +< meta Mode VsAI +< meta StartedAt 2026-06-15T20:43:11Z +< meta StartPosition Standard +< meta YourTurn b +< meta Name+ プレイヤー +< meta Name- KIOU CPU (Normal) +< meta TimeUnit 1sec +< meta TotalTime 600 +< meta Byoyomi 30 < usinewgame < position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1 < go movetime 30000 @@ -404,15 +407,15 @@ For an Online match the `go` line and move block carry live clock values: ... > readyok (state = READY) -< meta protocol_version 1.0 -< meta game_id 20260615T204311-OnlinePvP -< meta mode OnlinePvP -< meta your_turn b -< meta name+ プレイヤー -< meta name- 対戦相手 -< meta time_unit 1sec -< meta total_time 600 -< meta byoyomi 30 +< meta ProtocolVersion 1.0 +< meta GameId 20260615T204311-OnlinePvP +< meta Mode OnlinePvP +< meta YourTurn b +< meta Name+ プレイヤー +< meta Name- 対戦相手 +< meta TimeUnit 1sec +< meta TotalTime 600 +< meta Byoyomi 30 < usinewgame < position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1 < go btime 600000 wtime 600000 byoyomi 30000 diff --git a/docs/usi-compatibility.md b/docs/usi-compatibility.md index 1e329d7..40eefd8 100644 --- a/docs/usi-compatibility.md +++ b/docs/usi-compatibility.md @@ -77,11 +77,11 @@ Instead, the same information is delivered in two ways: | CSA field | CSA line | KEB equivalent | Status | |---|---|---|---| -| Protocol version | `Protocol_Version:1.2` | `meta protocol_version 1.0` | ✅ JB build | -| Game ID | `Game_ID:...` | `meta game_id ...` | ✅ JB build | -| Black player name | `Name+:YaneuraOu` | `meta name+ ...` | ✅ JB build | -| White player name | `Name-:Kristallweizen` | `meta name- ...` | ✅ JB build | -| Your seat | `Your_Turn:+` | `meta your_turn b\|w\|-` | ✅ JB build | +| Protocol version | `Protocol_Version:1.2` | `meta ProtocolVersion 1.0` | ✅ JB build | +| Game ID | `Game_ID:...` | `meta GameId ...` | ✅ JB build | +| Black player name | `Name+:YaneuraOu` | `meta Name+ ...` | ✅ JB build | +| White player name | `Name-:Kristallweizen` | `meta Name- ...` | ✅ JB build | +| Your seat | `Your_Turn:+` | `meta YourTurn b\|w\|-` | ✅ JB build | | First to move | `To_Move:+` | SFEN side-to-move in first `position` | ✅ | | Max moves | `Max_Moves:256` | — | ⛔ KIOU limit not exposed | | Rematch on draw | `Rematch_On_Draw:NO` | — | ⛔ omitted | @@ -96,10 +96,10 @@ derived from `MatchConfig.TimeControlConfig` and the live snapshot cache. | CSA field | CSA line | KEB equivalent | Status | |---|---|---|---| -| Time unit | `Time_Unit:1sec` | implicit (ms in `go`) | ✅ always ms | -| Total (main) time | `Total_Time:600` | `btime` / `wtime` in `go` | ✅ when snapshot available | -| Byoyomi | `Byoyomi:30` | `byoyomi` in `go` | ✅ | -| Increment | `Increment:10` | `binc` / `winc` in `go` | ✅ | +| Time unit | `Time_Unit:1sec` | `meta TimeUnit 1sec` + implicit ms in `go` | ✅ JB build | +| Total (main) time | `Total_Time:600` | `meta TotalTime 600` + `btime`/`wtime` in `go` | ✅ JB build / when snapshot available | +| Byoyomi | `Byoyomi:30` | `meta Byoyomi 30` + `byoyomi` in `go` | ✅ | +| Increment | `Increment:10` | `meta Increment 10` + `binc`/`winc` in `go` | ✅ | | Delay | `Delay:5` | — | ⛔ not exposed by KIOU | | Min time per move | `Least_Time_Per_Move:1` | — | ⛔ not exposed by KIOU | | Time roundup | `Time_Roundup:YES` | — | ⛔ not exposed by KIOU | From 2b8a41cd96583344f3c49c8717f93951da7f52be Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 22:52:39 +0000 Subject: [PATCH 19/51] =?UTF-8?q?feat(csa):=20Wave=201=20=E2=80=94=20conve?= =?UTF-8?q?rt=20lib,=20USI=20deprecation,=20TCP=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1: Csa_Convert (pure) - Sources/KiouEngineBridge/Csa_Convert.{h,m} - Square <-> CSA coordinate (square 60 <-> '77') - PSC PieceType <-> CSA piece mnemonic (14 types: FU..RY) - Move bits <-> CSA move text (+7776FU, +0077FU drop, +8822UM promote) - SFEN -> CSA position block (P1..P9 + P+ / P- hand + side) - il2cpp-free, Foundation-only — linkable into host-side tests Task 2: USI deprecation - Wrap Server_WebSocket.m and Usi_Engine.m in '#if 0' with DEPRECATED headers and a pointer to the CSA migration plan - Exclude both from the Theos build via Makefile find filter - Add Csa_Stubs.m: no-op shims for KEBWsServer* / UsiEngine* so the half-migrated hook call sites keep linking until Task 4-5 rewrites them. Stubs will be deleted in Task 5. Task 3: CSA TCP server - Sources/KiouEngineBridge/Server_CSA.m - LF-terminated UTF-8 lines, single-client TCP server on port 4081 - New-client-wins preempt, SO_KEEPALIVE for fast dead-peer detection - KEBCsaServerStart / Push / Close / SetLineHandler public API in Internal.h - Mirrors Server_WebSocket.m's accept-queue + recv-queue split with WS framing / handshake stripped out - Tweak.m constructor now binds 4081 instead of 9527 All three build flavors (JB / JAILED=1 / BINPATCH=1) link clean. The hooks still drive the no-op USI stubs; CSA wire-up against KIOU events follows in Task 4-5. Plan: docs/plans/kiou_engine_bridge_csa_migration.md Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 9 +- Sources/KiouEngineBridge/Csa_Convert.h | 163 +++++++++ Sources/KiouEngineBridge/Csa_Convert.m | 362 ++++++++++++++++++++ Sources/KiouEngineBridge/Csa_Stubs.m | 98 ++++++ Sources/KiouEngineBridge/Internal.h | 49 ++- Sources/KiouEngineBridge/Server_CSA.m | 326 ++++++++++++++++++ Sources/KiouEngineBridge/Server_WebSocket.m | 12 + Sources/KiouEngineBridge/Tweak.m | 21 +- Sources/KiouEngineBridge/Usi_Engine.m | 12 + 9 files changed, 1034 insertions(+), 18 deletions(-) create mode 100644 Sources/KiouEngineBridge/Csa_Convert.h create mode 100644 Sources/KiouEngineBridge/Csa_Convert.m create mode 100644 Sources/KiouEngineBridge/Csa_Stubs.m create mode 100644 Sources/KiouEngineBridge/Server_CSA.m diff --git a/Makefile b/Makefile index 8d218ff..e5fe756 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,14 @@ include $(THEOS)/makefiles/common.mk TWEAK_NAME = KiouEngineBridge -KiouEngineBridge_FILES = $(shell find Sources/KiouEngineBridge -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp') +# CSA migration: Server_WebSocket.m and Usi_Engine.m are kept in tree as +# `#if 0` blocks for historical reference; exclude them from the build so +# we link only the active CSA implementation. See +# docs/plans/kiou_engine_bridge_csa_migration.md Task 2. +KiouEngineBridge_FILES = $(shell find Sources/KiouEngineBridge \ + \( -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp' \) \ + ! -name 'Server_WebSocket.m' \ + ! -name 'Usi_Engine.m') # Shared logging implementation lives in Sources/Common. il2cpp helpers are # inline-only, so they don't need to be listed here. KiouEngineBridge_FILES += Sources/Common/logging.m diff --git a/Sources/KiouEngineBridge/Csa_Convert.h b/Sources/KiouEngineBridge/Csa_Convert.h new file mode 100644 index 0000000..85b98c0 --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_Convert.h @@ -0,0 +1,163 @@ +#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); diff --git a/Sources/KiouEngineBridge/Csa_Convert.m b/Sources/KiouEngineBridge/Csa_Convert.m new file mode 100644 index 0000000..04a716e --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -0,0 +1,362 @@ +#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. +// The CSA cell format is two chars per square: ` *` for empty, `+XX` for +// black piece XX, `-XX` for white piece XX. Each `P` row therefore is +// `P` followed by 9 × 2 = 18 chars. +// --------------------------------------------------------------------------- + +// 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:18]; + 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; + 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; +} diff --git a/Sources/KiouEngineBridge/Csa_Stubs.m b/Sources/KiouEngineBridge/Csa_Stubs.m new file mode 100644 index 0000000..e8ad865 --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_Stubs.m @@ -0,0 +1,98 @@ +#import "Internal.h" + +// =========================================================================== +// Csa_Stubs — temporary shim during the CSA migration (Tasks 2-5). +// +// The hooks in Hook_*.m and Meta_Emitter.m still call into the legacy USI +// engine driver via UsiEngine* functions and the WebSocket sink via +// KEBWsServer*. The implementations of those symbols live in +// Server_WebSocket.m and Usi_Engine.m, which Task 2 deprecates by wrapping +// them in `#if 0` and dropping them from the build. +// +// To keep `make` green between Task 2 and Tasks 4-5 (where the real +// CsaEngine* / KEBCsaServer* implementations land and every hook call site +// is migrated), this file provides no-op shims for the symbols the hooks +// still reference. Once every hook has been switched over to the CSA +// callbacks, this file is deleted in Task 5. +// +// IMPORTANT: do NOT add any new call sites against these stubs. They exist +// solely to bridge the half-migrated build state. The TaskGet logs in the +// task tracker make this lifetime explicit. +// =========================================================================== + +// --------------------------------------------------------------------------- +// WebSocket sink stubs. The real KEBCsaServer* family is introduced in +// Task 3; until then, every push is dropped silently. +// --------------------------------------------------------------------------- + +void KEBWsServerStart(uint16_t port) { + (void)port; + file_log(@"[STUB] KEBWsServerStart called during CSA migration — no-op"); +} + +void KEBWsServerPush(NSString *line) { + (void)line; + // Drop silently. Logging every call would flood the file log because + // Meta_Emitter.m emits meta lines on every match event. +} + +void KEBWsServerSetTextHandler(kiou_ws_text_handler_t fn) { + (void)fn; + file_log(@"[STUB] KEBWsServerSetTextHandler called during CSA migration" + @" — no-op"); +} + +// --------------------------------------------------------------------------- +// USI engine driver stubs. Real CsaEngine* callbacks land in Task 4; the +// call sites in Hook_LowLevelObserve.m and Hook_MatchModeObserve.m are +// rewritten in Task 5. +// --------------------------------------------------------------------------- + +void UsiEngineOnMatchStart(int32_t local_player) { + (void)local_player; +} + +void UsiEngineOnMatchEnd(usi_match_result_t result) { + (void)result; +} + +void UsiEngineOnMoveObserved(NSString *usi, + NSString *sfen_after, + int32_t side_to_move) { + (void)usi; + (void)sfen_after; + (void)side_to_move; +} + +void UsiEngineOnWsClientConnected(void) { + // no-op: WS transport is gone +} + +void UsiEngineOnWsClientDisconnected(void) { + // no-op: WS transport is gone +} + +void UsiEngineSendLine(NSString *line) { + (void)line; + // no-op: USI line emission is being replaced by CsaEngineSendLine +} + +void UsiEngineInstall(void) { + file_log(@"[STUB] UsiEngineInstall called during CSA migration — no-op"); +} + +// --------------------------------------------------------------------------- +// CSA engine driver stubs. Real implementations land in Task 4 once +// Csa_Engine.m is written; until then Server_CSA.m's accept handler hits +// these no-ops so the build links. +// --------------------------------------------------------------------------- + +void CsaEngineOnTcpClientConnected(void) { + file_log(@"[STUB] CsaEngineOnTcpClientConnected — engine driver lands " + @"in Task 4"); +} + +void CsaEngineOnTcpClientDisconnected(void) { + file_log(@"[STUB] CsaEngineOnTcpClientDisconnected — engine driver " + @"lands in Task 4"); +} diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index 2bce639..87cb7c3 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -124,18 +124,38 @@ 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 KEBWsServerStart(uint16_t port); -void KEBWsServerPush(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. +// `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); + +// --------------------------------------------------------------------------- +// DEPRECATED — WebSocket server API. +// +// The USI-era WebSocket sink (Server_WebSocket.m) is excluded from the +// build by Makefile filter as part of the CSA migration. The declarations +// below stay so half-migrated translation units still compile; the +// implementations live in Csa_Stubs.m as no-ops. Once Tasks 4-5 retarget +// every hook call site to the CSA APIs above, these declarations and the +// stub file are deleted. +// --------------------------------------------------------------------------- +void KEBWsServerStart(uint16_t port); +void KEBWsServerPush(NSString *json); typedef void (*kiou_ws_text_handler_t)(const char *data, size_t len); void KEBWsServerSetTextHandler(kiou_ws_text_handler_t fn); @@ -337,6 +357,17 @@ void UsiEngineOnMatchEnd(usi_match_result_t result); void UsiEngineOnWsClientConnected(void); void UsiEngineOnWsClientDisconnected(void); +// --------------------------------------------------------------------------- +// Csa_Engine.m — the CSA-protocol server-side state machine that drives +// the connected engine through Game_Summary / AGREE / per-move exchange / +// gameover. Symbols are declared here and stubbed in Csa_Stubs.m during +// the CSA migration; the real implementation lands in Task 4. +// --------------------------------------------------------------------------- + +// Lifecycle callbacks invoked by Server_CSA.m from the accept queue. +void CsaEngineOnTcpClientConnected(void); +void CsaEngineOnTcpClientDisconnected(void); + // --------------------------------------------------------------------------- // Meta_Emitter.m — 1-line JSON metadata stream that runs alongside the USI // protocol on the same WS port. Each line is prefixed with "meta " so the diff --git a/Sources/KiouEngineBridge/Server_CSA.m b/Sources/KiouEngineBridge/Server_CSA.m new file mode 100644 index 0000000..d51beb7 --- /dev/null +++ b/Sources/KiouEngineBridge/Server_CSA.m @@ -0,0 +1,326 @@ +#import "Internal.h" + +#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 oldest pending 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 int g_clientFd = -1; +static NSUInteger 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) { + bool wasUp = (g_clientFd >= 0); + if (g_clientFd >= 0) { + close(g_clientFd); + g_clientFd = -1; + } + 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; + file_log([NSString stringWithFormat: + @"[CSA] recv errno=%d, exiting loop", errno]); + break; + } + if (n == 0) { + file_log(@"[CSA] peer closed connection"); + break; + } + + [acc appendBytes:chunk length:(NSUInteger)n]; + if (acc.length > CSA_LINE_MAX) { + file_log([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) { + file_log(@"[CSA] dropped non-UTF8 line"); + continue; + } + file_log([NSString stringWithFormat:@"[CSA<] %@", line]); + if (g_lineHandler) g_lineHandler(line); + } + } + + file_log(@"[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) { + file_log([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 (g_clientFd >= 0) { + file_log([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(); + } + + file_log([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); + g_clientFd = fd; + CsaEngineOnTcpClientConnected(); + + dispatch_async(g_recvQueue, ^{ + csa_client_recv_loop(fd); + }); +} + +// --------------------------------------------------------------------------- +// Public API. +// --------------------------------------------------------------------------- +void KEBCsaServerStart(uint16_t port) { + if (g_listenFd >= 0) { + file_log(@"[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) { + file_log([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) { + file_log([NSString stringWithFormat:@"[CSA] bind errno=%d port=%u", + errno, (unsigned)port]); + close(s); + return; + } + if (listen(s, 4) < 0) { + file_log([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); + + file_log([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 (g_clientFd < 0) return; // no client attached + + if (g_pendingSends >= CSA_QUEUE_DROP_THRESHOLD) { + if ((g_pendingSends % 32) == 0) { + file_log([NSString stringWithFormat: + @"[CSA] drop: backlog=%lu", + (unsigned long)g_pendingSends]); + } + return; + } + + g_pendingSends++; + NSString *withNewline = [line hasSuffix:@"\n"] + ? [line copy] + : [line stringByAppendingString:@"\n"]; + + dispatch_async(g_acceptQueue, ^{ + int fd = g_clientFd; + if (fd < 0) { + g_pendingSends--; + return; + } + NSData *data = [withNewline dataUsingEncoding:NSUTF8StringEncoding]; + BOOL ok = csa_send_all(fd, data.bytes, data.length); + g_pendingSends--; + if (!ok) { + file_log(@"[CSA] send failed, dropping client"); + csa_close_client(); + } + }); +} + +void KEBCsaServerClose(void) { + if (!g_acceptQueue) return; + dispatch_async(g_acceptQueue, ^{ + if (g_clientFd >= 0) { + file_log(@"[CSA] KEBCsaServerClose: tearing down client"); + csa_close_client(); + } + }); +} diff --git a/Sources/KiouEngineBridge/Server_WebSocket.m b/Sources/KiouEngineBridge/Server_WebSocket.m index 96773ae..59438a0 100644 --- a/Sources/KiouEngineBridge/Server_WebSocket.m +++ b/Sources/KiouEngineBridge/Server_WebSocket.m @@ -1,3 +1,13 @@ +// =========================================================================== +// DEPRECATED — WebSocket server for the USI protocol. +// Replaced by the plain TCP CSA server (Server_CSA.m) in the CSA migration +// plan (docs/plans/kiou_engine_bridge_csa_migration.md). Excluded from the +// build via Makefile filter; kept in tree as a reference for the accept +// loop / send/recv plumbing that Server_CSA.m mirrors with WS framing +// stripped out. +// =========================================================================== +#if 0 + #import "Internal.h" #import @@ -560,3 +570,5 @@ void KEBWsServerPush(NSString *json) { } }); } + +#endif // 0 — DEPRECATED USI WebSocket server diff --git a/Sources/KiouEngineBridge/Tweak.m b/Sources/KiouEngineBridge/Tweak.m index 4a78561..fe6c636 100644 --- a/Sources/KiouEngineBridge/Tweak.m +++ b/Sources/KiouEngineBridge/Tweak.m @@ -70,7 +70,8 @@ static void installUnityHooks(void) { // patched `B ` instruction. InstallMatchModeObserveHook(unityBase); InstallInjectHook(unityBase); - UsiEngineInstall(); + // CSA migration Task 2: USI engine driver removed. Task 4 will plug in + // CsaEngineInstall() here once Csa_Engine.m lands. #else InstallOnlineObserveHook(unityBase); InstallLowLevelObserveHook(unityBase); @@ -90,9 +91,10 @@ static void installUnityHooks(void) { // match_start with the matchmaking-resolved opponent identity on // Online matches (MatchConfig alone holds placeholders there). InstallGameStateStoreObserveHook(unityBase); - // Phase 2: USI engine driver. Must come AFTER InstallInjectHook so - // inject_apply is fully wired before the WS handler can call into it. - UsiEngineInstall(); + // CSA migration Task 2: USI engine driver removed. Task 4 will plug in + // CsaEngineInstall() here once Csa_Engine.m lands. Must come AFTER + // InstallInjectHook so inject_apply is fully wired before the TCP + // handler can call into it. #endif g_unityHooked = YES; @@ -128,10 +130,13 @@ static void retryInstallHooks(void) { KIOU_ENGINE_BRIDGE_COMMIT, kBuildFlavor, __DATE__, __TIME__]); - // 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 KEBWsServerPush() call below is a no-op. - KEBWsServerStart(9527); + // 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(); diff --git a/Sources/KiouEngineBridge/Usi_Engine.m b/Sources/KiouEngineBridge/Usi_Engine.m index d89b5ae..f2fe080 100644 --- a/Sources/KiouEngineBridge/Usi_Engine.m +++ b/Sources/KiouEngineBridge/Usi_Engine.m @@ -1,3 +1,13 @@ +// =========================================================================== +// DEPRECATED — USI protocol implementation. +// Replaced by CSA server protocol in the CSA migration plan +// (docs/plans/kiou_engine_bridge_csa_migration.md). Excluded from the build +// via Makefile filter; kept in tree for historical reference and so the git +// blame on the CSA implementation's state-machine derivatives stays +// readable. +// =========================================================================== +#if 0 + #import "Internal.h" #import @@ -460,3 +470,5 @@ void UsiEngineInstall(void) { KEBWsServerSetTextHandler(usi_engine_text_handler); file_log(@"[USI] engine installed (text handler registered)"); } + +#endif // 0 — DEPRECATED USI implementation From 5acf82d014b5acd7c5f794813f5c2650333062a4 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 23:06:51 +0000 Subject: [PATCH 20/51] =?UTF-8?q?feat(csa):=20Wave=202=20=E2=80=94=20engin?= =?UTF-8?q?e=20state=20machine,=20Game=5FSummary,=20resign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4: Csa_Engine.h/.m - BOOT -> LOGIN -> AGREE_WAIT -> PLAYING -> GAME_OVER state machine - Inbound: LOGIN (any creds OK), LOGOUT, AGREE, REJECT, %TORYO, %KACHI, %CHUDAN, ±[,T] move submissions - Outbound: LOGIN reply, Game_Summary, START, #RESIGN/#WIN/#LOSE/#DRAW - Engine-side moves: CSA -> USI translation -> inject_apply() - KIOU-side moves: CsaEngineOnMoveObserved with snapshot-delta T value Task 5: Csa_GameInfo.m - CsaBuildGameSummary reads MatchConfig / PlayerInfo / TimeControlConfig using the same offsets Meta_Emitter walked. KIOU_* extension lines (Mode, StartPosition, Rank+/-, Rate+/-, UserId+/-, StartedAt) live between Position and END Game_Summary so strict CSA parsers ignore them. - CsaBuildMatchResult emits #REASON + #OUTCOME pairs. - Csa_Convert.{h,m} grows PscPieceTypeAtSquare so the engine driver can recover the piece type sitting on a SFEN square (needed because the Move bits' upper-16 piece-type encoding is still under RE). - Hook_MatchModeObserve.m's Init/Start/End macros now feed Csa_GameInfo alongside the legacy meta path (both fire each event; Meta_Emitter is no-op'd via Csa_Stubs.m until full retirement in a follow-up commit). - Hook_GameStateStoreObserve.m's NotifyPieceMoved forwards (move, playerSide, sfen_after) into CsaEngineOnMoveObserved. Task 6: Inject_Resign.m - %TORYO -> GameOrchestrator.RequestSurrender (RVA 0x594A91C). Uses the already-cached g_gameOrchestratorCache pointer; resolves the void function pointer at first call and dispatches it onto the main queue. - %KACHI logs but does not drive KIOU — no first-class declaration API surfaced in dump.cs yet; the CSA session still emits #JISHOGI/#WIN so the engine learns the outcome. All three build flavors (JB / JAILED=1 / BINPATCH=1) link clean. Plan: docs/plans/kiou_engine_bridge_csa_migration.md Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Convert.h | 21 + Sources/KiouEngineBridge/Csa_Convert.m | 77 +++ Sources/KiouEngineBridge/Csa_Engine.h | 63 +++ Sources/KiouEngineBridge/Csa_Engine.m | 518 ++++++++++++++++++ Sources/KiouEngineBridge/Csa_GameInfo.m | 313 +++++++++++ Sources/KiouEngineBridge/Csa_Stubs.m | 17 +- .../Hook_GameStateStoreObserve.m | 6 + .../KiouEngineBridge/Hook_MatchModeObserve.m | 46 +- Sources/KiouEngineBridge/Inject_Resign.m | 69 +++ Sources/KiouEngineBridge/Internal.h | 55 +- Sources/KiouEngineBridge/Tweak.m | 12 +- 11 files changed, 1151 insertions(+), 46 deletions(-) create mode 100644 Sources/KiouEngineBridge/Csa_Engine.h create mode 100644 Sources/KiouEngineBridge/Csa_Engine.m create mode 100644 Sources/KiouEngineBridge/Csa_GameInfo.m create mode 100644 Sources/KiouEngineBridge/Inject_Resign.m diff --git a/Sources/KiouEngineBridge/Csa_Convert.h b/Sources/KiouEngineBridge/Csa_Convert.h index 85b98c0..3c45dfb 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.h +++ b/Sources/KiouEngineBridge/Csa_Convert.h @@ -161,3 +161,24 @@ BOOL MoveBitsFromCsaText(NSString *csa, // --------------------------------------------------------------------------- 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); + +// 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 index 04a716e..3c5454c 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.m +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -360,3 +360,80 @@ BOOL MoveBitsFromCsaText(NSString *csa, } 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; +} + +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..3048edd --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_Engine.h @@ -0,0 +1,63 @@ +#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` is the KIOU Move bits, `playerSide` is +// the side that just moved (0=Black, 1=White), and `sfenAfter` is the +// 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). +void CsaEngineOnMoveObserved(uint32_t move, + int32_t playerSide, + NSString *sfenAfter); + +// 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..f787e67 --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -0,0 +1,518 @@ +#import "Internal.h" +#import "Csa_Engine.h" + +#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-snapshot black/white remaining-time cache, used to derive the `T` +// field on per-move notifications. Both default to -1 (= no snapshot yet), +// which translates to "omit the ,T suffix" in CsaTextFromMoveBits. +static _Atomic int g_csaLastBlackTimeSec = -1; +static _Atomic int g_csaLastWhiteTimeSec = -1; + +// --------------------------------------------------------------------------- +// 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(int newState) { + int old = atomic_exchange(&g_csaState, newState); + if (old != newState) { + file_log([NSString stringWithFormat:@"[CSA-ENG] state %s -> %s", + csa_state_name(old), csa_state_name(newState)]); + } +} + +// --------------------------------------------------------------------------- +// 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; + file_log([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); +extern NSString *CsaBuildMatchResult(usi_match_result_t result); + +static void csa_send_game_summary(int32_t local_player) { + NSString *gameId = nil; + NSString *summary = CsaBuildGameSummary(local_player, &gameId); + if (summary.length == 0) { + file_log(@"[CSA-ENG] CsaBuildGameSummary returned empty — " + @"deferring Game_Summary"); + return; + } + g_csaLastGameSummary = summary; + g_csaLastGameID = gameId ?: @"GAME"; + CsaEngineSendBlock(summary); + csa_set_state(CSA_STATE_AGREE_WAIT); +} + +// --------------------------------------------------------------------------- +// 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 now. + csa_send_game_summary(lp); + } 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) { + int s = atomic_load(&g_csaState); + if (s != CSA_STATE_AGREE_WAIT) { + file_log([NSString stringWithFormat: + @"[CSA-ENG] AGREE in state %s — ignoring", + csa_state_name(s)]); + return; + } + (void)line; // optional suffix is accepted but not validated + 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; + file_log(@"[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; + } + file_log([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) { + file_log([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 — meaning the local KIOU side wins. + // Convert that to a KIOU-side resign call against the engine's + // seat (= the opposite of the local player). + int32_t enginePlayer = (lp == 0) ? 1 : (lp == 1) ? 0 : -1; + InjectResign(enginePlayer); + CsaEngineSendLine(@"#RESIGN"); + CsaEngineSendLine(@"#WIN"); + csa_set_state(CSA_STATE_GAME_OVER); + return; + } + if ([line isEqualToString:@"%KACHI"]) { + int32_t enginePlayer = (lp == 0) ? 1 : (lp == 1) ? 0 : -1; + InjectNyugyokuDeclaration(enginePlayer); + CsaEngineSendLine(@"#JISHOGI"); + CsaEngineSendLine(@"#WIN"); + csa_set_state(CSA_STATE_GAME_OVER); + return; + } + if ([line isEqualToString:@"%CHUDAN"]) { + file_log(@"[CSA-ENG] %%CHUDAN received — not surfaced to KIOU"); + CsaEngineSendLine(@"#CHUDAN"); + csa_set_state(CSA_STATE_GAME_OVER); + return; + } + file_log([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; + } +} + +static void csa_handle_move_from_engine(NSString *line) { + int s = atomic_load(&g_csaState); + if (s != CSA_STATE_PLAYING) { + file_log([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)) { + file_log([NSString stringWithFormat: + @"[CSA-ENG] malformed move: %@", line]); + return; + } + + int32_t lp = atomic_load(&g_csaLocalPlayer); + int32_t enginePlayer = (lp == 0) ? 1 : (lp == 1) ? 0 : -1; + if (enginePlayer != -1 && playerSide != enginePlayer) { + file_log([NSString stringWithFormat: + @"[CSA-ENG] move side mismatch: engine=%d got=%d", + enginePlayer, playerSide]); + // Still try to apply it — the engine knows its side better than we + // do in open-seat modes. + } + + // Build USI for the inject pipeline. The "from" / "to" bits already + // match the PSC Square encoding, so they translate directly. + uint32_t to = move & 0x7F; + uint32_t from = (move >> 7) & 0x7F; + uint32_t promote = (move >> 14) & 1; + uint32_t drop = (move >> 15) & 1; + + NSString *toCsa = CsaSquareFromMoveBits(to); + NSString *toUsi = csa_squareToUsi(toCsa); + if (!toUsi) { + file_log([NSString stringWithFormat: + @"[CSA-ENG] bad to-square in: %@", line]); + return; + } + + NSString *usi; + if (drop) { + NSString *letter = csa_pieceToUsiDropLetter(pieceType); + if (!letter) { + file_log([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) { + file_log([NSString stringWithFormat: + @"[CSA-ENG] bad from-square in: %@", line]); + return; + } + usi = promote + ? [NSString stringWithFormat:@"%@%@+", fromUsi, toUsi] + : [NSString stringWithFormat:@"%@%@", fromUsi, toUsi]; + } + + NSString *outSfen = nil; + NSString *outErr = nil; + uint32_t outRaw = 0; + bool ok = inject_apply(usi, &outSfen, &outRaw, &outErr); + file_log([NSString stringWithFormat: + @"[CSA-ENG] inject_apply usi=%@ ok=%d raw=0x%x err=%@ sfen=%@", + usi, (int)ok, (unsigned)outRaw, outErr ?: @"", outSfen ?: @""]); +} + +// --------------------------------------------------------------------------- +// Match lifecycle. +// --------------------------------------------------------------------------- + +void CsaEngineOnMatchStart(int32_t local_player) { + atomic_store(&g_csaLocalPlayer, local_player); + atomic_store(&g_csaLastBlackTimeSec, -1); + atomic_store(&g_csaLastWhiteTimeSec, -1); + + int s = atomic_load(&g_csaState); + file_log([NSString stringWithFormat: + @"[CSA-ENG] match_start local_player=%d state=%s", + (int)local_player, csa_state_name(s)]); + + // If a client is already logged in, push Game_Summary right away. + if (s == CSA_STATE_LOGIN || s == CSA_STATE_GAME_OVER) { + csa_send_game_summary(local_player); + } else if (s == CSA_STATE_BOOT) { + // No client connected yet — cache state; the LOGIN handler will + // pick this up when the engine eventually connects. + } else { + file_log([NSString stringWithFormat: + @"[CSA-ENG] match_start in unexpected state %s, " + @"forcing Game_Summary", + csa_state_name(s)]); + csa_send_game_summary(local_player); + } +} + +void CsaEngineOnMatchEnd(usi_match_result_t result) { + int s = atomic_load(&g_csaState); + file_log([NSString stringWithFormat: + @"[CSA-ENG] match_end result=%d state=%s", + (int)result, csa_state_name(s)]); + + 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) { + 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) { + file_log([NSString stringWithFormat: + @"[CSA-ENG] cannot resolve piece type at to=%u " + @"(sfen=%@) — emitting raw bits log only", + to, sfenAfter ?: @""]); + return; + } + + // For normal promoting moves, CsaTextFromMoveBits wants the *unpromoted* + // piece type and uses the promote bit to compute the destination piece + // name (FU → TO etc). The post-move SFEN at the `to` square already + // holds the promoted piece type; downshift before handing over. + uint32_t promote = (move >> 14) & 1; + uint32_t drop = (move >> 15) & 1; + if (!drop && promote && pscPieceType >= 9 && pscPieceType <= 14) { + // 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; + } + + // Compute the seconds spent on this move from the snapshot delta. + int32_t timeSpent = -1; + if (playerSide == 0) { + int last = atomic_load(&g_csaLastBlackTimeSec); + int now = (int)g_latestBlackTimeSec; + if (last >= 0 && now >= 0 && last >= now) timeSpent = last - now; + if (now > 0) atomic_store(&g_csaLastBlackTimeSec, now); + } else if (playerSide == 1) { + int last = atomic_load(&g_csaLastWhiteTimeSec); + int now = (int)g_latestWhiteTimeSec; + if (last >= 0 && now >= 0 && last >= now) timeSpent = last - now; + if (now > 0) atomic_store(&g_csaLastWhiteTimeSec, now); + } + + NSString *csa = CsaTextFromMoveBits(move, pscPieceType, playerSide, + timeSpent); + if (!csa) { + file_log([NSString stringWithFormat: + @"[CSA-ENG] CsaTextFromMoveBits returned nil for " + @"move=0x%x piece=%d side=%d", + (unsigned)move, (int)pscPieceType, (int)playerSide]); + return; + } + CsaEngineSendLine(csa); +} + +// --------------------------------------------------------------------------- +// TCP transport callbacks. Server_CSA.m calls these from the accept queue. +// --------------------------------------------------------------------------- + +void CsaEngineOnTcpClientConnected(void) { + file_log(@"[CSA-ENG] tcp client connected"); + csa_set_state(CSA_STATE_LOGIN); +} + +void CsaEngineOnTcpClientDisconnected(void) { + file_log(@"[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); + file_log(@"[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..4453ae0 --- /dev/null +++ b/Sources/KiouEngineBridge/Csa_GameInfo.m @@ -0,0 +1,313 @@ +#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) { + 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]; + [out appendString:@"To_Move:+\n"]; + + [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) { + 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. + [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/Csa_Stubs.m b/Sources/KiouEngineBridge/Csa_Stubs.m index e8ad865..5cfe178 100644 --- a/Sources/KiouEngineBridge/Csa_Stubs.m +++ b/Sources/KiouEngineBridge/Csa_Stubs.m @@ -81,18 +81,5 @@ void UsiEngineInstall(void) { file_log(@"[STUB] UsiEngineInstall called during CSA migration — no-op"); } -// --------------------------------------------------------------------------- -// CSA engine driver stubs. Real implementations land in Task 4 once -// Csa_Engine.m is written; until then Server_CSA.m's accept handler hits -// these no-ops so the build links. -// --------------------------------------------------------------------------- - -void CsaEngineOnTcpClientConnected(void) { - file_log(@"[STUB] CsaEngineOnTcpClientConnected — engine driver lands " - @"in Task 4"); -} - -void CsaEngineOnTcpClientDisconnected(void) { - file_log(@"[STUB] CsaEngineOnTcpClientDisconnected — engine driver " - @"lands in Task 4"); -} +// (Inject_Resign.m now provides the real InjectResign / +// InjectNyugyokuDeclaration entry points — Task 6 has landed.) diff --git a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m index 65f415f..ef498bf 100644 --- a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m @@ -64,11 +64,13 @@ void MetaOnPlayerInfoSet(int32_t side, void *playerInfo) { // --------------------------------------------------------------------------- static void HookSetBlackPlayerInfo(void *self, void *playerInfo) { MetaOnPlayerInfoSet(/*side=*/0, playerInfo); + CsaOnPlayerInfoSet(/*side=*/0, playerInfo); if (orig_SetBlackPlayerInfo) orig_SetBlackPlayerInfo(self, playerInfo); } static void HookSetWhitePlayerInfo(void *self, void *playerInfo) { MetaOnPlayerInfoSet(/*side=*/1, playerInfo); + CsaOnPlayerInfoSet(/*side=*/1, playerInfo); if (orig_SetWhitePlayerInfo) orig_SetWhitePlayerInfo(self, playerInfo); } @@ -117,6 +119,10 @@ static void HookNotifyPieceMoved(void *self, @"usi=\"%@\" sfen=\"%@\"", self, (int)playerSide, usi ?: @"", sfen ?: @""]); MetaEmitMove(usi, sfen, nextSide); + // CSA engine driver wants the raw Move bits + post-move SFEN so it can + // reconstruct the piece type at the destination square and ship a + // `+7776FU,T10`-style notification. + CsaEngineOnMoveObserved((uint32_t)move, playerSide, sfen); } // --------------------------------------------------------------------------- diff --git a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m index a5d38b1..ab29970 100644 --- a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m +++ b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m @@ -234,12 +234,14 @@ 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). */ \ + /* 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); \ @@ -304,16 +306,14 @@ static inline BOOL shouldLog(uint32_t n) { file_log([NSString stringWithFormat: \ @"[MMODE] " MODE_TAG " Start self=%p localPlayer=%d", \ selfCap, (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. */ \ + /* 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); \ + /* Keep the legacy USI driver and meta sidecar fed too — both are */ \ + /* no-ops via Csa_Stubs.m during the CSA migration, but the call */ \ + /* sites stay so a future revert path is mechanical. */ \ UsiEngineOnMatchStart(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. */ \ MetaEmitMatchStart(lp); \ /* Prevent the screen from dimming while a match is active. */ \ /* Already on the main queue here. */ \ @@ -563,15 +563,15 @@ static void scheduleAutoRematch(const char *modeTag) { /* Reset time cache so the next match doesn't inherit stale values. */ \ g_latestBlackTimeSec = 0.0f; \ g_latestWhiteTimeSec = 0.0f; \ - /* 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. */ \ - UsiEngineOnMatchEnd(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. */ \ - MetaEmitMatchEnd(result, finalSfen, usiText); \ - MetaSetMatchConfig(NULL); \ + /* Surface the result to the CSA engine driver so it can emit */ \ + /* #REASON + #WIN/#LOSE/#DRAW before the next match resets state. */ \ + CsaEngineOnMatchEnd(result); \ + /* Keep the legacy USI / meta paths fed too — both no-op via */ \ + /* Csa_Stubs.m during the CSA migration. */ \ + UsiEngineOnMatchEnd(result); \ + MetaEmitMatchEnd(result, finalSfen, usiText); \ + MetaSetMatchConfig(NULL); \ + CsaSetMatchConfig(NULL); \ /* Schedule the auto-rematch sequence. REMATCH_TAG = NULL opts out. */ \ scheduleAutoRematch(REMATCH_TAG); \ /* Re-enable the idle timer now that the match has ended. The start */ \ diff --git a/Sources/KiouEngineBridge/Inject_Resign.m b/Sources/KiouEngineBridge/Inject_Resign.m new file mode 100644 index 0000000..6765285 --- /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) { + file_log([NSString stringWithFormat: + @"[RESIGN] cannot resign player=%d: orch=%p fn=%p", + (int)playerSide, orch, g_RequestSurrender]); + return; + } + file_log([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) { + file_log([NSString stringWithFormat: + @"[RESIGN] threw: %@", e]); + } + }); +} + +void InjectNyugyokuDeclaration(int32_t playerSide) { + file_log([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 87cb7c3..27f7347 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -360,14 +360,65 @@ void UsiEngineOnWsClientDisconnected(void); // --------------------------------------------------------------------------- // Csa_Engine.m — the CSA-protocol server-side state machine that drives // the connected engine through Game_Summary / AGREE / per-move exchange / -// gameover. Symbols are declared here and stubbed in Csa_Stubs.m during -// the CSA migration; the real implementation lands in Task 4. +// 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` +// is the KIOU Move bits, `playerSide` is the side that just moved +// (0=Black, 1=White), and `sfenAfter` is the post-move SFEN snapshot. +void CsaEngineOnMoveObserved(uint32_t move, + int32_t playerSide, + NSString *sfenAfter); + +// --------------------------------------------------------------------------- +// 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. +NSString *CsaBuildGameSummary(int32_t local_player, NSString **outGameId); + +// 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); + +// --------------------------------------------------------------------------- +// 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 // protocol on the same WS port. Each line is prefixed with "meta " so the diff --git a/Sources/KiouEngineBridge/Tweak.m b/Sources/KiouEngineBridge/Tweak.m index fe6c636..80625d3 100644 --- a/Sources/KiouEngineBridge/Tweak.m +++ b/Sources/KiouEngineBridge/Tweak.m @@ -70,8 +70,9 @@ static void installUnityHooks(void) { // patched `B ` instruction. InstallMatchModeObserveHook(unityBase); InstallInjectHook(unityBase); - // CSA migration Task 2: USI engine driver removed. Task 4 will plug in - // CsaEngineInstall() here once Csa_Engine.m lands. + // 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); @@ -91,10 +92,9 @@ static void installUnityHooks(void) { // match_start with the matchmaking-resolved opponent identity on // Online matches (MatchConfig alone holds placeholders there). InstallGameStateStoreObserveHook(unityBase); - // CSA migration Task 2: USI engine driver removed. Task 4 will plug in - // CsaEngineInstall() here once Csa_Engine.m lands. Must come AFTER - // InstallInjectHook so inject_apply is fully wired before the TCP - // handler can call into it. + // 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; From 4cd20ce399baed68f30b4b1cff65cb905e0af073 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 23:23:58 +0000 Subject: [PATCH 21/51] =?UTF-8?q?feat(csa):=20Wave=203=20=E2=80=94=20tests?= =?UTF-8?q?=20+=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 8: tests/test_csa_convert_expectations.py - 85 pinned test cases for the CSA conversion library - Python port of Csa_Convert.{h,m} runs alongside the ObjC implementation; any divergence is a regression - Covers square/piece/move/SFEN-position conversion in both directions, including malformed-input rejection paths - Caught a real format bug in csa_lineFromSfenRank: empty cells must render as ' * ' (three columns wide) so the row aligns, with the trailing column-padding trimmed off the rightmost cell. Fix shipped in Csa_Convert.m's csa_lineFromSfenRank. Task 9: documentation pass - git mv docs/bridge_protocol.md docs/archive/usi_bridge_protocol.md - git mv docs/usi-compatibility.md docs/archive/usi_compatibility.md - Archive headers point at the CSA replacement docs - New docs/csa_protocol.md — wire contract, state machine, Game_Summary schema, KIOU_* extension keys, example session - New docs/csa_compatibility.md — table mapping every CSA v1.2 command onto KEB's behaviour (supported / partial / omitted) - README rewritten: badges, intro paragraph, sequence diagram, flow diagram, command table, install steps, Layout block all point at the CSA TCP server on :4081 and document the historical USI WebSocket sink as deprecated. 162 tests pass; all three build flavours (JB / JAILED=1 / BINPATCH=1) link clean. Plan: docs/plans/kiou_engine_bridge_csa_migration.md Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 238 ++++++----- Sources/KiouEngineBridge/Csa_Convert.m | 26 +- docs/bridge_protocol.md | 445 --------------------- docs/csa_compatibility.md | 169 ++++++++ docs/csa_protocol.md | 280 +++++++++++++ docs/usi-compatibility.md | 296 -------------- tests/test_csa_convert_expectations.py | 532 +++++++++++++++++++++++++ 7 files changed, 1115 insertions(+), 871 deletions(-) delete mode 100644 docs/bridge_protocol.md create mode 100644 docs/csa_compatibility.md create mode 100644 docs/csa_protocol.md delete mode 100644 docs/usi-compatibility.md create mode 100644 tests/test_csa_convert_expectations.py diff --git a/README.md b/README.md index 16a44e5..98a4dc6 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@

- 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 + engine (Apery / 技巧 / YaneuraOu in CSA mode / shogi-server clients) + can connect over LAN and play against KIOU's live board — no extra + proxy, no host-side wrapper.

@@ -17,7 +18,7 @@ platform arch engine - protocol + protocol side license status @@ -26,17 +27,25 @@ --- 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. +runs inside KIOU and exposes a CSA TCP server on `0.0.0.0:4081`; +a CSA engine 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 engine 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. + +No proxy server, no cloud, no third-party service — one ~140 KB dylib +on the phone, one TCP socket to a LAN box, and the CSA engine of your +choice on the other end. See `docs/csa_protocol.md` for the full wire +contract. + +> **Historical note.** Earlier revisions of KEB spoke a custom USI +> extension over WebSocket on port `9527`. The USI implementation lives +> in `Sources/KiouEngineBridge/{Server_WebSocket,Usi_Engine}.m` behind +> `#if 0` blocks and is excluded from every build flavour; the surviving +> wire description is in `docs/archive/usi_bridge_protocol.md`. ### Observation + scoped injection @@ -66,131 +75,106 @@ What the injection layer is **not** allowed to do: 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. +- Replay anything that didn't come from the engine. Only CSA move + lines (`+7776FU` / `-3334FU` / `+0055FU`) submitted by the connected + engine make it into the move pipeline. `%TORYO` / `%KACHI` / + `%CHUDAN` are accepted but routed to dedicated end-of-match handlers + rather than the inject path. 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. +Three actors, two wires. KIOU is the game itself; Bridge is this tweak +loaded into KIOU's process; the CSA engine is the thinking part on the +other end of the LAN socket. - **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). + `OnPlayerMoveAsync` / `TryMakeMove` / `GameOrchestrator.RequestSurrender` + for the inject side. +- **Bridge <-> CSA engine** — plain TCP on `tcp://:4081`, + CSA server protocol v1.2. ```mermaid sequenceDiagram autonumber participant K as KIOU participant B as Bridge (this tweak) - participant W as Wrapper (host) - participant E as YaneuraOu + participant E as CSA engine 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,E: tcp://device:4081 (CSA v1.2) + + Note over B,E: Session handshake + E->>B: LOGIN test pass + B-->>E: LOGIN:test OK + + Note over K,E: Match start + K-->>B: IMatchMode.InitializeAsync (latch MatchConfig / local_player) + B-->>E: BEGIN Game_Summary ... END Game_Summary + E->>B: AGREE + B-->>E: START: + + loop Per move + K-->>B: NotifyPieceMoved (move bits, side) + B-->>E: +7776FU,T10 (or -3334FU,T8 for white) + E->>B: +2726FU 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":"..."} + K-->>B: IMatchMode.OnMatchEndAsync (result) + B-->>E: #RESIGN + B-->>E: #WIN ``` -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()"] - - obs -- "latch self / extract SFEN+USI" --> state - state --> sfen - sfen --> wsout - - 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)"] - - wsin --> commit["Move.Create / CreateDrop
OnPlayerMoveAsync
Adapter.TryMakeMove"] + obs["Hook_LowLevelObserve
Hook_MatchModeObserve
Hook_OnlineObserve
Hook_GameStateStoreObserve"] + state(["g_gameCtrlCache · g_adapterCache
g_*ModeCache · g_localPlayer*
g_csaMatchConfig"]) + convert["Csa_Convert
(square / piece / SFEN -> CSA)"] + + obs -- "latch state / surface SFEN+move" --> state + state --> convert + convert --> csaout + + tcp[("TCP :4081
line-oriented UTF-8")] + csaout["Csa_Engine state machine
BOOT / LOGIN / AGREE_WAIT /
PLAYING / GAME_OVER"] + csaout --> tcp + tcp -- "+7776FU / %TORYO" --> csain + csain["Csa_Engine inbound dispatch"] + + csain --> commit["inject_apply
(Move.Create / OnPlayerMoveAsync / TryMakeMove)"] + csain --> resign["Inject_Resign
(GameOrchestrator.RequestSurrender)"] commit --> kiou(["KIOU board state advances"]) + resign --> kiou - state --> meta["Meta_Emitter (sidecar)"] - meta --> ws + state --> gameinfo["Csa_GameInfo
(Game_Summary + KIOU_* lines)"] + gameinfo --> tcp ``` -Two protocol streams share one TCP port: - -| Stream | Direction | Wire | Purpose | -|---|---|---|---| -| **USI** | bidirectional | one USI command per text frame | carry the tweak-owned handshake, `position sfen ...`, `bestmove ...`, and end-of-game notifications | -| **meta** | tweak -> host | `meta{...json}\n` per frame | match lifecycle + per-move record for KIF assembly | - -The USI state machine in `Sources/KiouEngineBridge/Usi_Engine.m` -currently owns this surface on the WebSocket link: - -| Command | Direction | Status | Notes | -|---|---|---|---| -| `usi` | tweak -> peer | supported | sent on WS connect | -| `id name ...` / `id author ...` | peer -> tweak | observed | logged, not interpreted | -| `option ...` | peer -> tweak | observed | logged, not interpreted | -| `usiok` | peer -> tweak | supported | advances handshake to `isready` | -| `isready` | tweak -> peer | supported | sent after `usiok` | -| `readyok` | peer -> tweak | supported | advances to READY and triggers `usinewgame` | -| `usinewgame` | tweak -> peer | supported | sent once the peer reports ready | -| `position sfen ` | tweak -> peer | supported | emitted when observation says it is our turn | -| `info ...` | peer -> tweak | partially supported | `info string ...` is cached; other forms are logged and ignored | -| `bestmove ` | peer -> tweak | supported | injected through KIOU's normal move pipeline | -| `gameover win|lose|draw` | tweak -> peer | supported | sent on match end when the result is known | -| `setoption ...` | either | not handled by the tweak | expected to live on the host peer / wrapper side | -| `go ...` | either | not handled by the tweak | current design leaves think-limit policy to the host peer / wrapper side | -| `stop` | peer -> tweak | not supported yet | currently ignored | -| `ponderhit` | peer -> tweak | not supported yet | current flow does not expose ponder mode | -| `quit` | peer -> tweak | not supported yet | current flow treats WS disconnect as session end | -| `position startpos moves ...` | either | not emitted | outbound path uses `position sfen ...` from KIOU's live board snapshot | -| `bestmove ponder ` | peer -> tweak | partial | only the primary bestmove token is consumed | - -YaneuraOu-specific extensions such as `go depth`, `go nodes`, -`go movetime`, `go rtime`, `getoption`, `bench`, `moves`, `side`, and -other debug / benchmark commands are intentionally **out of scope** for -this tweak-level protocol surface. If you need them, implement them in -your host-side peer or wrapper and translate them there. +KEB exposes the standard CSA v1.2 surface on the TCP link: + +| Direction | Lines | Notes | +|---|---|---| +| tweak → engine | `LOGIN: OK`, `LOGOUT:completed` | session control | +| tweak → engine | `BEGIN Game_Summary ... END Game_Summary` | full match preamble, includes `KIOU_*` extension lines | +| tweak → engine | `START:` | after `AGREE` | +| tweak → engine | `,T` | per-move notification, both colours | +| tweak → engine | `#RESIGN` / `#SENNICHITE` / `#JISHOGI` / `#CHUDAN` + `#WIN` / `#LOSE` / `#DRAW` | match end | +| engine → tweak | `LOGIN ` | accepted unconditionally | +| engine → tweak | `LOGOUT` | tears the session down | +| engine → tweak | `AGREE []` / `REJECT []` | advance / decline pre-match | +| engine → tweak | `` | engine's move; injected via `inject_apply` | +| engine → tweak | `%TORYO` | drives `GameOrchestrator.RequestSurrender` | +| engine → tweak | `%KACHI` / `%CHUDAN` | engine 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`. ## Install @@ -204,7 +188,7 @@ 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`. +relaunch KIOU, then point your CSA engine at `tcp://:4081`. ### Non-JB (Sideloadly / TrollStore / AltStore / Apple Developer Program) @@ -227,11 +211,9 @@ frida-gum), the static binary patch never writes to `__TEXT` at runtime and therefore survives the iOS 18 Code Signing Monitor (CSM) — the binpatch flavour covers iOS 15.0 – 18.x. -**Note:** the binpatch build omits the `meta` sidecar lines. KIF -assembly on the host bridge depends on `meta {…}` lines emitted by the -JB build's `GameStateStore` observation hooks. Non-JB users get USI -handshake + position/go/bestmove and can still play through YaneuraOu, -but no KIF record is assembled. See +All three build flavours (`make`, `make JAILED=1`, `make BINPATCH=1`) +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. @@ -242,7 +224,7 @@ matrix. | **KIOU app version** | `1.0.1` (`CFBundleVersion` 11) | | **iOS (JB rootless build)** | 15.0 – 16.5, arm64, rootless | | **iOS (non-JB binpatch build)** | 15.0 – 18.x, arm64 | -| **Engine wire** | standard USI protocol over WebSocket text frames | +| **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 @@ -279,9 +261,9 @@ is always recoverable. no Swift runtime. - iOS 15.0–16.5, arm64, rootless layout. - 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. +- A CSA engine reachable on the LAN. Apery, 技巧, and YaneuraOu in CSA + mode are reference setups. shogi-server's bundled `csa.rb` test + client is enough to sanity-check the wire. ## Layout @@ -295,11 +277,17 @@ Sources/KiouEngineBridge/ 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 + Inject_Move.m # CSA move -> Move.Create / TryMakeMove path + Inject_Resign.m # %TORYO -> GameOrchestrator.RequestSurrender + Csa_Convert.{h,m} # square / piece / move / SFEN <-> CSA conversion + Csa_Engine.{h,m} # CSA protocol state machine (BOOT/LOGIN/.../GAME_OVER) + Csa_GameInfo.m # Game_Summary + KIOU_* extension builder + Server_CSA.m # 0.0.0.0:4081 listener + line-oriented recv loop BinpatchDispatcher.m # binpatch-only: publishes hook dispatcher into __bss SLOT + Usi_Engine.m # DEPRECATED (#if 0) — pre-CSA USI driver + Server_WebSocket.m # DEPRECATED (#if 0) — pre-CSA WS sink + Meta_Emitter.m # legacy JSON sidecar (no longer wired on the wire) + Csa_Stubs.m # transitional no-op shims for USI symbols Sources/Common/ # IPA-Patch/Common submodule (logging, il2cpp, hookengine) shared/ # IPA-Patch/Shared submodule (binpatch tooling) diff --git a/Sources/KiouEngineBridge/Csa_Convert.m b/Sources/KiouEngineBridge/Csa_Convert.m index 3c5454c..7eaba4f 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.m +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -230,9 +230,17 @@ BOOL MoveBitsFromCsaText(NSString *csa, // // 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. -// The CSA cell format is two chars per square: ` *` for empty, `+XX` for -// black piece XX, `-XX` for white piece XX. Each `P` row therefore is -// `P` followed by 9 × 2 = 18 chars. +// +// 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 @@ -261,7 +269,7 @@ BOOL MoveBitsFromCsaText(NSString *csa, // 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:18]; + NSMutableString *out = [NSMutableString stringWithCapacity:27]; BOOL pendingPromote = NO; NSUInteger filled = 0; for (NSUInteger i = 0; i < sfenRank.length; i++) { @@ -273,7 +281,7 @@ BOOL MoveBitsFromCsaText(NSString *csa, if (ch >= '1' && ch <= '9') { int empty = (int)(ch - '0'); for (int e = 0; e < empty; e++) { - [out appendString:@" *"]; + [out appendString:@" * "]; filled++; } pendingPromote = NO; @@ -288,6 +296,14 @@ BOOL MoveBitsFromCsaText(NSString *csa, 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; } diff --git a/docs/bridge_protocol.md b/docs/bridge_protocol.md deleted file mode 100644 index 92d8eed..0000000 --- a/docs/bridge_protocol.md +++ /dev/null @@ -1,445 +0,0 @@ -# KiouEngineBridge wire protocol - -KiouEngineBridge (KEB) is the iOS-side dylib that turns KIOU into a -**USI match server** — analogous in role to Floodgate or shogi-server, -but embedded inside the KIOU process rather than running as a standalone -service. - -The mental model: - -``` -KIOU (authoritative source of board state and clocks) - ↕ in-process hooks - KEB (USI match server — negotiates the match, delivers position + clocks) - ↕ USI over WebSocket :9527 - Engine (connects when ready to play; just needs to return bestmove) -``` - -KIOU decides the match conditions (time control, handicap, opponents). -KEB reads those conditions via hooks and opens a WebSocket server that -announces them through the standard USI protocol. An engine that wants -to play connects, exchanges the USI handshake, and from that point -receives `position sfen ...` and `go btime/wtime/byoyomi` on every -turn — exactly the same two pieces of information Floodgate delivers to -each engine in a CSA match. The engine returns `bestmove`; KEB feeds -that move back into KIOU via the injection path. - -Because KIOU is the authority on both board state and clocks, KEB has -no way to offer the engine a pre-negotiated time control before the -match starts — the engine learns the time budget from the first `go` -line of the first turn, not from `setoption` or a pre-game declaration. -A wrapper that wants to translate this into engine-specific search limits -(`go depth`, `go nodes`, custom byoyomi policy) can sit between KEB and -the raw engine process; KEB makes no distinction. - -This document is the contract between the dylib and the connecting -engine (or wrapper). It is the authoritative description of what bytes -go on the wire; if the C source and this file disagree, the C source -wins and this file is the bug. - -This document is the contract between the dylib and the host. It is the -authoritative description of what bytes go on the wire; if the C source -and this file disagree, the C source wins and this file is the bug. - -Source of truth pointers: - -- `Sources/KiouEngineBridge/Server_WebSocket.m` — RFC 6455 server. -- `Sources/KiouEngineBridge/Usi_Engine.m` — USI half of the protocol. -- `Sources/KiouEngineBridge/Meta_Emitter.m` — `meta` JSON half (JB build - only; the binpatch build links the empty stubs at the top of the same - file). - -## Transport - -- **TCP, plaintext, port 9527.** Bound to `0.0.0.0` from - `Sources/KiouEngineBridge/Tweak.m:113` via - `kiou_ws_server_start(9527)`. No TLS, no auth, no Bonjour / - `NSLocalNetworkUsageDescription`. The model is "tweak and host live on - the same trusted LAN." -- **WebSocket, RFC 6455 minimal subset.** Client sends a normal - `HTTP/1.1 Upgrade: websocket` with a `Sec-WebSocket-Key`; server - replies with the standard SHA-1 + base64 accept token. Sub-protocols - and extensions are ignored if offered. -- **One client at a time.** A second incoming TCP connection is accepted - only to be immediately closed with `HTTP/1.1 409 Conflict` so the - kernel stops half-opening the handshake. The current client must - close (or be detected dead via TCP keepalive) before a new one can - attach. -- **Frame types used.** - - Outbound (dylib → host): `0x1` text frames, FIN=1, unmasked. Two- - and eight-byte extended payload-length forms are both supported. - - Inbound (host → dylib): `0x1` text frames, FIN=1, masked per spec. - `0x9` Ping → server replies with `0xA` Pong. `0x8` Close → server - tears the client down. Anything else is logged at `[WS-DBG]` and - dropped. -- **TCP keepalive.** `SO_KEEPALIVE` on, idle 5 s, interval 3 s, count 3 - — a silently dead host is reaped in roughly 15 s so the next connect - attempt is not refused with 409. -- **Backpressure.** Outbound frames go through a serial GCD queue. If - the in-flight backlog exceeds 128 frames the oldest pending frame is - dropped and a `[WS]` warning is logged. The protocol is best-effort — - the host MUST be able to recover state from observation lines alone - and MUST NOT assume the dylib will retransmit anything. - -## Logical channels - -Every text frame is exactly one line. The dylib classifies its -**outbound** traffic into two channels by the first token of the line; -the **inbound** side only carries USI lines. - -| Direction | Channel | Frame shape | Defined in | -|---|---|---|---| -| dylib → host | USI | one USI command per line, e.g. `usi\n`, `position sfen ...\n` | `Usi_Engine.m::usi_engine_send_line` | -| dylib → host | meta (extended USI) | `meta []\n` — one line per field, block-structured | `Meta_Emitter.m` (JB build only) | -| host → dylib | USI | one USI command, lines split on `\r\n` / `\n` / `\r` | `Usi_Engine.m::usi_engine_text_handler` | - -Both channels use the same line-oriented format: first token is the -command or `meta`, remaining tokens are the payload. The `meta` prefix -is the channel tag; the engine parser can skip any line starting with -`meta` without breaking the USI state machine. - -There is no separate channel for control or for errors. If the dylib -needs to tell the host something the host can't act on from the existing -channels, it goes into the on-device log only (`tmp/kiouenginebridge.log` -on the jailbroken build; Documents on the binpatch build) — not over the -WebSocket. - -## USI channel — state machine - -KEB acts as the **match-server side** of the USI protocol. The -connecting peer is a USI engine. KEB drives the full game loop — it -emits `usi`, `isready`, `usinewgame`, `position sfen ...`, `go ...`, -`stop`, `gameover`, and `quit`. The engine is expected to respond -according to the standard USI engine protocol. - -`setoption` is not emitted by KEB. Match conditions are set by KIOU -before KEB is even aware of them; there is no opportunity for KEB to -pre-negotiate engine options before the match starts. If the engine -requires option configuration, a wrapper should handle that (see § -Wrapper-mediated deployment). - -States are tracked atomically in `Usi_Engine.m::g_usiState`: - -``` -BOOT - ↓ WS client finishes upgrade → send "usi" -HANDSHAKE - ↓ inbound "usiok" → send "isready" - ↓ inbound "readyok" → (no usinewgame here — see Notes) -READY - ↓ usi_engine_on_match_start fires → send "usinewgame" - ↓ observation says it is our turn → send "position sfen ..." - send "go ..." -THINKING - ↓ inbound "bestmove " → inject move locally -INJECTING - ↓ next observation arrives → back to READY -``` - -Notes: - -- `usinewgame` is sent once per match start (`usi_engine_on_match_start`), - not once per WebSocket session. On `readyok` KEB does NOT send - `usinewgame` — it waits for the first match-start event. This avoids a - double send on the first match while still priming the engine at each - subsequent match. -- On WebSocket disconnect the state resets to `BOOT`. -- A match start where KEB already knows the seat and it is our turn - triggers a 500 ms-delayed "kick" so the engine gets a `position` + `go` - even before any opponent move fires an observation. See - `usi_engine_try_kick_on_main`. - -### dylib → peer (USI) - -| Line | When | Notes | -|---|---|---| -| `usi` | WS client connected, state → `HANDSHAKE` | Standard USI handshake opener. | -| `isready` | inbound `usiok` | | -| `usinewgame` | `usi_engine_on_match_start` fires AND state is `READY` | One per match, not per WebSocket session. | -| `position sfen ` | observation says it is our turn AND state is `READY` | `` is the full SFEN after the opponent's move. KEB never appends `moves ...` — the engine treats this as an absolute position. | -| `go ...` | immediately after each `position sfen ...` | See § `go` command detail below. | -| `stop` | (reserved) | Not yet emitted by KEB in this version; see § `stop`. | -| `gameover {win\|lose\|draw}` | match ends with a known result | Suppressed when the result is unknown (`USI_RESULT_UNKNOWN`, e.g. open-seat modes). | -| `quit` | (reserved) | See § `quit`. | - -#### `go` command detail - -KEB emits `go` immediately after each `position sfen ...`, in the same -turn decision that triggered the `position` line. - -The exact form of `go` depends on what clock information is available -from KIOU at the time the line is sent: - -**Clock-aware form** — used when KIOU has emitted at least one -authoritative snapshot this match (Online and CPUStream modes via -`UpdateAuthoritativeSnapshot`): - -``` -go btime wtime [byoyomi ] [binc ] [winc ] -``` - -- `btime` / `wtime` come from `g_latestBlackTimeSec` / - `g_latestWhiteTimeSec` (floats, converted to milliseconds). -- `byoyomi` is included when `time_control.byoyomi_seconds` from the - cached `MatchConfig.TimeControlConfig` is non-zero. -- `binc` / `winc` are included when - `time_control.increment_seconds` is non-zero. -- Parameters whose value is zero are omitted. - -**Fallback form** — used when no authoritative snapshot has arrived -yet (VsAI / LocalPvP / RecordReplay modes, or the first turn in Online -before the first snapshot lands): - -``` -go movetime -``` - -The movetime value defaults to `30000` (30 seconds) and is not yet -runtime-configurable. - -The peer must handle both forms. A wrapper that wants to translate to -engine-specific time management may do so transparently. - -#### `stop` (outbound, reserved) - -KEB does not yet emit `stop` spontaneously. The command is reserved for -a future version where KEB needs to abort an in-flight engine search -before injecting a result (e.g. on sudden match end or operator resign). - -A peer that handles `stop` correctly (answering with `bestmove`) -is forward-compatible with this planned addition. - -#### `quit` (outbound, reserved) - -KEB does not yet emit `quit` spontaneously. The command is reserved so -that a future teardown path can signal clean session end before closing -the WebSocket. Peers SHOULD handle `quit` by closing their engine -process and disconnecting. - -### peer → dylib (USI) - -KEB only acts on the first token of each inbound line. Lines may be -batched in a single text frame; the dylib splits on any of `\r\n`, -`\n`, `\r`. - -| First token | Action | -|---|---| -| `id` | logged, otherwise ignored | -| `option` | logged, otherwise ignored | -| `usiok` | reply `isready` | -| `readyok` | state → `READY`; kick if it is already our turn (does NOT send `usinewgame` — that waits for match start) | -| `info` | last `info string ...` is cached for debugging; everything else logged and ignored | -| `bestmove resign` / `bestmove (none)` / `bestmove win` | no injection, state → `READY` | -| `bestmove ` | state → `INJECTING`, call `inject_apply()`, then wait for the next observation to revert to `READY` | -| `bestmove ponder ` | same as above; ponder token is discarded | -| `stop` | if state is `THINKING`: logged (KEB waits for `bestmove` as normal — the peer is expected to reply with `bestmove` after receiving `stop`); otherwise logged and ignored | -| `quit` | KEB closes the WebSocket connection and resets state to `BOOT` | -| anything else | logged at `[USI] ignored inbound:`, otherwise dropped | - -`` for `bestmove` is a normal USI move string (`7g7f`, `8h2b+`, -`P*5e`). The dylib applies it through KIOU's own -`ShogiGameAdapter.TryMakeMove` path, so it has to be legal in the -current KIOU position. - -#### `setoption` (inbound — not handled) - -KEB does not process `setoption`. If the peer is a wrapper that forwards -engine options, it should send them to the engine directly rather than -over the WebSocket connection to KEB. Unrecognised first tokens -(including `setoption`) are logged and dropped. - -## Extended USI lines (JB build only) - -KEB emits two kinds of extended USI lines that are not part of the -standard USI spec. **They are not delivered on the binpatch build** — -the binpatch flavour links no-op stubs in `Meta_Emitter.m` lines 3-18. - -Both use the same line-oriented format as standard USI (one command per -line, space-separated tokens). A parser that does not recognise the -first token MUST skip the line silently — this keeps the extended lines -transparent to standard USI engine parsers. - -### `meta` lines — match metadata - -`meta` lines carry per-match information that has no equivalent in -standard USI. They are emitted as a flat sequence before `usinewgame`; -`usinewgame` itself signals the end of the metadata block. Values may -contain spaces (e.g. player names); the parser MUST treat everything -after the subcommand token as the value. - -Subcommand names follow the same PascalCase convention as YaneuraOu -extension option names (`USI_Hash`, `BookFile`, etc.). - -``` -meta ProtocolVersion 1.0 -meta GameId 20260615T204311-VsAI -meta Mode VsAI -meta StartedAt 2026-06-15T20:43:11Z -meta StartPosition Standard -meta YourTurn b -meta Name+ プレイヤー -meta Name- KIOU CPU (Normal) -meta TimeUnit 1sec -meta TotalTime 600 -meta Byoyomi 30 -usinewgame -``` - -| Subcommand | Value | Notes | -|---|---|---| -| `ProtocolVersion` | `1.0` | KEB extended USI protocol version. | -| `GameId` | `-` | Unique per match. | -| `Mode` | `VsAI` \| `LocalPvP` \| `OnlinePvP` \| `RecordReplay` \| `Spectate` | KIOU match mode. | -| `StartedAt` | ISO 8601 UTC | | -| `StartPosition` | `Standard` \| `HandicapLance` \| … \| `TsumeShogi` | Initial position type. | -| `YourTurn` | `b` \| `w` \| `-` | Local player's seat. `-` for open-seat modes. | -| `Name+` | string (rest of line) | Black player name. Omitted if unknown. | -| `Name-` | string (rest of line) | White player name. Omitted if unknown. | -| `Rank+` | string | Black player rank. Omitted if unknown. | -| `Rank-` | string | White player rank. Omitted if unknown. | -| `Rate+` | integer | Black player rate. Omitted if unknown. | -| `Rate-` | integer | White player rate. Omitted if unknown. | -| `UserId+` | string | Black player user ID. Omitted if unknown. | -| `UserId-` | string | White player user ID. Omitted if unknown. | -| `TimeUnit` | `1sec` | Always seconds; `go` uses milliseconds. | -| `TotalTime` | integer (seconds) | Initial main time per player. Omitted if unlimited or unreadable. | -| `Byoyomi` | integer (seconds) | Byoyomi per move. Omitted if zero. | -| `Increment` | integer (seconds) | Increment per move. Omitted if zero. | - -### `move` lines — per-move notification - -`move` lines notify the peer of each committed move. Modelled directly -on the CSA per-move notification (`+7776FU,T10`). - -``` -move +7g7f T10 -``` - -| Token | Value | Notes | -|---|---|---| -| `+` \| `-` | side | `+` = black just moved, `-` = white just moved. | -| USI move | e.g. `7g7f`, `8h2b+`, `P*5e` | The move that was applied. | -| `T` | integer (seconds) | Time spent on this move, derived from the difference between consecutive authoritative snapshots. Omitted in AI / Local modes where KIOU does not issue snapshots. | - -The receiver can compute remaining time as `total_time - Σ T` (summing -only the moves for that side), exactly as in the CSA protocol. The -`go btime/wtime` line KEB sends on the next turn carries the same -remaining time directly for engines that prefer not to track it. - -Match end is signalled by `gameover` on the USI channel. - -## Build-flavour matrix - -| Build | USI channel | meta channel | Distribution | -|---|---|---|---| -| `make package install` (JB / rootless) | available | available | jailbroken iOS 15.0 – 16.5 | -| `make binpatch` + `make ipa` | available | **no-op** (stubs in `Meta_Emitter.m`) | sideloaded iOS 15.0 – 18.x via Sideloadly / TrollStore / AltStore / Apple Developer Program | - -Hosts that want KIF assembly today need the jailbroken build. The -binpatch build's USI half is enough to drive the engine end of a CPU / -Online match, but the host has to live without the structured `meta` -stream and reconstruct what it can from the on-device log file. - -## Wrapper-mediated deployment - -A wrapper is an optional process that sits between KEB and a raw USI -engine. KEB makes no distinction — from its perspective the peer is -always a USI engine, whether or not a wrapper is in the path. - -Typical wrapper responsibilities: - -- Launching the engine as a child process and bridging stdio ↔ WebSocket. -- Injecting `setoption` lines at startup (KEB does not do this). -- Translating KEB's `go btime/wtime/...` into engine-specific variants - (`go depth`, `go nodes`, custom byoyomi policies). -- Logging or recording raw engine I/O. - -``` -KEB ←—— WebSocket (USI) ——→ Wrapper ←—— stdio ——→ USI engine -``` - -A wrapper is NOT required if the peer is already a conforming USI -engine that can accept `go btime/wtime/byoyomi` directly. - -## Example session - -Peer's perspective (peer = wrapper or raw engine), from connect to one -engine reply, on the JB build. Lines starting with `>` are sent by the -peer, `<` by KEB. `\n`s and the leading `meta ` prefix are shown -literally. - -``` -< usi -> id name YaneuraOu NNUE 7.6.3 -> option name USI_Hash type spin default 16 -> usiok -< isready -> readyok -(state = READY; no usinewgame yet — KEB waits for match start) -< meta ProtocolVersion 1.0 -< meta GameId 20260615T204311-VsAI -< meta Mode VsAI -< meta StartedAt 2026-06-15T20:43:11Z -< meta StartPosition Standard -< meta YourTurn b -< meta Name+ プレイヤー -< meta Name- KIOU CPU (Normal) -< meta TimeUnit 1sec -< meta TotalTime 600 -< meta Byoyomi 30 -< usinewgame -< position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1 -< go movetime 30000 -> info depth 12 score cp 32 pv 7g7f -> bestmove 7g7f -< move +7g7f T10 -... -< gameover win -``` - -The VsAI example above shows the `go movetime` fallback because KIOU -does not send authoritative clock snapshots in that mode. `black_time_sec` -/ `white_time_sec` are also omitted from `BEGIN_MOVE` for the same reason. - -For an Online match the `go` line and move block carry live clock values: - -``` -... -> readyok -(state = READY) -< meta ProtocolVersion 1.0 -< meta GameId 20260615T204311-OnlinePvP -< meta Mode OnlinePvP -< meta YourTurn b -< meta Name+ プレイヤー -< meta Name- 対戦相手 -< meta TimeUnit 1sec -< meta TotalTime 600 -< meta Byoyomi 30 -< usinewgame -< position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1 -< go btime 600000 wtime 600000 byoyomi 30000 -> bestmove 7g7f -< move +7g7f T10 -... -< gameover win -``` - -Note that `meta` lines always arrive before `usinewgame` and the first -`position sfen`. The engine can use `usinewgame` as the cue that a new -game is starting and that the metadata block is complete. - -## Compatibility notes - -- The `meta` protocol version is carried in `meta protocol_version` - inside each `BEGIN_GAME` block. Parsers MUST ignore unknown - subcommands within a block and MUST NOT error on unrecognised - `BEGIN_` / `END_` pairs — both will appear in future - versions. -- Fields within a block may be added in future versions without a - version bump. Parsers MUST NOT require all fields to be present. -- The USI channel is standard USI. KEB only emits the subset listed - above, but the host is free to send any USI line — unknown - first-tokens are logged and dropped, never errored. A future KEB - version can handle more inbound tokens without requiring a host - upgrade. diff --git a/docs/csa_compatibility.md b/docs/csa_compatibility.md new file mode 100644 index 0000000..1f28145 --- /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 []` | Triggers `START:`. The optional id is accepted but not validated. | ✅ | +| Engine reject | `REJECT []` | Sends `REJECT: by engine`, drops back to LOGIN. KIOU side stays in match. | ✅ | +| Match start | `START:` | Emitted after AGREE. | ✅ | + +## 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` + `#WIN`, calls `GameOrchestrator.RequestSurrender` on the main thread (`Inject_Resign.m`). | ⚠️ 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..ebd261d --- /dev/null +++ b/docs/csa_protocol.md @@ -0,0 +1,280 @@ +# 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 ..." +AGREE_WAIT + ↓ inbound "AGREE []" → send "START:" +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 +``` + +`AGREE_WAIT` is also entered directly from `LOGIN` if `Game_Summary` has +already been sent (KIOU match started before the engine logged in). The +engine doesn't need to know whether `Game_Summary` was scheduled or just +delivered — the state always advances when it sees the block. + +## 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/docs/usi-compatibility.md b/docs/usi-compatibility.md deleted file mode 100644 index 40eefd8..0000000 --- a/docs/usi-compatibility.md +++ /dev/null @@ -1,296 +0,0 @@ -# KIOU-KEB USI compatibility - -This document explains the KIOU-KEB role in the USI protocol and which -commands are supported. The authoritative wire contract is `docs/bridge_protocol.md`; -if this file and that document disagree, `bridge_protocol.md` wins. - -Primary implementation sources: - -- `Sources/KiouEngineBridge/Usi_Engine.m` -- `Sources/KiouEngineBridge/Server_WebSocket.m` -- `Sources/KiouEngineBridge/Meta_Emitter.m` - -## Role model - -KEB is a **USI match server** embedded inside the KIOU process — similar -in role to Floodgate or shogi-server. KIOU is the authoritative source -of board state and clocks. An engine (or a wrapper in front of one) -connects to KEB over WebSocket and receives `position` + `go` each turn, -exactly as it would from any other USI match server. - -```mermaid -flowchart LR - K[KIOU\nboard state / clocks] - B[KIOU-KEB\nUSI match server] - P[USI engine\nor wrapper] - U[Operator\nlaunch / configure engine] - - K <-->|observe / inject\nin-process| B - B <-->|USI + meta\nWebSocket :9527| P - U -->|configure / launch| P -``` - -The engine does not need to know or care that there is a KIOU process -behind KEB. It connects, plays, and receives `bestmove` feedback in -standard USI. - -### Why KEB cannot pre-negotiate time control - -In a typical USI GUI the time control is known before the match starts -and can be communicated via `setoption`. KEB has no such opportunity: -match conditions are set by KIOU and only become available to KEB -after `OnMatchStart` fires. The engine therefore learns the time budget -from the first `go btime/wtime/byoyomi` line of the first turn, not -from a pre-match declaration. A wrapper that needs to translate this -into engine-specific search limits should observe the `go` line and -remap it accordingly. - -## CSA protocol correspondence - -KEB's design is conceptually modelled on the CSA server protocol -(v1.2.1, `https://www.computer-shogi.org/protocol/tcp_ip_server_121.html`). -The table below maps each CSA concept to the KEB equivalent and notes -where reproduction is impossible given KIOU's constraints. - -### Session management - -| CSA concept | CSA line | KEB equivalent | Status | -|---|---|---|---| -| Connect | TCP to port 4081 | WebSocket to port 9527 | ✅ different transport, same idea | -| Login | `LOGIN name pass` | WS upgrade (no auth) | ⚠️ omitted — no auth in KEB | -| Logout | `LOGOUT` / `LOGOUT:completed` | `quit` / WS disconnect | ✅ partial | - -### Match negotiation - -CSA servers send a `BEGIN Game_Summary ... END Game_Summary` block -before the match starts so engines can inspect time control and initial -position before agreeing. KEB has no equivalent pre-match block because -match conditions are not known until `OnMatchStart` fires inside KIOU. -Instead, the same information is delivered in two ways: - -- **`meta` lines** (JB build only) — flat sequence of extended USI - lines with player names, time control settings, mode, and initial - position type. Arrive before `usinewgame`, which signals the end of - the metadata. -- **`go` line** — carries the live `btime`/`wtime`/`byoyomi` values - each turn. - -| CSA field | CSA line | KEB equivalent | Status | -|---|---|---|---| -| Protocol version | `Protocol_Version:1.2` | `meta ProtocolVersion 1.0` | ✅ JB build | -| Game ID | `Game_ID:...` | `meta GameId ...` | ✅ JB build | -| Black player name | `Name+:YaneuraOu` | `meta Name+ ...` | ✅ JB build | -| White player name | `Name-:Kristallweizen` | `meta Name- ...` | ✅ JB build | -| Your seat | `Your_Turn:+` | `meta YourTurn b\|w\|-` | ✅ JB build | -| First to move | `To_Move:+` | SFEN side-to-move in first `position` | ✅ | -| Max moves | `Max_Moves:256` | — | ⛔ KIOU limit not exposed | -| Rematch on draw | `Rematch_On_Draw:NO` | — | ⛔ omitted | -| Engine agree/reject | `AGREE` / `REJECT` | — | ⛔ omitted — engine just connects and waits | -| Match start signal | `START:` | `usinewgame` | ✅ | - -### Time control - -CSA's `BEGIN Time ... END Time` block can express a rich variety of -clocks. KEB delivers the same information via `go` parameters each turn, -derived from `MatchConfig.TimeControlConfig` and the live snapshot cache. - -| CSA field | CSA line | KEB equivalent | Status | -|---|---|---|---| -| Time unit | `Time_Unit:1sec` | `meta TimeUnit 1sec` + implicit ms in `go` | ✅ JB build | -| Total (main) time | `Total_Time:600` | `meta TotalTime 600` + `btime`/`wtime` in `go` | ✅ JB build / when snapshot available | -| Byoyomi | `Byoyomi:30` | `meta Byoyomi 30` + `byoyomi` in `go` | ✅ | -| Increment | `Increment:10` | `meta Increment 10` + `binc`/`winc` in `go` | ✅ | -| Delay | `Delay:5` | — | ⛔ not exposed by KIOU | -| Min time per move | `Least_Time_Per_Move:1` | — | ⛔ not exposed by KIOU | -| Time roundup | `Time_Roundup:YES` | — | ⛔ not exposed by KIOU | -| Unlimited time | (all fields omitted) | `go movetime 30000` (fallback) | ⚠️ treated as movetime fallback | - -### Per-turn exchange - -This is the core of the CSA protocol: server notifies each engine of the -opponent's move, engine replies with its own move. - -| CSA concept | CSA direction / line | KEB equivalent | Status | -|---|---|---|---| -| Position after opponent's move | `+7776FU,T10` (incremental) | `position sfen ...` (full snapshot) | ✅ different encoding | -| Move notification to peer | `+7776FU,T10` | `move +7g7f T10` | ✅ JB build | -| Remaining / consumed time per turn | `,T10` suffix on move line | `btime` / `wtime` in `go` | ✅ remaining (not consumed) | -| Engine submits move | `+7776FU` | `bestmove 7g7f` | ✅ | -| Engine resigns | `%TORYO` | `bestmove resign` | ✅ | -| Engine claims win (nyugyoku) | `%KACHI` | `bestmove win` | ✅ | -| Engine requests abort | `%CHUDAN` | — | ⛔ not supported | -| Keepalive / heartbeat | LF (≥30 s interval) | WS Ping/Pong frames | ✅ handled by transport | - -### Match result - -| CSA result | CSA lines | KEB equivalent | Status | -|---|---|---|---| -| Resign | `#RESIGN` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ✅ | -| Time up | `#TIME_UP` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ⚠️ result only, no TIME_UP signal | -| Illegal move | `#ILLEGAL_MOVE` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ⚠️ result only; KIOU handles detection | -| Sennichite (draw) | `#SENNICHITE` + `#DRAW` | `gameover draw` | ✅ | -| Oute-sennichite | `#OUTE_SENNICHITE` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ⚠️ result only | -| Nyugyoku win | `#JISHOGI` + `#WIN` / `#LOSE` | `gameover win` / `gameover lose` | ⚠️ result only | -| Max moves reached | `#MAX_MOVES` + `#CENSORED` | `gameover draw` (KIOU-dependent) | ⚠️ result only | -| Server abort | `#CHUDAN` | — | ⛔ not surfaced | -| Unknown / open-seat | — | (gameover suppressed) | ✅ | - -## Standard USI flow - -```mermaid -sequenceDiagram - autonumber - participant K as KIOU - participant B as KIOU-KEB\n(match server) - participant P as USI engine - - P->>B: WebSocket connect - - Note over B,P: USI handshake (KEB initiates) - B->>P: usi - P-->>B: id name ... - P-->>B: id author ... - P-->>B: option ... - P-->>B: usiok - B->>P: isready - P-->>B: readyok - Note over B,P: state = READY (no usinewgame yet) - - Note over K,B: Match start - K-->>B: OnMatchStart → local_player latched - B-->>P: meta protocol_version / meta name+ / meta byoyomi / ... - B->>P: usinewgame - - loop When it becomes our turn - K-->>B: TryMakeMove observation → SFEN / side_to_move - B->>P: position sfen - B->>P: go btime wtime byoyomi - P-->>B: info ... - P-->>B: bestmove 7g7f - Note over B,K: inject_apply → KIOU's move pipeline - B->>K: replay move - B-->>P: move +7g7f T10 - end - - K-->>B: OnMatchEndAsync - B->>P: gameover win|lose|draw -``` - -## Wrapper-mediated deployment (optional) - -A wrapper sits between KEB and the raw engine process. From KEB's -perspective, the wrapper is just the peer — the protocol is identical. -Typical wrapper responsibilities are engine spawning, `setoption` -injection, and `go` policy translation. - -```mermaid -sequenceDiagram - autonumber - participant B as KIOU-KEB\n(match server) - participant W as Wrapper - participant E as USI engine - - B->>W: usi - W->>E: usi - E-->>W: id / option / usiok - W-->>B: id / option / usiok - - B->>W: isready - W->>E: isready - E-->>W: readyok - W-->>B: readyok - - B->>W: usinewgame - W->>E: usinewgame - - B->>W: position sfen - B->>W: go btime 540000 wtime 570000 byoyomi 30000 - W->>E: position sfen - W->>E: go btime 540000 wtime 570000 byoyomi 30000 - Note over W,E: wrapper may remap to go depth / go nodes / etc. - - E-->>W: info ... - E-->>W: bestmove 7g7f - W-->>B: info ... - W-->>B: bestmove 7g7f -``` - -## Supported USI surface - -Implemented in `Sources/KiouEngineBridge/Usi_Engine.m`. - -### KEB → engine (outbound) - -| Command | Status | Notes | -|---|---|---| -| `usi` | ✅ supported | Sent on WS connect. | -| `isready` | ✅ supported | Sent on inbound `usiok`. | -| `usinewgame` | ✅ supported | Sent once per match start (not per WS session). | -| `position sfen ` | ✅ supported | Emitted when KIOU observation says it is our turn. | -| `go btime wtime [byoyomi ] [binc ] [winc ]` | ✅ supported | Emitted immediately after `position`, when authoritative clocks are available (Online / CPUStream). | -| `go movetime 30000` | ✅ supported | Fallback when authoritative clocks are not available (VsAI / Local modes, or first turn before snapshot arrives). | -| `gameover win\|lose\|draw` | ✅ supported | Sent on match end when the result is known. Suppressed for open-seat modes. | -| `stop` | 🔜 reserved | Not yet emitted. Planned for forced abort (e.g. operator resign mid-think). | -| `quit` | 🔜 reserved | Not yet emitted. Planned for clean session teardown. | - -### engine → KEB (inbound) - -| Command | Status | Notes | -|---|---|---| -| `id name ...` / `id author ...` | ✅ observed | Logged; not interpreted. | -| `option ...` | ✅ observed | Logged; not interpreted. | -| `usiok` | ✅ supported | Advances handshake; triggers `isready`. | -| `readyok` | ✅ supported | State → READY; triggers post-readyok kick if already our turn. Does NOT send `usinewgame`. | -| `info string ...` | ✅ partial | Value cached for debugging. | -| `info` (other forms) | ✅ partial | Logged; otherwise ignored. | -| `bestmove ` | ✅ supported | Injected through KIOU's normal move pipeline. | -| `bestmove resign` / `(none)` / `win` | ✅ supported | No injection; state → READY. | -| `bestmove ponder ` | ✅ partial | Primary move injected; ponder token discarded. | -| `stop` | ✅ supported | If state is THINKING: logged, KEB waits for `bestmove` as normal. Otherwise ignored. | -| `quit` | ✅ supported | KEB closes WS and resets to BOOT. | -| `setoption ...` | ❌ not handled | Logged and dropped. Configure engine options in the wrapper. | -| `ponderhit` | ❌ not supported | No ponder mode exposed. | -| anything else | — | Logged at `[USI] ignored inbound:`; dropped. | - -## `go` command detail - -KEB emits `go` immediately after each `position sfen ...`, in the same -turn decision that triggered the `position` line. - -**Clock-aware form** (Online and CPUStream modes, once a snapshot has arrived): - -``` -go btime wtime [byoyomi ] [binc ] [winc ] -``` - -- `btime` / `wtime` come from the most recent `UpdateAuthoritativeSnapshot` - call (`g_latestBlackTimeSec` / `g_latestWhiteTimeSec`, converted to ms). -- `byoyomi` / `binc` / `winc` come from `MatchConfig.TimeControlConfig` - (`byoyomi_seconds` / `increment_seconds`). Parameters whose value is - zero are omitted. - -**Fallback form** (VsAI / LocalPvP / RecordReplay, or before the first -snapshot lands in Online): - -``` -go movetime 30000 -``` - -The movetime value is 30 000 ms and is not yet runtime-configurable. - -## Scope boundary - -YaneuraOu-specific extensions (`go depth`, `go nodes`, `go rtime`, -`getoption`, `bench`, `moves`, `side`, etc.) and standard commands not -listed above (`debug`, `register`, `ponderhit`) are intentionally out of -scope for the KEB-level protocol surface. - -If you need those, implement them in the wrapper layer and translate -to/from the `go btime/wtime/byoyomi` form that KEB emits. - -## Related documents - -- `docs/bridge_protocol.md` — authoritative wire contract and full example - sessions. -- `README.md` — project overview and install/build notes. -- CSA server protocol v1.2.1 — `https://www.computer-shogi.org/protocol/tcp_ip_server_121.html` 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" From 35bf4631ee2381def28ab011f64a707c9aba891d Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Tue, 16 Jun 2026 23:48:45 +0000 Subject: [PATCH 22/51] fix(csa): auto-START after Game_Summary so KIOU CPU moves are not dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KIOU's match-start event fires its in-game CPU immediately; KEB has no way to pause KIOU until the connected engine has replied with AGREE. Sitting in AGREE_WAIT meant the CPU's first moves landed in NotifyPieceMoved → CsaEngineOnMoveObserved while the state was not yet PLAYING, so the per-move dispatch dropped them. Send START: together with Game_Summary and roll straight into PLAYING. A later inbound AGREE from the engine in PLAYING is now logged as a no-op (csa_handle_agree's new PLAYING short-circuit) instead of treated as a state error. The AGREE_WAIT enum value is kept reserved for a future build flavour that pauses KIOU during the handshake. Caught on a live VsAI session: Game_Summary arrived but no moves until the user typed AGREE. With this fix the test client sees the CSA spec's START line right after Game_Summary and immediately gets +7776FU,T0 once the KIOU CPU thinks. docs/csa_protocol.md updated to call out the deviation explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 20 ++++++++++++++++++-- docs/csa_protocol.md | 21 +++++++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index f787e67..f9da3c1 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -132,7 +132,16 @@ static void csa_send_game_summary(int32_t local_player) { g_csaLastGameSummary = summary; g_csaLastGameID = gameId ?: @"GAME"; CsaEngineSendBlock(summary); - csa_set_state(CSA_STATE_AGREE_WAIT); + // 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); } // --------------------------------------------------------------------------- @@ -159,14 +168,21 @@ static void csa_handle_login(NSString *line) { } 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. + file_log(@"[CSA-ENG] AGREE in PLAYING — already started, dropping"); + return; + } if (s != CSA_STATE_AGREE_WAIT) { file_log([NSString stringWithFormat: @"[CSA-ENG] AGREE in state %s — ignoring", csa_state_name(s)]); return; } - (void)line; // optional suffix is accepted but not validated NSString *gid = g_csaLastGameID ?: @"GAME"; CsaEngineSendLine([NSString stringWithFormat:@"START:%@", gid]); csa_set_state(CSA_STATE_PLAYING); diff --git a/docs/csa_protocol.md b/docs/csa_protocol.md index ebd261d..aa9227c 100644 --- a/docs/csa_protocol.md +++ b/docs/csa_protocol.md @@ -64,8 +64,8 @@ BOOT LOGIN ↓ inbound "LOGIN " → send "LOGIN: OK" ↓ (if KIOU match already in progress) → send "BEGIN Game_Summary ..." -AGREE_WAIT - ↓ inbound "AGREE []" → send "START:" + + "START:" + (KIOU does not wait for AGREE) PLAYING ↓ inbound move "+7776FU" → inject into KIOU ↓ KIOU move observed → send "+7776FU,T10" @@ -76,10 +76,19 @@ GAME_OVER ↓ inbound "LOGOUT" → send "LOGOUT:completed", close ``` -`AGREE_WAIT` is also entered directly from `LOGIN` if `Game_Summary` has -already been sent (KIOU match started before the engine logged in). The -engine doesn't need to know whether `Game_Summary` was scheduled or just -delivered — the state always advances when it sees the block. +**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 From 13097b423060c5f8fe2d781153363b76a6a9c83b Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 00:13:50 +0000 Subject: [PATCH 23/51] fix(csa): read remaining time from GameStateStore +0x80/0x90 +0x20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot path (Hook_OnlineObserve::UpdateAuthoritativeSnapshot) only fires reliably on OnlinePvP — VsAI / Local sessions either leave the g_latestBlackTimeSec floats at zero or update them on an unrelated cadence, which produced T values that were dramatically off (showing T7 for a move that took ~40s). GameStateStore (dump.cs:1422268) keeps the live clocks for every match mode at: +0x80 _blackTimeRemaining : ReactiveProperty +0x90 _whiteTimeRemaining : ReactiveProperty Live probing confirmed the boxed float lives at +0x20 inside the ReactiveProperty object. VsAI uses 86400.0f (24 h) as a 'no time limit' sentinel on the CPU side; we filter that to -1 so it doesn't ship as a wild T value. HookNotifyPieceMoved now reads both clocks at observation time and hands them to CsaEngineOnMoveObserved alongside the move bits and SFEN. Csa_Engine caches the post-move remaining seconds per side and computes the next move's T as the integer delta — independent of the snapshot-driven Hook_OnlineObserve path. Also fixes the engine-seat calculation: the connected CSA engine stands in for KIOU's local player (Your_Turn maps directly to lp), not the opposite side. The 'move side mismatch' warning was firing on every legitimate move because of the inversion; now the warning only fires on genuine seat confusion and the log notes that the move is applied anyway. Verified on-device: 30-min VsAI session shows correct T values per move (e.g. +2625FU,T37 for a 37s think) and the 'side mismatch' warnings are gone for normal play. All three build flavors link clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.h | 21 +++++-- Sources/KiouEngineBridge/Csa_Engine.m | 62 ++++++++++++------- .../Hook_GameStateStoreObserve.m | 28 ++++++++- Sources/KiouEngineBridge/Internal.h | 16 +++-- 4 files changed, 93 insertions(+), 34 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.h b/Sources/KiouEngineBridge/Csa_Engine.h index 3048edd..67b0d7d 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.h +++ b/Sources/KiouEngineBridge/Csa_Engine.h @@ -50,14 +50,23 @@ void CsaEngineOnMatchStart(int32_t local_player); void CsaEngineOnMatchEnd(usi_match_result_t result); // Per-move observation, fired from Hook_GameStateStoreObserve.m's -// NotifyPieceMoved hook. `move` is the KIOU Move bits, `playerSide` is -// the side that just moved (0=Black, 1=White), and `sfenAfter` is the -// 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). +// 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); + 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 index f9da3c1..fbb9aa5 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -57,11 +57,15 @@ static NSString *volatile g_csaLastGameSummary = nil; static NSString *volatile g_csaLastGameID = nil; -// Per-snapshot black/white remaining-time cache, used to derive the `T` -// field on per-move notifications. Both default to -1 (= no snapshot yet), -// which translates to "omit the ,T suffix" in CsaTextFromMoveBits. -static _Atomic int g_csaLastBlackTimeSec = -1; -static _Atomic int g_csaLastWhiteTimeSec = -1; +// 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; // --------------------------------------------------------------------------- // Helpers. @@ -344,13 +348,16 @@ static void csa_handle_move_from_engine(NSString *line) { } int32_t lp = atomic_load(&g_csaLocalPlayer); - int32_t enginePlayer = (lp == 0) ? 1 : (lp == 1) ? 0 : -1; + // 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) { file_log([NSString stringWithFormat: - @"[CSA-ENG] move side mismatch: engine=%d got=%d", + @"[CSA-ENG] move side mismatch: engine=%d got=%d " + @"(applying anyway)", enginePlayer, playerSide]); - // Still try to apply it — the engine knows its side better than we - // do in open-seat modes. } // Build USI for the inject pipeline. The "from" / "to" bits already @@ -406,8 +413,10 @@ static void csa_handle_move_from_engine(NSString *line) { void CsaEngineOnMatchStart(int32_t local_player) { atomic_store(&g_csaLocalPlayer, local_player); - atomic_store(&g_csaLastBlackTimeSec, -1); - atomic_store(&g_csaLastWhiteTimeSec, -1); + // 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; int s = atomic_load(&g_csaState); file_log([NSString stringWithFormat: @@ -446,7 +455,9 @@ void CsaEngineOnMatchEnd(usi_match_result_t result) { void CsaEngineOnMoveObserved(uint32_t move, int32_t playerSide, - NSString *sfenAfter) { + 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. @@ -479,18 +490,23 @@ void CsaEngineOnMoveObserved(uint32_t move, pscPieceType = pscPieceType - 8; } - // Compute the seconds spent on this move from the snapshot delta. + // Compute the seconds spent on THIS side's just-played move from the + // post-move clock delta. The caller (HookNotifyPieceMoved) already + // pre-filtered the VsAI "no time limit" sentinel (86400s) to -1.0f + // so any non-negative value can be trusted. int32_t timeSpent = -1; - if (playerSide == 0) { - int last = atomic_load(&g_csaLastBlackTimeSec); - int now = (int)g_latestBlackTimeSec; - if (last >= 0 && now >= 0 && last >= now) timeSpent = last - now; - if (now > 0) atomic_store(&g_csaLastBlackTimeSec, now); - } else if (playerSide == 1) { - int last = atomic_load(&g_csaLastWhiteTimeSec); - int now = (int)g_latestWhiteTimeSec; - if (last >= 0 && now >= 0 && last >= now) timeSpent = last - now; - if (now > 0) atomic_store(&g_csaLastWhiteTimeSec, now); + float remain = (playerSide == 0) ? blackTimeRemainSec : whiteTimeRemainSec; + float volatile *cacheSlot = (playerSide == 0) + ? &g_csaLastBlackRemainSec + : &g_csaLastWhiteRemainSec; + if (remain >= 0.0f) { + float last = *cacheSlot; + if (!isnan(last) && last >= remain) { + float delta = last - remain; + // Round down — CSA T values are integer seconds. + timeSpent = (int32_t)delta; + } + *cacheSlot = remain; } NSString *csa = CsaTextFromMoveBits(move, pscPieceType, playerSide, diff --git a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m index ef498bf..10a44c0 100644 --- a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m @@ -118,11 +118,37 @@ static void HookNotifyPieceMoved(void *self, @"[GSTATE-MOVE] NotifyPieceMoved self=%p moved_side=%d " @"usi=\"%@\" sfen=\"%@\"", self, (int)playerSide, usi ?: @"", sfen ?: @""]); + + // GameStateStore (dump.cs:1422268) keeps two ReactiveProperty + // clocks at offset 0x80 / 0x90. The underlying R3 / UniRx box stores + // the current value at +0x20 (verified live with a probe sweep, see + // commit history). Online matches keep these in sync with the server; + // VsAI / Local matches use them for the on-screen clock too. + // + // Sanity check: VsAI emits 86400.0 (= 24 h) as the CPU side's clock to + // mean "no time limit." Treat anything ≥ 86400 - 60 as "no clock" so we + // don't ship bogus large T values. + 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; + } + } + MetaEmitMove(usi, sfen, nextSide); // CSA engine driver wants the raw Move bits + post-move SFEN so it can // reconstruct the piece type at the destination square and ship a // `+7776FU,T10`-style notification. - CsaEngineOnMoveObserved((uint32_t)move, playerSide, sfen); + CsaEngineOnMoveObserved((uint32_t)move, playerSide, sfen, + blackRemain, whiteRemain); } // --------------------------------------------------------------------------- diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index 27f7347..3983b17 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -377,12 +377,20 @@ void CsaEngineInstall(void); void CsaEngineOnMatchStart(int32_t local_player); void CsaEngineOnMatchEnd(usi_match_result_t result); -// Per-move notification, fired from Hook_GameStateStoreObserve.m. `move` -// is the KIOU Move bits, `playerSide` is the side that just moved -// (0=Black, 1=White), and `sfenAfter` is the post-move SFEN snapshot. +// 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); + NSString *sfenAfter, + float blackTimeRemainSec, + float whiteTimeRemainSec); // --------------------------------------------------------------------------- // Csa_GameInfo.m — MatchConfig / PlayerInfo readers + CSA Game_Summary / From eca187ce52271be0cf77d67095d67ce09c66a5b6 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 00:22:00 +0000 Subject: [PATCH 24/51] fix(csa): wall-clock T fallback always available (also on first move) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous structure picked path (a) (live KIOU clock) OR path (b) (wall-clock fallback) but never both, so a side's very first move ate the live-clock branch — last cache is NaN at that point so the delta can't be computed — and the wall-clock branch was skipped because 'remain >= 0' had already taken path (a). Real-world symptom: in VsAI, the human side's first move (e.g. '-3334FU') landed with no ,T suffix even though wall time was ~10 seconds. Restructure: try path (a) first and update the live-clock cache as before, then fall through to path (b) if T is still -1. The two paths now compose: - subsequent moves with live clocks: path (a) wins (more accurate, accounts for animation latency) - first move on any side: path (a) updates cache but emits no T, path (b) supplies a wall-clock-based one - VsAI CPU sentinel (86400s -> filtered to -1): path (a) skipped, path (b) supplies T OnMatchStart seeds both sides' mach_absolute_time baselines so the opening move on either colour can compute against 'match start -> first move'. Also adds to Csa_Engine.m and a small csa_machTicksToSec helper that converts mach ticks to integer seconds via mach_timebase_info. Builds clean on all three flavors. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 71 ++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index fbb9aa5..eab2da5 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -1,6 +1,7 @@ #import "Internal.h" #import "Csa_Engine.h" +#import #import // =========================================================================== @@ -67,6 +68,27 @@ static float volatile g_csaLastBlackRemainSec = NAN; static float volatile g_csaLastWhiteRemainSec = NAN; +// 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. // --------------------------------------------------------------------------- @@ -417,6 +439,13 @@ void CsaEngineOnMatchStart(int32_t local_player) { // delta isn't computed against a stale previous-match value. g_csaLastBlackRemainSec = NAN; g_csaLastWhiteRemainSec = NAN; + // 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); file_log([NSString stringWithFormat: @@ -490,24 +519,54 @@ void CsaEngineOnMoveObserved(uint32_t move, pscPieceType = pscPieceType - 8; } - // Compute the seconds spent on THIS side's just-played move from the - // post-move clock delta. The caller (HookNotifyPieceMoved) already - // pre-filtered the VsAI "no time limit" sentinel (86400s) to -1.0f - // so any non-negative value can be trusted. + // 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; - // Round down — CSA T values are integer seconds. - timeSpent = (int32_t)delta; + 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); From dd26acf5ca6f617679c898ac882bb8976fee86c7 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 00:28:13 +0000 Subject: [PATCH 25/51] feat(csa): auto-renegotiate on TCP reconnect mid-match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a CSA engine reconnected to KEB while a KIOU match was already in progress, the engine landed in LOGIN state and had no way to discover the current board / clock / Game_ID short of receiving Game_Summary — which only fires on KIOU's OnMatchStart, not on TCP accept. The result: every subsequent move from KIOU got dropped by csa_handle_move_from_engine's PLAYING-state guard and the user's inputs were rejected as 'move in state LOGIN — ignoring'. CsaEngineOnTcpClientConnected now checks g_csaLocalPlayer at accept time; if a match is mid-flight (lp 0 or 1), it auto-ships Game_Summary and rolls straight into PLAYING via the existing auto- START path. The engine doesn't need to send LOGIN to discover the state. csa_handle_login still accepts an explicit LOGIN from standard CSA clients (Apery, technique, etc.) but skips re-sending Game_Summary when we're already in PLAYING — no duplicate handshake noise. Net behaviour: - TCP-only client (csa_test_client, nc): auto Game_Summary + START - Standard CSA engine sending LOGIN: same auto Game_Summary + START, LOGIN reply still goes out for protocol compliance Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index eab2da5..250687c 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -182,6 +182,16 @@ static void csa_handle_login(NSString *line) { NSString *name = (parts.count >= 2) ? parts[1] : @"engine"; CsaEngineSendLine([NSString stringWithFormat:@"LOGIN:%@ OK", name]); + int s = atomic_load(&g_csaState); + if (s == CSA_STATE_PLAYING) { + // CsaEngineOnTcpClientConnected already shipped Game_Summary for + // the in-progress match. A late LOGIN from a verbose client gets + // the OK reply above but doesn't re-trigger renegotiation. + file_log(@"[CSA-ENG] LOGIN in PLAYING — Game_Summary already sent, " + @"skipping renegotiation"); + return; + } + int32_t lp = atomic_load(&g_csaLocalPlayer); if (lp == 0 || lp == 1) { // We already have a KIOU match in progress — push Game_Summary now. @@ -587,6 +597,17 @@ void CsaEngineOnMoveObserved(uint32_t move, void CsaEngineOnTcpClientConnected(void) { file_log(@"[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. Standard CSA engines that DO send LOGIN + // still get a normal LOGIN: OK reply; csa_handle_login skips + // re-sending Game_Summary when we're already past LOGIN state. + int32_t lp = atomic_load(&g_csaLocalPlayer); + if (lp == 0 || lp == 1) { + file_log(@"[CSA-ENG] mid-match reconnect — auto-renegotiating"); + csa_send_game_summary(lp); + } } void CsaEngineOnTcpClientDisconnected(void) { From 8914f8b6741bbbc42173e39d377349536516a4d0 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 00:39:17 +0000 Subject: [PATCH 26/51] fix(csa): drop piece type from hand delta + promote bit from prev SFEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related cases the static Move bits / CSA piece mnemonic couldn't disambiguate were both reading the wrong thing: (A) Drop emission. KIOU's Move bits don't carry a reliably-decoded upper-16 piece encoding for drops yet — the bit-15 drop flag also isn't always set on KIOU-side drops we observe. CsaTextFromMoveBits therefore returned nil whenever the player dropped a piece, and the CSA engine got no notification of the move. (B) Promote bit on engine-submitted moves. CSA encodes both 'promote while moving' and 'move the already-promoted piece' with the same promoted mnemonic (TO/NY/NK/NG/UM/RY). MoveBitsFromCsaText flagged promote=true unconditionally, which then made inject_buildMove read USI '+' as a promoting move, look for an unpromoted piece on the from-square, and fail with empty_from when the piece on the board was already the promoted form. Symptom: the engine sent '-8786RY' to slide a dragon back, KEB rejected it as 'empty_from'. Both fixes lean on a small SFEN-snapshot cache (g_csaPrevSfen) the engine maintains across observed moves: - DropPieceTypeFromHandDelta walks the SFEN hand string and finds the piece type whose count decreased by one. Lives in Csa_Convert so the conversion side stays pure / testable. - Csa_Engine cross-checks the bit-15 drop flag with the hand delta every move; if either says 'drop,' we treat it as a drop and patch the Move bits to the canonical drop form (drop=1, from=0). - csa_handle_move_from_engine reads PscPieceTypeAtSquare(prev, from) when MoveBitsFromCsaText says promote=true; if the from-square already holds the promoted form, we clear the promote bit before building the USI string for inject_apply. Csa_Convert is untouched so the existing 85 pinned tests keep passing. A nil-return failure path now logs prev/post SFEN, drop/promote flags, and piece type so the next mismatch is debuggable from a single log line. Match-end clears g_csaPrevSfen so the next match's first move doesn't inherit stale hand counts. All three build flavors link clean; 85 Csa_Convert tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Convert.h | 12 ++++ Sources/KiouEngineBridge/Csa_Convert.m | 87 ++++++++++++++++++++++++++ Sources/KiouEngineBridge/Csa_Engine.m | 87 +++++++++++++++++++++++--- 3 files changed, 178 insertions(+), 8 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Convert.h b/Sources/KiouEngineBridge/Csa_Convert.h index 3c45dfb..dbf3646 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.h +++ b/Sources/KiouEngineBridge/Csa_Convert.h @@ -177,6 +177,18 @@ NSString *CsaPositionFromSfen(NSString *sfen); // 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); + // 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 diff --git a/Sources/KiouEngineBridge/Csa_Convert.m b/Sources/KiouEngineBridge/Csa_Convert.m index 7eaba4f..463eb15 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.m +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -445,6 +445,93 @@ int32_t PscPieceTypeAtSquare(NSString *sfen, uint32_t square) { 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; +} + NSString *CsaTextAppendingTime(NSString *csaMove, int32_t seconds) { if (seconds < 0 || csaMove.length == 0) return csaMove; if ([csaMove rangeOfString:@",T"].location != NSNotFound) { diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 250687c..6a7c068 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -68,6 +68,11 @@ 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* @@ -399,6 +404,30 @@ static void csa_handle_move_from_engine(NSString *line) { uint32_t promote = (move >> 14) & 1; uint32_t drop = (move >> 15) & 1; + // 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) { + file_log([NSString stringWithFormat: + @"[CSA-ENG] promote bit cleared (from=%u " + @"already holds promoted piece %d): %@", + from, fromPiece, line]); + promote = 0; + } + } + } + NSString *toCsa = CsaSquareFromMoveBits(to); NSString *toUsi = csa_squareToUsi(toCsa); if (!toUsi) { @@ -449,6 +478,9 @@ void CsaEngineOnMatchStart(int32_t local_player) { // 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 @@ -517,18 +549,48 @@ void CsaEngineOnMoveObserved(uint32_t move, return; } - // For normal promoting moves, CsaTextFromMoveBits wants the *unpromoted* - // piece type and uses the promote bit to compute the destination piece - // name (FU → TO etc). The post-move SFEN at the `to` square already - // holds the promoted piece type; downshift before handing over. uint32_t promote = (move >> 14) & 1; - uint32_t drop = (move >> 15) & 1; - if (!drop && promote && pscPieceType >= 9 && pscPieceType <= 14) { + 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) { + file_log([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 @@ -583,11 +645,20 @@ void CsaEngineOnMoveObserved(uint32_t move, if (!csa) { file_log([NSString stringWithFormat: @"[CSA-ENG] CsaTextFromMoveBits returned nil for " - @"move=0x%x piece=%d side=%d", - (unsigned)move, (int)pscPieceType, (int)playerSide]); + @"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]; } // --------------------------------------------------------------------------- From 80433801e8545149e6bcebc8ccb6bb00fda5db30 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 00:57:42 +0000 Subject: [PATCH 27/51] feat(csa): pre-inject legality validators (drop on occupied, nifu, etc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KIOU's GameController silently bounces illegal moves back without clearing inject_apply's intermediate state, leaving the on-engine view desynced (the 'piece bounces back' symptom reported on-device while testing pawn drops on occupied squares and dropping pawns on the back rank). Add lightweight legality checks that run against the cached pre-move SFEN before the move reaches inject_apply, so the worst categories of input are filtered out and the engine learns its move was rejected: - ValidateCsaDrop catches: * dropping onto an occupied square * dropping a promoted piece type (caller sanity) * pawn / lance lands on the deepest rank (dead end) * knight lands on the two deepest ranks (dead end) * nifu (two unpromoted pawns of the moving side on the same file) - ValidateCsaMove catches: * from-square empty or out of range * the piece on from doesn't match the named CSA piece type (allowing the named-promoted-form + promote-bit case for the ordinary 'promoting move' line) * capturing one's own piece When a check fires we log the short reason, send '#ILLEGAL_MOVE' so the connected CSA engine knows its submission was discarded, and skip inject_apply entirely. The session stays in PLAYING so the engine can submit a different move; no KIOU state change happens. These checks deliberately stop short of replicating the full shogi rule engine — KIOU is the authority on position-specific rules (uchifuzume, double check, pinning, etc). They just catch the cheap input categories KEB has observed leaving KIOU's state inconsistent. All three build flavors link clean; 85 Csa_Convert tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Convert.h | 34 ++++++ Sources/KiouEngineBridge/Csa_Convert.m | 149 +++++++++++++++++++++++++ Sources/KiouEngineBridge/Csa_Engine.m | 25 +++++ 3 files changed, 208 insertions(+) diff --git a/Sources/KiouEngineBridge/Csa_Convert.h b/Sources/KiouEngineBridge/Csa_Convert.h index dbf3646..db46486 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.h +++ b/Sources/KiouEngineBridge/Csa_Convert.h @@ -189,6 +189,40 @@ 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 diff --git a/Sources/KiouEngineBridge/Csa_Convert.m b/Sources/KiouEngineBridge/Csa_Convert.m index 463eb15..9b81fc4 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.m +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -532,6 +532,155 @@ int32_t DropPieceTypeFromHandDelta(NSString *sfenBefore, return found; } +// --------------------------------------------------------------------------- +// Move legality checks. The shared file_log() 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. +// --------------------------------------------------------------------------- + +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) { + (void)promote; // promotion legality (entering enemy camp) deferred + 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"; + } + + // 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) { diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 6a7c068..b7e1fcb 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -436,6 +436,31 @@ static void csa_handle_move_from_engine(NSString *line) { return; } + // Cheap legality pre-checks against the cached pre-move SFEN. The + // primary goal is to keep blatantly illegal moves (drop on occupied, + // move from empty, nifu, dead-end drops, etc) from reaching + // inject_apply, because KIOU's GameController silently bounces those + // back and leaves both the board and the engine's view inconsistent. + // Validators return NULL on success or a short reason string on + // rejection. + if (g_csaPrevSfen.length > 0) { + const char *reason = drop + ? ValidateCsaDrop(g_csaPrevSfen, to, pieceType, playerSide) + : ValidateCsaMove(g_csaPrevSfen, from, to, pieceType, + promote ? YES : NO, playerSide); + if (reason) { + file_log([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); From c050e96b807b105abf5d359f29388d9295c5a5ca Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:00:40 +0000 Subject: [PATCH 28/51] fix(csa): promotion legality + prev-SFEN refresh on emit nil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related issues that surfaced once the move-legality validators were live: (1) Promote-outside-enemy-camp acceptance. An engine sending a move with the promote bit set but neither from-rank nor to-rank in the enemy camp (e.g. mid-board +5958KI+ or a silver moving entirely within own territory) was passing ValidateCsaMove, hitting inject_apply, and getting bounced by KIOU's GameController. Adds three new checks: * promote_already_promoted (from-square already holds the promoted form — covers the case where the engine clones a promoted-mnemonic line but sets the bit redundantly) * promote_unpromotable (King and Gold can never promote) * promote_outside_enemy_camp (Black needs from or to rank ≤ 3; White needs from or to rank ≥ 7) On rejection KEB emits #ILLEGAL_MOVE and keeps PLAYING so the engine can retry. (2) Stale g_csaPrevSfen after a CsaTextFromMoveBits failure. CsaEngineOnMoveObserved bailed early when PscPieceTypeAtSquare couldn't resolve a piece type at the destination square (drop bit lies, hand-delta ambiguous, etc.) without advancing the prev-SFEN cache. The next inject_apply then validated against the board from BEFORE the unobservable move, letting otherwise illegal follow-ups (notably a from-square the cache thought was empty) sneak past from_piece_mismatch. Always copy sfenAfter into g_csaPrevSfen before returning, even on the early-exit path. All three build flavors link clean; 85 Csa_Convert tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Convert.m | 25 ++++++++++++++++++++++++- Sources/KiouEngineBridge/Csa_Engine.m | 5 +++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Sources/KiouEngineBridge/Csa_Convert.m b/Sources/KiouEngineBridge/Csa_Convert.m index 9b81fc4..72c5abe 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.m +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -627,7 +627,6 @@ int32_t DropPieceTypeFromHandDelta(NSString *sfenBefore, int32_t pscPieceType, BOOL promote, int32_t playerSide) { - (void)promote; // promotion legality (entering enemy camp) deferred if (sfenBefore.length == 0) return NULL; if (fromSquare > 80) return "from_oob"; if (toSquare > 80) return "to_oob"; @@ -650,6 +649,30 @@ int32_t DropPieceTypeFromHandDelta(NSString *sfenBefore, 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"; + } + // Can't capture your own piece. NSArray *parts = [sfenBefore componentsSeparatedByString:@" "]; if (parts.count >= 1) { diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index b7e1fcb..37a7564 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -571,6 +571,11 @@ void CsaEngineOnMoveObserved(uint32_t move, @"[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; } From a06304241297501b0d4354542c5cd5de512332c6 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:08:08 +0000 Subject: [PATCH 29/51] feat(csa): #ILLEGAL_MOVE on inject_apply rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KEB's own ValidateCsaDrop / ValidateCsaMove catches the cheap categories of illegal input before the move reaches inject_apply, but they deliberately don't replicate KIOU's full rule engine — position-specific rules (piece movement geometry, pinned pieces, occupied path on sliding moves, uchifuzume, leaving own king in check, etc.) are still owned by KIOU's GameController. inject_apply already surfaces 'parse_fail' / 'empty_from' / 'dropfmt' type rejections via ok=false, but Csa_Engine.m was silently dropping them on the floor: the move never reached the engine's view of the board, but neither did any signal that the move was rejected. The engine kept submitting the same illegal move (or worse, assumed it stuck and built a follow-up). Send #ILLEGAL_MOVE whenever inject_apply returns ok=false, with the inject-side err string preserved in the file log for postmortem. The CSA spec lets the server emit #ILLEGAL_MOVE on the engine's submission and keep the session alive, which is exactly what we do — the state machine stays in PLAYING so the engine can try another move. Together with the pre-inject validators this gives two layers of defense: layer 1 — KEB's cheap rules (drop on occupied, nifu, dead-end drop, from/to piece mismatch, promote outside enemy camp, ...) reject before inject_apply ever runs. layer 2 — KIOU's GameController rejects anything else and KEB now forwards that rejection as #ILLEGAL_MOVE. A future revision should also propagate Adapter.TryMakeMove's tryOk into inject_apply's ok (today the OPM path forces ok=true for benign-failure reasons documented at Inject_Move.m:790), but that involves disentangling the 'move already applied via HandleMoveResult' case and is held back from this commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 37a7564..6af242e 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -491,6 +491,16 @@ static void csa_handle_move_from_engine(NSString *line) { file_log([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"); + } } // --------------------------------------------------------------------------- From 99aef8e89d72dbf01e4eff0b42b141c31272c9bc Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:19:04 +0000 Subject: [PATCH 30/51] fix(csa): hop mid-match auto-renegotiate to the Unity main queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CsaEngineOnTcpClientConnected runs on the CSA accept queue (a serial GCD queue inside Server_CSA.m). The auto-renegotiate path it added called csa_send_game_summary, which eventually walks SfenFromGameController — and that helper dereferences il2cpp objects that are only valid on Unity's main thread. Touching them off-main crashes the il2cpp runtime instantly. The crash reproduces with: connect a CSA client mid-match, kill it, kill KIOU; bring KIOU back, resume the saved match; reconnect the client. The reconnect lands CsaEngineOnTcpClientConnected on the accept queue with g_csaLocalPlayer still set from the resumed match, which trips the auto-renegotiate branch and dies inside the il2cpp SFEN read. Move both auto-renegotiate sites (the TCP-accept path and the explicit-LOGIN path in csa_handle_login, which runs on the recv queue) onto dispatch_async(main_queue). Re-check g_csaState inside the block so a LOGOUT or a new match landing between dispatch and execution doesn't ship a stale Game_Summary. All three build flavors link clean; 85 Csa_Convert tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 79 +++++++++++++++++++++------ 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 6af242e..85de3e0 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -199,8 +199,22 @@ static void csa_handle_login(NSString *line) { int32_t lp = atomic_load(&g_csaLocalPlayer); if (lp == 0 || lp == 1) { - // We already have a KIOU match in progress — push Game_Summary now. - csa_send_game_summary(lp); + // 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. + int32_t lpCap = lp; + dispatch_async(dispatch_get_main_queue(), ^{ + int now = atomic_load(&g_csaState); + if (now == CSA_STATE_LOGIN) { + csa_send_game_summary(lpCap); + } else { + file_log([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. @@ -436,17 +450,27 @@ static void csa_handle_move_from_engine(NSString *line) { return; } - // Cheap legality pre-checks against the cached pre-move SFEN. The - // primary goal is to keep blatantly illegal moves (drop on occupied, - // move from empty, nifu, dead-end drops, etc) from reaching - // inject_apply, because KIOU's GameController silently bounces those - // back and leaves both the board and the engine's view inconsistent. - // Validators return NULL on success or a short reason string on - // rejection. - if (g_csaPrevSfen.length > 0) { + // 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, because KIOU's + // GameController silently bounces those back and leaves both the + // board and the engine's view inconsistent. + // + // We prefer the live SFEN over g_csaPrevSfen here: the cached value is + // a snapshot captured from NotifyPieceMoved, which fires even on a + // move KIOU later rolls back. The live read picks up that rollback, + // so a follow-up illegal move that the cache would have approved + // (because the cached board still believes the rolled-back piece is + // where the original move landed) gets rejected correctly. Falls + // back to the cached SFEN when the live read isn't available yet + // (the very first move of the session). + NSString *validatorSfen = SfenFromGameController(g_gameCtrlCache); + if (validatorSfen.length == 0) validatorSfen = g_csaPrevSfen; + + if (validatorSfen.length > 0) { const char *reason = drop - ? ValidateCsaDrop(g_csaPrevSfen, to, pieceType, playerSide) - : ValidateCsaMove(g_csaPrevSfen, from, to, pieceType, + ? ValidateCsaDrop(validatorSfen, to, pieceType, playerSide) + : ValidateCsaMove(validatorSfen, from, to, pieceType, promote ? YES : NO, playerSide); if (reason) { file_log([NSString stringWithFormat: @@ -711,13 +735,36 @@ void CsaEngineOnTcpClientConnected(void) { // 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. Standard CSA engines that DO send LOGIN - // still get a normal LOGIN: OK reply; csa_handle_login skips - // re-sending Game_Summary when we're already past LOGIN state. + // 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. int32_t lp = atomic_load(&g_csaLocalPlayer); if (lp == 0 || lp == 1) { file_log(@"[CSA-ENG] mid-match reconnect — auto-renegotiating"); - csa_send_game_summary(lp); + int32_t lpCap = lp; + dispatch_async(dispatch_get_main_queue(), ^{ + int s = atomic_load(&g_csaState); + // Guard against a LOGOUT / new match landing in the window + // between this dispatch_async being queued and running. + if (s == CSA_STATE_LOGIN) { + csa_send_game_summary(lpCap); + } else { + file_log([NSString stringWithFormat: + @"[CSA-ENG] auto-renegotiate skipped: state " + @"changed to %s before main-queue dispatch", + csa_state_name(s)]); + } + }); } } From 63ca7baca542e7866cad8660eca0e1518c13dcb2 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:21:43 +0000 Subject: [PATCH 31/51] fix(csa): swap validator live-SFEN read to inject_currentSfen (thread-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used SfenFromGameController(g_gameCtrlCache) directly to give the validators an up-to-date board view that accounts for KIOU rolling back illegal moves. That helper dereferences il2cpp objects inline, but csa_handle_move_from_engine runs on the CSA recv queue, so the read happens off the Unity main thread and crashes the il2cpp runtime — exactly the same SfenFromGameController-off-main pathology the auto-renegotiate fix just resolved on the accept queue. Switch to inject_currentSfen(), which is the canonical safe path: Inject_Move.m wraps the GameController.GetUSIText call in a dispatch_sync onto the main queue, so callers on any GCD queue get a valid SFEN string back without violating il2cpp threading. Reproducer was: connect, send any move (e.g. +1837HI), KEB dies during the validator's pre-inject SFEN read. With this commit the validator still gets the post-rollback live board the previous fix intended, just through the queue-safe entry point. All three build flavors link clean; 85 Csa_Convert tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 85de3e0..3bbbd1d 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -458,13 +458,14 @@ static void csa_handle_move_from_engine(NSString *line) { // // We prefer the live SFEN over g_csaPrevSfen here: the cached value is // a snapshot captured from NotifyPieceMoved, which fires even on a - // move KIOU later rolls back. The live read picks up that rollback, - // so a follow-up illegal move that the cache would have approved - // (because the cached board still believes the rolled-back piece is - // where the original move landed) gets rejected correctly. Falls - // back to the cached SFEN when the live read isn't available yet - // (the very first move of the session). - NSString *validatorSfen = SfenFromGameController(g_gameCtrlCache); + // move KIOU later rolls back. inject_currentSfen does an internal + // dispatch_sync onto the Unity main thread before reading + // GameController.GetUSIText, which is mandatory — calling + // SfenFromGameController from this CSA recv queue dereferences + // il2cpp objects off-main and crashes the runtime. Falls back to the + // cached SFEN when the live read isn't available yet (the very first + // move of the session). + NSString *validatorSfen = inject_currentSfen(); if (validatorSfen.length == 0) validatorSfen = g_csaPrevSfen; if (validatorSfen.length > 0) { From 2ebd30366f44a68449114330fa446cac770a5129 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:24:40 +0000 Subject: [PATCH 32/51] fix(csa): run engine-submitted moves on the Unity main queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit csa_handle_move_from_engine runs on the CSA recv queue (Server_CSA.m's GCD serial recv queue). The body called inject_currentSfen (which delegates to inject_sfenFromCachedGameCtrl → Position.ToSFEN, a raw il2cpp method invocation) and then inject_apply, both of which dereference il2cpp objects that are only valid on Unity's main thread. inject_apply itself wraps every il2cpp call in dispatch_sync, so the later half of the path was safe; but the validator's live-SFEN probe (inject_currentSfen → Position.ToSFEN) was added without the same guard, so any move submitted while the validator was active crashed the runtime — same off-main pathology as the auto-renegotiate fix landed minutes ago. Restructure: parse the CSA line on the recv queue (just byte shuffling), then dispatch_async the entire validator + USI build + inject pipeline onto the main queue. A new csa_apply_engine_move holds the body, csa_handle_move_from_engine is now just a thin parser + queue hop. We re-check g_csaState inside the main-queue block so a LOGOUT / match_end that lands between the two queues doesn't ship a move into a dead session. All three build flavors link clean; 85 Csa_Convert tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 41 +++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 3bbbd1d..296da42 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -379,6 +379,11 @@ static void csa_handle_special(NSString *line) { } } +// 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) { @@ -397,6 +402,34 @@ static void csa_handle_move_from_engine(NSString *line) { @"[CSA-ENG] malformed move: %@", line]); return; } + (void)timeSpent; // engine-reported think time is logged but unused + + // Hop to the Unity main queue: both the legality probe + // (inject_currentSfen / SfenFromGameController) and inject_apply touch + // 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(), ^{ + csa_apply_engine_move(lineCap, move, pieceType, playerSide); + }); +} + +static void csa_apply_engine_move(NSString *line, uint32_t move, + int32_t pieceType, int32_t playerSide) { + // 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); + if (s != CSA_STATE_PLAYING) { + file_log([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; int32_t lp = atomic_load(&g_csaLocalPlayer); // The connected CSA engine stands in for KIOU's local human player — @@ -411,12 +444,8 @@ static void csa_handle_move_from_engine(NSString *line) { enginePlayer, playerSide]); } - // Build USI for the inject pipeline. The "from" / "to" bits already - // match the PSC Square encoding, so they translate directly. - uint32_t to = move & 0x7F; - uint32_t from = (move >> 7) & 0x7F; - uint32_t promote = (move >> 14) & 1; - uint32_t drop = (move >> 15) & 1; + // (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 From 04fb301182092d068bd49b10326b1135ecfb0902 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:28:30 +0000 Subject: [PATCH 33/51] debug(csa): log each step of csa_apply_engine_move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A move (e.g. +1828HI) sent on a live session still crashes the runtime even after both the auto-renegotiate fix and the inject_currentSfen → main-queue refactor. The log line right before the crash is just '[CSA<] +1828HI'; no follow-up #ILLEGAL_MOVE, no inject_apply trace, no engine echo. The handler is dying somewhere between MoveBitsFromCsaText and the next file_log, but we can't see where. Sprinkle [CSA-ENG-DBG] markers through the whole path so the next device log will pin down which line dies: - just before / inside / after the dispatch_async hop - on entry to csa_apply_engine_move - after state recheck - after move-bits decode - after the promote-bit-clear branch - after building toUsi - around the validator call - just before inject_apply Also drop the inject_currentSfen / SfenFromGameController live read in the validator. That path was the earlier suspect, but moving the whole handler onto the main queue should have made it safe, and KIOU performs its own validation downstream — using the cached g_csaPrevSfen is enough to catch the cheap rejection categories KEB needs to flag. All three build flavors link clean; 85 Csa_Convert tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 62 +++++++++++++++++++-------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 296da42..8a03ad9 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -404,21 +404,36 @@ static void csa_handle_move_from_engine(NSString *line) { } (void)timeSpent; // engine-reported think time is logged but unused - // Hop to the Unity main queue: both the legality probe - // (inject_currentSfen / SfenFromGameController) and inject_apply touch - // il2cpp methods that are only safe from the main thread. The CSA - // recv queue this handler runs on isn't. + file_log([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(), ^{ + file_log([NSString stringWithFormat: + @"[CSA-ENG-DBG] main-queue dispatch arrived for: %@", + lineCap]); csa_apply_engine_move(lineCap, move, pieceType, playerSide); + file_log(@"[CSA-ENG-DBG] csa_apply_engine_move returned cleanly"); }); + file_log(@"[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) { + file_log([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); + file_log([NSString stringWithFormat: + @"[CSA-ENG-DBG] state on main queue=%s", csa_state_name(s)]); if (s != CSA_STATE_PLAYING) { file_log([NSString stringWithFormat: @"[CSA-ENG] move state changed to %s before main " @@ -430,6 +445,9 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, uint32_t promote = (move >> 14) & 1; uint32_t from = (move >> 7) & 0x7F; uint32_t to = move & 0x7F; + file_log([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 — @@ -470,6 +488,7 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, } } } + file_log(@"[CSA-ENG-DBG] promote check done — building toUsi"); NSString *toCsa = CsaSquareFromMoveBits(to); NSString *toUsi = csa_squareToUsi(toCsa); @@ -478,30 +497,34 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, @"[CSA-ENG] bad to-square in: %@", line]); return; } + file_log([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, because KIOU's - // GameController silently bounces those back and leaves both the - // board and the engine's view inconsistent. + // drops, etc) from reaching inject_apply. // - // We prefer the live SFEN over g_csaPrevSfen here: the cached value is - // a snapshot captured from NotifyPieceMoved, which fires even on a - // move KIOU later rolls back. inject_currentSfen does an internal - // dispatch_sync onto the Unity main thread before reading - // GameController.GetUSIText, which is mandatory — calling - // SfenFromGameController from this CSA recv queue dereferences - // il2cpp objects off-main and crashes the runtime. Falls back to the - // cached SFEN when the live read isn't available yet (the very first - // move of the session). - NSString *validatorSfen = inject_currentSfen(); - if (validatorSfen.length == 0) validatorSfen = g_csaPrevSfen; + // 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; + + file_log([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); + file_log([NSString stringWithFormat: + @"[CSA-ENG-DBG] validator result reason=%s", + reason ?: "OK"]); if (reason) { file_log([NSString stringWithFormat: @"[CSA-ENG] rejecting illegal move (reason=%s): %@", @@ -538,6 +561,9 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, : [NSString stringWithFormat:@"%@%@", fromUsi, toUsi]; } + file_log([NSString stringWithFormat: + @"[CSA-ENG-DBG] built usi=%@ — calling inject_apply", usi]); + NSString *outSfen = nil; NSString *outErr = nil; uint32_t outRaw = 0; From e0a0d9ae9729e82ca5b9f3f926a0508798e010ea Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:34:33 +0000 Subject: [PATCH 34/51] fix(inject): avoid deadlock when inject_apply called from main queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When csa_apply_engine_move dispatches to the main queue and then calls inject_apply, inject_apply's internal dispatch_sync(dispatch_get_main_ queue(), ...) calls block forever because you can't dispatch_sync onto your own queue. Add KIOU_RUN_ON_MAIN_SYNC(block) helper macro that detects [NSThread isMainThread] and inlines the block directly instead of wrapping it in dispatch_sync. All 4 dispatch_sync sites in inject_apply's hot path (lines 1044, 1062, 1132, 1167) now use this macro. Verified on device: +1828HI (飛車 1八→2八) previously crashed; now logs 'inject_apply ok=1' and 'csa_apply_engine_move returned cleanly'. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Inject_Move.m | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Sources/KiouEngineBridge/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index 32d1196..b6cac7b 100644 --- a/Sources/KiouEngineBridge/Inject_Move.m +++ b/Sources/KiouEngineBridge/Inject_Move.m @@ -1023,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; @@ -1032,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; @@ -1050,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(); }); @@ -1120,7 +1129,7 @@ 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) { @@ -1155,7 +1164,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); }); From af37564d2952be75e58c244565c7d7692fe8cffa Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:48:12 +0000 Subject: [PATCH 35/51] feat(validate): add per-piece reachability check to ValidateCsaMove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add moveDirsForPiece(), squareOccupied(), and pieceCanReach() helpers to Csa_Convert.m. These implement a lightweight movement table for all 14 PSC piece types, matching YaneuraOu's per-piece direction logic: - Non-sliding pieces (FU, KE, GI, KI/TO/NY/NK/NG, OU): fixed (dFile, dRank) offsets relative to playerSide (Black fwd=rank-1, White fwd=+1) - Sliding pieces (KY, KA, HI): raycast along direction until board edge or blocking piece - Promoted sliders (UM=bishop+adjacent ortho, RY=rook+adjacent diag) ValidateCsaMove now calls pieceCanReach() before the to_own_piece check, returning "unreachable" when the piece cannot legally travel from fromSquare to toSquare given board occupancy. This fixes the +2716HI (飛車 2七→1六 斜め移動) false-OK observed on device. 85 unit tests pass. All 3 build flavors clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Convert.m | 194 +++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/Sources/KiouEngineBridge/Csa_Convert.m b/Sources/KiouEngineBridge/Csa_Convert.m index 72c5abe..4c0a62b 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.m +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -546,6 +546,182 @@ int32_t DropPieceTypeFromHandDelta(NSString *sfenBefore, // 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, @@ -673,6 +849,24 @@ int32_t DropPieceTypeFromHandDelta(NSString *sfenBefore, if (!inEnemyCamp) return "promote_outside_enemy_camp"; } + // 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) { From c781325220cccc1f195de734e4b1a68975d48fa9 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:52:48 +0000 Subject: [PATCH 36/51] feat(validate): add must-promote check for FU/KY/KE FU and KY cannot be left unpromoted on the back rank (rank 1 for Black, rank 9 for White). KE cannot be left unpromoted on ranks 1-2 (Black) or ranks 8-9 (White). ValidateCsaMove now returns "must_promote" for these cases, matching the bitboard engine's approach of excluding no-legal- follow-up moves at generation time. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Convert.m | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Sources/KiouEngineBridge/Csa_Convert.m b/Sources/KiouEngineBridge/Csa_Convert.m index 4c0a62b..8e3b3c5 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.m +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -849,6 +849,27 @@ static BOOL pieceCanReach(int32_t pscPieceType, int32_t playerSide, 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 From bbfe6103cee06afe774849cd9dcefe61138e4a6e Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 01:58:25 +0000 Subject: [PATCH 37/51] fix(validate): seed g_csaPrevSfen from Game_Summary starting position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-move validator was always skipping (sfen len=0) because g_csaPrevSfen starts nil and NotifyPieceMoved only fires after a move lands — leaving the board snapshot blind for the engine's very first move. CsaBuildGameSummary now accepts outStartSfen and writes the SFEN obtained from SfenFromGameController into it. csa_send_game_summary caches it as g_csaPrevSfen immediately after building the Game_Summary block, before sending START. The validator now has a real board snapshot for move 1. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 15 +++++++++++++-- Sources/KiouEngineBridge/Csa_GameInfo.m | 5 ++++- Sources/KiouEngineBridge/Internal.h | 8 +++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 8a03ad9..0f12da8 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -149,12 +149,14 @@ csa_state_t CsaEngineCurrentState(void) { // the Csa_Stubs.m no-op variants resolve these symbols. // --------------------------------------------------------------------------- extern NSString *CsaBuildGameSummary(int32_t local_player, - NSString **outGameId); + 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 *summary = CsaBuildGameSummary(local_player, &gameId); + NSString *startSfen = nil; + NSString *summary = CsaBuildGameSummary(local_player, &gameId, &startSfen); if (summary.length == 0) { file_log(@"[CSA-ENG] CsaBuildGameSummary returned empty — " @"deferring Game_Summary"); @@ -162,6 +164,15 @@ static void csa_send_game_summary(int32_t local_player) { } 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; + file_log([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 diff --git a/Sources/KiouEngineBridge/Csa_GameInfo.m b/Sources/KiouEngineBridge/Csa_GameInfo.m index 4453ae0..cf5492a 100644 --- a/Sources/KiouEngineBridge/Csa_GameInfo.m +++ b/Sources/KiouEngineBridge/Csa_GameInfo.m @@ -187,7 +187,9 @@ static csa_time_control_t csa_readTimeControl(void *tcc) { // 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 *CsaBuildGameSummary(int32_t local_player, + NSString **outGameId, + NSString **outStartSfen) { void *cfg = g_csaMatchConfig; if (!cfg) { // No MatchConfig — without it we cannot construct a meaningful @@ -249,6 +251,7 @@ static csa_time_control_t csa_readTimeControl(void *tcc) { [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]; diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index 3983b17..8e2d7f4 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -412,7 +412,13 @@ void CsaOnPlayerInfoSet(int32_t side, void *playerInfo); // `*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. -NSString *CsaBuildGameSummary(int32_t local_player, NSString **outGameId); +// 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 From 056363059eef1737094812ed4dfe6aaebd08aeb6 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 04:11:59 +0000 Subject: [PATCH 38/51] fix(csa): eliminate LOGIN/PLAYING flip-flop race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: OnMatchStart's else branch called csa_send_game_summary in PLAYING state, racing with csa_handle_login's dispatch_async(main) and causing repeated PLAYING→LOGIN→PLAYING state flips. Fix: - OnMatchStart: only send Game_Summary in LOGIN or GAME_OVER state. In PLAYING the engine is already running; the LOGIN handler owns renegotiation when the engine reconnects. - csa_handle_login: send Game_Summary in both LOGIN and PLAYING states (the connect-time race may have already advanced state to PLAYING before this dispatch lands). - Removed connect-time auto-renegotiate dispatch from CsaEngineOnTcpClientConnected — LOGIN is the sole trigger, which eliminates the double-dispatch race entirely. Also fix csa_usi_bridge.py: - Guard move_from_csa None/0 return to avoid 'moves None' USI error - Handle mid-game BEGIN Game_Summary by restarting game loop Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 73 +++++++++++++-------------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 0f12da8..a6e4586 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -198,26 +198,32 @@ static void csa_handle_login(NSString *line) { NSString *name = (parts.count >= 2) ? parts[1] : @"engine"; CsaEngineSendLine([NSString stringWithFormat:@"LOGIN:%@ OK", name]); - int s = atomic_load(&g_csaState); - if (s == CSA_STATE_PLAYING) { - // CsaEngineOnTcpClientConnected already shipped Game_Summary for - // the in-progress match. A late LOGIN from a verbose client gets - // the OK reply above but doesn't re-trigger renegotiation. - file_log(@"[CSA-ENG] LOGIN in PLAYING — Game_Summary already sent, " - @"skipping renegotiation"); - return; - } - 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) { + 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 { file_log([NSString stringWithFormat: @@ -620,18 +626,18 @@ void CsaEngineOnMatchStart(int32_t local_player) { @"[CSA-ENG] match_start local_player=%d state=%s", (int)local_player, csa_state_name(s)]); - // If a client is already logged in, push Game_Summary right away. + // 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) { csa_send_game_summary(local_player); - } else if (s == CSA_STATE_BOOT) { - // No client connected yet — cache state; the LOGIN handler will - // pick this up when the engine eventually connects. - } else { - file_log([NSString stringWithFormat: - @"[CSA-ENG] match_start in unexpected state %s, " - @"forcing Game_Summary", - csa_state_name(s)]); - csa_send_game_summary(local_player); } } @@ -815,24 +821,13 @@ void CsaEngineOnTcpClientConnected(void) { // 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. - int32_t lp = atomic_load(&g_csaLocalPlayer); - if (lp == 0 || lp == 1) { - file_log(@"[CSA-ENG] mid-match reconnect — auto-renegotiating"); - int32_t lpCap = lp; - dispatch_async(dispatch_get_main_queue(), ^{ - int s = atomic_load(&g_csaState); - // Guard against a LOGOUT / new match landing in the window - // between this dispatch_async being queued and running. - if (s == CSA_STATE_LOGIN) { - csa_send_game_summary(lpCap); - } else { - file_log([NSString stringWithFormat: - @"[CSA-ENG] auto-renegotiate skipped: state " - @"changed to %s before main-queue dispatch", - csa_state_name(s)]); - } - }); - } + // 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) { From bdd8326846fb4ff718cf81437978216077acfe3c Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 04:14:28 +0000 Subject: [PATCH 39/51] debug(csa): add caller name to state transition logs csa_set_state now logs (from ) so PLAYING->LOGIN flips can be traced to their source. Also simplify csa_usi_bridge prefetch handling via _buf prepend instead of passing prefetch lists. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index a6e4586..db5883a 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -108,13 +108,14 @@ static uint32_t csa_machTicksToSec(uint64_t ticks) { } } -static void csa_set_state(int newState) { +static void csa_set_state_impl(int newState, const char *caller) { int old = atomic_exchange(&g_csaState, newState); if (old != newState) { - file_log([NSString stringWithFormat:@"[CSA-ENG] state %s -> %s", - csa_state_name(old), csa_state_name(newState)]); + file_log([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 From e58baba3755647152e4d70124c733053a4cee0b3 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 04:40:17 +0000 Subject: [PATCH 40/51] feat(bridge): add KIOU_Sfen extension and use it for board init KEB now sends KIOU_Sfen: in Game_Summary so the bridge can initialize its local board to the correct position without parsing the multi-line CSA position format (which caused segfaults via cshogi.CSA.Parser.parse_str). Bridge reads KIOU_Sfen and calls board.set_position('sfen '). If SFEN matches startpos the bridge uses 'startpos' for the engine position command; otherwise passes 'sfen moves ...' so mid-game reconnects start from the correct board state. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_GameInfo.m | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/KiouEngineBridge/Csa_GameInfo.m b/Sources/KiouEngineBridge/Csa_GameInfo.m index cf5492a..f010e01 100644 --- a/Sources/KiouEngineBridge/Csa_GameInfo.m +++ b/Sources/KiouEngineBridge/Csa_GameInfo.m @@ -262,6 +262,13 @@ static csa_time_control_t csa_readTimeControl(void *tcc) { // 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)]; From 4e7180c6f5666c6e9a46ca2b11baa7d18114c6b6 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 04:42:47 +0000 Subject: [PATCH 41/51] chore: ignore large binary assets (YaneuraOu, nn.bin) Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5b17844..773376d 100644 --- a/.gitignore +++ b/.gitignore @@ -493,3 +493,5 @@ logs/* assets/dump.cs assets/dump.cs.index.json # *.ipa already ignored globally above. +assets/YaneuraOu +assets/nn.bin From 5e469961c3854c1370bd5776230584974bc2d427 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 04:44:16 +0000 Subject: [PATCH 42/51] =?UTF-8?q?perf:=20reduce=20inject=20animation=20del?= =?UTF-8?q?ay=20400ms=E2=86=92150ms,=20add=20bridge=20interval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INJECT_ANIMATION_DELAY_SEC was 0.40s — each engine move blocked the main thread for ~450ms via usleep(), causing visible UI stutters during rapid YaneuraOu engine play. Reduce to 0.15s. Animation typically completes in ~0.3s but the actual board commit is the authoritative state; a shorter delay trades visual smoothness for responsiveness. Bridge side: add 0.5s sleep after sending our move to give KIOU's main thread breathing room between back-to-back injections. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Inject_Move.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/KiouEngineBridge/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index b6cac7b..38a2776 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.15 // _stateStore field offsets per IMatchMode concrete implementor. Verified // against dump.cs:1419817 (AI), 1420396 (CPUStream), 1421565 (Online), From 857ff05f26a777e2a87ee069add3b6b8c0152444 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 04:44:51 +0000 Subject: [PATCH 43/51] perf: set INJECT_ANIMATION_DELAY_SEC to 0 Board state commit (TryMakeMove) and animation are independent. Animation plays regardless; no need to block the main thread waiting for it. Eliminates the ~150-450ms main-thread stall on each inject. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Inject_Move.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/KiouEngineBridge/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index 38a2776..418baf5 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.15 +#define INJECT_ANIMATION_DELAY_SEC 0.0 // _stateStore field offsets per IMatchMode concrete implementor. Verified // against dump.cs:1419817 (AI), 1420396 (CPUStream), 1421565 (Online), From d48134bda92dd6d77c7e9c9574a26ba3fa5e8cfd Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 04:46:22 +0000 Subject: [PATCH 44/51] fix(csa): derive To_Move from SFEN side-to-move, not hardcoded '+' On reconnect to a mid-game position the side to move may be white (-). Csa_GameInfo.m now reads the SFEN from GameController and sets To_Move:- when the SFEN's side token is 'w'. Bridge also now derives current_turn from KIOU_Sfen rather than To_Move, so it correctly waits for the opponent on reconnect when the position has white to move. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_GameInfo.m | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/KiouEngineBridge/Csa_GameInfo.m b/Sources/KiouEngineBridge/Csa_GameInfo.m index f010e01..13b0a8c 100644 --- a/Sources/KiouEngineBridge/Csa_GameInfo.m +++ b/Sources/KiouEngineBridge/Csa_GameInfo.m @@ -233,7 +233,18 @@ static csa_time_control_t csa_readTimeControl(void *tcc) { // up controlling the black side). NSString *yourTurn = (local_player == 1) ? @"-" : @"+"; [out appendFormat:@"Your_Turn:%@\n", yourTurn]; - [out appendString:@"To_Move:+\n"]; + // 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"]; From b1bf745185208f690661789f9507709c4efff726 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 04:56:45 +0000 Subject: [PATCH 45/51] fix(csa): defer Game_Summary on match_start to avoid scene-create watchdog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS killed KIOU with 0x8badf00d (scene-create watchdog transgression) during auto-rematch — main thread was blocked ~20s. Root cause: when OnMatchStart fires during scene transition, csa_send_game_summary calls SfenFromGameController which blocks on il2cpp locks until the new scene finishes initializing. Defer Game_Summary by 500ms via dispatch_after so the scene-create phase completes first. The re-check of g_csaState in the deferred block ensures we don't send Game_Summary if a LOGOUT raced us. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/KiouEngineBridge/Csa_Engine.m | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index db5883a..704b415 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -638,7 +638,18 @@ void CsaEngineOnMatchStart(int32_t local_player) { // 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) { - csa_send_game_summary(local_player); + // 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); + } + }); } } From 081bbbe8e0c1076f1a74813f99788dfabedab71e Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 05:42:03 +0000 Subject: [PATCH 46/51] =?UTF-8?q?docs:=20rewrite=20README=20=E2=80=94=20st?= =?UTF-8?q?rip=20internal=20sections,=20align=20with=20KiouForge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove: Observation+scoped injection, What you get, How it works, Layout, Versioning, Sibling tweaks, Plan documents, Scope of use - Install: align with KiouForge (JB / TrollStore / Patched IPA sections) - Compatibility: match KiouForge table structure, update iOS 15–26 - CSA wire table: split Direction into KEB→client / client→KEB - Add CSA v1.2 compatibility summary + KIOU_* extensions table - Badge: platform iOS 15–26, drop authorized-testing-only badge - Logs: update [WS]/[USI] tags to [CSA]/[CSA-ENG] - .gitignore: exclude scripts/*.py, scripts/*.sh, docs/plans/, docs/archive/ Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 371 +++++++++++++++++------------------------------------- 1 file changed, 117 insertions(+), 254 deletions(-) diff --git a/README.md b/README.md index 98a4dc6..923e37c 100644 --- a/README.md +++ b/README.md @@ -7,195 +7,155 @@

Turn KIOU into a CSA match server. The tweak speaks the standard CSA server protocol on TCP :4081, so any CSA - engine (Apery / 技巧 / YaneuraOu in CSA mode / shogi-server clients) - can connect over LAN and play against KIOU's live board — no extra + client can connect over LAN and play against KIOU's live board — no extra proxy, no host-side wrapper.

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

--- Kiou Engine Bridge is the in-app half of a two-piece system: the tweak runs inside KIOU and exposes a CSA TCP server on `0.0.0.0:4081`; -a CSA engine on your LAN connects in, plays through the standard +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 engine plays its move, the tweak parses it and feeds it back +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. No proxy server, no cloud, no third-party service — one ~140 KB dylib -on the phone, one TCP socket to a LAN box, and the CSA engine of your -choice on the other end. See `docs/csa_protocol.md` for the full wire -contract. - -> **Historical note.** Earlier revisions of KEB spoke a custom USI -> extension over WebSocket on port `9527`. The USI implementation lives -> in `Sources/KiouEngineBridge/{Server_WebSocket,Usi_Engine}.m` behind -> `#if 0` blocks and is excluded from every build flavour; the surviving -> wire description is in `docs/archive/usi_bridge_protocol.md`. - -### 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 - `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 CSA move - lines (`+7776FU` / `-3334FU` / `+0055FU`) submitted by the connected - engine make it into the move pipeline. `%TORYO` / `%KACHI` / - `%CHUDAN` are accepted but routed to dedicated end-of-match handlers - rather than the inject path. - -Uninstalling the dylib returns KIOU to a fully vanilla state. - -## What you get - -Three actors, two wires. KIOU is the game itself; Bridge is this tweak -loaded into KIOU's process; the CSA engine is the thinking part on the -other end of the LAN socket. - -- **KIOU <-> Bridge** — in-process: il2cpp hook callbacks for the - read side, function-pointer calls into `Move.Create` / - `OnPlayerMoveAsync` / `TryMakeMove` / `GameOrchestrator.RequestSurrender` - for the inject side. -- **Bridge <-> CSA engine** — plain TCP on `tcp://:4081`, - CSA server protocol v1.2. - -```mermaid -sequenceDiagram - autonumber - participant K as KIOU - participant B as Bridge (this tweak) - participant E as CSA engine - - Note over K,B: in-process hooks - Note over B,E: tcp://device:4081 (CSA v1.2) - - Note over B,E: Session handshake - E->>B: LOGIN test pass - B-->>E: LOGIN:test OK - - Note over K,E: Match start - K-->>B: IMatchMode.InitializeAsync (latch MatchConfig / local_player) - B-->>E: BEGIN Game_Summary ... END Game_Summary - E->>B: AGREE - B-->>E: START: - - loop Per move - K-->>B: NotifyPieceMoved (move bits, side) - B-->>E: +7776FU,T10 (or -3334FU,T8 for white) - E->>B: +2726FU - Note over B,K: inject_apply -> Move.Create -> OnPlayerMoveAsync -> TryMakeMove - B->>K: replay move through KIOU's move pipeline - end - - K-->>B: IMatchMode.OnMatchEndAsync (result) - B-->>E: #RESIGN - B-->>E: #WIN -``` - -## How it works - -```mermaid -flowchart TD - obs["Hook_LowLevelObserve
Hook_MatchModeObserve
Hook_OnlineObserve
Hook_GameStateStoreObserve"] - state(["g_gameCtrlCache · g_adapterCache
g_*ModeCache · g_localPlayer*
g_csaMatchConfig"]) - convert["Csa_Convert
(square / piece / SFEN -> CSA)"] +on the phone, one TCP socket to a LAN box. See `docs/csa_protocol.md` +for the full wire contract. - obs -- "latch state / surface SFEN+move" --> state - state --> convert - convert --> csaout - - tcp[("TCP :4081
line-oriented UTF-8")] - csaout["Csa_Engine state machine
BOOT / LOGIN / AGREE_WAIT /
PLAYING / GAME_OVER"] - csaout --> tcp - tcp -- "+7776FU / %TORYO" --> csain - csain["Csa_Engine inbound dispatch"] +KEB exposes the standard CSA v1.2 surface on the TCP link: - csain --> commit["inject_apply
(Move.Create / OnPlayerMoveAsync / TryMakeMove)"] - csain --> resign["Inject_Resign
(GameOrchestrator.RequestSurrender)"] - commit --> kiou(["KIOU board state advances"]) - resign --> kiou +**KEB → client** - state --> gameinfo["Csa_GameInfo
(Game_Summary + KIOU_* lines)"] - gameinfo --> tcp -``` +| 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 | -KEB exposes the standard CSA v1.2 surface on the TCP link: +**client → KEB** -| Direction | Lines | Notes | -|---|---|---| -| tweak → engine | `LOGIN: OK`, `LOGOUT:completed` | session control | -| tweak → engine | `BEGIN Game_Summary ... END Game_Summary` | full match preamble, includes `KIOU_*` extension lines | -| tweak → engine | `START:` | after `AGREE` | -| tweak → engine | `,T` | per-move notification, both colours | -| tweak → engine | `#RESIGN` / `#SENNICHITE` / `#JISHOGI` / `#CHUDAN` + `#WIN` / `#LOSE` / `#DRAW` | match end | -| engine → tweak | `LOGIN ` | accepted unconditionally | -| engine → tweak | `LOGOUT` | tears the session down | -| engine → tweak | `AGREE []` / `REJECT []` | advance / decline pre-match | -| engine → tweak | `` | engine's move; injected via `inject_apply` | -| engine → tweak | `%TORYO` | drives `GameOrchestrator.RequestSurrender` | -| engine → tweak | `%KACHI` / `%CHUDAN` | engine learns; KIOU is not signalled | +| 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 CSA engine at `tcp://:4081`. +and is loaded by ElleKit on next launch. Respring or relaunch KIOU, then +point your CSA client at `tcp://:4081`. + +### Jailed dylib (TrollStore) + +TrollStore is only supported on specific iOS versions. Check the +[supported versions table](https://ios.cfw.guide/installing-trollstore/) +before proceeding. + +```sh +make JAILED=1 +# -> packages/jailed/KiouEngineBridge.dylib +``` + +Stage inside the decrypted KIOU `.app/Frameworks/`, add an `LC_LOAD_DYLIB`, +and install via TrollStore. + +### Patched IPA (Sideload) -### Non-JB (Sideloadly / TrollStore / AltStore / Apple Developer Program) +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 +make BINPATCH=1 # -> packages/binpatch/KiouEngineBridge.dylib +``` + +Then build the patched IPA: +```sh shared/tools/build_patched_ipa.sh \ --recipe kiouenginebridge \ --framework UnityFramework \ @@ -204,96 +164,35 @@ shared/tools/build_patched_ipa.sh \ # -> Kiou-1.0.1-patched.ipa ``` -The patched IPA can be installed via TrollStore (direct), Sideloadly / -AltStore (sign with Apple ID), or the Apple Developer Program (sign -with a paid cert). Unlike runtime hook engines (Substrate, Dobby, -frida-gum), the static binary patch never writes to `__TEXT` at -runtime and therefore survives the iOS 18 Code Signing Monitor (CSM) — -the binpatch flavour covers iOS 15.0 – 18.x. +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. -All three build flavours (`make`, `make JAILED=1`, `make BINPATCH=1`) -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. +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 (JB rootless build)** | 15.0 – 16.5, arm64, rootless | -| **iOS (non-JB binpatch build)** | 15.0 – 18.x, arm64 | +| **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.** - -## Versioning - -Kiou Engine Bridge uses **its own [SemVer](https://semver.org/)** numbering, -independent of the KIOU app's version. The two never share a digit. - -| Field | What it means | -|---|---| -| `MAJOR` | Bumped on a breaking change to the wire protocol, distribution shape, or hook interface (e.g. dropping a build flavor). | -| `MINOR` | New observation hook, new injection path, new wire-protocol feature. | -| `PATCH` | Bug / crash / wiring fix with no user-visible behaviour change. | - -The **target KIOU app version** is pinned separately in the -[Compatibility](#compatibility) table above and in `recipes/kiouenginebridge.py`'s -RVA table. When KIOU itself updates, every hook site is re-derived against -the new `UnityFramework`, and that re-port lands as a PATCH or MINOR — -never a MAJOR just because the host bumped. - -Releases are tagged `vMAJOR.MINOR.PATCH` on the repo. The dylib also -embeds the short git commit hash (read at build time into -`KIOU_ENGINE_BRIDGE_COMMIT`) so the exact build behind a sideloaded copy -is always recoverable. +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 CSA engine reachable on the LAN. Apery, 技巧, and YaneuraOu in CSA - mode are reference setups. shogi-server's bundled `csa.rb` test - client is enough to sanity-check the wire. - -## Layout - -``` -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 # CSA move -> Move.Create / TryMakeMove path - Inject_Resign.m # %TORYO -> GameOrchestrator.RequestSurrender - Csa_Convert.{h,m} # square / piece / move / SFEN <-> CSA conversion - Csa_Engine.{h,m} # CSA protocol state machine (BOOT/LOGIN/.../GAME_OVER) - Csa_GameInfo.m # Game_Summary + KIOU_* extension builder - Server_CSA.m # 0.0.0.0:4081 listener + line-oriented recv loop - BinpatchDispatcher.m # binpatch-only: publishes hook dispatcher into __bss SLOT - Usi_Engine.m # DEPRECATED (#if 0) — pre-CSA USI driver - Server_WebSocket.m # DEPRECATED (#if 0) — pre-CSA WS sink - Meta_Emitter.m # legacy JSON sidecar (no longer wired on the wire) - Csa_Stubs.m # transitional no-op shims for USI symbols - -Sources/Common/ # IPA-Patch/Common submodule (logging, il2cpp, hookengine) -shared/ # IPA-Patch/Shared submodule (binpatch tooling) -recipes/kiouenginebridge.py # static-patch site table + cave payload builder -scripts/pre-commit # recipe<->dump cross-check hook (install with `make hooks`) -``` ### Developer hooks @@ -318,53 +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. - -Bridge and KifExporter use the same static-binpatch toolchain -(`shared/tools/patch_macho.py`) and can be applied to the same IPA — -their cave regions are partitioned -(`docs/plans/kiou_kif_exporter_binpatch.md` § 8). Stack both dylibs in -Sideloadly to ship a single patched IPA that does both jobs. - -## Plan documents - -- [`docs/plans/kiou_engine_bridge_binpatch.md`](docs/plans/kiou_engine_bridge_binpatch.md) — - this project's static-binpatch migration plan. -- [`docs/plans/kiou_kif_exporter_binpatch.md`](docs/plans/kiou_kif_exporter_binpatch.md) — - the sibling KifExporter recipe that pioneered the same approach. - ## 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. From 36f0d5e18f09ed1214a1dcedfdf314ecb87d9dd1 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 05:47:28 +0000 Subject: [PATCH 47/51] chore: exclude .claude/plans, settings.local.json, scripts, docs/plans, docs/archive Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.gitignore b/.gitignore index 773376d..8273606 100644 --- a/.gitignore +++ b/.gitignore @@ -495,3 +495,15 @@ 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 From 8a560587983543f30ca8eb83629d1057215e3bd6 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 05:52:22 +0000 Subject: [PATCH 48/51] chore: align Makefile and CI workflows with KiouForge Makefile: - Extract project variables to top (TWEAK_NAME, TARGET_PROCESS, DECRYPTED_IPA, etc.) - Use $(TWEAK_NAME) throughout instead of hardcoded strings - Rename KIOU_CLEAN_IPA -> DECRYPTED_IPA for consistency - Drop -Wl,-undefined,error from non-binpatch jailed path - Trim verbose comments from hook engine section deployment.yaml: - actions/checkout v4 -> v5, actions/cache v4 -> v5 - Add LDID_SHA256 pin - Add 'Verify control version matches tag' step - Add binpatch build + sanity check steps - Ship binpatch dylib as release asset alongside jailed integration.yaml: - actions/checkout v5, actions/cache v5, actions/upload-artifact v4 - Add LDID_SHA256 pin - Add binpatch flavor to build matrix - Merge jailed/binpatch sanity check into single parameterized step Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deployment.yaml | 128 +++++++++++----------- .github/workflows/integration.yaml | 66 +++-------- Makefile | 170 +++++++++++++---------------- 3 files changed, 159 insertions(+), 205 deletions(-) diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index a12f844..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,50 +7,50 @@ 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@v6 + uses: actions/checkout@v5 with: ref: ${{ steps.tag.outputs.value }} - fetch-depth: 0 - # Sources/Common is a submodule (IPA-Patch/Shared); the build - # references logging.m and binpatch.m from inside it. submodules: recursive + fetch-depth: 0 - name: Verify control version matches tag - # Catch the "bumped the tag but forgot to update control" class of - # mistake before the build wastes several minutes producing a .deb - # whose embedded version disagrees with the GitHub Release name. - # control's Version field must equal the tag without the leading "v". run: | TAG="${{ steps.tag.outputs.value }}" VER="${TAG#v}" CTRL_VER=$(grep -E '^Version:' control | awk '{print $2}') - echo "Tag version : ${VER}" + 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})" @@ -63,9 +59,6 @@ jobs: - name: Install host build tools run: | - # Mirrors integration.yaml: ldid is NOT in the noble apt repos - # and is installed separately below. libtinfo6 / libncurses6 are - # the runtime deps the Procursus ldid binary links against. sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential curl git make perl xz-utils \ @@ -73,12 +66,6 @@ jobs: libtinfo6 libncurses6 - name: Install ldid - # apt has no ldid on Ubuntu noble (24.04), so pull the prebuilt - # Linux x86_64 binary from the ProcursusTeam release. SHA256 - # pinned so a release-asset swap can't silently swap our signer. - env: - LDID_VERSION: "v2.1.5-procursus7" - LDID_SHA256: "4b8862b2fefa2cd7fa8f88cb0310779619aeaa8c72d6aff22f019b470f2fa99a" run: | curl -L -o /tmp/ldid \ "https://github.com/ProcursusTeam/ldid/releases/download/${LDID_VERSION}/ldid_linux_x86_64" @@ -90,28 +77,19 @@ jobs: id: theos-cache uses: actions/cache@v5 with: - path: | - theos - # Bump on toolchain layout changes (see Install Theos comment). + path: theos key: theos-linux-${{ env.IOS_SDK_VERSION }}-v3 - name: Install Theos if: steps.theos-cache.outputs.cache-hit != 'true' run: | - # Mirrors integration.yaml. - # Theos itself. git clone --recursive https://github.com/theos/theos.git "$THEOS" - # iOS cross-toolchain from L1ghtmann/llvm-project. The tarball - # already lays out the linux/iphone/ directory under - # $THEOS/toolchain, which is what Theos's makefiles expect -- - # no rename needed. 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.gz \ "https://github.com/theos/sdks/archive/master.tar.gz" @@ -121,49 +99,54 @@ jobs: - 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" + "$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: | + make binpatch FINALPACKAGE=1 + ls -la packages/binpatch/ + + - name: Sanity-check dylibs (no external hook engine) 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 + 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 @@ -172,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@v3 + 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 7186e07..b76a0c0 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -1,11 +1,5 @@ name: integration -# CI: build both the rootless .deb (MobileSubstrate path) and the jailed -# .dylib (Dobby-static path) on every push to master/develop and on every -# PR targeting either. Fails fast if the dylib accidentally picks up an -# external hook-engine dependency, since that would brick Sideloadly -# installs. - on: push: branches: [master, develop] @@ -20,6 +14,8 @@ concurrency: env: THEOS: ${{ github.workspace }}/theos IOS_SDK_VERSION: "16.5" + LDID_VERSION: "v2.1.5-procursus7" + LDID_SHA256: "4b8862b2fefa2cd7fa8f88cb0310779619aeaa8c72d6aff22f019b470f2fa99a" jobs: commitlint: @@ -28,21 +24,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v5 with: - # commitlint walks --from ~, so we need the PR - # commit history available locally. The default fetch-depth: 1 - # leaves the parent revision out of the working tree and - # `git rev-parse` fails with ambiguous argument. fetch-depth: 0 - name: Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install commitlint - # .commitlintrc.yaml extends "@commitlint/config-conventional", - # which is a separate package from commitlint itself. Without it - # commitlint exits 1 with "Cannot find module". run: | bun install conventional-changelog-conventionalcommits bun install @commitlint/config-conventional@latest @@ -53,6 +42,7 @@ jobs: - 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 @@ -68,22 +58,19 @@ 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@v6 + uses: actions/checkout@v5 with: - # Sources/Common is a submodule and the build references files - # inside it (logging.m, binpatch.m, ...). The default - # submodules: false leaves that path empty and the build fails - # at the very first preprocessing pass. submodules: recursive - name: Install host build tools run: | - # Mirrors KiouKifExporter's CI: ldid is NOT in the noble apt - # repos and is installed separately below. libtinfo6 / libncurses6 - # are the runtime deps the Procursus ldid binary links against. sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential curl git make perl xz-utils \ @@ -91,12 +78,6 @@ jobs: libtinfo6 libncurses6 - name: Install ldid - # apt has no ldid on Ubuntu noble (24.04), so pull the prebuilt - # Linux x86_64 binary from the ProcursusTeam release. SHA256 - # pinned so a release-asset swap can't silently swap our signer. - env: - LDID_VERSION: "v2.1.5-procursus7" - LDID_SHA256: "4b8862b2fefa2cd7fa8f88cb0310779619aeaa8c72d6aff22f019b470f2fa99a" run: | curl -L -o /tmp/ldid \ "https://github.com/ProcursusTeam/ldid/releases/download/${LDID_VERSION}/ldid_linux_x86_64" @@ -108,28 +89,19 @@ jobs: id: theos-cache uses: actions/cache@v5 with: - path: | - theos - # Bump on toolchain layout changes (see Install Theos comment). + path: theos key: theos-linux-${{ env.IOS_SDK_VERSION }}-v3 - name: Install Theos if: steps.theos-cache.outputs.cache-hit != 'true' run: | - # Mirrors KiouKifExporter's working CI. - # Theos itself. git clone --recursive https://github.com/theos/theos.git "$THEOS" - # iOS cross-toolchain from L1ghtmann/llvm-project. The tarball - # already lays out the linux/iphone/ directory under - # $THEOS/toolchain, which is what Theos's makefiles expect -- - # no rename needed. 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.gz \ "https://github.com/theos/sdks/archive/master.tar.gz" @@ -139,9 +111,8 @@ jobs: - 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: | @@ -154,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@v7 + uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_path }} diff --git a/Makefile b/Makefile index e5fe756..2d702fe 100644 --- a/Makefile +++ b/Makefile @@ -1,143 +1,127 @@ -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 -TWEAK_NAME = KiouEngineBridge +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 + +include $(THEOS)/makefiles/common.mk # CSA migration: Server_WebSocket.m and Usi_Engine.m are kept in tree as # `#if 0` blocks for historical reference; exclude them from the build so -# we link only the active CSA implementation. See -# docs/plans/kiou_engine_bridge_csa_migration.md Task 2. -KiouEngineBridge_FILES = $(shell find Sources/KiouEngineBridge \ +# we link only the active CSA implementation. +$(TWEAK_NAME)_FILES := $(shell find $(TWEAK_SOURCES_DIR) \ \( -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp' \) \ ! -name 'Server_WebSocket.m' \ ! -name 'Usi_Engine.m') -# Shared logging implementation lives in Sources/Common. il2cpp helpers are -# inline-only, so they don't need to be listed here. -KiouEngineBridge_FILES += Sources/Common/logging.m +$(TWEAK_NAME)_FILES += Sources/Common/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)\" -ISources/Common -KiouEngineBridge_FRAMEWORKS = Foundation +$(TWEAK_NAME)_CFLAGS := -fobjc-arc -Wno-unused-function \ + -D$(BUILD_COMMIT_DEFINE)=\"$(BUILD_COMMIT)\" \ + -ISources/Common +$(TWEAK_NAME)_FRAMEWORKS := Foundation # --------------------------------------------------------------------------- # Hook engine selection. # -# default (JB / rootless): MobileSubstrate (MSHookFunction in libsubstrate) +# default (JB / rootless): MobileSubstrate (MSHookFunction via libsubstrate) # JAILED=1 : Dobby statically linked from vendor/dobby. No -# libsubstrate dependency, so the resulting -# .dylib can be injected via Sideloadly into a -# non-jailbroken iOS 15-17 install. +# libsubstrate dependency — safe for Sideloadly / +# TrollStore injection on iOS 15–26. # BINPATCH=1 : static binary patch + __DATA,__bss SLOT -# dispatcher. No runtime __TEXT writes, so works -# on iOS 18 CSM (Sideloadly / TrollStore / -# AltStore / Apple Developer Program). The hook -# redirection lives in the patched UnityFramework -# cave; this dylib only publishes a function -# pointer into the reserved slot at constructor -# time. See docs/plans/kiou_engine_bridge_binpatch.md. -# Implies JAILED=1 so libsubstrate is dropped. +# dispatcher. No runtime __TEXT writes, survives +# iOS 18 CSM. Implies JAILED=1. # # Sources/Common/hookengine.h picks the API at compile time via IPA_JAILED. # --------------------------------------------------------------------------- ifeq ($(BINPATCH),1) JAILED := 1 - KiouEngineBridge_CFLAGS += -DKIOU_BINPATCH=1 -DIPA_LOG_TO_DOCUMENTS=1 + $(TWEAK_NAME)_CFLAGS += -DKIOU_BINPATCH=1 -DIPA_LOG_TO_DOCUMENTS=1 endif ifeq ($(JAILED),1) - KiouEngineBridge_CFLAGS += -DIPA_JAILED=1 -Ivendor/dobby/include - # Dobby is C++; pull in libc++ for __cxa_guard_*, __cxa_pure_virtual, etc. - 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) - # binpatch path never actually invokes Dobby at runtime but keeps the - # same link shape; refuse to silently fall back to a host hook engine. - KiouEngineBridge_LDFLAGS += -Wl,-undefined,error + $(TWEAK_NAME)_LDFLAGS += -Wl,-undefined,error endif else - KiouEngineBridge_LDFLAGS = -lsubstrate + $(TWEAK_NAME)_LDFLAGS := -lsubstrate endif include $(THEOS_MAKE_PATH)/tweak.mk -.PHONY: jailed binpatch ipa hooks - 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, then copy the -# resulting .dylib into packages/jailed/ for Sideloadly injection. Verifies -# the final binary has no libsubstrate / libdobby external dep (libdobby.a -# is bundled as a static archive, so it must not appear in the load list). 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)" - -# binpatch distribution: rebuild with KIOU_BINPATCH=1, drop libsubstrate, copy -# into packages/binpatch/ for the build_patched_ipa.sh pipeline. + $(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)/KiouEngineBridge.dylib packages/binpatch/KiouEngineBridge.dylib$(ECHO_END) - @echo "binpatch dylib -> packages/binpatch/KiouEngineBridge.dylib" - @echo "--- otool -L (must NOT list libsubstrate or libdobby) ---" - @$(THEOS)/toolchain/linux/iphone/bin/otool -L packages/binpatch/KiouEngineBridge.dylib 2>/dev/null \ - || otool -L packages/binpatch/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/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)" -# --------------------------------------------------------------------------- -# Full patched-IPA pipeline (Phase 1.5 distribution unit). -# -# Builds the binpatch dylib and runs shared/tools/build_patched_ipa.sh to -# produce a TrollStore / Sideloadly / AltStore / Apple Developer Program -# ready IPA. The user supplies a decrypted clean KIOU IPA via -# KIOU_CLEAN_IPA; the pipeline is driven by recipes/kiouenginebridge.py. -# -# This target NEVER redistributes a clean KIOU IPA — supply your own -# decrypted copy. Defaults to assets/Kiou-1.0.1.ipa (which is .gitignored) -# so a casual `make ipa` works in the dev container after the operator -# drops the IPA there. -# --------------------------------------------------------------------------- -KIOU_CLEAN_IPA ?= $(PWD)/assets/Kiou-1.0.1.ipa -KIOU_IPA_RECIPE := recipes.kiouenginebridge -KIOU_IPA_FRAMEWORK := UnityFramework -KIOU_IPA_DYLIB := $(PWD)/packages/binpatch/KiouEngineBridge.dylib +IPA_DYLIB := $(CURDIR)/packages/binpatch/$(TWEAK_NAME).dylib ipa:: binpatch - @echo "==> assembling patched IPA from $(KIOU_CLEAN_IPA)" - @if [ ! -f "$(KIOU_CLEAN_IPA)" ]; then \ - echo "error: clean KIOU IPA missing at $(KIOU_CLEAN_IPA)"; \ - echo " override with: make ipa KIOU_CLEAN_IPA=/path/to/clean.ipa"; \ + @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 "$(KIOU_IPA_RECIPE)" \ - --framework "$(KIOU_IPA_FRAMEWORK)" \ - --dylib "$(KIOU_IPA_DYLIB)" \ - --input "$(KIOU_CLEAN_IPA)" + --recipe "$(IPA_RECIPE)" \ + --framework "$(IPA_FRAMEWORK)" \ + --dylib "$(IPA_DYLIB)" \ + --input "$(DECRYPTED_IPA)" -# --------------------------------------------------------------------------- -# Developer hooks. Point core.hooksPath at scripts/ so scripts/pre-commit -# fires before every commit. The hook runs the recipe<->dump cross-check -# (verify_sites) when a commit touches recipes/ or shared/tools/, and is -# a no-op otherwise -- including on workstations without the local dump -# index. See scripts/pre-commit for the full contract. -# --------------------------------------------------------------------------- +.PHONY: hooks hooks:: git config core.hooksPath scripts - @echo "git hooks now resolve under scripts/ (pre-commit installed)" + @echo "git hooks now resolve under scripts/" From 6950174d19fea14743795ae8e065378eb662ce8e Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 06:18:19 +0000 Subject: [PATCH 49/51] fix(binpatch): wire GameStateStore hooks; drop legacy USI/WS code BinpatchDispatcher.m / Internal.h: - Add KIOU_BR_HOOK_GSTATE_SET_BLACK_PLAYER_INFO, KIOU_BR_HOOK_GSTATE_SET_WHITE_PLAYER_INFO, KIOU_BR_HOOK_GSTATE_NOTIFY_PIECE_MOVED to the hook id enum - Add corresponding dispatch_one cases calling HookGState* bodies - Declare HookGState* in Internal.h for the dispatcher Hook_GameStateStoreObserve.m: - Refactor: move HookGState* bodies above the #if KIOU_BINPATCH split so both JB and binpatch compile the same observation logic - Binpatch path: installer is a no-op (cave wires the hooks); JB path: MSHookFunction installer unchanged, now calls HookGState* - Fixes binpatch builds where CsaEngineOnMoveObserved was never called (all CSA move notifications were silently dropped) - Also fixes CsaOnPlayerInfoSet missing on binpatch (player identity for Game_Summary Name+/Name- fields on Online matches) recipes/kiouenginebridge.py: - Add three cave sites for the new GameStateStore hooks (28 total; verified to fit within 0x826A000..0x826C000 with 5840 bytes spare) Remove legacy USI / WebSocket code (migration complete): - Delete Sources/KiouEngineBridge/Server_WebSocket.m (was #if 0) - Delete Sources/KiouEngineBridge/Usi_Engine.m (was #if 0) - Delete Sources/KiouEngineBridge/Csa_Stubs.m (transition no-ops) - Makefile: drop Server_WebSocket.m / Usi_Engine.m exclusion filter - Internal.h: remove UsiEngine*, KEBWsServer*, kiou_ws_text_handler_t, usi_state_t declarations; keep usi_match_result_t (used by CSA path); update file header comment to reflect CSA architecture integration.yaml: actions/upload-artifact v4 -> v7 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/integration.yaml | 2 +- Makefile | 7 +- Sources/KiouEngineBridge/BinpatchDispatcher.m | 14 + Sources/KiouEngineBridge/Csa_Stubs.m | 85 --- .../Hook_GameStateStoreObserve.m | 196 +++--- Sources/KiouEngineBridge/Internal.h | 137 +---- Sources/KiouEngineBridge/Server_WebSocket.m | 574 ------------------ Sources/KiouEngineBridge/Usi_Engine.m | 474 --------------- recipes/kiouenginebridge.py | 5 + 9 files changed, 141 insertions(+), 1353 deletions(-) delete mode 100644 Sources/KiouEngineBridge/Csa_Stubs.m delete mode 100644 Sources/KiouEngineBridge/Server_WebSocket.m delete mode 100644 Sources/KiouEngineBridge/Usi_Engine.m diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index b76a0c0..6555b53 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -143,7 +143,7 @@ jobs: 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/Makefile b/Makefile index 2d702fe..63cdf0d 100644 --- a/Makefile +++ b/Makefile @@ -36,13 +36,8 @@ THEOS_DEVICE_IP := 192.168.0.49 include $(THEOS)/makefiles/common.mk -# CSA migration: Server_WebSocket.m and Usi_Engine.m are kept in tree as -# `#if 0` blocks for historical reference; exclude them from the build so -# we link only the active CSA implementation. $(TWEAK_NAME)_FILES := $(shell find $(TWEAK_SOURCES_DIR) \ - \( -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp' \) \ - ! -name 'Server_WebSocket.m' \ - ! -name 'Usi_Engine.m') + \( -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp' \)) $(TWEAK_NAME)_FILES += Sources/Common/logging.m BUILD_COMMIT ?= $(shell git rev-parse --short=7 HEAD 2>/dev/null || echo unknown) diff --git a/Sources/KiouEngineBridge/BinpatchDispatcher.m b/Sources/KiouEngineBridge/BinpatchDispatcher.m index eb18e68..d7e6a64 100644 --- a/Sources/KiouEngineBridge/BinpatchDispatcher.m +++ b/Sources/KiouEngineBridge/BinpatchDispatcher.m @@ -118,6 +118,20 @@ static void dispatch_one(void *x0, void *x1, void *x2, void *x3, void *x4, 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 diff --git a/Sources/KiouEngineBridge/Csa_Stubs.m b/Sources/KiouEngineBridge/Csa_Stubs.m deleted file mode 100644 index 5cfe178..0000000 --- a/Sources/KiouEngineBridge/Csa_Stubs.m +++ /dev/null @@ -1,85 +0,0 @@ -#import "Internal.h" - -// =========================================================================== -// Csa_Stubs — temporary shim during the CSA migration (Tasks 2-5). -// -// The hooks in Hook_*.m and Meta_Emitter.m still call into the legacy USI -// engine driver via UsiEngine* functions and the WebSocket sink via -// KEBWsServer*. The implementations of those symbols live in -// Server_WebSocket.m and Usi_Engine.m, which Task 2 deprecates by wrapping -// them in `#if 0` and dropping them from the build. -// -// To keep `make` green between Task 2 and Tasks 4-5 (where the real -// CsaEngine* / KEBCsaServer* implementations land and every hook call site -// is migrated), this file provides no-op shims for the symbols the hooks -// still reference. Once every hook has been switched over to the CSA -// callbacks, this file is deleted in Task 5. -// -// IMPORTANT: do NOT add any new call sites against these stubs. They exist -// solely to bridge the half-migrated build state. The TaskGet logs in the -// task tracker make this lifetime explicit. -// =========================================================================== - -// --------------------------------------------------------------------------- -// WebSocket sink stubs. The real KEBCsaServer* family is introduced in -// Task 3; until then, every push is dropped silently. -// --------------------------------------------------------------------------- - -void KEBWsServerStart(uint16_t port) { - (void)port; - file_log(@"[STUB] KEBWsServerStart called during CSA migration — no-op"); -} - -void KEBWsServerPush(NSString *line) { - (void)line; - // Drop silently. Logging every call would flood the file log because - // Meta_Emitter.m emits meta lines on every match event. -} - -void KEBWsServerSetTextHandler(kiou_ws_text_handler_t fn) { - (void)fn; - file_log(@"[STUB] KEBWsServerSetTextHandler called during CSA migration" - @" — no-op"); -} - -// --------------------------------------------------------------------------- -// USI engine driver stubs. Real CsaEngine* callbacks land in Task 4; the -// call sites in Hook_LowLevelObserve.m and Hook_MatchModeObserve.m are -// rewritten in Task 5. -// --------------------------------------------------------------------------- - -void UsiEngineOnMatchStart(int32_t local_player) { - (void)local_player; -} - -void UsiEngineOnMatchEnd(usi_match_result_t result) { - (void)result; -} - -void UsiEngineOnMoveObserved(NSString *usi, - NSString *sfen_after, - int32_t side_to_move) { - (void)usi; - (void)sfen_after; - (void)side_to_move; -} - -void UsiEngineOnWsClientConnected(void) { - // no-op: WS transport is gone -} - -void UsiEngineOnWsClientDisconnected(void) { - // no-op: WS transport is gone -} - -void UsiEngineSendLine(NSString *line) { - (void)line; - // no-op: USI line emission is being replaced by CsaEngineSendLine -} - -void UsiEngineInstall(void) { - file_log(@"[STUB] UsiEngineInstall called during CSA migration — no-op"); -} - -// (Inject_Resign.m now provides the real InjectResign / -// InjectNyugyokuDeclaration entry points — Task 6 has landed.) diff --git a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m index 10a44c0..9bb8533 100644 --- a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m @@ -1,133 +1,89 @@ #import "Internal.h" -#if KIOU_BINPATCH -// This entire module is meta-sidecar-only. The binpatch flavour drops the -// meta sidecar (see docs/plans/kiou_engine_bridge_binpatch.md § 2), so the -// file compiles to nothing. InstallGameStateStoreObserveHook is a no-op -// shim defined at the bottom of this #if block so Tweak.m doesn't need its -// own #if to skip the call — keeping the constructor wiring uniform. -void InstallGameStateStoreObserveHook(uintptr_t unityBase) { (void)unityBase; } -void MetaOnPlayerInfoSet(int32_t side, void *playerInfo) { - (void)side; (void)playerInfo; -} -#else - -// =========================================================================== -// 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) は自分手しか -// 通らないので、MetaEmitMove の発火点はこちらに集約する。 #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 -// MetaOnPlayerInfoSet tells Meta_Emitter which slot was just written. -// --------------------------------------------------------------------------- -static void HookSetBlackPlayerInfo(void *self, void *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 HookSetWhitePlayerInfo(void *self, void *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 を通る。 -// したがってここで MetaEmitMove を 1 度だけ発火すれば、片肺 KIF -// 問題は解消する。MetaEmitMove は引数として「次の手番」を受け取る -// 設計なので、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 HookNotifyPieceMoved(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 *usi = moveToUsi((SfMove)move); - // playerSide はこの NotifyPieceMoved 呼び出しの「指した側」。 - // MetaEmitMove は「次の手番」を引数に取るので flip して渡す。 - int32_t nextSide = (playerSide == 0) ? 1 - : (playerSide == 1) ? 0 - : -1; - file_log([NSString stringWithFormat: - @"[GSTATE-MOVE] NotifyPieceMoved self=%p moved_side=%d " - @"usi=\"%@\" sfen=\"%@\"", - self, (int)playerSide, usi ?: @"", sfen ?: @""]); - - // GameStateStore (dump.cs:1422268) keeps two ReactiveProperty - // clocks at offset 0x80 / 0x90. The underlying R3 / UniRx box stores - // the current value at +0x20 (verified live with a probe sweep, see - // commit history). Online matches keep these in sync with the server; - // VsAI / Local matches use them for the on-screen clock too. - // - // Sanity check: VsAI emits 86400.0 (= 24 h) as the CPU side's clock to - // mean "no time limit." Treat anything ≥ 86400 - 60 as "no clock" so we - // don't ship bogus large T values. + // 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; { @@ -143,22 +99,46 @@ static void HookNotifyPieceMoved(void *self, } } + file_log([NSString stringWithFormat: + @"[GSTATE-MOVE] NotifyPieceMoved self=%p moved_side=%d " + @"usi=\"%@\" sfen=\"%@\"", + self, (int)playerSide, usi ?: @"", sfen ?: @""]); + + // 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 wants the raw Move bits + post-move SFEN so it can - // reconstruct the piece type at the destination square and ship a - // `+7776FU,T10`-style notification. + + // 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(). -// --------------------------------------------------------------------------- +// =========================================================================== +// 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 *)HookSetBlackPlayerInfo, + (void *)HookGStateSetBlackPlayerInfo, (void **)&orig_SetBlackPlayerInfo); file_log([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.SetBlackPlayerInfo " @@ -169,7 +149,7 @@ void InstallGameStateStoreObserveHook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_GAMESTATESTORE_SET_WHITE_PLAYER_INFO; MSHookFunction((void *)addr, - (void *)HookSetWhitePlayerInfo, + (void *)HookGStateSetWhitePlayerInfo, (void **)&orig_SetWhitePlayerInfo); file_log([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.SetWhitePlayerInfo " @@ -180,7 +160,7 @@ void InstallGameStateStoreObserveHook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_GAMESTATESTORE_NOTIFY_PIECE_MOVED; MSHookFunction((void *)addr, - (void *)HookNotifyPieceMoved, + (void *)HookGStateNotifyPieceMoved, (void **)&orig_NotifyPieceMoved); file_log([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.NotifyPieceMoved " diff --git a/Sources/KiouEngineBridge/Internal.h b/Sources/KiouEngineBridge/Internal.h index 8e2d7f4..e6202a4 100644 --- a/Sources/KiouEngineBridge/Internal.h +++ b/Sources/KiouEngineBridge/Internal.h @@ -11,53 +11,31 @@ // =========================================================================== // 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 -// `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: -// -// InstallOnlineObserveHook (Hook_OnlineObserve.m) -// InstallLowLevelObserveHook (Hook_LowLevelObserve.m) -// InstallMatchModeObserveHook (Hook_MatchModeObserve.m) -// InstallInjectHook (Inject_Move.m) -// UsiEngineInstall (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 @@ -109,7 +87,6 @@ void InstallMatchModeObserveHook(uintptr_t unityBase); void InstallInjectHook(uintptr_t unityBase); void InstallAfkSuppressHook(uintptr_t unityBase); void InstallGameOrchestratorObserveHook(uintptr_t unityBase); -void UsiEngineInstall(void); // UnityFramework base address captured at install time. Exposed so the // match-end auto-rematch path can resolve static il2cpp methods @@ -144,21 +121,6 @@ void KEBCsaServerClose(void); typedef void (*kiou_csa_line_handler_t)(NSString *line); void KEBCsaServerSetLineHandler(kiou_csa_line_handler_t fn); -// --------------------------------------------------------------------------- -// DEPRECATED — WebSocket server API. -// -// The USI-era WebSocket sink (Server_WebSocket.m) is excluded from the -// build by Makefile filter as part of the CSA migration. The declarations -// below stay so half-migrated translation units still compile; the -// implementations live in Csa_Stubs.m as no-ops. Once Tasks 4-5 retarget -// every hook call site to the CSA APIs above, these declarations and the -// stub file are deleted. -// --------------------------------------------------------------------------- -void KEBWsServerStart(uint16_t port); -void KEBWsServerPush(NSString *json); -typedef void (*kiou_ws_text_handler_t)(const char *data, size_t len); -void KEBWsServerSetTextHandler(kiou_ws_text_handler_t fn); - // --------------------------------------------------------------------------- // Observation-side instance cache, populated by Hook_LowLevelObserve.m and // Hook_OnlineObserve.m as live ShogiGameAdapter / GameController / @@ -294,8 +256,8 @@ typedef struct { 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 @@ -311,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::HookAdapterTryMakeMoveOut 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 UsiEngineOnMoveObserved(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 UsiEngineOnMatchStart(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, @@ -350,13 +282,6 @@ typedef enum { USI_RESULT_DRAW = 2, } usi_match_result_t; -void UsiEngineOnMatchEnd(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 UsiEngineOnWsClientConnected(void); -void UsiEngineOnWsClientDisconnected(void); - // --------------------------------------------------------------------------- // Csa_Engine.m — the CSA-protocol server-side state machine that drives // the connected engine through Game_Summary / AGREE / per-move exchange / @@ -450,10 +375,8 @@ void MetaSetMatchConfig(void *cfg); // latches the local-player seat (so we can carry it in the payload). void MetaEmitMatchStart(int32_t local_player); -// Emit "meta {type:move, ...}". Called from Hook_LowLevelObserve's adapter -// observation, right alongside UsiEngineOnMoveObserved. side_to_move is -// the side whose turn it is NEXT — we flip to "who just moved" in the -// payload. +// 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 @@ -558,6 +481,10 @@ enum kiou_bridge_hook_id { 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, }; @@ -701,6 +628,10 @@ void HookCpuStreamUpdateSnapshot(void *self, void *sfenStr, int32_t turn, 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 // files (Hook_GameStateStoreObserve.m's NotifyPieceMoved) can reuse the same @@ -724,7 +655,3 @@ NSString *SfenFromGameController(void *gameCtrl); // Hook_LowLevelObserve.m. 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 KEBWsServerPush. -void UsiEngineSendLine(NSString *line); diff --git a/Sources/KiouEngineBridge/Server_WebSocket.m b/Sources/KiouEngineBridge/Server_WebSocket.m deleted file mode 100644 index 59438a0..0000000 --- a/Sources/KiouEngineBridge/Server_WebSocket.m +++ /dev/null @@ -1,574 +0,0 @@ -// =========================================================================== -// DEPRECATED — WebSocket server for the USI protocol. -// Replaced by the plain TCP CSA server (Server_CSA.m) in the CSA migration -// plan (docs/plans/kiou_engine_bridge_csa_migration.md). Excluded from the -// build via Makefile filter; kept in tree as a reference for the accept -// loop / send/recv plumbing that Server_CSA.m mirrors with WS framing -// stripped out. -// =========================================================================== -#if 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 KEBWsServerPush() 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 KEBWsServerStart() 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 -// KEBWsServerSetTextHandler() 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). - UsiEngineOnWsClientDisconnected(); - } -} - -// --------------------------------------------------------------------------- -// 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 KEBWsServerPush -> 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. - UsiEngineOnWsClientConnected(); - - // 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 KEBWsServerStart(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 KEBWsServerSetTextHandler(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 KEBWsServerPush(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(); - } - }); -} - -#endif // 0 — DEPRECATED USI WebSocket server diff --git a/Sources/KiouEngineBridge/Usi_Engine.m b/Sources/KiouEngineBridge/Usi_Engine.m deleted file mode 100644 index f2fe080..0000000 --- a/Sources/KiouEngineBridge/Usi_Engine.m +++ /dev/null @@ -1,474 +0,0 @@ -// =========================================================================== -// DEPRECATED — USI protocol implementation. -// Replaced by CSA server protocol in the CSA migration plan -// (docs/plans/kiou_engine_bridge_csa_migration.md). Excluded from the build -// via Makefile filter; kept in tree for historical reference and so the git -// blame on the CSA implementation's state-machine derivatives stays -// readable. -// =========================================================================== -#if 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::HookAdapterTryMakeMoveOut fires -// ↓ UsiEngineOnMoveObserved(usi, sfen_after, side_to_move) -// ↓ side_to_move == g_LocalPlayer ? -// yes → UsiEngineSendLine("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 HookAdapterTryMakeMoveOut -// 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 HookAdapterTryMakeMoveOut 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 UsiEngineSendLine(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"]]); - KEBWsServerPush(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 UsiEngineOnMatchStart(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 UsiEngineOnMatchEnd(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) { - UsiEngineSendLine([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; - } - UsiEngineSendLine([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 UsiEngineOnMoveObserved(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 -// UsiEngineSendLine. -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 - UsiEngineSendLine(@"isready"); - return; - } - if ([cmd isEqualToString:@"readyok"]) { - UsiEngineSendLine(@"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 - // UsiEngineOnMoveObserved 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 UsiEngineOnWsClientConnected(void) { - file_log(@"[USI] ws client connected; starting USI handshake"); - usi_set_state(USI_STATE_HANDSHAKE); - g_expectedNextUsi = nil; - UsiEngineSendLine(@"usi"); -} - -void UsiEngineOnWsClientDisconnected(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 UsiEngineInstall(void) { - KEBWsServerSetTextHandler(usi_engine_text_handler); - file_log(@"[USI] engine installed (text handler registered)"); -} - -#endif // 0 — DEPRECATED USI implementation diff --git a/recipes/kiouenginebridge.py b/recipes/kiouenginebridge.py index b86bf7d..4458751 100644 --- a/recipes/kiouenginebridge.py +++ b/recipes/kiouenginebridge.py @@ -392,6 +392,11 @@ def emit(insn: bytes) -> None: (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"), ] From 9e09ee8ba37ca9e2037174d591630a37c1f13f41 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 09:15:36 +0000 Subject: [PATCH 50/51] chore: rename Common submodule to Chinlan, update API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitmodules: rename submodule Sources/Common → Sources/Chinlan, update URL to IPA-Patch/Chinlan.git - Sources/Common removed, Sources/Chinlan added (pinned to HEAD) - Makefile: update -I flag, logging.m path, and comment to Sources/Chinlan - Sources/KiouEngineBridge/**: - file_log → IPALog (all call sites) - logging_init → IPALoggingInit (Tweak.m) - recipes/kiouenginebridge.py: add missing KIOU_BR_HOOK_GSTATE_* entries to _HOOK_IDS (ids 25–27); also drop UsiEngineOnMatchStart/End and UsiEngineOnMoveObserved call sites left over from the USI removal Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitmodules | 6 +- Makefile | 6 +- Sources/Chinlan | 1 + Sources/Common | 1 - Sources/KiouEngineBridge/BinpatchDispatcher.m | 6 +- Sources/KiouEngineBridge/Csa_Convert.m | 2 +- Sources/KiouEngineBridge/Csa_Engine.m | 84 +++++++++---------- Sources/KiouEngineBridge/Hook_AfkSuppress.m | 4 +- .../Hook_GameOrchestratorObserve.m | 4 +- .../Hook_GameStateStoreObserve.m | 8 +- .../KiouEngineBridge/Hook_LowLevelObserve.m | 35 ++------ .../KiouEngineBridge/Hook_MatchModeObserve.m | 45 +++++----- Sources/KiouEngineBridge/Hook_OnlineObserve.m | 18 ++-- Sources/KiouEngineBridge/Inject_Move.m | 62 +++++++------- Sources/KiouEngineBridge/Inject_Resign.m | 8 +- Sources/KiouEngineBridge/Meta_Emitter.m | 12 +-- Sources/KiouEngineBridge/Server_CSA.m | 34 ++++---- Sources/KiouEngineBridge/Tweak.m | 12 +-- recipes/kiouenginebridge.py | 3 + 19 files changed, 163 insertions(+), 188 deletions(-) create mode 160000 Sources/Chinlan delete mode 160000 Sources/Common diff --git a/.gitmodules b/.gitmodules index d644e1f..10bb5cb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "shared"] path = shared url = https://github.com/IPA-Patch/Shared.git -[submodule "Sources/Common"] - path = Sources/Common - url = https://github.com/IPA-Patch/Common.git +[submodule "Sources/Chinlan"] + path = Sources/Chinlan + url = https://github.com/IPA-Patch/Chinlan.git diff --git a/Makefile b/Makefile index 63cdf0d..7b10d49 100644 --- a/Makefile +++ b/Makefile @@ -38,13 +38,13 @@ include $(THEOS)/makefiles/common.mk $(TWEAK_NAME)_FILES := $(shell find $(TWEAK_SOURCES_DIR) \ \( -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp' \)) -$(TWEAK_NAME)_FILES += Sources/Common/logging.m +$(TWEAK_NAME)_FILES += Sources/Chinlan/logging.m BUILD_COMMIT ?= $(shell git rev-parse --short=7 HEAD 2>/dev/null || echo unknown) $(TWEAK_NAME)_CFLAGS := -fobjc-arc -Wno-unused-function \ -D$(BUILD_COMMIT_DEFINE)=\"$(BUILD_COMMIT)\" \ - -ISources/Common + -ISources/Chinlan $(TWEAK_NAME)_FRAMEWORKS := Foundation # --------------------------------------------------------------------------- @@ -58,7 +58,7 @@ $(TWEAK_NAME)_FRAMEWORKS := Foundation # dispatcher. No runtime __TEXT writes, survives # iOS 18 CSM. Implies JAILED=1. # -# Sources/Common/hookengine.h picks the API at compile time via IPA_JAILED. +# Sources/Chinlan/hookengine.h picks the API at compile time via IPA_JAILED. # --------------------------------------------------------------------------- ifeq ($(BINPATCH),1) JAILED := 1 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/Common b/Sources/Common deleted file mode 160000 index 671451c..0000000 --- a/Sources/Common +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 671451c5af1cc72928e5f202c8a2d296ae65d3a1 diff --git a/Sources/KiouEngineBridge/BinpatchDispatcher.m b/Sources/KiouEngineBridge/BinpatchDispatcher.m index d7e6a64..039f321 100644 --- a/Sources/KiouEngineBridge/BinpatchDispatcher.m +++ b/Sources/KiouEngineBridge/BinpatchDispatcher.m @@ -137,7 +137,7 @@ static void dispatch_one(void *x0, void *x1, void *x2, void *x3, void *x4, // 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. - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[BINPATCH] unknown hook_id=%u self=%p", (unsigned)hook_id, x0]); break; @@ -149,7 +149,7 @@ void KEBBridgeBinpatchPublish(void) { // 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. - file_log(@"[BINPATCH] publish skipped: g_unityBase is zero"); + IPALog(@"[BINPATCH] publish skipped: g_unityBase is zero"); return; } // Slot lives in UnityFramework's __DATA,__bss at the RVA the recipe @@ -164,7 +164,7 @@ void KEBBridgeBinpatchPublish(void) { for (uint32_t i = 0; i < KIOU_BR_HOOK__COUNT; i++) { g_inject_entry[i] = kiou_bridge_bypass_entry_for_hook(i); } - file_log([NSString stringWithFormat: + 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", diff --git a/Sources/KiouEngineBridge/Csa_Convert.m b/Sources/KiouEngineBridge/Csa_Convert.m index 8e3b3c5..2540d2a 100644 --- a/Sources/KiouEngineBridge/Csa_Convert.m +++ b/Sources/KiouEngineBridge/Csa_Convert.m @@ -533,7 +533,7 @@ int32_t DropPieceTypeFromHandDelta(NSString *sfenBefore, } // --------------------------------------------------------------------------- -// Move legality checks. The shared file_log() emits a single line per +// 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. // diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index 704b415..a41efd1 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -111,7 +111,7 @@ static uint32_t csa_machTicksToSec(uint64_t ticks) { static void csa_set_state_impl(int newState, const char *caller) { int old = atomic_exchange(&g_csaState, newState); if (old != newState) { - file_log([NSString stringWithFormat:@"[CSA-ENG] state %s -> %s (from %s)", + IPALog([NSString stringWithFormat:@"[CSA-ENG] state %s -> %s (from %s)", csa_state_name(old), csa_state_name(newState), caller]); } } @@ -123,7 +123,7 @@ static void csa_set_state_impl(int newState, const char *caller) { // --------------------------------------------------------------------------- void CsaEngineSendLine(NSString *line) { if (line.length == 0) return; - file_log([NSString stringWithFormat:@"[CSA>] %@", line]); + IPALog([NSString stringWithFormat:@"[CSA>] %@", line]); KEBCsaServerPush(line); } @@ -159,7 +159,7 @@ static void csa_send_game_summary(int32_t local_player) { NSString *startSfen = nil; NSString *summary = CsaBuildGameSummary(local_player, &gameId, &startSfen); if (summary.length == 0) { - file_log(@"[CSA-ENG] CsaBuildGameSummary returned empty — " + IPALog(@"[CSA-ENG] CsaBuildGameSummary returned empty — " @"deferring Game_Summary"); return; } @@ -170,7 +170,7 @@ static void csa_send_game_summary(int32_t local_player) { // NotifyPieceMoved fires, which leaves the validator blind for move 1). if (startSfen.length > 0) { g_csaPrevSfen = startSfen; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] seeded g_csaPrevSfen from Game_Summary sfen=%@", startSfen]); } @@ -227,7 +227,7 @@ static void csa_handle_login(NSString *line) { // next move observation to be dropped. csa_send_game_summary(lpCap); } else { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] LOGIN renegotiate skipped: state " @"changed to %s before main-queue dispatch", csa_state_name(now)]); @@ -247,11 +247,11 @@ static void csa_handle_agree(NSString *line) { // 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. - file_log(@"[CSA-ENG] AGREE in PLAYING — already started, dropping"); + IPALog(@"[CSA-ENG] AGREE in PLAYING — already started, dropping"); return; } if (s != CSA_STATE_AGREE_WAIT) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] AGREE in state %s — ignoring", csa_state_name(s)]); return; @@ -263,7 +263,7 @@ static void csa_handle_agree(NSString *line) { static void csa_handle_reject(NSString *line) { (void)line; - file_log(@"[CSA-ENG] engine REJECTed match — staying connected"); + 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); @@ -312,7 +312,7 @@ static void csa_handle_line(NSString *line) { csa_handle_move_from_engine(trimmed); return; } - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] ignoring unrecognised line: %@", trimmed]); } @@ -328,7 +328,7 @@ static void csa_handle_line(NSString *line) { static void csa_handle_special(NSString *line) { int s = atomic_load(&g_csaState); if (s != CSA_STATE_PLAYING) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] special %@ in state %s — ignoring", line, csa_state_name(s)]); return; @@ -355,12 +355,12 @@ static void csa_handle_special(NSString *line) { return; } if ([line isEqualToString:@"%CHUDAN"]) { - file_log(@"[CSA-ENG] %%CHUDAN received — not surfaced to KIOU"); + IPALog(@"[CSA-ENG] %%CHUDAN received — not surfaced to KIOU"); CsaEngineSendLine(@"#CHUDAN"); csa_set_state(CSA_STATE_GAME_OVER); return; } - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] unknown special: %@", line]); } @@ -405,7 +405,7 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, static void csa_handle_move_from_engine(NSString *line) { int s = atomic_load(&g_csaState); if (s != CSA_STATE_PLAYING) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] move in state %s — ignoring: %@", csa_state_name(s), line]); return; @@ -416,13 +416,13 @@ static void csa_handle_move_from_engine(NSString *line) { int32_t playerSide = -1; int32_t timeSpent = -1; if (!MoveBitsFromCsaText(line, &move, &pieceType, &playerSide, &timeSpent)) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] malformed move: %@", line]); return; } (void)timeSpent; // engine-reported think time is logged but unused - file_log([NSString stringWithFormat: + 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, @@ -433,27 +433,27 @@ static void csa_handle_move_from_engine(NSString *line) { // handler runs on isn't. NSString *lineCap = [line copy]; dispatch_async(dispatch_get_main_queue(), ^{ - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG-DBG] main-queue dispatch arrived for: %@", lineCap]); csa_apply_engine_move(lineCap, move, pieceType, playerSide); - file_log(@"[CSA-ENG-DBG] csa_apply_engine_move returned cleanly"); + IPALog(@"[CSA-ENG-DBG] csa_apply_engine_move returned cleanly"); }); - file_log(@"[CSA-ENG-DBG] dispatch_async(main) submitted, returning"); + 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) { - file_log([NSString stringWithFormat: + 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); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG-DBG] state on main queue=%s", csa_state_name(s)]); if (s != CSA_STATE_PLAYING) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] move state changed to %s before main " @"dispatch — dropping: %@", csa_state_name(s), line]); @@ -463,7 +463,7 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, uint32_t promote = (move >> 14) & 1; uint32_t from = (move >> 7) & 0x7F; uint32_t to = move & 0x7F; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG-DBG] decoded from=%u to=%u promote=%u drop=%u", from, to, promote, drop]); @@ -474,7 +474,7 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, // claims. int32_t enginePlayer = lp; if (enginePlayer != -1 && playerSide != enginePlayer) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] move side mismatch: engine=%d got=%d " @"(applying anyway)", enginePlayer, playerSide]); @@ -498,7 +498,7 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, if (prev.length > 0) { int32_t fromPiece = PscPieceTypeAtSquare(prev, from); if (fromPiece >= 9 && fromPiece <= 14) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] promote bit cleared (from=%u " @"already holds promoted piece %d): %@", from, fromPiece, line]); @@ -506,16 +506,16 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, } } } - file_log(@"[CSA-ENG-DBG] promote check done — building toUsi"); + IPALog(@"[CSA-ENG-DBG] promote check done — building toUsi"); NSString *toCsa = CsaSquareFromMoveBits(to); NSString *toUsi = csa_squareToUsi(toCsa); if (!toUsi) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] bad to-square in: %@", line]); return; } - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG-DBG] toCsa=%@ toUsi=%@", toCsa, toUsi]); // Cheap legality pre-checks. The primary goal is to keep blatantly @@ -531,7 +531,7 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, // dead-end drops, nifu, piece-type mismatches). NSString *validatorSfen = g_csaPrevSfen; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG-DBG] validator sfen len=%lu — running validator", (unsigned long)validatorSfen.length]); @@ -540,11 +540,11 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, ? ValidateCsaDrop(validatorSfen, to, pieceType, playerSide) : ValidateCsaMove(validatorSfen, from, to, pieceType, promote ? YES : NO, playerSide); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG-DBG] validator result reason=%s", reason ?: "OK"]); if (reason) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] rejecting illegal move (reason=%s): %@", reason, line]); // CSA spec lets the server emit `#ILLEGAL_MOVE` on the engine's @@ -560,7 +560,7 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, if (drop) { NSString *letter = csa_pieceToUsiDropLetter(pieceType); if (!letter) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] cannot map drop piece %d to USI: %@", pieceType, line]); return; @@ -570,7 +570,7 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, NSString *fromCsa = CsaSquareFromMoveBits(from); NSString *fromUsi = csa_squareToUsi(fromCsa); if (!fromUsi) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] bad from-square in: %@", line]); return; } @@ -579,14 +579,14 @@ static void csa_apply_engine_move(NSString *line, uint32_t move, : [NSString stringWithFormat:@"%@%@", fromUsi, toUsi]; } - file_log([NSString stringWithFormat: + 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); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] inject_apply usi=%@ ok=%d raw=0x%x err=%@ sfen=%@", usi, (int)ok, (unsigned)outRaw, outErr ?: @"", outSfen ?: @""]); if (!ok) { @@ -623,7 +623,7 @@ void CsaEngineOnMatchStart(int32_t local_player) { g_csaLastMoveMachTicks[1] = startMach; int s = atomic_load(&g_csaState); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] match_start local_player=%d state=%s", (int)local_player, csa_state_name(s)]); @@ -655,7 +655,7 @@ void CsaEngineOnMatchStart(int32_t local_player) { void CsaEngineOnMatchEnd(usi_match_result_t result) { int s = atomic_load(&g_csaState); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] match_end result=%d state=%s", (int)result, csa_state_name(s)]); @@ -686,7 +686,7 @@ void CsaEngineOnMoveObserved(uint32_t move, uint32_t to = move & 0x7F; int32_t pscPieceType = PscPieceTypeAtSquare(sfenAfter, to); if (pscPieceType < 0) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] cannot resolve piece type at to=%u " @"(sfen=%@) — emitting raw bits log only", to, sfenAfter ?: @""]); @@ -710,7 +710,7 @@ void CsaEngineOnMoveObserved(uint32_t move, int32_t handDelta = DropPieceTypeFromHandDelta(g_csaPrevSfen, sfenAfter, playerSide); if (!isDrop && handDelta > 0) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] drop inferred from hand delta — piece=%d " @"(move bit said normal move)", handDelta]); isDrop = YES; @@ -792,7 +792,7 @@ void CsaEngineOnMoveObserved(uint32_t move, NSString *csa = CsaTextFromMoveBits(move, pscPieceType, playerSide, timeSpent); if (!csa) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA-ENG] CsaTextFromMoveBits returned nil for " @"move=0x%x piece=%d side=%d drop=%d promote=%d " @"prevSfen=\"%@\" postSfen=\"%@\"", @@ -815,7 +815,7 @@ void CsaEngineOnMoveObserved(uint32_t move, // --------------------------------------------------------------------------- void CsaEngineOnTcpClientConnected(void) { - file_log(@"[CSA-ENG] tcp client connected"); + 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), @@ -843,7 +843,7 @@ void CsaEngineOnTcpClientConnected(void) { } void CsaEngineOnTcpClientDisconnected(void) { - file_log(@"[CSA-ENG] tcp client disconnected"); + IPALog(@"[CSA-ENG] tcp client disconnected"); csa_set_state(CSA_STATE_BOOT); } @@ -858,5 +858,5 @@ static void csa_engine_line_handler(NSString *line) { void CsaEngineInstall(void) { KEBCsaServerSetLineHandler(csa_engine_line_handler); - file_log(@"[CSA-ENG] installed (line handler registered)"); + IPALog(@"[CSA-ENG] installed (line handler registered)"); } diff --git a/Sources/KiouEngineBridge/Hook_AfkSuppress.m b/Sources/KiouEngineBridge/Hook_AfkSuppress.m index 49cff57..4000223 100644 --- a/Sources/KiouEngineBridge/Hook_AfkSuppress.m +++ b/Sources/KiouEngineBridge/Hook_AfkSuppress.m @@ -47,7 +47,7 @@ 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]); } @@ -64,7 +64,7 @@ void InstallAfkSuppressHook(uintptr_t unityBase) { MSHookFunction((void *)addr, (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, diff --git a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m index 61efe5c..89b2ad4 100644 --- a/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameOrchestratorObserve.m @@ -74,7 +74,7 @@ UniTaskRet HookGameOrchActivateAsync(void *self, void *setup, // 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]); } @@ -92,7 +92,7 @@ void InstallGameOrchestratorObserveHook(uintptr_t unityBase) { uintptr_t addr = unityBase + RVA_GAMEORCH_ACTIVATE; 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]); diff --git a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m index 9bb8533..d1fcdcb 100644 --- a/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m +++ b/Sources/KiouEngineBridge/Hook_GameStateStoreObserve.m @@ -99,7 +99,7 @@ void HookGStateNotifyPieceMoved(void *self, uint32_t move, int32_t playerSide) { } } - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GSTATE-MOVE] NotifyPieceMoved self=%p moved_side=%d " @"usi=\"%@\" sfen=\"%@\"", self, (int)playerSide, usi ?: @"", sfen ?: @""]); @@ -140,7 +140,7 @@ void InstallGameStateStoreObserveHook(uintptr_t unityBase) { MSHookFunction((void *)addr, (void *)HookGStateSetBlackPlayerInfo, (void **)&orig_SetBlackPlayerInfo); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.SetBlackPlayerInfo " @"@0x%lx (base+0x%x)", (unsigned long)addr, @@ -151,7 +151,7 @@ void InstallGameStateStoreObserveHook(uintptr_t unityBase) { MSHookFunction((void *)addr, (void *)HookGStateSetWhitePlayerInfo, (void **)&orig_SetWhitePlayerInfo); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.SetWhitePlayerInfo " @"@0x%lx (base+0x%x)", (unsigned long)addr, @@ -162,7 +162,7 @@ void InstallGameStateStoreObserveHook(uintptr_t unityBase) { MSHookFunction((void *)addr, (void *)HookGStateNotifyPieceMoved, (void **)&orig_NotifyPieceMoved); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[GSTATE] hooked GameStateStore.NotifyPieceMoved " @"@0x%lx (base+0x%x)", (unsigned long)addr, diff --git a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m index 760c23f..f4d1b5d 100644 --- a/Sources/KiouEngineBridge/Hook_LowLevelObserve.m +++ b/Sources/KiouEngineBridge/Hook_LowLevelObserve.m @@ -235,35 +235,14 @@ bool HookAdapterTryMakeMoveOut(void *self, SfMove move, void *outMove) { void *gameCtrl = readPtr(selfCap, ADAPTER_OFF_GAME_CONTROLLER); NSString *sfen = SfenFromGameController(gameCtrl); NSString *usi = moveToUsi((SfMove)mv_copy); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[ADAPTER2] TryMakeMove self=%p " @"usi=\"%@\" argMove={%@} sfen_after=\"%@\"", selfCap, usi ?: @"", describeMoveBits((SfMove)mv_copy), sfen ?: @""]); - // 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 (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; - } - UsiEngineOnMoveObserved(usi, sfen, sideToMove); - // MetaEmitMove はここでは出さない — ADAPTER2 は「自分のクライアント - // が指した手」しか通らない (オンライン対戦では相手手はサーバ state - // 経由で来る) ので、ここで出すと自分側 ply のみの片肺 KIF になる。 - // 両手番分の meta は Hook_GameStateStoreObserve.m の - // NotifyPieceMoved フックに集約してある。 - } + // Move observation is handled by Hook_GameStateStoreObserve.m's + // NotifyPieceMoved hook, which covers both sides. Nothing to do here. }); return ok; @@ -283,7 +262,7 @@ static bool HookGameCtrlTryMakeMove(void *self, SfMove move) { ? orig_GameCtrlTryMakeMove(self, move) : false; 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 — @@ -314,7 +293,7 @@ void InstallLowLevelObserveHook(uintptr_t unityBase) { MSHookFunction((void *)addr, (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, @@ -325,7 +304,7 @@ void InstallLowLevelObserveHook(uintptr_t unityBase) { MSHookFunction((void *)addr, (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]); @@ -340,7 +319,7 @@ void InstallLowLevelObserveHook(uintptr_t unityBase) { // covered by HookAdapterTryMakeMoveOut for every move that // reaches the board. Dropping it on binpatch saves a cave and // keeps the hook surface tight. - file_log(@"[LOWLEVEL] binpatch build — site hooks driven by cave/SLOT, " + 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 ab29970..916b989 100644 --- a/Sources/KiouEngineBridge/Hook_MatchModeObserve.m +++ b/Sources/KiouEngineBridge/Hook_MatchModeObserve.m @@ -175,7 +175,7 @@ static inline BOOL shouldLog(uint32_t n) { (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]); \ } \ @@ -245,7 +245,7 @@ static inline BOOL shouldLog(uint32_t n) { /* 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); \ - file_log([NSString stringWithFormat: \ + IPALog([NSString stringWithFormat: \ @"[MMODE] " MODE_TAG " Init self=%p store=%p adapter=%p cfg=%p", \ self, store, adapter, cfg]); \ return ret; \ @@ -288,7 +288,7 @@ static inline BOOL shouldLog(uint32_t n) { // 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 / file_log / usi / +// `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) \ @@ -303,17 +303,13 @@ static inline BOOL shouldLog(uint32_t n) { lp = readI32(selfCap, lpOffsetCap); \ (LP_CACHE) = lp; \ } \ - file_log([NSString stringWithFormat: \ + 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); \ - /* Keep the legacy USI driver and meta sidecar fed too — both are */ \ - /* no-ops via Csa_Stubs.m during the CSA migration, but the call */ \ - /* sites stay so a future revert path is mechanical. */ \ - UsiEngineOnMatchStart(lp); \ MetaEmitMatchStart(lp); \ /* Prevent the screen from dimming while a match is active. */ \ /* Already on the main queue here. */ \ @@ -444,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]); @@ -459,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]); @@ -477,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 = @@ -485,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]); } }); @@ -501,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) { @@ -509,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]); } @@ -528,11 +524,11 @@ 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]); } } @@ -551,7 +547,7 @@ static void scheduleAutoRematch(const char *modeTag) { /* 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: \ + IPALog([NSString stringWithFormat: \ @"[MMODE] " MODE_TAG " End self=%p localPlayer=%d " \ @"result=%d sfen=\"%@\"", \ self, (int)lpSnapshot, (int)result, finalSfen ?: @""]); \ @@ -566,9 +562,6 @@ static void scheduleAutoRematch(const char *modeTag) { /* Surface the result to the CSA engine driver so it can emit */ \ /* #REASON + #WIN/#LOSE/#DRAW before the next match resets state. */ \ CsaEngineOnMatchEnd(result); \ - /* Keep the legacy USI / meta paths fed too — both no-op via */ \ - /* Csa_Stubs.m during the CSA migration. */ \ - UsiEngineOnMatchEnd(result); \ MetaEmitMatchEnd(result, finalSfen, usiText); \ MetaSetMatchConfig(NULL); \ CsaSetMatchConfig(NULL); \ @@ -661,7 +654,7 @@ void InstallMatchModeObserveHook(uintptr_t unityBase) { 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]); @@ -686,7 +679,7 @@ void InstallMatchModeObserveHook(uintptr_t unityBase) { // bypass-entry path. void InstallMatchModeObserveHook(uintptr_t unityBase) { (void)unityBase; - file_log(@"[MMODE] binpatch: install is a no-op — orig_* OPM slots stay " + 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 bda3f5a..214c2a3 100644 --- a/Sources/KiouEngineBridge/Hook_OnlineObserve.m +++ b/Sources/KiouEngineBridge/Hook_OnlineObserve.m @@ -82,7 +82,7 @@ void HookUpdateAuthoritativeSnapshot(void *self, 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, @@ -90,7 +90,7 @@ void HookUpdateAuthoritativeSnapshot(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) { @@ -129,12 +129,12 @@ void HookHandleMoveResult(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); } @@ -160,14 +160,14 @@ void HookCpuStreamUpdateSnapshot(void *self, 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 HookUpdateAuthoritativeSnapshot — snapshots - // stay file_log-only and the USI engine tracks turns via ADAPTER2. + // stay IPALog-only and the USI engine tracks turns via ADAPTER2. (void)sfen; if (orig_CpuStream_UpdateSnapshot) { orig_CpuStream_UpdateSnapshot(self, sfenStr, turn, @@ -185,7 +185,7 @@ void InstallOnlineObserveHook(uintptr_t unityBase) { MSHookFunction((void *)addrSnap, (void *)HookUpdateAuthoritativeSnapshot, (void **)&orig_UpdateAuthoritativeSnapshot); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[ONLINE] hooked OnlinePvPMode.UpdateAuthoritativeSnapshot " @"@0x%lx (base+0x%x)", (unsigned long)addrSnap, @@ -195,7 +195,7 @@ void InstallOnlineObserveHook(uintptr_t unityBase) { MSHookFunction((void *)addrRes, (void *)HookHandleMoveResult, (void **)&orig_HandleMoveResult); - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[ONLINE] hooked OnlinePvPMode.HandleMoveResult " @"@0x%lx (base+0x%x)", (unsigned long)addrRes, @@ -205,7 +205,7 @@ void InstallOnlineObserveHook(uintptr_t unityBase) { MSHookFunction((void *)addrCpuSnap, (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, diff --git a/Sources/KiouEngineBridge/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index 418baf5..0a7cf73 100644 --- a/Sources/KiouEngineBridge/Inject_Move.m +++ b/Sources/KiouEngineBridge/Inject_Move.m @@ -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, @@ -646,14 +646,14 @@ 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, @@ -770,7 +770,7 @@ static void inject_runOnMain(const char *usi, uint32_t move, void *self = (SELF); \ OnPlayerMoveAsync_t fn = (ORIG); \ if (!self || !fn) { err = "no_session"; break; } \ - file_log([NSString stringWithFormat: \ + IPALog([NSString stringWithFormat: \ @"[INJECT-DBG] route=%s callable=%p orig=%p " \ @"bypass=%p", \ inject_routeName(route), (void *)fn, \ @@ -798,13 +798,13 @@ static void inject_runOnMain(const char *usi, uint32_t move, uint32_t outMv = 0; \ bool tryOk = adapterFn( \ (void *)g_adapterCache, move, &outMv); \ - file_log([NSString stringWithFormat: \ + IPALog([NSString stringWithFormat: \ @"[INJECT-DBG] local TryMakeMove tryOk=%d " \ @"outMv=0x%x adapter=%p", \ (int)tryOk, (unsigned)outMv, \ (void *)adapterFn]); \ } else { \ - file_log([NSString stringWithFormat: \ + IPALog([NSString stringWithFormat: \ @"[INJECT-DBG] local TryMakeMove skipped: " \ @"adapterCache=%p adapterFn=%p", \ g_adapterCache, (void *)adapterFn]); \ @@ -831,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]); \ } \ @@ -853,24 +853,24 @@ 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) @@ -908,7 +908,7 @@ static void inject_runOnMain(const char *usi, uint32_t move, err = "no_session"; break; } - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[INJECT-DBG] route=adapter callable=%p orig=%p bypass=%p", (void *)fn, (void *)orig_AdapterTryMakeMoveOut, @@ -956,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; @@ -1012,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]; @@ -1074,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; @@ -1092,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, @@ -1112,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; @@ -1133,19 +1133,19 @@ bool inject_apply(NSString *usi, 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]); } }); @@ -1155,7 +1155,7 @@ bool inject_apply(NSString *usi, // from a Unity callback. 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"); } @@ -1190,7 +1190,7 @@ bool inject_apply(NSString *usi, void InstallInjectHook(uintptr_t unityBase) { if (g_SunfishMoveDrop) { - file_log(@"[INJECT] install: already initialized, skipping"); + IPALog(@"[INJECT] install: already initialized, skipping"); return; } @@ -1224,7 +1224,7 @@ void InstallInjectHook(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 index 6765285..612e12b 100644 --- a/Sources/KiouEngineBridge/Inject_Resign.m +++ b/Sources/KiouEngineBridge/Inject_Resign.m @@ -42,12 +42,12 @@ void InjectResign(int32_t playerSide) { resolve_request_surrender(); void *orch = g_gameOrchestratorCache; if (!orch || !g_RequestSurrender) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[RESIGN] cannot resign player=%d: orch=%p fn=%p", (int)playerSide, orch, g_RequestSurrender]); return; } - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[RESIGN] invoking GameOrchestrator.RequestSurrender " @"(player=%d, orch=%p)", (int)playerSide, orch]); @@ -55,14 +55,14 @@ void InjectResign(int32_t playerSide) { @try { g_RequestSurrender(orch); } @catch (NSException *e) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[RESIGN] threw: %@", e]); } }); } void InjectNyugyokuDeclaration(int32_t playerSide) { - file_log([NSString stringWithFormat: + 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/Meta_Emitter.m b/Sources/KiouEngineBridge/Meta_Emitter.m index 598c107..5e88b9b 100644 --- a/Sources/KiouEngineBridge/Meta_Emitter.m +++ b/Sources/KiouEngineBridge/Meta_Emitter.m @@ -289,18 +289,18 @@ 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]); + IPALog([NSString stringWithFormat:@"[META>] %@", body]); KEBWsServerPush(line); } @@ -352,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, @@ -387,7 +387,7 @@ void MetaEmitMatchStart(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]); @@ -420,7 +420,7 @@ void MetaOnPlayerInfoSet(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, diff --git a/Sources/KiouEngineBridge/Server_CSA.m b/Sources/KiouEngineBridge/Server_CSA.m index d51beb7..96eee30 100644 --- a/Sources/KiouEngineBridge/Server_CSA.m +++ b/Sources/KiouEngineBridge/Server_CSA.m @@ -119,18 +119,18 @@ static void csa_client_recv_loop(int fd) { ssize_t n = recv(fd, chunk, sizeof(chunk), 0); if (n < 0) { if (errno == EINTR) continue; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA] recv errno=%d, exiting loop", errno]); break; } if (n == 0) { - file_log(@"[CSA] peer closed connection"); + IPALog(@"[CSA] peer closed connection"); break; } [acc appendBytes:chunk length:(NSUInteger)n]; if (acc.length > CSA_LINE_MAX) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA] line buffer overflowed %d bytes, closing", CSA_LINE_MAX]); break; @@ -160,15 +160,15 @@ static void csa_client_recv_loop(int fd) { withBytes:NULL length:0]; if (line == nil) { - file_log(@"[CSA] dropped non-UTF8 line"); + IPALog(@"[CSA] dropped non-UTF8 line"); continue; } - file_log([NSString stringWithFormat:@"[CSA<] %@", line]); + IPALog([NSString stringWithFormat:@"[CSA<] %@", line]); if (g_lineHandler) g_lineHandler(line); } } - file_log(@"[CSA] client recv loop exited"); + IPALog(@"[CSA] client recv loop exited"); dispatch_async(g_acceptQueue, ^{ csa_close_client(); }); @@ -183,7 +183,7 @@ static void csa_handle_accept(void) { int fd = accept(g_listenFd, (struct sockaddr *)&peer, &peerLen); if (fd < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA] accept errno=%d", errno]); } return; @@ -196,14 +196,14 @@ static void csa_handle_accept(void) { // otherwise reject the new peer; CSA engines tend to reconnect after // crashes, and we want the most recent connect to be authoritative. if (g_clientFd >= 0) { - file_log([NSString stringWithFormat: + 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(); } - file_log([NSString stringWithFormat:@"[CSA] accepted from %s:%u fd=%d", + 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 @@ -225,7 +225,7 @@ static void csa_handle_accept(void) { // --------------------------------------------------------------------------- void KEBCsaServerStart(uint16_t port) { if (g_listenFd >= 0) { - file_log(@"[CSA] server already running"); + IPALog(@"[CSA] server already running"); return; } @@ -236,7 +236,7 @@ void KEBCsaServerStart(uint16_t port) { int s = socket(AF_INET, SOCK_STREAM, 0); if (s < 0) { - file_log([NSString stringWithFormat:@"[CSA] socket errno=%d", errno]); + IPALog([NSString stringWithFormat:@"[CSA] socket errno=%d", errno]); return; } @@ -248,13 +248,13 @@ void KEBCsaServerStart(uint16_t port) { 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:@"[CSA] bind errno=%d port=%u", + IPALog([NSString stringWithFormat:@"[CSA] bind errno=%d port=%u", errno, (unsigned)port]); close(s); return; } if (listen(s, 4) < 0) { - file_log([NSString stringWithFormat:@"[CSA] listen errno=%d", errno]); + IPALog([NSString stringWithFormat:@"[CSA] listen errno=%d", errno]); close(s); return; } @@ -268,7 +268,7 @@ void KEBCsaServerStart(uint16_t port) { }); dispatch_resume(g_listenSrc); - file_log([NSString stringWithFormat:@"[CSA] listening on 0.0.0.0:%u", + IPALog([NSString stringWithFormat:@"[CSA] listening on 0.0.0.0:%u", (unsigned)port]); } @@ -287,7 +287,7 @@ void KEBCsaServerPush(NSString *line) { if (g_pendingSends >= CSA_QUEUE_DROP_THRESHOLD) { if ((g_pendingSends % 32) == 0) { - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"[CSA] drop: backlog=%lu", (unsigned long)g_pendingSends]); } @@ -309,7 +309,7 @@ void KEBCsaServerPush(NSString *line) { BOOL ok = csa_send_all(fd, data.bytes, data.length); g_pendingSends--; if (!ok) { - file_log(@"[CSA] send failed, dropping client"); + IPALog(@"[CSA] send failed, dropping client"); csa_close_client(); } }); @@ -319,7 +319,7 @@ void KEBCsaServerClose(void) { if (!g_acceptQueue) return; dispatch_async(g_acceptQueue, ^{ if (g_clientFd >= 0) { - file_log(@"[CSA] KEBCsaServerClose: tearing down client"); + IPALog(@"[CSA] KEBCsaServerClose: tearing down client"); csa_close_client(); } }); diff --git a/Sources/KiouEngineBridge/Tweak.m b/Sources/KiouEngineBridge/Tweak.m index 80625d3..fc155c9 100644 --- a/Sources/KiouEngineBridge/Tweak.m +++ b/Sources/KiouEngineBridge/Tweak.m @@ -47,7 +47,7 @@ static void installUnityHooks(void) { g_unityBase = unityBase; - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"UnityFramework base=0x%lx (%s)", (unsigned long)unityBase, unityName ? unityName : "?"]); @@ -98,7 +98,7 @@ static void installUnityHooks(void) { #endif g_unityHooked = YES; - file_log(@"=== KiouEngineBridge: all hooks installed ==="); + IPALog(@"=== KiouEngineBridge: all hooks installed ==="); } static void retryInstallHooks(void) { @@ -113,8 +113,8 @@ static void retryInstallHooks(void) { } __attribute__((constructor)) static void init(void) { - logging_init("com.neconome.shogi.kiouenginebridge"); - file_log(@"=== KiouEngineBridge loaded ==="); + 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). @@ -125,7 +125,7 @@ static void retryInstallHooks(void) { #else static const char *const kBuildFlavor = "jb"; #endif - file_log([NSString stringWithFormat: + IPALog([NSString stringWithFormat: @"build commit=%s flavor=%s built=%s %s", KIOU_ENGINE_BRIDGE_COMMIT, kBuildFlavor, __DATE__, __TIME__]); @@ -146,5 +146,5 @@ static void retryInstallHooks(void) { retryInstallHooks(); }); - file_log(@"=== KiouEngineBridge constructor done ==="); + IPALog(@"=== KiouEngineBridge constructor done ==="); } diff --git a/recipes/kiouenginebridge.py b/recipes/kiouenginebridge.py index 4458751..8c1aec4 100644 --- a/recipes/kiouenginebridge.py +++ b/recipes/kiouenginebridge.py @@ -144,6 +144,9 @@ "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, } From da9c3b90be7058e529b5821a1df1c914b76e2342 Mon Sep 17 00:00:00 2001 From: tkgstrator Date: Wed, 17 Jun 2026 11:42:02 +0000 Subject: [PATCH 51/51] fix(review): address Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server_CSA.m: - Make g_clientFd / g_pendingSends atomic (_Atomic int / _Atomic uint32_t) so concurrent reads from the recv queue and writes from the accept queue are race-free without a serial gate. - Fix header comment: drop policy is "drop new line", not "drop oldest". Csa_Engine.m: - Fix %TORYO result: the CSA engine IS the local player, so %TORYO means the local seat surrenders → #LOSE (was incorrectly #WIN). Pass lp to InjectResign instead of the engine-opposite seat. - Fix %KACHI: InjectNyugyokuDeclaration should take the local seat (lp), not the engine-opposite. - CsaEngineOnMatchEnd: skip result block when state is already GAME_OVER (%TORYO / %KACHI / %CHUDAN already sent it) to prevent duplicate or contradictory #WIN/#LOSE lines. Inject_Move.m: - Guard usleep with [NSThread isMainThread] check so the delay is skipped when inject_apply is called from the main thread (CSA path), avoiding a potential UI block if INJECT_ANIMATION_DELAY_SEC is ever raised above 0. docs/csa_compatibility.md: - Correct AGREE / START rows to reflect that KEB sends START immediately after Game_Summary without waiting for AGREE (KIOU's CPU can't wait). - Correct %TORYO row: result is #LOSE (engine/local seat surrenders), not #WIN. Co-Authored-By: Claude Opus 4.7 --- Sources/KiouEngineBridge/Csa_Engine.m | 26 +++++++++------- Sources/KiouEngineBridge/Inject_Move.m | 13 ++++---- Sources/KiouEngineBridge/Server_CSA.m | 41 +++++++++++++------------- docs/csa_compatibility.md | 6 ++-- 4 files changed, 47 insertions(+), 39 deletions(-) diff --git a/Sources/KiouEngineBridge/Csa_Engine.m b/Sources/KiouEngineBridge/Csa_Engine.m index a41efd1..b9a0a46 100644 --- a/Sources/KiouEngineBridge/Csa_Engine.m +++ b/Sources/KiouEngineBridge/Csa_Engine.m @@ -336,19 +336,18 @@ static void csa_handle_special(NSString *line) { int32_t lp = atomic_load(&g_csaLocalPlayer); if ([line isEqualToString:@"%TORYO"]) { - // The CSA engine resigns — meaning the local KIOU side wins. - // Convert that to a KIOU-side resign call against the engine's - // seat (= the opposite of the local player). - int32_t enginePlayer = (lp == 0) ? 1 : (lp == 1) ? 0 : -1; - InjectResign(enginePlayer); + // 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(@"#WIN"); + CsaEngineSendLine(@"#LOSE"); csa_set_state(CSA_STATE_GAME_OVER); return; } if ([line isEqualToString:@"%KACHI"]) { - int32_t enginePlayer = (lp == 0) ? 1 : (lp == 1) ? 0 : -1; - InjectNyugyokuDeclaration(enginePlayer); + // The engine declares nyugyoku for the local seat — local wins. + InjectNyugyokuDeclaration(lp); CsaEngineSendLine(@"#JISHOGI"); CsaEngineSendLine(@"#WIN"); csa_set_state(CSA_STATE_GAME_OVER); @@ -659,9 +658,14 @@ void CsaEngineOnMatchEnd(usi_match_result_t result) { @"[CSA-ENG] match_end result=%d state=%s", (int)result, csa_state_name(s)]); - NSString *resultLines = CsaBuildMatchResult(result); - if (resultLines.length > 0) { - CsaEngineSendBlock(resultLines); + // 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); diff --git a/Sources/KiouEngineBridge/Inject_Move.m b/Sources/KiouEngineBridge/Inject_Move.m index 0a7cf73..5106edf 100644 --- a/Sources/KiouEngineBridge/Inject_Move.m +++ b/Sources/KiouEngineBridge/Inject_Move.m @@ -1149,11 +1149,14 @@ bool inject_apply(NSString *usi, @"[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 { IPALog(@"[INJECT-DBG] PlayMoveAnimation skipped: " @"fn or orch cache missing"); diff --git a/Sources/KiouEngineBridge/Server_CSA.m b/Sources/KiouEngineBridge/Server_CSA.m index 96eee30..96555bd 100644 --- a/Sources/KiouEngineBridge/Server_CSA.m +++ b/Sources/KiouEngineBridge/Server_CSA.m @@ -5,6 +5,7 @@ #import #import #import +#import #import #import @@ -26,7 +27,7 @@ // 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 oldest pending line and log a [CSA] warning. +// drop the new line and log a [CSA] warning. // // What it deliberately doesn't do: // - TLS. CSA's TCP transport is unencrypted by design. @@ -52,8 +53,8 @@ static dispatch_queue_t g_recvQueue = NULL; static dispatch_source_t g_listenSrc = NULL; static int g_listenFd = -1; -static int g_clientFd = -1; -static NSUInteger g_pendingSends = 0; +static _Atomic int g_clientFd = -1; +static _Atomic uint32_t g_pendingSends = 0; static kiou_csa_line_handler_t g_lineHandler = NULL; @@ -92,12 +93,12 @@ static BOOL csa_send_all(int fd, const uint8_t *buf, size_t len) { } static void csa_close_client(void) { - bool wasUp = (g_clientFd >= 0); - if (g_clientFd >= 0) { - close(g_clientFd); - g_clientFd = -1; + int fd = atomic_exchange(&g_clientFd, -1); + bool wasUp = (fd >= 0); + if (fd >= 0) { + close(fd); } - g_pendingSends = 0; + 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 @@ -195,7 +196,7 @@ static void csa_handle_accept(void) { // 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 (g_clientFd >= 0) { + if (atomic_load(&g_clientFd) >= 0) { IPALog([NSString stringWithFormat: @"[CSA] preempt: closing prior client fd=%d to make room " @"for %s:%u", @@ -212,7 +213,7 @@ static void csa_handle_accept(void) { if (flags >= 0) fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); csa_set_keepalive(fd); - g_clientFd = fd; + atomic_store(&g_clientFd, fd); CsaEngineOnTcpClientConnected(); dispatch_async(g_recvQueue, ^{ @@ -283,31 +284,31 @@ void KEBCsaServerSetLineHandler(kiou_csa_line_handler_t fn) { void KEBCsaServerPush(NSString *line) { if (!line) return; if (!g_acceptQueue) return; // server never started - if (g_clientFd < 0) return; // no client attached + if (atomic_load(&g_clientFd) < 0) return; // no client attached - if (g_pendingSends >= CSA_QUEUE_DROP_THRESHOLD) { - if ((g_pendingSends % 32) == 0) { + uint32_t pending = atomic_load(&g_pendingSends); + if (pending >= CSA_QUEUE_DROP_THRESHOLD) { + if ((pending % 32) == 0) { IPALog([NSString stringWithFormat: - @"[CSA] drop: backlog=%lu", - (unsigned long)g_pendingSends]); + @"[CSA] drop: backlog=%u", pending]); } return; } - g_pendingSends++; + atomic_fetch_add(&g_pendingSends, 1); NSString *withNewline = [line hasSuffix:@"\n"] ? [line copy] : [line stringByAppendingString:@"\n"]; dispatch_async(g_acceptQueue, ^{ - int fd = g_clientFd; + int fd = atomic_load(&g_clientFd); if (fd < 0) { - g_pendingSends--; + atomic_fetch_sub(&g_pendingSends, 1); return; } NSData *data = [withNewline dataUsingEncoding:NSUTF8StringEncoding]; BOOL ok = csa_send_all(fd, data.bytes, data.length); - g_pendingSends--; + atomic_fetch_sub(&g_pendingSends, 1); if (!ok) { IPALog(@"[CSA] send failed, dropping client"); csa_close_client(); @@ -318,7 +319,7 @@ void KEBCsaServerPush(NSString *line) { void KEBCsaServerClose(void) { if (!g_acceptQueue) return; dispatch_async(g_acceptQueue, ^{ - if (g_clientFd >= 0) { + if (atomic_load(&g_clientFd) >= 0) { IPALog(@"[CSA] KEBCsaServerClose: tearing down client"); csa_close_client(); } diff --git a/docs/csa_compatibility.md b/docs/csa_compatibility.md index 1f28145..b881f02 100644 --- a/docs/csa_compatibility.md +++ b/docs/csa_compatibility.md @@ -41,9 +41,9 @@ KEB follows it exactly, with the local KIOU side filling the server role. | 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 []` | Triggers `START:`. The optional id is accepted but not validated. | ✅ | +| 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 after AGREE. | ✅ | +| 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 @@ -83,7 +83,7 @@ arrives. KEB follows the same pattern from both directions. |---|---|---|---| | 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` + `#WIN`, calls `GameOrchestrator.RequestSurrender` on the main thread (`Inject_Resign.m`). | ⚠️ surrenders the local seat | +| 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. | ✅ |