obscene-tool
One binary, in tool/. It builds the module, reads it
back, and makes sense of the reports it produces.
cd tool && cargo build --release
./tool/target/release/obscene-tool --helpNothing else is required to use it - it ships as a single binary with no runtime. The build only needs cargo, and only when the tool itself changes.
Building a module
| Command | What it does |
|---|---|
mkmodule <file> |
Applies the header fixups the hardware loader requires |
mkmodule <file> --dynlib |
Also builds the vendor dynamic segment |
The linker produces a correct, ordinary ELF that a loader rejects on
sight. mkmodule sets EI_OSABI to FreeBSD and
e_type to the vendor dynamic-executable type. It is
idempotent, so it is safe in a build rule that runs
repeatedly, and it refuses a file it does not
understand rather than rewriting a field to make it look
right.
--dynlib goes further: it rebuilds the string and symbol
tables with every import re-encoded to NID form, appends them as a
PT_SCE_DYNLIBDATA segment, and rewrites the dynamic table
with DT_SCE_* tags. Nothing in the file moves - the
linker's unused GNU_STACK header is repurposed and the
dynamic table is overwritten in place.
Reading a module
| Command | What it does |
|---|---|
imports <file> |
Every undefined dynamic symbol - what the loader must resolve |
inspect <file> |
Segments and the dynamic table, with each tag's role where known |
vaddrs <file> --source <label> |
Every exported symbol as name vaddr,
names resolved from the mined corpus |
imports exits non-zero on an empty
list. A module that imports nothing had its platform calls
resolved at link time or optimised away, and would test nothing at all -
that is a failure, not a result.
inspect is how unassigned vendor tags get identified.
Pointed at a real module it classifies each value by shape: offsets land
inside the vendor segment, sizes do not, entry sizes are exactly
0x18, and DT_SCE_PLTREL holds 7.
That is evidence for an assignment, never the
assignment itself.
vaddrs reads a module's exports - the symbols
it defines, not the ones it imports - and prints one
name vaddr line each. A loader adds the vaddr to the
module's load base to reach the function, so this is the table that lets
a payload's base + offset arithmetic land on a
reimplementation of the named export. It is two facts joined: the vaddr
is measured from the file, and the name is looked up in
the mined corpus (data/mined-names.txt) by the NID the
export table stores, because the table holds the hash, not the name. An
export whose NID the corpus does not know keeps its encoded NID
(hash#lib#mod) - no vaddr is dropped and nothing is
invented. --source labels the provenance in the file's
header. It is the producer for a consumer that resolves imports by name;
orbistoun's libkernel-vaddrs.txt is generated by it:
obscene-tool vaddrs libkernel_sys.sprx --source "12.40 libkernel_sys.sprx" \
> ../orbistoun/crates/orbistoun-firmware/data/libkernel-vaddrs.txt
On the 12.40 libkernel_sys.sprx that is 1,867 exports,
1,644 of them named. The raw name-less form - NID and
vaddr, no corpus - is SELFish's libkernel_vaddrs example,
which reads the same table and is what proves the vaddr measurement
independent of any name.
Symbol hashing
| Command | What it does |
|---|---|
nid <name> |
The NID and encoded symbol name for a function |
decode <encoded> |
The value behind an encoded NID |
selftest |
Checks the whole chain against a published pair |
$ obscene-tool nid sceKernelWrite
nid 0xe304b37bdd8184b2
encoded 4wSze92BhLI
symbol 4wSze92BhLI#A#A
Run selftest if anything about resolution looks
wrong. Four independent things - the suffix, the byte order,
the alphabet, the bit packing - are each individually plausible when
wrong and each produce eleven ordinary-looking characters that resolve
to nothing. One published pair constrains all four at once.
decode inverts the encoding, not the hash: a NID cannot be
turned back into a name.
Reading reports
| Command | What it does |
|---|---|
report [--seconds N] [--into F] [--name C] |
Capture obscene's records off the console system log into a file |
verify <file> |
Checks a report against the format contract |
diff <before> <after> |
What changed; exits 1 on regression |
pretty [file] |
Colour, grouped by section. Reads stdin if no file |
report is where a hardware report comes from. A
packaged run's report file is sealed inside the title's sandbox (D233),
so the readable channel is the system log; report listens
to it, keeps only the OBS| records, writes them to
reports/hardware/console-klog.txt, and prints the tally so
you can see the run finished. It is reached as
./bin/obscene report; ./bin/obscene deploy
calls it across a launch, so the whole round-trip is one command (D269).
The file it writes is exactly what verify,
diff and pretty read.
verify asks "can I believe this file", not
"is the platform any good" - an all-red report is perfectly
well-formed and on a host build that is expected.
diff is what the report format exists for. A
regression means a check that got worse, not one that is
failing: under an early emulator almost everything fails, so
the only useful question is whether today beats yesterday.
skip ranks below fail, and a check
that vanishes counts as a regression - otherwise deleting an awkward
check would read as progress.
pretty colours only when writing to a terminal. Piping
it into a file or an agent gives plain text.
Asking a loader questions
| Command | What it does |
|---|---|
probe <tag> <out> [--value V] |
A minimal module carrying one candidate dynamic tag |
Run the result under a loader and read its output. A loader reports
the tags it does not recognise, so silence about the
candidate - with the controls still reported - means it knows that tag.
That is how DT_SCE_FINGERPRINT was identified.
One tag per probe, deliberately. Putting every candidate in one table produced a result that looked like fifty-seven recognised tags and was actually a crash on the third one, because a probe that stops early and a probe that finds nothing produce identical silence. The controls after the candidate are what tell them apart.
Supply --value to chase a named failure rather
than silence: if the loader's message changes, that identifies the tag
rather than merely proving it is known.
The gates
Everything verify.sh runs. Each has a
--check that fails rather than rewriting, which is what
makes a committed generated file trustworthy: without it, the file and
its source drift and nothing says so.
| Command | What it does |
|---|---|
guards --root . |
every check guards the platform symbols it calls (D058) |
caps --root . |
every prerequisite capability is granted before some check needs it (D158) |
(docs) BOOT.md |
the load sequence, who calls DT_INIT, executable vs
library, and what is still unknown |
rows --root . --report <file> |
the table parser sees every check a report says ran (D168) |
claims --root . |
prose anchored to a source token still describes what the source does (D170) |
counts --root . --check |
the numbers in the documentation equal the numbers in the code |
doccheck --root . |
nothing in prose names a file, rule, subcommand or decision that does not exist |
compat --check <name=report>… |
the per-loader results table matches the reports it was built from |
protocol --root . |
the captured exchanges match PROTOCOL.md |
protocol --root . --selftest |
thirteen deliberate corruptions, all of which must be rejected |
corpus --root . |
the mined corpus has been shown the current emulator checkouts |
decisions --root . |
every entry under docs/decisions/ has exactly one row
in the index, and every row a real file |
The generators
Each writes a committed artefact from a data file. The data file decides; the generator assembles. That split exists because the inputs are judgements with prose attached, and retyping a judgement into another language is how a transcription error gets into a census.
| Command | Writes | From |
|---|---|---|
surface |
include/obscene/surface.h |
data/surface.txt |
census corpus --platform |
include/obscene/corpus.h |
data/mined-names.txt |
census nids --platform |
include/obscene/nids.h |
data/unnamed-nids.txt |
font |
src/probe/font.c |
data/font.txt - glyph art, so a glyph is corrected in
place |
shaders |
include/obscene/gpu_shaders.gen.h |
src/probe/shaders/*.comp, needs
glslangValidator |
mine |
both corpus files | the emulator checkouts and the firmware trees |
shaders and gpusurface want the build VM:
one needs a shader compiler, and the other cross-checks against LLVM's
IntrinsicsAMDGPU.td and says so in its output when it
cannot.
Analysis
| Command | What it does |
|---|---|
gap --root . |
what the emulators implement that obSCEne does not reach |
gap --root . --current |
the same, counting only current-generation emulators |
unresolved <log>… |
a loader's unresolved-import log, translated to names |
consensus <name=report>… |
several implementations compared; only the disagreements |
What is still a script
Orchestration, and only orchestration. A script starts a VM, launches a Windows emulator, waits, and collects what came back - work where a shell is the right tool and nothing is silently corruptible.
Everything else is Rust. Binary formats moved first, because a stringly-typed pack and an untyped integer are liabilities exactly where a mistake produces a file that is accepted and subtly wrong rather than one that fails (D026). The checkers and generators followed, and the reason was the same one arrived at from the other direction: they were the only part of the tree without tests or types, and they were the code deciding whether everything else was correct. Every one of them was found miscounting something.
Everything remaining is sh. The module is built inside
WSL, because a Windows mount cannot carry the execute bit and a binary
built into the tree refuses to run (D012); the emulators are Windows
applications. Neither fact says anything about what language should
drive them, and Git Bash launches a Windows executable perfectly
well.
Building - run these in WSL
verify.sh |
everything that has to pass before a change is done. The one to run. |
build-all.sh |
every target, plus a re-derivation of the tag assignment from what it built. |
emu-build.sh |
tool and module, for the change-rebuild-read-the-log loop. |
sweep-build.sh |
the module, excluding every check in the sweep file. |
lint.sh |
the tool's lints, in full. |
format.sh |
clang-format, writing by redirect - -i renames, and a
mounted Windows share refuses that. |
verify.sh and build-all.sh both build
without exclusions, so sweep-build.sh must be the
last build before anything runs the module. Getting that order wrong has
twice handed a later step a module that walked straight into a known
crash.
Running - run these on
the host, also sh
run-emulator.sh |
one run: fetch the module from WSL, run it, extract the report. |
sweep.sh |
rounds until nothing kills the process. |
harvest-nids.sh |
grows the NID table from emulator logs. Merges, never replaces. |
sweep.sh reads the report for a try with no
matching res - announce-before-attempting naming the exact
call that did not return - excludes it, and goes again. Three rounds
took one run from 180 records to a complete 554; against fpPS4 it took
35 rounds and 44 exclusions to reach 742. A timeout doubles the budget
and retries rather than excluding, because one round cannot tell a hang
from a check that was still going (D144). --resume keeps a
list a previous sweep proved; --corpus 0 leaves the mined
census out of the hunt.
Inspecting
manifest.sh |
the symbol-to-library manifest, derived from the check tables rather than a second copy that goes stale. |
imports.sh |
what a build imports, and which of those the manifest cannot place. |
shapes.sh |
the module and payload shapes side by side. |
nids.sh |
the NID for each name given. What to reach for when a loader reports
resolved <nid> as Unknown. |
census-summary.sh |
how much of each library's known surface a census run found. |
The
hardware round-trip - reached as ./bin/obscene verbs, never
by script path
These cross the WSL/Windows split or talk to a real console, so a
portable Makefile rule cannot carry them. Each is one
bin/obscene verb; the scripts behind them are
implementation and are not run directly (D269,
scripts/README.md).
| verb | what it does |
|---|---|
./bin/obscene deploy |
build, install, launch, capture the report - the whole package round-trip |
./bin/obscene native-deploy |
build and deploy current-generation native title directory
(/user/data/homebrew/<TITLE_ID>) |
./bin/obscene restart-ui |
safely restart SceShellUI to recover from VSH UI
softlocks without rebooting (D294) |
./bin/obscene payload |
build the plain-ELF payload, run it via elfldr, capture the raw system log |
./bin/obscene report |
capture obscene's records off the console system log into a file
(obscene-tool report) |
./bin/obscene recover |
read-only: boot log, report file, and crash dumps the console kept |
./bin/obscene hwsweep |
iterate against hardware, excluding each call that does not return |
./bin/obscene minbuild |
the minimal diagnostic package |
./bin/obscene digcheck |
does a built package still agree with its own digests? |
./bin/obscene prep |
send klogsrv/shsrv payloads to bring the readable services up |
The one environment hazard worth knowing
Git Bash rewrites anything that looks like a Unix path before a
Windows program sees it, and wsl.exe is as Windows a
program as any, however Linux the command it carries. So
wsl.exe -d Ubuntu -- bash -lc '... /home/ubuntu/obscene ...'
reaches the distro with its /... arguments rewritten to
C:/Program Files/Git/..., and the command fails on a
directory that does not exist. Paths meant for inside WSL must survive
untouched, so every wsl.exe call goes through a one-line
wrapper that sets MSYS_NO_PATHCONV=1.
This is why these scripts used to be PowerShell. CLAUDE.md said to use it for the cross-boundary invocations for exactly this reason - and the reason was one environment variable, not a language. Writing them in sh also deleted the hazard PowerShell brought with it: it turns a native command's stderr into a terminating error, so a warning printed on a step that had actually succeeded would kill a script, intermittently.
consensus
A substitute oracle for a project with no hardware.
obscene-tool consensus host=reports/host.txt shadps4=reports/full-sweep.txtPrints only where implementations disagree. Unanimity is the uninteresting case and there is a great deal of it.
An OUTLIER is the actionable shape - one implementation differing from a unanimous rest. It supports "you are the only one of four that fails this", which needs nobody to accept this project's expectation. A SPLIT is weaker and often more interesting: the behaviour is genuinely unsettled between implementations, which is a question for hardware rather than a bug report.
A skip is not an opinion. An implementation that lacks the function has said nothing about how it should behave. Counting skips as verdicts made the first run report 80 disagreements out of 126, almost all of them "this platform has the function and that one does not" - true, useless, and enough noise to bury the real differences. They are counted separately as no opinion.
It names implementations and never counts them. These projects read each other's source, so four agreeing is not four witnesses; a bare number would hide that. See D064 for the time this project made exactly that mistake.
The host build is the most useful second opinion available today. It is a real implementation of the POSIX and C library surface, so a disagreement against it is a disagreement with something known to work - which is the closest thing to an oracle short of the hardware.
crack
Recovers names from NIDs by hashing candidates and matching. The only way back, because hashing is one way.
obscene-tool crack --nids targets.txt --words candidates.txt --known data/nid-corpus.txt--nids accepts either bare NIDs or the
<nid>#lib#mod form a symbol table carries, so a
module's imports can be fed in directly.
Read the header before the results. It reports the suffix used, how many candidates were tried, and how many known pairs the candidate list reproduced. A list that cannot regenerate names already known is not evidence about names that are not.
A miss is not a finding. A match is proof - the hash agrees. A non-match says the candidate list did not contain the name, and nothing at all about whether the name exists.
Not to be confused with decode, which reverses the
encoding and leaves you holding the hash.