Skip to content

feat(bridge): observe the network libraries through the import thunks, not in every export - #35

Open
AlxcBTW wants to merge 7 commits into
mainfrom
feat/central-observation-hook
Open

AlxcBTW wants to merge 7 commits into
mainfrom
feat/central-observation-hook

Conversation

@AlxcBTW

@AlxcBTW AlxcBTW commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

What

Observation of the network libraries no longer needs a line in every export, so an upstream sync stops conflicting on them.

Mechanism. A game converted with --trace-imports already sends each import through a thunk of libc.prx (CallTrace.cpp, installed by the relinker's startup code on Linux, Windows and macOS). The thunk gains an optional call observer: SetCallObserver_nid_no_patch registers select, enter and leave. enter gets rdi, rsi, rdx, rcx, r8 and r9; the thunk puts a small stub (CallTraceReturn_nid_no_patch) in place of the return address so leave gets rax. A per-thread shadow stack pairs them, and calls a longjmp unwound are finished without a result at the next observed call.

libSceNetCtl registers the observer when it loads (NetBridgeObserveHook.cpp). select maps an import's NID to a library and export name and writes the same line as before (lib, fn, in, out, ret); it does nothing unless ANYPS5_NET_OBSERVE is set. The names come from CMake: ObservedExports.cmake scans the observed libraries for APS5_VABI definitions when it configures, so a new upstream export is observed with no line of its own, and an import of an observed library that the scan does not know is written under its NID instead of being lost. Arguments that were logged by name (netIn.Int("s", s)) are rows of ObservedArguments.inc (register, type, name). NetBridgeCall and NetBridgeRecord stay where an export writes something back or answers; they claim their call, so the hook writes no second line.

Size. 1,763 of 1,777 NetBridgeCall objects and their netCall.Ret(...) wrappers are gone from the 17 observed libraries (about 4,000 lines deleted); 14 stay (sockaddr and option decoding in libSceNet, Out() and Bool users). 485 of them became rows of ObservedArguments.inc. The edit was made by a script that can be run again on a merged tree, so this PR need not land first: if #29 or #33 land before it, rebase and rerun the script instead of resolving hunks.

Cost, and why it is opt-in. Expected, not measured. An import nothing selects gets no thunk and costs nothing; at start each import costs one getenv and a string compare (plus a hash lookup while observing), a few hundred nanoseconds in all. An observed call costs the thunk (about 20 register saves and two calls into libc.prx), a thread-local stack push, and building the line (a JSON string, a mutex and a queue push), on the order of one to three microseconds. The network libraries' calls are not per-frame, so that is acceptable while recording, but not something to pay in normal play. The launcher therefore passes --trace-imports only while the player records network calls: anyplaynet/launcher#242 (relinks when the setting changes, leaves normal play untouched). Without the flag nothing is observed except what an export describes itself.

Behaviour changes

  • An exception escaping an observed export now terminates the process. The return stub has no unwind data, so an exception thrown out of an observed export (for example NotImplemented_nid_no_patch) can no longer reach a handler in guest code. This only applies to executables converted with --trace-imports and only to libraries in NET_OBSERVED_EXPORT_LIBS; it needs parl0v's decision.
  • An export that throws NotImplemented writes the not_implemented line but none of its own.
  • ret is written for every export whose declared return type is an integer of 32 bits or less (read from the source); before, where the code called Ret.
  • A call from one export of a library to another export of it writes one line instead of two; calls from bundled guest modules or through function pointers are not thunked by --trace-imports.
    Also listed in the debt files of libc and libSceNetCtl.

Tested

Not tested on a game. guest_call_trace_observe (libc): arguments, results (integer, struct return, floating point, variadic), nesting, claim, longjmp (not on Windows), four threads, and that an import only the observer selected is not counted by the tracer. net_observe_hook (libSceNetCtl): exports per library against floors (the number of exports that carried a line before, so a silent loss fails), an export that answers nothing (libSceNpCppWebApi) observed through the hook alone, argument types, a claimed call written once, an unknown NID of an observed library written under the NID. CI builds on Linux, Windows and macOS are the first full build; the Mach-O build compiles the stub but does not run the tests.

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

…d imports

The import thunks of the call tracer can now hand every call of the imports an observer selects to it: the six integer argument registers on the way in, rax on the way out. The thunk replaces the return address with a stub that finishes the call, keeps a shadow stack per thread, and finishes calls a longjmp unwound at the next observed call.
…he import thunks

An observer in libSceNetCtl picks the imports of the network libraries out of the call tracer's thunks and writes the observation line, so an export needs no line of its own. CMake scans the sources for the export names and return kinds when it configures, ObservedArguments.inc names the arguments worth logging, and a NetBridgeCall or NetBridgeRecord in an export claims its call so the line is written once.
1,763 NetBridgeCall objects and their Ret wrappers leave the exports of the network libraries. The 14 that describe what an export writes back stay.
@AlxcBTW

AlxcBTW commented Oct 27, 2026

Copy link
Copy Markdown
Member Author

Full build (run 40599806387, linux hosted + windows, all tests): Windows is green. Linux passes everything except precise_wait, a timing test this change does not touch (it also failed once on an earlier run of this branch and passed in the next). guest_call_trace_observe, net_observe_hook and public_headers pass on both. Not covered: Mach-O only compiles the stub, and no game was started.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant