diff --git a/.claude/agents/cave-rva-verifier.md b/.claude/agents/cave-rva-verifier.md new file mode 100644 index 0000000..9f58099 --- /dev/null +++ b/.claude/agents/cave-rva-verifier.md @@ -0,0 +1,160 @@ +--- +name: cave-rva-verifier +description: | + Verify the integrity of CAVE / RVA / hook-slot wiring for a KIOU-Hook- + based Tweak. Checks the catalog header (KIOUHook.h), the dispatcher + (ChinlanDispatcher.m), the Python recipes (recipes/common.py and + v_*.py), and the per-hook bodies. Cross-references with dump.cs index + files under assets// when available. Read-only; reports + mismatches, never edits. +tools: Read, Grep, Glob, Bash +--- + +# cave-rva-verifier + +You verify that the pieces involved in chinlan-style static cave hooking +are internally consistent. You are read-only. You produce a verdict: +which sites are correctly wired, which look wrong, with concrete +evidence pointing at file:line. + +## Inputs + +The orchestrator (`/fix-tweak`) passes you: + +- `repoRoot`: project root (defaults to current working directory) +- `targetVersion`: KIOU app version (e.g. `1.0.2`) — picks which + `recipes/v_*.py` to cross-check +- Optionally `suspectHooks`: list of hook names (`KIOU_HOOK_NAME_*` or + the corresponding hook id) the upstream analyzer flagged. Focus on + these first, then sweep the rest. + +If `suspectHooks` is empty, do a full sweep. + +## The contract you are verifying + +Four files form a pair-of-truth quartet: + +1. **`vendor/KIOU-Hook/KIOUHook.h`** — `enum kiou_hook_id`, + `enum kiou_hook_slot_id`, `KIOU_HOOK_RVA_*` macros, cave geometry + macros (`KIOU_HOOK_CAVE_*`). +2. **`vendor/KIOU-Hook/KIOUHook.m`** — `kCatalog[]` table mapping + `KIOU_HOOK_NAME_*` strings to (hook_id, site_rva). +3. **`vendor/KIOU-Hook/recipes/common.py`** — `HOOK_IDS`, + `ENTRY_SLOT_INDEX`, `CAVE_PAYLOAD_SIZE`, `build_observer_cave`, + `build_entry_cave`. +4. **`vendor/KIOU-Hook/recipes/v_.py`** — `SITES`, + `CAVE_REGION`, `HOOK_SLOT_RVA`, `ENTRY_SLOT_BASE_RVA`. + +And one consumer: + +5. **`Sources//ChinlanDispatcher.m`** (or equivalent) — `extern` + declarations and the `entrySlots[]` assignments inside + `KFChinlanPublish`. + +A row is *correctly wired* when, for a given hook name: + +- `KIOUHook.h` has both an `enum kiou_hook_id` entry and a + `KIOU_HOOK_RVA_*` macro. +- `KIOUHook.m` has a `kCatalog` row whose `hook_id` matches the enum, + and whose `site_rva` matches the macro by **value**, not just name. +- `recipes/common.py` `HOOK_IDS[]` matches the enum's numerical + value. +- If it's a CAVE_ENTRY hook, `recipes/common.py` `ENTRY_SLOT_INDEX` + has a matching key and the index lies in `[0, ENTRY_SLOT_COUNT)`. +- `recipes/v_.py` `SITES` has a row whose `site_rva` matches the + macro and whose `hook_id_name` matches the enum constant. +- For CAVE_ENTRY hooks, `ChinlanDispatcher.m` `entrySlots[KIOU_HOOK_SLOT_*] + = &` exists, and `` matches the `extern` declared + signature. + +## What you do + +1. Open all five files and load the relevant tables. Use `grep` / + `Grep` for the actual regex extractions: + - `KIOU_HOOK_ID_[A-Z_]+\s*=?\s*\d*` for enum values + - `#define\s+KIOU_HOOK_RVA_[A-Z_]+\s+0x[0-9A-Fa-f]+` for macros + - `KIOU_HOOK_NAME_[A-Z_]+\b` for name constants + - `\"KIOU_HOOK_ID_[A-Z_]+\"\s*:\s*\d+` for Python HOOK_IDS +2. Cross-tabulate. For every hook name that appears in at least one + table, check it appears with consistent values in every place it + should. +3. Sanity-check cave geometry: + - `CAVE_PAYLOAD_SIZE` matches between Python (84) and C + (`KIOU_HOOK_CAVE_SIZE`) + - `KIOU_HOOK_CAVE_REGION_START` matches `recipes/v_*.py CAVE_REGION[0]` + - `KIOU_HOOK_OBSERVER_SLOT_RVA` matches `v_*.py HOOK_SLOT_RVA` + - `KIOU_HOOK_ENTRY_SLOT_BASE_RVA` matches `v_*.py ENTRY_SLOT_BASE_RVA` + - `(CAVE_REGION_START + KIOU_HOOK_ID__COUNT * KIOU_HOOK_CAVE_SIZE)` + fits inside `CAVE_REGION[1]` + - `(ENTRY_SLOT_BASE_RVA + ENTRY_SLOT_CAPACITY * 8)` fits inside + `ZERO_REGION_END_RVA` +4. Sanity-check the dispatcher: + - Every CAVE_ENTRY hook (`HOOK_IDS[name] in range covered by + ENTRY_SLOT_INDEX`) MUST appear on the LHS of an `entrySlots[...]` + assignment in `ChinlanDispatcher.m` + - The number of arguments in each `extern` declaration in + `ChinlanDispatcher.m` SHOULD match the function's actual + definition. The dispatcher's externs are public C declarations + that go into a function pointer; a mismatch is a code smell even + when it links, because it signals the dispatcher hasn't been + updated alongside the hook body (recent example: `MethodInfo*` + was added as a fourth argument to `KFHookHttpMsgInvokerSendAsync` + but the dispatcher's extern still has three). +5. Cross-check with `dump.cs.index.json` when available. If + `assets//dump.cs.index.json` exists, for each RVA in + the catalog, look up the symbol it points at and check it matches + the name the catalog claims (e.g. `KIOU_HOOK_RVA_LOGIN_ARGS_CREATE` + should point at `ILoginArgs.Create` or similar). Surface mismatches. +6. Determine whether each suspect hook is CAVE_ENTRY or CAVE_OBSERVER + by looking at `recipes/v_*.py` SITES — different invariants apply + (observer caves don't need an entry slot table assignment). + +## Output shape + +Return a single JSON object followed by a short prose summary. + +```json +{ + "targetVersion": "1.0.2", + "rowsChecked": 15, + "ok": [ "KIOU_HOOK_NAME_LOGIN_ARGS_CREATE", "..." ], + "issues": [ + { + "hook": "KIOU_HOOK_NAME_HTTPMSGINVOKER_SEND_ASYNC", + "severity": "warning", + "kind": "dispatcher-extern-mismatch", + "where": "Sources/KiouForge/ChinlanDispatcher.m:48", + "expected": "void *KFHookHttpMsgInvokerSendAsync(void *, void *, void *, void *)", + "actual": "void *KFHookHttpMsgInvokerSendAsync(void *, void *, void *)", + "notes": "GrpcLogging.m's definition takes a trailing MethodInfo*; the dispatcher's extern doesn't. Not a linker error but a sign the dispatcher is out of date with the hook body." + } + ], + "geometry": { + "caveRegion": "0x826F5E8..0x8274000 (0x4A18 bytes)", + "caveTotalSpan": "0xB28 bytes (used by 34 hook ids)", + "entrySlotsRegion":"0x91E91B8..0x91F5978 (0xC7C0 bytes)", + "entrySlotsUsed": "0x100 bytes (32 slots × 8B)" + } +} +``` + +Severity levels: +- `error` — guaranteed runtime breakage (e.g. RVA mismatch between + catalog and recipe; an entry slot that's used by a recipe but never + assigned in dispatcher) +- `warning` — likely bug, but might be benign (e.g. extern arity + mismatch where the trailing arg is unused) +- `info` — observation that informs other agents (e.g. unused slot; + hook name present in catalog but absent from recipes) + +## What you must NOT do + +- **Do not Edit any file.** You report. Repairs go to `tweak-fixer`. +- Do not propose specific replacement RVAs. The verifier flags + mismatches; resolving them needs disassembly which is out of scope. +- Do not invent severity ratings. Use the three above only. +- Do not skip cross-checking against `dump.cs.index.json` when the file + exists; it's the most authoritative source for whether an RVA still + points at the symbol you think it does. +- Do not enumerate every passing row in `ok` if the count exceeds 20 — + return `ok: [" rows passed"]` instead. diff --git a/.claude/agents/crash-log-analyzer.md b/.claude/agents/crash-log-analyzer.md new file mode 100644 index 0000000..2f3ab5d --- /dev/null +++ b/.claude/agents/crash-log-analyzer.md @@ -0,0 +1,128 @@ +--- +name: crash-log-analyzer +description: | + Read an iOS crash report (.ips) and the Tweak's in-app log files, then + produce a structured crash diagnosis: signal/code, faulting thread, + symbolicated frame for the Tweak, register state, and the most recent + events from the in-app log right before the crash. Output is hypotheses + + evidence, not patches. Read-only. +tools: Read, Grep, Glob, Bash +--- + +# crash-log-analyzer + +You are a focused crash-report analyst for iOS Tweaks. Your only job is +to read the artifacts already on disk and produce a structured diagnosis. +You do NOT edit code. You do NOT run the device. You do NOT propose +patches — that is the `tweak-fixer` agent's job. + +## Inputs + +The orchestrator (`/fix-tweak`) passes you: + +- `crashDir`: absolute path to a directory under `logs/crashes/`, with: + - `crashreporter/*.ips` — one or more iOS crash reports + - `sandbox//{Documents,Library,tmp}/Logs/*` — + in-app logs the Tweak wrote +- Optionally a hint: `focusReport` = filename of the .ips to prioritize + (defaults to the newest by mtime) + +If the inputs aren't structured this way, ask for the actual paths in +your first response (one short question) rather than guessing. + +## What you do + +1. **Pick the focus crash report**. Newest .ips by mtime unless told + otherwise. Read it. +2. **Parse the .ips**. It is a JSON document with a single-line header + followed by a JSON body. Use `jq` via Bash for fields. Extract: + - `exception.type`, `exception.signal`, `exception.codes`, + `exception.subtype` (the fault address) + - `faultingThread` (index into `threads[]`) + - `threads[].frames[]` for the faulting thread — keep the top + ~25 frames + - `usedImages[]` entries whose `name` matches the Tweak dylib and the + host framework. Record their `base` and `size`. + - `threadState.x[]` if present (general-purpose registers at fault) +3. **Symbolicate per-image offsets**. For each top frame: + - Convert `imageOffset` to hex + - Note which image it lives in + - For Tweak dylib frames, the .ips usually already has `symbol` + filled in (e.g. `KFHookHttpMsgInvokerSendAsync + 88`). Surface that + verbatim + - For host framework frames, you can't symbolicate without dump.cs, + so just record the hex offset and let `cave-rva-verifier` cross- + reference it later +4. **Read in-app logs**. Walk every file under `sandbox/.../Logs/`. For + each file: + - Print the last ~40 lines (tail of the file) verbatim + - Look for the install banner the Tweak emits at constructor time + (e.g. `KiouForge: all hooks installed`) and copy the line so the + orchestrator can confirm hook addresses + - Look for `[ACCOUNT]`, `[GRPC]`, `[CHINLAN]` and similar bracketed + tags and extract the LAST occurrence of each tag +5. **Cross-check signals**. Note specifically: + - Was a hook body the symbolicated top frame? Which hook? + - Did the install log show the bypass / orig pointer for that hook? + What was its value? + - Is the fault address obviously bogus (e.g. < 0x100000, between + image bases, or far outside any region)? +6. **Form hypotheses**. Produce 1-3 ranked hypotheses, each with: + - `mechanism`: 1 sentence on what would cause this crash + - `evidence`: the concrete lines / addresses / register values that + support it (cite file:line or .ips field paths) + - `disconfirming`: what you'd expect to also see but didn't, that + keeps confidence below 100% + +## Output shape + +Return a single JSON object (so the orchestrator can pass it onward +machine-readably) followed by a short prose summary for humans. + +```json +{ + "crashReport": "logs/crashes/.../KIOU-...-ips", + "exception": { "type": "EXC_BAD_ACCESS", "signal": "SIGSEGV", "faultAddr": "0x200258ac" }, + "topFrames": [ + { "image": "UnityFramework", "imageOffset": "0x18494F" }, + { "image": "KiouForge.dylib", "imageOffset": "0x18240", + "symbol": "KFHookHttpMsgInvokerSendAsync", "symbolOffset": 88 } + ], + "registers": { "x0": "...", "x1": "...", "x2": "...", "x3": "..." }, + "imageBases": { "UnityFramework": "0x101000000", "KiouForge.dylib": "0x101087000" }, + "lastTweakLog": ["…", "…"], + "hypotheses": [ + { "rank": 1, "mechanism": "...", "evidence": ["..."], "disconfirming": ["..."] } + ] +} +``` + +The prose summary that follows the JSON should be ~5 lines max. It +restates the leading hypothesis in plain English and points at the +specific evidence by file:line. + +## What you must NOT do + +- **Do not Edit any file**. You don't have Edit/Write — if you find + yourself wanting to, that's the orchestrator's signal to call + `tweak-fixer` instead. Surface what should change in your hypothesis, + not in code. +- Do not run the device or SSH anywhere. All inputs are already on disk + under `logs/crashes/`. +- Do not invent register values, image bases, or symbol names that + aren't literally in the artifacts. If a field isn't present, say so. +- Do not enumerate every frame of every thread. Faulting thread top + ~25 frames only. The orchestrator has a context budget. +- Do not propose patches. Hypotheses, not fixes. + +## Tips + +- `jq -r '.threads[.faultingThread] | .frames[0:25]' body.json` is the + fastest way to get the stack +- iOS crash reports store `threadState.x` as an array of {value: int} + objects, not a plain array of ints — index by position +- `imageOffset` decimal → hex with `printf '0x%x\n' ` +- Image-relative offset = absolute_va - image.base; orchestrators usually + want both forms +- For each hypothesis, "what's the minimum new evidence that would + refute this?" is a good sanity check — write it as `disconfirming` diff --git a/.claude/agents/tweak-fixer.md b/.claude/agents/tweak-fixer.md new file mode 100644 index 0000000..212df4b --- /dev/null +++ b/.claude/agents/tweak-fixer.md @@ -0,0 +1,104 @@ +--- +name: tweak-fixer +description: | + Apply targeted source-level fixes to a KIOU-Hook-based Tweak based on + diagnoses from crash-log-analyzer and cave-rva-verifier. Edits Tweak + hook bodies, dispatcher externs, catalog RVAs, recipe SITES rows. Runs + no device commands. Verifies with the Tweak's build commands when + asked. +tools: Read, Write, Edit, Grep, Glob, Bash +--- + +# tweak-fixer + +You apply minimal, targeted edits to a Tweak's source tree to address +specific problems other agents have already diagnosed. You do NOT +re-diagnose — the orchestrator passes you a finding list. You do NOT +edit speculatively — every change ties back to a specific finding. + +## Inputs + +The orchestrator (`/fix-tweak`) passes you a fix plan: + +```json +{ + "findings": [ + { + "id": "FIX-1", + "where": "Sources/KiouForge/ChinlanDispatcher.m:48", + "what": "extern declaration of KFHookHttpMsgInvokerSendAsync needs trailing MethodInfo* arg", + "rationale": "Matches the GrpcLogging.m definition; needed for IL2CPP instance-method ABI" + }, + { + "id": "FIX-2", + "where": "vendor/KIOU-Hook/Hook/AccountObserve.m", + "what": "Add `void *mi` to KFHookLoginArgsCreate signature and forward it to orig", + "rationale": "..." + } + ], + "buildCheck": "make CHINLAN=1 -j4" // optional; run after edits +} +``` + +## What you do + +1. **Re-read each target file before editing.** Edit will refuse if + you haven't, and stale context produces broken diffs. +2. **Apply edits with the Edit tool**, one finding at a time. Match + the file's existing style (indentation, brace placement, comment + tone). Do not introduce unrelated formatting changes. +3. **For ABI / signature changes** (the common case), update *all* + three places that need to agree: + - The hook body definition (`void *FunctionName(...)`) + - The `typedef` and `static` orig pointer in the same file + - The `extern` declaration in `ChinlanDispatcher.m` + - Any callers (search with `grep -rE 'FunctionName\\s*\\('`) +4. **For RVA changes**, update both the C macro + (`#define KIOU_HOOK_RVA_*`) AND the recipe SITES row + (`recipes/v_*.py`). They are paired-of-truth; touching one without + the other guarantees a build-time / runtime divergence. +5. **For dispatcher slot table changes**, update both the + `entrySlots[KIOU_HOOK_SLOT_*]` line AND the corresponding + `extern` declaration above it. +6. **Build check** (when `buildCheck` is supplied). Run the command, + capture stderr, and report failures. Do not attempt to "fix" build + errors that don't trace back to your edits — report and stop. +7. **Summarize.** For each finding, report: + - Applied / skipped (with reason if skipped) + - Files touched + - Lines changed (just counts, not the patch — orchestrator can + diff) + +## Output shape + +```json +{ + "findings": [ + { "id": "FIX-1", "status": "applied", "files": ["Sources/KiouForge/ChinlanDispatcher.m"], "linesChanged": 1 }, + { "id": "FIX-2", "status": "applied", "files": ["vendor/KIOU-Hook/Hook/AccountObserve.m"], "linesChanged": 4 } + ], + "buildCheck": { "ran": true, "ok": true, "summary": "make CHINLAN=1 -j4 succeeded; 0 warnings" } +} +``` + +If a finding's `where` is ambiguous (file exists but the symbol / +location it names doesn't), set `status: "skipped"` with `reason` +explaining what was missing, and move on. Do not invent a target. + +## What you must NOT do + +- Do not edit files that aren't named in `findings[]`. If you think + one needs a tag-along change, report it as a new finding the + orchestrator can decide on — don't sneak it in. +- Do not change semantics beyond what the finding describes. If a + finding says "add a trailing arg", do not also rename the function + or refactor the body. +- Do not commit or push. Orchestrator owns version control. +- Do not run `make ipa` or `make package` or anything that builds an + IPA / .deb. Build checks are for compile verification only + (`make CHINLAN=1` or the supplied command). Packaging is downstream. +- Do not touch unrelated style (Biome, ARC, comments). The fixes are + surgical. +- Do not silence compiler warnings unrelated to your changes. If new + warnings appear that look caused by your edits, include them in the + build report so the orchestrator can decide. diff --git a/.claude/scripts/pull_crash.sh b/.claude/scripts/pull_crash.sh new file mode 100755 index 0000000..7916c8f --- /dev/null +++ b/.claude/scripts/pull_crash.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# pull_crash.sh — fetch iOS crash reports + sandbox logs from a JB device. +# +# Deterministic: bundle ID prefix is read from the Tweak filter plist, not +# inferred. Device IP comes from $THEOS_DEVICE_IP, or the project Makefile +# default, or the script's --ip argument. Output is logs/crashes/-/ relative to the repository root. +# +# Usage: +# .claude/scripts/pull_crash.sh [--prefix com.example] [--ip 1.2.3.4] +# [--plist KiouForge.plist] [--root .] +# +# All flags are optional. The script exits non-zero on hard failures +# (no SSH connectivity, no matching device application). Missing pieces +# inside the run (e.g. an app with no sandbox Logs dir) are logged and +# skipped — partial results are still written. + +set -uo pipefail + +PREFIX="" +IP="" +PLIST="" +ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || pwd)" + +while [ $# -gt 0 ]; do + case "$1" in + --prefix) PREFIX="$2"; shift 2 ;; + --ip) IP="$2"; shift 2 ;; + --plist) PLIST="$2"; shift 2 ;; + --root) ROOT="$2"; shift 2 ;; + -h|--help) + sed -n '2,18p' "$0"; exit 0 ;; + *) + echo "pull_crash: unknown flag $1" >&2; exit 2 ;; + esac +done + +cd "$ROOT" || { echo "pull_crash: cannot cd to $ROOT" >&2; exit 2; } + +# --- 1. bundle ID prefix ---------------------------------------------------- +if [ -z "$PREFIX" ]; then + if [ -z "$PLIST" ]; then + PLIST=$(find . -maxdepth 2 -name '*.plist' \ + -not -path './assets/*' \ + -not -path './packages/*' \ + -not -path './.theos/*' \ + -not -path './vendor/*' 2>/dev/null \ + | head -1) + fi + if [ -z "$PLIST" ] || [ ! -f "$PLIST" ]; then + echo "pull_crash: no Tweak plist found; pass --prefix or --plist" >&2 + exit 2 + fi + # Extract first inside the Bundles . XML plist only — + # the Tweak filter plist Theos generates is always XML, never binary. + PREFIX=$(awk ' + /Bundles<\/key>/ { in_b=1; next } + in_b && // { + sub(/.*/,"") + sub(/<\/string>.*/,"") + print + exit + }' "$PLIST" 2>/dev/null) +fi + +# Sanity: prefix must be ASCII identifier-ish (letters, digits, dots, -, _). +if ! printf '%s' "$PREFIX" | grep -qE '^[A-Za-z0-9._-]+$'; then + echo "pull_crash: extracted prefix is not a valid bundle id: '$PREFIX'" >&2 + exit 2 +fi +if [ -z "$PREFIX" ]; then + echo "pull_crash: could not read bundle ID prefix from $PLIST" >&2 + exit 2 +fi + +# --- 2. device IP ----------------------------------------------------------- +if [ -z "$IP" ] && [ -n "${THEOS_DEVICE_IP:-}" ]; then + IP="$THEOS_DEVICE_IP" +fi +if [ -z "$IP" ] && [ -f Makefile ]; then + IP=$(grep -E '^THEOS_DEVICE_IP[[:space:]]*[?]=' Makefile \ + | head -1 \ + | sed -E 's/^THEOS_DEVICE_IP[[:space:]]*[?]=[[:space:]]*//' \ + | tr -d '[:space:]') +fi +if [ -z "$IP" ]; then + echo "pull_crash: device IP not set; pass --ip or export THEOS_DEVICE_IP" >&2 + exit 2 +fi + +SSH_OPTS=(-o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes) +# -n keeps ssh from swallowing stdin (otherwise it eats lines from while-read loops). +SSH() { ssh -n "${SSH_OPTS[@]}" "root@$IP" "$@"; } +if ! SSH 'echo ok' >/dev/null 2>&1; then + echo "pull_crash: cannot reach root@$IP via SSH" >&2 + exit 1 +fi + +# --- 3. output directory ---------------------------------------------------- +TS=$(date +%Y%m%d-%H%M%S) +SAFE_PREFIX=${PREFIX//./-} +OUT="logs/crashes/${SAFE_PREFIX}-${TS}" +mkdir -p "$OUT/crashreporter" "$OUT/sandbox" + +# --- 4. crash reports ------------------------------------------------------- +# Match by JSON-header bundleID OR by filename prefix (proc_name resource +# diagnostics like KIOU.wakeups_resource-*.ips carry the proc name as the +# filename prefix but a different bundleID in the JSON). +ESCAPED_PREFIX=$(printf '%s' "$PREFIX" | sed 's/[.[\*^$/]/\\&/g') +LIST_FILE="$OUT/crash-list.txt" +SSH "for f in /var/mobile/Library/Logs/CrashReporter/*.ips; do + [ -f \"\$f\" ] || continue + head -1 \"\$f\" | grep -qE '\"bundleID\":\"${ESCAPED_PREFIX}([.][^\"]*)?\"' && echo \"\$f\" + done" > "$LIST_FILE" 2>/dev/null + +CRASH_COUNT=0 +if [ -s "$LIST_FILE" ]; then + while IFS= read -r f; do + [ -n "$f" ] || continue + if scp -q "${SSH_OPTS[@]}" "root@$IP:$f" "$OUT/crashreporter/" 2>/dev/null; then + CRASH_COUNT=$((CRASH_COUNT + 1)) + fi + done < "$LIST_FILE" +fi + +# --- 5. sandbox discovery --------------------------------------------------- +# All applications whose MCMMetadataIdentifier starts with the prefix. +# Binary plists pack strings without surrounding whitespace, so `strings` +# can split the id into multiple chunks. Use `grep -ao` to extract the +# bundle id straight from the raw bytes. +SANDBOX_FILE="$OUT/sandbox-list.txt" +SSH "for d in /var/mobile/Containers/Data/Application/*/; do + p=\"\${d}.com.apple.mobile_container_manager.metadata.plist\" + [ -f \"\$p\" ] || continue + grep -aq '${PREFIX}' \"\$p\" 2>/dev/null || continue + id=\$(grep -aoE '${ESCAPED_PREFIX}([.][A-Za-z0-9_-]+)*' \"\$p\" 2>/dev/null | sort -u | head -1) + [ -n \"\$id\" ] && echo \"\$id|\$d\" + done" > "$SANDBOX_FILE" 2>/dev/null + +SANDBOX_COUNT=0 +LOG_COUNT=0 +if [ -s "$SANDBOX_FILE" ]; then + while IFS='|' read -r bundle dir; do + [ -n "$dir" ] || continue + safe_bundle=${bundle//./-} + dest="$OUT/sandbox/$safe_bundle" + mkdir -p "$dest" + for sub in Documents/Logs Library/Logs tmp/Logs; do + # Probe existence first so missing dirs don't pollute stderr. + if SSH "[ -d \"${dir}${sub}\" ]"; then + mkdir -p "$dest/$sub" + if scp -q -r "${SSH_OPTS[@]}" "root@$IP:${dir}${sub}/." "$dest/$sub/" 2>/dev/null; then + n=$(find "$dest/$sub" -type f 2>/dev/null | wc -l) + LOG_COUNT=$((LOG_COUNT + n)) + fi + fi + done + SANDBOX_COUNT=$((SANDBOX_COUNT + 1)) + done < "$SANDBOX_FILE" +fi + +# --- 6. summary ------------------------------------------------------------- +LATEST_IPS="" +if [ "$CRASH_COUNT" -gt 0 ]; then + LATEST_IPS=$(ls -t "$OUT/crashreporter"/*.ips 2>/dev/null | head -1 | xargs -I{} basename {}) +fi + +cat <\nfocusReport=\nuserContext=" +} +``` + +Conventions for the `prompt`: +- Always use absolute paths. The subagent's working directory is the + repo root. +- `focusReport` is blank unless the user named a specific `.ips`. +- `userContext` carries the Step 0 note (or `""` if none). + +Save the returned JSON as `analysis`. From +`analysis.hypotheses[0].evidence`, extract any hook names of the form +`KIOU_HOOK_NAME_*` or `KFHook[A-Z]\w*` and collect them as the +`suspectHooks` list for Stage 3. If the leading hypothesis names no +hook explicitly, pass `suspectHooks=[]` (Stage 3 will sweep). + +--- + +## Stage 3 — CAVE/RVA verification + +Spawn the `cave-rva-verifier` subagent. + +```json +{ + "subagent_type": "cave-rva-verifier", + "description": "Verify CAVE/RVA wiring around the suspected hook(s)", + "prompt": "repoRoot=\ntargetVersion=\nsuspectHooks=[]" +} +``` + +Resolve `targetVersion`: + +```bash +grep -oE 'TARGET_VERSION\s*\?=\s*\S+' Makefile | awk -F'=' '{gsub(/ /,"",$2); print $2}' +``` + +Default to `1.0.2` if extraction fails. + +Save the returned JSON as `verification`. Build the combined +`findings[]` list for Stage 4 by merging: + +- Every entry of `verification.issues[]` with severity `error` or + `warning`. +- Any actionable mechanism from `analysis.hypotheses[]` that names a + specific file:line and is NOT already covered by a `verification` + issue at the same location. + +Each merged finding MUST have these fields: + +```json +{ + "id": "FIX-", + "where": ":", + "what": "", + "rationale": "" +} +``` + +Number `id`s sequentially starting at `FIX-1`. Do NOT rewrite the +underlying `what` text — copy it from the source subagent so the +audit trail is preserved. + +--- + +## Stage 4 — Patch application (requires user consent) + +Show the merged `findings[]` to the user verbatim, then issue +`AskUserQuestion`: + +- `Apply all` — forward every finding to `tweak-fixer`. +- `Pick which to apply` — follow-up `AskUserQuestion` (multiSelect) + listing each finding by `id` + first 80 chars of `what`. +- `Don't apply` — leave the findings on screen and end the pipeline. + +If the user opts in, spawn `tweak-fixer`: + +```json +{ + "subagent_type": "tweak-fixer", + "description": "Apply Tweak fixes", + "prompt": "findings=\nbuildCheck=make CHINLAN=1 -j4" +} +``` + +`buildCheck` enables compile verification after edits. Do not omit it +unless the user explicitly requests skipping the build. + +When `tweak-fixer` returns, present its result to the user: +- Per-finding `status` (applied / skipped) and `files`. +- `buildCheck.summary` (ok / errors). + +Do NOT proceed to packaging, IPA assembly, git commit, push, or PR +creation. Hand control back to the user. + +--- + +## Pipeline control + +- Maintain four tasks via `TaskCreate` / `TaskUpdate`, one per stage. + Set `in_progress` on stage entry, `completed` on success. +- If any stage fails (non-zero exit, subagent returns empty / error + JSON), stop and report the partial state. Do not skip ahead. +- Never let one subagent's output be paraphrased before reaching the + next subagent. Pass JSON through. + +--- + +## Hard prohibitions + +- Do NOT perform analysis, verification, or code edits yourself. They + belong to the subagents. Your role is dispatch and aggregation. +- Do NOT ask the user for device IP, bundle ID, target files, or + Tweak version. `pull_crash.sh` is deterministic; trust it. +- Do NOT invoke `tweak-fixer` without explicit user consent in + Stage 4. +- Do NOT summarize subagent JSON in a way that drops fields. Pass it + through; summarize once at the end for the human reader. +- Do NOT run `make ipa`, `make package`, `make jailed`, or any + packaging target. `tweak-fixer` stops at `make CHINLAN=1` for + compile verification. +- Do NOT fall back to the global `~/.claude/skills/fix-tweak/SKILL.md` + generic flow. This project-local SKILL.md is authoritative inside + this repository. + +--- + +## Asset locations (reference) + +- `.claude/scripts/pull_crash.sh` — log collection +- `.claude/agents/crash-log-analyzer.md` — crash analysis subagent +- `.claude/agents/cave-rva-verifier.md` — CAVE/RVA verifier subagent +- `.claude/agents/tweak-fixer.md` — code repair subagent + +To change pipeline behavior, edit the asset directly rather than +patching this orchestrator. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d8eb66e..3c9c03e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1.7 FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 ARG _REMOTE_USER_HOME=/home/vscode @@ -5,6 +6,7 @@ ARG _REMOTE_USER_HOME=/home/vscode RUN \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ --mount=type=cache,target=/var/cache/apt,sharing=locked \ + rm -f /etc/apt/apt.conf.d/docker-clean && \ apt-get update && apt-get install -y --no-install-recommends \ wget \ unzip \ @@ -41,38 +43,47 @@ RUN \ # ── 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 && \ +RUN \ + --mount=type=cache,target=/var/cache/dl,sharing=locked,uid=1000,gid=1000 \ + 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') && \ + JADX_FILE=/var/cache/dl/$(basename "$JADX_URL") && \ + { [ -f "$JADX_FILE" ] || wget -q "$JADX_URL" -O "$JADX_FILE"; } && \ + unzip -q "$JADX_FILE" -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 + ln -s /opt/jadx/bin/jadx-gui /usr/local/bin/jadx-gui # ── 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 && \ +RUN \ + --mount=type=cache,target=/var/cache/dl,sharing=locked,uid=1000,gid=1000 \ + GHIDRA_URL=$(wget -qO- https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/latest | jq -r '.assets[] | select(.name | test("PUBLIC.*\\.zip$")) | .browser_download_url') && \ + GHIDRA_FILE=/var/cache/dl/$(basename "$GHIDRA_URL") && \ + { [ -f "$GHIDRA_FILE" ] || wget -q "$GHIDRA_URL" -O "$GHIDRA_FILE"; } && \ + unzip -q "$GHIDRA_FILE" -d /opt && \ GHIDRA_DIR=$(find /opt -maxdepth 1 -name 'ghidra_*' -type d) && \ - ln -s "$GHIDRA_DIR" /opt/ghidra && \ - rm /tmp/ghidra.zip + ln -s "$GHIDRA_DIR" /opt/ghidra 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 +RUN \ + --mount=type=cache,target=/var/cache/dl,sharing=locked,uid=1000,gid=1000 \ + 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') && \ + IPSW_FILE=/var/cache/dl/$(basename "$IPSW_URL") && \ + { [ -f "$IPSW_FILE" ] || wget -q "$IPSW_URL" -O "$IPSW_FILE"; } && \ + tar xzf "$IPSW_FILE" -C /usr/local/bin ipsw # ── yq (mikefarah/yq, Go 製) ── # YAML 操作。agent-iossolve の hook 仕様 YAML を扱う recon/frida エージェントが使う -RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') && \ +RUN \ + --mount=type=cache,target=/var/cache/dl,sharing=locked,uid=1000,gid=1000 \ + 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 + YQ_FILE=/var/cache/dl/yq_linux_${ARCH} && \ + { [ -f "$YQ_FILE" ] || wget -q "$YQ_URL" -O "$YQ_FILE"; } && \ + install -m 0755 "$YQ_FILE" /usr/local/bin/yq # ── Theos 本体 ── USER vscode @@ -80,25 +91,37 @@ 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 +RUN \ + --mount=type=cache,target=/var/cache/dl,sharing=locked,uid=1000,gid=1000 \ + ARCH=$(uname -m) && \ + TC_FILE=/var/cache/dl/iOSToolchain-${ARCH}.tar.xz && \ + { [ -f "$TC_FILE" ] || curl -fsSL -o "$TC_FILE" "https://github.com/L1ghtmann/llvm-project/releases/latest/download/iOSToolchain-${ARCH}.tar.xz"; } && \ + tar xJf "$TC_FILE" -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 +RUN \ + --mount=type=cache,target=/var/cache/dl,sharing=locked,uid=1000,gid=1000 \ + ARCH=$(uname -m) && \ + SWIFT_TC_FILE=/var/cache/dl/swift-5.8-ubuntu22.04-${ARCH}.tar.xz && \ + { [ -f "$SWIFT_TC_FILE" ] || curl -fsSL -o "$SWIFT_TC_FILE" "https://github.com/kabiroberai/swift-toolchain-linux/releases/download/v2.3.0/swift-5.8-ubuntu22.04-${ARCH}.tar.xz"; } && \ + tar xJf "$SWIFT_TC_FILE" -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 +RUN \ + --mount=type=cache,target=/var/cache/dl,sharing=locked,uid=1000,gid=1000 \ + ARCH=$(uname -m) && \ + HOST_SWIFT_FILE=/var/cache/dl/swift-5.8.1-RELEASE-ubuntu22.04-${ARCH}.tar.gz && \ + { [ -f "$HOST_SWIFT_FILE" ] || curl -fsSL -o "$HOST_SWIFT_FILE" "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 xzf "$HOST_SWIFT_FILE" --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 && \ +RUN \ + --mount=type=cache,target=/var/cache/dl,sharing=locked,uid=1000,gid=1000 \ + SDKS_FILE=/var/cache/dl/theos-sdks-master.tar.gz && \ + { [ -f "$SDKS_FILE" ] || curl -fsSL -o "$SDKS_FILE" "https://github.com/theos/sdks/archive/master.tar.gz"; } && \ + tar xzf "$SDKS_FILE" -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 index 3b84ac6..0927647 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -6,9 +6,21 @@ services: volumes: - ../:/home/vscode/app:cached - venv:/home/vscode/app/.venv + - uv-cache:/home/vscode/.cache/uv + - bun-cache:/home/vscode/.bun/install/cache + - npm-cache:/home/vscode/.npm + - pip-cache:/home/vscode/.cache/pip tty: true stdin_open: true volumes: venv: driver: local + uv-cache: + driver: local + bun-cache: + driver: local + npm-cache: + driver: local + pip-cache: + driver: local diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e9b7a5d..bfc57db 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,9 +20,7 @@ "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}/.config/gh,target=/home/vscode/.config/gh,type=bind,consistency=cached" ], "features": { "ghcr.io/devcontainers/features/common-utils:2": { diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index 81ca4f8..816aaa1 100644 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -1,5 +1,22 @@ #!/bin/sh +USER_NAME="$(whoami)" +USER_HOME="/home/${USER_NAME}" + git submodule update --init --recursive -sudo chown -R "$(whoami)":"$(whoami)" /home/"$(whoami)"/app/.venv + +sudo mkdir -p \ + "${USER_HOME}/app/.venv" \ + "${USER_HOME}/.cache/uv" \ + "${USER_HOME}/.cache/pip" \ + "${USER_HOME}/.bun/install/cache" \ + "${USER_HOME}/.npm" + +sudo chown -R "${USER_NAME}":"${USER_NAME}" \ + "${USER_HOME}/app/.venv" \ + "${USER_HOME}/.cache/uv" \ + "${USER_HOME}/.cache/pip" \ + "${USER_HOME}/.bun/install/cache" \ + "${USER_HOME}/.npm" + uv sync diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cbfe6d..7d42a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to KiouForge are documented here. ## [Unreleased] +## [0.2.2] — 2026-08-01 + +### Added + +- **KIOU 1.1.0 support** (CFBundleVersion 15). All 17 hook sites re-verified against the 1.1.0 Mach-O; every struct offset the KIF pipeline reads is unchanged from 1.0.2. + +### Changed + +- `TARGET_VERSION` now defaults to `1.1.0`; `1.0.1` and `1.0.2` remain buildable by passing it explicitly. The Makefile maps the version to KIOU-Hook's `KIOU_HOOK_TARGET_BUILD` so the dylib and the recipe always agree on which addresses to bake in, and errors out on an unknown version instead of silently falling back. +- The five direct-call RVAs the KIF pipeline uses (`GetUSIText`, `ToSFEN`, `ParseUSI`, `KIFWriteOptions..ctor`, `KIFWriter.Write`) now resolve through the KIOU-Hook catalog instead of being pinned in `Kif/Helpers.m`, so they follow `TARGET_VERSION` like every other site. +- `make deploy` accepts `THEOS_DEVICE_PORT`, needed when the device is reached through an iproxy forward on the host — there port 22 answers as the host rather than the phone. +- FPS override logging only reports transitions. KIOU re-applies its own target every ~5 s, and logging each one buried every other line (198 of 242 lines in one session). +- Bumped the `vendor/KIOU-Hook` submodule to `8ab5868` (per-version RVA headers under `rva/`, `recipes/v1_1_0.py`) and `shared` (Kanade) to develop, which carries the verify_sites overload resolution and the `caves.py` placeholder rows 1.1.0 needs. +- `make deploy` now works on TrollStore Lite devices. `TROLLSTORE_HELPER` defaults to empty and the target SSH-discovers the helper binary at deploy time, preferring `TrollStoreLite.app` over `TrollStore.app` and filtering out stale `TrollStorePersistenceHelper.app` leftovers whose entitlements expire between sessions (which manifest as ssh 255 / SIGKILL 137). The IPA is staged in `/var/mobile/Documents/` instead of `/tmp/` — the trollstorehelper sandbox cannot read `/tmp/` and rejects an IPA there with error 166. The staged file is `chown mobile:mobile`-ed after `scp` so the helper can open it. Non-Lite setups keep working unchanged; operators who pinned `TROLLSTORE_HELPER` in their `.env` are unaffected. `REMOTE_STAGING_DIR` is exposed as an override for setups that stage elsewhere. + ## [0.2.1] — 2026-06-26 ### Changed diff --git a/Makefile b/Makefile index 88df2d6..bbf17a4 100644 --- a/Makefile +++ b/Makefile @@ -20,12 +20,30 @@ TARGET_PROCESS := KIOU TARGET_BUNDLE_ID := com.neconome.shogi # Override on the command line: make ipa TARGET_VERSION=1.0.2 -TARGET_VERSION ?= 1.0.2 +TARGET_VERSION ?= 1.1.0 DECRYPTED_IPA ?= $(CURDIR)/assets/$(TARGET_VERSION)/Kiou-$(TARGET_VERSION).ipa + +# KIOU-Hook selects its per-version RVA header (vendor/KIOU-Hook/rva/) by +# CFBundleVersion, not by marketing version, so map one to the other here. +# Without the -D below the catalog silently falls back to its own default +# and the dylib gets built against another build's addresses. +KIOU_BUILD_1.0.1 := 11 +KIOU_BUILD_1.0.2 := 12 +KIOU_BUILD_1.1.0 := 15 +KIOU_HOOK_TARGET_BUILD := $(KIOU_BUILD_$(TARGET_VERSION)) +ifeq ($(KIOU_HOOK_TARGET_BUILD),) +$(error unknown TARGET_VERSION '$(TARGET_VERSION)'; known: 1.0.1 1.0.2 1.1.0) +endif IPA_RECIPE := recipes.__init__ KIOU_HOOK_DIR := $(CURDIR)/vendor/KIOU-Hook IPA_FRAMEWORK := UnityFramework +# Optional CFBundleIdentifier suffix. Empty (default) leaves the bundle +# id alone so the patched IPA overwrites the original app; set e.g. +# BUNDLE_ID_SUFFIX=chinlan in .env or on the command line to append +# ".chinlan" and install alongside the original. +BUNDLE_ID_SUFFIX ?= + BUILD_COMMIT_DEFINE := KIOU_FORGE_COMMIT # --------------------------------------------------------------------------- @@ -36,7 +54,7 @@ INSTALL_TARGET_PROCESSES := $(TARGET_PROCESS) ARCHS := arm64 THEOS_PACKAGE_SCHEME := rootless -include .env -THEOS_DEVICE_IP ?= 192.168.0.49 +THEOS_DEVICE_IP ?= 192.168.0.30 include $(THEOS)/makefiles/common.mk @@ -64,6 +82,7 @@ endif $(TWEAK_NAME)_CFLAGS := -fobjc-arc -Wno-unused-function \ -D$(BUILD_COMMIT_DEFINE)=\"$(BUILD_COMMIT)\" \ -DKIOU_FORGE_VERSION=\"$(PACKAGE_VERSION)\" \ + -DKIOU_HOOK_TARGET_BUILD=$(KIOU_HOOK_TARGET_BUILD) \ -ISources/Chinlan -I$(TWEAK_SOURCES_DIR) \ -Ivendor/KIOU-Hook ifdef FINAL_RELEASE @@ -116,6 +135,31 @@ chinlan:: IPA_DYLIB := $(CURDIR)/packages/chinlan/$(TWEAK_NAME).dylib +IPA_OUT := $(CURDIR)/packages/ipa/$(basename $(notdir $(DECRYPTED_IPA)))-patched.ipa + +# KIOU-Hook site allow-list — KiouForge only ships 17 of the 34 catalog +# rows. Passing this to `recipes/__init__.py` (via env) filters out the +# KiouEditor entries whose caves would otherwise collide with the +# __oslogstring fragment past 0x826FFF8. +KIOU_HOOK_ID_ALLOW := \ + KIOU_HOOK_ID_SET_TARGET_FRAMERATE,\ + KIOU_HOOK_ID_NSS_SETHASHSIZE,\ + KIOU_HOOK_ID_NSS_SETSKILLEVEL,\ + KIOU_HOOK_ID_NSS_SEARCHFULL,\ + KIOU_HOOK_ID_ACCOUNT_EXISTS,\ + KIOU_HOOK_ID_LOGIN_ARGS_CREATE,\ + KIOU_HOOK_ID_REGISTER_USER_ARGS_CREATE,\ + KIOU_HOOK_ID_RUN_LOGIN_SEQ_MOVENEXT,\ + KIOU_HOOK_ID_GET_SELF_PROFILE_MOVENEXT,\ + KIOU_HOOK_ID_HTTPMSGINVOKER_SEND_ASYNC,\ + KIOU_HOOK_ID_KIFU_AI_END,\ + KIOU_HOOK_ID_KIFU_CPUSTREAM_END,\ + KIOU_HOOK_ID_KIFU_LOCAL_END,\ + KIOU_HOOK_ID_KIFU_ONLINE_END,\ + KIOU_HOOK_ID_KIFU_REPLAY_END,\ + KIOU_HOOK_ID_HEADER_PROVIDER_SET_OR_UPDATE_HEADER,\ + KIOU_HOOK_ID_GAME_ORCHESTRATOR_IS_AFK + ipa:: chinlan @echo "==> assembling patched IPA from $(DECRYPTED_IPA) (v$(TARGET_VERSION))" @if [ ! -f "$(DECRYPTED_IPA)" ]; then \ @@ -124,13 +168,92 @@ ipa:: chinlan exit 1; \ fi @TARGET_VERSION="$(TARGET_VERSION)" \ + KIOU_HOOK_ID_ALLOW="$(strip $(KIOU_HOOK_ID_ALLOW))" \ PYTHONPATH="$(KIOU_HOOK_DIR):$$PYTHONPATH" \ ./shared/tools/build_patched_ipa.sh \ - --recipe "$(IPA_RECIPE)" \ - --framework "$(IPA_FRAMEWORK)" \ - --dylib "$(IPA_DYLIB)" \ - --input "$(DECRYPTED_IPA)" \ - --output "$(CURDIR)/packages/ipa/$(basename $(notdir $(DECRYPTED_IPA)))-patched.ipa" + --recipe "$(IPA_RECIPE)" \ + --framework "$(IPA_FRAMEWORK)" \ + --dylib "$(IPA_DYLIB)" \ + --input "$(DECRYPTED_IPA)" \ + --output "$(IPA_OUT)" \ + --bundle-id-suffix "$(BUNDLE_ID_SUFFIX)" + +# --------------------------------------------------------------------------- +# TrollStore-backed IPA deploy on a JB device. +# Ships the patched IPA to the device via SSH, installs it through +# trollstorehelper (force flag, so it overrides an existing Sideloadly / +# AltStore build with the same bundle id), and relaunches the app. +# Kept separate from Theos's own `install::` (JB rootless .deb install) +# so `make deploy` targets only the IPA path and doesn't drag in the +# JB dpkg install as a side effect. +# +# TrollStore vs TrollStore Lite: the trollstorehelper binary lives in +# different places per install flavour. The default TROLLSTORE_HELPER +# value below empty-strings out on purpose so the target falls back +# to SSH-discovery, which walks /var/jb/Applications and the App +# bundle tree. TrollStoreLite.app is ranked above plain TrollStore.app +# so a Lite device wins if both directories exist. The old +# TrollStorePersistenceHelper.app directory is excluded: on Lite it +# never gets installed, and a leftover from a previous non-Lite JB +# session often has a helper binary whose entitlements have been +# invalidated, which SIGKILLs on invocation and shows up as ssh 255. +# +# Remote staging path: /var/mobile/Documents/, not /tmp/. +# trollstorehelper runs in a sandbox that cannot read /tmp/ — an IPA +# staged there is rejected with return code 166 +# ("IPA does not exist or is not accessible"), even though the file +# is physically present. Documents/ is one of the few locations both +# root (scp target) and mobile (trollstorehelper runtime) can access; +# the recipe chowns after scp so mobile can read the payload. +# +# Override on the command line or in .env: +# TROLLSTORE_HELPER — pin trollstorehelper path (skips discovery) +# INSTALLED_IPA_BUNDLE_ID — bundle id used to relaunch the app +# DEVICE_USER — SSH user (defaults to root) +# REMOTE_STAGING_DIR — sandbox-visible dir to scp the IPA into +# THEOS_DEVICE_PORT — SSH port. Needed when the device is reached +# through an iproxy forward on the host +# (THEOS_DEVICE_IP=host.docker.internal), where +# port 22 answers as the host, not the phone. +# --------------------------------------------------------------------------- +TROLLSTORE_HELPER ?= +INSTALLED_IPA_BUNDLE_ID ?= $(TARGET_BUNDLE_ID)$(if $(BUNDLE_ID_SUFFIX),.$(BUNDLE_ID_SUFFIX),) +DEVICE_USER ?= root +REMOTE_STAGING_DIR ?= /var/mobile/Documents +THEOS_DEVICE_PORT ?= 22 +# scp spells the port -P, ssh spells it -p. +DEVICE_SCP := scp -P $(THEOS_DEVICE_PORT) +DEVICE_SSH := ssh -p $(THEOS_DEVICE_PORT) + +.PHONY: deploy +deploy: ipa + @helper='$(TROLLSTORE_HELPER)'; \ + if [ -z "$$helper" ]; then \ + echo "==> discovering trollstorehelper on $(THEOS_DEVICE_IP)"; \ + helper=$$($(DEVICE_SSH) $(DEVICE_USER)@$(THEOS_DEVICE_IP) \ + "find /var/jb/Applications /var/containers/Bundle/Application \ + -maxdepth 4 -type f -name trollstorehelper 2>/dev/null \ + | grep -v 'PersistenceHelper' \ + | awk 'BEGIN{FS=\"/\"} {for(i=1;i<=NF;i++) if(\$$i ~ /^TrollStoreLite\\.app\$$/){print \"0 \"\$$0; next}} {print \"1 \"\$$0}' \ + | sort -k1,1 | awk '{print \$$2}' | head -n1"); \ + fi; \ + if [ -z "$$helper" ]; then \ + echo "error: trollstorehelper not found on device"; \ + echo " override with: make deploy TROLLSTORE_HELPER="; \ + exit 1; \ + fi; \ + echo "==> helper: $$helper"; \ + echo "==> scp $(notdir $(IPA_OUT)) -> $(DEVICE_USER)@$(THEOS_DEVICE_IP):$(THEOS_DEVICE_PORT):$(REMOTE_STAGING_DIR)/"; \ + $(DEVICE_SCP) -q $(IPA_OUT) $(DEVICE_USER)@$(THEOS_DEVICE_IP):$(REMOTE_STAGING_DIR)/$(notdir $(IPA_OUT)); \ + $(DEVICE_SSH) $(DEVICE_USER)@$(THEOS_DEVICE_IP) \ + "chown mobile:mobile '$(REMOTE_STAGING_DIR)/$(notdir $(IPA_OUT))' 2>/dev/null || true"; \ + echo "==> trollstorehelper install force $(REMOTE_STAGING_DIR)/$(notdir $(IPA_OUT))"; \ + $(DEVICE_SSH) $(DEVICE_USER)@$(THEOS_DEVICE_IP) \ + "$$helper install force $(REMOTE_STAGING_DIR)/$(notdir $(IPA_OUT))" + @echo "==> launching $(TARGET_PROCESS) ($(INSTALLED_IPA_BUNDLE_ID))" + @$(DEVICE_SSH) $(DEVICE_USER)@$(THEOS_DEVICE_IP) 'sleep 1; (open $(INSTALLED_IPA_BUNDLE_ID) 2>/dev/null \ + || uiopen $(INSTALLED_IPA_BUNDLE_ID):// 2>/dev/null \ + || echo "no launcher tool; start $(TARGET_PROCESS) manually")' .PHONY: hooks hooks:: diff --git a/README.ja.md b/README.ja.md index 8e719cb..453b891 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@

version - targets KIOU + targets KIOU platform arch runs @@ -136,21 +136,21 @@ KIOU はオンラインゲームのため頻繁にアップデートされ、古 ### 機能 × バージョン対応表 -| 機能 | 1.0.1 (build 11) | 1.0.2 (build 12) | -|---|:---:|:---:| -| FPS Override | ✓ | ✓ | -| AFK Guard | ✓ | ✓ | -| Analysis Tune | ✓ | ✓ | -| Kifu Autosave | ✓ | ✓ | -| **アカウント切り替え** | — | ✓ | +| 機能 | 1.0.1 (build 11) | 1.0.2 (build 12) | 1.1.0 (build 15) | +|---|:---:|:---:|:---:| +| FPS Override | ✓ | ✓ | ✓ | +| AFK Guard | ✓ | ✓ | ✓ | +| Analysis Tune | ✓ | ✓ | ✓ | +| Kifu Autosave | ✓ | ✓ | ✓ | +| **アカウント切り替え** | — | ✓ | ✓ | -アカウント切り替えは 1.0.2 で追加されたフックサイトが必要なため、1.0.1 では非対応です。Jailed / JB ビルドは RVA がコンパイル時に固定されるため、常に**最新バージョンのみ**を対象とします。Patched IPA は `TARGET_VERSION` でビルド時にレシピを選択します。 +アカウント切り替えは 1.0.2 で追加されたフックサイトが必要なため、1.0.1 では非対応です。アドレスはどの配布形式でもビルド時に `TARGET_VERSION` で決まります。Patched IPA はレシピを、dylib は対応する RVA ヘッダを選択します。 ### プラットフォーム | | | |---|---| -| **最新対応 KIOU** | `1.0.2`(CFBundleVersion 12) | +| **最新対応 KIOU** | `1.1.0`(CFBundleVersion 15) | | **KIOU の最低 iOS バージョン** | 10.0(アプリの `MinimumOSVersion`) | | **KiouForge の最低 iOS バージョン** | 13.0(`UIWindowScene` が必要) | | **動作確認済み** | iOS 15.0 〜 26、arm64 | @@ -187,22 +187,22 @@ TrollStore が使えないデバイス向けです。[Sideloadly](https://sidelo **復号済み** の KIOU IPA が必要です([palera1n](https://palera.in/) + Filza または [TrollDecrypt](https://github.com/donato-fiore/TrollDecrypt) で取得できます)。App Store からダウンロードした IPA は FairPlay で暗号化されているため、そのままでは使用できません。 ```sh -# デフォルト(1.0.2) -mkdir -p assets/1.0.2 -cp ~/Downloads/Kiou-1.0.2.ipa assets/1.0.2/ +# デフォルト(1.1.0) +mkdir -p assets/1.1.0 +cp ~/Downloads/Kiou-1.1.0.ipa assets/1.1.0/ make ipa # -> packages/ipa/KiouForge-patched.ipa # バージョンを指定する場合 -make ipa TARGET_VERSION=1.0.1 +make ipa TARGET_VERSION=1.0.2 ``` フックサイトを編集した後や KIOU のアップデート後にビルドする場合: ```sh # 特定バージョンの dump + IPA でレシピを検証 -PYTHONPATH=shared:. TARGET_VERSION=1.0.2 python3 -m tools.verify_sites \ +PYTHONPATH=shared:vendor/KIOU-Hook TARGET_VERSION=1.1.0 python3 -m tools.verify_sites \ --recipe recipes \ - --index assets/1.0.2/dump.cs.index.json \ - --ipa assets/1.0.2/Kiou-1.0.2.ipa + --index assets/1.1.0/dump.cs.index.json \ + --ipa assets/1.1.0/Kiou-1.1.0.ipa ``` diff --git a/README.md b/README.md index f5f1148..588ed6c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

version - targets KIOU + targets KIOU platform arch runs @@ -179,23 +179,23 @@ server-side. The recommended target is always the **latest supported version**. ### Feature × version matrix -| Feature | 1.0.1 (build 11) | 1.0.2 (build 12) | -|---|:---:|:---:| -| FPS Override | ✓ | ✓ | -| AFK Guard | ✓ | ✓ | -| Analysis Tune | ✓ | ✓ | -| Kifu Autosave | ✓ | ✓ | -| **Account Switching** | — | ✓ | +| Feature | 1.0.1 (build 11) | 1.0.2 (build 12) | 1.1.0 (build 15) | +|---|:---:|:---:|:---:| +| FPS Override | ✓ | ✓ | ✓ | +| AFK Guard | ✓ | ✓ | ✓ | +| Analysis Tune | ✓ | ✓ | ✓ | +| Kifu Autosave | ✓ | ✓ | ✓ | +| **Account Switching** | — | ✓ | ✓ | -Account Switching requires hook sites introduced in 1.0.2; the Jailed/JB build -always targets the **latest** version only (RVAs are pinned at compile time). -The Patched IPA build selects the recipe at build time via `TARGET_VERSION`. +Account Switching requires hook sites introduced in 1.0.2. Every build shape +pins its addresses at build time via `TARGET_VERSION`: the Patched IPA picks +the recipe, and the dylib picks the matching RVA header. ### Platform | | | |---|---| -| **Latest supported KIOU** | `1.0.2` (CFBundleVersion 12) | +| **Latest supported KIOU** | `1.1.0` (CFBundleVersion 15) | | **KIOU minimum iOS** | 10.0 (`MinimumOSVersion` in app bundle) | | **KiouForge minimum iOS** | 13.0 (requires `UIWindowScene`) | | **Tested on** | iOS 15.0 – 26, arm64 | @@ -238,23 +238,23 @@ Filza, or [TrollDecrypt](https://github.com/donato-fiore/TrollDecrypt)). The App Store download is FairPlay-encrypted and cannot be patched directly. ```sh -# default (1.0.2) -mkdir -p assets/1.0.2 -cp ~/Downloads/Kiou-1.0.2.ipa assets/1.0.2/ +# default (1.1.0) +mkdir -p assets/1.1.0 +cp ~/Downloads/Kiou-1.1.0.ipa assets/1.1.0/ make ipa # -> packages/ipa/KiouForge-patched.ipa # target a specific version -make ipa TARGET_VERSION=1.0.1 +make ipa TARGET_VERSION=1.0.2 ``` Before building after editing hook sites or after a KIOU update: ```sh # verify current recipe against a specific version's dump + IPA -PYTHONPATH=shared:. TARGET_VERSION=1.0.2 python3 -m tools.verify_sites \ +PYTHONPATH=shared:vendor/KIOU-Hook TARGET_VERSION=1.1.0 python3 -m tools.verify_sites \ --recipe recipes \ - --index assets/1.0.2/dump.cs.index.json \ - --ipa assets/1.0.2/Kiou-1.0.2.ipa + --index assets/1.1.0/dump.cs.index.json \ + --ipa assets/1.1.0/Kiou-1.1.0.ipa ``` diff --git a/Sources/Chinlan b/Sources/Chinlan index 6e3c214..645a1dd 160000 --- a/Sources/Chinlan +++ b/Sources/Chinlan @@ -1 +1 @@ -Subproject commit 6e3c214882e7e09898b54d3159f78b6c8867be52 +Subproject commit 645a1ddbc0c968509db4b3254674a3c3574d496e diff --git a/Sources/KiouForge/ChinlanDispatcher.m b/Sources/KiouForge/ChinlanDispatcher.m index 27dceae..a309f28 100644 --- a/Sources/KiouForge/ChinlanDispatcher.m +++ b/Sources/KiouForge/ChinlanDispatcher.m @@ -13,7 +13,7 @@ // Hook_*.m observer body. // // CAVE_ENTRY caves each load their own slot at unityBase + -// KIOU_HOOK_ENTRY_SLOT_BASE_RVA + slot_index*8 and BLR it. KFChinlanPublish() +// KIOU_HOOK_ENTRY_SLOT_BASE_RVA + slot_index*8 and BLR it. KIOUChinlanPublish() // writes the live hook function pointers into those slots. // // g_inject_entry[i] holds the per-site cave-bypass entry pointer so a @@ -40,16 +40,17 @@ // replace orig; the body calls the cave bypass (resolved by KIOUHookOrig) // to invoke the original method when needed. // --------------------------------------------------------------------------- -extern void KFHookSetTargetFrameRateEntry(int32_t value, void *mi); -extern void KFHookNSSSetHashSizeEntry(void *self, int32_t mb, void *mi); -extern void KFHookNSSSetSkillLevelEntry(void *self, int32_t level, void *mi); -extern void *KFHookNSSSearchFullEntry(void *self, void *sfen, int32_t depth, void *mi); -extern bool KFHookAccountExists(void *data); -extern void *KFHookLoginArgsCreate(void *deviceId, void *distinctId); -extern void *KFHookRegisterUserArgsCreate(void *userName, void *distinctId); -extern void KFHookRunLoginSeqMoveNext(void *self); -extern void KFHookGetSelfProfileMoveNext(void *self); -extern void *KFHookHttpMsgInvokerSendAsync(void *self, void *request, void *ct); +extern void KIOUHookSetTargetFrameRateEntry(int32_t value, void *mi); +extern void KIOUHookNSSSetHashSizeEntry(void *self, int32_t mb, void *mi); +extern void KIOUHookNSSSetSkillLevelEntry(void *self, int32_t level, void *mi); +extern KIOUSyncSearchResult KIOUHookNSSSearchFullEntry(void *self, void *sfen, int32_t depth, void *mi); +extern bool KIOUHookAccountExists(void *data, void *mi); +extern void * KIOUHookLoginArgsCreate(void *deviceId, void *distinctId, void *mi); +extern void * KIOUHookRegisterUserArgsCreate(void *userName, void *distinctId, void *mi); +extern void KIOUHookRunLoginSeqMoveNext(void *self, void *mi); +extern void KIOUHookGetSelfProfileMoveNext(void *self, void *mi); +extern void * KIOUHookHttpMsgInvokerSendAsync(void *self, void *request, void *ct, void *mi); +extern void KIOUHookHeaderProviderSetOrUpdate(void *self, void *keyStr, void *valueStr, void *mi); // --------------------------------------------------------------------------- // dispatch_one — single shared observer slot, switches on W6=hook_id. @@ -91,10 +92,10 @@ + (uintptr_t)hook_id * KIOU_HOOK_CAVE_SIZE } // --------------------------------------------------------------------------- -// KFChinlanPublish — install the observer dispatcher pointer + entry slot +// KIOUChinlanPublish — install the observer dispatcher pointer + entry slot // table the moment UnityFramework's base address is known. // --------------------------------------------------------------------------- -void KFChinlanPublish(uintptr_t unityBase) { +void KIOUChinlanPublish(uintptr_t unityBase) { if (unityBase == 0) { IPALog(@"[CHINLAN] publish skipped: unityBase is zero"); return; @@ -113,16 +114,17 @@ void KFChinlanPublish(uintptr_t unityBase) { // Entry-slot table — one slot per CAVE_ENTRY hook. void * volatile *entrySlots = (void * volatile *)(unityBase + KIOU_HOOK_ENTRY_SLOT_BASE_RVA); - entrySlots[KIOU_HOOK_SLOT_SET_TARGET_FRAMERATE] = (void *)&KFHookSetTargetFrameRateEntry; - entrySlots[KIOU_HOOK_SLOT_NSS_SETHASHSIZE] = (void *)&KFHookNSSSetHashSizeEntry; - entrySlots[KIOU_HOOK_SLOT_NSS_SETSKILLEVEL] = (void *)&KFHookNSSSetSkillLevelEntry; - entrySlots[KIOU_HOOK_SLOT_NSS_SEARCHFULL] = (void *)&KFHookNSSSearchFullEntry; - entrySlots[KIOU_HOOK_SLOT_ACCOUNT_EXISTS] = (void *)&KFHookAccountExists; - entrySlots[KIOU_HOOK_SLOT_LOGIN_ARGS_CREATE] = (void *)&KFHookLoginArgsCreate; - entrySlots[KIOU_HOOK_SLOT_REGISTER_USER_ARGS_CREATE] = (void *)&KFHookRegisterUserArgsCreate; - entrySlots[KIOU_HOOK_SLOT_RUN_LOGIN_SEQ_MOVENEXT] = (void *)&KFHookRunLoginSeqMoveNext; - entrySlots[KIOU_HOOK_SLOT_GET_SELF_PROFILE_MOVENEXT] = (void *)&KFHookGetSelfProfileMoveNext; - entrySlots[KIOU_HOOK_SLOT_HTTPMSGINVOKER_SEND_ASYNC] = (void *)&KFHookHttpMsgInvokerSendAsync; + entrySlots[KIOU_HOOK_SLOT_SET_TARGET_FRAMERATE] = (void *)&KIOUHookSetTargetFrameRateEntry; + entrySlots[KIOU_HOOK_SLOT_NSS_SETHASHSIZE] = (void *)&KIOUHookNSSSetHashSizeEntry; + entrySlots[KIOU_HOOK_SLOT_NSS_SETSKILLEVEL] = (void *)&KIOUHookNSSSetSkillLevelEntry; + entrySlots[KIOU_HOOK_SLOT_NSS_SEARCHFULL] = (void *)&KIOUHookNSSSearchFullEntry; + entrySlots[KIOU_HOOK_SLOT_ACCOUNT_EXISTS] = (void *)&KIOUHookAccountExists; + entrySlots[KIOU_HOOK_SLOT_LOGIN_ARGS_CREATE] = (void *)&KIOUHookLoginArgsCreate; + entrySlots[KIOU_HOOK_SLOT_REGISTER_USER_ARGS_CREATE] = (void *)&KIOUHookRegisterUserArgsCreate; + entrySlots[KIOU_HOOK_SLOT_RUN_LOGIN_SEQ_MOVENEXT] = (void *)&KIOUHookRunLoginSeqMoveNext; + entrySlots[KIOU_HOOK_SLOT_GET_SELF_PROFILE_MOVENEXT] = (void *)&KIOUHookGetSelfProfileMoveNext; + entrySlots[KIOU_HOOK_SLOT_HTTPMSGINVOKER_SEND_ASYNC] = (void *)&KIOUHookHttpMsgInvokerSendAsync; + entrySlots[KIOU_HOOK_SLOT_HEADER_PROVIDER_SET_OR_UPDATE_HEADER] = (void *)&KIOUHookHeaderProviderSetOrUpdate; IPALog([NSString stringWithFormat: @"[CHINLAN] dispatcher=%p observer slot=%p (unityBase+0x%lx) " diff --git a/Sources/KiouForge/Hook/AnalysisTune.m b/Sources/KiouForge/Hook/AnalysisTune.m index 6d5229e..9b840c3 100644 --- a/Sources/KiouForge/Hook/AnalysisTune.m +++ b/Sources/KiouForge/Hook/AnalysisTune.m @@ -13,7 +13,7 @@ typedef void (*NSSSetHashSize_t)(void *self, int32_t mb, void *mi); typedef void (*NSSSetSkillLevel_t)(void *self, int32_t level, void *mi); -typedef KFSyncSearchResult (*NSSSearchFull_t)(void *self, void *sfen, +typedef KIOUSyncSearchResult (*NSSSearchFull_t)(void *self, void *sfen, int32_t depth, void *mi); #if !IPA_CHINLAN @@ -23,8 +23,8 @@ typedef KFSyncSearchResult (*NSSSearchFull_t)(void *self, void *sfen, #endif static int32_t pickHashMB(int32_t mb) { - int32_t target = KFFeatureEnabled(KIOU_FEATURE_ANALYSIS_TUNE) - ? KFAnalysisHashMB() : mb; + int32_t target = KIOUFeatureEnabled(KIOU_FEATURE_ANALYSIS_TUNE) + ? KIOUAnalysisHashMB() : mb; if (target != mb) { IPALog([NSString stringWithFormat: @"[ANALYSIS] SetHashSize %d -> %d MB (override)", mb, target]); @@ -33,8 +33,8 @@ static int32_t pickHashMB(int32_t mb) { } static int32_t pickSkillLevel(int32_t level) { - int32_t target = KFFeatureEnabled(KIOU_FEATURE_ANALYSIS_TUNE) - ? KFAnalysisSkillLevel() : level; + int32_t target = KIOUFeatureEnabled(KIOU_FEATURE_ANALYSIS_TUNE) + ? KIOUAnalysisSkillLevel() : level; if (target != level) { IPALog([NSString stringWithFormat: @"[ANALYSIS] SetSkillLevel %d -> %d (override)", level, target]); @@ -43,8 +43,8 @@ static int32_t pickSkillLevel(int32_t level) { } static int32_t pickDepth(int32_t depth) { - int32_t target = KFFeatureEnabled(KIOU_FEATURE_ANALYSIS_TUNE) - ? KFAnalysisDepth() : depth; + int32_t target = KIOUFeatureEnabled(KIOU_FEATURE_ANALYSIS_TUNE) + ? KIOUAnalysisDepth() : depth; if (target != depth) { IPALog([NSString stringWithFormat: @"[ANALYSIS] SearchFull depth %d -> %d (override)", @@ -54,38 +54,38 @@ static int32_t pickDepth(int32_t depth) { } #if IPA_CHINLAN -void KFHookNSSSetHashSizeEntry(void *self, int32_t mb, void *mi) { +void KIOUHookNSSSetHashSizeEntry(void *self, int32_t mb, void *mi) { int32_t v = pickHashMB(mb); NSSSetHashSize_t bypass = (NSSSetHashSize_t)g_inject_entry[KIOU_HOOK_ID_NSS_SETHASHSIZE]; if (bypass) bypass(self, v, mi); } -void KFHookNSSSetSkillLevelEntry(void *self, int32_t level, void *mi) { +void KIOUHookNSSSetSkillLevelEntry(void *self, int32_t level, void *mi) { int32_t v = pickSkillLevel(level); NSSSetSkillLevel_t bypass = (NSSSetSkillLevel_t)g_inject_entry[KIOU_HOOK_ID_NSS_SETSKILLEVEL]; if (bypass) bypass(self, v, mi); } -KFSyncSearchResult KFHookNSSSearchFullEntry(void *self, void *sfen, +KIOUSyncSearchResult KIOUHookNSSSearchFullEntry(void *self, void *sfen, int32_t depth, void *mi) { int32_t v = pickDepth(depth); NSSSearchFull_t bypass = (NSSSearchFull_t)g_inject_entry[KIOU_HOOK_ID_NSS_SEARCHFULL]; if (bypass) return bypass(self, sfen, v, mi); - KFSyncSearchResult empty = {0}; + KIOUSyncSearchResult empty = {0}; return empty; } -void KFInstallAnalysisTuneHook(uintptr_t unityBase) { +void KIOUInstallAnalysisTuneHook(uintptr_t unityBase) { (void)unityBase; IPALog([NSString stringWithFormat: @"[CHINLAN] AnalysisTune: entry hooks wired " @"(depth=%d hash=%d MB skill=%d)", - (int)KFAnalysisDepth(), - (int)KFAnalysisHashMB(), - (int)KFAnalysisSkillLevel()]); + (int)KIOUAnalysisDepth(), + (int)KIOUAnalysisHashMB(), + (int)KIOUAnalysisSkillLevel()]); } #else static void HookNSSSetHashSize(void *self, int32_t mb, void *mi) { @@ -98,15 +98,15 @@ static void HookNSSSetSkillLevel(void *self, int32_t level, void *mi) { if (orig_NSS_SetSkillLevel) orig_NSS_SetSkillLevel(self, v, mi); } -static KFSyncSearchResult HookNSSSearchFull(void *self, void *sfen, +static KIOUSyncSearchResult HookNSSSearchFull(void *self, void *sfen, int32_t depth, void *mi) { int32_t v = pickDepth(depth); if (orig_NSS_SearchFull) return orig_NSS_SearchFull(self, sfen, v, mi); - KFSyncSearchResult empty = {0}; + KIOUSyncSearchResult empty = {0}; return empty; } -void KFInstallAnalysisTuneHook(uintptr_t unityBase) { +void KIOUInstallAnalysisTuneHook(uintptr_t unityBase) { { uintptr_t addr = unityBase + RVA_NSS_SETHASHSIZE; MSHookFunction((void *)addr, @@ -114,7 +114,7 @@ void KFInstallAnalysisTuneHook(uintptr_t unityBase) { (void **)&orig_NSS_SetHashSize); IPALog([NSString stringWithFormat: @"NativeSyncSession.SetHashSize hooked @0x%lx hash=%d MB", - (unsigned long)addr, (int)KFAnalysisHashMB()]); + (unsigned long)addr, (int)KIOUAnalysisHashMB()]); } { uintptr_t addr = unityBase + RVA_NSS_SETSKILLEVEL; @@ -123,7 +123,7 @@ void KFInstallAnalysisTuneHook(uintptr_t unityBase) { (void **)&orig_NSS_SetSkillLevel); IPALog([NSString stringWithFormat: @"NativeSyncSession.SetSkillLevel hooked @0x%lx skill=%d", - (unsigned long)addr, (int)KFAnalysisSkillLevel()]); + (unsigned long)addr, (int)KIOUAnalysisSkillLevel()]); } { uintptr_t addr = unityBase + RVA_NSS_SEARCHFULL; @@ -132,7 +132,7 @@ void KFInstallAnalysisTuneHook(uintptr_t unityBase) { (void **)&orig_NSS_SearchFull); IPALog([NSString stringWithFormat: @"NativeSyncSession.SearchFull hooked @0x%lx depth=%d", - (unsigned long)addr, (int)KFAnalysisDepth()]); + (unsigned long)addr, (int)KIOUAnalysisDepth()]); } } #endif diff --git a/Sources/KiouForge/Hook/FrameRate.m b/Sources/KiouForge/Hook/FrameRate.m index ae836e4..f4b58eb 100644 --- a/Sources/KiouForge/Hook/FrameRate.m +++ b/Sources/KiouForge/Hook/FrameRate.m @@ -14,9 +14,14 @@ #endif static int32_t pickFPS(int32_t value) { - int32_t v = KFFeatureEnabled(KIOU_FEATURE_FPS_OVERRIDE) - ? KFTargetFPS() : value; - if (v != value) { + int32_t v = KIOUFeatureEnabled(KIOU_FEATURE_FPS_OVERRIDE) + ? KIOUTargetFPS() : value; + // KIOU re-applies its own target every ~5 s, so logging every + // override would bury everything else. Only report transitions. + static int32_t lastFrom = -1, lastTo = -1; + if (v != value && (value != lastFrom || v != lastTo)) { + lastFrom = value; + lastTo = v; IPALog([NSString stringWithFormat: @"[FPS] set_targetFrameRate %d -> %d (override)", value, v]); } @@ -25,7 +30,7 @@ static int32_t pickFPS(int32_t value) { // Direct call from the settings slider callback so the new value takes // effect immediately without waiting for KIOU's next settings apply. -void KFApplyFPS(int32_t fps) { +void KIOUApplyFPS(int32_t fps) { if (g_unityBase == 0) return; SetTargetFrameRate_t fn = (SetTargetFrameRate_t)(g_unityBase + RVA_SET_TARGET_FRAMERATE); @@ -34,18 +39,18 @@ void KFApplyFPS(int32_t fps) { } #if IPA_CHINLAN -void KFHookSetTargetFrameRateEntry(int32_t value, void *mi) { +void KIOUHookSetTargetFrameRateEntry(int32_t value, void *mi) { int32_t v = pickFPS(value); SetTargetFrameRate_t bypass = (SetTargetFrameRate_t)g_inject_entry[KIOU_HOOK_ID_SET_TARGET_FRAMERATE]; if (bypass) bypass(v, mi); } -void KFInstallFrameRateHook(uintptr_t unityBase) { +void KIOUInstallFrameRateHook(uintptr_t unityBase) { (void)unityBase; IPALog([NSString stringWithFormat: @"[CHINLAN] set_targetFrameRate: entry hook wired (fps=%d)", - (int)KFTargetFPS()]); + (int)KIOUTargetFPS()]); } #else static void HookSetTargetFrameRate(int32_t value, void *mi) { @@ -53,13 +58,13 @@ static void HookSetTargetFrameRate(int32_t value, void *mi) { if (orig_set_targetFrameRate) orig_set_targetFrameRate(v, mi); } -void KFInstallFrameRateHook(uintptr_t unityBase) { +void KIOUInstallFrameRateHook(uintptr_t unityBase) { uintptr_t addr = unityBase + RVA_SET_TARGET_FRAMERATE; MSHookFunction((void *)addr, (void *)HookSetTargetFrameRate, (void **)&orig_set_targetFrameRate); IPALog([NSString stringWithFormat: @"Application.set_targetFrameRate hooked @0x%lx (base+0x%x) fps=%d", - (unsigned long)addr, RVA_SET_TARGET_FRAMERATE, (int)KFTargetFPS()]); + (unsigned long)addr, RVA_SET_TARGET_FRAMERATE, (int)KIOUTargetFPS()]); } #endif diff --git a/Sources/KiouForge/Hook/KifuObserve.m b/Sources/KiouForge/Hook/KifuObserve.m index 92f2bc3..9ae13fa 100644 --- a/Sources/KiouForge/Hook/KifuObserve.m +++ b/Sources/KiouForge/Hook/KifuObserve.m @@ -5,7 +5,7 @@ // // Five sites (AI / CPUStream / LocalPvP / OnlinePvP / RecordReplay) all // route into mode-specific HookXxxEnd(self, ct) observer bodies that -// invoke KFKifuObserveMatchEnd with the right KiouMatchMode index. +// invoke KIOUKifuObserveMatchEnd with the right KiouMatchMode index. // // Chinlan path: // Each cave passes its hook_id in W6 to the shared observer slot at @@ -23,8 +23,8 @@ #define RVA_ONLINE_END KIOU_HOOK_RVA_ONLINE_END #define RVA_REPLAY_END KIOU_HOOK_RVA_REPLAY_END -// 16-byte UniTask return (see KFUniTaskRet in Internal.h). -typedef KFUniTaskRet (*OnMatchEndAsync_t)(void *self, void *ct); +// 16-byte UniTask return (see KIOUUniTaskRet in Internal.h). +typedef KIOUUniTaskRet (*OnMatchEndAsync_t)(void *self, void *ct); #if !IPA_CHINLAN static OnMatchEndAsync_t orig_AI_End = NULL; @@ -38,47 +38,47 @@ // Observer bodies — single source of truth, called by both chinlan // dispatch_one and (via the JB trampolines below) MSHookFunction. // --------------------------------------------------------------------------- -void HookAiEnd (void *self, void *ct) { KFKifuObserveMatchEnd(self, ct, KIOU_MMODE_AI_MATCH); } -void HookCpuStreamEnd(void *self, void *ct) { KFKifuObserveMatchEnd(self, ct, KIOU_MMODE_CPU_STREAM); } -void HookLocalEnd (void *self, void *ct) { KFKifuObserveMatchEnd(self, ct, KIOU_MMODE_LOCAL_PVP); } -void HookOnlineEnd (void *self, void *ct) { KFKifuObserveMatchEnd(self, ct, KIOU_MMODE_ONLINE_PVP); } -void HookReplayEnd (void *self, void *ct) { KFKifuObserveMatchEnd(self, ct, KIOU_MMODE_RECORD_REPLAY); } +void HookAiEnd (void *self, void *ct) { KIOUKifuObserveMatchEnd(self, ct, KIOU_MMODE_AI_MATCH); } +void HookCpuStreamEnd(void *self, void *ct) { KIOUKifuObserveMatchEnd(self, ct, KIOU_MMODE_CPU_STREAM); } +void HookLocalEnd (void *self, void *ct) { KIOUKifuObserveMatchEnd(self, ct, KIOU_MMODE_LOCAL_PVP); } +void HookOnlineEnd (void *self, void *ct) { KIOUKifuObserveMatchEnd(self, ct, KIOU_MMODE_ONLINE_PVP); } +void HookReplayEnd (void *self, void *ct) { KIOUKifuObserveMatchEnd(self, ct, KIOU_MMODE_RECORD_REPLAY); } #if !IPA_CHINLAN // JB-flavour trampolines: observer body first, then chain to orig. -static KFUniTaskRet ThunkAIEnd(void *self, void *ct) { +static KIOUUniTaskRet ThunkAIEnd(void *self, void *ct) { HookAiEnd(self, ct); if (orig_AI_End) return orig_AI_End(self, ct); - return (KFUniTaskRet){ NULL, NULL }; + return (KIOUUniTaskRet){ NULL, NULL }; } -static KFUniTaskRet ThunkCPUStreamEnd(void *self, void *ct) { +static KIOUUniTaskRet ThunkCPUStreamEnd(void *self, void *ct) { HookCpuStreamEnd(self, ct); if (orig_CPUStream_End) return orig_CPUStream_End(self, ct); - return (KFUniTaskRet){ NULL, NULL }; + return (KIOUUniTaskRet){ NULL, NULL }; } -static KFUniTaskRet ThunkLocalEnd(void *self, void *ct) { +static KIOUUniTaskRet ThunkLocalEnd(void *self, void *ct) { HookLocalEnd(self, ct); if (orig_Local_End) return orig_Local_End(self, ct); - return (KFUniTaskRet){ NULL, NULL }; + return (KIOUUniTaskRet){ NULL, NULL }; } -static KFUniTaskRet ThunkOnlineEnd(void *self, void *ct) { +static KIOUUniTaskRet ThunkOnlineEnd(void *self, void *ct) { HookOnlineEnd(self, ct); if (orig_Online_End) return orig_Online_End(self, ct); - return (KFUniTaskRet){ NULL, NULL }; + return (KIOUUniTaskRet){ NULL, NULL }; } -static KFUniTaskRet ThunkReplayEnd(void *self, void *ct) { +static KIOUUniTaskRet ThunkReplayEnd(void *self, void *ct) { HookReplayEnd(self, ct); if (orig_RecordReplay_End) return orig_RecordReplay_End(self, ct); - return (KFUniTaskRet){ NULL, NULL }; + return (KIOUUniTaskRet){ NULL, NULL }; } #endif -void KFInstallKifuObserveHook(uintptr_t unityBase) { - NSString *outDir = KFKifEnsureOutputDir(); +void KIOUInstallKifuObserveHook(uintptr_t unityBase) { + NSString *outDir = KIOUKifEnsureOutputDir(); IPALog([NSString stringWithFormat:@"[KIFU] output dir = %@", outDir ?: @"(failed)"]); #if IPA_CHINLAN - // Nothing to do — KFChinlanPublish has already wired dispatch_one + // Nothing to do — KIOUChinlanPublish has already wired dispatch_one // into the observer slot; dispatch_one switches on W6=hook_id and // calls HookXxxEnd directly. (void)unityBase; diff --git a/Sources/KiouForge/Hook/SettingsUI.m b/Sources/KiouForge/Hook/SettingsUI.m index 2134632..4288d2f 100644 --- a/Sources/KiouForge/Hook/SettingsUI.m +++ b/Sources/KiouForge/Hook/SettingsUI.m @@ -15,16 +15,16 @@ // Declare the apply helper from Hook_FrameRate.m so we can call it from // the FPS stepper callback. -extern void KFApplyFPS(int32_t fps); +extern void KIOUApplyFPS(int32_t fps); // --------------------------------------------------------------------------- -// KFAccountsViewController — account list with select / delete / reorder. +// KIOUAccountsViewController — account list with select / delete / reorder. // --------------------------------------------------------------------------- -@interface KFAccountsViewController : UITableViewController +@interface KIOUAccountsViewController : UITableViewController @property (nonatomic, strong) NSMutableArray *accounts; @end -@implementation KFAccountsViewController +@implementation KIOUAccountsViewController - (instancetype)init { self = [super initWithStyle:UITableViewStyleInsetGrouped]; @@ -39,11 +39,11 @@ - (void)viewDidLoad { target:self action:@selector(exportAccounts:)]; self.navigationItem.rightBarButtonItems = @[self.editButtonItem, share]; - self.accounts = [NSMutableArray arrayWithArray:KFListAccounts()]; + self.accounts = [NSMutableArray arrayWithArray:KIOUListAccounts()]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAccountStateChanged:) - name:KFAccountStateChangedNotification + name:KIOUAccountStateChangedNotification object:nil]; } @@ -53,13 +53,13 @@ - (void)dealloc { - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; - self.accounts = [NSMutableArray arrayWithArray:KFListAccounts()]; + self.accounts = [NSMutableArray arrayWithArray:KIOUListAccounts()]; [self.tableView reloadData]; } - (void)onAccountStateChanged:(NSNotification *)note { dispatch_async(dispatch_get_main_queue(), ^{ - self.accounts = [NSMutableArray arrayWithArray:KFListAccounts()]; + self.accounts = [NSMutableArray arrayWithArray:KIOUListAccounts()]; [self.tableView reloadData]; }); } @@ -67,7 +67,7 @@ - (void)onAccountStateChanged:(NSNotification *)note { - (void)exportAccounts:(UIBarButtonItem *)sender { NSError *err = nil; NSData *data = [NSJSONSerialization - dataWithJSONObject:KFListAccounts() + dataWithJSONObject:KIOUListAccounts() options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys error:&err]; if (data.length == 0) { @@ -117,7 +117,7 @@ - (UITableViewCell *)tableView:(UITableView *)tv NSString *userId = acc[@"userId"]; cell.textLabel.text = userName.length > 0 ? userName : @"(no name)"; cell.detailTextLabel.text = openId.length > 0 ? openId : @"(no open id)"; - NSString *activeUserId = KFActiveAccountUserId(); + NSString *activeUserId = KIOUActiveAccountUserId(); cell.accessoryType = ([userId isKindOfClass:[NSString class]] && [userId isEqualToString:activeUserId]) ? UITableViewCellAccessoryCheckmark @@ -146,15 +146,15 @@ - (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip { [alert addAction:[UIAlertAction actionWithTitle:@"切り替え" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { - if (uuid.length > 0) KFSwitchAccount(uuid); - KFSetActiveAccountUserId(userId); + if (uuid.length > 0) KIOUSwitchAccount(uuid); + KIOUSetActiveAccountUserId(userId); // Close the settings modal then let KIOU navigate itself back to the // title scene — that re-runs AccountExists → LoginAsync with the // pending_device_id substitution in effect, no app relaunch needed. UIViewController *modalRoot = self.navigationController ?: self; UIViewController *presenter = modalRoot.presentingViewController; [presenter dismissViewControllerAnimated:YES completion:^{ - KFNavigateToTitleScene(); + KIOUNavigateToTitleScene(); }]; }]]; [self presentViewController:alert animated:YES completion:nil]; @@ -170,7 +170,7 @@ - (void)tableView:(UITableView *)tv if (ip.row >= (NSInteger)self.accounts.count) return; NSString *userId = self.accounts[ip.row][@"userId"]; [self.accounts removeObjectAtIndex:ip.row]; - if ([userId isKindOfClass:[NSString class]]) KFDeleteAccount(userId); + if ([userId isKindOfClass:[NSString class]]) KIOUDeleteAccount(userId); [tv deleteRowsAtIndexPaths:@[ip] withRowAnimation:UITableViewRowAnimationAutomatic]; } @@ -190,7 +190,7 @@ - (void)tableView:(UITableView *)tv // --------------------------------------------------------------------------- // Root settings view controller. // --------------------------------------------------------------------------- -@interface KFSettingsViewController : UITableViewController +@interface KIOUSettingsViewController : UITableViewController @property (nonatomic, strong) UILabel *fpsValueLabel; @property (nonatomic, strong) UILabel *depthValueLabel; @property (nonatomic, strong) UILabel *hashValueLabel; @@ -198,7 +198,7 @@ @interface KFSettingsViewController : UITableViewController @end // Sub-screen: per-match-mode toggles for kifu autosave. -@interface KFKifuModesViewController : UITableViewController +@interface KIOUKifuModesViewController : UITableViewController @end // Mirror of the preset tables in Persistence.m for local label rendering. @@ -231,7 +231,7 @@ @interface KFKifuModesViewController : UITableViewController static NSString *const kAboutRepoURL = @"https://github.com/IPA-Patch/KiouForge"; static NSString *const kAboutTwitterURL = @"https://x.com/tkgling"; -@implementation KFSettingsViewController +@implementation KIOUSettingsViewController - (instancetype)init { if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) { @@ -249,7 +249,7 @@ - (void)viewDidLoad { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAccountStateChanged:) - name:KFAccountStateChangedNotification + name:KIOUAccountStateChangedNotification object:nil]; } @@ -347,9 +347,9 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cell.detailTextLabel.textColor = UIColor.secondaryLabelColor; } cell.textLabel.text = @"Active"; - NSString *activeUserId = KFActiveAccountUserId(); + NSString *activeUserId = KIOUActiveAccountUserId(); NSString *activeName = nil; - for (NSDictionary *acc in KFListAccounts()) { + for (NSDictionary *acc in KIOUListAccounts()) { NSString *u = acc[@"userId"]; if ([u isKindOfClass:[NSString class]] && [u isEqualToString:activeUserId]) { NSString *n = acc[@"userName"]; @@ -374,7 +374,7 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cell2.accessoryView = sw; } cell2.textLabel.text = @"New Register"; - ((UISwitch *)cell2.accessoryView).on = KFForceRegisterOnNextLaunch(); + ((UISwitch *)cell2.accessoryView).on = KIOUForceRegisterOnNextLaunch(); return cell2; } @@ -386,25 +386,25 @@ - (UITableViewCell *)tableView:(UITableView *)tableView // "3 of 5") + disclosure indicator. // We dequeue under distinct identifiers so the cached style stays // correct across reuse. - if (KFFeatureHasNavigation(f)) { + if (KIOUFeatureHasNavigation(f)) { static NSString *kId = @"feature-nav"; UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kId]; if (!cell) { cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:kId]; } - cell.textLabel.text = KFFeatureLabel(f); + cell.textLabel.text = KIOUFeatureLabel(f); cell.accessoryView = nil; // disclosure indicator below cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; cell.selectionStyle = UITableViewCellSelectionStyleDefault; // Caption: master state + per-mode count for Kifu Autosave. if (f == KIOU_FEATURE_KIFU_AUTOSAVE) { - if (!KFFeatureEnabled(f)) { + if (!KIOUFeatureEnabled(f)) { cell.detailTextLabel.text = @"Off"; } else { int32_t on = 0; for (int i = 0; i < KIOU_MMODE_COUNT; i++) { - if (KFKifuModeEnabled((KiouMatchMode)i)) on++; + if (KIOUKifuModeEnabled((KiouMatchMode)i)) on++; } cell.detailTextLabel.text = [NSString stringWithFormat:@"%d of %ld", @@ -412,7 +412,7 @@ - (UITableViewCell *)tableView:(UITableView *)tableView } } else { cell.detailTextLabel.text = - KFFeatureEnabled(f) ? @"On" : @"Off"; + KIOUFeatureEnabled(f) ? @"On" : @"Off"; } cell.detailTextLabel.textColor = UIColor.secondaryLabelColor; return cell; @@ -425,9 +425,9 @@ - (UITableViewCell *)tableView:(UITableView *)tableView reuseIdentifier:kId]; cell.selectionStyle = UITableViewCellSelectionStyleNone; } - cell.textLabel.text = KFFeatureLabel(f); + cell.textLabel.text = KIOUFeatureLabel(f); UISwitch *sw = [[UISwitch alloc] init]; - sw.on = KFFeatureEnabled(f); + sw.on = KIOUFeatureEnabled(f); sw.tag = f; [sw addTarget:self action:@selector(onFeatureToggle:) forControlEvents:UIControlEventValueChanged]; @@ -449,7 +449,7 @@ - (UITableViewCell *)tableView:(UITableView *)tableView stepper.continuous = NO; // Only one row in Performance (FPS) for now. - int32_t idx = KFFPSIndex(); + int32_t idx = KIOUFPSIndex(); cell.textLabel.text = @"FPS"; cell.detailTextLabel.text = [NSString stringWithFormat:@"%d", kFpsPresets[idx]]; self.fpsValueLabel = cell.detailTextLabel; @@ -478,17 +478,17 @@ - (UITableViewCell *)tableView:(UITableView *)tableView if (indexPath.row == KF_ENGINE_ROW_DEPTH) { cell.textLabel.text = @"Analysis Depth"; cell.detailTextLabel.text = [NSString stringWithFormat:@"%d", - (int)KFAnalysisDepth()]; + (int)KIOUAnalysisDepth()]; self.depthValueLabel = cell.detailTextLabel; stepper.minimumValue = 1; stepper.maximumValue = 36; stepper.stepValue = 1; - stepper.value = KFAnalysisDepth(); + stepper.value = KIOUAnalysisDepth(); [stepper addTarget:self action:@selector(onDepthChanged:) forControlEvents:UIControlEventValueChanged]; } else if (indexPath.row == KF_ENGINE_ROW_HASH) { - int32_t idx = KFAnalysisHashIndex(); + int32_t idx = KIOUAnalysisHashIndex(); cell.textLabel.text = @"Analysis Hash"; cell.detailTextLabel.text = [NSString stringWithFormat:@"%d MB", kHashPresets[idx]]; self.hashValueLabel = cell.detailTextLabel; @@ -502,12 +502,12 @@ - (UITableViewCell *)tableView:(UITableView *)tableView } else { // KF_ENGINE_ROW_SKILL cell.textLabel.text = @"Analysis Skill"; cell.detailTextLabel.text = [NSString stringWithFormat:@"%d", - (int)KFAnalysisSkillLevel()]; + (int)KIOUAnalysisSkillLevel()]; self.skillValueLabel = cell.detailTextLabel; stepper.minimumValue = 1; stepper.maximumValue = 20; stepper.stepValue = 1; - stepper.value = KFAnalysisSkillLevel(); + stepper.value = KIOUAnalysisSkillLevel(); [stepper addTarget:self action:@selector(onSkillChanged:) forControlEvents:UIControlEventValueChanged]; } @@ -539,16 +539,16 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath if (indexPath.section == KF_SECTION_ACCOUNT && indexPath.row == KF_ACCOUNT_ROW_ACTIVE) { - KFAccountsViewController *vc = [[KFAccountsViewController alloc] init]; + KIOUAccountsViewController *vc = [[KIOUAccountsViewController alloc] init]; [self.navigationController pushViewController:vc animated:YES]; return; } if (indexPath.section == KF_SECTION_FEATURES) { KiouFeature f = (KiouFeature)indexPath.row; - if (!KFFeatureHasNavigation(f)) return; + if (!KIOUFeatureHasNavigation(f)) return; if (f == KIOU_FEATURE_KIFU_AUTOSAVE) { - KFKifuModesViewController *vc = [[KFKifuModesViewController alloc] init]; + KIOUKifuModesViewController *vc = [[KIOUKifuModesViewController alloc] init]; [self.navigationController pushViewController:vc animated:YES]; } return; @@ -565,14 +565,14 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath // --------------------------------------------------------------------------- - (void)onForceRegisterChanged:(UISwitch *)sw { - KFSetForceRegisterOnNextLaunch(sw.on); + KIOUSetForceRegisterOnNextLaunch(sw.on); if (sw.on) { NSString *fresh = [[NSUUID UUID] UUIDString].lowercaseString; - KFSetPendingDistinctId(fresh); - KFSetPendingDeviceId(fresh); + KIOUSetPendingDistinctId(fresh); + KIOUSetPendingDeviceId(fresh); } else { - KFSetPendingDistinctId(nil); - KFSetPendingDeviceId(nil); + KIOUSetPendingDistinctId(nil); + KIOUSetPendingDeviceId(nil); } IPALog([NSString stringWithFormat:@"[SETTINGS] force_register=%s", sw.on ? "true" : "false"]); @@ -580,38 +580,38 @@ - (void)onForceRegisterChanged:(UISwitch *)sw { - (void)onFeatureToggle:(UISwitch *)sw { KiouFeature f = (KiouFeature)sw.tag; - KFSetFeatureEnabled(f, sw.isOn); + KIOUSetFeatureEnabled(f, sw.isOn); IPALog([NSString stringWithFormat: - @"[SETTINGS] %@ -> %@", KFFeatureLabel(f), sw.isOn ? @"ON" : @"OFF"]); + @"[SETTINGS] %@ -> %@", KIOUFeatureLabel(f), sw.isOn ? @"ON" : @"OFF"]); } - (void)onFpsChanged:(UIStepper *)stepper { int32_t idx = (int32_t)stepper.value; - KFSetFPSIndex(idx); - int32_t fps = KFTargetFPS(); + KIOUSetFPSIndex(idx); + int32_t fps = KIOUTargetFPS(); self.fpsValueLabel.text = [NSString stringWithFormat:@"%d", fps]; - KFApplyFPS(fps); // apply immediately + KIOUApplyFPS(fps); // apply immediately IPALog([NSString stringWithFormat:@"[SETTINGS] fps -> %d (idx=%d)", fps, idx]); } - (void)onDepthChanged:(UIStepper *)stepper { int32_t v = (int32_t)stepper.value; - KFSetAnalysisDepth(v); + KIOUSetAnalysisDepth(v); self.depthValueLabel.text = [NSString stringWithFormat:@"%d", v]; IPALog([NSString stringWithFormat:@"[SETTINGS] analysis depth -> %d", v]); } - (void)onHashChanged:(UIStepper *)stepper { int32_t idx = (int32_t)stepper.value; - KFSetAnalysisHashIndex(idx); - int32_t mb = KFAnalysisHashMB(); + KIOUSetAnalysisHashIndex(idx); + int32_t mb = KIOUAnalysisHashMB(); self.hashValueLabel.text = [NSString stringWithFormat:@"%d MB", mb]; IPALog([NSString stringWithFormat:@"[SETTINGS] analysis hash -> %d MB (idx=%d)", mb, idx]); } - (void)onSkillChanged:(UIStepper *)stepper { int32_t v = (int32_t)stepper.value; - KFSetAnalysisSkillLevel(v); + KIOUSetAnalysisSkillLevel(v); self.skillValueLabel.text = [NSString stringWithFormat:@"%d", v]; IPALog([NSString stringWithFormat:@"[SETTINGS] analysis skill -> %d", v]); } @@ -619,20 +619,20 @@ - (void)onSkillChanged:(UIStepper *)stepper { @end // =========================================================================== -// KFKifuModesViewController — per-mode kifu autosave toggles. +// KIOUKifuModesViewController — per-mode kifu autosave toggles. // // Pushed by the root controller when the Kifu Autosave row is tapped. One // section, KIOU_MMODE_COUNT rows (AI / CPUStream / LocalPvP / OnlinePvP / -// RecordReplay), each a UISwitch on `KFKifuModeEnabled(mode)`. +// RecordReplay), each a UISwitch on `KIOUKifuModeEnabled(mode)`. // // Independent of the master KIOU_FEATURE_KIFU_AUTOSAVE flag — the master // stays where it is on the root screen; this screen edits only the per-mode // flags. The on-device hook (Hook_KifuObserve.m's -// KFKifuObserveMatchEnd) gates emission on BOTH the master and the +// KIOUKifuObserveMatchEnd) gates emission on BOTH the master and the // per-mode flag. // =========================================================================== -@implementation KFKifuModesViewController +@implementation KIOUKifuModesViewController - (instancetype)init { if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) { @@ -669,9 +669,9 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cell.selectionStyle = UITableViewCellSelectionStyleNone; } KiouMatchMode m = (KiouMatchMode)indexPath.row; - cell.textLabel.text = KFKifuModeLabel(m); + cell.textLabel.text = KIOUKifuModeLabel(m); UISwitch *sw = [[UISwitch alloc] init]; - sw.on = KFKifuModeEnabled(m); + sw.on = KIOUKifuModeEnabled(m); sw.tag = m; [sw addTarget:self action:@selector(onModeToggle:) forControlEvents:UIControlEventValueChanged]; @@ -681,10 +681,10 @@ - (UITableViewCell *)tableView:(UITableView *)tableView - (void)onModeToggle:(UISwitch *)sw { KiouMatchMode m = (KiouMatchMode)sw.tag; - KFSetKifuModeEnabled(m, sw.isOn); + KIOUSetKifuModeEnabled(m, sw.isOn); IPALog([NSString stringWithFormat: @"[SETTINGS] kifu mode %@ -> %@", - KFKifuModeLabel(m), sw.isOn ? @"ON" : @"OFF"]); + KIOUKifuModeLabel(m), sw.isOn ? @"ON" : @"OFF"]); } @end @@ -708,7 +708,7 @@ - (void)onModeToggle:(UISwitch *)sw { return fallback; } -void KFPresentSettings(void) { +void KIOUPresentSettings(void) { dispatch_async(dispatch_get_main_queue(), ^{ UIWindow *win = kfActiveWindow(); if (!win) { IPALog(@"[SETTINGS] no active window"); return; } @@ -720,7 +720,7 @@ void KFPresentSettings(void) { // Wrap in a UINavigationController so feature rows that need a // sub-screen (e.g. Kifu Autosave's per-mode toggles) get push // transitions for free. - KFSettingsViewController *root_vc = [[KFSettingsViewController alloc] init]; + KIOUSettingsViewController *root_vc = [[KIOUSettingsViewController alloc] init]; UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:root_vc]; nav.modalPresentationStyle = UIModalPresentationFormSheet; diff --git a/Sources/KiouForge/Hook/Version.m b/Sources/KiouForge/Hook/Version.m index 2361171..89ad88f 100644 --- a/Sources/KiouForge/Hook/Version.m +++ b/Sources/KiouForge/Hook/Version.m @@ -8,7 +8,7 @@ // =========================================================================== #ifndef IPA_CHINLAN -void KFInstallVersionHook(__unused uintptr_t unityBase) { } +void KIOUInstallVersionHook(__unused uintptr_t unityBase) { } #else -void KFPublishVersionSlots(__unused uintptr_t unityBase) { } +void KIOUPublishVersionSlots(__unused uintptr_t unityBase) { } #endif diff --git a/Sources/KiouForge/Internal.h b/Sources/KiouForge/Internal.h index 132d151..793c2d0 100644 --- a/Sources/KiouForge/Internal.h +++ b/Sources/KiouForge/Internal.h @@ -8,7 +8,7 @@ // Internal.h — KiouForge tweak-local declarations. // // The KIOU binary catalog (RVAs, hook-id enum, dispatcher externs, shared -// installer protos, KFUniTaskRet, KFNavigateToTitleScene) lives in +// installer protos, KIOUUniTaskRet, KIOUNavigateToTitleScene) lives in // vendor/KIOU-Hook/KIOUHook.h and is brought in by the import above. // =========================================================================== @@ -36,13 +36,13 @@ static inline void writeI32(void *base, uintptr_t off, int32_t val) { // --------------------------------------------------------------------------- // Tweak-local hook installers (KiouForge feature hooks). -// Shared installer protos (KFInstallAccountObserveHook / -// KFInstallGrpcLoggingHook) come from KIOUHook.h. +// Shared installer protos (KIOUInstallAccountObserveHook / +// KIOUInstallGrpcLoggingHook) come from KIOUHook.h. // --------------------------------------------------------------------------- -void KFInstallFrameRateHook(uintptr_t unityBase); -void KFInstallAnalysisTuneHook(uintptr_t unityBase); -void KFInstallKifuObserveHook(uintptr_t unityBase); +void KIOUInstallFrameRateHook(uintptr_t unityBase); +void KIOUInstallAnalysisTuneHook(uintptr_t unityBase); +void KIOUInstallKifuObserveHook(uintptr_t unityBase); // --------------------------------------------------------------------------- // SyncSearchResult — mirrors Project.RshogiEngine.SyncSearchResult (struct). @@ -59,7 +59,7 @@ typedef struct { int32_t Score; void *PV; // il2cpp String* int32_t Depth; -} KFSyncSearchResult; +} KIOUSyncSearchResult; // --------------------------------------------------------------------------- // KIF autosave — ported from KiouKifExporter. @@ -86,21 +86,21 @@ typedef struct { // x0 = self (the IMatchMode instance) // x1 = ct (CancellationToken) // x2 = mode_index (KiouMatchMode, injected by cave's MOVZ X2,#imm) -KFUniTaskRet KFKifuObserveMatchEnd(void *self, void *ct, uint32_t mode_index); +KIOUUniTaskRet KIOUKifuObserveMatchEnd(void *self, void *ct, uint32_t mode_index); // Kif_Writer pipeline. -NSString *KFKifWriterEmit(void *gameCtrl, +NSString *KIOUKifWriterEmit(void *gameCtrl, void *matchConfig, void *stateStore, const char *matchModeTag); // Helpers. -NSString *KFKifTimestamp(void); -NSString *KFKifSanitizeSegment(NSString *s, NSUInteger maxChars); -NSString *KFKifEnsureOutputDir(void); -NSString *KFKifDescribeStartpos(void *gameCtrl); -NSString *KFKifDescribeOpponents(void *matchConfig, void *stateStore); -NSString *KFKifTextFromGameController(void *gameCtrl, +NSString *KIOUKifTimestamp(void); +NSString *KIOUKifSanitizeSegment(NSString *s, NSUInteger maxChars); +NSString *KIOUKifEnsureOutputDir(void); +NSString *KIOUKifDescribeStartpos(void *gameCtrl); +NSString *KIOUKifDescribeOpponents(void *matchConfig, void *stateStore); +NSString *KIOUKifTextFromGameController(void *gameCtrl, void *matchConfig, void *stateStore, const char *matchModeTag); @@ -113,8 +113,8 @@ NSString *KFKifTextFromGameController(void *gameCtrl, #define KIFOPTS_OFF_TIME_RULE_LABEL 0x38 #define KIFOPTS_OFF_ENDING_LABEL 0x48 -void *KFIl2cppStringNew(const char *utf8); -void KFKifFillWriteOptions(void *opts, +void *KIOUIl2cppStringNew(const char *utf8); +void KIOUKifFillWriteOptions(void *opts, void *matchConfig, void *stateStore, void *gameCtrl, @@ -131,11 +131,11 @@ typedef NS_ENUM(NSInteger, KiouFeature) { KIOU_FEATURE_COUNT, }; -bool KFFeatureEnabled(KiouFeature f); -void KFSetFeatureEnabled(KiouFeature f, bool enabled); -NSString *KFFeatureLabel(KiouFeature f); +bool KIOUFeatureEnabled(KiouFeature f); +void KIOUSetFeatureEnabled(KiouFeature f, bool enabled); +NSString *KIOUFeatureLabel(KiouFeature f); -bool KFFeatureHasNavigation(KiouFeature f); +bool KIOUFeatureHasNavigation(KiouFeature f); // --------------------------------------------------------------------------- // Per-match-mode toggles for kifu autosave. @@ -149,9 +149,9 @@ typedef NS_ENUM(NSInteger, KiouMatchMode) { KIOU_MMODE_COUNT, }; -bool KFKifuModeEnabled(KiouMatchMode m); -void KFSetKifuModeEnabled(KiouMatchMode m, bool enabled); -NSString *KFKifuModeLabel(KiouMatchMode m); +bool KIOUKifuModeEnabled(KiouMatchMode m); +void KIOUSetKifuModeEnabled(KiouMatchMode m, bool enabled); +NSString *KIOUKifuModeLabel(KiouMatchMode m); // IMatchMode self -> _gameAdapter field offsets. The enum order MUST match // the observer rows of _SITES in recipes/ — the recipe bakes each index as @@ -164,6 +164,19 @@ static const uintptr_t kKiouMatchModeAdapterOffsets[KIOU_MMODE_COUNT] = { [KIOU_MMODE_RECORD_REPLAY] = 0x18, }; +// IMatchMode self -> _stateStore field offsets. All five IMatchMode +// implementations carry a GameStateStore reference sitting one pointer +// slot before _gameAdapter — reading it lets the kif writer pull +// player names via the same ReactiveProperty path online +// matches use. +static const uintptr_t kKiouMatchModeStateStoreOffsets[KIOU_MMODE_COUNT] = { + [KIOU_MMODE_AI_MATCH] = 0x40, + [KIOU_MMODE_CPU_STREAM] = 0x48, + [KIOU_MMODE_LOCAL_PVP] = 0x10, + [KIOU_MMODE_ONLINE_PVP] = 0x28, + [KIOU_MMODE_RECORD_REPLAY] = 0x10, +}; + static const char *const kKiouMatchModeTags[KIOU_MMODE_COUNT] = { [KIOU_MMODE_AI_MATCH] = "AIMatchMode", [KIOU_MMODE_CPU_STREAM] = "CPUStreamMode", @@ -178,21 +191,21 @@ static const char *const kKiouMatchModeTags[KIOU_MMODE_COUNT] = { #define KIOU_FPS_PRESET_COUNT 7 // Presets (preset[i] is the FPS value): {15,24,30,45,60,90,120} -int32_t KFTargetFPS(void); -int32_t KFFPSIndex(void); -void KFSetFPSIndex(int32_t idx); +int32_t KIOUTargetFPS(void); +int32_t KIOUFPSIndex(void); +void KIOUSetFPSIndex(int32_t idx); // --------------------------------------------------------------------------- // Analysis engine tuning (NativeSyncSession post-game path only). // --------------------------------------------------------------------------- -int32_t KFAnalysisDepth(void); -void KFSetAnalysisDepth(int32_t v); +int32_t KIOUAnalysisDepth(void); +void KIOUSetAnalysisDepth(int32_t v); #define KIOU_ANALYSIS_HASH_PRESET_COUNT 6 // Presets: {16, 64, 128, 256, 512, 1024} -int32_t KFAnalysisHashIndex(void); -void KFSetAnalysisHashIndex(int32_t idx); -int32_t KFAnalysisHashMB(void); -int32_t KFAnalysisSkillLevel(void); -void KFSetAnalysisSkillLevel(int32_t v); +int32_t KIOUAnalysisHashIndex(void); +void KIOUSetAnalysisHashIndex(int32_t idx); +int32_t KIOUAnalysisHashMB(void); +int32_t KIOUAnalysisSkillLevel(void); +void KIOUSetAnalysisSkillLevel(int32_t v); diff --git a/Sources/KiouForge/Kif/Helpers.m b/Sources/KiouForge/Kif/Helpers.m index 0ab0d53..640d302 100644 --- a/Sources/KiouForge/Kif/Helpers.m +++ b/Sources/KiouForge/Kif/Helpers.m @@ -8,7 +8,7 @@ // =========================================================================== // kif_trace_log — KIF_TRACE-gated wrapper around IPALog. // -// Diagnostic logging inside KFKifFillWriteOptions (the KIFWriteOptions +// Diagnostic logging inside KIOUKifFillWriteOptions (the KIFWriteOptions // fill path) is verbose enough that we don't want it in release builds. // Pass `-DKIF_TRACE=1` to the compiler (Makefile honors `TRACE=1`) when // you need to see every pointer the fill walks and every slot it writes. @@ -26,19 +26,19 @@ // =========================================================================== // Helpers.m — small utilities for the KIF export path. // -// * KFKifTimestamp — filename timestamp -// * KFKifSanitizeSegment — make user-supplied strings safe -// * KFKifEnsureOutputDir — create Documents/KiouForge/ -// * KFKifTextFromGameController — full KIF 2.0 via KIFWriter.Write -// * KFKifDescribeStartpos — pick "startpos"/"sfen-"/"unknown" +// * KIOUKifTimestamp — filename timestamp +// * KIOUKifSanitizeSegment — make user-supplied strings safe +// * KIOUKifEnsureOutputDir — create Documents/KiouForge/ +// * KIOUKifTextFromGameController — full KIF 2.0 via KIFWriter.Write +// * KIOUKifDescribeStartpos — pick "startpos"/"sfen-"/"unknown" // -// KFKifTextFromGameController is the bridge into il2cpp — everything else is +// KIOUKifTextFromGameController is the bridge into il2cpp — everything else is // pure Foundation. // -// Background: GameController.GetKifuText (RVA 0x5D43D10) returns an in-app -// SUMMARY ("001 ☗ ☗3八飛 … まで、☖後手の勝ち(詰み)"), NOT the standard -// KIF 2.0 that desktop kifu viewers expect. We instead run the canonical -// pipeline KIOU uses internally: +// Background: GameController.GetKifuText returns an in-app SUMMARY +// ("001 ☗ ☗3八飛 … まで、☖後手の勝ち(詰み)"), NOT the standard KIF 2.0 +// that desktop kifu viewers expect. We instead run the canonical pipeline +// KIOU uses internally: // // GetUSIText(self) → "position startpos moves ..." // ↓ @@ -51,7 +51,7 @@ // just runs the il2cpp init; it sets // no field to a non-zero value) // ↓ -// KFKifFillWriteOptions(opts, ...) → best-effort write of: +// KIOUKifFillWriteOptions(opts, ...) → best-effort write of: // StartDateTime (unconditional) // MatchTitle (unconditional) // EndingLabel (from GameController.Reason) @@ -71,14 +71,10 @@ // touched by the fill is ThinkingTimesMicros (per-move clock, queued for v0.4). // =========================================================================== -// --------------------------------------------------------------------------- -// RVAs (KIOU 1.0.1 build 11). Same source of truth as KiouUsiProxy. -// --------------------------------------------------------------------------- -#define RVA_GAMECTRL_GET_USI_TEXT 0x5D44074 // string GameController.GetUSIText(this) -#define RVA_POSITION_TO_SFEN 0x5D44374 // string Position.ToSFEN(this) -#define RVA_USIPARSER_PARSE_USI 0x5D572B4 // static RecordManager USIParser.ParseUSI(string) -#define RVA_KIFWRITEOPTIONS_CTOR 0x5D53960 // void KIFWriteOptions..ctor(this) -#define RVA_KIFWRITER_WRITE 0x5D53968 // static string KIFWriter.Write(RecordManager, KIFWriteOptions) +// The five methods this pipeline calls are direct-ABI catalog rows in +// KIOU-Hook (hook_id = -1 — resolved and called, never hooked), so their +// addresses come from KIOUHookSiteAddr and follow KIOU_HOOK_TARGET_BUILD +// instead of being pinned to one app version here. // KIFWriteOptions instance size needed for the raw-buffer trick. See the // KIFOPTS_OFF_* constants in Internal.h for the field map. Last field @@ -155,37 +151,49 @@ static KIFWriter_Write_t g_KIFWriter_Write = NULL; static Position_ToSFEN_t g_PositionToSFEN = NULL; +// KIOUHookSiteAddr returns 0 for a name the catalog doesn't carry; return +// NULL rather than a pointer to unityBase itself so callers' existing null +// checks catch it. +static void *resolveSite(const char *name) { + uintptr_t addr = KIOUHookSiteAddr(name, g_unityBase); + if (addr == 0) { + IPALog([NSString stringWithFormat:@"[KIF] site unresolved: %s", name]); + return NULL; + } + return (void *)addr; +} + // Resolve the il2cpp NativeFunction pointers we use. Idempotent and cheap; // safe to call once per export call. static void resolveIl2cppFunctions(void) { if (g_unityBase == 0) return; if (!g_GetUSIText) { g_GetUSIText = (GameCtrl_GetUSIText_t) - (void *)(g_unityBase + RVA_GAMECTRL_GET_USI_TEXT); + resolveSite(KIOU_HOOK_NAME_GAMECTRL_GET_USI_TEXT); } if (!g_ParseUSI) { g_ParseUSI = (USIParser_ParseUSI_t) - (void *)(g_unityBase + RVA_USIPARSER_PARSE_USI); + resolveSite(KIOU_HOOK_NAME_USIPARSER_PARSE_USI); } if (!g_KIFOpts_Ctor) { g_KIFOpts_Ctor = (KIFWriteOptions_Ctor_t) - (void *)(g_unityBase + RVA_KIFWRITEOPTIONS_CTOR); + resolveSite(KIOU_HOOK_NAME_KIFWRITEOPTIONS_CTOR); } if (!g_KIFWriter_Write) { g_KIFWriter_Write = (KIFWriter_Write_t) - (void *)(g_unityBase + RVA_KIFWRITER_WRITE); + resolveSite(KIOU_HOOK_NAME_KIFWRITER_WRITE); } if (!g_PositionToSFEN) { g_PositionToSFEN = (Position_ToSFEN_t) - (void *)(g_unityBase + RVA_POSITION_TO_SFEN); + resolveSite(KIOU_HOOK_NAME_POSITION_TO_SFEN); } } // --------------------------------------------------------------------------- -// KFKifTextFromGameController +// KIOUKifTextFromGameController // // Run the full GetUSIText → ParseUSI → KIFWriteOptions..ctor → -// KFKifFillWriteOptions → KIFWriter.Write pipeline and return the +// KIOUKifFillWriteOptions → KIFWriter.Write pipeline and return the // resulting KIF 2.0 string. Returns nil on any failure (NULL receiver, // USI text empty, ParseUSI gave back NULL, KIFWriter.Write threw, …). // @@ -197,7 +205,7 @@ static void resolveIl2cppFunctions(void) { // instance accessors and static methods in KIOU. Confirmed by Frida probe // (packages/frida/hook_kiou_kifwriter_probe.js). // --------------------------------------------------------------------------- -NSString *KFKifTextFromGameController(void *gameCtrl, +NSString *KIOUKifTextFromGameController(void *gameCtrl, void *matchConfig, void *stateStore, const char *matchModeTag) { @@ -260,7 +268,7 @@ static void resolveIl2cppFunctions(void) { // Step 3.5: fill the user-visible KIFWriteOptions fields. Safe to call // unconditionally — internal failures fall back to leaving the // specific field unset, which matches the pre-Phase-3 behavior. - KFKifFillWriteOptions(opts, matchConfig, stateStore, gameCtrl, matchModeTag); + KIOUKifFillWriteOptions(opts, matchConfig, stateStore, gameCtrl, matchModeTag); // Step 4: KIFWriter.Write(record, opts) → KIF 2.0 string void *kifStrPtr = NULL; @@ -280,14 +288,14 @@ static void resolveIl2cppFunctions(void) { } // --------------------------------------------------------------------------- -// KFKifDescribeStartpos +// KIOUKifDescribeStartpos // // Look at the GameController's PositionHistory[0] — that's the starting // position the match was set up from. Convert to SFEN; if it equals the // standard initial SFEN, return "startpos". Otherwise hash the SFEN and // return "sfen-<8 hex>" so the filename stays short. // --------------------------------------------------------------------------- -NSString *KFKifDescribeStartpos(void *gameCtrl) { +NSString *KIOUKifDescribeStartpos(void *gameCtrl) { resolveIl2cppFunctions(); if (!g_PositionToSFEN) return @"unknown"; if (!ptrLooksValid(gameCtrl)) return @"unknown"; @@ -325,13 +333,13 @@ static void resolveIl2cppFunctions(void) { } // --------------------------------------------------------------------------- -// KFKifTimestamp +// KIOUKifTimestamp // // Format the current wall clock as "YYYYMMDDTHHMMSS" (UTC). No separators // other than the ISO 'T' so the result is safe to drop into a POSIX // filename and sorts lexicographically. // --------------------------------------------------------------------------- -NSString *KFKifTimestamp(void) { +NSString *KIOUKifTimestamp(void) { static NSDateFormatter *fmt = nil; static dispatch_once_t once; dispatch_once(&once, ^{ @@ -344,7 +352,7 @@ static void resolveIl2cppFunctions(void) { } // --------------------------------------------------------------------------- -// KFKifSanitizeSegment +// KIOUKifSanitizeSegment // // Strip characters that are unsafe in POSIX filenames: NUL, '/', and any // C0/C1 control characters (U+0000–U+001F, U+007F–U+009F). Everything @@ -355,7 +363,7 @@ static void resolveIl2cppFunctions(void) { // Truncates to `maxChars` NSString code units after stripping. Falls back to // @"unknown" when the result is empty. // --------------------------------------------------------------------------- -NSString *KFKifSanitizeSegment(NSString *s, NSUInteger maxChars) { +NSString *KIOUKifSanitizeSegment(NSString *s, NSUInteger maxChars) { if (s.length == 0) return @"unknown"; NSMutableString *out = [NSMutableString stringWithCapacity:s.length]; for (NSUInteger i = 0; i < s.length; i++) { @@ -378,10 +386,10 @@ static void resolveIl2cppFunctions(void) { } // --------------------------------------------------------------------------- -// KFKifDescribeOpponents +// KIOUKifDescribeOpponents // // Build the "{black}vs{white}" filename segment from the live match objects. -// Mirrors the name-resolution order used by KFKifFillWriteOptions: +// Mirrors the name-resolution order used by KIOUKifFillWriteOptions: // 1. GameStateStore ReactiveProperty (online friend matches — // MatchConfig stays at the "プレイヤー" placeholder for the whole game, // but stateStore gets the real name once peer info arrives) @@ -390,7 +398,7 @@ static void resolveIl2cppFunctions(void) { // Either side may fall back to "unknown" independently. // Returns @"unknownvsunknown" when both sources are absent. // --------------------------------------------------------------------------- -NSString *KFKifDescribeOpponents(void *matchConfig, void *stateStore) { +NSString *KIOUKifDescribeOpponents(void *matchConfig, void *stateStore) { NSString *black = nil; NSString *white = nil; @@ -426,13 +434,13 @@ static void resolveIl2cppFunctions(void) { } } - NSString *b = KFKifSanitizeSegment(black ?: @"", 24); - NSString *w = KFKifSanitizeSegment(white ?: @"", 24); + NSString *b = KIOUKifSanitizeSegment(black ?: @"", 24); + NSString *w = KIOUKifSanitizeSegment(white ?: @"", 24); return [NSString stringWithFormat:@"%@vs%@", b, w]; } // --------------------------------------------------------------------------- -// KFIl2cppStringNew +// KIOUIl2cppStringNew // // dlsym the runtime's il2cpp_string_new export the first time we need it // and cache the function pointer. Returns NULL on: @@ -443,14 +451,14 @@ static void resolveIl2cppFunctions(void) { // "not present" which is what we want anyway) // // The returned il2cpp string is owned by the il2cpp runtime. See the -// Internal.h declaration of KFIl2cppStringNew for the GC lifetime caveat — +// Internal.h declaration of KIOUIl2cppStringNew for the GC lifetime caveat — // the string is NOT rooted, and we rely on the conservative Boehm GC // scanning the live stacks during the synchronous KIFWriter.Write to // keep it alive. That's an implementation assumption, not a contract. // --------------------------------------------------------------------------- typedef void *(*il2cpp_string_new_t)(const char *str); -void *KFIl2cppStringNew(const char *utf8) { +void *KIOUIl2cppStringNew(const char *utf8) { if (!utf8 || !*utf8) return NULL; static il2cpp_string_new_t fn = NULL; static dispatch_once_t once; @@ -550,7 +558,7 @@ static void resolveIl2cppFunctions(void) { } // --------------------------------------------------------------------------- -// KFKifFillWriteOptions +// KIOUKifFillWriteOptions // // Write the five user-visible KIFWriteOptions fields directly into `opts`. // `opts` MUST be a buffer that g_KIFOpts_Ctor() has already run on. @@ -596,7 +604,7 @@ static void resolveIl2cppFunctions(void) { // - rooting each string with il2cpp_gchandle_new for the duration // of KIFWriter.Write and releasing the handles afterwards. // --------------------------------------------------------------------------- -void KFKifFillWriteOptions(void *opts, +void KIOUKifFillWriteOptions(void *opts, void *matchConfig, void *stateStore, void *gameCtrl, @@ -724,11 +732,11 @@ void KFKifFillWriteOptions(void *opts, // ----- MatchTitle: "{mode} @ {iso8601}" via il2cpp_string_new. { - NSString *iso = KFKifTimestamp(); + NSString *iso = KIOUKifTimestamp(); NSString *title = [NSString stringWithFormat:@"%s @ %@", matchModeTag ? matchModeTag : "unknown", iso]; void *titleStr = - KFIl2cppStringNew(title.UTF8String); + KIOUIl2cppStringNew(title.UTF8String); kif_trace_log(@"[FILL] MatchTitle il2cpp_string_new(\"%@\") = %p", title, titleStr); if (titleStr) { @@ -745,7 +753,7 @@ void KFKifFillWriteOptions(void *opts, matchConfig, MC_OFF_TIME_CONTROL, tc, label ?: @"(nil)"); if (label) { - void *labelStr = KFIl2cppStringNew(label.UTF8String); + void *labelStr = KIOUIl2cppStringNew(label.UTF8String); if (labelStr) { memcpy(base + KIFOPTS_OFF_TIME_RULE_LABEL, &labelStr, sizeof(void *)); @@ -761,7 +769,7 @@ void KFKifFillWriteOptions(void *opts, gameCtrl, GC_OFF_WIN_REASON, (int)reason, label ?: @"(nil — none/unset)"); if (label) { - void *labelStr = KFIl2cppStringNew(label.UTF8String); + void *labelStr = KIOUIl2cppStringNew(label.UTF8String); if (labelStr) { memcpy(base + KIFOPTS_OFF_ENDING_LABEL, &labelStr, sizeof(void *)); @@ -791,7 +799,7 @@ void KFKifFillWriteOptions(void *opts, } // --------------------------------------------------------------------------- -// KFKifEnsureOutputDir +// KIOUKifEnsureOutputDir // // Returns the absolute path of ~/Documents/KiouForge/, creating it if // necessary. NSDocumentDirectory resolves to the running app's sandbox @@ -803,7 +811,7 @@ void KFKifFillWriteOptions(void *opts, // // Returns nil if NSFileManager refuses to create the directory. // --------------------------------------------------------------------------- -NSString *KFKifEnsureOutputDir(void) { +NSString *KIOUKifEnsureOutputDir(void) { NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); diff --git a/Sources/KiouForge/Kif/Writer.m b/Sources/KiouForge/Kif/Writer.m index e6edf72..20e7b4e 100644 --- a/Sources/KiouForge/Kif/Writer.m +++ b/Sources/KiouForge/Kif/Writer.m @@ -23,14 +23,14 @@ // names are safe to include directly without percent-encoding or hashing. // =========================================================================== -NSString *KFKifWriterEmit(void *gameCtrl, +NSString *KIOUKifWriterEmit(void *gameCtrl, void *matchConfig, void *stateStore, const char *matchModeTag) { // 1. Get the KIF text. matchConfig / stateStore may be NULL — in // that case player names and time-rule label come out blank, // which is acceptable. - NSString *kif = KFKifTextFromGameController(gameCtrl, + NSString *kif = KIOUKifTextFromGameController(gameCtrl, matchConfig, stateStore, matchModeTag); @@ -43,7 +43,7 @@ } // 2. Make sure the output directory exists. - NSString *outDir = KFKifEnsureOutputDir(); + NSString *outDir = KIOUKifEnsureOutputDir(); if (!outDir) { IPALog(@"[KIF] emit failed: output dir unavailable"); return nil; @@ -53,12 +53,12 @@ // Format: {timestamp}_{mode}_{black}vs{white}_{startpos}.kif // Player names are kept as-is (Unicode) — APFS and iOS Files handle // Japanese filenames natively. Only POSIX-unsafe characters (NUL, '/', - // control codes) are stripped by KFKifSanitizeSegment. - NSString *ts = KFKifTimestamp(); - NSString *modeSeg = KFKifSanitizeSegment( + // control codes) are stripped by KIOUKifSanitizeSegment. + NSString *ts = KIOUKifTimestamp(); + NSString *modeSeg = KIOUKifSanitizeSegment( matchModeTag ? @(matchModeTag) : @"unknown", 32); - NSString *opponentsSeg = KFKifDescribeOpponents(matchConfig, stateStore); - NSString *startposSeg = KFKifDescribeStartpos(gameCtrl); + NSString *opponentsSeg = KIOUKifDescribeOpponents(matchConfig, stateStore); + NSString *startposSeg = KIOUKifDescribeStartpos(gameCtrl); NSString *filename = [NSString stringWithFormat:@"%@_%@_%@_%@.kif", ts, modeSeg, opponentsSeg, startposSeg]; @@ -86,16 +86,16 @@ } // =========================================================================== -// KFKifuObserveMatchEnd — entry point for all 5 IMatchMode.OnMatchEnd +// KIOUKifuObserveMatchEnd — entry point for all 5 IMatchMode.OnMatchEnd // sites. The observer cave (recipes/kiouforge.py) saves caller registers, // injects the mode index in X2 via MOVZ, BLRs through the slot, restores // registers, executes the displaced prologue, then jumps to orig+4. So -// our return value is effectively dead — we still zero a KFUniTaskRet for +// our return value is effectively dead — we still zero a KIOUUniTaskRet for // shape correctness. // // We honour two feature flags: // * master KIOU_FEATURE_KIFU_AUTOSAVE -// * per-mode KFKifuModeEnabled(mode_index) +// * per-mode KIOUKifuModeEnabled(mode_index) // Either being off skips emission silently. // =========================================================================== @@ -121,17 +121,17 @@ return readPtr(adapter, KIOU_ADAPTER_OFF_GAMECTRL); } -KFUniTaskRet KFKifuObserveMatchEnd(void *self, void *ct, +KIOUUniTaskRet KIOUKifuObserveMatchEnd(void *self, void *ct, uint32_t mode_index) { (void)ct; - KFUniTaskRet zero = { NULL, NULL }; + KIOUUniTaskRet zero = { NULL, NULL }; // Master toggle. - if (!KFFeatureEnabled(KIOU_FEATURE_KIFU_AUTOSAVE)) return zero; + if (!KIOUFeatureEnabled(KIOU_FEATURE_KIFU_AUTOSAVE)) return zero; // Per-mode toggle. if (mode_index < KIOU_MMODE_COUNT && - !KFKifuModeEnabled((KiouMatchMode)mode_index)) { + !KIOUKifuModeEnabled((KiouMatchMode)mode_index)) { return zero; } @@ -146,20 +146,25 @@ KFUniTaskRet KFKifuObserveMatchEnd(void *self, void *ct, return zero; } - // MatchConfig / GameStateStore only available on OnlinePvPMode's `self`; - // other modes' KIF gets blank player names (acceptable for offline play). + // All five IMatchMode implementations carry a GameStateStore that + // KIOUKifDescribeOpponents / KIOUKifFillWriteOptions can read player + // names from via ReactiveProperty. Only OnlinePvPMode + // additionally exposes a MatchConfig — the other modes seed the + // PlayerInfo directly into stateStore at match start. void *matchConfig = NULL; void *stateStore = NULL; + if (mode_index < KIOU_MMODE_COUNT && ptrLooksValid(self)) { + stateStore = readPtr(self, kKiouMatchModeStateStoreOffsets[mode_index]); + } if (mode_index == KIOU_MMODE_ONLINE_PVP && ptrLooksValid(self)) { matchConfig = readPtr(self, ONLINEPVPMODE_OFF_MATCHCONFIG); - stateStore = readPtr(self, ONLINEPVPMODE_OFF_STATE_STORE); } IPALog([NSString stringWithFormat: @"[KIFU] %s self=%p gameCtrl=%p matchConfig=%p stateStore=%p — emitting", modeName, self, gameCtrl, matchConfig, stateStore]); - NSString *path = KFKifWriterEmit(gameCtrl, matchConfig, stateStore, modeName); + NSString *path = KIOUKifWriterEmit(gameCtrl, matchConfig, stateStore, modeName); if (path) { IPALog([NSString stringWithFormat:@"[KIFU] %s -> %@", modeName, path]); } diff --git a/Sources/KiouForge/Persistence.m b/Sources/KiouForge/Persistence.m index 74cc27c..45e57af 100644 --- a/Sources/KiouForge/Persistence.m +++ b/Sources/KiouForge/Persistence.m @@ -17,7 +17,7 @@ } } -NSString *KFFeatureLabel(KiouFeature f) { +NSString *KIOUFeatureLabel(KiouFeature f) { switch (f) { case KIOU_FEATURE_FPS_OVERRIDE: return @"FPS Override"; case KIOU_FEATURE_DISABLE_AFK: return @"AFK Guard"; @@ -27,13 +27,13 @@ } } -bool KFFeatureHasNavigation(KiouFeature f) { +bool KIOUFeatureHasNavigation(KiouFeature f) { // Kifu Autosave drills into a per-mode sub-screen. Everything else is // a plain toggle. return f == KIOU_FEATURE_KIFU_AUTOSAVE; } -bool KFFeatureEnabled(KiouFeature f) { +bool KIOUFeatureEnabled(KiouFeature f) { NSString *key = featureKey(f); if (!key) return false; NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; @@ -42,7 +42,7 @@ bool KFFeatureEnabled(KiouFeature f) { return [defs boolForKey:key]; } -void KFSetFeatureEnabled(KiouFeature f, bool enabled) { +void KIOUSetFeatureEnabled(KiouFeature f, bool enabled) { NSString *key = featureKey(f); if (!key) return; NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; @@ -68,22 +68,22 @@ static int32_t clampInt(int32_t v, int32_t lo, int32_t hi) { return v; } -int32_t KFFPSIndex(void) { +int32_t KIOUFPSIndex(void) { NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; if ([defs objectForKey:kFpsIndexKey] == nil) return 4; // index 4 = 60 fps return clampInt((int32_t)[defs integerForKey:kFpsIndexKey], 0, KIOU_FPS_PRESET_COUNT - 1); } -void KFSetFPSIndex(int32_t idx) { +void KIOUSetFPSIndex(int32_t idx) { NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; [defs setInteger:clampInt(idx, 0, KIOU_FPS_PRESET_COUNT - 1) forKey:kFpsIndexKey]; [defs synchronize]; } -int32_t KFTargetFPS(void) { - return kFpsPresets[KFFPSIndex()]; +int32_t KIOUTargetFPS(void) { + return kFpsPresets[KIOUFPSIndex()]; } // --------------------------------------------------------------------------- @@ -94,7 +94,7 @@ int32_t KFTargetFPS(void) { static NSString *const kAnalysisHashIndexKey = @"kiou_forge.analysis_hash_idx"; static NSString *const kAnalysisSkillLevelKey = @"kiou_forge.analysis_skill_level"; -int32_t KFAnalysisDepth(void) { +int32_t KIOUAnalysisDepth(void) { // Match the retail KifuDetailPopupAnalysisPresenter.SearchDepth default (15). // Users raise it for stronger analysis at the cost of longer run time. NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; @@ -102,7 +102,7 @@ int32_t KFAnalysisDepth(void) { return clampInt((int32_t)[defs integerForKey:kAnalysisDepthKey], 1, 36); } -void KFSetAnalysisDepth(int32_t v) { +void KIOUSetAnalysisDepth(int32_t v) { NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; [defs setInteger:clampInt(v, 1, 36) forKey:kAnalysisDepthKey]; [defs synchronize]; @@ -114,31 +114,31 @@ void KFSetAnalysisDepth(int32_t v) { 16, 64, 128, 256, 512, 1024 }; -int32_t KFAnalysisHashIndex(void) { +int32_t KIOUAnalysisHashIndex(void) { NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; if ([defs objectForKey:kAnalysisHashIndexKey] == nil) return 0; // 16 MB return clampInt((int32_t)[defs integerForKey:kAnalysisHashIndexKey], 0, KIOU_ANALYSIS_HASH_PRESET_COUNT - 1); } -void KFSetAnalysisHashIndex(int32_t idx) { +void KIOUSetAnalysisHashIndex(int32_t idx) { NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; [defs setInteger:clampInt(idx, 0, KIOU_ANALYSIS_HASH_PRESET_COUNT - 1) forKey:kAnalysisHashIndexKey]; [defs synchronize]; } -int32_t KFAnalysisHashMB(void) { - return kAnalysisHashPresetsMB[KFAnalysisHashIndex()]; +int32_t KIOUAnalysisHashMB(void) { + return kAnalysisHashPresetsMB[KIOUAnalysisHashIndex()]; } -int32_t KFAnalysisSkillLevel(void) { +int32_t KIOUAnalysisSkillLevel(void) { NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; if ([defs objectForKey:kAnalysisSkillLevelKey] == nil) return 20; return clampInt((int32_t)[defs integerForKey:kAnalysisSkillLevelKey], 1, 20); } -void KFSetAnalysisSkillLevel(int32_t v) { +void KIOUSetAnalysisSkillLevel(int32_t v) { NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; [defs setInteger:clampInt(v, 1, 20) forKey:kAnalysisSkillLevelKey]; [defs synchronize]; @@ -162,7 +162,7 @@ void KFSetAnalysisSkillLevel(int32_t v) { } } -NSString *KFKifuModeLabel(KiouMatchMode m) { +NSString *KIOUKifuModeLabel(KiouMatchMode m) { switch (m) { case KIOU_MMODE_AI_MATCH: return @"AI Match"; case KIOU_MMODE_CPU_STREAM: return @"CPU Stream"; @@ -173,7 +173,7 @@ void KFSetAnalysisSkillLevel(int32_t v) { } } -bool KFKifuModeEnabled(KiouMatchMode m) { +bool KIOUKifuModeEnabled(KiouMatchMode m) { NSString *key = kifuModeKey(m); if (!key) return false; NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; @@ -182,7 +182,7 @@ bool KFKifuModeEnabled(KiouMatchMode m) { return [defs boolForKey:key]; } -void KFSetKifuModeEnabled(KiouMatchMode m, bool enabled) { +void KIOUSetKifuModeEnabled(KiouMatchMode m, bool enabled) { NSString *key = kifuModeKey(m); if (!key) return; NSUserDefaults *defs = [NSUserDefaults standardUserDefaults]; diff --git a/Sources/KiouForge/Settings.h b/Sources/KiouForge/Settings.h index 7c05a34..901d288 100644 --- a/Sources/KiouForge/Settings.h +++ b/Sources/KiouForge/Settings.h @@ -10,10 +10,10 @@ // settings table itself lives in Hook_SettingsUI.m and Persistence.m — this // header only declares the gesture installer that opens it. // -// KFGestureInstall() — attach a UIScreenEdgePanGestureRecognizer +// KIOUGestureInstall() — attach a UIScreenEdgePanGestureRecognizer // (right edge) to the key window. Right-edge swipe → present the // existing KEditorSettingsViewController via -// `KFPresentSettings()` (defined in Hook_SettingsUI.m). +// `KIOUPresentSettings()` (defined in Hook_SettingsUI.m). // // Safe to call multiple times (no-op once a right-edge recognizer is // already attached) and safe to call from any thread — the @@ -21,4 +21,4 @@ // and retries until the UIWindow is up. // =========================================================================== -void KFGestureInstall(void); +void KIOUGestureInstall(void); diff --git a/Sources/KiouForge/Settings.m b/Sources/KiouForge/Settings.m index 2600606..60ef9a1 100644 --- a/Sources/KiouForge/Settings.m +++ b/Sources/KiouForge/Settings.m @@ -9,7 +9,7 @@ // // Design: // * The settings table itself is owned by Hook_SettingsUI.m via -// `KFPresentSettings()`. We don't duplicate any of that +// `KIOUPresentSettings()`. We don't duplicate any of that // UI here — we only own the gesture that opens it. // * The recognizer is attached to the key window's gesture-recognizer // list. Touches inside game UI are unaffected: only screen-edge @@ -22,13 +22,13 @@ // stay easy to diff side-by-side. // =========================================================================== -// KFPresentSettings() is implemented in Hook_SettingsUI.m. Same +// KIOUPresentSettings() is implemented in Hook_SettingsUI.m. Same // extern-in-callsite pattern Hook_FriendUnhide.m uses; do not lift this // into Internal.h. -extern void KFPresentSettings(void); +extern void KIOUPresentSettings(void); // --------------------------------------------------------------------------- -// KFKeyWindow — iOS 13+ safe replacement for the deprecated +// KIOUKeyWindow — iOS 13+ safe replacement for the deprecated // [UIApplication sharedApplication].keyWindow. Walks connected // UIWindowScenes and returns the first key window in a foreground-active // scene. Falls back to the first visible window if none is marked key. @@ -38,7 +38,7 @@ // at file scope). Keeping a copy here costs nothing and means this file is // self-contained — Hook_SettingsUI.m doesn't need to expose its helper. // --------------------------------------------------------------------------- -static UIWindow *KFKeyWindow(void) { +static UIWindow *KIOUKeyWindow(void) { for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { if (![scene isKindOfClass:[UIWindowScene class]]) continue; UIWindowScene *ws = (UIWindowScene *)scene; @@ -52,46 +52,46 @@ } // =========================================================================== -// KFGestureHandler — target for the UIScreenEdgePanGestureRecognizer. +// KIOUGestureHandler — target for the UIScreenEdgePanGestureRecognizer. // Kept as a separate NSObject so its lifetime is independent of any VC. // =========================================================================== -@interface KFGestureHandler : NSObject +@interface KIOUGestureHandler : NSObject - (void)handleEdgePan:(UIScreenEdgePanGestureRecognizer *)gr; @end -@implementation KFGestureHandler +@implementation KIOUGestureHandler - (void)handleEdgePan:(UIScreenEdgePanGestureRecognizer *)gr { // Fire on Began only — we don't need to track the drag. if (gr.state != UIGestureRecognizerStateBegan) return; IPALog(@"[KF] right-edge swipe began -> presenting settings"); - KFPresentSettings(); + KIOUPresentSettings(); } @end // --------------------------------------------------------------------------- -// KFGestureInstall — public entry point called from Tweak.m. +// KIOUGestureInstall — public entry point called from Tweak.m. // // Attaches a UIScreenEdgePanGestureRecognizer (right edge) to the key // window. The handler object is retained statically so it lives for the // app's lifetime without needing an owner. Retries every second until // the window is up; once attached, becomes a no-op. // --------------------------------------------------------------------------- -void KFGestureInstall(void) { +void KIOUGestureInstall(void) { dispatch_async(dispatch_get_main_queue(), ^{ - UIWindow *win = KFKeyWindow(); + UIWindow *win = KIOUKeyWindow(); if (!win) { dispatch_after( dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - KFGestureInstall(); + KIOUGestureInstall(); }); return; } - // Guard: don't install twice (e.g. if KFGestureInstall is somehow + // Guard: don't install twice (e.g. if KIOUGestureInstall is somehow // called a second time after a window recreation). for (UIGestureRecognizer *gr in win.gestureRecognizers) { if ([gr isKindOfClass:[UIScreenEdgePanGestureRecognizer class]]) { @@ -104,9 +104,9 @@ void KFGestureInstall(void) { } } - static KFGestureHandler *sHandler = nil; + static KIOUGestureHandler *sHandler = nil; static dispatch_once_t once; - dispatch_once(&once, ^{ sHandler = [[KFGestureHandler alloc] init]; }); + dispatch_once(&once, ^{ sHandler = [[KIOUGestureHandler alloc] init]; }); UIScreenEdgePanGestureRecognizer *gr = [[UIScreenEdgePanGestureRecognizer alloc] diff --git a/Sources/KiouForge/Tweak.m b/Sources/KiouForge/Tweak.m index e848ea2..e2a8f90 100644 --- a/Sources/KiouForge/Tweak.m +++ b/Sources/KiouForge/Tweak.m @@ -8,11 +8,11 @@ // KiouForge — entry point. // // Distribution shapes: -// JB rootless / jailed (Dobby static): KFInstall*Hook() patches the +// JB rootless / jailed (Dobby static): KIOUInstall*Hook() patches the // UnityFramework at runtime via MSHookFunction / DobbyHook. // Chinlan (make ipa): UnityFramework is statically rewritten so each // site BLs into a __TEXT cave; the dylib publishes hook pointers into -// the __DATA slot table via KFChinlanPublish(). +// the __DATA slot table via KIOUChinlanPublish(). // =========================================================================== static BOOL g_unityHooked = NO; @@ -46,15 +46,16 @@ static void installUnityHooks(uintptr_t unityBase, const char *unityName) { (unsigned long)unityBase, unityName ? unityName : "?"]); #if IPA_CHINLAN - // Publish the slot table and bypass entries before any KFInstall* runs; + // Publish the slot table and bypass entries before any KIOUInstall* runs; // the chinlan branch of each installer reads g_kfHookSlot. - KFChinlanPublish(unityBase); + KIOUChinlanPublish(unityBase); #endif - KFInstallFrameRateHook(unityBase); - KFInstallAnalysisTuneHook(unityBase); - KFInstallKifuObserveHook(unityBase); - KFInstallAccountObserveHook(unityBase); - KFInstallGrpcLoggingHook(unityBase); + KIOUInstallFrameRateHook(unityBase); + KIOUInstallAnalysisTuneHook(unityBase); + KIOUInstallKifuObserveHook(unityBase); + KIOUInstallAccountObserveHook(unityBase); + KIOUInstallGrpcLoggingHook(unityBase); + KIOUInstallAfkSuppressHook(unityBase); g_unityHooked = YES; IPALog(@"=== KiouForge: all hooks installed ==="); @@ -81,7 +82,7 @@ static void installUnityHooks(uintptr_t unityBase, const char *unityName) { // Settings panel (right-edge swipe). Dispatches to main queue internally // and retries until the key window is available — safe to call here. - KFGestureInstall(); + KIOUGestureInstall(); // Wire UnityFramework hooks the moment UnityFramework is mapped. // _dyld_register_func_for_add_image fires synchronously for every image diff --git a/control b/control index e1ca59c..091f645 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: work.tkgstrator.kiouforge Name: KiouForge -Version: 0.2.1 +Version: 0.2.2 Architecture: iphoneos-arm64 Description: KIOU (Shogi) local quality-of-life tool. Extends the frame-rate preset beyond 30/60, suppresses false AFK warnings during long-think sessions, and lets you tune the depth and hash used by the on-device post-game kifu analysis engine. Runs entirely client-side and never modifies any server-stored data. For authorized testing only. Maintainer: NEVER KNOWS BEST diff --git a/docs/plans/kifu-sqlite-storage.md b/docs/plans/kifu-sqlite-storage.md new file mode 100644 index 0000000..3b73892 --- /dev/null +++ b/docs/plans/kifu-sqlite-storage.md @@ -0,0 +1,283 @@ +# Kifu SQLite storage + in-app viewer + +Move .kif output away from loose files under `Documents/KiouForge/` to a +single SQLite database plus an in-app viewer sheet reachable from the +existing right-edge-swipe settings surface. + +## Motivation + +Current shape (`Sources/KiouForge/Kif/Writer.m::KIOUKifWriterEmit`): + +- On resign / match end, generate a KIF text via `KIOUKifTextFromGameController` + and write it as UTF-8 to `Documents/KiouForge/__vs_.kif`. +- Filenames encode metadata so the Files app view is somewhat browsable, + but the naming scheme is long, non-searchable, and locale-fragile + (Japanese names inside filenames survive APFS but confuse some + external viewers). + +Pain points: + +- No in-app browsing. Users must open Files, dig into the tweak's + Documents directory, and pick a file with a long name. +- No filtering by date / mode / opponent. +- No delete UI — pruning old kifu is a manual Files.app job. +- No share / export UI — moving a kifu to another shogi viewer app + needs Files.app copy dance. +- Filename length keeps growing as we add match metadata. + +Target shape: + +- Store the KIF blob plus metadata in `Documents/KiouForge/kifu.sqlite`. +- Add a "棋譜一覧" entry to the settings sheet that opens a list view + backed by the database. +- Detail view shows the KIF text with a share sheet handoff (so + PiyoShogi / Shogi Browser Q can still consume it). + +## Scope + +### In scope + +- SQLite schema + `KifuStorage` wrapper that owns opening, migrations, + insert-on-match-end, list, fetch-by-id, delete. +- `KifuListSheet` view controller (UITableView) + `KifuDetailSheet` + (UITextView + share button). +- One entry in the existing settings sheet routing into + `KifuListSheet`. +- Replace the file write in `KIOUKifWriterEmit` with a + `KifuStorage` insert. +- Migration: on first launch after upgrade, scan the existing + `Documents/KiouForge/*.kif`, insert each into the DB, delete the + source file. Guard with a "migrated" flag so it only runs once. + +### Out of scope (deferred) + +- Cloud sync / iCloud kifu library. +- Multi-user separation (one DB per KIOU account). +- Search over move sequences. +- Editing the KIF blob in-app. + +## Design + +### Storage layer — `Sources/KiouForge/Storage/KifuStorage.{h,m}` + +Uses the iOS-provided `sqlite3` C API — link with `-lsqlite3` +(add to Makefile `TWEAK_NAME_LDFLAGS`). No FMDB / GRDB / external +dependency; keeps the tweak self-contained. + +Public API: + +```objc +// Open/close (singleton, opens on first use, closes at dylib unload). +sqlite3 *KifuStorageDatabase(void); + +// Insert a game record. Returns 0 on success, sqlite errcode otherwise. +// Caller keeps ownership of NSStrings; the wrapper copies as needed. +int KifuStorageInsert(NSString *modeName, + NSString *blackName, + NSString *whiteName, + NSString *startposSeg, + NSString *kifText, + NSDate *playedAt); + +// Enumerate all games most-recent-first. Rows are lightweight +// dictionaries: id, played_at (NSDate), mode, black, white, startpos. +// KIF text is intentionally excluded — fetched lazily by detail view. +NSArray *KifuStorageList(NSUInteger limit, NSUInteger offset); + +// Fetch a single row including the KIF blob for the detail view. +NSDictionary *KifuStorageFetch(int64_t rowId); + +// Delete a row. +BOOL KifuStorageDelete(int64_t rowId); +``` + +Schema (SQL run at `KifuStorageDatabase` open time): + +```sql +CREATE TABLE IF NOT EXISTS games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + played_at TEXT NOT NULL, -- ISO 8601 UTC + mode TEXT NOT NULL, + black TEXT NOT NULL, + white TEXT NOT NULL, + startpos TEXT NOT NULL, -- "startpos" | "sfen-" | "unknown" + kif_text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE INDEX IF NOT EXISTS games_played_at_idx ON games(played_at DESC); +``` + +`kif_text` stays UTF-8 (the file version already is). No blob column — +text keeps the schema greppable via `sqlite3` CLI for debugging. + +Migration table for future schema bumps: + +```sql +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); +``` + +Initial insert: `meta('schema_version', '1')`. Migrations bump this and +apply ALTER statements in sequence. + +Threading: everything runs on the caller's thread. Match-end fires on +the game's async continuation — the actual sqlite write completes in +microseconds so no queue needed. Detail view's fetch happens on the +main thread; already inside a UITableView cell tap handler which is +fine. + +### File → DB migration — one-shot at first launch + +`KifuStorageMigrateLegacyFiles()` called from `installUnityHooks` once +per session, guarded by `meta('legacy_files_migrated', '1')`: + +- Enumerate `Documents/KiouForge/*.kif` via `NSFileManager`. +- For each: parse filename `__vs_.kif` into + fields; read text via `stringWithContentsOfFile:`; call + `KifuStorageInsert`; delete source with `removeItemAtPath:`. +- Set the flag even on partial failure so a broken file doesn't keep + triggering migration attempts. + +If the filename doesn't parse cleanly, insert with best-effort +placeholders (`mode="unknown"` etc.) and log the discrepancy — losing +metadata is better than losing the kifu. + +### List sheet — `Sources/KiouForge/UI/KifuListSheet.{h,m}` + +```objc +@interface KifuListSheet : UIViewController ++ (void)presentFromViewController:(UIViewController *)parent; +@end +``` + +- `UITableView` with plain-style cells: + - Left: `played_at` formatted as `2026/07/02 01:56` (ja_JP locale). + - Center: `mode` short label ("CPU" / "AI" / "対局" / "リプレイ"). + - Right: ` vs ` truncated to 24 chars. + - Startpos badge (small chip) when non-standard. +- Swipe-to-delete calls `KifuStorageDelete` and reloads. +- Row tap pushes `KifuDetailSheet` with the row id. +- Empty state: friendly centered label + hint about resigning a match. +- Modal presentation via `UISheetPresentationController` with + `.medium()` and `.large()` detents so users can peek then expand. + +### Detail sheet — `Sources/KiouForge/UI/KifuDetailSheet.{h,m}` + +```objc +@interface KifuDetailSheet : UIViewController ++ (instancetype)detailForRowId:(int64_t)rowId; +@end +``` + +- `UITextView` (read-only, monospaced) showing the KIF blob. +- Nav bar right item: `UIActivityViewController` share sheet with the + KIF text — hands off to any registered UTI-aware app. +- Nav bar left item: close. +- Long-press on text → copy-all shortcut. + +Filename for external share stays `__vs.kif` (built +from row fields) so downstream apps see the familiar name even though +the local storage is DB-backed. + +### Settings entry — `Sources/KiouForge/Hook/SettingsUI.m` + +Add a row above the existing feature toggles: + +``` +┌─ 棋譜 ───────────────────────── +│ 棋譜一覧 ...................... › +└──────────────────────────────── +``` + +Tapping presents `KifuListSheet` as a sheet on top of the settings +modal. Nothing else in the settings surface changes. + +### Writer integration — `Sources/KiouForge/Kif/Writer.m` + +`KIOUKifWriterEmit` collapses to roughly: + +```objc +NSString *KIOUKifWriterEmit(void *gameCtrl, void *matchConfig, + void *stateStore, const char *matchModeTag) { + NSString *kif = KIOUKifTextFromGameController(gameCtrl, matchConfig, + stateStore, matchModeTag); + if (kif.length == 0) return nil; + + NSString *modeName = matchModeTag ? @(matchModeTag) : @"unknown"; + NSString *opponents = KIOUKifDescribeOpponents(matchConfig, stateStore); + NSString *startpos = KIOUKifDescribeStartpos(gameCtrl); + NSArray *sides = [opponents componentsSeparatedByString:@"vs"]; + NSString *black = sides.count > 0 ? sides[0] : @"unknown"; + NSString *white = sides.count > 1 ? sides[1] : @"unknown"; + + KifuStorageInsert(modeName, black, white, startpos, kif, [NSDate date]); + return nil; // caller only used the return value for a log line +} +``` + +Existing log lines stay for parity; the "wrote 204 bytes -> path" +message becomes "inserted rowId=X len=Y bytes". + +## File tree diff + +``` +Sources/KiouForge/ +├── Storage/ +│ ├── KifuStorage.h (new) +│ └── KifuStorage.m (new) +├── UI/ +│ ├── KifuListSheet.h (new) +│ ├── KifuListSheet.m (new) +│ ├── KifuDetailSheet.h (new) +│ └── KifuDetailSheet.m (new) +├── Kif/ +│ └── Writer.m (modified: file write → KifuStorageInsert) +├── Hook/ +│ └── SettingsUI.m (modified: add 棋譜一覧 row) +├── Internal.h (modified: forward-decls for the new modules) +└── Tweak.m (modified: KifuStorageMigrateLegacyFiles call) + +Makefile (modified: -lsqlite3 + new .m files) +``` + +## Estimate + +- ~300-400 lines total across the new/modified files. +- Objective-C UIKit boilerplate is the bulk (KifuListSheet + KifuDetailSheet). +- SQLite wrapper is thin — the sqlite3 C API is verbose but the actual + logic per call is 10-15 lines. +- No new external dependency. +- 1-2 focused sessions once implementation starts. + +## Risks + +- **SQLite corruption on abnormal exit.** Wrap inserts in + `BEGIN IMMEDIATE / COMMIT` and set `PRAGMA journal_mode=WAL` so a + mid-write crash leaves the DB consistent. +- **Migration losing files.** Only delete source `.kif` after the + insert commits successfully. Set the migration flag *inside* the + same transaction as the last insert so a partial run is retryable. +- **UIKit view controller lifetime on top of a modal Settings.** + Present via the current top-most view controller + (`UIApplication.keyWindow.rootViewController.presentedViewController`), + not the settings VC directly, so dismissing the outer sheet doesn't + yank the list sheet mid-interaction. +- **KIF text getting very large on long replays.** SQLite handles + megabyte TEXT columns fine; UITextView is the bottleneck. Cap the + detail view's `text` length at 100 KB and offer a "share full file" + button if truncated. Not expected to hit in normal play (average + kifu is ~200 bytes). + +## Open questions + +- Retention policy? Keep everything forever, or auto-purge past N + entries? Default: forever, add a manual "全削除" button in settings. +- Should the migration also copy `.kif` files to a backup subfolder + before deleting, for one release? Safer, but adds complexity. Vote: + no — the DB is the new source of truth, and files are trivially + recoverable via share sheet. +- Sheet presentation icon: `SF Symbols` `list.bullet.rectangle` vs + `doc.text`. Not blocking. diff --git a/docs/plans/on-device-smoke.md b/docs/plans/on-device-smoke.md new file mode 100644 index 0000000..36bfdc1 --- /dev/null +++ b/docs/plans/on-device-smoke.md @@ -0,0 +1,313 @@ +# On-device smoke test via Frida + +Automate the end-to-end verification we currently do by hand after every +`make deploy`: launch → login → start CPU match → resign → confirm no +crash + kifu file was written. Run as a scriptable `make smoke` target +against a paired JB device. + +## Motivation + +Every non-trivial change to KIOU-Hook / KiouForge risks breaking one of +three things: + +- **Startup** — hook wiring, cave layout, chinlan dylib load order. + Currently verified by tailing `kiouforge.log` for `[CHINLAN] all + hooks installed` and checking `/var/mobile/Library/Logs/CrashReporter/` + for a fresh `.ips`. +- **Core play loop** — resign path (observer cave → dispatcher → + HookXxxEnd → kifu writer). Currently verified by manually starting a + CPU game, playing a move or two, resigning, then SSH-ing to check + the resulting kifu file. +- **Account flows** — login, account switch. Currently verified by + UI navigation. + +Manual verification is slow and easy to skip. We have already regressed +resign-time behaviour twice in this session because "it builds fine" +did not imply "it survives a match end". A scripted smoke check flips +that failure mode to CI-detectable. + +We deliberately do *not* aim for coverage of every screen — this +document scopes a **single happy-path smoke test** whose goal is to +catch cave / hook / RVA breakage before it reaches the device in +practice. Broader flows can layer on the same harness later. + +## Scope + +### In scope + +- A Frida script (`scripts/smoke/kiou_smoke.js`) that: + 1. Attaches to `com.neconome.shogi`. + 2. Confirms `[CHINLAN] all hooks installed` was logged since launch. + 3. Drives login → CPU match start → play N moves → resign via a mix + of presenter-level il2cpp calls and uGUI `Button.onClick.Invoke`. + 4. Confirms the expected side effects (kifu file present under + Documents/KiouForge, log lines matching the flow, no new + CrashReporter entries). +- A `make smoke` target that packages `make deploy` + Frida spawn + + pass/fail summary. +- A support module (`scripts/smoke/on_device.py`) that fetches log + tails and `.ips` files over SSH and diffs them against pre-run + baselines. + +### Out of scope (deferred) + +- Multi-mode coverage (AI / LocalPvP / OnlinePvP / RecordReplay). + Only CPUStreamMode is included — the other modes reuse the same + cave / dispatcher machinery so a single mode covers the regression + surface. Adding more modes is a copy-paste extension. +- Login-with-real-account flows. The smoke uses the pre-provisioned + KiouForge account already on the test device. +- Any UI screen not on the login → CPU戦 → resign path. Settings + panel, kifu viewer (post-SQLite migration), account switcher tests + are their own follow-ups. +- Real production-signing runs. The smoke assumes a JB device with a + developer-provisioned KIOU install. Non-JB / TestFlight paths need + a different harness (Corellium, XCUITest via re-signed IPA) that is + out of scope for this repo. + +## Prerequisites + +- **JB device** paired to the dev host via SSH (`root@$THEOS_DEVICE_IP`), + same setup `make deploy` already uses. +- **frida-server** running on device. Standard tweak — installable + via a `.deb` in `/opt/procursus/bin/frida-server` for rootless. + Bring-up: `ssh root@device 'frida-server --listen 0.0.0.0:27042 &'`. + Not persistent across reboots by default; the `make smoke` target + starts it if missing. +- **Frida CLI + node bindings on host** (`bun add frida-node + frida-il2cpp-bridge`, or `pip install frida-tools` if we go Python). + The design below is bun-based to match KiouForge's existing tool + chain in `shared/tools/`. +- **`assets/$TARGET_VERSION/dump.cs.index.json`** (already checked in + for 1.0.1 / 1.0.2 / 1.1.0) — Frida script reads class + method names + from it so RVAs never appear literally in the script (they change per + app version). + +## Architecture + +### Layer 1 — host runner (`scripts/smoke/run.sh`) + +Bash entry point invoked by `make smoke`. Responsibilities: + +1. Baseline snapshot: SSH to device, capture the current tail of + `/var/mobile/Containers/Data/Application//Documents/Logs/kiouforge.log` + and the list of `.ips` files with timestamps. +2. Ensure frida-server is up (start if not). +3. Spawn: `frida -U -f com.neconome.shogi -l kiou_smoke.js + --runtime=v8 --no-pause -o smoke.out`. +4. Wait up to 90 s for the script to emit either `SMOKE PASS` or + `SMOKE FAIL: ` on stdout, or a timeout / crash. +5. Verify from the device side (via `on_device.py`): + - No new `.ips` file appeared since baseline. + - `kiouforge.log` gained a line matching `[KIF] wrote \d+ bytes ->` + within the run window. + - The named kifu file exists on-device with non-zero length. +6. Emit a single-line summary and exit 0 (pass) or non-zero (fail). + +### Layer 2 — Frida script (`scripts/smoke/kiou_smoke.js`) + +Uses `frida-il2cpp-bridge` for il2cpp method discovery + invocation. +Structured as a state machine to survive async hook waits without +blowing the timeout budget on any single step: + +``` +step 1: wait for [CHINLAN] all hooks installed (30s cap) +step 2: dispatch CPU戦 start via il2cpp presenter +step 3: wait for MatchController state == InProgress (10s cap) +step 4: submit N=3 moves (either presenter direct or move-input hook) +step 5: dispatch resign via il2cpp +step 6: wait for KIOUKifWriterEmit log line (10s cap) +step 7: verify kifu file appeared under Documents/KiouForge +step 8: exit PASS +``` + +Each `wait for` step is a polling loop with per-step timeout that +emits a `SMOKE FAIL: step N timeout` if it does not resolve. + +Failure modes tracked explicitly: + +- Frida attach failed → `SMOKE FAIL: attach` +- `[CHINLAN] all hooks installed` never appears → `SMOKE FAIL: + hook install` +- Presenter call throws → `SMOKE FAIL: presenter threw: + ` +- Timeout on any wait step → `SMOKE FAIL: step N timeout` +- Fresh `.ips` appeared → `SMOKE FAIL: crash ` + +Success is a single line `SMOKE PASS: mode=CPUStreamMode moves=3 +kifu=`. + +### Layer 3 — device-side glue + +Nothing new here — the smoke leans on existing infrastructure: + +- Live log server on port 18082 (already implemented in Chinlan) — can + optionally be tailed by the host as a live signal rather than + polling the log file. +- `Documents/Logs/kiouforge.log` is where the tweak already writes + its structured lines; the smoke matches specific ones. +- `Documents/KiouForge/*.kif` is the current storage location. + Post-SQLite migration this becomes a DB row check instead — see + the Follow-up section. + +## Concrete step-by-step Frida flow + +Rough sketch. Actual class / method names verified against dump.cs +during implementation: + +```javascript +import Il2Cpp from 'frida-il2cpp-bridge' + +async function smoke() { + await Il2Cpp.initialize() + + // step 1 — hook install banner + await waitForLogLine('[CHINLAN] all hooks installed', 30_000) + + // step 2 — presenter-level CPU match start + const HomePresenter = Il2Cpp.domain.assembly('KIOU').image + .class('Project.Home.HomeUtilityPresenter') + const presenter = HomePresenter.static.field('s_instance').value + if (presenter.isNull()) throw new Error('home presenter not spawned') + presenter.method('StartCpuMatch').invoke(/* difficulty */ 1) + + // step 3 — wait for MatchController.State == InProgress + const MatchCtrl = Il2Cpp.domain.assembly('KIOU').image + .class('Project.Match.MatchController') + await waitFor(() => { + const ctrl = MatchCtrl.static.field('s_instance').value + if (ctrl.isNull()) return false + return ctrl.field('_state').value === /* InProgress */ 2 + }, 10_000) + + // step 4 — play 3 moves via GameController.SubmitMove + // (uses same il2cpp method the UI would end up calling) + for (const usi of ['2g2f', '8c8d', '2f2e']) { + submitMove(usi) + await sleep(500) // let animations settle so onboarding overlays don't gate + } + + // step 5 — resign via UI button (belt-and-braces path) + const resignBtn = findButtonByLabel('投了') + if (resignBtn === null) throw new Error('resign button not found') + resignBtn.method('get_onClick').invoke().method('Invoke').invoke() + + // step 6 — wait for kifu writer log + const kifPath = await waitForLogMatch(/\[KIF\] wrote \d+ bytes -> (.+\.kif)/, 10_000) + + send({ status: 'pass', kifu: kifPath }) +} +``` + +`findButtonByLabel` walks the Canvas hierarchy: `Canvas.transform +.GetComponentsInChildren