Skip to content

docs(debt): name the title that passes vibration mode 4 - #13961

Merged
adriadam10 merged 2 commits into
boykopovar:mainfrom
xXPBSXx:docs/pad-vibration-mode-4
Nov 6, 2026
Merged

adriadam10 merged 2 commits into
boykopovar:mainfrom
xXPBSXx:docs/pad-vibration-mode-4

Conversation

@xXPBSXx

@xXPBSXx xXPBSXx commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

What

The scePadSetVibrationMode debt entry lists the modes AnyPS5 accepts (1 to 3 and 5) and says that whether the console accepts modes above 3 on an open handle was not measured. One title passes 4, which AnyPS5 rejects; this adds one sentence:

PPSA24779 passes mode 4, which is rejected here with INVALID_ARG; the console's answer for 4 is not measured

Changed: docs/dev/debt/libScePad.md, one existing entry; the existing text is unchanged. No code change.

Where AnyPS5 rejects it: scePadSetVibrationMode in core/libs/prx/libScePad/Export.cpp returns PAD_ERROR_INVALID_ARG (0x80920001) for any mode above 3 other than 5. PPSA24779 calls the function with 4 at two places (0xa90616 and 0xa91a13), each with mov esi, 4 shortly before the call (only the load of the handle in between), and at both it tests the return value: a non-zero result takes an error path that passes the code to another call and ends in -1. What the title does after that is not read.

Count (script and output, one line per call site with title, address and value):

Mode passed as a constant Call sites
1 4
2 13
3 10
4 2 (both in PPSA24779)
5 0
Not shown constant 47 (41 computed, 6 ambiguous)

48 titles import scePadSetVibrationMode and all 48 call it directly: 76 direct calls, no tail jumps, no non-stub call sites. The entry names PPSA25286 as passing 5; that title is not in this set, so it could not be checked here. An earlier count with a stricter byte rule and a different choice of executable per title (45 titles, 71 call sites: 1 x5, 2 x14, 3 x7) did not see the two calls with 4, because a load of the handle sits between mov esi, 4 and the call.

Method: for every direct call to the import stub the bytes before the call are decoded and the last write to esi (the mode) decides: xor of the register with itself or a mov of an immediate is a constant, any other write is "computed", no write in the window is "unreadable", and decodings that disagree are "ambiguous". The two calls with 4 were also read by hand from the start of the instruction sequence. Only the titles' own executables are read.

Overlap

All 1595 open PRs were checked on 2026-10-31 at 10:54 UTC against main 99061ea54e. Eighteen others change docs/dev/debt/libScePad.md or Export.cpp; none changes this entry or scePadSetVibrationMode, and none conflicts with this change. Eleven of them (#5038, #7064, #8260, #10112, #10935, #12274, #12308, #12309, #12310, #12311, #12312) conflict with main with or without it. Checked again on 2026-10-31 at 13:09 UTC (1595 open pull requests): of those updated since 10:54 UTC, #10113, #10222, #10226 and #10229 now also change one of the two files; none of them changes this entry or scePadSetVibrationMode, and none conflicts with this change. Checked once more on 2026-10-31 at 14:06 UTC against main 73f4f7e06e: of the 72 pull requests opened since 10:50 UTC, #13924 changes one of the two files and merges the same with and without this change.

Tested

Documentation only. tools/check_conventions.py --base upstream/main passes; based on main 0aefc11766, and it merges with main 73f4f7e06e without a conflict (checked 2026-10-31 13:59 UTC). Not built, not run.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

AI-assisted (Claude Code).

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xXPBSXx

xXPBSXx commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Per-title results and the counting script for the table in the description: one line per direct call site of scePadSetVibrationMode with title, address and what the last write to esi before the call is.

names: scePadSetVibrationMode=W2G-yoyMF5U
titles with a registry: 53

title | function | address | kind | value
PPSA01285 | scePadSetVibrationMode | 0x3771746 | call | computed
PPSA01530 | scePadSetVibrationMode | 0x8a56f8 | call | computed
PPSA01746 | scePadSetVibrationMode | 0x1dab75d | call | computed
PPSA01746 | scePadSetVibrationMode | 0x1dadb9e | call | computed
PPSA02100 | scePadSetVibrationMode | 0x17cf80d | call | computed
PPSA02100 | scePadSetVibrationMode | 0x17d2abe | call | computed
PPSA02225 | scePadSetVibrationMode | 0xfacba8 | call | 1
PPSA02530 | scePadSetVibrationMode | 0x95b5b1 | call | 2
PPSA02801 | scePadSetVibrationMode | 0x6deab0 | call | computed
PPSA03001 | scePadSetVibrationMode | 0x2074dd6 | call | computed
PPSA03001 | scePadSetVibrationMode | 0x207615d | call | computed
PPSA03001 | scePadSetVibrationMode | 0x2076190 | call | computed
PPSA03001 | scePadSetVibrationMode | 0x20761a9 | call | 2
PPSA03001 | scePadSetVibrationMode | 0x20761dc | call | computed
PPSA03001 | scePadSetVibrationMode | 0x20761f5 | call | 2
PPSA03001 | scePadSetVibrationMode | 0x2076228 | call | computed
PPSA03001 | scePadSetVibrationMode | 0x2076237 | call | 2
PPSA03001 | scePadSetVibrationMode | 0x2076483 | call | computed
PPSA03355 | scePadSetVibrationMode | 0x8b1c6 | call | 1
PPSA03490 | scePadSetVibrationMode | 0x3171369 | call | computed
PPSA03685 | scePadSetVibrationMode | 0x14564aa | call | 1
PPSA03685 | scePadSetVibrationMode | 0x14565b8 | call | 1
PPSA04354 | scePadSetVibrationMode | 0x99e480 | call | computed
PPSA05347 | scePadSetVibrationMode | 0x250549d | call | computed
PPSA06465 | scePadSetVibrationMode | 0x6c1a50 | call | computed
PPSA07057 | scePadSetVibrationMode | 0x979b60 | call | computed
PPSA07411 | scePadSetVibrationMode | 0x6c09d0e | call | 2
PPSA07426 | scePadSetVibrationMode | 0x14693ed | call | computed
PPSA07426 | scePadSetVibrationMode | 0x146b36e | call | computed
PPSA09076 | scePadSetVibrationMode | 0x6e0be0 | call | computed
PPSA09682 | scePadSetVibrationMode | 0x6c0060 | call | computed
PPSA10261 | scePadSetVibrationMode | 0xbc272d | call | computed
PPSA10264 | scePadSetVibrationMode | 0x32fbebe | call | computed
PPSA10600 | scePadSetVibrationMode | 0x1bac56d | call | computed
PPSA10600 | scePadSetVibrationMode | 0x1bae3de | call | computed
PPSA12544 | scePadSetVibrationMode | 0x6775ad | call | ambiguous [unreadable x16, computed x1 of 17 start offsets]
PPSA12544 | scePadSetVibrationMode | 0x16ae566 | call | computed
PPSA14421 | scePadSetVibrationMode | 0x1b7477 | call | 3
PPSA15065 | scePadSetVibrationMode | 0x68728d | call | ambiguous [unreadable x16, computed x1 of 17 start offsets]
PPSA15246 | scePadSetVibrationMode | 0x41a336 | call | computed
PPSA15246 | scePadSetVibrationMode | 0x41a42c | call | computed
PPSA15552 | scePadSetVibrationMode | 0x1656358 | call | 3
PPSA15552 | scePadSetVibrationMode | 0x165703e | call | ambiguous [1 x16, computed x1 of 17 start offsets]
PPSA16827 | scePadSetVibrationMode | 0x6d69c0 | call | computed
PPSA17226 | scePadSetVibrationMode | 0x33f501b | call | computed
PPSA17226 | scePadSetVibrationMode | 0x33f6eeb | call | computed
PPSA20612 | scePadSetVibrationMode | 0x1bccb8b | call | computed
PPSA20612 | scePadSetVibrationMode | 0x1bcea4b | call | computed
PPSA21480 | scePadSetVibrationMode | 0x835be | call | 3
PPSA23126 | scePadSetVibrationMode | 0x30fb22e | call | computed
PPSA23827 | scePadSetVibrationMode | 0x50ef57 | call | 3
PPSA24423 | scePadSetVibrationMode | 0x29782a | call | 3
PPSA24779 | scePadSetVibrationMode | 0xa90616 | call | 4
PPSA24779 | scePadSetVibrationMode | 0xa91a13 | call | 4
PPSA25936 | scePadSetVibrationMode | 0x6b059d | call | ambiguous [unreadable x16, computed x1 of 17 start offsets]
PPSA26223 | scePadSetVibrationMode | 0x4d1f7c | call | computed
PPSA26679 | scePadSetVibrationMode | 0x4b467e | call | computed
PPSA27752 | scePadSetVibrationMode | 0x4ccf4c | call | computed
PPSA29413 | scePadSetVibrationMode | 0x6ab33d | call | ambiguous [unreadable x16, computed x1 of 17 start offsets]
PPSA29866 | scePadSetVibrationMode | 0x122622 | call | 2
PPSA29866 | scePadSetVibrationMode | 0x124eea | call | 2
PPSA29866 | scePadSetVibrationMode | 0x1250ac | call | 2
PPSA29866 | scePadSetVibrationMode | 0x125138 | call | 2
PPSA30601 | scePadSetVibrationMode | 0x15eb126 | call | computed
PPSA31291 | scePadSetVibrationMode | 0x32c3e8 | call | 3
PPSA31372 | scePadSetVibrationMode | 0x74c97 | call | 3
PPSA35154 | scePadSetVibrationMode | 0x1496e0c | call | computed
PPSA35154 | scePadSetVibrationMode | 0x1498d0d | call | computed
PPSA35169 | scePadSetVibrationMode | 0x625582 | call | 3
PPSA35169 | scePadSetVibrationMode | 0x6255e4 | call | 3
PPSA35669 | scePadSetVibrationMode | 0x82ed47 | call | 3
PPSA36591 | scePadSetVibrationMode | 0x4529b1 | call | 2
PPSA36591 | scePadSetVibrationMode | 0x452a26 | call | ambiguous [2 x15, computed x2 of 17 start offsets]
PPSA36591 | scePadSetVibrationMode | 0x452cfa | call | 2
PPSA36591 | scePadSetVibrationMode | 0x4539ca | call | 2
PPSA36591 | scePadSetVibrationMode | 0x453b79 | call | 2

non-stub registry call sites: title | function | address | instruction kind
(none)

summary: function | titles that import | titles with direct calls | direct calls | tail jmp | calls to jump stub | non-stub registry sites | titles without readable ELF | argument values
scePadSetVibrationMode | 48 | 48 | 76 | 0 | 0 | 0 | 0 | {"1": 4, "2": 13, "3": 10, "4": 2, "ambiguous": 6, "computed": 41}

Script (run with --arg scePadSetVibrationMode=esi; the title folders and the objdump path are local arguments):

#!/usr/bin/env python3
"""Count the call sites of imported functions in title executables and read argument registers.

Input: a directory ROOT with one run folder per title, PPSA<id>_r*/, each holding
app.registry.json (import table written by the relinker; every import lists the addresses
of its call sites) and in/eboot.elf (the title's executable as ELF). Per title the registry
with the most imports that have call sites is used (ties: the newest file).
Only the title's own executable is read: libraries / extra module files, function pointers and
code that is only reached through data are not seen.

What is counted per title and function (NAME = import name, NID computed from the name):
  * call sites: the registry addresses whose first bytes are FF 25 (PLT stub, jmp [rip+got]).
  * direct calls: E8 rel32 instructions in the executable segments whose target is such a stub.
  * tail jumps: E9 rel32 instructions whose target is such a stub. They are listed and counted
    separately and are NOT direct calls.
  * calls to a jump stub: E8 rel32 whose target is one of those tail-jump instructions
    (a thunk that forwards to the import). Listed and counted separately, not direct calls.
  * non-stub registry call sites: registry addresses that are not a stub start (FF 15 call
    [rip+got], 48 8B / 4C 8B mov reg,[rip+got], anything else, or an address outside the file).
    Per title and function with address and instruction kind. They are not scanned for calls.
Matches are byte patterns in the executable segments, no control-flow recovery: an E8 inside an
instruction or data can only be a false hit if its target happens to be a stub (not checked).

Argument value (optional, --arg NAME=REG, REG one of rdi rsi rdx rcx r8 r9; the 32/16/8-bit
names are accepted and mean the same register): for every direct call the 64..48 bytes before it
are decoded with objdump (Intel syntax), once for every start offset in that range.
  * a decoding is used only if the call address is decoded as an instruction start; if no start
    offset gives that, the site is reported as 'desync';
  * going backwards from the call, the last instruction that writes REG decides: the first operand
    is REG or one of its sub-registers (mov, lea, movzx, pop, adc, rol, bswap, cmov, ...),
    an implicit writer (rep/movs/lods/stos/cmps/scas/ins/outs, cdq/cqo/cwd, mul/div/idiv and the
    one-operand imul for rdx, cpuid, rdtsc(p), rdmsr, rdpmc, xgetbv, syscall, cmpxchg8b/16b),
    xchg/xadd/cmpxchg that name REG, or a 'call' (clobbers all argument registers);
    cmp, test, push, bt and nop only read;
  * 'xor R,R' (R the 32- or 64-bit register) = 0; 'mov R,imm' (32- or 64-bit register) = imm;
    every other writer, and any write to a 16/8-bit part, = 'computed' (not constant);
  * a branch whose target lies between the last writer and the call, or an unconditional
    jmp / ret between them, = 'computed' (the writer may not be on the path to the call);
  * no writer in the window = 'unreadable' (not constant: set before the window or by the caller);
  * if the start offsets give different answers the site is 'ambiguous' (the answers are listed in
    the site line). With --reach-neutral an 'unreadable' answer of a shorter window is ignored when
    a longer window reads a value (it only means that the writer lies beyond that window's reach);
    all other disagreements stay 'ambiguous'.
Both 'computed' and 'unreadable' and 'ambiguous' mean "not a constant" in the sums; they are kept
apart so that a reader can see why. Window limits: a writer more than ~48 bytes back is not seen,
values that flow through memory or a callee-saved register are not followed, conditional moves
and branches taking different values on different paths are reported as 'computed'.
Nothing is executed; only counts, addresses and classifications are printed.

usage: anhang_zaehlen.py ROOT OBJDUMP NAME[,NAME...] OUT [--arg NAME=REG ...] [--titles FILE]
  OBJDUMP is the objdump command (GNU binutils, x86-64); 'wsl objdump' is accepted, paths are then
  converted to /mnt/<drive>/... . --titles FILE restricts the titles (PPSA<digits> found in FILE).
Output (OUT and stdout summary): one line per site "title | function | address | kind | value",
the non-stub list, and one summary line per function.
"""
import argparse
import bisect
import collections
import glob
import hashlib
import json
import os
import re
import struct
import subprocess
import sys
import tempfile

import numpy as np

SUFFIX = bytes([0x51, 0x8D, 0x64, 0xA6, 0x35, 0xDE, 0xD8, 0xC1, 0xE6, 0xB0, 0x39, 0xB1, 0xC3, 0xE5, 0x52, 0x30])
B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-"


def nid(name):
    d = hashlib.sha1(name.encode() + SUFFIX).digest()
    r = bytes(d[7 - i] for i in range(8))
    out = []
    for i in range(0, 6, 3):
        t = (r[i] << 16) | (r[i + 1] << 8) | r[i + 2]
        out += [B64[(t >> 18) & 63], B64[(t >> 12) & 63], B64[(t >> 6) & 63], B64[t & 63]]
    t = (r[6] << 16) | (r[7] << 8)
    return ''.join(out + [B64[(t >> 18) & 63], B64[(t >> 12) & 63], B64[(t >> 6) & 63]])


# argument registers: all names of the register, and the two names a constant may be written to
PARTS = {
    'rdi': ('rdi', 'edi', 'di', 'dil'),
    'rsi': ('rsi', 'esi', 'si', 'sil'),
    'rdx': ('rdx', 'edx', 'dx', 'dl', 'dh'),
    'rcx': ('rcx', 'ecx', 'cx', 'cl', 'ch'),
    'r8': ('r8', 'r8d', 'r8w', 'r8b'),
    'r9': ('r9', 'r9d', 'r9w', 'r9b'),
}
FULL = {'rdi': ('rdi', 'edi'), 'rsi': ('rsi', 'esi'), 'rdx': ('rdx', 'edx'), 'rcx': ('rcx', 'ecx'),
        'r8': ('r8', 'r8d'), 'r9': ('r9', 'r9d')}
ALIAS = {n: r for r, names in PARTS.items() for n in names}
PREFIX = {'lock', 'rep', 'repz', 'repe', 'repnz', 'repne', 'bnd', 'notrack', 'data16', 'cs', 'ds', 'es', 'fs', 'gs', 'addr32'}
READ_ONLY = {'cmp', 'test', 'push', 'bt', 'nop', 'ptest', 'ucomiss', 'ucomisd', 'comiss', 'comisd'}
# registers written by instructions that do not name them
STRING_OPS = {'movs': ('rsi', 'rdi'), 'cmps': ('rsi', 'rdi'), 'lods': ('rsi',), 'stos': ('rdi',),
              'scas': ('rdi',), 'ins': ('rdi',), 'outs': ('rsi',)}
IMPLICIT = {'rdx': ('cdq', 'cqo', 'cwd', 'mul', 'div', 'idiv', 'rdtsc', 'rdtscp', 'cpuid', 'rdmsr', 'rdpmc',
                    'xgetbv', 'cmpxchg8b', 'cmpxchg16b'),
            'rcx': ('loop', 'cpuid', 'rdtscp', 'xgetbv', 'syscall', 'sysenter', 'rdmsr', 'rdpmc'),
            'rdi': (), 'rsi': (), 'r8': (), 'r9': ()}
BRANCH = re.compile(r'^(j\w+|loop\w*|jrcxz|jecxz)$')
LINE = re.compile(r'\s+([0-9a-f]+):\s+(?:[0-9a-f]{2} )+\s*(.*)')


def split_ins(text):
    toks = text.split()
    pfx = []
    while toks and toks[0] in PREFIX:
        pfx.append(toks.pop(0))
    if not toks:
        return pfx, '', ''
    return pfx, toks[0], ' '.join(toks[1:])


def writes_reg(pfx, op, rest, reg):
    """True if the instruction (Intel syntax, prefixes split off) writes register reg."""
    names = PARTS[reg]
    anyname = re.compile(r'\b(?:' + '|'.join(names) + r')\b')
    if op == 'call' or op == 'syscall':
        return True
    base = op.rstrip('bwlq') if op.rstrip('bwlq') in STRING_OPS else op
    if base in STRING_OPS:
        if reg in STRING_OPS[base]:
            return True
        if reg == 'rcx' and any(p.startswith('rep') for p in pfx):
            return True
        return False
    if any(p.startswith('rep') for p in pfx) and reg == 'rcx':
        return True
    if op in IMPLICIT[reg]:
        return True
    if reg == 'rdx' and op in ('imul',) and ',' not in rest:
        return True
    if op in ('xchg', 'xadd', 'cmpxchg') and anyname.search(rest):
        return True
    if op in READ_ONLY:
        return False
    first = rest.split(',')[0].strip()
    return bool(anyname.fullmatch(first))


def const_of(op, rest, reg):
    """Value if the instruction sets the full register to a constant, else None."""
    f64, f32 = FULL[reg]
    ops = [x.strip() for x in rest.split(',')]
    if op == 'xor' and len(ops) == 2 and ops[0] == ops[1] and ops[0] in (f64, f32):
        return 0
    if op == 'mov' and len(ops) == 2 and ops[0] in (f64, f32) and re.fullmatch(r'-?(0x[0-9a-f]+|\d+)', ops[1]):
        v = int(ops[1], 0)
        return v & 0xffffffff if ops[0] == f32 else v & 0xffffffffffffffff
    return None


def classify(ins, callpos, reg):
    """ins: sorted list of (pos, text) of one decoded window. Returns a value string or None (desync)."""
    if not any(p == callpos for p, _ in ins):
        return None
    last = None
    lastpos = -1
    branches = []
    for pos, text in ins:
        if pos >= callpos:
            break
        pfx, op, rest = split_ins(text)
        if not op:
            continue
        if BRANCH.match(op) or op in ('jmp', 'ret', 'retn'):
            m = re.fullmatch(r'(?:0x)?([0-9a-f]+)', rest.split()[0]) if rest else None
            branches.append((pos, op, int(m.group(1), 16) if m else None))
            continue
        if writes_reg(pfx, op, rest, reg):
            last, lastpos = (op, rest), pos
    if last is None:
        return 'unreadable'
    for pos, op, tgt in branches:
        if pos > lastpos and op in ('jmp', 'ret', 'retn'):
            return 'computed'
        if tgt is not None and lastpos < tgt <= callpos:
            return 'computed'
    v = const_of(last[0], last[1], reg)
    if v is None:
        return 'computed'
    return str(v) if v < 10 else hex(v)


def to_tool_path(objdump, p):
    if objdump.split()[0] == 'wsl':
        p = os.path.abspath(p).replace('\\', '/')
        return '/mnt/' + p[0].lower() + p[2:] if len(p) > 1 and p[1] == ':' else p
    return p


def load_elf(path):
    data = open(path, 'rb').read()
    if data[:4] != b'\x7fELF':
        return None, None
    phoff = struct.unpack_from('<Q', data, 0x20)[0]
    phentsize, phnum = struct.unpack_from('<HH', data, 0x36)
    segs = []
    for i in range(phnum):
        ptype, flags, off, vaddr, paddr, filesz, memsz, align = struct.unpack_from('<IIQQQQQQ', data, phoff + i * phentsize)
        if ptype == 1:
            segs.append((vaddr, off, filesz, flags))
    return data, segs


def file_offset(segs, va):
    for vaddr, off, filesz, flags in segs:
        if vaddr <= va < vaddr + filesz:
            return off + (va - vaddr)
    return None


def scan(arr, segs, targets):
    """E8 (call) and E9 (jmp) rel32 instructions in executable segments whose target is in targets."""
    res = {'call': [], 'jmp': []}
    if not targets:
        return res
    tarr = np.array(sorted(targets), dtype=np.int64)
    for vaddr, off, filesz, flags in segs:
        if not flags & 1:
            continue
        seg = arr[off:off + filesz]
        for op, name in ((0xE8, 'call'), (0xE9, 'jmp')):
            idx = np.nonzero(seg[:-4] == op)[0]
            if idx.size == 0:
                continue
            rel = (seg[idx + 1].astype(np.int64) | (seg[idx + 2].astype(np.int64) << 8) |
                   (seg[idx + 3].astype(np.int64) << 16) | (seg[idx + 4].astype(np.int64) << 24))
            rel = np.where(rel >= 0x80000000, rel - 0x100000000, rel)
            hit = np.isin(vaddr + idx + 5 + rel, tarr)
            res[name] += [int(vaddr + i) for i in idx[hit]]
    return res


def site_kind(data, segs, va):
    fo = file_offset(segs, va)
    if fo is None:
        return 'outside file'
    b = data[fo:fo + 3]
    if b[:2] == b'\xff\x25':
        return 'FF 25 stub'
    if b[:2] == b'\xff\x15':
        return 'FF 15 call [rip+got]'
    if b[:2] in (b'\x48\x8b', b'\x4c\x8b'):
        return '%02X 8B mov reg,[rip+got]' % b[0]
    return 'other ' + b.hex(' ')


STARTS = range(64, 47, -1)
PAD = b'\xcc' * 16


def arg_values(objdump, data, segs, jobs, tmpdir, reach_neutral=False):
    """jobs: list of (va, reg). Returns {(va, reg): value string}. One objdump run for all windows."""
    blob = bytearray()
    wins = []  # (job index, back, start in blob, callpos in blob)
    for ji, (va, reg) in enumerate(jobs):
        fo = file_offset(segs, va)
        for back in STARTS:
            w0 = max(0, fo - back)
            s = len(blob)
            blob += data[w0:fo + 5]
            wins.append((ji, s, s + (fo - w0)))
            blob += PAD
    out = {}
    if not jobs:
        return out
    tmp = os.path.join(tmpdir, 'windows.bin')
    open(tmp, 'wb').write(bytes(blob))
    cmd = objdump.split() + ['-D', '-b', 'binary', '-mi386:x86-64', '-M', 'intel', to_tool_path(objdump, tmp)]
    txt = subprocess.run(cmd, capture_output=True, text=True).stdout
    pos_list, text_list = [], []
    for line in txt.split('\n'):
        m = LINE.match(line)
        if m and m.group(2).strip():
            pos_list.append(int(m.group(1), 16))
            text_list.append(m.group(2).strip())
    per = collections.defaultdict(collections.Counter)
    for ji, s, c in wins:
        lo = bisect.bisect_left(pos_list, s)
        hi = bisect.bisect_right(pos_list, c)
        v = classify(list(zip(pos_list[lo:hi], text_list[lo:hi])), c, ALIAS_REG[jobs[ji][1]])
        per[ji][v] += 1
    for ji, (va, reg) in enumerate(jobs):
        vals = {k: n for k, n in per[ji].items() if k is not None}
        if reach_neutral and len(vals) > 1 and 'unreadable' in vals:
            del vals['unreadable']  # a shorter window simply does not reach the writer
        if not vals:
            out[(va, reg)] = 'desync'
        elif len(vals) == 1:
            out[(va, reg)] = next(iter(vals))
        else:
            out[(va, reg)] = 'ambiguous'
            DETAIL[(va, reg)] = ', '.join('%s x%d' % (k, n) for k, n in sorted(vals.items(), key=lambda kv: -kv[1]))
    return out


ALIAS_REG = ALIAS
DETAIL = {}  # (address, register) -> answers of the start offsets for 'ambiguous' sites


def main():
    ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
    ap.add_argument('root')
    ap.add_argument('objdump')
    ap.add_argument('names')
    ap.add_argument('out')
    ap.add_argument('--arg', action='append', default=[], help='NAME=REG (rdi rsi rdx rcx r8 r9)')
    ap.add_argument('--titles')
    ap.add_argument('--reach-neutral', action='store_true',
                    help="ignore 'unreadable' answers of short windows when a longer window reads a value "
                         "(default: any disagreement between start offsets is 'ambiguous')")
    a = ap.parse_args()
    names = a.names.split(',')
    nids = {n: nid(n) for n in names}
    argreg = {}
    for x in a.arg:
        n, r = x.split('=')
        argreg[n] = ALIAS[r.lower()]
    only = None
    if a.titles:
        only = set(re.findall(r'PPSA\d+', open(a.titles, encoding='utf-8', errors='replace').read()))
    rows = {}
    for f in glob.glob(os.path.join(a.root, 'PPSA*_r*', 'app.registry.json')):
        folder = os.path.dirname(f)
        m = re.match(r'(PPSA\d+)_r\w*$', os.path.basename(folder))
        if not m or (only is not None and m.group(1) not in only):
            continue
        try:
            d = json.load(open(f, encoding='utf-8'))
        except Exception:
            continue
        key = (sum(1 for e in d if e.get('callSites')), os.path.getmtime(f))
        if m.group(1) not in rows or key > rows[m.group(1)][0]:
            rows[m.group(1)] = (key, d, folder)
    lines = ['names: ' + ', '.join('%s=%s' % (n, nids[n]) for n in names),
             'titles with a registry: %d' % len(rows), '',
             'title | function | address | kind | value']
    nonstub_lines = []
    summ = {n: {'import': set(), 'calling': set(), 'call': 0, 'jmp': 0, 'thunk': 0, 'val': collections.Counter(),
                'nonstub': collections.Counter(), 'noelf': set()} for n in names}
    with tempfile.TemporaryDirectory() as tmpdir:
        for t in sorted(rows):
            key, d, folder = rows[t]
            elf = os.path.join(folder, 'in', 'eboot.elf')
            data = segs = arr = None
            for n in names:
                sites = sorted({int(x, 16) if isinstance(x, str) else int(x)
                                for e in d if str(e.get('nid', '')).split('#')[0] == nids[n]
                                for x in (e.get('callSites') or [])})
                if not sites:
                    continue
                summ[n]['import'].add(t)
                if data is None and os.path.exists(elf):
                    data, segs = load_elf(elf)
                    arr = np.frombuffer(data, dtype=np.uint8) if data is not None else None
                if data is None:
                    summ[n]['noelf'].add(t)
                    continue
                stubs = []
                for va in sites:
                    k = site_kind(data, segs, va)
                    if k == 'FF 25 stub':
                        stubs.append(va)
                    else:
                        summ[n]['nonstub'][k.split()[0] + k.split()[1] if k.startswith('FF') else 'other'] += 1
                        nonstub_lines.append('%s | %s | 0x%x | %s' % (t, n, va, k))
                r = scan(arr, segs, stubs)
                via = scan(arr, segs, r['jmp'])['call'] if r['jmp'] else []
                vals = {}
                if n in argreg and r['call']:
                    vals = arg_values(a.objdump, data, segs, [(va, argreg[n]) for va in r['call']], tmpdir, a.reach_neutral)
                for va in sorted(r['call']):
                    v = vals.get((va, argreg.get(n)), '')
                    if n in argreg:
                        summ[n]['val'][v] += 1
                    if v == 'ambiguous':
                        v += ' [' + DETAIL.get((va, argreg[n]), '') + ' of %d start offsets]' % len(STARTS)
                    lines.append('%s | %s | 0x%x | call | %s' % (t, n, va, v))
                for va in sorted(r['jmp']):
                    lines.append('%s | %s | 0x%x | tail jmp (not counted as call) |' % (t, n, va))
                for va in sorted(via):
                    lines.append('%s | %s | 0x%x | call to jump stub (not counted as direct call) |' % (t, n, va))
                if r['call']:
                    summ[n]['calling'].add(t)
                summ[n]['call'] += len(r['call'])
                summ[n]['jmp'] += len(r['jmp'])
                summ[n]['thunk'] += len(via)
    lines += ['', 'non-stub registry call sites: title | function | address | instruction kind']
    lines += nonstub_lines or ['(none)']
    lines += ['', 'summary: function | titles that import | titles with direct calls | direct calls | tail jmp | '
              'calls to jump stub | non-stub registry sites | titles without readable ELF | argument values']
    for n in names:
        s = summ[n]
        lines.append('%s | %d | %d | %d | %d | %d | %s | %d | %s' % (
            n, len(s['import']), len(s['calling']), s['call'], s['jmp'], s['thunk'],
            json.dumps(dict(s['nonstub']), sort_keys=True) if s['nonstub'] else '0',
            len(s['noelf']), json.dumps(dict(s['val']), sort_keys=True)))
    open(a.out, 'w', encoding='utf-8').write('\n'.join(lines) + '\n')
    print('\n'.join(lines[-(len(names) + 1):]))


if __name__ == '__main__':
    main()

AI-assisted (Claude Code).

@MarcZX10 MarcZX10 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — looks good

The one-line debt update is narrowly scoped and clearly distinguishes the title's observed mode-4 argument from the unmeasured console response.

What I ran

Reviewed head 20dd3e5dbdca0588f762092fa3f21d2d2ab5bc88 against base 73f4f7e06e4f210a8c6570e335fd654dc0a86134 on Linux x86_64 with Python 3.11.2 and Git 2.39.5. The merge preview was clean.

git diff --check 73f4f7e06e4f210a8c6570e335fd654dc0a86134...20dd3e5dbdca0588f762092fa3f21d2d2ab5bc88
python3 tools/check_conventions.py --base 73f4f7e06e4f210a8c6570e335fd654dc0a86134 --head 20dd3e5dbdca0588f762092fa3f21d2d2ab5bc88
python3 tools/pr_precheck.py --repo boykopovar/AnyPS5 --pr 13961

The diff check and conventions passed. Hosted conventions, intake, eligibility, and labels passed; product build/test jobs were skipped. The precheck's test-quality helper was unavailable because it is absent from the isolated PR checkout; no test files changed.

Not verified

I did not independently rerun the title-binary scan or measure behavior on a console. The PR description and linked call-site report provide the evidence for the documented title observation and explicitly leave the console response unmeasured.

@github-actions github-actions Bot added the docs label Oct 31, 2026
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 5038 docs/dev/debt/libScePad.md
PR 8260 docs/dev/debt/libScePad.md
PR 10112 docs/dev/debt/libScePad.md
PR 12308 docs/dev/debt/libScePad.md
PR 12309 docs/dev/debt/libScePad.md
PR 12310 docs/dev/debt/libScePad.md
PR 12311 docs/dev/debt/libScePad.md
PR 12312 docs/dev/debt/libScePad.md
PR 14212 docs/dev/debt/libScePad.md

@adriadam10 adriadam10 self-assigned this Nov 1, 2026
@xXPBSXx

xXPBSXx commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

A run of PPSA24779 on main ac26e59d9a (2026-11-01 14:35 UTC, 600 s, libraries built from that commit) shows the title reaching this call at run time. Its own output has this line right after its startup threads, before the main menu loads:

ERROR: scePadSetVibrationMode: 0x80920001

0x80920001 is the INVALID_ARG that scePadSetVibrationMode returns for a mode of 0 or less and for a mode above 3 other than 5; the line does not print the mode, so it is the count in the description (this title passes the constant 4) that names the mode, and this run is consistent with it. The title logs it and carries on: its main menu loads, it later posts trophy progress, and the run reaches the end of its 600 s without a stop. What the console returns for mode 4 is still not measured, so this only shows that the title does reach the call and takes its error path here.

AI-assisted (Claude Code).

@JJenkx JJenkx mentioned this pull request Nov 4, 2026
12 tasks done
@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Nov 4, 2026
Resolve the conflict in docs/dev/debt/libScePad.md: keep main's text and
add the PR's sentence about mode 4 to the scePadSetVibrationMode entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Nov 5, 2026
@xXPBSXx

xXPBSXx commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Merged main (c3a5290c7d) into this branch as a merge commit (9c4b8f0478), no rebase or force push. The only conflict was in docs/dev/debt/libScePad.md, where main added the scePadSetTriggerEffect entry directly above; the resolution keeps main's text and adds only this PR's sentence to the scePadSetVibrationMode entry. The diff against main is unchanged: +1/-1 in that one file, and the entry is 924 characters. tools/check_conventions.py --base upstream/main passes, and the branch merges with main 15454571d9 without a conflict (checked 23:24 UTC).

Overlap: of the open pull requests that change libScePad.md (all open pull requests checked via the files API, 22:15 UTC to 23:24 UTC), only #15716 changes the same line. #15716 also changes the code (mode 4 accepted); this sentence describes main as of c3a5290c7d. The two conflict with each other, but each merges with main 15454571d9 without a conflict, so neither depends on the other; whichever merges second needs a one-line rebase, as #15716 states.

AI-assisted (Claude Code).

@adriadam10
adriadam10 merged commit 1ff0fe9 into boykopovar:main Nov 6, 2026
10 checks passed
@adriadam10

Copy link
Copy Markdown
Collaborator

Checked the added sentence against scePadSetVibrationMode in Export.cpp (modes above 3 other than 5 return INVALID_ARG) and the conventions check; docs-only; merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants