Initial triage wiki
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.DS_Store
|
||||
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Pokemon Spike 3 Rootfs Triage Wiki
|
||||
|
||||
Static triage of the extracted Raspberry Pi based root filesystem in this workspace.
|
||||
|
||||
## High-level Findings
|
||||
|
||||
- Product/banner identity: `Stern Pinball Spike 3` from `/etc/issue`.
|
||||
- OS base: `Buildroot 2024.08.1` from `/usr/lib/os-release`.
|
||||
- BusyBox: `v1.36.1`, build string `2025-10-22 11:34:32 EDT`.
|
||||
- CPU/userland target: 64-bit ARM/aarch64. Main ELFs use `/lib/ld-linux-aarch64.so.1` and report Linux ABI `6.6.0`.
|
||||
- Kernel module tree: `/lib/modules/6.6.28-v8`, consistent with Raspberry Pi 64-bit kernel packaging.
|
||||
- Init system: BusyBox init using `/etc/inittab`, not systemd.
|
||||
- Boot sequence: `/etc/inittab` mounts runtime filesystems and runs `/etc/init.d/rcS`, which executes every `/etc/init.d/S??*` script in lexical order.
|
||||
- Primary application path: `/games/game -> /games/pokemon_pro/game`.
|
||||
- Main game payloads:
|
||||
- `/games/pokemon_pro/game`: stripped AArch64 PIE, 52,829,400 bytes.
|
||||
- `/games/pokemon_pro/conagent`: stripped AArch64 PIE, 272,432 bytes.
|
||||
- `/games/pokemon_pro/image.bin`: 1,154,884,359 bytes.
|
||||
- `/games/pokemon_pro/assets/godot/main.pck`: 256,326,416 bytes.
|
||||
- Game version hint: `/games/spk/index/pokemon_pro.sidx -> pokemon_pro-0_83_0.sidx`, and release notes are under `/games/pokemon_pro/data/`.
|
||||
- SPIKE support package version: `/usr/local/spike/VERSION.txt` says `SPIKE3_secure v1.0.6`.
|
||||
|
||||
## Pages
|
||||
|
||||
- [HTML Viewer](viewer.html) - self-contained MediaWiki-style viewer generated from these wiki files.
|
||||
- [Boot and Init](boot-and-init.md)
|
||||
- [Filesystem, Users, and Network](filesystem-users-network.md)
|
||||
- [Applications and Services](applications-and-services.md)
|
||||
- [Update and Recovery Flow](update-and-recovery.md)
|
||||
- [Artifact Index](artifact-index.md)
|
||||
- [Next Analysis Targets](next-analysis-targets.md)
|
||||
- [Copy Fail Assessment](copy-fail-assessment.md)
|
||||
- [Godot and Radium Assets](godot-and-radium-assets.md)
|
||||
- [Audio Asset Search](audio-asset-search.md)
|
||||
- [Conagent Reverse Engineering](conagent-re.md)
|
||||
- [Main Game Behavior Notes](main-game-behavior.md)
|
||||
- [Package, Image, Netbridge, and Firmware Notes](package-image-firmware.md)
|
||||
- [Magikarp Topper Serial Key and Emulator](topper-serial-key-emulator.md)
|
||||
|
||||
## Notes and Caveats
|
||||
|
||||
- This is static filesystem triage. I did not execute target AArch64 binaries.
|
||||
- The extracted `/boot` directory is empty here. `/etc/fstab` expects `/dev/mmcblk0p1` to mount at `/boot` as read-only VFAT with `noauto`; the actual boot partition may be a separate extraction artifact.
|
||||
- I did not copy sensitive values into the wiki. In particular, `corefile_passphrase.txt` exists and is referenced by the core export script, but its value is intentionally omitted.
|
||||
- `viewer.html` is generated by `scripts/build_viewer.py`; rebuild it after wiki page edits so Markdown links, sidebar navigation, search, and source views stay in sync.
|
||||
141
applications-and-services.md
Normal file
141
applications-and-services.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Applications and Services
|
||||
|
||||
## Game Entry Points
|
||||
|
||||
Top-level symlinks:
|
||||
|
||||
| Path | Target |
|
||||
| --- | --- |
|
||||
| `/games/game` | `pokemon_pro/game` |
|
||||
| `/games/conagent` | `pokemon_pro/conagent` |
|
||||
| `/games/data` | `pokemon_pro/data` |
|
||||
| `/games/spike3` | `pokemon_pro/spike3` |
|
||||
|
||||
Main executables:
|
||||
|
||||
| Path | Type | Size |
|
||||
| --- | --- | ---: |
|
||||
| `/games/pokemon_pro/game` | Stripped AArch64 PIE, dynamically linked | 52,829,400 |
|
||||
| `/games/pokemon_pro/conagent` | Stripped AArch64 PIE, dynamically linked | 272,432 |
|
||||
| `/games/pokemon_pro/spike3/bin/dprint` | Stripped AArch64 PIE, dynamically linked | see artifact index |
|
||||
| `/games/pokemon_pro/spike3/bin/boot_display` | Stripped AArch64 PIE, dynamically linked | see artifact index |
|
||||
| `/games/pokemon_pro/spike3/bin/bttest` | Stripped AArch64 PIE, dynamically linked | see artifact index |
|
||||
| `/games/pokemon_pro/spike3/spike_menu/game` | Stripped AArch64 PIE, dynamically linked | 4,645,784 |
|
||||
| `/games/pokemon_pro/spike3/netbridge/netbridge.elf` | 32-bit ARM statically linked ELF with debug info, not stripped | see artifact index |
|
||||
|
||||
Large payloads:
|
||||
|
||||
- `/games/pokemon_pro/image.bin`: 1,154,884,359 bytes.
|
||||
- `/games/pokemon_pro/assets/godot/main.pck`: 256,326,416 bytes.
|
||||
- `/games/pokemon_pro/spike3/spike_menu/image.bin`: 14,959,521 bytes.
|
||||
|
||||
Asset extraction status:
|
||||
|
||||
- `/games/pokemon_pro/assets/godot/main.pck` has been statically extracted into workspace-local `assets/`; see `godot-and-radium-assets.md`.
|
||||
- `/games/pokemon_pro/image.bin` did not expose standard media signatures in first-pass static scans and remains a Radium/runtime image reverse-engineering target.
|
||||
|
||||
## Game Data
|
||||
|
||||
Release-note files:
|
||||
|
||||
- `/games/pokemon_pro/data/pro_release_notes.txt`
|
||||
- `/games/pokemon_pro/data/premium_release_notes.txt`
|
||||
|
||||
Version hints:
|
||||
|
||||
- `/games/spk/index/pokemon_pro.sidx -> pokemon_pro-0_83_0.sidx`
|
||||
- `/games/pokemon_pro/data/*_release_notes.txt` match the Pokemon Pro/Premium title.
|
||||
|
||||
Bundled node firmware images:
|
||||
|
||||
- `coil4node-LPC1112_101-1_33_0.hex`
|
||||
- `pinnode-LPC1112_101-1_33_0.hex`
|
||||
- `hdmi_ws2812node-LPC1313-1_33_0.hex`
|
||||
- `tmc5041node-LPC1313-1_33_0.hex`
|
||||
- `pinnode-LPC1313-1_33_0.hex`
|
||||
- `lcdnode-LPC1113_302-1_33_0.hex`
|
||||
- `coil4_lednode-LPC1313-1_33_0.hex`
|
||||
- `coil4node-LPC1313-1_33_0.hex`
|
||||
- `magsensornode-LPC812-1_33_0.hex`
|
||||
- `hdminode-LPC1313-1_33_0.hex`
|
||||
- `ws2812pinnode-LPC1313-1_33_0.hex`
|
||||
- `node4-LPC1124_303-1_33_0.hex`
|
||||
- `node4-LPC812-1_33_0.hex`
|
||||
- `ws2812node-LPC1313-1_33_0.hex`
|
||||
- `pinnode-LPC1112_201-1_33_0.hex`
|
||||
- `coil4node-LPC1112_201-1_33_0.hex`
|
||||
- `tmc2590node-LPC1313-1_33_0.hex`
|
||||
|
||||
These are likely firmware payloads for distributed SPIKE node boards.
|
||||
|
||||
## Monitors
|
||||
|
||||
`/etc/init.d/S95game` starts these in the background when `/games/game` exists:
|
||||
|
||||
- `/etc/init.d/reboot_monitor`
|
||||
- `/etc/init.d/conagent_monitor`
|
||||
- `/etc/init.d/game_monitor`
|
||||
- `/etc/init.d/update`
|
||||
|
||||
`game_monitor`:
|
||||
|
||||
- Starts `/games/game`.
|
||||
- Interprets game exit codes:
|
||||
- `0`: restart requested.
|
||||
- `1`: reboot requested.
|
||||
- `2`: exit requested, development-only control-C path.
|
||||
- `3`: power loss.
|
||||
- `4`: fatal error.
|
||||
- `5`: dispatch timeout.
|
||||
- `6`: deep sleep.
|
||||
- `>=128`: uncaught signal, encoded as `exit_reason - 128`.
|
||||
- On power loss or deep sleep, syncs filesystems, remounts `/data` and `/connectivity` read-only, and hangs waiting for power removal.
|
||||
- On reboot request, syncs, remounts persistent filesystems read-only, calls `/sbin/reboot -f`, and hangs.
|
||||
- On repeated fatal/non-normal exits, tracks restart sentinels under `/tmp/game_restarts`.
|
||||
- After more than 4 restart sentinels, clears selected `/connectivity` caches. If cache was already cleared and restart pressure continues, it reboots.
|
||||
|
||||
`conagent_monitor`:
|
||||
|
||||
- Loops forever.
|
||||
- Starts `/games/conagent`.
|
||||
- Sleeps one second and restarts it whenever it exits.
|
||||
|
||||
`reboot_monitor`:
|
||||
|
||||
- Tracks machine reboots under `/connectivity/machine_reboots`.
|
||||
- If more than 9 reboot markers exist before the system reaches 5 minutes uptime, it clears selected `/connectivity` caches.
|
||||
- Creates a reboot marker, sleeps 300 seconds, then clears reboot markers.
|
||||
|
||||
## Display Utilities
|
||||
|
||||
`/etc/init.d/shared` resolves:
|
||||
|
||||
- `BOOT_DISPLAY=/games/spike3/bin/boot_display`, fallback `/usr/local/spike/boot_display`.
|
||||
- `DPRINT=/games/spike3/bin/dprint`, fallback `/usr/local/spike/dprint`.
|
||||
|
||||
`boot_display` is launched early by `S12lvds_panel`; `dprint` is used by startup/update scripts to print messages to the boot display.
|
||||
|
||||
## Bluetooth Support Scripts
|
||||
|
||||
Bundled scripts under `/games/pokemon_pro/spike3/scripts`:
|
||||
|
||||
- `find_trusted_devices.sh`: walks `/var/lib/bluetooth` and prints device directories whose `info` contains `Trusted=true`.
|
||||
- `find_untrusted_devices.sh`: walks `/var/lib/bluetooth` and prints devices missing that trusted marker.
|
||||
- `copy_newest_corefile_to_usb.sh`: archives the newest core file from `/connectivity/dump/cores` into an encrypted 7z file and copies it to `/media` after mounting `/dev/sda1` or `/dev/sda`.
|
||||
|
||||
Sensitive note: `copy_newest_corefile_to_usb.sh` reads a passphrase from `/games/spike3/scripts/corefile_passphrase.txt`; the value is not copied into this wiki.
|
||||
|
||||
## Netbridge / Debug Hardware
|
||||
|
||||
`/games/pokemon_pro/spike3/netbridge` contains:
|
||||
|
||||
- `netbridge.elf`: 32-bit ARM static executable with debug info, not stripped.
|
||||
- `rp2040.cfg`: OpenOCD RP2040 target config.
|
||||
- `spike3-swd.cfg`: OpenOCD config using `bcm2835gpio`.
|
||||
|
||||
`spike3-swd.cfg` maps:
|
||||
|
||||
- SWCLK to GPIO 11.
|
||||
- SWDIO to GPIO 10.
|
||||
|
||||
This is a strong next target for understanding how the Pi interfaces with an RP2040 or other debug bridge in the machine.
|
||||
101
artifact-index.md
Normal file
101
artifact-index.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Artifact Index
|
||||
|
||||
## Main Game Artifacts
|
||||
|
||||
| SHA-256 | Path |
|
||||
| --- | --- |
|
||||
| `1e5becb85840d0cc7a1d334b7cc43bdb83e51c7c7f8243bead8db679e9ffb22d` | `/games/pokemon_pro/game` |
|
||||
| `a76da91f0cfdaecceb579ef27d223ab8a86c313d0eefa5217b96823c43c97e69` | `/games/pokemon_pro/conagent` |
|
||||
| `2f1a958c1f859c339c716e53cc9ee752412373c486ea2a1bfd116eefce4617a3` | `/games/pokemon_pro/image.bin` |
|
||||
| `a7df00dbbfb75838fd19d57ada30f4247d2ef1bc4b357ca0a64ab60f3a679af0` | `/games/pokemon_pro/spike3/spike_menu/image.bin` |
|
||||
| `04af0fd78866ec3ae60b10311dedb3df2f3877595d19ee7bb01665ab10512778` | `/games/pokemon_pro/assets/godot/main.pck` |
|
||||
| `caf06531ca1b969b6d727d76af7ace8931277dbdb6584ecb5a238f427fe0a76a` | `/games/spk/index/pokemon_pro-0_83_0.sidx` |
|
||||
|
||||
## Generated Asset Extraction Artifacts
|
||||
|
||||
These files are workspace-local generated artifacts, not target-root files.
|
||||
|
||||
| SHA-256 | Path | Notes |
|
||||
| --- | --- | --- |
|
||||
| `36fb0f000743093338a4994b75ce0de40611154dfc1d43f3d2d4b539036c9b79` | `assets/manifest.json` | Manifest for `main.pck` extraction into `assets/godot_raw/` and `assets/openable/`. |
|
||||
| `e97b226472d7531464f8a95c0d01a2661c0486f21beb74216acd9d8c737cc436` | `assets/loose_manifest.json` | Manifest for openable copies harvested from loose `/games/pokemon_pro/assets` files. |
|
||||
| `ad44ee65ddbd7f0a44e0a4b7f15332460fa48185fc45b12dc74b10f9dece9334` | `project.zip` | Improved Godot editor reconstruction with restored PNG source paths and loose `lcd`/`nuk` assets; 980,835,332 bytes. |
|
||||
| `ad44ee65ddbd7f0a44e0a4b7f15332460fa48185fc45b12dc74b10f9dece9334` | `project_reconstructed.zip` | Same improved archive as `project.zip`, kept under an explicit reconstruction name. |
|
||||
| `05d2606f23e694c369c855a232a787e03b8c838b346558df615598f01519cca1` | `project_basic_exported.zip` | First-pass exported-resource-only Godot wrapper; 255,346,627 bytes. |
|
||||
| `4be4399dcebce7d3f857dd76319061ef4284a65770fe3de3d742668c31ae8fbf` | `godot_editor_project_reconstructed/project.godot` | Reconstructed Godot 4.4.1 project settings wrapper based on `project.binary`. |
|
||||
| `d56d6efd7a5b6cbf26cd1a2fcf1292b11ba3131fdefc3e821edde1b6f8be240e` | `godot_editor_project_reconstructed/RECONSTRUCTION_MANIFEST.json` | Manifest for the improved reconstruction: 1,050 restored PNG sources and media-audio scan summary. |
|
||||
| `256ffb7711beecdc9f165e40d8f84fd2b1e474798bc4b497ab64558129f94964` | `analysis/media-audio-scan.tsv` | `ffprobe` scan of Godot and loose media containers; one AAC-bearing MP4 found. |
|
||||
| `a976fbd8d25f6376a5abee510c5fed9ade44cdacff6dfd6d399bfff3e30c8ee5` | `rootfs-triage-wiki/scripts/build_godot_editor_reconstruction.py` | Static reconstruction builder for restored PNG sources, loose asset copy, media scan, and zip packaging. |
|
||||
| `19187f77af51093e2262ba8a042ec5a41cb5be20f9f12d19ef8855055567b952` | `rootfs-triage-wiki/scripts/search_audio_assets.py` | Whole-target-root static audio search helper; scans filenames, `file(1)`, magic signatures, strings, and `ffprobe` stream metadata. |
|
||||
| `975df239ca2930e91abf7af5e53cb24a865ce31faf11a2bba9adae6e61995c3a` | `analysis/audio-search/summary.json` | Broad target-root audio scan summary: 7,375 files scanned, 281 stream-bearing files, 0 target-root files with audio streams. |
|
||||
| `9f0132e612f2a6aafb5b00335cbf74b7f5fb57c5b34a4f9bda21e02b6ad0f91b` | `analysis/audio-search/filename-hits.tsv` | Audio-related filename hits; system audio stack files and images, not gameplay sound content. |
|
||||
| `818e61772280cd597c3b101aa90b90764630bd8d7892ea5c20bf5e48f34d408c` | `analysis/audio-search/file-type-hits.tsv` | `file(1)` media/audio-looking hits; target-root video containers plus firmware false positives. |
|
||||
| `8707c33b9ce6b33c693dcf187f277b474d0abd4086858096b041d3e1f2abd0eb` | `analysis/audio-search/magic-hits.tsv` | Binary magic signature hits, including expected codec/library false positives and invalid Ogg-like executable offsets. |
|
||||
| `b93b2019c8b021267e96c77fee7932f30e8d8f8f71b0c424e4f74f9e02f3c9d9` | `analysis/audio-search/ffprobe-streams.tsv` | `ffprobe` stream metadata for target-root files; all parsed streams are video/non-audio. |
|
||||
| `0587eb42a19459c99f35574b0892ea20a97737ad11c498189698392e7c7cc95a` | `analysis/audio-search/audio-related-strings.json` | Audio-related string samples from target-root support files and game binaries. |
|
||||
| `e33480f3c18c1e81096fa029b671765f13316b0bad33b9718de5587e316d3137` | `rootfs-triage-wiki/scripts/extract_godot_pck_assets.py` | Static Godot PCK extractor and converter. |
|
||||
| `b5ddb933f125cf6b54b81c56f4e3430ded50d843c045a49f0727a1f7ebc5f66f` | `rootfs-triage-wiki/scripts/harvest_loose_openable_assets.py` | Static loose-asset copier for PNG, TTF, MP4, and MOV files. |
|
||||
| `257cb2d9ba653385aba63157a3bc143bb479c5188071ef529996d3f5da258b71` | `analysis/topper-emulator/topper_emulator.py` | MicroPython SPIKE node-board emulator for the Magikarp topper serial-key path; 6,452 bytes. |
|
||||
| `32263b75f49aefba04596c196f85cc4ae4477eb536f767fcdeac7c0d5fc40e9b` | `analysis/topper-emulator/README.md` | Emulator notes and recovered command summary; 5,820 bytes. |
|
||||
|
||||
## Generated Static Triage Artifacts
|
||||
|
||||
These files are workspace-local generated artifacts, not target-root files.
|
||||
|
||||
| Path | Notes |
|
||||
| --- | --- |
|
||||
| `analysis/static-triage/game.strings` | Offset strings for `/games/pokemon_pro/game`. |
|
||||
| `analysis/static-triage/game.objdump-p.txt` | Dynamic section/program metadata for `/games/pokemon_pro/game`. |
|
||||
| `analysis/static-triage/game-image-bin.binwalk.txt` | `binwalk` report for `/games/pokemon_pro/image.bin`. |
|
||||
| `analysis/static-triage/spike-menu-image-bin.binwalk.txt` | `binwalk` report for `/games/pokemon_pro/spike3/spike_menu/image.bin`. |
|
||||
| `analysis/static-triage/netbridge.strings` | Offset strings for `/games/pokemon_pro/spike3/netbridge/netbridge.elf`. |
|
||||
| `analysis/static-triage/netbridge.objdump-p.txt` | Program headers for `/games/pokemon_pro/spike3/netbridge/netbridge.elf`. |
|
||||
| `analysis/static-triage/node-firmware-ranges.md` | Parsed Intel HEX record/range summary for top-level node firmware. |
|
||||
| `analysis/static-triage/pokemon_pro_0_83_0_sidx.strings` | Offset strings for `/games/spk/index/pokemon_pro-0_83_0.sidx`. |
|
||||
| `analysis/static-triage/sidx-path-summary.txt` | Summary of package paths found in the `.sidx` strings. |
|
||||
|
||||
## SPIKE Support Artifacts
|
||||
|
||||
| SHA-256 | Path |
|
||||
| --- | --- |
|
||||
| `022619c279d51d0efc9a3ba289140ebc05c202b11b6c26d4685503feb7ada8d8` | `/games/pokemon_pro/spike3/bin/dprint` |
|
||||
| `4b189cabd04c27ccf3583c6d708fe48dde8c17e2b5ad0056610d45a915868a04` | `/games/pokemon_pro/spike3/bin/boot_display` |
|
||||
| `5e12b2e65d077ca0fa0ad18df2fc302d350dfc91b716c41967015db6e964a755` | `/games/pokemon_pro/spike3/bin/bttest` |
|
||||
| `05b7f7c329da79fb24078df69c8401c7a47be5c8a855b73fb937e052bce28d85` | `/games/pokemon_pro/spike3/netbridge/netbridge.elf` |
|
||||
| `3928411d136f58197dc6fd8dd9a9f2f2f5150c55dd3fa1637ad68a6931eb01f6` | `/games/pokemon_pro/spike3/spike_menu/game` |
|
||||
|
||||
## SPIKE Scripts
|
||||
|
||||
| SHA-256 | Path |
|
||||
| --- | --- |
|
||||
| `e35a3b97ab3d2ff515d777d83f78da0256bf5d4d1c1e4af5f2b89c977d69299a` | `/games/pokemon_pro/spike3/scripts/find_trusted_devices.sh` |
|
||||
| `58f888ae6049962adfee980cfe500f5d01a812fe07b0ee4e223625205513620c` | `/games/pokemon_pro/spike3/scripts/find_untrusted_devices.sh` |
|
||||
| `2be517c5b1b4f6f1dae01d00935db964e98639b6a82dc707240ea39f2390b05c` | `/games/pokemon_pro/spike3/scripts/copy_newest_corefile_to_usb.sh` |
|
||||
|
||||
## Node Firmware Images
|
||||
|
||||
| SHA-256 | Path |
|
||||
| --- | --- |
|
||||
| `6ab4f2d8c6e2e4f10ae7c61dcc2c994dbbf490669cf014626577659cbe709314` | `/games/pokemon_pro/coil4node-LPC1112_101-1_33_0.hex` |
|
||||
| `f3bd45e05f693b8bb99931f0bc9f65a9f6046de5a97d6157cf1ac4d38868ac1e` | `/games/pokemon_pro/pinnode-LPC1112_101-1_33_0.hex` |
|
||||
| `6a073cd25469dfa0db41c9ec80e877f3cebaa9ffcc0c4c1afe81e110b86ac7ad` | `/games/pokemon_pro/hdmi_ws2812node-LPC1313-1_33_0.hex` |
|
||||
| `f09048aae0f29852430d60f2316fde89459735d6f840c1d5ff467c3a2db48439` | `/games/pokemon_pro/tmc5041node-LPC1313-1_33_0.hex` |
|
||||
| `7724048f99cb0bd41718cff45a2dd0c346e96ccd469ff0167fd5f8c3da558fdd` | `/games/pokemon_pro/pinnode-LPC1313-1_33_0.hex` |
|
||||
| `8726374ef31843c04741467ec222e3866151ef4af01fca3d51de056016d0698f` | `/games/pokemon_pro/lcdnode-LPC1113_302-1_33_0.hex` |
|
||||
| `9714787bf448873914c1a6fb1ddb89acb272e563f782d25ebc995f8672a3d416` | `/games/pokemon_pro/coil4_lednode-LPC1313-1_33_0.hex` |
|
||||
| `f92a011475196fef8b7cd73c49a557022c46cfb60af96999b769a7144ed31861` | `/games/pokemon_pro/coil4node-LPC1313-1_33_0.hex` |
|
||||
| `2226aedfa289ecca610e29de38418fa9df1319fdc20cbb45bf4e6a2cfda26651` | `/games/pokemon_pro/magsensornode-LPC812-1_33_0.hex` |
|
||||
| `d44012247c045c5ad1502f0c68b903a25186195f23ee07d8948c6f045c143c1f` | `/games/pokemon_pro/hdminode-LPC1313-1_33_0.hex` |
|
||||
| `97bd462868f9e9a1ac6a2dca2c3f14f277f621d6f4b43022f6e9f8184d062746` | `/games/pokemon_pro/ws2812pinnode-LPC1313-1_33_0.hex` |
|
||||
| `4ea55b676552c2eb7c6256dd0cb8994e09fc553781a77afac8816423c5760982` | `/games/pokemon_pro/node4-LPC1124_303-1_33_0.hex` |
|
||||
| `eb14c9ab8e730510b1f2b75f27c39be2345228561075c0d7334584e01d6082e1` | `/games/pokemon_pro/node4-LPC812-1_33_0.hex` |
|
||||
| `f8c4af570b7419dee7ccffd4c0d219b4cad3a7fe785f934c2a864cbb100dde03` | `/games/pokemon_pro/ws2812node-LPC1313-1_33_0.hex` |
|
||||
| `a59efe815648fce1316e11c05881ba3c05edd4bc0d857359a07fc902f61fdf4c` | `/games/pokemon_pro/pinnode-LPC1112_201-1_33_0.hex` |
|
||||
| `21fb36da50792beca06372a27c122c4f6a45739d4e92a688f74217eb4311a8b9` | `/games/pokemon_pro/coil4node-LPC1112_201-1_33_0.hex` |
|
||||
| `5ca73eb8f6c1fe2574f2fb5ed1b4fe383c91db28e9ac463fb91af42812a2fa42` | `/games/pokemon_pro/tmc2590node-LPC1313-1_33_0.hex` |
|
||||
|
||||
## Sensitive / Handle Carefully
|
||||
|
||||
The following file exists and is referenced by tooling, but its value is intentionally omitted:
|
||||
|
||||
- `/games/pokemon_pro/spike3/scripts/corefile_passphrase.txt`
|
||||
101
audio-asset-search.md
Normal file
101
audio-asset-search.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Audio Asset Search
|
||||
|
||||
## Summary
|
||||
|
||||
A whole-target-root static scan did not find standalone gameplay audio files in ordinary formats. The scan covered target-root directories, not just `/games/pokemon_pro/assets`, and wrote reproducible results under `analysis/audio-search/`.
|
||||
|
||||
Confirmed results:
|
||||
|
||||
- `analysis/audio-search/summary.json` counted `7375` target-root files.
|
||||
- Filename scanning found `74` audio-related paths, but these are ALSA/BlueALSA/GStreamer support files, kernel sound modules, udev rules, two speaker-panel PNGs, and the exported Godot sound-id script; they are not gameplay sound content.
|
||||
- `file(1)` classification produced `284` media/audio-looking hits: `278` were video containers and `6` were false-positive Wi-Fi firmware files reported as `GTA audio index data (SDT)`.
|
||||
- `ffprobe` parsed streams from `281` target-root files and found `0` files with audio streams.
|
||||
- Binary magic scanning found many codec/library false positives. No confirmed standalone Ogg, WAV, MP3, FLAC, Opus, or AAC gameplay-audio file was recovered from the target rootfs.
|
||||
|
||||
This extends the narrower Godot/Radium media scan documented in `godot-and-radium-assets.md`. That earlier generated-asset scan found one AAC-bearing MP4 after extracting `/games/pokemon_pro/assets/godot/main.pck`: `assets/godot_raw/scenes/shared_items/gfx/bknd/Arena_Neon/Tournament_Neon_01_22_26_h265.mp4`.
|
||||
|
||||
## Scope And Command
|
||||
|
||||
The scanner is workspace-local and does not execute target AArch64 binaries.
|
||||
|
||||
```sh
|
||||
python3 rootfs-triage-wiki/scripts/search_audio_assets.py
|
||||
```
|
||||
|
||||
The script scanned these target-root directories: `bin`, `boot`, `connectivity`, `data`, `dev`, `etc`, `games`, `home`, `lib`, `lib64`, `linuxrc`, `lost+found`, `media`, `media2`, `mnt`, `opt`, `proc`, `root`, `run`, `sbin`, `sys`, `tmp`, `usr`, and `var`.
|
||||
|
||||
Generated outputs:
|
||||
|
||||
- `analysis/audio-search/summary.json`
|
||||
- `analysis/audio-search/filename-hits.tsv`
|
||||
- `analysis/audio-search/file-type-hits.tsv`
|
||||
- `analysis/audio-search/magic-hits.tsv`
|
||||
- `analysis/audio-search/ffprobe-streams.tsv`
|
||||
- `analysis/audio-search/audio-related-strings.json`
|
||||
|
||||
## Target-Root Media Probe Results
|
||||
|
||||
`analysis/audio-search/ffprobe-streams.tsv` contains every target-root file where `ffprobe` could parse stream metadata:
|
||||
|
||||
| Count | Streams |
|
||||
| ---: | --- |
|
||||
| `248` | `0:video:hevc` |
|
||||
| `30` | `0:video:h264` |
|
||||
| `2` | `0:video:png` |
|
||||
| `1` | `0:video:ansi` |
|
||||
|
||||
Every parsed target-root stream row has `has_audio=False`.
|
||||
|
||||
## Filename Hits
|
||||
|
||||
`analysis/audio-search/filename-hits.tsv` found audio-related names such as:
|
||||
|
||||
- `/etc/alsa/conf.d/20-bluealsa.conf`
|
||||
- `/usr/lib/libasound.so.2.0.0`
|
||||
- `/usr/lib/gstreamer-1.0/libgstaudioconvert.so`
|
||||
- `/usr/bin/aplay`
|
||||
- `/usr/bin/bluealsa`
|
||||
- `/lib/modules/6.6.28-v8/kernel/sound/usb/snd-usb-audio.ko.xz`
|
||||
- `/games/pokemon_pro/assets/nuk/images/System/TestMode/spike_3_speaker_panel.png`
|
||||
|
||||
These are system audio stack components, tests, images, or support files, not recovered gameplay audio content.
|
||||
|
||||
## False Positives
|
||||
|
||||
`file(1)` reported six firmware files as `GTA audio index data (SDT)`:
|
||||
|
||||
- `/lib/firmware/brcm/brcmfmac43436-sdio.bin`
|
||||
- `/lib/firmware/brcm/brcmfmac43436s-sdio.bin`
|
||||
- `/lib/firmware/cypress/cyfmac43430-sdio.bin`
|
||||
- `/lib64/firmware/brcm/brcmfmac43436-sdio.bin`
|
||||
- `/lib64/firmware/brcm/brcmfmac43436s-sdio.bin`
|
||||
- `/lib64/firmware/cypress/cyfmac43430-sdio.bin`
|
||||
|
||||
These paths and filenames identify them as Wi-Fi firmware. `ffprobe` did not parse audio streams from them.
|
||||
|
||||
Binary magic scanning also found Ogg-looking byte sequences in executable/library data. Two game-specific examples were carved and checked:
|
||||
|
||||
- `/games/pokemon_pro/game` at offset `52183200`
|
||||
- `/games/pokemon_pro/spike3/spike_menu/game` at offset `4398800`
|
||||
|
||||
`file(1)` labels the carved 1 MiB samples as `Ogg data`, but `ffprobe` rejects both with `CRC mismatch`. These are not confirmed playable audio streams. Based on their location in ELF binaries and nearby strings such as `external/soloud/src/audiosource/wav/stb_vorbis.cpp`, they are likely codec/test/parser data or static constants rather than asset payloads.
|
||||
|
||||
## Native Sound Path Evidence
|
||||
|
||||
The absence of standalone audio files does not mean the game lacks sound support. Static strings in `/games/pokemon_pro/game` show a native audio path:
|
||||
|
||||
- `libasound.so.2`
|
||||
- `ALSA`
|
||||
- `AD_SOUND_MASTER_VOLUME_SETTING`
|
||||
- `AD_MUSIC_ATTENUATION`
|
||||
- `AD_SPEECH_ATTENUATION`
|
||||
- `AD_ENABLE_SPIKE3_BOOT_SOUND`
|
||||
- `PLAYING SOUND #%d`
|
||||
- `SPEECH: 'SQUIRTLE'`
|
||||
- `MUSIC: TEAM ROCKET`
|
||||
- `MUSIC: MAINPLAY`
|
||||
- `Radium::Sound`
|
||||
- `void Radium::Sound::Play()`
|
||||
- `external/soloud/src/audiosource/wav/stb_vorbis.cpp`
|
||||
|
||||
Inference: most gameplay audio is probably stored in a Radium/native asset representation, likely inside `/games/pokemon_pro/image.bin` or an internal object table addressed by the native game, rather than as standard loose `.ogg`, `.wav`, or `.mp3` files. Reconstructing those sounds will require reversing the Radium sound/object lookup path rather than searching for ordinary file extensions alone.
|
||||
75
boot-and-init.md
Normal file
75
boot-and-init.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Boot and Init
|
||||
|
||||
## Init System
|
||||
|
||||
The rootfs uses BusyBox init via `/etc/inittab`. There is no systemd unit layout in this extraction.
|
||||
|
||||
Important `/etc/inittab` behavior:
|
||||
|
||||
1. Mounts proc at `/proc`.
|
||||
2. Creates `/dev/pts` and `/dev/shm`.
|
||||
3. Runs `mount -a`.
|
||||
4. Creates `/run/lock/subsys`.
|
||||
5. Runs `swapon -a`.
|
||||
6. Links `/dev/fd`, `/dev/stdin`, `/dev/stdout`, and `/dev/stderr` into proc fd paths.
|
||||
7. Sets hostname from `/etc/hostname`.
|
||||
8. Runs `/etc/init.d/rcS`.
|
||||
9. On shutdown, runs `/etc/init.d/rcK`, then `swapoff -a`, then `umount -a -r`.
|
||||
|
||||
Console gettys are present but commented out:
|
||||
|
||||
- Serial console: `console::respawn:/sbin/getty -L console 0 vt100`
|
||||
- HDMI console: `tty1::respawn:/sbin/getty -L tty1 0 vt100`
|
||||
|
||||
That means the extracted configuration does not spawn an interactive login shell by default.
|
||||
|
||||
## rcS Startup Order
|
||||
|
||||
`/etc/init.d/rcS` runs all `/etc/init.d/S??*` files in lexical order. For this rootfs:
|
||||
|
||||
| Order | Script | Purpose |
|
||||
| --- | --- | --- |
|
||||
| 01 | `S01seedrng` | Seed RNG support. Comment references `/data/seedrng`. |
|
||||
| 01 | `S01syslogd` | Starts syslog daemon. |
|
||||
| 02 | `S02klogd` | Starts kernel log daemon. |
|
||||
| 02 | `S02sysctl` | Applies sysctl settings. |
|
||||
| 10 | `S10udev` | Starts udev. |
|
||||
| 12 | `S12lvds_panel` | Starts boot display on the LVDS panel. |
|
||||
| 30 | `S30dbus` | Starts D-Bus. |
|
||||
| 40 | `S40bluetoothd` | Starts Bluetooth daemon. |
|
||||
| 40 | `S40network` | Runs `/sbin/ifup -a`. |
|
||||
| 50 | `S50crond` | Starts cron daemon. No cron job files were present under `/etc/cron*` in this extraction. |
|
||||
| 55 | `S55spike3` | Spike-specific Bluetooth, audio, and module setup. |
|
||||
| 95 | `S95game` | Kernel update check, persistent directory setup, core dump policy, monitors, game launch, update scanner. |
|
||||
|
||||
## Shutdown Order
|
||||
|
||||
`/etc/init.d/rcK` reverses the `/etc/init.d/S??*` list and calls each script with `stop`.
|
||||
|
||||
## Spike-specific Boot Actions
|
||||
|
||||
`S12lvds_panel`:
|
||||
|
||||
- Sources `/etc/init.d/shared`.
|
||||
- Starts `$BOOT_DISPLAY` in the background.
|
||||
- `$BOOT_DISPLAY` resolves first to `/games/spike3/bin/boot_display`, falling back to `/usr/local/spike/boot_display`.
|
||||
|
||||
`S55spike3`:
|
||||
|
||||
- Runs `bluetoothctl power on`.
|
||||
- Registers a `KeyboardOnly` Bluetooth agent and makes it the default agent.
|
||||
- Starts BlueALSA as A2DP source: `/usr/bin/bluealsa -p a2dp-source &`.
|
||||
- Loads `i2c-dev`, `exfat`, and `fuse`.
|
||||
|
||||
`S95game`:
|
||||
|
||||
- Checks staged kernel update files under `/connectivity/os_update`.
|
||||
- Runs a previous `/connectivity/os_update/post_install_hook.sh` if present.
|
||||
- Creates persistent ALSA/Bluetooth directories under `/connectivity`.
|
||||
- Ensures `/var/lib/alsa`, `/var/lib/bluetooth`, and `/var/lib/bluealsa` point into `/connectivity`.
|
||||
- Overrides hostname from `/connectivity/hostname` if present.
|
||||
- Prepares `/connectivity/dump/cores`.
|
||||
- Sets `ulimit -c unlimited`.
|
||||
- Sets core pattern to `/connectivity/dump/cores/core.%h.%t`.
|
||||
- Starts `reboot_monitor`, `conagent_monitor`, and `game_monitor` if `/games/game` exists.
|
||||
- Starts `/etc/init.d/update` in the background so USB/downloaded `.spk` updates can interrupt the running game.
|
||||
469
conagent-pseudocode.c
Normal file
469
conagent-pseudocode.c
Normal file
@@ -0,0 +1,469 @@
|
||||
/*
|
||||
* Non-compilable C-like reconstruction of /games/pokemon_pro/conagent.
|
||||
*
|
||||
* This is not a Ghidra C export. Ghidra imported the binary but did not create
|
||||
* normal functions for most stripped text during this pass. The code below is
|
||||
* manually reconstructed from Ghidra imports, objdump ranges, and strings.
|
||||
*/
|
||||
|
||||
typedef struct AgentConfig AgentConfig;
|
||||
typedef struct Log Log;
|
||||
typedef struct GameClient GameClient;
|
||||
typedef struct HttpClient HttpClient;
|
||||
typedef struct GameMessage GameMessage;
|
||||
typedef struct HttpRequest HttpRequest;
|
||||
typedef struct TransferRequest TransferRequest;
|
||||
|
||||
static volatile bool g_shutdown;
|
||||
|
||||
int conagent_main(int argc, char **argv)
|
||||
{
|
||||
AgentConfig cfg;
|
||||
Log *log;
|
||||
GameClient *game;
|
||||
|
||||
cfg = default_agent_config();
|
||||
parse_agent_args(argc, argv, &cfg);
|
||||
|
||||
install_signal_handler(SIGTERM, request_shutdown);
|
||||
install_signal_handler(SIGINT, request_shutdown);
|
||||
|
||||
log = create_log_channel(&cfg);
|
||||
if (log == NULL) {
|
||||
if (!cfg.silent) {
|
||||
fwrite("WARNING: Failed to create agent log file.\n", 1, 42, stderr);
|
||||
fwrite("WARNING: Failed to create agent log channel.\n", 1, 45, stderr);
|
||||
}
|
||||
}
|
||||
|
||||
log_info(log, "Stern Pinball Connectivity Agent %s", "1.0.16 (Linux,ARM64,GNU)");
|
||||
log_info(log, "Using libagentcmn %s", "libagent 1.0.16 (Linux,ARM64,GNU)");
|
||||
log_agent_configuration(log, &cfg);
|
||||
|
||||
game = game_client_create(&cfg, log);
|
||||
if (game == NULL) {
|
||||
log_fatal(log, "Failed to create Game process interface.");
|
||||
destroy_log_channel(log);
|
||||
return 0;
|
||||
}
|
||||
|
||||
game_client_start(game, log);
|
||||
|
||||
while (!g_shutdown) {
|
||||
game_client_update(game, log);
|
||||
usleep(16000);
|
||||
}
|
||||
|
||||
game_client_stop(game, log);
|
||||
destroy_log_channel(log);
|
||||
return 0;
|
||||
}
|
||||
|
||||
GameClient *game_client_create(const AgentConfig *cfg, Log *log)
|
||||
{
|
||||
GameClient *gc;
|
||||
|
||||
gc = allocate_game_client_state();
|
||||
if (gc == NULL) {
|
||||
log_error(log, "Failed to allocate memory for game_client_state_t.");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
gc->socket_path = cfg->game_process_host ? cfg->game_process_host
|
||||
: "/usr/local/spike/agent.uds";
|
||||
gc->state = NOT_CONNECTED;
|
||||
gc->next_rx_id = 0;
|
||||
gc->next_tx_id = 0;
|
||||
|
||||
gc->http = http_client_create(cfg, log);
|
||||
if (gc->http == NULL) {
|
||||
log_error(log, "Failed to create HTTP client to access back-end server.");
|
||||
free(gc);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
init_arena(&gc->json_arena, "game_client_json");
|
||||
init_arena(&gc->action_arena, "game_client_action");
|
||||
init_arena(&gc->config_arena, "game_client_config");
|
||||
return gc;
|
||||
}
|
||||
|
||||
void game_client_update(GameClient *gc, Log *log)
|
||||
{
|
||||
GameMessage msg;
|
||||
|
||||
if (gc->state == NOT_CONNECTED) {
|
||||
if (agent_client_connect(gc->socket_path)) {
|
||||
log_info(log, "Successfully connected to Game process. Resetting state.");
|
||||
gc->state = NEGOTIATE_PROTOCOL;
|
||||
send_protocol_request(gc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
while (receive_game_message(gc, &msg)) {
|
||||
GameAction action = decode_game_message(gc, &msg);
|
||||
process_game_action(gc, log, action);
|
||||
}
|
||||
|
||||
if (gc->state == COMMS_ENABLED) {
|
||||
maybe_send_heartbeat(gc, log);
|
||||
http_client_update(gc->http, log);
|
||||
}
|
||||
}
|
||||
|
||||
void process_game_action(GameClient *gc, Log *log, GameAction action)
|
||||
{
|
||||
switch (action.type) {
|
||||
case AGREE_PROTOCOL_VERSION:
|
||||
if (action.protocol_version == EXPECTED_PROTOCOL_VERSION) {
|
||||
log_info(log, "Negotiated protocol version %d.", action.protocol_version);
|
||||
gc->state = AWAIT_CONFIG;
|
||||
} else {
|
||||
log_error(log, "Expected protocol version %d, got unsupported version %d.",
|
||||
EXPECTED_PROTOCOL_VERSION, action.protocol_version);
|
||||
disconnect_game(gc);
|
||||
}
|
||||
break;
|
||||
|
||||
case CONFIG_SECURITY:
|
||||
require(action.has_device_credentials,
|
||||
"CONFIG_SECURITY message missing device credentials.");
|
||||
store_security_config(gc, action.security);
|
||||
break;
|
||||
|
||||
case CONFIG_NETWORK:
|
||||
validate_network_config(action.network);
|
||||
http_client_configure(gc->http, action.network);
|
||||
break;
|
||||
|
||||
case CONFIG_TITLE:
|
||||
require(action.serial_number, "CONFIG_TITLE message missing machine serial number.");
|
||||
require(action.machine_configuration_number,
|
||||
"CONFIG_TITLE message missing machine configuration number.");
|
||||
require(action.code_version, "CONFIG_TITLE message missing code version identifier.");
|
||||
require(action.game_title, "CONFIG_TITLE message missing game title identifier.");
|
||||
require(action.game_model, "CONFIG_TITLE message missing game model identifier.");
|
||||
store_title_config(gc, action.title);
|
||||
break;
|
||||
|
||||
case COMMS_ENABLE:
|
||||
if (gc->state == NEGOTIATE_PROTOCOL) {
|
||||
log_error(log, "Cannot enable communications during protocol version negotiation");
|
||||
break;
|
||||
}
|
||||
if (!have_required_config(gc)) {
|
||||
log_error(log, "Cannot enable communications while waiting for required configuration data");
|
||||
break;
|
||||
}
|
||||
if (!http_sync_api_token(gc->http, log)) {
|
||||
log_error(log, "Failed to retrieve the API token from the backend server");
|
||||
break;
|
||||
}
|
||||
gc->state = COMMS_ENABLED;
|
||||
break;
|
||||
|
||||
case COMMS_DISABLE:
|
||||
gc->state = COMMS_DISABLED;
|
||||
break;
|
||||
|
||||
case MACHINE_REGISTER:
|
||||
api_game_register(gc, log, &action.register_machine);
|
||||
break;
|
||||
|
||||
case SET_MACHINE_UUID:
|
||||
gc->machine_uuid = action.machine_uuid;
|
||||
log_info(log, "Machine UUID updated to %s.", gc->machine_uuid);
|
||||
break;
|
||||
|
||||
case PLAYER_AUTHENTICATE:
|
||||
api_player_auth(gc, log, action.user_uuid);
|
||||
break;
|
||||
|
||||
case PING_REQUEST:
|
||||
api_ping(gc, log);
|
||||
break;
|
||||
|
||||
case GAME_TO_SERVER_POST:
|
||||
if (!valid_game_api_endpoint(action.endpoint)) {
|
||||
nack(action.id, "The API endpoint supplied with GAME_TO_SERVER_POST request is invalid");
|
||||
break;
|
||||
}
|
||||
api_game_to_server_post(gc, log, action.endpoint, action.json_payload);
|
||||
break;
|
||||
|
||||
case GAME_TO_SERVER_GET:
|
||||
if (!valid_game_api_endpoint(action.endpoint)) {
|
||||
nack(action.id, "The API endpoint supplied with GAME_TO_SERVER_GET request is invalid");
|
||||
break;
|
||||
}
|
||||
api_game_to_server_get(gc, log, action.endpoint, action.args);
|
||||
break;
|
||||
|
||||
case FILE_TRANSFER_REQUEST:
|
||||
http_client_queue_file_transfer(gc->http, &action.transfer);
|
||||
break;
|
||||
|
||||
case DISABLE_INTERFACE_REQUEST:
|
||||
disable_network_interface(log, action.interface_name);
|
||||
break;
|
||||
|
||||
case CONFIGURE_INTERFACE_REQUEST:
|
||||
reconfigure_network_interface(log, &action.network_interface);
|
||||
break;
|
||||
|
||||
case QUERY_SERVER_TIME:
|
||||
http_client_query_server_time(gc->http, log, action.server_host);
|
||||
break;
|
||||
|
||||
case CHECK_FOR_UPDATES:
|
||||
api_version_upgrades_available(gc, log);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool http_sync_api_token(HttpClient *http, Log *log)
|
||||
{
|
||||
char url[MAX_URL];
|
||||
char body[MAX_JSON];
|
||||
char response[MAX_RESPONSE];
|
||||
json_t *json;
|
||||
|
||||
if (!http->server_host) {
|
||||
log_error(log, "http_sync_api_token missing back-end server hostname or IP address.");
|
||||
return false;
|
||||
}
|
||||
if (!http->api_username) {
|
||||
log_error(log, "http_sync_api_token missing API token username.");
|
||||
return false;
|
||||
}
|
||||
if (!http->api_password) {
|
||||
log_error(log, "http_sync_api_token missing API token password.");
|
||||
return false;
|
||||
}
|
||||
|
||||
snprintf(url, sizeof(url), "https://%s/api-token-auth/", http->server_host);
|
||||
snprintf(body, sizeof(body), "{\"username\":\"%s\",\"password\":\"%s\"}",
|
||||
http->api_username, http->api_password);
|
||||
|
||||
CURL *curl = curl_easy_init();
|
||||
if (curl == NULL) {
|
||||
log_error(log, "Failed to allocate cURL request object.");
|
||||
return false;
|
||||
}
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, strlen(body));
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, "Content-Type: application/json");
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "Spike-Connectivity-Agent/1.0");
|
||||
|
||||
if (curl_easy_perform(curl) != CURLE_OK) {
|
||||
log_error(log, "http_sync_api_token: Failed to retrieve API token");
|
||||
curl_easy_cleanup(curl);
|
||||
return false;
|
||||
}
|
||||
|
||||
json = parse_json(response);
|
||||
if (!json_is_object(json)) {
|
||||
log_error(log, "http_sync_api_token: Expected json_type_object");
|
||||
curl_easy_cleanup(curl);
|
||||
return false;
|
||||
}
|
||||
if (!json_get_string(json, "token", http->api_token, sizeof(http->api_token))) {
|
||||
log_error(log, "http_sync_api_token: Failed to find the token field.");
|
||||
curl_easy_cleanup(curl);
|
||||
return false;
|
||||
}
|
||||
|
||||
curl_easy_cleanup(curl);
|
||||
return true;
|
||||
}
|
||||
|
||||
HttpResult http_execute_api(HttpClient *http, HttpRequest *req, Log *log)
|
||||
{
|
||||
char url[MAX_URL];
|
||||
char auth[MAX_HEADER];
|
||||
char device_token[MAX_HEADER];
|
||||
char device_refresh[MAX_HEADER];
|
||||
|
||||
if (req->query_len > 0) {
|
||||
snprintf(url, sizeof(url), "https://%s%s/?%.*s",
|
||||
http->server_host, req->endpoint, req->query_len, req->query);
|
||||
} else {
|
||||
snprintf(url, sizeof(url), "https://%s%s/", http->server_host, req->endpoint);
|
||||
}
|
||||
|
||||
snprintf(auth, sizeof(auth), "Authorization: Token %s", http->api_token);
|
||||
snprintf(device_token, sizeof(device_token), "Device-Token: %s", http->device_token);
|
||||
snprintf(device_refresh, sizeof(device_refresh), "Device-Refresh: %s", http->device_refresh);
|
||||
|
||||
curl_easy_reset(req->curl);
|
||||
curl_easy_setopt(req->curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(req->curl, CURLOPT_HTTPHEADER, auth);
|
||||
curl_easy_setopt(req->curl, CURLOPT_HTTPHEADER, device_token);
|
||||
curl_easy_setopt(req->curl, CURLOPT_HTTPHEADER, device_refresh);
|
||||
curl_easy_setopt(req->curl, CURLOPT_USERAGENT, "Spike-Connectivity-Agent/1.0");
|
||||
|
||||
if (req->method == HTTP_PUT) {
|
||||
curl_easy_setopt(req->curl, CURLOPT_CUSTOMREQUEST, "PUT");
|
||||
} else if (req->method == HTTP_DELETE) {
|
||||
curl_easy_setopt(req->curl, CURLOPT_CUSTOMREQUEST, "DELETE");
|
||||
} else if (req->method == HTTP_POST) {
|
||||
curl_easy_setopt(req->curl, CURLOPT_POSTFIELDS, req->body);
|
||||
curl_easy_setopt(req->curl, CURLOPT_HTTPHEADER, "Content-Type: application/json");
|
||||
}
|
||||
|
||||
CURLcode rc = curl_easy_perform(req->curl);
|
||||
if (rc != CURLE_OK) {
|
||||
log_error(log, "curl_easy_perform returned failure code %d.", rc);
|
||||
return HTTP_RESULT_CURL_ERROR;
|
||||
}
|
||||
|
||||
read_response_headers(req, "Message-Number: ", "App-Status-Code: ");
|
||||
return classify_http_response(req);
|
||||
}
|
||||
|
||||
void disable_network_interface(Log *log, const char *ifname)
|
||||
{
|
||||
char path[128];
|
||||
char cmd[256];
|
||||
|
||||
if (!valid_interface_name(ifname)) {
|
||||
log_error(log, "Cannot reconfigure network interface; no interface name supplied.");
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(path, sizeof(path), "/tmp/network_interface.%s", ifname);
|
||||
if (!file_exists(path)) {
|
||||
log_info(log, "Skipping disablement of network interface %s since interface configuration file %s does not exist.",
|
||||
ifname, path);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(cmd, sizeof(cmd), "ifdown -i %s %s", path, ifname);
|
||||
log_info(log, "Executing command \"%s\".", cmd);
|
||||
int rc = system(cmd);
|
||||
if (rc != 0) {
|
||||
log_error(log, "The command \"%s\" failed (exit code %d).", cmd, rc);
|
||||
}
|
||||
}
|
||||
|
||||
bool reconfigure_network_interface(Log *log, const NetworkInterfaceConfig *cfg)
|
||||
{
|
||||
char path[128];
|
||||
char cmd[256];
|
||||
FILE *fp;
|
||||
|
||||
validate_network_interface_config(cfg);
|
||||
|
||||
snprintf(path, sizeof(path), "/tmp/network_interface.%s", cfg->ifname);
|
||||
fp = fopen(path, "w");
|
||||
if (fp == NULL) {
|
||||
log_error(log, "Failed to open network interface configuration file \"%s\".", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cfg->dhcp) {
|
||||
fprintf(fp, "iface %s inet dhcp\n", cfg->ifname);
|
||||
} else {
|
||||
fprintf(fp, "iface %s inet static\n", cfg->ifname);
|
||||
fprintf(fp, "address %s\n", cfg->address);
|
||||
fprintf(fp, "netmask %s\n", cfg->netmask);
|
||||
fprintf(fp, "gateway %s\n", cfg->gateway);
|
||||
if (cfg->secondary_dns) {
|
||||
fprintf(fp, "dns-nameservers %s %s\n", cfg->primary_dns, cfg->secondary_dns);
|
||||
} else {
|
||||
fprintf(fp, "dns-nameservers %s\n", cfg->primary_dns);
|
||||
}
|
||||
}
|
||||
|
||||
fflush(fp);
|
||||
fclose(fp);
|
||||
|
||||
if (cfg->store_only) {
|
||||
log_info(log, "Not actually reconfiguring network interface %s; store-only flag is set.",
|
||||
cfg->ifname);
|
||||
return true;
|
||||
}
|
||||
|
||||
snprintf(cmd, sizeof(cmd), "ifup -i %s %s", path, cfg->ifname);
|
||||
log_info(log, "Executing command \"%s\".", cmd);
|
||||
return system(cmd) == 0;
|
||||
}
|
||||
|
||||
void *agent_client_rx_thread(void *arg)
|
||||
{
|
||||
GameClient *gc = arg;
|
||||
|
||||
pthread_setname_np(pthread_self(), "agent_client_rx");
|
||||
log_info(gc->log, "Client receive thread started normally.");
|
||||
|
||||
while (!gc->rx_shutdown) {
|
||||
fd_set readfds;
|
||||
FD_ZERO(&readfds);
|
||||
FD_SET(gc->socket_fd, &readfds);
|
||||
|
||||
int rc = select(gc->socket_fd + 1, &readfds, NULL, NULL, NULL);
|
||||
if (rc < 0) {
|
||||
if (errno == EAGAIN || errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
gc->last_error = errno;
|
||||
break;
|
||||
}
|
||||
|
||||
if (FD_ISSET(gc->socket_fd, &readfds)) {
|
||||
ssize_t n = recv(gc->socket_fd, gc->rxbuf, sizeof(gc->rxbuf), 0);
|
||||
if (n == 0) {
|
||||
log_info(gc->log, "Client receive thread saw client disconnect.");
|
||||
break;
|
||||
}
|
||||
if (n < 0) {
|
||||
gc->last_error = errno;
|
||||
break;
|
||||
}
|
||||
|
||||
ParserStatus st = agent_parse_frame(&gc->parser, gc->rxbuf, n);
|
||||
if (st == PARSER_COMPLETE) {
|
||||
push_received_message(gc, &gc->parser.message);
|
||||
} else if (st == PARSER_ERROR) {
|
||||
log_error(gc->log, "Encountered error decoding message frame");
|
||||
reset_stream(gc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log_info(gc->log, "Client receive thread terminated.");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void cloudwatch_put_log_events(CloudWatchLogger *cw, const char *message)
|
||||
{
|
||||
/*
|
||||
* Builds an AWS Signature Version 4 request:
|
||||
* service: logs
|
||||
* region: us-west-1
|
||||
* target: Logs_20140328.PutLogEvents
|
||||
* URL: https://logs.us-west-1.amazonaws.com
|
||||
*
|
||||
* The binary imports SHA256_* and HMAC and contains strings for the
|
||||
* canonical request, signed headers, x-amz-date, and nextSequenceToken.
|
||||
*/
|
||||
char payload[MAX_CLOUDWATCH_PAYLOAD];
|
||||
char authorization[MAX_HEADER];
|
||||
|
||||
build_put_log_events_json(payload, cw->log_group, cw->log_stream,
|
||||
cw->next_sequence_token, message);
|
||||
aws4_sign_request(authorization, cw->access_key, cw->secret_key,
|
||||
"us-west-1", "logs", payload);
|
||||
|
||||
curl_easy_setopt(cw->curl, CURLOPT_URL, "https://logs.us-west-1.amazonaws.com");
|
||||
curl_easy_setopt(cw->curl, CURLOPT_HTTPHEADER, "x-amz-target: Logs_20140328.PutLogEvents");
|
||||
curl_easy_setopt(cw->curl, CURLOPT_HTTPHEADER, authorization);
|
||||
curl_easy_setopt(cw->curl, CURLOPT_POSTFIELDS, payload);
|
||||
|
||||
if (curl_easy_perform(cw->curl) != CURLE_OK) {
|
||||
log_error(cw->log, "CLOUD LOG POST: curl_easy_perform() failed");
|
||||
}
|
||||
}
|
||||
421
conagent-re.md
Normal file
421
conagent-re.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Conagent Reverse Engineering Report
|
||||
|
||||
## Scope
|
||||
|
||||
Target binary:
|
||||
|
||||
- Target path: `/games/pokemon_pro/conagent`
|
||||
- Workspace path: `games/pokemon_pro/conagent`
|
||||
- SHA-256: `a76da91f0cfdaecceb579ef27d223ab8a86c313d0eefa5217b96823c43c97e69`
|
||||
- Type: stripped AArch64 PIE ELF, dynamically linked, interpreter `/lib/ld-linux-aarch64.so.1`
|
||||
- Size: 272,432 bytes
|
||||
- Ghidra import path: `/pokemon_spike3/conagent`
|
||||
- Ghidra language: `AARCH64:LE:64:v8A`
|
||||
|
||||
## Ghidra Status
|
||||
|
||||
The binary imported and auto-analyzed in Ghidra successfully. The MCP decompiler recovered the ELF entry stub, but Ghidra did not create ordinary functions for most of the stripped `.text` region during this pass.
|
||||
|
||||
Evidence:
|
||||
|
||||
- Ghidra import result: success, language `AARCH64:LE:64:v8A`, auto-analysis complete.
|
||||
- Ghidra `entry` decompilation shows `__libc_start_main(&DAT_00103d90, ...)`.
|
||||
- Ghidra `find_code_gaps` reported one large unanalyzed region from `00104411` to `0012f11f`, which covers most application logic.
|
||||
- Local `objdump -p` shows the executable LOAD segment maps file offsets directly to VAs from `0x0` through `0x41007`, and Ghidra imported it with an image base of `0x100000`.
|
||||
|
||||
Because of that analysis limitation, this report combines:
|
||||
|
||||
- Ghidra import/import-symbol results.
|
||||
- Local `objdump` disassembly.
|
||||
- Local `strings -a -t x` evidence.
|
||||
- Manual reconstruction from string clusters and call sites.
|
||||
|
||||
## High-level Role
|
||||
|
||||
`conagent` is the Stern Pinball Connectivity Agent. It bridges the local game process to Stern back-end services.
|
||||
|
||||
Confirmed evidence:
|
||||
|
||||
- Version/banner string at `0x36838`: `Stern Pinball Connectivity Agent %s`
|
||||
- Version string at `0x36818`: `1.0.16 (Linux,ARM64,GNU)`
|
||||
- Library string at `0x39448`: `libagent 1.0.16 (Linux,ARM64,GNU)`
|
||||
- Local game-process socket path at `0x396d9`: `/usr/local/spike/agent.uds`
|
||||
- Default logs:
|
||||
- `0x36720`: `/dump/log/connectivity/agent.log`
|
||||
- `0x36748`: `/dump/log/connectivity/agent.msglog`
|
||||
- `0x36770`: `/dump/log/connectivity/agent.httplog`
|
||||
|
||||
Inferred architecture:
|
||||
|
||||
- The game process communicates with `conagent` over a local Unix-domain socket.
|
||||
- `conagent` parses framed game messages, maintains a state machine, and translates selected game actions into HTTPS requests and file-transfer activity.
|
||||
- It also accepts game-originated network configuration requests and can mutate host network interface configuration.
|
||||
|
||||
## Dynamic Dependencies and Imports
|
||||
|
||||
Dynamic libraries from `objdump -p`:
|
||||
|
||||
- `libcurl.so.4`
|
||||
- `libssl.so.3`
|
||||
- `libcrypto.so.3`
|
||||
- `libstdc++.so.6`
|
||||
- `libm.so.6`
|
||||
- `libgcc_s.so.1`
|
||||
- `libc.so.6`
|
||||
- `ld-linux-aarch64.so.1`
|
||||
|
||||
Important imported APIs from Ghidra/import table:
|
||||
|
||||
- Network/socket IPC: `socket`, `bind`, `listen`, `accept`, `connect`, `send`, `recv`, `select`, `shutdown`, `setsockopt`, `getaddrinfo`, `freeaddrinfo`
|
||||
- HTTP: `curl_global_init`, `curl_easy_init`, `curl_easy_setopt`, `curl_easy_perform`, `curl_easy_getinfo`, `curl_multi_*`, `curl_share_*`, `curl_slist_append`
|
||||
- Crypto/signing: `SHA256_Init`, `SHA256_Update`, `SHA256_Final`, `EVP_sha256`, `HMAC`
|
||||
- Filesystem/process: `fopen`, `fread`, `fwrite`, `fflush`, `fclose`, `rename`, `unlink`, `mkdir`, `stat`, `readlink`, `system`
|
||||
- Shared memory/threading: `shm_open`, `shm_unlink`, `mmap`, `munmap`, `pthread_create`, `pthread_join`, `pthread_setname_np`, C++ `std::thread`, `std::condition_variable`
|
||||
|
||||
## Startup Flow
|
||||
|
||||
Recovered from entry at `0x4300` and main-like range at file VA `0x3d90`:
|
||||
|
||||
- ELF entry calls `__libc_start_main` with the main function pointer at `0x3d90`.
|
||||
- The main function allocates a stack configuration block of about `0x140` bytes.
|
||||
- It installs signal handling via `sigaction`.
|
||||
- It calls internal setup around `0x23960`, then creates a logger/channel around `0x24310`.
|
||||
- It logs banner/configuration strings from `agent.cpp`.
|
||||
- It creates the game process interface around `0xc2c0`.
|
||||
- It starts the game client/interface around `0xc940`.
|
||||
- It loops calling an update function around `0xc990` with a `usleep(16000)` delay until a global shutdown flag is set.
|
||||
- It shuts down the interface around `0xc8d0`, destroys logging around `0x23cb0`, and returns `0`.
|
||||
|
||||
Confirmed configuration strings:
|
||||
|
||||
- `0x36888`: `Configuration data:`
|
||||
- `0x368a0`: `Log path : %s`
|
||||
- `0x368c0`: `Message log path : %s`
|
||||
- `0x368e0`: `HTTP log path : %s`
|
||||
- `0x36900`: `Game process host : %s`
|
||||
- `0x36920`: `Socket type : %s`
|
||||
- `0x36940`: `Log level : %s`
|
||||
- `0x36960`: `Transmit buffer size : %zu (%zuKB)`
|
||||
- `0x36988`: `HTTP request count : %zu`
|
||||
- `0x369a8`: `Log received messages: %s`
|
||||
- `0x369c8`: `Log HTTP data : %s`
|
||||
- `0x369e8`: `Log to console : %s`
|
||||
- `0x36a08`: `Silent operation : %s`
|
||||
|
||||
## Local Game Protocol
|
||||
|
||||
Source-string evidence:
|
||||
|
||||
- `0x36ac0`: `src/agent_client.cpp`
|
||||
- `0x36db0`: `Client receive thread started normally.`
|
||||
- `0x36f08`: `client_rx`
|
||||
- `0x37010`: `agent_client_rx`
|
||||
- `0x37290`: `Message stream state reset; prev ID %d, next ID %d.`
|
||||
- `0x373d0`: `Expected ID %d; got ID %d. Sending stream RESET.`
|
||||
- `0x37408`: `Empty receive queue but ACTION_MESSAGE received.`
|
||||
|
||||
The local protocol appears to be a reliable framed message stream with:
|
||||
|
||||
- Message IDs.
|
||||
- ACK/NACK handling.
|
||||
- Resend handling.
|
||||
- Stream reset handling.
|
||||
- A receive thread named `agent_client_rx`.
|
||||
|
||||
Action names:
|
||||
|
||||
- `AgreeProtocolVersion`
|
||||
- `AcceptConfig`
|
||||
- `DenyConfig`
|
||||
- `CommsEnable`
|
||||
- `CommsDisable`
|
||||
- `RegisterMachine`
|
||||
- `AuthPlayer`
|
||||
- `PingServer`
|
||||
- `DisableNetworkInterface`
|
||||
- `ConfigureNetworkInterface`
|
||||
- `CheckForUpdates`
|
||||
- `QueryServerTime`
|
||||
- `QueueFileTransfer`
|
||||
- `UpdateTransferState`
|
||||
- `QueryTransferStatus`
|
||||
- `QueryTransferEngineStatus`
|
||||
- `QueryAchievementDescriptors`
|
||||
- `GameToServerPOST`
|
||||
- `GameToServerGET`
|
||||
|
||||
State names:
|
||||
|
||||
- `AwaitRegistration`
|
||||
- `CommsDisabled`
|
||||
- `NotConnected`
|
||||
- `NegotiateProtocol`
|
||||
- `AwaitConfig`
|
||||
- `CommsEnabled`
|
||||
|
||||
Protocol/config message names:
|
||||
|
||||
- `HEARTBEAT`
|
||||
- `CONFIG_SECURITY`
|
||||
- `CONFIG_NETWORK`
|
||||
- `CONFIG_TITLE`
|
||||
- `CONFIG_DIAGNOSTICS`
|
||||
- `CONFIG_ACCEPT`
|
||||
- `CONFIG_DENY`
|
||||
- `COMMS_ENABLE`
|
||||
- `COMMS_DISABLE`
|
||||
- `COMMS_REJECT`
|
||||
- `SYSTEM_STATUS`
|
||||
- `MACHINE_REGISTER`
|
||||
- `SET_MACHINE_UUID`
|
||||
- `PING_REQUEST`
|
||||
- `PING_RESULT`
|
||||
- `SERVER_RESPONSE`
|
||||
- `PLAYER_AUTHENTICATE`
|
||||
- `GAME_ACHIEVEMENT_QUERY`
|
||||
- `DISABLE_INTERFACE_REQUEST`
|
||||
- `CONFIGURE_INTERFACE_RESULT`
|
||||
- `SOFTWARE_VERSION_INFO`
|
||||
- `FILE_TRANSFER_REQUEST`
|
||||
- `FILE_TRANSFER_STATUS`
|
||||
- `FILE_TRANSFER_QUERY`
|
||||
- `FILE_TRANSFER_UPDATE`
|
||||
- `GAME_TO_SERVER_POST`
|
||||
- `GAME_TO_SERVER_GET`
|
||||
|
||||
## Back-end API Behavior
|
||||
|
||||
The agent talks to HTTPS APIs with libcurl and an API token.
|
||||
|
||||
Confirmed endpoints and strings:
|
||||
|
||||
| Offset | String |
|
||||
| --- | --- |
|
||||
| `0x33a20` | `https://%s/api-token-auth/` |
|
||||
| `0x33a40` | `{"username":"%s","password":"%s"}` |
|
||||
| `0x301f8` | `/api/v1/ping` |
|
||||
| `0x30388` | `/api/v1/game/version_upgrades_available` |
|
||||
| `0x30818` | `/api/v3/game/player_auth` |
|
||||
| `0x30c60` | `/api/v3/game/game_register` |
|
||||
| `0x311a8` | `/api/v3/game/game_achievement_descriptors` |
|
||||
| `0x31750` | `/api/v3/game/game_auth` |
|
||||
| `0x33190` | `/api/v2/game/heartbeat` |
|
||||
| `0x35618` | `http://%s:8345/b8Ag3XU6TH/wibbly_wobbly_timey_wimey/` |
|
||||
| `0x35cb0` | `https://%s%s/?%.*s` |
|
||||
| `0x35cc8` | `https://%s%s/` |
|
||||
|
||||
HTTP request headers/fields:
|
||||
|
||||
- `Authorization: Token %s`
|
||||
- `Device-Token: %s`
|
||||
- `Device-Refresh: %s`
|
||||
- `Message-Number: `
|
||||
- `App-Status-Code: `
|
||||
- `Content-Type: application/json`
|
||||
- User agent: `Spike-Connectivity-Agent/1.0`
|
||||
|
||||
The token sync flow:
|
||||
|
||||
- Requires server hostname/IP, API token username, and API token password.
|
||||
- POSTs JSON credentials to `https://<server>/api-token-auth/`.
|
||||
- Parses a JSON object and extracts string field `token`.
|
||||
- Logs detailed cURL and HTTP failures.
|
||||
|
||||
The generic request flow:
|
||||
|
||||
- Builds HTTPS URLs from server hostname, endpoint, and optional query arguments.
|
||||
- Adds `message_number` to API requests.
|
||||
- Adds token/device headers.
|
||||
- Supports at least `DELETE`, `POST`, `PUT`, and default GET behavior.
|
||||
- Reads server-updated message numbers from response data/headers.
|
||||
|
||||
## Server Time Probe Endpoint
|
||||
|
||||
`/games/pokemon_pro/conagent` contains a separate plain-HTTP server-time probe:
|
||||
|
||||
```text
|
||||
http://%s:8345/b8Ag3XU6TH/wibbly_wobbly_timey_wimey/
|
||||
```
|
||||
|
||||
Evidence:
|
||||
|
||||
- `strings -a -t x /games/pokemon_pro/conagent` reports the URL format at file/string offset `0x35618`.
|
||||
- Ghidra xref from string address `00135618` identifies `FUN_00115930`; the decompiler shows `src/client_http.cpp` constructing the URL with `snprintf(..., "http://%s:8345/b8Ag3XU6TH/wibbly_wobbly_timey_wimey/", server_host)`.
|
||||
- `FUN_00115930` is the `http_client_query_server_time` path. It requires a destination buffer and backend server hostname/IP, optionally binds a network interface, sets libcurl options, uses user agent `Spike-Connectivity-Agent/1.0`, performs the request, stores the HTTP status, and copies up to `0x1000` bytes into the response buffer.
|
||||
- The caller in `src/client_game.cpp` logs `Attempting to retrieve current date and time from server %s.`, calls the HTTP helper, and returns either the response body or an empty response to the game process.
|
||||
- The game binary builds a `ServerTime` / `TIME_QUERY_REQUEST` message from `../source/net/net_connection.cpp` and logs `Requesting Agent to retrieve server time of day.`.
|
||||
- The game-side response handler parses the returned body as JSON, reads `datetime`, applies it to TimeOfDay, and warns if `server_timezone` is present and not `UTC`. If no data is returned, it logs `TODO(Game): The server did not respond to the time query request.`
|
||||
|
||||
Interpretation: this is not one of the authenticated HTTPS `/api/v*` calls. It is a special connectivity-agent time endpoint on port `8345`, apparently used so the game can ask the configured server for current UTC time and update its internal TimeOfDay. The path token `b8Ag3XU6TH/wibbly_wobbly_timey_wimey` appears to be obscurity for a narrow time-service endpoint, not a secret copied into the wiki from a credential file.
|
||||
|
||||
## Game Registration and Authentication
|
||||
|
||||
Machine registration request fields:
|
||||
|
||||
- `serial_number`
|
||||
- `machine_configuration_number`
|
||||
- `model_number`
|
||||
- `model_version`
|
||||
- `user_uuid`
|
||||
- `mac_address`
|
||||
- `internal_ip_address`
|
||||
|
||||
Machine authentication request fields:
|
||||
|
||||
- `machine_uuid`
|
||||
- `machine_configuration_number`
|
||||
- `code_version`
|
||||
|
||||
Player authentication request fields:
|
||||
|
||||
- `user_uuid`
|
||||
- `machine_uuid`
|
||||
|
||||
Game auth response fields:
|
||||
|
||||
- `stern_cdn_url`
|
||||
- `stern_speed_test_url`
|
||||
|
||||
The binary contains detailed service status strings such as:
|
||||
|
||||
- `MACHINE_NOT_ENABLED`
|
||||
- `MACHINE_NOT_REGISTERED`
|
||||
- `MACHINE_HAS_BEEN_ARCHIVED`
|
||||
- `MACHINE_PENDING_HARDWARE_REPLACEMENT`
|
||||
- `GAME_MACHINE_TOKEN_INVALID`
|
||||
- `GAME_MACHINE_TOKEN_EXPIRED`
|
||||
- `GAME_MACHINE_TOKEN_REVOKED`
|
||||
- `GAME_MACHINE_TOKEN_MISMATCH`
|
||||
- `USER_NOT_ACTIVE`
|
||||
- `USER_TOKEN_EXPIRED`
|
||||
|
||||
## File Transfer and Software Update Behavior
|
||||
|
||||
Evidence strings under `src/client_http.cpp`:
|
||||
|
||||
- `Launching download request %lu, priority %u, %s -> %s.`
|
||||
- `Launching upload request %lu, priority %u, %s -> %s.`
|
||||
- `File transfer request %lu has type %s, which is unsupported. The request will be dropped.`
|
||||
- `File transfer %lu failed; partially downloaded file %s will be deleted.`
|
||||
- `All software update file transfers will be paused and all other transfers will be canceled during the next http_client_update.`
|
||||
- `Found transfer request %lu as the active software update transfer.`
|
||||
- `Found transfer request %lu in the software update queue.`
|
||||
|
||||
Recovered behavior:
|
||||
|
||||
- File transfers are managed through libcurl multi handles.
|
||||
- Transfers are prioritized into high-priority, normal-priority, software-update, and paused queues.
|
||||
- Download destinations are opened/created on disk.
|
||||
- Upload sources are opened and sized with file seeks.
|
||||
- Partial downloads are deleted on failure.
|
||||
- File transfer queue size and request size are configurable through `CONFIG_NETWORK`.
|
||||
|
||||
This likely feeds the `/connectivity/update/UPDATE_REQUESTED` flow documented in `update-and-recovery.md`, but that handoff was not proven in this pass.
|
||||
|
||||
## Network Configuration Mutation
|
||||
|
||||
The agent can apply game-requested network configuration to the host.
|
||||
|
||||
Evidence strings:
|
||||
|
||||
- `0x30448`: `/tmp/network_interface.%s`
|
||||
- `0x2f888`: `iface %s inet dhcp`
|
||||
- `0x2f8f0`: `iface %s inet static`
|
||||
- `0x2f909`: `address %s`
|
||||
- `0x2f919`: `netmask %s`
|
||||
- `0x2f929`: `gateway %s`
|
||||
- `0x2f939`: `dns-nameservers %s %s`
|
||||
- `0x2f951`: `dns-nameservers %s`
|
||||
- `0x2fa78`: `ifdown -i %s %s`
|
||||
- `0x31d70`: `ifup -i %s %s`
|
||||
|
||||
Validation strings indicate it checks for:
|
||||
|
||||
- Interface name.
|
||||
- IP address.
|
||||
- Netmask.
|
||||
- Gateway.
|
||||
- Primary DNS for static config.
|
||||
- Server hostname.
|
||||
- Transfer queue capacity.
|
||||
- Transfer request size.
|
||||
- Flags `APPLY` or `STORE`.
|
||||
- Required interface MAC address.
|
||||
|
||||
Security note:
|
||||
|
||||
- The binary imports `system`.
|
||||
- The network code constructs `ifdown -i %s %s` and `ifup -i %s %s` command lines.
|
||||
- The strings indicate validation exists, but this pass did not prove whether interface names and generated config paths are shell-escaped or restricted enough. This is a worthwhile follow-up audit target.
|
||||
|
||||
## CloudWatch Logging
|
||||
|
||||
The binary contains an AWS CloudWatch Logs client.
|
||||
|
||||
Evidence strings:
|
||||
|
||||
- `0x362b0`: `https://logs.us-west-1.amazonaws.com`
|
||||
- `0x365e8`: `Logs_20140328.PutLogEvents`
|
||||
- `0x36268`: `AWS4-HMAC-SHA256`
|
||||
- `0x36280`: `/us-west-1/logs/aws4_request`
|
||||
- `0x361b9`: `host:logs.us-west-1.amazonaws.com`
|
||||
- `0x36478`: `src/cloudwatch_logger.cpp`
|
||||
- `0x36498`: `nextSequenceToken`
|
||||
- `0x364c8`: `UnrecognizedClientException`
|
||||
- `0x364e8`: `CLOUD LOG: disabling due to credential errors`
|
||||
|
||||
The implementation imports OpenSSL SHA-256 and HMAC routines. It appears to build AWS Signature Version 4 requests for `PutLogEvents`.
|
||||
|
||||
Credential-related string labels:
|
||||
|
||||
- `keys`
|
||||
- `tokens`
|
||||
- `sternmachine`
|
||||
- `sternapi`
|
||||
|
||||
This report does not include any secret values. No concrete AWS access keys were copied into this page.
|
||||
|
||||
## Security-relevant Observations
|
||||
|
||||
1. `conagent` is root-supervised by `/etc/init.d/conagent_monitor`, which restarts it forever.
|
||||
2. It accepts commands from the local game process over `/usr/local/spike/agent.uds`.
|
||||
3. It can write network interface config files and execute `ifup`/`ifdown` through shell command strings.
|
||||
4. It receives game-originated direct HTTP GET/POST proxy requests, but strings show endpoint and payload validation/NACK paths.
|
||||
5. It handles auth tokens, device tokens, refresh tokens, and CloudWatch credentials in-process.
|
||||
6. It logs HTTP and message traffic to `/dump/log/connectivity/agent.*` depending on configuration.
|
||||
7. File transfers can write to local paths and delete partial downloads on failure; path validation needs a dedicated pass.
|
||||
|
||||
## Generated C-like Export
|
||||
|
||||
See `conagent-pseudocode.c` in this wiki folder. It is a manually reconstructed, non-compilable pseudocode export based on the Ghidra import, local disassembly, and string evidence.
|
||||
|
||||
## Commands Used
|
||||
|
||||
```sh
|
||||
file games/pokemon_pro/conagent
|
||||
shasum -a 256 games/pokemon_pro/conagent
|
||||
objdump -p games/pokemon_pro/conagent
|
||||
nm -D games/pokemon_pro/conagent
|
||||
strings -a -t x -n 4 games/pokemon_pro/conagent
|
||||
objdump -d --start-address=0x3d90 --stop-address=0x42b0 games/pokemon_pro/conagent
|
||||
objdump -d --start-address=0x23200 --stop-address=0x23d00 games/pokemon_pro/conagent
|
||||
```
|
||||
|
||||
Ghidra MCP calls:
|
||||
|
||||
- `import_file` for `/Users/jordan/Downloads/pokemon_spike3_rootfs/games/pokemon_pro/conagent`
|
||||
- `list_imports`
|
||||
- `find_code_gaps`
|
||||
- `decompile_function` at `00104300`
|
||||
|
||||
## Follow-up Targets
|
||||
|
||||
- Force Ghidra function creation for the large stripped `.text` region, then rerun decompilation on:
|
||||
- Main at file VA `0x3d90` / Ghidra VA `0x103d90`.
|
||||
- Logger creation/destruction around `0x23960` and `0x23cb0`.
|
||||
- Agent client creation/update/shutdown around `0xc2c0`, `0xc940`, `0xc990`, and `0xc8d0`.
|
||||
- HTTP client functions around the `src/client_http.cpp` string cluster.
|
||||
- Audit shell command construction for `ifup`/`ifdown`.
|
||||
- Audit direct Game-to-Server endpoint validation.
|
||||
- Audit file-transfer local path validation and update handoff into `/connectivity/update`.
|
||||
- Determine where API token username/password and device token material originate in runtime config messages.
|
||||
81
copy-fail-assessment.md
Normal file
81
copy-fail-assessment.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Copy Fail Assessment
|
||||
|
||||
## Short Answer
|
||||
|
||||
Based on the extracted rootfs, this kernel should be treated as vulnerable to Copy Fail / CVE-2026-31431 unless Stern or Raspberry Pi backported the fix into this exact `6.6.28-v8` kernel build out-of-band.
|
||||
|
||||
The available local evidence points vulnerable:
|
||||
|
||||
- Kernel module tree is `/lib/modules/6.6.28-v8`.
|
||||
- `algif_aead.ko.xz` is present as a loadable module.
|
||||
- `authencesn.ko.xz` is present as a loadable module.
|
||||
- `modules.dep` wires both into the normal module dependency map.
|
||||
- `algif_aead` module metadata describes it as `AEAD kernel crypto API user space interface`.
|
||||
- `authencesn` module metadata describes it as `AEAD wrapper for IPsec with extended sequence numbers`.
|
||||
- No `/etc/modprobe.d` mitigation was present in the extracted rootfs.
|
||||
- No local marker for the upstream fix commit `a664bf3d603d` was found in the rootfs.
|
||||
|
||||
## Why This Is In Scope
|
||||
|
||||
Copy Fail is a Linux kernel local privilege escalation in the crypto `authencesn` / `AF_ALG` path. Public guidance says the primary fix is a kernel containing mainline commit `a664bf3d603d`, with temporary mitigation by disabling `algif_aead`.
|
||||
|
||||
This rootfs carries the relevant affected modules:
|
||||
|
||||
```text
|
||||
/lib/modules/6.6.28-v8/kernel/crypto/algif_aead.ko.xz
|
||||
/lib/modules/6.6.28-v8/kernel/crypto/authenc.ko.xz
|
||||
/lib/modules/6.6.28-v8/kernel/crypto/authencesn.ko.xz
|
||||
```
|
||||
|
||||
The module vermagic for both checked modules is:
|
||||
|
||||
```text
|
||||
6.6.28-v8 SMP preempt mod_unload modversions aarch64
|
||||
```
|
||||
|
||||
`6.6.28` predates the public 2026 Copy Fail fix window. Without source or a vendor changelog proving a backport, assume affected.
|
||||
|
||||
## Local Exploitability Notes
|
||||
|
||||
The extracted permissions do not show the usual broad setuid surface:
|
||||
|
||||
- No setuid files were found with `find . -xdev -type f -perm -4000`.
|
||||
- No setgid files were found with `find . -xdev -type f -perm -2000`.
|
||||
- `/bin/su` exists only as a non-setuid symlink to `busybox`.
|
||||
|
||||
That means the stock public exploit path that targets a setuid binary such as `/usr/bin/su` likely will not work unchanged on this image.
|
||||
|
||||
However, the kernel exposure should still matter for threat modeling:
|
||||
|
||||
- If an attacker obtains unprivileged local code execution and can load or trigger `algif_aead`, the vulnerable kernel primitive may be reachable.
|
||||
- The system appears to run the main game and monitors as root from init. A page-cache corruption primitive may still be adaptable against root-executed readable binaries or scripts, even without classic setuid helpers.
|
||||
- Embedded deployments often have fewer unprivileged accounts, which lowers exposure, but a network/app compromise that lands in a non-root context could still use this as a step-up if the primitive is reachable.
|
||||
|
||||
## Mitigation Options To Test On Hardware
|
||||
|
||||
Preferred:
|
||||
|
||||
1. Boot a kernel containing the Copy Fail fix or a vendor backport of mainline commit `a664bf3d603d`.
|
||||
2. Confirm the running kernel version and vendor build date on the machine.
|
||||
3. Confirm whether `algif_aead` can be autoloaded from an unprivileged process.
|
||||
|
||||
Temporary mitigation if patching is not yet possible:
|
||||
|
||||
```text
|
||||
install algif_aead /bin/false
|
||||
```
|
||||
|
||||
Place that in a modprobe policy file if the deployed image honors `/etc/modprobe.d`, then unload `algif_aead` if already loaded. This rootfs did not include an existing `/etc/modprobe.d` directory, so test the exact Buildroot/kmod behavior on hardware before relying on it.
|
||||
|
||||
Also consider a seccomp rule blocking `AF_ALG` socket creation for any untrusted workload, though this image does not currently look like a container or multi-user host.
|
||||
|
||||
## Commands Used For Static Check
|
||||
|
||||
```sh
|
||||
rg -n "algif_aead|authencesn|AF_ALG|a664bf3d603d|disable-algif" .
|
||||
find lib/modules/6.6.28-v8 -path '*algif_aead*' -o -path '*authenc*' -o -path '*authencesn*'
|
||||
xz -dc lib/modules/6.6.28-v8/kernel/crypto/algif_aead.ko.xz | strings | rg -n "vermagic|depends|description|AF_ALG|aead|6\\.6"
|
||||
xz -dc lib/modules/6.6.28-v8/kernel/crypto/authencesn.ko.xz | strings | rg -n "vermagic|depends|description|authenc|authencesn|6\\.6"
|
||||
find . -xdev -type f -perm -4000 -exec ls -l {} \;
|
||||
find . -xdev -type f -perm -2000 -exec ls -l {} \;
|
||||
```
|
||||
104
filesystem-users-network.md
Normal file
104
filesystem-users-network.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Filesystem, Users, and Network
|
||||
|
||||
## OS Identity
|
||||
|
||||
- `/etc/issue`: `Stern Pinball Spike 3`
|
||||
- `/etc/os-release` is a symlink to `/usr/lib/os-release`.
|
||||
- `/bin/busybox`: BusyBox `v1.36.1`, build string `2025-10-22 11:34:32 EDT`.
|
||||
- `/usr/lib/os-release`:
|
||||
- `NAME=Buildroot`
|
||||
- `VERSION=2024.08.1`
|
||||
- `ID=buildroot`
|
||||
- `VERSION_ID=2024.08.1`
|
||||
- `PRETTY_NAME="Buildroot 2024.08.1"`
|
||||
|
||||
## Mounts
|
||||
|
||||
`/etc/fstab` mounts only runtime filesystems by default, plus an explicit noauto boot partition:
|
||||
|
||||
| Device/source | Mount point | Type | Options |
|
||||
| --- | --- | --- | --- |
|
||||
| `devpts` | `/dev/pts` | `devpts` | `defaults,gid=5,mode=620,ptmxmode=0666` |
|
||||
| `tmpfs` | `/dev/shm` | `tmpfs` | `mode=0777` |
|
||||
| `tmpfs` | `/tmp` | `tmpfs` | `mode=1777` |
|
||||
| `tmpfs` | `/run` | `tmpfs` | `mode=0755,nosuid,nodev` |
|
||||
| `/dev/mmcblk0p1` | `/boot` | `vfat` | `defaults,ro,noauto` |
|
||||
|
||||
Notable extracted top-level directories:
|
||||
|
||||
- `/games`: active game and SPIKE payloads.
|
||||
- `/connectivity`: persistent connectivity/cache/update/dump storage. Only `lost+found` is present in this extraction.
|
||||
- `/data`: persistent data area. Only `lost+found` is present in this extraction.
|
||||
- `/usr/local/spike`: fallback SPIKE display utilities and logos.
|
||||
- `/boot`: empty in this extraction, but expected as separate VFAT partition.
|
||||
|
||||
## Persistent Symlinks
|
||||
|
||||
Runtime state is intentionally shifted out of rootfs into persistent or tmpfs paths:
|
||||
|
||||
- `/etc/resolv.conf -> ../tmp/resolv.conf`
|
||||
- `/etc/mtab -> ../proc/self/mounts`
|
||||
- `/etc/os-release -> ../usr/lib/os-release`
|
||||
- `/var/lib/misc -> ../../tmp`
|
||||
- `/var/lib/dbus -> /tmp/dbus`
|
||||
- `/var/lib/alsa -> /connectivity/alsa`
|
||||
- `/var/lib/bluetooth -> /connectivity/bluetooth`
|
||||
- `/var/lib/bluealsa -> /connectivity/bluealsa`
|
||||
|
||||
`S95game` recreates the ALSA/Bluetooth symlinks if needed by temporarily remounting `/` read-write.
|
||||
|
||||
## Users
|
||||
|
||||
`/etc/passwd` contains:
|
||||
|
||||
- `root:*:0:0:root:/root:/bin/sh`
|
||||
- standard service users including `daemon`, `bin`, `sys`, `mail`, `www-data`, `operator`, `nobody`, `dbus`, and `sshd`.
|
||||
|
||||
No `/etc/shadow` file was found during triage. The `root:*` passwd field indicates password login for root is disabled through the passwd file, and gettys are commented out in `/etc/inittab`.
|
||||
|
||||
## Groups
|
||||
|
||||
Notable groups:
|
||||
|
||||
- `wheel:x:10:root`
|
||||
- `audio:x:29:`
|
||||
- `video:x:28:`
|
||||
- `plugdev:x:46:`
|
||||
- `netdev:x:82:`
|
||||
- `input:x:103:`
|
||||
- `kvm:x:104:`
|
||||
- `dbus:x:101:dbus`
|
||||
|
||||
## Network
|
||||
|
||||
`/etc/network/interfaces`:
|
||||
|
||||
- Loopback is automatic.
|
||||
- `eth0` is automatic DHCP.
|
||||
- `wlan0` is DHCP-capable but not marked `auto`; it uses WEXT and `/etc/wpa_supplicant.conf`.
|
||||
- `pre-up /etc/network/nfs_check`, `wait-delay 15`, and dynamic hostname commands are present but commented out.
|
||||
|
||||
`/etc/wpa_supplicant.conf`:
|
||||
|
||||
- `ctrl_interface=/var/run/wpa_supplicant`
|
||||
- `ap_scan=1`
|
||||
- Includes an open network block with `key_mgmt=NONE`.
|
||||
|
||||
No static SSH startup script was identified in `/etc/init.d`, despite the presence of an `sshd` privilege-separation user and OpenSSH client/server-related binaries.
|
||||
|
||||
## Kernel Modules
|
||||
|
||||
`/etc/modules` asks the system to load:
|
||||
|
||||
- `vc4`
|
||||
- `v3d`
|
||||
- `bcm2835-codec`
|
||||
- `i2c-dev`
|
||||
|
||||
`S55spike3` also explicitly loads:
|
||||
|
||||
- `i2c-dev`
|
||||
- `exfat`
|
||||
- `fuse`
|
||||
|
||||
The kernel module tree is `/lib/modules/6.6.28-v8`.
|
||||
157
godot-and-radium-assets.md
Normal file
157
godot-and-radium-assets.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Godot and Radium Assets
|
||||
|
||||
## Summary
|
||||
|
||||
Static extraction produced a new workspace-local `assets/` directory. The source rootfs evidence files were not modified.
|
||||
|
||||
Generated outputs:
|
||||
|
||||
- `assets/godot_raw/`: all 2,350 entries from `/games/pokemon_pro/assets/godot/main.pck`, with RSCC/zstd-compressed entries decompressed where applicable.
|
||||
- `assets/openable/images/imported/`: 1,050 WebP images carved from Godot `.ctex` texture resources.
|
||||
- `assets/openable/videos/`: 10 MP4 files from the Godot pack.
|
||||
- `assets/openable/text/`: 1,170 text metadata/config files from the Godot pack, written with `.txt` suffixes for easy opening.
|
||||
- `assets/openable/loose/`: 430 openable files copied from loose files under `/games/pokemon_pro/assets`, including PNG, TTF, MP4, and MOV files with normal extensions.
|
||||
- `assets/manifest.json`: PCK extraction manifest with source hash, offsets, sizes, MD5 values from the PCK table, decoded paths, and converted output paths.
|
||||
- `assets/loose_manifest.json`: loose asset copy manifest with source paths, output paths, sizes, and SHA-256 values.
|
||||
- `godot_editor_project/`: first-pass editor-openable reconstruction copied from `assets/godot_raw/` with a reconstructed `project.godot`.
|
||||
- `project_basic_exported.zip`: first-pass zip of `godot_editor_project/` contents with `project.godot` at archive root.
|
||||
- `godot_editor_project_reconstructed/`: improved editor-openable reconstruction with restored PNG source files and copied loose `lcd`/`nuk` assets.
|
||||
- `project.zip` and `project_reconstructed.zip`: improved reconstruction archives; both currently have the same SHA-256.
|
||||
- `analysis/media-audio-scan.tsv`: `ffprobe` scan of Godot MP4s and loose Radium `.asset` media containers.
|
||||
|
||||
Final openable count, excluding generated metadata files:
|
||||
|
||||
| Extension | Count |
|
||||
| --- | ---: |
|
||||
| `.txt` | 1,170 |
|
||||
| `.webp` | 1,050 |
|
||||
| `.mp4` | 258 |
|
||||
| `.png` | 162 |
|
||||
| `.mov` | 15 |
|
||||
| `.ttf` | 5 |
|
||||
| **Total** | **2,660** |
|
||||
|
||||
Commands used:
|
||||
|
||||
```sh
|
||||
python3 rootfs-triage-wiki/scripts/extract_godot_pck_assets.py --pck games/pokemon_pro/assets/godot/main.pck --output assets
|
||||
python3 rootfs-triage-wiki/scripts/harvest_loose_openable_assets.py --source games/pokemon_pro/assets --output assets/openable/loose --manifest assets/loose_manifest.json
|
||||
mkdir godot_editor_project
|
||||
cp -R assets/godot_raw/. godot_editor_project
|
||||
(cd godot_editor_project && zip -r -q ../project.zip . -x '*.DS_Store' '__MACOSX/*')
|
||||
python3 rootfs-triage-wiki/scripts/build_godot_editor_reconstruction.py
|
||||
cp project.zip project_basic_exported.zip
|
||||
cp project_reconstructed.zip project.zip
|
||||
```
|
||||
|
||||
## Godot Editor Reconstruction
|
||||
|
||||
The first-pass `project.zip` wrapper was created as a Godot editor-openable reconstruction from the already extracted PCK contents, then preserved as `project_basic_exported.zip`.
|
||||
|
||||
The improved `project.zip` was then created by `rootfs-triage-wiki/scripts/build_godot_editor_reconstruction.py`. Confirmed static archive facts:
|
||||
|
||||
- `project.zip` size: `980,835,332` bytes.
|
||||
- SHA-256: `ad44ee65ddbd7f0a44e0a4b7f15332460fa48185fc45b12dc74b10f9dece9334`.
|
||||
- `project_reconstructed.zip` has the same SHA-256 and is kept under an explicit reconstruction name.
|
||||
- `zipinfo -t project.zip` reported `3871 files`, `1162014868 bytes uncompressed`, and no archive errors.
|
||||
- `project.godot` is at the archive root.
|
||||
- `godot_editor_project_reconstructed/project.godot` SHA-256: `4be4399dcebce7d3f857dd76319061ef4284a65770fe3de3d742668c31ae8fbf`.
|
||||
- The reconstruction restored `1050` source-like PNG files from `1050` Godot `.png.import` entries by carving WebP payloads from `.ctex` files and converting them to PNG with ImageMagick.
|
||||
- The reconstruction copied loose `/games/pokemon_pro/assets/lcd` and `/games/pokemon_pro/assets/nuk` under `rootfs_loose_assets/` inside the Godot project for inspection. These are not native Godot resources.
|
||||
|
||||
First-pass archive facts:
|
||||
|
||||
- `project_basic_exported.zip` size: `255,346,627` bytes.
|
||||
- SHA-256: `05d2606f23e694c369c855a232a787e03b8c838b346558df615598f01519cca1`.
|
||||
- `zipinfo -t project_basic_exported.zip` reported `2466 files`, `256063801 bytes uncompressed`, and no archive errors before it was superseded.
|
||||
- `godot_editor_project/project.godot` SHA-256: `833e470c341b0f96284398890b2eae31e5933477234185f8695781f0aa0ceba1`.
|
||||
|
||||
The reconstructed `project.godot` uses values recovered from `assets/godot_raw/project.binary`:
|
||||
|
||||
- Project name: `pokemon_battle_previs`.
|
||||
- Main scene: `res://scenes/main/main.tscn`.
|
||||
- Feature tags: `4.4` and `Forward Plus`.
|
||||
- Viewport: `1920x1080`.
|
||||
- Autoloads: `PokemonData`, `Spike`, `AsyncLoadUtilV2`, and `SpikeGameSoundIds0`.
|
||||
|
||||
Important limitation: this is not the original source project. The exported pack contains compiled `.gdc` bytecode, `.gd.remap` files, `.tscn.remap` files, exported binary `.scn` resources, and imported texture/font cache resources. The improved project should be more useful in the editor because PNG source paths exist again, but script source editing and scene editing will still be limited compared with the original development checkout.
|
||||
|
||||
## Audio and Sound Asset Status
|
||||
|
||||
Static evidence does not show a standalone Godot sound library in `/games/pokemon_pro/assets/godot/main.pck`:
|
||||
|
||||
- `find assets/godot_raw -type f` found no `.ogg`, `.wav`, `.mp3`, `.sample`, `.stream`, `.bnk`, or `.wem` files.
|
||||
- PCK extraction produced `10` MP4 files, `1050` texture imports, `93` compiled scripts, and `22` exported scene resources.
|
||||
- The exported Godot script `scripts/spike_game_sound_ids.gdc` exists, with a `.gd.remap`, but the original GDScript source is not present.
|
||||
|
||||
Static evidence also found no standalone audio files by extension under `/games/pokemon_pro`. A broader whole-target-root scan is documented in `audio-asset-search.md`; it found no target-root file with an `ffprobe`-confirmed audio stream.
|
||||
|
||||
`analysis/media-audio-scan.tsv` used `ffprobe` on `273` media candidates from `assets/godot_raw` and `/games/pokemon_pro/assets`:
|
||||
|
||||
- `273` files were probed successfully.
|
||||
- `272` files were video-only containers.
|
||||
- `1` file had an audio stream: `assets/godot_raw/scenes/shared_items/gfx/bknd/Arena_Neon/Tournament_Neon_01_22_26_h265.mp4`, with streams `0:video:hevc,1:audio:aac`.
|
||||
|
||||
The main game binary is linked against ALSA (`libasound.so.2`) and includes audio adjustment strings such as `AD_SOUND_MASTER_VOLUME_SETTING`, `AD_MUSIC_ATTENUATION`, and `AD_ENABLE_SPIKE3_BOOT_SOUND`. Inference: most gameplay sound content is probably not represented as standalone Godot audio resources in `main.pck`; it is likely handled by the native game/Radium asset path or another internal runtime format. Further confirmation requires deeper reverse engineering of the native asset/sound lookup path.
|
||||
|
||||
## Godot PCK Evidence
|
||||
|
||||
`/games/pokemon_pro/game` embeds Godot runtime strings and references the pack path:
|
||||
|
||||
- `./assets/godot/main.pck`
|
||||
- `Godot Engine v4.4.1.stable.custom_build`
|
||||
- `COMPRESSION_ZSTD`
|
||||
- `RSCC`
|
||||
|
||||
`/games/pokemon_pro/assets/godot/main.pck` has SHA-256:
|
||||
|
||||
```text
|
||||
04af0fd78866ec3ae60b10311dedb3df2f3877595d19ee7bb01665ab10512778
|
||||
```
|
||||
|
||||
The PCK header starts with `GDPC`; the parsed header values are:
|
||||
|
||||
- PCK format: `2`
|
||||
- Godot version tuple: `4.4.1`
|
||||
- file table count: `2,350`
|
||||
- payload base/file table end: `0x41870`
|
||||
|
||||
The PCK table layout used by `rootfs-triage-wiki/scripts/extract_godot_pck_assets.py` is:
|
||||
|
||||
- `uint32 path_length`
|
||||
- NUL-terminated path bytes
|
||||
- `uint64 payload_offset`, relative to payload base `0x41870`
|
||||
- `uint64 payload_size`
|
||||
- 16-byte MD5 stored by the PCK
|
||||
- `uint32 flags`
|
||||
|
||||
Confirmed examples from the table:
|
||||
|
||||
- `.godot/imported/Stern_Aztech.ttf-3064810bd5908d7a5012fbe63bbbf40b.fontdata` starts at relative offset `0x0`.
|
||||
- `fonts/Stern_Aztech.ttf.import` starts at relative offset `0x3c50`.
|
||||
- `.godot/exported/133200997/export-2260fc5460b11a2a7043d0925ae7e8df-progress_bars.scn` starts at relative offset `0x464be10`.
|
||||
|
||||
Three `.fontdata` entries were preserved in `assets/godot_raw/`, but the static carve did not find directly embedded standalone sfnt/TTF payloads in those Godot FontFile resources. The loose asset harvest did recover 5 TTF files already present under `/games/pokemon_pro/assets`.
|
||||
|
||||
## Loose Asset Evidence
|
||||
|
||||
The rootfs also contains loose files under `/games/pokemon_pro/assets`. Static `file` triage showed many already-openable files and many Radium-specific files:
|
||||
|
||||
- 263 ISO media files.
|
||||
- 162 PNG files.
|
||||
- 5 TrueType font files.
|
||||
- 27 generic `data` files, including Radium scene files.
|
||||
|
||||
The helper `rootfs-triage-wiki/scripts/harvest_loose_openable_assets.py` copied only files with clear PNG, sfnt/TTF, or ISO media headers. `.asset` files with `ftypqt ` were copied with `.mov`; other ISO media files were copied with `.mp4`.
|
||||
|
||||
## `image.bin` Status
|
||||
|
||||
`/games/pokemon_pro/image.bin` remains a separate large Radium/runtime image. Confirmed static facts:
|
||||
|
||||
- Size: `1,154,884,359` bytes.
|
||||
- SHA-256 is recorded in `artifact-index.md`.
|
||||
- The game binary includes strings `image.bin`, `Flash memory init mmap failed`, `Flash memory sidecar mmap threshold failed`, `radium`, `/lcd/demand_loaded`, and `/lcd/auto_loaded`.
|
||||
- `binwalk games/pokemon_pro/image.bin` did not identify extractable standard media; it only reported copyright text and a likely false-positive `JBOOT STAG` signature at `0xD36E9D6`.
|
||||
- Exact scans found no PNG, OGG, MP4 `ftyp`, WAV, or WebP signatures in `image.bin`.
|
||||
|
||||
Inference: `image.bin` is likely a Radium serialized or mmap-oriented runtime image rather than a standard archive. Reversing its internal object/table format remains a separate analysis task.
|
||||
221
main-game-behavior.md
Normal file
221
main-game-behavior.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Main Game Behavior Notes
|
||||
|
||||
## Scope
|
||||
|
||||
Target:
|
||||
|
||||
- `/games/pokemon_pro/game`
|
||||
|
||||
Derived analysis artifacts:
|
||||
|
||||
- `analysis/static-triage/game.strings`: `strings -a -t x /games/pokemon_pro/game`.
|
||||
- `analysis/static-triage/game.objdump-p.txt`: `objdump -p /games/pokemon_pro/game`.
|
||||
|
||||
Ghidra project:
|
||||
|
||||
- Project `stern`, program `/games/pokemon_pro/game`.
|
||||
|
||||
Static caveat: this page is based on rootfs files, strings with offsets, and targeted Ghidra decompilation. It does not include live machine or hardware validation.
|
||||
|
||||
## Main Game Binary
|
||||
|
||||
Confirmed type:
|
||||
|
||||
- `/games/pokemon_pro/game` is a stripped AArch64 PIE executable, 52,829,400 bytes.
|
||||
- SHA-256: `1e5becb85840d0cc7a1d334b7cc43bdb83e51c7c7f8243bead8db679e9ffb22d`.
|
||||
|
||||
Dynamic dependencies from `objdump -p` include:
|
||||
|
||||
- `libcrypto.so.3`
|
||||
- `libcurl.so.4`
|
||||
- `libusb-1.0.so.0`
|
||||
- `libgpiod.so.2`
|
||||
- `libEGL.so.1`, `libGLESv2.so.2`, `libdrm.so.2`, `libgbm.so.1`
|
||||
- `libgstapp-1.0.so.0`, `libgstvideo-1.0.so.0`, `libgstreamer-1.0.so.0`
|
||||
- `libavcodec.so.59`, `libavformat.so.59`, `libavfilter.so.8`, `libavutil.so.57`
|
||||
|
||||
## Topper Connection and Authentication
|
||||
|
||||
The Pokemon topper is represented as an optional Magikarp node-board/mechanism subsystem, not as a USB or network accessory in the evidence reviewed here.
|
||||
|
||||
Evidence from `/games/pokemon_pro/game` strings:
|
||||
|
||||
- `0x258bf30`: `AD_MAGIKARP_TOPPER_SERIAL_NUMBER_KEY`
|
||||
- `0x258f808`: `MAGIKARP TOPPER SERIAL NUMBER KEY`
|
||||
- `0x2594a70`: `Adjustments related to Magikarp Topper.`
|
||||
- `0x2594a98`: `Magikarp Topper`
|
||||
- `0x25a03c8`: `Press Select for Magikarp Topper Calibration`
|
||||
- `0x25a03f8`: `Magikarp Topper Node Board Not Signed`
|
||||
- `0x25a0420`: `Magikarp Topper Mech Needs Calibration`
|
||||
- `0x25a0448`: `Magikarp Topper Mech is Disabled`
|
||||
- `0x25b8388`: `Topper Servos (Optional)`
|
||||
- `0x25b83a8`: `Topper LEDs (Optional)`
|
||||
- `0x25ce291`: `Cannot detect Magikarp Topper.`
|
||||
- `0x25ce2b9`: `Check Interlock switch and cable connections`
|
||||
- `0x25d2f20`: `Magikarp Node Board Device:`
|
||||
- `0x25d2f3c`: `Calibrated %d %d %d %d %d`
|
||||
- `0x25d2f74`: `Not Calibrated`
|
||||
- `0x25f3100`: `31MagikarpTopperNodeBoardSettings`
|
||||
|
||||
Inference: the topper connects over the SPIKE node-board bus as one or more node devices, with servos and LED channels exposed to the game. Use is gated by node detection, a node-board signature check, and calibration state. The `AD_MAGIKARP_TOPPER_SERIAL_NUMBER_KEY` string maps to a serial-derived node-board key path; see `topper-serial-key-emulator.md` for the recovered `0xfc` command and XOR key algorithm.
|
||||
|
||||
### Topper Node Registration Communication
|
||||
|
||||
The Magikarp topper registration/authentication path appears to reuse the generic SPIKE node-board registration path. Static evidence supports this sequence:
|
||||
|
||||
1. The game powers and scans the node bus during startup. In Ghidra, `FUN_0090bb00` toggles node-bus power/control helpers, calls a node locator (`FUN_0090aff0`), and updates the expected-node table with `FUN_007cbee0(..., 0x20, 2)`.
|
||||
2. `FUN_0090aff0` clears a 32-byte discovery buffer, polls node addresses through `FUN_00a23450`, stores each discovered address, acknowledges it with `FUN_00a26690(address)`, and marks it through `FUN_00a26700(address, 1)`.
|
||||
3. Discovered node records live in a global 32-entry table beginning at `DAT_033c0258`. `FUN_007cbd10(start_address)` returns the next present board record. `FUN_008e7090` formats the diagnostic inventory from this table as:
|
||||
|
||||
```text
|
||||
%2d: ver %d.%d.%d, part %s%c, serial %d-%d-%d
|
||||
```
|
||||
|
||||
The formatted fields come from the board record: node address at offset `0x00`, firmware version bytes at `0x24..0x26`, part string at `0x38`, part suffix at `0x37`, and serial fields at `0x58`, `0x5a`, and `0x5c`.
|
||||
4. For each discovered board whose status is `2`, `FUN_0090bb00` calls `FUN_00a2b410(address, out_status)`. `FUN_00a2b410` sends a node command frame with:
|
||||
|
||||
```text
|
||||
byte 0: address | 0x80
|
||||
byte 1: 0x01
|
||||
byte 2: 0xff
|
||||
byte 3: 0x08
|
||||
```
|
||||
|
||||
The response is treated as two 32-bit status words. `FUN_0090bb00` stores an inverted view of bit `0x80000` from the second word into `DAT_03431830[address]`.
|
||||
5. If board byte `0x9e` is not already set, `FUN_0090bb00` calls `FUN_0090a9c0(board_record)`. This helper performs additional board provisioning/configuration checks. It sends several node commands through the shared frame sender `FUN_00a22aa0`, including commands `0x14`, `0x40`, `0x44`, `0x46`, `0x48`, `0x72`, `0xf2`, and `0xf8` depending on board state. It also reads 64 bytes of board data via `FUN_00a2aad0`, which issues repeated command `0xf2` reads in 8-byte chunks:
|
||||
|
||||
```text
|
||||
byte 0: address | 0x80
|
||||
byte 1: 0x02
|
||||
byte 2: 0xf2
|
||||
byte 3: offset
|
||||
byte 4: 0x08
|
||||
```
|
||||
|
||||
6. `FUN_0090a9c0` sets board-record flag bit `0x4000` when its probe/provisioning checks find the board in the unsigned/problem state. The node-board test UI separately enumerates these boards: `FUN_00853f80` prints `Unsigned Board #%d` when the same flag bit is set.
|
||||
7. After the per-board checks, `FUN_0090bb00` calls runtime/configuration helpers including `FUN_00a25290`, `FUN_007c7b40`, `FUN_006b4b40`, `FUN_008cc100`, `FUN_008eb840`, and `FUN_007c0710`. `FUN_006b4b40` is the recurring node-board service loop; it sends command frames for runtime/config/device operations such as `0x40`, `0x41`, `0x42`, `0x43`, `0x45`, `0x4d`, and `0xf4`.
|
||||
|
||||
For the topper specifically:
|
||||
|
||||
- The topper is exposed to the game as `MagikarpTopperNodeBoardSettings`, `TopperMech`, `Topper Servos (Optional)`, `Topper LEDs (Optional)`, and named servo/LED devices such as `SERVO HEAD`, `SERVO TAIL`, and `ETOPPER ... LED ...`.
|
||||
- The topper test/calibration UI uses the generic node-board state. It can display `Cannot detect Magikarp Topper.`, `Magikarp Topper Node Board Not Signed`, `Magikarp Topper Mech Needs Calibration`, or `Magikarp Topper Mech is Disabled`.
|
||||
- The calibration details callback at `FUN_006a9380` reports `Magikarp Node Board Device: Calibrated %d %d %d %d %d` using adjustment IDs `0xa6` through `0xaa`, or `Magikarp Node Board Device: Not Calibrated` if the calibration predicate fails.
|
||||
|
||||
Interpretation: the game communicates with the topper control board as a SPIKE node-board at a discovered node address. Registration is not a cloud registration exchange and not a USB/network exchange; it is a local node-bus handshake. The game discovers the board, reads identity/status/version/part/serial fields, sends a status command (`0xff`), reads additional 64-byte board data with command `0xf2`, and then marks the board as signed/usable or unsigned/problem via board-record flags. The recovered Magikarp-specific serial key path reads 16 bytes with command `0xfc` and computes `(word0 ^ word1 ^ word2 ^ word3) & 0x7fffffff`; details and a MicroPython emulator are documented in `topper-serial-key-emulator.md`.
|
||||
|
||||
## Stuck-Ball and Lost-Ball Handling
|
||||
|
||||
The game has explicit ball-search, lost-ball, and trough replacement behavior.
|
||||
|
||||
Evidence from `/games/pokemon_pro/game` strings:
|
||||
|
||||
- `0x258b878`: `AD_LOST_BALL_RECOVERY`
|
||||
- `0x258eaa0`: `Serve a new ball in the event of a lost/stuck ball.`
|
||||
- `0x2595ea0`: `AUD_TOTAL_BALL_SEARCH_STARTS`
|
||||
- `0x2595ec0`: `AUD_TOTAL_LOST_BALL_FEEDS`
|
||||
- `0x2595ee0`: `AUD_TOTAL_LOST_BALL_GAME_STARTS`
|
||||
- `0x25981c0`: `Total count times the game attempted to find a stuck ball via ball search.`
|
||||
- `0x2598220`: `Total count times the game determined a stuck ball was lost, and was replaced with a ball from the trough.`
|
||||
- `0x25982a8`: `Total count times the game determined a stuck ball was lost while starting a game.`
|
||||
- `0x25aba50`: `SELECT: TROUGH TEST`
|
||||
- `0x25abaa8`: `%d : Ball%P1//s/% Missing`
|
||||
- `0x25ada30`: `BALL MISSING`
|
||||
- `0x25ada90`: `LOCATING BALLS`
|
||||
- `0x25beaf0`: `TROUGH JAM`
|
||||
- `0x25beb18`: `SHOOTER LANE`
|
||||
|
||||
Targeted Ghidra decompilation:
|
||||
|
||||
- String `0x026cdee0`, `%s: MD_EVENT_STABLE - balls_on_playfield = %d, endball_count = %d\n`, is referenced by `FUN_005e4f20`.
|
||||
- `FUN_005e4f20` handles `trough_event_handler` stable events. It computes `balls_on_playfield` from the total balls in game minus the stable count of balls in devices, logs the number of balls in the trough and `endball_count`, then pushes `balls_on_playfield` into game state through virtual calls.
|
||||
- For `MD_EVENT_KICK`, the same function fires the trough eject coil using a strength adjustment.
|
||||
- For `MD_EVENT_ENDBALL`, it logs whether endball processing will be ignored or processed.
|
||||
|
||||
Behavior summary: when the game cannot find a ball, it starts ball search and records the event. If it decides the ball is lost/stuck and lost-ball recovery is enabled, it serves a replacement ball from the trough and records a lost-ball feed. If all balls dispensed onto the playfield are physically stuck and no ball returns to a device/trough, static evidence says the game will continue treating those balls as on the playfield, then eventually show missing/locating-ball or device-malfunction paths. It cannot create more replacement balls once the trough is depleted; operator intervention is required to recover the physical balls.
|
||||
|
||||
## Unexpected Extra Balls
|
||||
|
||||
Confirmed behavior:
|
||||
|
||||
- The multiball-device subsystem validates impossible device states such as `Too many balls locked`, `Too many balls in device`, `Bad lock count after lost ball`, and `Multiball device system is not stable` at offsets `0x259eba0`, `0x259ebe8`, `0x259ebc8`, and `0x259ed38`.
|
||||
- The trough event handler calculates and propagates a live `balls_on_playfield` count on stable events.
|
||||
- Game diagnostic text exposes `FG_VALID_PLAYFIELD_ACHIEVED_THIS_BALL` at `0x259fa88` and `0x25a4120`.
|
||||
|
||||
Inference: the game logic does track physical ball accounting and detects inconsistent ball counts in devices. I did not find a distinct string or decompiled branch named "unexpected extra ball on playfield." A loose extra ball on the playfield would be reflected in `balls_on_playfield` once the trough/device counts stabilize, but the static evidence found here does not prove a special penalty, tilt, or operator alert solely for an unexpected extra playfield ball.
|
||||
|
||||
## Hidden Buttons and Debug Controls
|
||||
|
||||
Accessible without opening the cabinet:
|
||||
|
||||
- `0x258d460`: Home Team can be used by holding the left flipper after setup.
|
||||
- `0x258e138`: restart behavior can be allowed by holding the start button.
|
||||
- `0x258e190`: end current game by holding Start plus Left Flipper.
|
||||
- `0x2597e98`: audit text counts games ended by holding left flipper and start.
|
||||
- `0x25a33b0`: `HOLD BOTH FLIPPER BUTTONS FOR MORE GAME MODES`
|
||||
- `0x25a3868`: `HOLD BOTH FLIPPER BUTTONS FOR MENU`
|
||||
- `0x25a5bb8`: `PRESS BOTH FLIPPER BUTTONS TO SELECT PLAYER`
|
||||
- `0x25a5c38`: `HOLD BOTH FLIPPER BUTTONS TO MOVE PLAYER BACK TO ROSTER`
|
||||
- `0x25a5e50`: `HOLD BOTH FLIPPER BUTTONS TO REMOVE GUEST FROM HOME TEAM™`
|
||||
- `0x25a6140`: `Scan QR to log in or hold left flipper to activate Home Team™`
|
||||
|
||||
Debug/test controls found in the binary:
|
||||
|
||||
- `0x258c768`: `AD_DEBUG_MENU_SHOW`
|
||||
- `0x2590af0`: `DEBUG MENU SHOW`
|
||||
- `0x2590bd8`: `Test Only: Award Magikarp Letters`
|
||||
- `0x2590c20`: `Test Only: Start Pikachu vs. Magikarp`
|
||||
- `0x2590c90`: `GODOT ASSET DEMO`
|
||||
- `0x2590ce0`: `POKEMON PREVIEW`
|
||||
- `0x2590d40`: `GODOT PROGRESSION TEST`
|
||||
- `0x259fa40`: `FG_TEST_MENU_SHOW_DEBUG_ADJUSTMENTS`
|
||||
- `0x259fa68`: `FG_TEST_MENU_SHOW_DEBUG_AUDITS`
|
||||
|
||||
Conclusion: I found player-accessible hold/press combinations for login, player selection, game-mode UI, ball launch, restart, and end-game. I did not find evidence of a cabinet-closed secret sequence that directly opens operator diagnostics or debug commands. Debug/test items exist, but the names indicate they are gated by service/test-menu feature flags or adjustments rather than exposed as a hidden public button sequence.
|
||||
|
||||
## QR Scanner and QR Input Handling
|
||||
|
||||
The QR scanner appears as a SPIKE node-board peripheral, not as host-side camera decode logic.
|
||||
|
||||
Evidence:
|
||||
|
||||
- `0x25b8418`: `QR Scanner`
|
||||
- `0x25b8a40`: `QR SCANNER BACKLIGHT`
|
||||
- `0x25b8a58`: `QR SCANNER STATUS READY`
|
||||
- `0x25b8a70`: `QR SCANNER STATUS STRING`
|
||||
- `0x25b8a90`: `QR SCANNER STATUS ENCODED DATA`
|
||||
- `0x25b8ab0`: `QR SCANNER STATUS MISSED STRING`
|
||||
- `0x25b8ad0`: `QR SCANNER STATUS OVERRUN`
|
||||
- `0x25b8af0`: `QR SCANNER STATUS UNSUPPORTED_AP`
|
||||
- `0x25b8b18`: `QR SCANNER STATUS FIFO OVERRUN`
|
||||
- `0x25b8c00`: `QR SCANNER STATUS NO READER`
|
||||
- `0x25dd3e8`: `Check QR scanner RJ45 nodebus cable`
|
||||
- `0x25dd410`: `Check QR scanner ribbon cable`
|
||||
- `0x25e26e8`: `/connectivity/qr_code_storage/`
|
||||
- `0x25e2708`: `wifi_qr_code`
|
||||
|
||||
Targeted Ghidra decompilation:
|
||||
|
||||
- `FUN_00913720` builds QR scanner status/diagnostic display text. It explicitly chooses `Check QR scanner RJ45 nodebus cable` or `Check QR scanner ribbon cable` depending on scanner/node state.
|
||||
- No host-side QR decode library strings such as `zbar`, `quirc`, `qrencode`, or `libcamera` were found in the game strings. `libusb-1.0.so.0` is linked, but the QR scanner strings point to nodebus status and cabling.
|
||||
|
||||
Insider Connected scan flow evidence:
|
||||
|
||||
- `0x25dd568`: `ScannedUserAccountUUID`
|
||||
- `0x25dedd0`: `QET_SCANNED_USER_ACCOUNT`
|
||||
- `0x25de1a0`: `Authenticating player with back-end server.`
|
||||
- `0x25de1d0`: `Failed to begin construction of PLAYER_AUTHENTICATE message.`
|
||||
- `0x25ecf18`: `PLAYER_AUTHENTICATE`
|
||||
- `0x25d3b10`: `user_uuid`
|
||||
- `0x25df370`: `{ "machine_uuid": "%s", "user_uuids": [`
|
||||
- `0x25dfa20`: `/api/v2/game/player_properties`
|
||||
- `0x25e0cc8`: `Server response to retrieve_player_properties has no body; returned error status %s.`
|
||||
- `0x25e0d20`: `Server response to retrieve_player_properties has invalid JSON; returned error status %s.`
|
||||
- `0x25e0d80`: `Server response to retrieve_player_properties has invalid user %s.`
|
||||
|
||||
Targeted Ghidra decompilation:
|
||||
|
||||
- `FUN_00914520` maps netgame action `0x10` to `ScannedUserAccountUUID`.
|
||||
- `FUN_00919250` maps queue entry type `7` to `QET_SCANNED_USER_ACCOUNT` and `0x1f` to `QET_RETRIEVE_PLAYER_PROPERTIES`.
|
||||
- `FUN_0092d270` handles the retrieve-player-properties response. It parses JSON, extracts `user_uuid`, converts it with `uuid_copy` / `uuid_unparse`, checks whether the UUID maps to a known player object, and logs `invalid JSON`, `no body`, and `invalid user` failures.
|
||||
|
||||
Security assessment: the game treats scanned login data as a UUID/account identifier and sends it through the connectivity agent to Stern back-end authentication and player-property APIs. The recovered response-handling path validates JSON shape and user identity against the local player/account model before applying properties. I did not find a static evidence path where arbitrary QR text is directly executed as a command, file path, SQL statement, or shell input. The scanner also reports encoded-data, overrun, unsupported-AP, FIFO-overrun, and no-reader status states. Remaining uncertainty: this pass did not recover the exact QR payload parser or maximum input length checks, so QR input should be considered partially assessed rather than proven memory-safe.
|
||||
139
next-analysis-targets.md
Normal file
139
next-analysis-targets.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Next Analysis Targets
|
||||
|
||||
## Priority 1: Main Game Binary
|
||||
|
||||
Status: first-pass static strings, dependency review, targeted Ghidra decompilation, topper serial-key recovery, and a MicroPython emulator artifact are complete; see `main-game-behavior.md` and `topper-serial-key-emulator.md`.
|
||||
|
||||
Target:
|
||||
|
||||
- `/games/pokemon_pro/game`
|
||||
|
||||
Why:
|
||||
|
||||
- It is the real machine application started by `game_monitor`.
|
||||
- It interprets hardware state, controls gameplay, and likely talks to SPIKE node hardware.
|
||||
- Exit codes are meaningful and documented in `game_monitor`.
|
||||
|
||||
Suggested first passes:
|
||||
|
||||
- Extract strings with offsets. Done: `analysis/static-triage/game.strings`.
|
||||
- Identify linked libraries with an AArch64-aware ELF parser. Done with `objdump -p`; see `analysis/static-triage/game.objdump-p.txt`.
|
||||
- Load into Ghidra/IDA/rizin as AArch64 PIE. Done for targeted paths.
|
||||
- Search for paths from the init scripts: `/connectivity`, `/data`, `/games`, `/dev`, `UPDATE_REQUESTED`, `UPDATE_COMPLETED`, and node firmware filenames.
|
||||
- Search for exit-code constants matching the monitor script: `0` through `6`, plus signal paths.
|
||||
|
||||
Follow-up passes:
|
||||
|
||||
- Validate `analysis/topper-emulator/topper_emulator.py` on live hardware or a nodebus capture.
|
||||
- Recover the exact Magikarp topper `IDENTITY_BOARD_PART_ID` expected by command `0xfe`.
|
||||
- Confirm physical UART pins, baud, voltage level, and any netbridge timing/break behavior required for a MicroPython node.
|
||||
|
||||
## Priority 2: `image.bin` / Radium Runtime Image
|
||||
|
||||
Status: first-pass static comparison and whole-rootfs audio search complete; see `package-image-firmware.md` and `audio-asset-search.md`.
|
||||
|
||||
Targets:
|
||||
|
||||
- `/games/pokemon_pro/image.bin`
|
||||
- `/games/pokemon_pro/spike3/spike_menu/image.bin`
|
||||
|
||||
Why:
|
||||
|
||||
- `image.bin` is larger than the executable and is probably a resource pack, filesystem image, or application asset bundle used by the game.
|
||||
- The SPIKE menu explicitly copies its `image.bin` to tmpfs because updates may overwrite it while mmap-ed.
|
||||
- `main.pck` has already been extracted into workspace-local `assets/`; see `godot-and-radium-assets.md`.
|
||||
|
||||
Suggested first passes:
|
||||
|
||||
- Run `file`, `binwalk`, entropy analysis, and magic-number scans. `file`, `binwalk`, header dump, and first strings completed.
|
||||
- Search for embedded filenames, JSON, SQLite, compression headers, and Godot pack headers. First strings show Stern EULA text; no standard archive signature found by `binwalk`.
|
||||
- Compare structure between game `image.bin` and spike-menu `image.bin`. First 0xb0-byte header shape and early EULA structure are similar, with different size/table values.
|
||||
- Reverse the Radium/cereal mmap image table used by `/games/pokemon_pro/game` strings such as `Flash memory init mmap failed`, `/lcd/demand_loaded`, and `/lcd/auto_loaded`.
|
||||
- Trace the native sound lookup path from strings such as `Radium::Sound`, `PLAYING SOUND #%d`, `MUSIC: MAINPLAY`, and `SPEECH: 'SQUIRTLE'`; broad static searching found no ordinary loose Ogg/WAV/MP3 gameplay audio files.
|
||||
|
||||
## Priority 3: `conagent`
|
||||
|
||||
Status: first-pass reverse engineering complete in `conagent-re.md`; non-compilable C-like flow export is in `conagent-pseudocode.c`.
|
||||
|
||||
Target:
|
||||
|
||||
- `/games/pokemon_pro/conagent`
|
||||
|
||||
Why:
|
||||
|
||||
- It is separately supervised and restarted forever.
|
||||
- Name and `/connectivity` paths suggest Stern Insider Connected or connectivity agent behavior.
|
||||
|
||||
Suggested first passes:
|
||||
|
||||
- Strings and URL/domain extraction.
|
||||
- Network syscall/import review.
|
||||
- Trace references to `/connectivity/cache`, `/connectivity/files`, `/connectivity/update`, and hostname handling.
|
||||
|
||||
Follow-up passes:
|
||||
|
||||
- Force Ghidra function creation for the stripped text region and rerun decompilation.
|
||||
- Audit `ifup`/`ifdown` command construction and interface-name validation.
|
||||
- Audit direct Game-to-Server endpoint validation.
|
||||
- Audit file-transfer local path validation and update handoff.
|
||||
|
||||
## Priority 4: Netbridge Debug Path
|
||||
|
||||
Status: first-pass strings and ELF program-header review complete; see `package-image-firmware.md`.
|
||||
|
||||
Targets:
|
||||
|
||||
- `/games/pokemon_pro/spike3/netbridge/netbridge.elf`
|
||||
- `/games/pokemon_pro/spike3/netbridge/spike3-swd.cfg`
|
||||
- `/games/pokemon_pro/spike3/netbridge/rp2040.cfg`
|
||||
|
||||
Why:
|
||||
|
||||
- `netbridge.elf` is not stripped and has debug info.
|
||||
- OpenOCD config maps SWD onto Raspberry Pi GPIOs 10 and 11.
|
||||
- This may be the clearest path to understanding lower-level board communication and programming/debug pathways.
|
||||
|
||||
Findings:
|
||||
|
||||
- `netbridge.elf` is a 32-bit ARM static executable with code mapped at `0x10000000` and RAM at `0x20000000`/`0x20040000`.
|
||||
- Strings include `uart_node.c`, `NODEBUS_Poll`, `NODEUART_Write`, `UART_NodeRxIrq`, and `WS2812_NodeSetLED`.
|
||||
|
||||
## Priority 5: Node Firmware
|
||||
|
||||
Status: first-pass Intel HEX range parsing complete for top-level node firmware; see `package-image-firmware.md`.
|
||||
|
||||
Targets:
|
||||
|
||||
- All `*-LPC*.hex` files under `/games/pokemon_pro`.
|
||||
- `/games/pokemon_pro/spike3/powerdist/powerdist_ext.hex`.
|
||||
|
||||
Why:
|
||||
|
||||
- These likely define behavior for distributed playfield nodes: coils, LEDs, LCD, magnet sensors, HDMI, TMC drivers, power distribution.
|
||||
- Firmware names expose node families and MCU types.
|
||||
|
||||
Suggested first passes:
|
||||
|
||||
- Parse Intel HEX metadata and address ranges. Done for 17 top-level HEX files in `analysis/static-triage/node-firmware-ranges.md`.
|
||||
- Group by MCU family. Initial grouping by filename identifies LPC1112, LPC1113, LPC1124, LPC1313, and LPC812 families.
|
||||
- Disassemble with the correct Cortex-M profile per chip.
|
||||
- Correlate firmware names with harnesses, playfield devices, and game binary strings.
|
||||
|
||||
## Priority 6: Update Format
|
||||
|
||||
Status: first-pass `.sidx` strings/header review complete; see `package-image-firmware.md`.
|
||||
|
||||
Targets:
|
||||
|
||||
- `/games/spk/index/pokemon_pro-0_83_0.sidx`
|
||||
- Any available `.spk` update packages outside this rootfs.
|
||||
|
||||
Why:
|
||||
|
||||
- `/etc/init.d/update` and `update_monitor` make `.spk` the software update format.
|
||||
- The index file may describe package contents, hashes, versions, or install paths.
|
||||
|
||||
Suggested first passes:
|
||||
|
||||
- Inspect `.sidx` with `file`, strings, hexdump, and structured parsers. Done with `file`, `xxd`, and `strings`; 686 package paths were identified.
|
||||
- Compare with any `.spk` available from machine update media or connectivity cache.
|
||||
132
package-image-firmware.md
Normal file
132
package-image-firmware.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Package, Image, Netbridge, and Firmware Notes
|
||||
|
||||
## Scope
|
||||
|
||||
This page records first-pass static work for the non-main-game targets listed in `next-analysis-targets.md`.
|
||||
|
||||
Generated artifacts:
|
||||
|
||||
- `analysis/static-triage/game-image-bin.binwalk.txt`
|
||||
- `analysis/static-triage/spike-menu-image-bin.binwalk.txt`
|
||||
- `analysis/static-triage/game-image-bin.strings-head.txt`
|
||||
- `analysis/static-triage/spike-menu-image-bin.strings-head.txt`
|
||||
- `analysis/static-triage/netbridge.strings`
|
||||
- `analysis/static-triage/netbridge.objdump-p.txt`
|
||||
- `analysis/static-triage/node-firmware-ranges.md`
|
||||
- `analysis/static-triage/pokemon_pro_0_83_0_sidx.strings`
|
||||
- `analysis/static-triage/pokemon_pro_0_83_0_sidx.head.hex`
|
||||
- `analysis/static-triage/sidx-path-summary.txt`
|
||||
- `analysis/static-triage/key-artifact-sha256.txt`
|
||||
|
||||
## `image.bin` Files
|
||||
|
||||
Targets:
|
||||
|
||||
- `/games/pokemon_pro/image.bin`
|
||||
- `/games/pokemon_pro/spike3/spike_menu/image.bin`
|
||||
|
||||
Hashes:
|
||||
|
||||
- `/games/pokemon_pro/image.bin`: `2f1a958c1f859c339c716e53cc9ee752412373c486ea2a1bfd116eefce4617a3`
|
||||
- `/games/pokemon_pro/spike3/spike_menu/image.bin`: `a7df00dbbfb75838fd19d57ada30f4247d2ef1bc4b357ca0a64ab60f3a679af0`
|
||||
|
||||
Both files start with the same 0xb0-byte-looking binary header shape. First 8 bytes are `b0 00 00 00 00 00 00 00`. The game image has early little-endian values including `0x1be18`, `0x1bdf8`, `0x18fd0`, and `0x18c70`; the spike-menu image has corresponding smaller values including `0x3d00`, `0x3ce0`, `0xef8`, and `0xb98`.
|
||||
|
||||
`binwalk` results:
|
||||
|
||||
- `/games/pokemon_pro/image.bin` reported copyright strings at `0x1ca6c` and `0x1ddee`, plus a likely false-positive `JBOOT STAG` signature at `0xd36e9d6`.
|
||||
- `/games/pokemon_pro/spike3/spike_menu/image.bin` reported copyright strings at `0x4954` and `0x5cd6`.
|
||||
|
||||
The first strings in both images are Stern EULA text. This is consistent with a Radium/runtime serialized image rather than a normal media archive. The `.sidx` path table below strongly links these images to Radium asset trees under `assets/lcd/demand_loaded/` and `assets/lcd/auto_loaded/`.
|
||||
|
||||
## Update Index `.sidx`
|
||||
|
||||
Target:
|
||||
|
||||
- `/games/spk/index/pokemon_pro-0_83_0.sidx`
|
||||
|
||||
SHA-256:
|
||||
|
||||
- `caf06531ca1b969b6d727d76af7ace8931277dbdb6584ecb5a238f427fe0a76a`
|
||||
|
||||
`strings -a -t x` over the `.sidx` file found 686 package paths. Examples:
|
||||
|
||||
- `0x40`: `pokemon_pro/spike3/netbridge/rp2040.cfg`
|
||||
- `0x68`: `pokemon_pro/spike3/netbridge/spike3-swd.cfg`
|
||||
- `0x94`: `pokemon_pro/spike3/netbridge/netbridge.elf`
|
||||
- `0xbf`: `pokemon_pro/spike3/netbridge/version.txt`
|
||||
- `0xe8`: `pokemon_pro/spike3/powerdist/powerdist_ext.hex`
|
||||
- `0x117`: `pokemon_pro/spike3/powerdist/version.txt`
|
||||
- `0x140`: `pokemon_pro/spike3/spike_menu/game`
|
||||
- `0x163`: `pokemon_pro/spike3/spike_menu/image.bin`
|
||||
- `0x18b`: `pokemon_pro/spike3/spike_menu/assets/lcd/demand_loaded/.../scene.radium`
|
||||
- `0x1232`: `pokemon_pro/spike3/spike_menu/assets/nuk/fonts/Hack-Regular.ttf`
|
||||
|
||||
The final path entries include game Radium assets such as:
|
||||
|
||||
- `pokemon_pro/assets/lcd/auto_loaded/60ed7e5036b8ce09d35a3e101ea6fc1380b37d97/scene.assets/2.asset/247.asset`
|
||||
- `pokemon_pro/assets/lcd/auto_loaded/60ed7e5036b8ce09d35a3e101ea6fc1380b37d97/scene.radium`
|
||||
- `pokemon_pro/assets/lcd/auto_loaded/856d8afd38e1c753b3536bccebf3b4c925a4fc6e/scene.radium`
|
||||
|
||||
Inference: the `.sidx` file is a package/content index for the installed `.spk` payload. It enumerates files, but this pass did not fully reverse record headers, hashes, or size fields.
|
||||
|
||||
## Netbridge
|
||||
|
||||
Target:
|
||||
|
||||
- `/games/pokemon_pro/spike3/netbridge/netbridge.elf`
|
||||
|
||||
Confirmed facts:
|
||||
|
||||
- SHA-256: `05b7f7c329da79fb24078df69c8401c7a47be5c8a855b73fb937e052bce28d85`
|
||||
- ELF: 32-bit little-endian ARM, statically linked, debug info present, not stripped.
|
||||
- Program headers map executable code at `0x10000000`, initialized data at `0x200000c0`, and RAM regions at `0x20000000` and `0x20040000`.
|
||||
|
||||
Strings/function names from `netbridge.elf`:
|
||||
|
||||
- `uart_node.c`
|
||||
- `UART_NodeRxIrq`
|
||||
- `UART_NodeTxIrq`
|
||||
- `UART_NodeGetMessageBlock`
|
||||
- `NODE_Init`
|
||||
- `NODEBUS_Poll`
|
||||
- `NODEUART_Write`
|
||||
- `NODEUART_WritePoll`
|
||||
- `NODEUART_WriteAddress`
|
||||
- `NODE_SendBreak`
|
||||
- `WS2812_NodeSetLED`
|
||||
|
||||
This confirms netbridge is directly related to nodebus UART communication and WS2812 LED-node operations.
|
||||
|
||||
OpenOCD configs already documented in `applications-and-services.md`:
|
||||
|
||||
- `spike3-swd.cfg` maps SWCLK to Raspberry Pi GPIO 11 and SWDIO to GPIO 10.
|
||||
- `rp2040.cfg` is present for RP2040 target handling.
|
||||
|
||||
## Node Firmware
|
||||
|
||||
The 17 top-level node firmware HEX files under `/games/pokemon_pro` are Intel HEX style data payloads. All parsed data ranges begin at `0x1000`, consistent with Cortex-M images that leave a bootloader or vector-reserved area below that address.
|
||||
|
||||
Parsed ranges:
|
||||
|
||||
| Path | Records | Data bytes | Min addr | Max addr |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| `/games/pokemon_pro/coil4_lednode-LPC1313-1_33_0.hex` | 1541 | 24576 | `0x1000` | `0x6fff` |
|
||||
| `/games/pokemon_pro/coil4node-LPC1112_101-1_33_0.hex` | 772 | 12288 | `0x1000` | `0x3fff` |
|
||||
| `/games/pokemon_pro/coil4node-LPC1112_201-1_33_0.hex` | 772 | 12288 | `0x1000` | `0x3fff` |
|
||||
| `/games/pokemon_pro/coil4node-LPC1313-1_33_0.hex` | 1541 | 24576 | `0x1000` | `0x6fff` |
|
||||
| `/games/pokemon_pro/hdmi_ws2812node-LPC1313-1_33_0.hex` | 900 | 14336 | `0x1000` | `0x47ff` |
|
||||
| `/games/pokemon_pro/hdminode-LPC1313-1_33_0.hex` | 836 | 13312 | `0x1000` | `0x43ff` |
|
||||
| `/games/pokemon_pro/lcdnode-LPC1113_302-1_33_0.hex` | 1093 | 17408 | `0x1000` | `0x53ff` |
|
||||
| `/games/pokemon_pro/magsensornode-LPC812-1_33_0.hex` | 772 | 12288 | `0x1000` | `0x3fff` |
|
||||
| `/games/pokemon_pro/node4-LPC1124_303-1_33_0.hex` | 900 | 14336 | `0x1000` | `0x47ff` |
|
||||
| `/games/pokemon_pro/node4-LPC812-1_33_0.hex` | 772 | 12288 | `0x1000` | `0x3fff` |
|
||||
| `/games/pokemon_pro/pinnode-LPC1112_101-1_33_0.hex` | 772 | 12288 | `0x1000` | `0x3fff` |
|
||||
| `/games/pokemon_pro/pinnode-LPC1112_201-1_33_0.hex` | 772 | 12288 | `0x1000` | `0x3fff` |
|
||||
| `/games/pokemon_pro/pinnode-LPC1313-1_33_0.hex` | 1606 | 25600 | `0x1000` | `0x73ff` |
|
||||
| `/games/pokemon_pro/tmc2590node-LPC1313-1_33_0.hex` | 836 | 13312 | `0x1000` | `0x43ff` |
|
||||
| `/games/pokemon_pro/tmc5041node-LPC1313-1_33_0.hex` | 964 | 15360 | `0x1000` | `0x4bff` |
|
||||
| `/games/pokemon_pro/ws2812node-LPC1313-1_33_0.hex` | 900 | 14336 | `0x1000` | `0x47ff` |
|
||||
| `/games/pokemon_pro/ws2812pinnode-LPC1313-1_33_0.hex` | 1157 | 18432 | `0x1000` | `0x57ff` |
|
||||
|
||||
`/games/pokemon_pro/spike3/powerdist/powerdist_ext.hex` is present in the `.sidx` package index and remains a separate follow-up target for power-distribution firmware parsing.
|
||||
338
scripts/build_godot_editor_reconstruction.py
Normal file
338
scripts/build_godot_editor_reconstruction.py
Normal file
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a more useful Godot editor reconstruction from exported assets.
|
||||
|
||||
This is a static reconstruction. It does not run target AArch64 binaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_GODOT = """; Engine configuration file.
|
||||
; Reconstructed from /games/pokemon_pro/assets/godot/main.pck project.binary.
|
||||
; This opens an exported Godot 4.4.1 resource set in the editor. It is not the
|
||||
; original source checkout: scenes remain exported .scn resources and scripts
|
||||
; remain compiled .gdc bytecode with .gd.remap files.
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="pokemon_battle_previs"
|
||||
run/main_scene="res://scenes/main/main.tscn"
|
||||
config/features=PackedStringArray("4.4", "Forward Plus")
|
||||
|
||||
[autoload]
|
||||
|
||||
PokemonData="*res://scripts/pokemon_data.gd"
|
||||
Spike="*res://scripts/godot_spike_bridge.gd"
|
||||
AsyncLoadUtilV2="*res://scripts/async_load_util_v2.gd"
|
||||
SpikeGameSoundIds0="*res://scripts/spike_game_sound_ids.gd"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
window/stretch/mode="canvas_items"
|
||||
|
||||
[gui]
|
||||
|
||||
theme/custom_font="uid://mi8jb2bietkw"
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="forward_plus"
|
||||
"""
|
||||
|
||||
|
||||
README = """# Pokemon Spike 3 Godot Editor Reconstruction
|
||||
|
||||
This directory is a derived, editor-openable reconstruction from the exported
|
||||
Godot pack at `/games/pokemon_pro/assets/godot/main.pck`.
|
||||
|
||||
Compared with the first wrapper reconstruction, this version also restores
|
||||
source-like PNG files at the original `res://.../*.png` paths by carving the
|
||||
embedded WebP/JPEG payloads from Godot `.ctex` cache files and converting them
|
||||
to PNG. It also includes loose non-Godot assets from `/games/pokemon_pro/assets`
|
||||
under `rootfs_loose_assets/`.
|
||||
|
||||
Important limitations:
|
||||
|
||||
- This is not the original Godot source checkout.
|
||||
- GDScript source files are not present; the pack contains compiled `.gdc`
|
||||
bytecode and `.gd.remap` files.
|
||||
- Scenes are exported binary `.scn` resources reached through `.tscn.remap`
|
||||
files, not original editable text `.tscn` source.
|
||||
- Loose Radium assets and rootfs media are included for inspection, but they
|
||||
are not native Godot editor resources.
|
||||
- No standalone Godot audio resources were found in `main.pck`.
|
||||
|
||||
Primary evidence:
|
||||
|
||||
- Source pack: `/games/pokemon_pro/assets/godot/main.pck`
|
||||
- Source pack SHA-256:
|
||||
`04af0fd78866ec3ae60b10311dedb3df2f3877595d19ee7bb01665ab10512778`
|
||||
- Runtime string: `Godot Engine v4.4.1.stable.custom_build`
|
||||
- Main scene: `res://scenes/main/main.tscn`
|
||||
"""
|
||||
|
||||
|
||||
IMPORT_PATH_RE = re.compile(r'^path="res://(?P<path>[^"]+)"')
|
||||
|
||||
|
||||
def ignore_metadata(_dir: str, names: list[str]) -> set[str]:
|
||||
return {name for name in names if name in {".DS_Store", "__MACOSX"}}
|
||||
|
||||
|
||||
def copy_tree(src: Path, dst: Path) -> None:
|
||||
shutil.copytree(src, dst, ignore=ignore_metadata)
|
||||
|
||||
|
||||
def extract_riff_webp(data: bytes) -> bytes | None:
|
||||
idx = 0
|
||||
while True:
|
||||
idx = data.find(b"RIFF", idx)
|
||||
if idx < 0:
|
||||
return None
|
||||
if idx + 12 <= len(data) and data[idx + 8 : idx + 12] == b"WEBP":
|
||||
size = int.from_bytes(data[idx + 4 : idx + 8], "little") + 8
|
||||
end = idx + size
|
||||
if end <= len(data):
|
||||
return data[idx:end]
|
||||
idx += 4
|
||||
|
||||
|
||||
def extract_jpeg(data: bytes) -> bytes | None:
|
||||
start = data.find(b"\xff\xd8\xff")
|
||||
if start < 0:
|
||||
return None
|
||||
end = data.find(b"\xff\xd9", start + 2)
|
||||
if end < 0:
|
||||
return None
|
||||
return data[start : end + 2]
|
||||
|
||||
|
||||
def imported_resource_path(import_file: Path) -> str | None:
|
||||
for line in import_file.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
match = IMPORT_PATH_RE.match(line)
|
||||
if match:
|
||||
return match.group("path")
|
||||
return None
|
||||
|
||||
|
||||
def convert_image(magick: str, payload: bytes, suffix: str, out_path: Path) -> bool:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(payload)
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[magick, str(tmp_path), str(out_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0 and out_path.exists() and out_path.stat().st_size > 0
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def restore_png_sources(project: Path, magick: str) -> dict[str, object]:
|
||||
stats: dict[str, object] = {
|
||||
"png_import_files": 0,
|
||||
"png_sources_restored": 0,
|
||||
"webp_payloads": 0,
|
||||
"jpeg_payloads": 0,
|
||||
"missing_import_targets": [],
|
||||
"conversion_failures": [],
|
||||
}
|
||||
|
||||
for import_file in sorted(project.rglob("*.png.import")):
|
||||
if "/rootfs_loose_assets/" in str(import_file):
|
||||
continue
|
||||
stats["png_import_files"] = int(stats["png_import_files"]) + 1
|
||||
rel_ctex = imported_resource_path(import_file)
|
||||
if not rel_ctex:
|
||||
stats["missing_import_targets"].append(str(import_file.relative_to(project))) # type: ignore[index]
|
||||
continue
|
||||
ctex_path = project / rel_ctex
|
||||
if not ctex_path.exists():
|
||||
stats["missing_import_targets"].append(str(ctex_path.relative_to(project))) # type: ignore[index]
|
||||
continue
|
||||
data = ctex_path.read_bytes()
|
||||
webp = extract_riff_webp(data)
|
||||
out_png = import_file.with_suffix("")
|
||||
if webp is not None:
|
||||
stats["webp_payloads"] = int(stats["webp_payloads"]) + 1
|
||||
if convert_image(magick, webp, ".webp", out_png):
|
||||
stats["png_sources_restored"] = int(stats["png_sources_restored"]) + 1
|
||||
else:
|
||||
stats["conversion_failures"].append(str(import_file.relative_to(project))) # type: ignore[index]
|
||||
continue
|
||||
jpeg = extract_jpeg(data)
|
||||
if jpeg is not None:
|
||||
stats["jpeg_payloads"] = int(stats["jpeg_payloads"]) + 1
|
||||
if convert_image(magick, jpeg, ".jpg", out_png):
|
||||
stats["png_sources_restored"] = int(stats["png_sources_restored"]) + 1
|
||||
else:
|
||||
stats["conversion_failures"].append(str(import_file.relative_to(project))) # type: ignore[index]
|
||||
continue
|
||||
stats["conversion_failures"].append(str(import_file.relative_to(project))) # type: ignore[index]
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def ffprobe_streams(ffprobe: str, path: Path) -> list[dict[str, str]]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
ffprobe,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=index,codec_type,codec_name",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(result.stdout.decode("utf-8", "replace"))
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return data.get("streams", [])
|
||||
|
||||
|
||||
def media_candidates(paths: list[Path]) -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
for root in paths:
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
lower = path.name.lower()
|
||||
if lower.endswith((".mp4", ".mov", ".m4a", ".asset")):
|
||||
candidates.append(path)
|
||||
return sorted(candidates)
|
||||
|
||||
|
||||
def scan_media_audio(ffprobe: str, roots: list[Path], out_tsv: Path) -> dict[str, object]:
|
||||
rows: list[tuple[str, str, str]] = []
|
||||
audio_files = 0
|
||||
video_only_files = 0
|
||||
probed_files = 0
|
||||
for path in media_candidates(roots):
|
||||
streams = ffprobe_streams(ffprobe, path)
|
||||
if not streams:
|
||||
continue
|
||||
probed_files += 1
|
||||
has_audio = any(stream.get("codec_type") == "audio" for stream in streams)
|
||||
has_video = any(stream.get("codec_type") == "video" for stream in streams)
|
||||
if has_audio:
|
||||
audio_files += 1
|
||||
elif has_video:
|
||||
video_only_files += 1
|
||||
stream_desc = ",".join(
|
||||
f"{stream.get('index')}:{stream.get('codec_type')}:{stream.get('codec_name')}"
|
||||
for stream in streams
|
||||
)
|
||||
rel = path.as_posix()
|
||||
rows.append((rel, "yes" if has_audio else "no", stream_desc))
|
||||
out_tsv.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_tsv.write_text(
|
||||
"path\thas_audio\tstreams\n"
|
||||
+ "\n".join("\t".join(row) for row in rows)
|
||||
+ ("\n" if rows else ""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {
|
||||
"candidate_files": len(media_candidates(roots)),
|
||||
"probed_files": probed_files,
|
||||
"files_with_audio_streams": audio_files,
|
||||
"video_only_files": video_only_files,
|
||||
"scan_output": str(out_tsv),
|
||||
}
|
||||
|
||||
|
||||
def zip_dir(src: Path, zip_path: Path) -> dict[str, int]:
|
||||
file_count = 0
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
||||
for path in sorted(src.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name == ".DS_Store" or "__MACOSX" in path.parts:
|
||||
continue
|
||||
file_count += 1
|
||||
zf.write(path, path.relative_to(src))
|
||||
return {"files": file_count, "bytes": zip_path.stat().st_size}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--godot-raw", type=Path, default=Path("assets/godot_raw"))
|
||||
parser.add_argument("--loose-assets", type=Path, default=Path("games/pokemon_pro/assets"))
|
||||
parser.add_argument("--output", type=Path, default=Path("godot_editor_project_reconstructed"))
|
||||
parser.add_argument("--zip", type=Path, default=Path("project_reconstructed.zip"))
|
||||
parser.add_argument("--media-scan", type=Path, default=Path("analysis/media-audio-scan.tsv"))
|
||||
parser.add_argument("--magick", default=shutil.which("magick") or "/opt/homebrew/bin/magick")
|
||||
parser.add_argument("--ffprobe", default=shutil.which("ffprobe") or "/opt/homebrew/bin/ffprobe")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.output.exists():
|
||||
raise SystemExit(f"output path already exists: {args.output}")
|
||||
if args.zip.exists():
|
||||
raise SystemExit(f"zip path already exists: {args.zip}")
|
||||
if not Path(args.magick).exists():
|
||||
raise SystemExit("ImageMagick 'magick' is required for PNG restoration")
|
||||
|
||||
copy_tree(args.godot_raw, args.output)
|
||||
(args.output / "project.godot").write_text(PROJECT_GODOT, encoding="utf-8")
|
||||
(args.output / "README_RECONSTRUCTION.md").write_text(README, encoding="utf-8")
|
||||
|
||||
restore_stats = restore_png_sources(args.output, args.magick)
|
||||
|
||||
loose_out = args.output / "rootfs_loose_assets"
|
||||
for subdir in ("lcd", "nuk"):
|
||||
src = args.loose_assets / subdir
|
||||
if src.exists():
|
||||
copy_tree(src, loose_out / subdir)
|
||||
|
||||
media_stats: dict[str, object] = {}
|
||||
if Path(args.ffprobe).exists():
|
||||
media_stats = scan_media_audio(args.ffprobe, [args.godot_raw, args.loose_assets], args.media_scan)
|
||||
|
||||
manifest = {
|
||||
"source_godot_raw": str(args.godot_raw),
|
||||
"source_loose_assets": str(args.loose_assets),
|
||||
"output": str(args.output),
|
||||
"zip": str(args.zip),
|
||||
"png_restore": restore_stats,
|
||||
"media_audio_scan": media_stats,
|
||||
}
|
||||
(args.output / "RECONSTRUCTION_MANIFEST.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
|
||||
zip_stats = zip_dir(args.output, args.zip)
|
||||
manifest["zip_stats"] = zip_stats
|
||||
(args.output / "RECONSTRUCTION_MANIFEST.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
|
||||
print(json.dumps(manifest, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
855
scripts/build_viewer.py
Normal file
855
scripts/build_viewer.py
Normal file
@@ -0,0 +1,855 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the self-contained HTML viewer for rootfs-triage-wiki."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / "viewer.html"
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def title_for(path: Path, text: str) -> str:
|
||||
match = re.search(r"^#\s+(.+?)\s*$", text, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return path.stem.replace("-", " ").replace("_", " ").title()
|
||||
|
||||
|
||||
def page_order() -> list[str]:
|
||||
readme = read_text(ROOT / "README.md")
|
||||
ordered = ["README.md"]
|
||||
for target in re.findall(r"\[[^\]]+\]\(([^)#?]+)", readme):
|
||||
target_path = ROOT / target
|
||||
if target_path.suffix not in {".md", ".c"}:
|
||||
continue
|
||||
if target not in ordered and target_path.exists():
|
||||
ordered.append(target)
|
||||
for path in sorted(ROOT.glob("*.md")):
|
||||
name = path.name
|
||||
if name not in ordered:
|
||||
ordered.append(name)
|
||||
c_export = "conagent-pseudocode.c"
|
||||
if (ROOT / c_export).exists() and c_export not in ordered:
|
||||
ordered.append(c_export)
|
||||
return ordered
|
||||
|
||||
|
||||
def build_pages() -> list[dict[str, str]]:
|
||||
pages = []
|
||||
for name in page_order():
|
||||
path = ROOT / name
|
||||
text = read_text(path)
|
||||
kind = "markdown" if path.suffix == ".md" else "source"
|
||||
pages.append(
|
||||
{
|
||||
"path": name,
|
||||
"title": title_for(path, text),
|
||||
"kind": kind,
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
return pages
|
||||
|
||||
|
||||
HTML_TEMPLATE = r"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pokemon Spike 3 Rootfs Triage Wiki</title>
|
||||
<style>
|
||||
:root {
|
||||
--page: #f8f9fa;
|
||||
--content: #ffffff;
|
||||
--ink: #202122;
|
||||
--muted: #54595d;
|
||||
--line: #a2a9b1;
|
||||
--soft-line: #c8ccd1;
|
||||
--blue: #0645ad;
|
||||
--blue-visited: #0b0080;
|
||||
--tab: #36c;
|
||||
--code-bg: #f1f2f3;
|
||||
--sidebar: #f6f6f6;
|
||||
--mark: #fff3cd;
|
||||
--danger: #d73333;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color: var(--ink);
|
||||
background: var(--page);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
body { margin: 0; min-width: 320px; }
|
||||
|
||||
a { color: var(--blue); text-decoration: none; }
|
||||
a:visited { color: var(--blue-visited); }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.wiki-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 184px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--soft-line);
|
||||
background: var(--sidebar);
|
||||
padding: 14px 12px 24px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 18px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(135deg, transparent 42%, rgba(0,0,0,.08) 42% 58%, transparent 58%),
|
||||
radial-gradient(circle at 35% 35%, #fff 0 9px, #202122 10px 11px, transparent 12px),
|
||||
linear-gradient(#d73333 0 50%, #fff 50% 100%);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 4px #fff;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.12;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.brand span { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.side-section {
|
||||
margin-top: 18px;
|
||||
padding-top: 9px;
|
||||
border-top: 1px solid var(--soft-line);
|
||||
}
|
||||
|
||||
.side-heading {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.page-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-list a {
|
||||
display: block;
|
||||
padding: 3px 0;
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.page-list a.active {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main { min-width: 0; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 44px;
|
||||
padding: 8px 28px 0;
|
||||
background: linear-gradient(#ffffff 0%, #f8f9fa 100%);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
background: linear-gradient(#ffffff, #eaf3ff);
|
||||
color: var(--tab);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px 7px;
|
||||
cursor: pointer;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--content);
|
||||
color: var(--ink);
|
||||
margin-bottom: -1px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
width: min(360px, 42vw);
|
||||
position: relative;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.search-wrap input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 7px 9px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% - 5px);
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.16);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-results.open { display: block; }
|
||||
.search-results a {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #eaecf0;
|
||||
color: var(--ink);
|
||||
}
|
||||
.search-results a:hover { background: #f8f9fa; text-decoration: none; }
|
||||
.search-results small { display: block; color: var(--muted); }
|
||||
|
||||
.article-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 220px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
padding: 24px 28px 44px;
|
||||
}
|
||||
|
||||
.article {
|
||||
min-width: 0;
|
||||
background: var(--content);
|
||||
border: 1px solid var(--line);
|
||||
border-top: 0;
|
||||
padding: 24px 32px 34px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin: -7px 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.wiki-content h1,
|
||||
.wiki-content h2 {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin: 22px 0 12px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.wiki-content h1 {
|
||||
font-size: 31px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wiki-content h2 { font-size: 23px; }
|
||||
.wiki-content h3 { font-size: 18px; margin: 20px 0 8px; }
|
||||
.wiki-content h4 { font-size: 15px; margin: 18px 0 6px; }
|
||||
|
||||
.wiki-content p { margin: 0 0 12px; }
|
||||
.wiki-content ul,
|
||||
.wiki-content ol {
|
||||
margin: 0 0 13px 24px;
|
||||
padding: 0;
|
||||
}
|
||||
.wiki-content li { margin: 3px 0; }
|
||||
|
||||
.wiki-content code,
|
||||
.source-view code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: .92em;
|
||||
background: var(--code-bg);
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.wiki-content pre,
|
||||
.source-view pre {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--soft-line);
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
max-width: 100%;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.wiki-content pre code,
|
||||
.source-view pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.wiki-content table {
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0 18px;
|
||||
max-width: 100%;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-scroll table {
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
.wiki-content th,
|
||||
.wiki-content td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 7px 9px;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.wiki-content th {
|
||||
background: #eaecf0;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wiki-content tr:nth-child(even) td { background: #f8f9fa; }
|
||||
|
||||
.missing-link {
|
||||
color: var(--danger);
|
||||
border-bottom: 1px dotted var(--danger);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.toc {
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--content);
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toc h2,
|
||||
.backlinks h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.toc ol {
|
||||
margin: 0 0 0 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toc li { margin: 3px 0; }
|
||||
|
||||
.backlinks {
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid var(--soft-line);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.backlinks ul {
|
||||
margin: 0 0 0 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
mark { background: var(--mark); padding: 0 2px; }
|
||||
|
||||
.footer {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--soft-line);
|
||||
margin-top: 28px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wiki-shell { grid-template-columns: 1fr; }
|
||||
.sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--soft-line);
|
||||
}
|
||||
.brand { grid-template-columns: 42px minmax(0, 1fr); align-items: center; }
|
||||
.brand-mark { width: 42px; height: 42px; }
|
||||
.side-section { margin-top: 10px; }
|
||||
.page-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column-reverse;
|
||||
padding: 10px 14px 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.search-wrap { width: 100%; padding-bottom: 0; }
|
||||
.article-layout {
|
||||
display: block;
|
||||
padding: 0 14px 28px;
|
||||
}
|
||||
.article {
|
||||
padding: 18px 16px 26px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.wiki-content code {
|
||||
word-break: break-all;
|
||||
}
|
||||
.wiki-content pre code {
|
||||
word-break: normal;
|
||||
}
|
||||
.toc {
|
||||
position: static;
|
||||
margin: 14px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wiki-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#/README.md" aria-label="Open wiki home">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong>Spike 3<br>Rootfs Wiki</strong>
|
||||
<span>static evidence viewer</span>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="side-section" aria-label="Wiki pages">
|
||||
<p class="side-heading">Navigation</p>
|
||||
<ul class="page-list" id="page-list"></ul>
|
||||
</nav>
|
||||
<nav class="side-section" aria-label="Generated artifacts">
|
||||
<p class="side-heading">Generated files</p>
|
||||
<ul class="page-list" id="artifact-list"></ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div class="tabs" role="tablist" aria-label="View mode">
|
||||
<button class="tab active" id="read-tab" type="button">Article</button>
|
||||
<button class="tab" id="source-tab" type="button">Source</button>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<input id="search" type="search" autocomplete="off" placeholder="Search the wiki">
|
||||
<div class="search-results" id="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-layout">
|
||||
<article class="article">
|
||||
<div id="article-view"></div>
|
||||
<div class="footer">
|
||||
Built from files in <code>rootfs-triage-wiki/</code>. This viewer is static and does not execute target rootfs binaries.
|
||||
</div>
|
||||
</article>
|
||||
<aside class="toc" id="toc" aria-label="Contents"></aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script id="wiki-data" type="application/json">__WIKI_DATA__</script>
|
||||
<script>
|
||||
const wiki = JSON.parse(document.getElementById("wiki-data").textContent);
|
||||
const pageMap = new Map(wiki.pages.map((page) => [page.path, page]));
|
||||
let currentPath = "README.md";
|
||||
let viewMode = "article";
|
||||
let headingIndex = [];
|
||||
|
||||
const pageList = document.getElementById("page-list");
|
||||
const artifactList = document.getElementById("artifact-list");
|
||||
const articleView = document.getElementById("article-view");
|
||||
const tocView = document.getElementById("toc");
|
||||
const searchInput = document.getElementById("search");
|
||||
const searchResults = document.getElementById("search-results");
|
||||
const readTab = document.getElementById("read-tab");
|
||||
const sourceTab = document.getElementById("source-tab");
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replaceAll("`", "`");
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/`/g, "")
|
||||
.replace(/&[a-z0-9#]+;/gi, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "section";
|
||||
}
|
||||
|
||||
function splitFragment(href) {
|
||||
const hash = href.indexOf("#");
|
||||
if (hash === -1) return [href, ""];
|
||||
return [href.slice(0, hash), href.slice(hash + 1)];
|
||||
}
|
||||
|
||||
function routeFor(href, fromPath = currentPath) {
|
||||
if (!href) return "#/" + fromPath;
|
||||
if (/^(https?:|mailto:|tel:)/i.test(href)) return href;
|
||||
const [rawPath, rawFragment] = splitFragment(href);
|
||||
let target = rawPath || fromPath;
|
||||
target = target.replace(/^\.\//, "");
|
||||
if (target.includes("/")) {
|
||||
const base = fromPath.split("/").slice(0, -1);
|
||||
for (const part of target.split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") base.pop();
|
||||
else base.push(part);
|
||||
}
|
||||
target = base.join("/");
|
||||
}
|
||||
const fragment = rawFragment ? "#" + encodeURIComponent(rawFragment) : "";
|
||||
if (pageMap.has(target)) return "#/" + target + fragment;
|
||||
return href;
|
||||
}
|
||||
|
||||
function renderInline(raw, fromPath = currentPath) {
|
||||
let text = escapeHtml(raw);
|
||||
text = text.replace(/`([^`]+)`/g, (match, code) => {
|
||||
const decoded = code
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("&", "&");
|
||||
if (pageMap.has(decoded)) {
|
||||
return `<code><a href="${escapeAttr(routeFor(decoded, fromPath))}">${escapeHtml(decoded)}</a></code>`;
|
||||
}
|
||||
return `<code>${code}</code>`;
|
||||
});
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, href) => {
|
||||
const route = routeFor(href, fromPath);
|
||||
const external = /^(https?:|mailto:|tel:)/i.test(route);
|
||||
const missing = route === href && !external && !href.startsWith("#") && /\.(md|c)(#.*)?$/i.test(href);
|
||||
if (missing) {
|
||||
return `<span class="missing-link" title="Target not embedded in viewer">${label}</span>`;
|
||||
}
|
||||
const targetAttrs = external ? ' target="_blank" rel="noopener noreferrer"' : "";
|
||||
return `<a href="${escapeAttr(route)}"${targetAttrs}>${label}</a>`;
|
||||
});
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
text = text.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
return text;
|
||||
}
|
||||
|
||||
function parseTable(lines, start, fromPath) {
|
||||
const rows = [];
|
||||
let i = start;
|
||||
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
|
||||
rows.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (rows.length < 2 || !/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(rows[1])) {
|
||||
return null;
|
||||
}
|
||||
const split = (row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
|
||||
const head = split(rows[0]);
|
||||
const body = rows.slice(2).map(split);
|
||||
let html = '<div class="table-scroll"><table><thead><tr>';
|
||||
html += head.map((cell) => `<th>${renderInline(cell, fromPath)}</th>`).join("");
|
||||
html += "</tr></thead><tbody>";
|
||||
html += body.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell, fromPath)}</td>`).join("")}</tr>`).join("");
|
||||
html += "</tbody></table></div>";
|
||||
return { html, next: i };
|
||||
}
|
||||
|
||||
function flushParagraph(parts, out, fromPath) {
|
||||
if (!parts.length) return;
|
||||
out.push(`<p>${renderInline(parts.join(" "), fromPath)}</p>`);
|
||||
parts.length = 0;
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown, fromPath) {
|
||||
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
||||
const out = [];
|
||||
const paragraph = [];
|
||||
headingIndex = [];
|
||||
let listType = null;
|
||||
let inFence = false;
|
||||
let fenceLang = "";
|
||||
let fenceLines = [];
|
||||
|
||||
function closeList() {
|
||||
if (!listType) return;
|
||||
out.push(`</${listType}>`);
|
||||
listType = null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const fence = line.match(/^```([A-Za-z0-9_-]+)?\s*$/);
|
||||
if (fence) {
|
||||
if (inFence) {
|
||||
out.push(`<pre><code class="language-${escapeAttr(fenceLang)}">${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
|
||||
inFence = false;
|
||||
fenceLang = "";
|
||||
fenceLines = [];
|
||||
} else {
|
||||
flushParagraph(paragraph, out, fromPath);
|
||||
closeList();
|
||||
inFence = true;
|
||||
fenceLang = fence[1] || "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
fenceLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushParagraph(paragraph, out, fromPath);
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
|
||||
const table = parseTable(lines, i, fromPath);
|
||||
if (table) {
|
||||
flushParagraph(paragraph, out, fromPath);
|
||||
closeList();
|
||||
out.push(table.html);
|
||||
i = table.next - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
||||
if (heading) {
|
||||
flushParagraph(paragraph, out, fromPath);
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
const label = renderInline(heading[2], fromPath);
|
||||
const id = slugify(heading[2]);
|
||||
headingIndex.push({ level, label: heading[2], id });
|
||||
out.push(`<h${level} id="${escapeAttr(id)}">${label}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unordered = line.match(/^\s*[-*]\s+(.+)$/);
|
||||
const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
|
||||
if (unordered || ordered) {
|
||||
flushParagraph(paragraph, out, fromPath);
|
||||
const type = unordered ? "ul" : "ol";
|
||||
if (listType && listType !== type) closeList();
|
||||
if (!listType) {
|
||||
out.push(`<${type}>`);
|
||||
listType = type;
|
||||
}
|
||||
out.push(`<li>${renderInline((unordered || ordered)[1], fromPath)}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = line.match(/^>\s?(.+)$/);
|
||||
if (quote) {
|
||||
flushParagraph(paragraph, out, fromPath);
|
||||
closeList();
|
||||
out.push(`<blockquote>${renderInline(quote[1], fromPath)}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
closeList();
|
||||
paragraph.push(line.trim());
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
out.push(`<pre><code class="language-${escapeAttr(fenceLang)}">${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
|
||||
}
|
||||
flushParagraph(paragraph, out, fromPath);
|
||||
closeList();
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function renderSource(page) {
|
||||
const lang = page.path.endsWith(".c") ? "c" : "markdown";
|
||||
headingIndex = [{ level: 2, label: "Raw source", id: "raw-source" }];
|
||||
return `<h1>${escapeHtml(page.title)}</h1><p class="page-subtitle">Source view of <code>${escapeHtml(page.path)}</code></p><div class="source-view"><pre id="raw-source"><code class="language-${lang}">${escapeHtml(page.text)}</code></pre></div>`;
|
||||
}
|
||||
|
||||
function makeToc(page) {
|
||||
const headings = headingIndex.filter((heading) => heading.level > 1 && heading.level <= 3);
|
||||
const backlinks = wiki.pages.filter((candidate) => {
|
||||
if (candidate.path === page.path) return false;
|
||||
return candidate.text.includes(`](${page.path}`) || candidate.text.includes("`" + page.path + "`");
|
||||
});
|
||||
let html = "";
|
||||
if (headings.length) {
|
||||
html += "<h2>Contents</h2><ol>";
|
||||
html += headings.map((heading) => `<li><a href="#/${escapeAttr(page.path)}#${escapeAttr(heading.id)}">${escapeHtml(heading.label)}</a></li>`).join("");
|
||||
html += "</ol>";
|
||||
} else {
|
||||
html += "<h2>Contents</h2><p>No headings.</p>";
|
||||
}
|
||||
if (backlinks.length) {
|
||||
html += '<div class="backlinks"><h2>Referenced by</h2><ul>';
|
||||
html += backlinks.map((source) => `<li><a href="#/${escapeAttr(source.path)}">${escapeHtml(source.title)}</a></li>`).join("");
|
||||
html += "</ul></div>";
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function parseRoute() {
|
||||
let hash = window.location.hash || "#/README.md";
|
||||
if (!hash.startsWith("#/")) hash = "#/README.md";
|
||||
const withoutHash = hash.slice(2);
|
||||
const fragmentIndex = withoutHash.indexOf("#");
|
||||
const path = decodeURIComponent(fragmentIndex === -1 ? withoutHash : withoutHash.slice(0, fragmentIndex));
|
||||
const fragment = fragmentIndex === -1 ? "" : decodeURIComponent(withoutHash.slice(fragmentIndex + 1));
|
||||
return { path: pageMap.has(path) ? path : "README.md", fragment };
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
viewMode = mode;
|
||||
readTab.classList.toggle("active", mode === "article");
|
||||
sourceTab.classList.toggle("active", mode === "source");
|
||||
renderPage();
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const route = parseRoute();
|
||||
currentPath = route.path;
|
||||
const page = pageMap.get(currentPath) || pageMap.get("README.md");
|
||||
document.title = `${page.title} - Pokemon Spike 3 Rootfs Triage Wiki`;
|
||||
articleView.innerHTML = viewMode === "source" || page.kind === "source"
|
||||
? renderSource(page)
|
||||
: `<div class="wiki-content">${renderMarkdown(page.text, page.path)}</div>`;
|
||||
tocView.innerHTML = makeToc(page);
|
||||
document.querySelectorAll(".page-list a").forEach((link) => {
|
||||
link.classList.toggle("active", link.dataset.path === currentPath);
|
||||
});
|
||||
if (route.fragment) {
|
||||
const wanted = document.getElementById(route.fragment) || document.getElementById(slugify(route.fragment));
|
||||
if (wanted) wanted.scrollIntoView({ block: "start" });
|
||||
} else {
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
function renderNavigation() {
|
||||
const pages = wiki.pages.filter((page) => page.kind === "markdown");
|
||||
const artifacts = wiki.pages.filter((page) => page.kind !== "markdown");
|
||||
pageList.innerHTML = pages.map((page) => `<li><a data-path="${escapeAttr(page.path)}" href="#/${escapeAttr(page.path)}">${escapeHtml(page.title)}</a></li>`).join("");
|
||||
artifactList.innerHTML = artifacts.map((page) => `<li><a data-path="${escapeAttr(page.path)}" href="#/${escapeAttr(page.path)}">${escapeHtml(page.title)}</a></li>`).join("");
|
||||
}
|
||||
|
||||
function search(query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
searchResults.classList.remove("open");
|
||||
searchResults.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const hits = [];
|
||||
for (const page of wiki.pages) {
|
||||
const titleHit = page.title.toLowerCase().includes(q);
|
||||
const idx = page.text.toLowerCase().indexOf(q);
|
||||
if (!titleHit && idx === -1) continue;
|
||||
const start = Math.max(0, idx - 44);
|
||||
const end = Math.min(page.text.length, idx + q.length + 74);
|
||||
let snippet = idx === -1 ? page.path : page.text.slice(start, end).replace(/\s+/g, " ");
|
||||
snippet = escapeHtml(snippet);
|
||||
if (idx !== -1) {
|
||||
snippet = snippet.replace(new RegExp(escapeHtml(q).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "ig"), (m) => `<mark>${m}</mark>`);
|
||||
}
|
||||
hits.push({ page, snippet, titleHit });
|
||||
}
|
||||
searchResults.innerHTML = hits.slice(0, 12).map(({ page, snippet }) => (
|
||||
`<a href="#/${escapeAttr(page.path)}"><strong>${escapeHtml(page.title)}</strong><small>${snippet}</small></a>`
|
||||
)).join("") || '<a href="#/README.md"><strong>No matches</strong><small>Try a path, hash, service name, or binary name.</small></a>';
|
||||
searchResults.classList.add("open");
|
||||
}
|
||||
|
||||
readTab.addEventListener("click", () => setMode("article"));
|
||||
sourceTab.addEventListener("click", () => setMode("source"));
|
||||
window.addEventListener("hashchange", () => renderPage());
|
||||
searchInput.addEventListener("input", (event) => search(event.target.value));
|
||||
searchInput.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
const first = searchResults.querySelector("a");
|
||||
if (first) first.click();
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!searchResults.contains(event.target) && event.target !== searchInput) {
|
||||
searchResults.classList.remove("open");
|
||||
}
|
||||
});
|
||||
|
||||
renderNavigation();
|
||||
renderPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
data = {"pages": build_pages()}
|
||||
encoded = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
|
||||
OUT.write_text(HTML_TEMPLATE.replace("__WIKI_DATA__", encoded), encoding="utf-8")
|
||||
print(f"wrote {OUT.relative_to(ROOT.parent)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
320
scripts/extract_godot_pck_assets.py
Normal file
320
scripts/extract_godot_pck_assets.py
Normal file
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract and convert assets from the Pokemon Pro Godot PCK.
|
||||
|
||||
This is a static extractor. It does not run the target AArch64 game binary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
PCK_TABLE_OFFSET = 0x64
|
||||
PCK_FILE_BASE_OFFSET = 0x18
|
||||
PCK_FILE_COUNT_OFFSET = 0x60
|
||||
HEX32_RE = re.compile(r"^(?P<name>.+)-(?P<hash>[0-9a-fA-F]{32})\.ctex$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PckEntry:
|
||||
path: str
|
||||
offset: int
|
||||
size: int
|
||||
md5: str
|
||||
flags: int
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def safe_parts(path: str) -> list[str]:
|
||||
parts: list[str] = []
|
||||
for part in Path(path).parts:
|
||||
if part in ("", ".", ".."):
|
||||
continue
|
||||
clean = re.sub(r"[^A-Za-z0-9._' -]+", "_", part)
|
||||
clean = clean.strip()
|
||||
parts.append(clean or "_")
|
||||
return parts
|
||||
|
||||
|
||||
def safe_join(root: Path, path: str) -> Path:
|
||||
out = root.joinpath(*safe_parts(path))
|
||||
resolved = out.resolve()
|
||||
root_resolved = root.resolve()
|
||||
if root_resolved != resolved and root_resolved not in resolved.parents:
|
||||
raise ValueError(f"unsafe output path: {path}")
|
||||
return out
|
||||
|
||||
|
||||
def parse_pck(data: bytes) -> tuple[int, list[PckEntry]]:
|
||||
if data[:4] != b"GDPC":
|
||||
raise ValueError("not a Godot PCK: missing GDPC magic")
|
||||
file_base = struct.unpack_from("<Q", data, PCK_FILE_BASE_OFFSET)[0]
|
||||
file_count = struct.unpack_from("<I", data, PCK_FILE_COUNT_OFFSET)[0]
|
||||
pos = PCK_TABLE_OFFSET
|
||||
entries: list[PckEntry] = []
|
||||
for _ in range(file_count):
|
||||
path_len = struct.unpack_from("<I", data, pos)[0]
|
||||
pos += 4
|
||||
raw_path = data[pos : pos + path_len]
|
||||
pos += path_len
|
||||
path = raw_path.rstrip(b"\0").decode("utf-8", "replace")
|
||||
offset, size = struct.unpack_from("<QQ", data, pos)
|
||||
md5 = data[pos + 16 : pos + 32].hex()
|
||||
flags = struct.unpack_from("<I", data, pos + 32)[0]
|
||||
pos += 36
|
||||
entries.append(PckEntry(path=path, offset=offset, size=size, md5=md5, flags=flags))
|
||||
if pos != file_base:
|
||||
raise ValueError(f"PCK table ended at 0x{pos:x}, file_base is 0x{file_base:x}")
|
||||
return file_base, entries
|
||||
|
||||
|
||||
def decompress_rscc(blob: bytes, zstd_path: str) -> bytes:
|
||||
if not blob.startswith(b"RSCC"):
|
||||
return blob
|
||||
if len(blob) < 16:
|
||||
raise ValueError("truncated RSCC header")
|
||||
_version, block_size, total_size = struct.unpack_from("<III", blob, 4)
|
||||
if block_size == 0:
|
||||
raise ValueError("invalid RSCC block size 0")
|
||||
block_count = math.ceil(total_size / block_size)
|
||||
size_table_end = 16 + (4 * block_count)
|
||||
if len(blob) < size_table_end:
|
||||
raise ValueError("truncated RSCC block table")
|
||||
block_sizes = struct.unpack_from("<" + "I" * block_count, blob, 16)
|
||||
compressed_start = size_table_end
|
||||
compressed_end = compressed_start + sum(block_sizes)
|
||||
compressed = blob[compressed_start:compressed_end]
|
||||
result = subprocess.run(
|
||||
[zstd_path, "-dc"],
|
||||
input=compressed,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
err = result.stderr.decode("utf-8", "replace").strip()
|
||||
raise ValueError(f"zstd failed: {err}")
|
||||
if len(result.stdout) != total_size:
|
||||
raise ValueError(f"RSCC decompressed to {len(result.stdout)} bytes, expected {total_size}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def extract_riff_webp(data: bytes) -> Optional[bytes]:
|
||||
idx = 0
|
||||
while True:
|
||||
idx = data.find(b"RIFF", idx)
|
||||
if idx < 0:
|
||||
return None
|
||||
if idx + 12 <= len(data) and data[idx + 8 : idx + 12] == b"WEBP":
|
||||
riff_size = struct.unpack_from("<I", data, idx + 4)[0] + 8
|
||||
end = idx + riff_size
|
||||
if end <= len(data):
|
||||
return data[idx:end]
|
||||
idx += 4
|
||||
|
||||
|
||||
def extract_jpeg(data: bytes) -> Optional[bytes]:
|
||||
start = data.find(b"\xff\xd8\xff")
|
||||
if start < 0:
|
||||
return None
|
||||
end = data.find(b"\xff\xd9", start + 2)
|
||||
if end < 0:
|
||||
return None
|
||||
return data[start : end + 2]
|
||||
|
||||
|
||||
def extract_ttf(data: bytes) -> Optional[bytes]:
|
||||
candidates = [data.find(b"\x00\x01\x00\x00"), data.find(b"OTTO"), data.find(b"ttcf")]
|
||||
candidates = [idx for idx in candidates if idx >= 0]
|
||||
if not candidates:
|
||||
return None
|
||||
start = min(candidates)
|
||||
if data[start : start + 4] == b"ttcf":
|
||||
return data[start:]
|
||||
if start + 12 > len(data):
|
||||
return None
|
||||
num_tables = struct.unpack_from(">H", data, start + 4)[0]
|
||||
table_dir_end = start + 12 + (num_tables * 16)
|
||||
if num_tables == 0 or table_dir_end > len(data):
|
||||
return None
|
||||
max_end = table_dir_end
|
||||
for i in range(num_tables):
|
||||
record = start + 12 + (i * 16)
|
||||
table_offset = struct.unpack_from(">I", data, record + 8)[0]
|
||||
table_length = struct.unpack_from(">I", data, record + 12)[0]
|
||||
max_end = max(max_end, start + table_offset + table_length)
|
||||
if max_end > len(data):
|
||||
return None
|
||||
return data[start:max_end]
|
||||
|
||||
|
||||
def image_output_name(entry_path: str, extension: str) -> str:
|
||||
name = Path(entry_path).name
|
||||
match = HEX32_RE.match(name)
|
||||
if match:
|
||||
source_name = match.group("name")
|
||||
short_hash = match.group("hash")[:8].lower()
|
||||
stem = Path(source_name).stem
|
||||
return f"{stem}--{short_hash}{extension}"
|
||||
return f"{Path(name).stem}{extension}"
|
||||
|
||||
|
||||
def text_like(data: bytes) -> bool:
|
||||
if b"\0" in data[:4096]:
|
||||
return False
|
||||
try:
|
||||
data.decode("utf-8")
|
||||
return True
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
|
||||
|
||||
def write_file(path: Path, data: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--pck", type=Path, default=Path("games/pokemon_pro/assets/godot/main.pck"))
|
||||
parser.add_argument("--output", type=Path, default=Path("assets"))
|
||||
args = parser.parse_args()
|
||||
|
||||
zstd_path = shutil.which("zstd") or "/opt/homebrew/bin/zstd"
|
||||
if not Path(zstd_path).exists():
|
||||
print("zstd is required to decompress RSCC resources", file=sys.stderr)
|
||||
return 1
|
||||
if args.output.exists():
|
||||
print(f"output path already exists: {args.output}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
data = args.pck.read_bytes()
|
||||
file_base, entries = parse_pck(data)
|
||||
|
||||
raw_root = args.output / "godot_raw"
|
||||
openable_root = args.output / "openable"
|
||||
stats = {
|
||||
"entries": len(entries),
|
||||
"raw_files": 0,
|
||||
"rscc_decompressed": 0,
|
||||
"images_webp": 0,
|
||||
"images_jpeg": 0,
|
||||
"videos_mp4": 0,
|
||||
"fonts_ttf": 0,
|
||||
"text_files": 0,
|
||||
"conversion_failures": [],
|
||||
}
|
||||
manifest_entries = []
|
||||
|
||||
for entry in entries:
|
||||
start = file_base + entry.offset
|
||||
end = start + entry.size
|
||||
blob = data[start:end]
|
||||
try:
|
||||
decoded = decompress_rscc(blob, zstd_path)
|
||||
if decoded is not blob:
|
||||
stats["rscc_decompressed"] += 1
|
||||
except Exception as exc:
|
||||
decoded = blob
|
||||
stats["conversion_failures"].append({"path": entry.path, "stage": "rscc", "error": str(exc)})
|
||||
|
||||
raw_path = safe_join(raw_root, entry.path)
|
||||
write_file(raw_path, decoded)
|
||||
stats["raw_files"] += 1
|
||||
|
||||
lower_path = entry.path.lower()
|
||||
converted_path: Optional[Path] = None
|
||||
|
||||
if lower_path.endswith(".ctex"):
|
||||
webp = extract_riff_webp(decoded)
|
||||
if webp is not None:
|
||||
converted_path = openable_root / "images" / "imported" / image_output_name(entry.path, ".webp")
|
||||
write_file(converted_path, webp)
|
||||
stats["images_webp"] += 1
|
||||
else:
|
||||
jpeg = extract_jpeg(decoded)
|
||||
if jpeg is not None:
|
||||
converted_path = openable_root / "images" / "imported" / image_output_name(entry.path, ".jpg")
|
||||
write_file(converted_path, jpeg)
|
||||
stats["images_jpeg"] += 1
|
||||
else:
|
||||
stats["conversion_failures"].append({"path": entry.path, "stage": "image-carve", "error": "no WEBP/JPEG payload found"})
|
||||
elif lower_path.endswith(".fontdata"):
|
||||
ttf = extract_ttf(decoded)
|
||||
if ttf is not None:
|
||||
name = Path(entry.path).name.split("-", 1)[0]
|
||||
converted_path = openable_root / "fonts" / name
|
||||
write_file(converted_path, ttf)
|
||||
stats["fonts_ttf"] += 1
|
||||
else:
|
||||
stats["conversion_failures"].append({"path": entry.path, "stage": "font-carve", "error": "no sfnt font payload found"})
|
||||
elif lower_path.endswith(".mp4"):
|
||||
converted_path = safe_join(openable_root / "videos", entry.path)
|
||||
write_file(converted_path, decoded)
|
||||
stats["videos_mp4"] += 1
|
||||
elif lower_path.endswith((".txt", ".cfg", ".import", ".remap")) or text_like(decoded):
|
||||
converted_path = safe_join(openable_root / "text", f"{entry.path}.txt")
|
||||
write_file(converted_path, decoded)
|
||||
stats["text_files"] += 1
|
||||
|
||||
manifest_entries.append(
|
||||
{
|
||||
"path": entry.path,
|
||||
"offset": entry.offset,
|
||||
"size": entry.size,
|
||||
"md5": entry.md5,
|
||||
"flags": entry.flags,
|
||||
"decoded_size": len(decoded),
|
||||
"raw_path": str(raw_path.relative_to(args.output)),
|
||||
"openable_path": str(converted_path.relative_to(args.output)) if converted_path else None,
|
||||
}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"source": str(args.pck),
|
||||
"source_sha256": sha256_file(args.pck),
|
||||
"source_size": args.pck.stat().st_size,
|
||||
"pck_file_base": file_base,
|
||||
"pck_entry_count": len(entries),
|
||||
"stats": stats,
|
||||
"entries": manifest_entries,
|
||||
}
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
(args.output / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
(args.output / "README.md").write_text(
|
||||
"# Extracted Pokemon Pro Godot Assets\n\n"
|
||||
"Generated from `/games/pokemon_pro/assets/godot/main.pck` by "
|
||||
"`rootfs-triage-wiki/scripts/extract_godot_pck_assets.py`.\n\n"
|
||||
"- `godot_raw/` preserves every PCK entry after RSCC/zstd decompression where applicable.\n"
|
||||
"- `openable/images/imported/` contains WebP/JPEG payloads carved from Godot `.ctex` textures.\n"
|
||||
"- `openable/videos/` contains MP4 assets from the pack.\n"
|
||||
"- `openable/fonts/` contains TTF payloads carved from Godot `.fontdata` resources.\n"
|
||||
"- `openable/text/` contains text metadata and config files with `.txt` suffixes for easy opening.\n"
|
||||
"- `manifest.json` records offsets, sizes, hashes, decoded paths, and conversion results.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps({"output": str(args.output), "stats": stats}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
102
scripts/harvest_loose_openable_assets.py
Normal file
102
scripts/harvest_loose_openable_assets.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Copy loose rootfs assets with viewer-friendly extensions.
|
||||
|
||||
This preserves target files unchanged and writes renamed copies under assets/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def safe_parts(path: Path) -> list[str]:
|
||||
parts: list[str] = []
|
||||
for part in path.parts:
|
||||
if part in ("", ".", ".."):
|
||||
continue
|
||||
clean = re.sub(r"[^A-Za-z0-9._' -]+", "_", part).strip()
|
||||
parts.append(clean or "_")
|
||||
return parts
|
||||
|
||||
|
||||
def detect_extension(data: bytes) -> str | None:
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ".png"
|
||||
if data.startswith((b"\x00\x01\x00\x00", b"OTTO", b"ttcf")):
|
||||
return ".ttf"
|
||||
if len(data) >= 12 and data[4:8] == b"ftyp":
|
||||
major = data[8:12]
|
||||
return ".mov" if major == b"qt " else ".mp4"
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", type=Path, default=Path("games/pokemon_pro/assets"))
|
||||
parser.add_argument("--output", type=Path, default=Path("assets/openable/loose"))
|
||||
parser.add_argument("--manifest", type=Path, default=Path("assets/loose_manifest.json"))
|
||||
args = parser.parse_args()
|
||||
|
||||
copied = []
|
||||
skipped = 0
|
||||
for src in sorted(args.source.rglob("*")):
|
||||
if not src.is_file() or src.name == "main.pck":
|
||||
continue
|
||||
with src.open("rb") as f:
|
||||
head = f.read(64)
|
||||
ext = detect_extension(head)
|
||||
if ext is None:
|
||||
skipped += 1
|
||||
continue
|
||||
rel = src.relative_to(args.source)
|
||||
rel_parts = safe_parts(rel)
|
||||
out = args.output.joinpath(*rel_parts)
|
||||
if out.suffix.lower() != ext:
|
||||
out = out.with_name(out.name + ext)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(src, out)
|
||||
copied.append(
|
||||
{
|
||||
"source": str(src),
|
||||
"source_target_path": "/games/pokemon_pro/assets/" + str(rel),
|
||||
"output": str(out),
|
||||
"size": src.stat().st_size,
|
||||
"sha256": sha256_file(src),
|
||||
"detected_extension": ext,
|
||||
}
|
||||
)
|
||||
|
||||
args.manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"source_root": str(args.source),
|
||||
"output_root": str(args.output),
|
||||
"copied": len(copied),
|
||||
"skipped_non_openable": skipped,
|
||||
"entries": copied,
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps({"copied": len(copied), "skipped_non_openable": skipped}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
279
scripts/search_audio_assets.py
Normal file
279
scripts/search_audio_assets.py
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Search the extracted target rootfs for audio-like assets.
|
||||
|
||||
The scan is static: it reads files, checks names, file(1) output, magic
|
||||
signatures, and ffprobe stream metadata. It does not execute target binaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TARGET_ROOT_DIRS = [
|
||||
"bin",
|
||||
"boot",
|
||||
"connectivity",
|
||||
"data",
|
||||
"dev",
|
||||
"etc",
|
||||
"games",
|
||||
"home",
|
||||
"lib",
|
||||
"lib64",
|
||||
"linuxrc",
|
||||
"lost+found",
|
||||
"media",
|
||||
"media2",
|
||||
"mnt",
|
||||
"opt",
|
||||
"proc",
|
||||
"root",
|
||||
"run",
|
||||
"sbin",
|
||||
"sys",
|
||||
"tmp",
|
||||
"usr",
|
||||
"var",
|
||||
]
|
||||
|
||||
NAME_RE = re.compile(
|
||||
r"(audio|sound|music|voice|vocal|speech|sfx|song|speaker|alsa|bluealsa|"
|
||||
r"\.(wav|wave|ogg|oga|mp3|flac|aac|m4a|opus|mid|midi|aif|aiff|aifc|"
|
||||
r"bnk|wem|fsb|bank|it|xm|mod|s3m)$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
TEXT_RE = re.compile(r"(audio|sound|music|voice|vocal|speech|sfx|song|alsa|volume)", re.IGNORECASE)
|
||||
|
||||
MAGICS = [
|
||||
("ogg", b"OggS"),
|
||||
("flac", b"fLaC"),
|
||||
("id3", b"ID3"),
|
||||
("midi", b"MThd"),
|
||||
("riff", b"RIFF"),
|
||||
("aiff", b"FORM"),
|
||||
("mp4_ftyp", b"ftyp"),
|
||||
("wwise_bkhd", b"BKHD"),
|
||||
("fsb5", b"FSB5"),
|
||||
("opus_head", b"OpusHead"),
|
||||
]
|
||||
|
||||
|
||||
def target_files(root: Path) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
for name in TARGET_ROOT_DIRS:
|
||||
start = root / name
|
||||
if not start.exists():
|
||||
continue
|
||||
if start.is_file():
|
||||
paths.append(start)
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(start):
|
||||
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
||||
for filename in filenames:
|
||||
path = Path(dirpath) / filename
|
||||
if path.is_file() and not path.is_symlink():
|
||||
paths.append(path)
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def rel(path: Path, root: Path) -> str:
|
||||
return "/" + path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def run_file(paths: list[Path]) -> dict[Path, str]:
|
||||
out: dict[Path, str] = {}
|
||||
batch_size = 128
|
||||
for i in range(0, len(paths), batch_size):
|
||||
batch = paths[i : i + batch_size]
|
||||
result = subprocess.run(["file", *map(str, batch)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
|
||||
for line in result.stdout.decode("utf-8", "replace").splitlines():
|
||||
left, sep, right = line.partition(":")
|
||||
if not sep:
|
||||
continue
|
||||
out[Path(left)] = right.strip()
|
||||
return out
|
||||
|
||||
|
||||
def interesting_file_type(desc: str) -> bool:
|
||||
return bool(
|
||||
re.search(
|
||||
r"audio|mpeg.*layer|mp3|ogg|wave|flac|aac|midi|aiff|matroska|iso media|quicktime|video",
|
||||
desc,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def scan_magic(path: Path) -> list[dict[str, object]]:
|
||||
hits: list[dict[str, object]] = []
|
||||
size = path.stat().st_size
|
||||
if size == 0:
|
||||
return hits
|
||||
chunk_size = 1024 * 1024
|
||||
overlap = 64
|
||||
offset = 0
|
||||
prev_tail = b""
|
||||
with path.open("rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
data = prev_tail + chunk
|
||||
base = offset - len(prev_tail)
|
||||
for name, magic in MAGICS:
|
||||
start = 0
|
||||
while True:
|
||||
idx = data.find(magic, start)
|
||||
if idx < 0:
|
||||
break
|
||||
absolute = base + idx
|
||||
if absolute >= offset and len([h for h in hits if h["magic"] == name]) < 32:
|
||||
context = data[idx : idx + 16].hex()
|
||||
detail = ""
|
||||
if magic == b"RIFF" and idx + 12 <= len(data):
|
||||
detail = data[idx + 8 : idx + 12].decode("ascii", "replace")
|
||||
elif magic == b"FORM" and idx + 12 <= len(data):
|
||||
detail = data[idx + 8 : idx + 12].decode("ascii", "replace")
|
||||
elif magic == b"ftyp" and idx >= 4 and idx + 8 <= len(data):
|
||||
detail = data[idx + 4 : idx + 8].decode("ascii", "replace")
|
||||
hits.append({"magic": name, "offset": absolute, "detail": detail, "context": context})
|
||||
start = idx + 1
|
||||
prev_tail = data[-overlap:]
|
||||
offset += len(chunk)
|
||||
return hits
|
||||
|
||||
|
||||
def ffprobe(path: Path, ffprobe_bin: str) -> list[dict[str, object]]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
ffprobe_bin,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=index,codec_type,codec_name",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
return json.loads(result.stdout.decode("utf-8", "replace")).get("streams", [])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
|
||||
def first_strings(path: Path, limit: int = 20) -> list[str]:
|
||||
result = subprocess.run(["strings", "-a", "-n", "4", str(path)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
|
||||
strings: list[str] = []
|
||||
for line in result.stdout.decode("utf-8", "replace").splitlines():
|
||||
if TEXT_RE.search(line):
|
||||
strings.append(line[:240])
|
||||
if len(strings) >= limit:
|
||||
break
|
||||
return strings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, default=Path("."))
|
||||
parser.add_argument("--output", type=Path, default=Path("analysis/audio-search"))
|
||||
parser.add_argument("--ffprobe", default="/opt/homebrew/bin/ffprobe")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = args.root.resolve()
|
||||
files = target_files(root)
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_types = run_file(files)
|
||||
filename_hits = [p for p in files if NAME_RE.search(p.name)]
|
||||
file_type_hits = [p for p in files if interesting_file_type(file_types.get(p, ""))]
|
||||
|
||||
magic_rows: list[dict[str, object]] = []
|
||||
ffprobe_candidates: set[Path] = set(file_type_hits)
|
||||
for p in files:
|
||||
hits = scan_magic(p)
|
||||
if hits:
|
||||
for hit in hits:
|
||||
magic_rows.append({"path": rel(p, root), **hit})
|
||||
if any(hit["magic"] in {"mp4_ftyp", "riff", "ogg", "flac", "id3", "midi", "aiff"} for hit in hits):
|
||||
ffprobe_candidates.add(p)
|
||||
|
||||
ffprobe_rows: list[dict[str, object]] = []
|
||||
if Path(args.ffprobe).exists():
|
||||
for p in sorted(ffprobe_candidates):
|
||||
streams = ffprobe(p, args.ffprobe)
|
||||
if not streams:
|
||||
continue
|
||||
ffprobe_rows.append(
|
||||
{
|
||||
"path": rel(p, root),
|
||||
"size": p.stat().st_size,
|
||||
"has_audio": any(s.get("codec_type") == "audio" for s in streams),
|
||||
"has_video": any(s.get("codec_type") == "video" for s in streams),
|
||||
"streams": ",".join(f"{s.get('index')}:{s.get('codec_type')}:{s.get('codec_name')}" for s in streams),
|
||||
}
|
||||
)
|
||||
|
||||
string_rows = []
|
||||
for p in sorted(set(filename_hits + file_type_hits)):
|
||||
string_rows.append({"path": rel(p, root), "strings": first_strings(p)})
|
||||
|
||||
with (args.output / "filename-hits.tsv").open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f, delimiter="\t")
|
||||
writer.writerow(["path", "size"])
|
||||
for p in filename_hits:
|
||||
writer.writerow([rel(p, root), p.stat().st_size])
|
||||
|
||||
with (args.output / "file-type-hits.tsv").open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f, delimiter="\t")
|
||||
writer.writerow(["path", "size", "file_type"])
|
||||
for p in file_type_hits:
|
||||
writer.writerow([rel(p, root), p.stat().st_size, file_types.get(p, "")])
|
||||
|
||||
with (args.output / "magic-hits.tsv").open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["path", "magic", "offset", "detail", "context"], delimiter="\t")
|
||||
writer.writeheader()
|
||||
writer.writerows(magic_rows)
|
||||
|
||||
with (args.output / "ffprobe-streams.tsv").open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["path", "size", "has_audio", "has_video", "streams"], delimiter="\t")
|
||||
writer.writeheader()
|
||||
writer.writerows(ffprobe_rows)
|
||||
|
||||
(args.output / "audio-related-strings.json").write_text(json.dumps(string_rows, indent=2), encoding="utf-8")
|
||||
|
||||
summary = {
|
||||
"target_file_count": len(files),
|
||||
"filename_hits": len(filename_hits),
|
||||
"file_type_hits": len(file_type_hits),
|
||||
"magic_hits": len(magic_rows),
|
||||
"ffprobe_stream_files": len(ffprobe_rows),
|
||||
"ffprobe_audio_files": sum(1 for row in ffprobe_rows if row["has_audio"]),
|
||||
"outputs": {
|
||||
"filename_hits": str(args.output / "filename-hits.tsv"),
|
||||
"file_type_hits": str(args.output / "file-type-hits.tsv"),
|
||||
"magic_hits": str(args.output / "magic-hits.tsv"),
|
||||
"ffprobe_streams": str(args.output / "ffprobe-streams.tsv"),
|
||||
"audio_related_strings": str(args.output / "audio-related-strings.json"),
|
||||
},
|
||||
}
|
||||
(args.output / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
print(json.dumps(summary, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
166
topper-serial-key-emulator.md
Normal file
166
topper-serial-key-emulator.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Magikarp Topper Serial Key and Emulator
|
||||
|
||||
## Scope
|
||||
|
||||
Target:
|
||||
|
||||
- `/games/pokemon_pro/game`
|
||||
|
||||
Generated artifacts:
|
||||
|
||||
- `analysis/topper-emulator/topper_emulator.py`
|
||||
- `analysis/topper-emulator/README.md`
|
||||
|
||||
Static caveat: this page is based on static rootfs files and targeted Ghidra decompilation. It does not include live cabinet or logic-analyzer validation.
|
||||
|
||||
## Transport Finding
|
||||
|
||||
The Magikarp topper path uses the generic SPIKE node-board bus.
|
||||
|
||||
Evidence:
|
||||
|
||||
- `/games/pokemon_pro/spike3/netbridge/netbridge.elf` contains debug/source strings including `uart_node.c`, `NODEBUS_Poll`, `NODEUART_Write`, `NODEUART_WritePoll`, and `NODEUART_WriteAddress`.
|
||||
- `/games/pokemon_pro/game` function `FUN_00a21a20` constructs addressed node frames and calls lower transport helper `FUN_00a4c4c0`.
|
||||
- `/games/pokemon_pro/game` functions `FUN_0090aff0`, `FUN_00a23450`, `FUN_00a26690`, and `FUN_00a26700` implement discovery/address-management around node addresses, not USB or cloud registration.
|
||||
|
||||
Inference: the MicroPython emulator should target a UART-style nodebus interface first. The rootfs does load `i2c-dev`, but the topper-specific evidence reviewed here points to nodebus UART. Exact pins, voltage, baud, and half-duplex behavior still need hardware validation.
|
||||
|
||||
## Frame Format Recovered From `FUN_00a21a20`
|
||||
|
||||
For addressed commands, higher-level helpers pass an application frame shaped like:
|
||||
|
||||
```text
|
||||
byte 0: node_address | 0x80
|
||||
byte 1: application argument count
|
||||
byte 2..N: command and arguments
|
||||
last: response payload length
|
||||
```
|
||||
|
||||
`FUN_00a21a20` converts that to this wire frame:
|
||||
|
||||
```text
|
||||
byte 0: node_address | 0x80
|
||||
byte 1: application argument count + 1
|
||||
byte 2..N: command and arguments
|
||||
next: subtractive checksum over bytes before the checksum
|
||||
last: response payload length + 2
|
||||
```
|
||||
|
||||
The response is expected as:
|
||||
|
||||
```text
|
||||
bytes 0..payload_len-1: payload
|
||||
next: subtractive checksum over payload
|
||||
last: status bits; status bits 0x0c cause the game to reject the response
|
||||
```
|
||||
|
||||
Discovery is special: `FUN_00a23450` sends a single zero byte and expects one raw response byte containing a discovered node address.
|
||||
|
||||
## Serial Number Key Algorithm
|
||||
|
||||
The strongest recovered key path is:
|
||||
|
||||
- `FUN_006a9620` gets logical node device `10` with `FUN_007cbc80(10)`.
|
||||
- It verifies the node is available with `FUN_007cbfb0(10, 1)`.
|
||||
- It passes the configured node address byte from device entry offset `0x1a` to `FUN_00a28a00(address, out)`.
|
||||
- `FUN_00a28a00` sends command `0xfc` with expected response length `0x10`.
|
||||
- `FUN_006a9620` treats the 16 returned bytes as four little-endian 32-bit words and computes:
|
||||
|
||||
```c
|
||||
key = (word0 ^ word1 ^ word2 ^ word3) & 0x7fffffff;
|
||||
```
|
||||
|
||||
This is the recovered algorithm for the Magikarp topper serial-number key material read from the control board.
|
||||
|
||||
Important namespace note:
|
||||
|
||||
- The public adjustment name table places `AD_MAGIKARP_TOPPER_SERIAL_NUMBER_KEY` at index `0x1a3`. Nearby entries are:
|
||||
- `0x19b`: `AD_MAGIKARP_MECH_DISABLED`
|
||||
- `0x19c`: `AD_MAGIKARP_HEAD_HI_LIMIT`
|
||||
- `0x19d`: `AD_MAGIKARP_HEAD_LOW_LIMIT`
|
||||
- `0x19e`: `AD_MAGIKARP_TAIL_HI_LIMIT`
|
||||
- `0x19f`: `AD_MAGIKARP_TAIL_LOW_LIMIT`
|
||||
- `0x1a0`: `AD_MAGIKARP_WATER_CENTER`
|
||||
- `0x1a1`: `AD_MAGIKARP_ATTRACT_BEHAVIOR`
|
||||
- `0x1a2`: `AD_MAGIKARP_ATTRACT_FREQUENCY`
|
||||
- `0x1a3`: `AD_MAGIKARP_TOPPER_SERIAL_NUMBER_KEY`
|
||||
- The Magikarp runtime functions use constants such as `0xa6..0xaa` and `0xad` with `FUN_007d3330` / `FUN_007d39c0`. Static table dumping showed those are not direct indexes into the public `AD_*` name-pointer table, so they should be treated as a separate adjustment-access namespace or translated runtime IDs.
|
||||
|
||||
## Persistence and Calibration Path
|
||||
|
||||
`FUN_006a9570` compares the recovered key from object offset `+0x0c` against `FUN_007d3330(0xad)`. If the stored value is zero or mismatched, and the calibration/config payload is present and valid, it stores the recovered key with `FUN_007d39c0(0xad, key)` and marks the local state as synchronized.
|
||||
|
||||
Calibration validity is checked by `FUN_006a8f50`:
|
||||
|
||||
```text
|
||||
byte 0: 0x01
|
||||
byte 1..3: ASCII "-15"
|
||||
byte 4..8: five Magikarp calibration bytes
|
||||
byte 9..10: little-endian ((sum(bytes 4..8) ^ 0x93d3) & 0xffff)
|
||||
```
|
||||
|
||||
`FUN_006a9920` constructs the same payload from five calibration adjustment reads. Static dump of the pointer used as the three-byte magic resolves to target address `0x0268ab40`, whose first bytes are ASCII `-15`.
|
||||
|
||||
`FUN_0090c290` and `FUN_0090c310` copy a 64-byte node config/cache block from the generic node record. They treat bytes `0x3c..0x3f` equal to `0x3254789a` as a stored-valid marker.
|
||||
|
||||
## Emulator
|
||||
|
||||
The generated emulator is `analysis/topper-emulator/topper_emulator.py`.
|
||||
|
||||
It implements:
|
||||
|
||||
- Discovery request `0x00`.
|
||||
- Address-management command `0xf0` as a no-op ACK path.
|
||||
- Config read command `0xf2` in 8-byte chunks.
|
||||
- Serial-key command `0xfc`, returning the 16-byte key material.
|
||||
- Adjacent serial/cache helper command `0xfd`.
|
||||
- Identity command `0xfe`.
|
||||
- Status command `0xff`.
|
||||
- Selected provisioning/runtime commands as ACK/no-op paths: `0x14`, `0x40`, `0x41`, `0x42`, `0x43`, `0x44`, `0x45`, `0x46`, `0x48`, `0x4d`, `0x72`, `0xf4`, `0xf8`.
|
||||
|
||||
Host-side validation performed:
|
||||
|
||||
```text
|
||||
python3 -m py_compile analysis/topper-emulator/topper_emulator.py
|
||||
```
|
||||
|
||||
Frame self-test:
|
||||
|
||||
```text
|
||||
request: 8a02fc7812
|
||||
response length: 18
|
||||
response payload matched serial material: True
|
||||
response status: 0
|
||||
```
|
||||
|
||||
The sample request is command `0xfc` for node address `10`, using the recovered wire-frame checksum and expected response length.
|
||||
|
||||
## Open Hardware Variables
|
||||
|
||||
These values must be matched against a real board or a known-good capture:
|
||||
|
||||
- `NODE_ADDRESS`: logical device `10` resolves to a configured node address at runtime via `FUN_007cbc80(10)`, device-entry offset `0x1a`. This value was not recovered as a fixed static literal.
|
||||
- UART ID, pins, baud, electrical level, and bus direction control.
|
||||
- `IDENTITY_BOARD_PART_ID`: command `0xfe` returns an 11-byte identity payload. The game compares its part ID against the expected node-device table. This part ID was not confidently recovered from static analysis.
|
||||
- Exact serial/version fields expected by diagnostics.
|
||||
- Whether live netbridge behavior requires breaks, XON/XOFF, timing gaps, or address arbitration before the game accepts a MicroPython device on the physical bus.
|
||||
|
||||
## Confidence
|
||||
|
||||
High confidence:
|
||||
|
||||
- Command `0xfc` supplies 16 bytes used for the key calculation.
|
||||
- The key calculation is XOR of four little-endian dwords masked with `0x7fffffff`.
|
||||
- Logical topper node device ID is `10`.
|
||||
- Calibration block predicate and checksum are recovered.
|
||||
- The implemented node response checksum/status shape matches `FUN_00a21a20`.
|
||||
|
||||
Moderate confidence:
|
||||
|
||||
- The implemented no-op handling is enough for startup/provisioning if identity fields match.
|
||||
- Returning zero in the second status word for command `0xff` avoids the generic unsigned/problem bit paths.
|
||||
|
||||
Low confidence until hardware-tested:
|
||||
|
||||
- Physical-layer UART settings and direction handling.
|
||||
- Correct `IDENTITY_BOARD_PART_ID` for a live Magikarp topper board.
|
||||
116
update-and-recovery.md
Normal file
116
update-and-recovery.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Update and Recovery Flow
|
||||
|
||||
## Shared Paths
|
||||
|
||||
`/etc/init.d/shared` defines the main update and persistence paths:
|
||||
|
||||
| Variable | Path |
|
||||
| --- | --- |
|
||||
| `GAMES_PATH` | `/games` |
|
||||
| `SPIKE_MENU_PATH` | `/games/spike3/spike_menu` |
|
||||
| `OS_UPDATE_PATH` | `/connectivity/os_update` |
|
||||
| `DOWNLOADED_SPK_PATH` | `/connectivity/update` |
|
||||
| `MACHINE_REBOOTS_PATH` | `/connectivity/machine_reboots` |
|
||||
| `CORE_FILE_PATH` | `/connectivity/dump/cores` |
|
||||
| `GAME_RESTARTS_PATH` | `/tmp/game_restarts` |
|
||||
| `DOWNLOADED_SPK_AVAILABLE` | `/connectivity/update/UPDATE_REQUESTED` |
|
||||
| `CONNECTIVITY_CACHE_CLEARED` | `/tmp/connectivity_cache_cleared` |
|
||||
|
||||
## Kernel / Boot Partition Update
|
||||
|
||||
`S95game` checks `/connectivity/os_update` for:
|
||||
|
||||
- `boot.img`
|
||||
- `boot.img.sha1`
|
||||
- `boot.sig`
|
||||
- `boot.sig.sha1`
|
||||
- `config.txt`
|
||||
- `config.txt.sha1`
|
||||
|
||||
If any are present, all must be present. It validates SHA-1 before copying.
|
||||
|
||||
Install behavior:
|
||||
|
||||
1. Mount `/dev/mmcblk0p1` at `/boot`.
|
||||
2. Compare current `/boot` target hash to expected hash.
|
||||
3. Copy file if needed.
|
||||
4. Sync and re-check installed hash.
|
||||
5. Unmount `/boot`.
|
||||
6. Delete staged update files.
|
||||
7. If anything was updated, sync, remount `/data` and `/connectivity` read-only, call `/sbin/reboot -f`, and hang.
|
||||
|
||||
The expected boot partition files are:
|
||||
|
||||
- `/boot/boot.img`
|
||||
- `/boot/boot.sig`
|
||||
- `/boot/config.txt`
|
||||
|
||||
## Post-install Hook
|
||||
|
||||
Before normal startup work, `S95game` runs `/connectivity/os_update/post_install_hook.sh` if present, then deletes it and syncs.
|
||||
|
||||
## Game Software Update
|
||||
|
||||
`/etc/init.d/update` checks two sources for `.spk` update files:
|
||||
|
||||
1. Connectivity-downloaded update files under `/connectivity/update` when `/connectivity/update/UPDATE_REQUESTED` exists.
|
||||
2. USB flash drives mounted from `/dev/sda1` or `/dev/sda` at `/media`.
|
||||
|
||||
Accepted filename patterns:
|
||||
|
||||
- `*.spk` case-insensitive.
|
||||
- `*.spk.NNN.000` case-insensitive extension, with three digits before `.000`.
|
||||
|
||||
When a valid update is found:
|
||||
|
||||
1. Kill `reboot_monitor`, `conagent_monitor`, and `game_monitor`.
|
||||
2. Kill `conagent` and `game`.
|
||||
3. Wait until `pidof game` is empty.
|
||||
4. Start `/etc/init.d/update_monitor`.
|
||||
5. On success path, display an update-complete message and hang for power cycle.
|
||||
|
||||
## Spike Menu Update Monitor
|
||||
|
||||
`/etc/init.d/update_monitor` runs the SPIKE menu update application from tmpfs:
|
||||
|
||||
1. Deletes `/tmp/spike_menu`.
|
||||
2. Copies `/games/spike3/spike_menu/game`, `image.bin`, and `assets` to `/tmp/spike_menu`.
|
||||
3. Runs `/tmp/spike_menu/game`.
|
||||
4. Re-runs while `/tmp/spike_menu.restart` exists.
|
||||
5. Deletes `/tmp/spike_menu` on exit.
|
||||
|
||||
The tmpfs copy is intentional because an update may overwrite `image.bin` while it is memory-mapped by the update process.
|
||||
|
||||
## Connectivity Cache Recovery
|
||||
|
||||
`clear_connectivity_cache` in `/etc/init.d/shared` deletes:
|
||||
|
||||
- `/connectivity/cache`
|
||||
- `/connectivity/files`
|
||||
- `/connectivity/media_overlay`
|
||||
|
||||
It then touches `/tmp/connectivity_cache_cleared`.
|
||||
|
||||
The comments identify additional intended `/connectivity` content:
|
||||
|
||||
- ALSA and Bluetooth persistent state.
|
||||
- IC metadata and media caches.
|
||||
- QR code storage.
|
||||
- Net game save state/playback under `/connectivity/files/gkpd_4`.
|
||||
- Connectivity software download cache.
|
||||
- Sentinels such as `UPDATE_REQUESTED`, `UPDATE_COMPLETED`, and `.swdl_tod`.
|
||||
|
||||
## Core Dumps
|
||||
|
||||
Startup creates `/connectivity/dump/cores`, prunes stale `.7z` files, and keeps either:
|
||||
|
||||
- No core files if `/connectivity` is more than 90% full.
|
||||
- Otherwise, at most 3 newest core files.
|
||||
|
||||
Core pattern:
|
||||
|
||||
```text
|
||||
/connectivity/dump/cores/core.%h.%t
|
||||
```
|
||||
|
||||
The export helper `copy_newest_corefile_to_usb.sh` encrypts the newest core with `7za` and a passphrase file, then copies the encrypted archive to a USB drive.
|
||||
782
viewer.html
Normal file
782
viewer.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user