Compare commits

..
17 Commits
Author SHA1 Message Date
vii a3a2b76a06 feat(update): add an evidence-scoring solver for HostAbi addresses with a check command 2026-10-15 01:49:19 -05:00
vii f333399e18 test(update): add a 2908 to 3222 regression set for the HostAbi deriver
Share the input checks and FMOD slot tables so the solver reuses them.
2026-10-15 01:49:19 -05:00
vii 3062909209 refactor(jnivm): resolve method names once and log calls with no handler
GetMethodID maps each name to a JniMethod enum, so dispatch compares
numbers instead of strings. Calls that reach no handler print one
"no handler for" line per class, method and signature. Unknown
gameActivity_ callbacks are recorded on the CallVoidMethod path too.
The FMOD write path reuses a per-thread buffer instead of allocating.
2026-10-15 01:31:02 -05:00
vii 049784d0f8 fix(docs): restore the screenshots at full quality with transparency 2026-10-15 01:31:02 -05:00
vii ccf910f064 chore(ci): keep the signing and AUR keys in a release environment
The release environment only accepts main and v* tags, so a workflow pushed on another branch cannot read the keys. Flatpak builds that do not publish run without it.
2026-10-15 01:26:27 -05:00
vii 23e7831149 fix(site): keep the screenshots transparent
The first retake was flattened to RGB, which turned the transparent window edges black.
2026-10-13 21:34:07 -05:00
vii 5b2ee2c7f3 docs(site): rewrite the description and feature list, add SEO tags, refresh screenshots
New intro and feature list in the README and the site. The site gets a title with Roblox, link preview tags, structured data, a sitemap, robots.txt, a preview image and the Search Console tag. Screenshots are retaken, the old lobby shot is removed and the store listing points at the new ones.
2026-10-13 21:29:12 -05:00
vii 63ed62b4aa docs(faq): point the VR answer at Nightcap's vr branch 2026-10-13 20:21:33 -05:00
vii 81de528668 feat(packaging): publish nightcap and nightcap-bin to the AUR on release
Pushes nothing until the AUR_SSH_PRIVATE_KEY secret is set. Recipes move to 0.3.4.
2026-10-12 01:44:21 -05:00
vii ff81882cb0 docs: point Arch users to nightcap-git on the AUR
The package is maintained by @dim-ghub.
2026-10-12 01:44:21 -05:00
vii a87373d003 feat(assets): let the overlay replace files Roblox downloads
Roblox maps some rbxasset:// names to downloads and opens no local file for
them. For each file under content/ in the overlay folder, change its name in
the loaded libroblox so Roblox opens the local file.

Idea from @dim-ghub in #20.
2026-10-12 01:20:13 -05:00
vii b46098c211 feat(assets): trace asset lookups that find no file 2026-10-12 01:20:13 -05:00
vii 144350090e docs: describe the asset overlay folder 2026-10-11 23:58:26 -05:00
vii 3ed0418997 refactor(assets): share the asset lookup cache insert 2026-10-11 23:58:26 -05:00
vii 8410070eec Merge pull request #19 from dim-ghub/main
Add Sober-compatible asset overlay system
2026-10-11 23:56:58 -05:00
dim-ghub a1166fb6ba Add Sober-compatible asset overlay system 2026-10-11 23:11:54 -05:00
vii c9eb4fb7f0 docs(changelog): credit @qutad for the anti-aliasing fix 2026-10-10 22:02:40 -05:00
49 changed files with 4650 additions and 787 deletions

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
name: Publish AUR packages
on:
workflow_dispatch:
inputs:
tag:
description: Release tag to publish, such as v0.3.4
required: true
type: string
workflow_call:
inputs:
tag:
required: true
type: string
secrets:
AUR_SSH_PRIVATE_KEY:
required: false
permissions:
contents: read
concurrency:
group: aur-publish
cancel-in-progress: false
jobs:
publish:
name: Publish nightcap and nightcap-bin
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: release
steps:
- name: Check out the release
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag }}
persist-credentials: false
- name: Render the recipes
shell: bash
env:
TAG: ${{ inputs.tag }}
run: |
set -Eeuo pipefail
[[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)*$ ]]
version="${TAG#v}"
archive="nightcap-${version}-1-x86_64.pkg.tar.zst"
# nightcap-bin installs the pacman package from the release page, so
# its checksum is the one published beside that asset.
checksum="$(curl -fsSL \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${archive}.sha256" |
awk '{print $1}')"
[[ "$checksum" =~ ^[0-9a-f]{64}$ ]]
sed -i "s/^pkgver=.*/pkgver=${version}/" \
packaging/aur/nightcap/PKGBUILD packaging/aur/nightcap-bin/PKGBUILD
sed -i "s/^sha256sums=('SKIP')$/sha256sums=('${checksum}')/" \
packaging/aur/nightcap-bin/PKGBUILD
grep -Fqx "sha256sums=('${checksum}')" packaging/aur/nightcap-bin/PKGBUILD
docker run --rm \
--volume "$GITHUB_WORKSPACE/packaging/aur:/aur" \
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
useradd --create-home builder
for package in nightcap nightcap-bin; do
install -d -o builder -g builder "/tmp/${package}"
install -o builder -g builder -m 0644 \
"/aur/${package}/PKGBUILD" "/tmp/${package}/PKGBUILD"
runuser -u builder -- bash -c \
"cd /tmp/${package} && makepkg --printsrcinfo" \
> "/aur/${package}/.SRCINFO"
done
'
for package in nightcap nightcap-bin; do
grep -Fqx "pkgver=${version}" "packaging/aur/${package}/PKGBUILD"
grep -Fqx $'\tpkgver = '"${version}" "packaging/aur/${package}/.SRCINFO"
done
echo "VERSION=${version}" >> "$GITHUB_ENV"
- name: Push to the AUR
shell: bash
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
set -Eeuo pipefail
if [[ -z "${AUR_SSH_PRIVATE_KEY}" ]]; then
echo "::notice::AUR_SSH_PRIVATE_KEY is not set; nothing was pushed to the AUR"
exit 0
fi
ssh_dir="$(mktemp -d)"
trap 'rm -rf "$ssh_dir"' EXIT
install -m 0600 /dev/null "$ssh_dir/key"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$ssh_dir/key"
# The AUR lists this key's fingerprint on its front page:
# SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4
printf '%s\n' \
'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' \
> "$ssh_dir/known_hosts"
export GIT_SSH_COMMAND="ssh -i $ssh_dir/key -o IdentitiesOnly=yes -o UserKnownHostsFile=$ssh_dir/known_hosts -o StrictHostKeyChecking=yes"
for package in nightcap nightcap-bin; do
checkout="$ssh_dir/$package"
git clone "ssh://aur@aur.archlinux.org/${package}.git" "$checkout"
cp "packaging/aur/${package}/PKGBUILD" "packaging/aur/${package}/.SRCINFO" \
"$checkout/"
git -C "$checkout" add PKGBUILD .SRCINFO
if git -C "$checkout" diff --cached --quiet; then
echo "::notice::${package} is already at ${VERSION}"
continue
fi
git -C "$checkout" \
-c user.name='Nightcap release' \
-c user.email='CoderDayton@users.noreply.github.com' \
commit --quiet -m "Update to ${VERSION}"
# The AUR only accepts the master branch.
git -C "$checkout" push origin HEAD:master
done
+3
View File
@@ -38,6 +38,9 @@ jobs:
name: Flatpak build
runs-on: ubuntu-24.04
timeout-minutes: 120
# The signing key lives in the release environment, which only main and
# v* tags may use. Builds that do not publish run without it.
environment: ${{ (startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) && 'release' || '' }}
env:
# Publish on release tags and on manual runs from main. Pull requests
# only verify that the manifest still builds.
+12
View File
@@ -336,3 +336,15 @@ jobs:
--title "Nightcap $tag" --notes-file release/notes.md
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
"${uploads[@]}"
# nightcap-bin downloads the pacman package from the release page, so this
# waits for the upload.
aur:
name: Publish AUR packages
if: startsWith(github.ref, 'refs/tags/v')
needs: publish
uses: ./.github/workflows/aur-publish.yml
with:
tag: ${{ github.ref_name }}
secrets:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
+21 -1
View File
@@ -5,6 +5,26 @@ Release notes on GitHub are taken from the matching section here.
## [Unreleased]
### Added
- An asset overlay folder, `~/.local/share/mocktail/asset_overlay`. A file there replaces the file Roblox
ships at the same path, such as a cursor, a font or a sky texture, and it stays in place when Roblox
updates. The layout matches Sober's `asset_overlay` folder. `MOCKTAIL_ASSET_OVERLAY_DIR` moves it. Thanks to
@dim-ghub.
- The asset overlay folder now also replaces files Roblox downloads and no longer ships, such as the shift
lock and text cursors. Put the file under `content/`, at the path Roblox uses. Thanks to @dim-ghub for the
idea.
- `MOCKTAIL_ASSET_TRACE=1` now also prints the files Roblox asks for and does not get, as `[asset] miss`.
- Arch Linux: `nightcap-git` is on the AUR and builds the current `main` branch. Thanks to @dim-ghub, who
maintains it.
- The log now names each Roblox Java call that Nightcap has no handler for, once per call, as
`[JNI] no handler for <class>.<method><signature>`. This shows what a new Roblox version needs.
### Changed
- Nightcap answers Roblox's Java calls with less work per call, and the audio thread no longer allocates
memory for each block of sound it plays.
## [0.3.4] - 2026-10-11
### Added
@@ -29,7 +49,7 @@ Release notes on GitHub are taken from the matching section here.
- Fixed the old Roblox home screen showing instead of the new one with the Me tab and the "For you" and
"Charts" tabs. Nightcap now runs the Roblox start-up step that loads the new interface for your account.
- Fixed anti-aliasing being forced off at every graphics quality level. Roblox now picks it, and a
`FIntDebugForceMSAASamples` value in `fflags.json` still applies.
`FIntDebugForceMSAASamples` value in `fflags.json` still applies. Thanks to @qutad.
- Fixed the voice chat microphone only working on Roblox 2.738. It no longer depends on one Roblox version.
- Fixed other players not hearing you in voice chat after a Roblox update. The updater now rebuilds the
audio device data when the previous version's profile has none.
+2
View File
@@ -252,6 +252,7 @@ add_library(mocktail_legacy_jnivm STATIC
src/jnivm/jni_references.cc
src/jnivm/jni_fields.cc
src/jnivm/jni_strings.cc
src/jnivm/jni_method_names.cc
)
add_library(Mocktail::LegacyJni ALIAS mocktail_legacy_jnivm)
target_include_directories(mocktail_legacy_jnivm PUBLIC include)
@@ -298,6 +299,7 @@ add_library(mocktail_runtime STATIC
src/runtime/payload_update_preflight.cc
src/runtime/process_launch_policy.cc
src/runtime/process_diagnostics.cc
src/runtime/asset_overlay_names.cc
src/runtime/runtime_config.cc
src/runtime/runtime_config_bootstrap.cc
src/runtime/runtime_config_file.cc
+10 -6
View File
@@ -56,15 +56,14 @@ after Roblox added measures against running multiple clients.
## Can I play in VR?
VR is experimental and lives on upstream's `vr` branch, which does not
include the Nightcap changes. Install the
Yes, as an experiment. VR lives on Nightcap's `vr` branch and is not in the
releases yet. It works on Roblox builds 2998, 3092 and 3222. Install the
[build dependencies](README.md#building), then switch to that branch and
build:
```bash
git remote add upstream https://github.com/komaruworld/mocktail.git
git fetch upstream
git switch -c vr upstream/vr
git fetch origin
git switch vr
git submodule update --init --recursive
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMOCKTAIL_ENABLE_VR=ON
cmake --build build -j4
@@ -73,5 +72,10 @@ cmake --build build -j4
Start WiVRn or SteamVR/ALVR, connect your headset, then run:
```bash
./build/mocktail -vr
./build/mocktail --vr
```
That branch's
[README](https://github.com/CoderDayton/nightcap/blob/vr/README.md#experimental-vr)
and [docs/VR.md](https://github.com/CoderDayton/nightcap/blob/vr/docs/VR.md)
have the details, including how to try it without a headset.
+35 -24
View File
@@ -11,10 +11,12 @@
</h1>
</div>
Nightcap is Mocktail, tuned for playing on a real PC. It runs the Android
`x86_64` Roblox client on Linux, including a Linux userspace hosted by
FreeBSD's Linuxulator. It provides the Android ABI and JNI pieces the client
expects, then connects them to SDL3 and Vulkan or OpenGL on the Linux side.
Nightcap is Roblox on Linux, tuned for desktop play. It is a Mocktail fork
that keeps up with Roblox updates, with extra bug fixes, custom assets and
more. It runs the Android `x86_64` Roblox client on Linux, including a Linux
userspace hosted by FreeBSD's Linuxulator. It provides the Android ABI and JNI
pieces the client expects, then connects them to SDL3 and Vulkan or OpenGL on
the Linux side.
Nightcap is an independent community project. It is not affiliated with Roblox
Corporation or VinegarHQ and does not distribute the Roblox client.
@@ -27,23 +29,25 @@ with extra fixes for playing on a PC. The program and config paths keep the
`io.github.CoderDayton.nightcap`, so Nightcap and upstream can be installed
side by side.
- **Lower input lag.** Frames are shown as soon as they are ready instead of
waiting in a queue.
- **Sharper textures.** Small textures are upscaled 4x by default, and you
can replace any texture with your own PNG in `~/.config/mocktail/textures`.
A ready-made crosshair is in [`textures/`](textures/README.md). Set
`MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn the upscale off.
- **ETC2 textures on any GPU.** Games that use compressed mobile textures now
render correctly on desktop GPUs, without stutter.
- **No more error 319 kicks.** Servers no longer drop you shortly after
joining.
- **Correct device info.** Roblox sees your real RAM and screen size.
- **Better desktop launcher.** Runs on Wayland and always finds the installed
binary.
- **Keeps up with Roblox.** New Roblox versions work soon after they come out.
- **Plays more games.** Nightcap answers a server check that some experiences
use, so you can join and stay in those too.
- **Voice chat that keeps working.** Your microphone still works after a
Roblox update.
- **Logging in works.** Verification checks and captchas finish instead of
hanging.
- **The current Roblox look.** The new home screen and side menu, like on
Windows.
- **Your own cursors, fonts and skies.** Drop files in
`~/.local/share/mocktail/asset_overlay` and Nightcap uses them. The layout
matches Sober's `asset_overlay` folder.
- **Smoother mouse and a cooler PC.** Optional raw mouse input, and less CPU
use while idle.
Everything else works the same as upstream. See the
[roadmap](docs/ROADMAP.md) for what is coming next, the
[changelog](CHANGELOG.md) for what each release changed,
Everything else works the same as upstream. See
[`textures/`](textures/README.md) for custom textures, a ready-made crosshair
and the asset overlay folder, the [roadmap](docs/ROADMAP.md) for what is
coming next, the [changelog](CHANGELOG.md) for what each release changed,
[performance](docs/PERFORMANCE.md) for CPU load, heat and the knobs that change
them, and the [FAQ](FAQ.md) for Discord RPC, logs, and VR.
@@ -80,8 +84,17 @@ sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst
```
They install `/usr/bin/mocktail`, so they conflict with upstream's `mocktail`
package. Nightcap is not in any distribution repository or the AUR; the
recipes under `packaging/aur` are kept for local builds only.
package.
On Arch Linux, [`nightcap-git`](https://aur.archlinux.org/packages/nightcap-git)
is on the AUR. It builds the current `main` branch and is maintained by
[@dim-ghub](https://github.com/dim-ghub):
```bash
yay -S nightcap-git
```
Nightcap is in no other distribution repository.
Or build and install from source using the steps under
[Building](#building).
@@ -109,8 +122,6 @@ removing the marker.
![Roblox gameplay in Nightcap](assets/screenshots/flatpak-gameplay-tower.png)
![Roblox experience in Nightcap](assets/screenshots/flatpak-gameplay-lobby.png)
</details>
## FFlag overrides
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1009 KiB

+27
View File
@@ -0,0 +1,27 @@
#ifndef MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
#define MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace mocktail {
namespace runtime {
// The rbxasset:// name of every regular file under <overlay_dir>/content, such
// as rbxasset://textures/MouseLockedCursor.png. Sorted.
std::vector<std::string> OverlayRbxAssetNames(
const std::filesystem::path& overlay_dir);
// Roblox opens no local file for an rbxasset:// name it maps to a download.
// Writes '!' over the first byte of each whole NUL-terminated copy of a name
// in `data`, which unmaps it. Returns the number of copies changed.
std::size_t DisableAssetNameMappings(std::uint8_t* data, std::size_t size,
const std::vector<std::string>& names);
}
}
#endif
+4
View File
@@ -67,6 +67,9 @@ class RuntimePaths {
return active_payload_manifest_;
}
const std::filesystem::path& cookie_file() const { return cookie_file_; }
const std::filesystem::path& asset_overlay_dir() const {
return asset_overlay_dir_;
}
bool use_real_sober_paths() const { return use_real_sober_paths_; }
std::filesystem::path DefaultAssetPath() const;
@@ -99,6 +102,7 @@ class RuntimePaths {
std::filesystem::path config_file_;
std::filesystem::path active_payload_manifest_;
std::filesystem::path cookie_file_;
std::filesystem::path asset_overlay_dir_;
bool use_real_sober_paths_ = false;
};
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: vii <coderdayton14@gmail.com>
pkgname=nightcap
pkgver=0.3.1
pkgver=0.3.4
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
+3 -3
View File
@@ -1,6 +1,6 @@
pkgbase = nightcap-bin
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
pkgver = 0.3.1
pkgver = 0.3.4
pkgrel = 1
url = https://github.com/CoderDayton/nightcap
arch = x86_64
@@ -34,10 +34,10 @@ pkgbase = nightcap-bin
conflicts = mocktail
conflicts = nightcap
conflicts = nightcap-git
noextract = nightcap-0.3.1-1-x86_64.pkg.tar.zst
noextract = nightcap-0.3.4-1-x86_64.pkg.tar.zst
options = !debug
options = !strip
source = nightcap-0.3.1-1-x86_64.pkg.tar.zst::https://github.com/CoderDayton/nightcap/releases/download/v0.3.1/nightcap-0.3.1-1-x86_64.pkg.tar.zst
source = nightcap-0.3.4-1-x86_64.pkg.tar.zst::https://github.com/CoderDayton/nightcap/releases/download/v0.3.4/nightcap-0.3.4-1-x86_64.pkg.tar.zst
sha256sums = SKIP
pkgname = nightcap-bin
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=nightcap-bin
pkgver=0.3.1
pkgver=0.3.4
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
arch=('x86_64')
+2 -2
View File
@@ -1,6 +1,6 @@
pkgbase = nightcap
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 0.3.1
pkgver = 0.3.4
pkgrel = 1
url = https://github.com/CoderDayton/nightcap
arch = x86_64
@@ -42,7 +42,7 @@ pkgbase = nightcap
conflicts = nightcap-git
options = !debug
options = !lto
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.1
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#tag=v0.3.4
sha256sums = SKIP
pkgname = nightcap
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=nightcap
pkgver=0.3.1
pkgver=0.3.4
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
@@ -28,16 +28,12 @@
</description>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-home.png</image>
<image>https://raw.githubusercontent.com/CoderDayton/nightcap/main/assets/screenshots/flatpak-home.png</image>
<caption>Browse Roblox experiences</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-tower.png</image>
<caption>Play Tower of Hell on Linux</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-lobby.png</image>
<caption>Play DOORS on Linux</caption>
<image>https://raw.githubusercontent.com/CoderDayton/nightcap/main/assets/screenshots/flatpak-gameplay-tower.png</image>
<caption>Play Murder Mystery 2 on Linux</caption>
</screenshot>
</screenshots>
<launchable type="desktop-id">io.github.CoderDayton.nightcap.desktop</launchable>
+18 -1
View File
@@ -62,7 +62,7 @@ Branch=stable
Url=${BASE_URL}/repo/
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
Homepage=https://github.com/CoderDayton/nightcap
Comment=Roblox on Linux, tuned for a real PC
Comment=Roblox on Linux, tuned for desktop play
Icon=${BASE_URL}/nightcap.svg
GPGKey=${GPG_KEY}
IsRuntime=false
@@ -72,3 +72,20 @@ install -m 0644 -- "${PROJECT_DIR}/site/index.html" "${OUTPUT}/index.html"
install -m 0644 -- "${PROJECT_DIR}/site/styles.css" "${OUTPUT}/styles.css"
install -m 0644 -- "${PROJECT_DIR}/site/terms.html" "${OUTPUT}/terms.html"
install -m 0644 -- "${PROJECT_DIR}/site/privacy.html" "${OUTPUT}/privacy.html"
install -m 0644 -- "${PROJECT_DIR}/site/preview.png" "${OUTPUT}/preview.png"
cat >"${OUTPUT}/robots.txt" <<EOF
User-agent: *
Allow: /
Sitemap: ${BASE_URL}/sitemap.xml
EOF
cat >"${OUTPUT}/sitemap.xml" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>${BASE_URL}/</loc></url>
<url><loc>${BASE_URL}/terms.html</loc></url>
<url><loc>${BASE_URL}/privacy.html</loc></url>
</urlset>
EOF
+62 -42
View File
@@ -1356,6 +1356,35 @@ def has_fmod_device_lists_select_contract(image: ElfImage, rva: int) -> bool:
)
FMOD_OUTPUT_FIELDS = frozenset({
"vtable_rva",
"string_constructor_rva",
"count_method_rva",
"info_method_rva",
"current_method_rva",
"select_method_rva",
})
FMOD_INPUT_FIELDS = (
"input_count_method_rva",
"input_info_method_rva",
"input_current_method_rva",
"input_select_method_rva",
)
def fmod_vtable_slots(layout: int, with_input: bool) -> dict[str, int]:
"""Vtable slot of each FMOD method field in vtable layout 1 or 2."""
slots = {
"count_method_rva": 5,
"info_method_rva": 6,
"current_method_rva": 8 if layout == 2 else 7,
"select_method_rva": 19 if layout == 2 else 17,
}
if with_input:
slots.update(zip(FMOD_INPUT_FIELDS, (9, 10, 12, 18) if layout == 2 else (8, 9, 10, 16)))
return slots
def load_reference_runtime_profile(
paths: Sequence[Path], reference_build_id: str
) -> dict[str, Any]:
@@ -1381,21 +1410,11 @@ def load_reference_runtime_profile(
)
if "fmod_output_device_bridge" in profile:
bridge = profile["fmod_output_device_bridge"]
fields = {
"vtable_rva",
"string_constructor_rva",
"count_method_rva",
"info_method_rva",
"current_method_rva",
"select_method_rva",
}
if (
not isinstance(bridge, dict)
or not fields <= set(bridge)
or set(bridge) - fields - {"vtable_layout_version"} - {
"input_count_method_rva", "input_info_method_rva",
"input_current_method_rva", "input_select_method_rva",
}
or not FMOD_OUTPUT_FIELDS <= set(bridge)
or set(bridge) - FMOD_OUTPUT_FIELDS - {"vtable_layout_version"}
- set(FMOD_INPUT_FIELDS)
or profile.get("allow_host_abi_bridges") is not True
):
raise AnalyzerError(
@@ -1415,10 +1434,7 @@ def load_reference_runtime_profile(
if field in result:
previous = result[field]
if field == "fmod_output_device_bridge":
input_fields = {
"input_count_method_rva", "input_info_method_rva",
"input_current_method_rva", "input_select_method_rva",
}
input_fields = set(FMOD_INPUT_FIELDS)
compatible = ((set(previous) ^ set(value)) <= input_fields and
all(previous[key] == value[key]
for key in previous.keys() & value.keys()))
@@ -1440,12 +1456,7 @@ def derive_fmod_output_device_bridge(
source: dict[str, int],
) -> dict[str, Any]:
source_layout = source.get("vtable_layout_version", 1)
indexes = {
"count_method_rva": 5,
"info_method_rva": 6,
"current_method_rva": 8 if source_layout == 2 else 7,
"select_method_rva": 19 if source_layout == 2 else 17,
}
indexes = fmod_vtable_slots(source_layout, False)
source_vtable = source["vtable_rva"]
reference.require_relro_rva(source_vtable, (max(indexes.values()) + 1) * 8)
reference_relocations = relative_relocation_map(reference)
@@ -1530,7 +1541,10 @@ def derive_fmod_output_device_bridge(
continue
vtable = offset - indexes["info_method_rva"] * 8
count_method = relocations.get(vtable + indexes["count_method_rva"] * 8)
for layout, current_index, select_index in ((1, 7, 17), (2, 8, 19)):
for layout in (1, 2):
layout_slots = fmod_vtable_slots(layout, False)
current_index = layout_slots["current_method_rva"]
select_index = layout_slots["select_method_rva"]
current_method = relocations.get(vtable + current_index * 8)
selected = relocations.get(vtable + select_index * 8)
if (
@@ -1568,18 +1582,13 @@ def derive_fmod_output_device_bridge(
}
if layout != 1:
result["vtable_layout_version"] = layout
input_fields = (
"input_count_method_rva", "input_info_method_rva",
"input_current_method_rva", "input_select_method_rva",
)
if any(field in source for field in input_fields):
if not all(field in source for field in input_fields):
if any(field in source for field in FMOD_INPUT_FIELDS):
if not all(field in source for field in FMOD_INPUT_FIELDS):
raise AnalyzerError("reference FMOD input profile is incomplete")
source_slots = (9, 10, 12, 18) if source_layout == 2 else (8, 9, 10, 16)
target_slots = (9, 10, 12, 18) if layout == 2 else (8, 9, 10, 16)
for field, old_slot, new_slot, length in zip(
input_fields, source_slots, target_slots, (4, 16, 12, 16)
):
source_slots = fmod_vtable_slots(source_layout, True)
target_slots = fmod_vtable_slots(layout, True)
for field, length in zip(FMOD_INPUT_FIELDS, (4, 16, 12, 16)):
old_slot, new_slot = source_slots[field], target_slots[field]
if reference_relocations.get(source_vtable + old_slot * 8) != source[field]:
raise AnalyzerError("reference FMOD input slot changed")
target = relocations.get(vtable + new_slot * 8)
@@ -2247,12 +2256,12 @@ def remapped_constructor_ranges(
return validated_ranges(result, "derived constructor ranges", candidate_count)
def derive_profile(
def check_derivation_inputs(
reference: ElfImage,
candidate: ElfImage,
reference_sidecar: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any]]:
require_capstone()
) -> tuple[tuple[int, ...], tuple[int, ...]]:
"""Rejects inputs no derivation may use; returns both init-arrays."""
if (
reference.build_id != reference_sidecar["elf_build_id"]
or reference.sha256 != reference_sidecar["payload_sha256"]
@@ -2270,17 +2279,28 @@ def derive_profile(
"candidate is missing required JNI exports: "
+ ", ".join(sorted(missing_exports))
)
reference_profile = reference_sidecar["profile"]
anchors = reference_sidecar["derivation_anchors"]
reference_init = reference.init_array_relocations()
candidate_init = candidate.init_array_relocations()
if len(reference_init) != reference_profile["init_array_count"]:
if len(reference_init) != reference_sidecar["profile"]["init_array_count"]:
raise AnalyzerError(
"reference sidecar init-array count differs from ELF metadata"
)
if len(candidate_init) < 8:
raise AnalyzerError("candidate init-array is unexpectedly short")
return reference_init, candidate_init
def derive_profile(
reference: ElfImage,
candidate: ElfImage,
reference_sidecar: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any]]:
require_capstone()
reference_init, candidate_init = check_derivation_inputs(
reference, candidate, reference_sidecar
)
reference_profile = reference_sidecar["profile"]
anchors = reference_sidecar["derivation_anchors"]
matches = {}
allocate_matches = ()
for spec in create_signature_specs(reference_profile, anchors):
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
# Copyright 2026 Mocktail Project Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Run the HostAbi deriver and the solver on one update and compare them.
Usage:
python3 roblox_abi_check.py --reference-lib OLD.so \\
--reference-profile OLD_PROFILE.json \\
--reference-compatibility config/roblox_compatibility.json \\
--candidate-lib NEW.so
Prints every field where the two disagree. Exit status: 0 agree, 1 differ or
one side failed, 2 both failed.
"""
from __future__ import annotations
import argparse
import importlib.util
from pathlib import Path
import sys
from typing import Any, Sequence
def _load_solver():
name = "mocktail_roblox_abi_solver"
if name in sys.modules:
return sys.modules[name]
path = Path(__file__).resolve().with_name("roblox_abi_solver.py")
specification = importlib.util.spec_from_file_location(name, path)
if specification is None or specification.loader is None:
raise RuntimeError("cannot load roblox_abi_solver.py")
module = importlib.util.module_from_spec(specification)
sys.modules[name] = module
specification.loader.exec_module(module)
return module
SOLVER = _load_solver()
DERIVER = SOLVER.DERIVER
AnalyzerError = SOLVER.AnalyzerError
format_rva = SOLVER.format_rva
def compare_results(derived: Any, solved: Any, path: str = "") -> list[str]:
"""Returns one line for every leaf value where the two results differ."""
if isinstance(derived, dict) and isinstance(solved, dict):
lines = []
for key in sorted(derived.keys() | solved.keys(), key=str):
child = f"{path}.{key}" if path else str(key)
if key not in solved:
lines.append(f"{child}: deriver {derived[key]}, solver missing")
elif key not in derived:
lines.append(f"{child}: deriver missing, solver {solved[key]}")
else:
lines.extend(compare_results(derived[key], solved[key], child))
return lines
if isinstance(derived, list) and isinstance(solved, list) and len(derived) == len(solved):
return [
line
for index, (left, right) in enumerate(zip(derived, solved))
for line in compare_results(left, right, f"{path}[{index}]")
]
return [] if derived == solved else [f"{path}: deriver {derived}, solver {solved}"]
def check_status(
derived: Any, solved: Any, deriver_error: str | None, solver_error: str | None
) -> tuple[int, list[str]]:
"""Exit status and report lines: 0 agree, 1 differ or one failed, 2 both failed."""
lines = []
if deriver_error is not None:
lines.append(f"deriver failed: {deriver_error}")
if solver_error is not None:
lines.append(f"solver failed: {solver_error}")
if deriver_error is not None and solver_error is not None:
return 2, lines
if deriver_error is not None or solver_error is not None:
return 1, lines
differences = compare_results(derived, solved)
if differences:
return 1, differences
return 0, ["deriver and solver agree on every field"]
def _solver_runtime_source(source: dict[str, Any]) -> dict[str, Any]:
# The deriver's loader returns integers; the solver reads hex strings.
result: dict[str, Any] = {}
for key, value in source.items():
if isinstance(value, dict):
result[key] = {
field: item if field == "vtable_layout_version" else format_rva(item)
for field, item in value.items()
}
else:
result[key] = format_rva(value)
return result
def _describe(error: Exception) -> str:
if isinstance(error, AnalyzerError):
return str(error)
return f"{type(error).__name__}: {error}"
def check(arguments: argparse.Namespace) -> int:
"""Runs the deriver and the solver on one update and compares them."""
sidecar = DERIVER.validated_sidecar(Path(arguments.reference_profile))
manifests = [Path(path) for path in arguments.reference_compatibility]
cache_dir = Path(arguments.cache_dir) if arguments.cache_dir else None
derived = solved = deriver_error = solver_error = None
with DERIVER.ElfImage(Path(arguments.reference_lib)) as reference, DERIVER.ElfImage(
Path(arguments.candidate_lib)
) as candidate:
try:
profile, anchors = DERIVER.derive_profile(reference, candidate, sidecar)
runtime = DERIVER.derive_runtime_compatibility(reference, candidate, manifests)
derived = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
except Exception as error:
deriver_error = _describe(error)
try:
# The input checks are cheap; indexing both libraries is not.
SOLVER.check_inputs(reference, candidate, sidecar)
solver = SOLVER.Solver(reference, candidate, cache_dir)
profile, anchors = solver.derive_profile(sidecar)
source = DERIVER.load_reference_runtime_profile(manifests, reference.build_id)
runtime = solver.derive_runtime_compatibility(_solver_runtime_source(source))
solved = {"profile": profile, "derivation_anchors": anchors, "runtime": runtime}
except Exception as error:
solver_error = _describe(error)
status, lines = check_status(derived, solved, deriver_error, solver_error)
for line in lines:
print(line)
return status
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Run the HostAbi deriver and the solver on one update and compare them"
)
parser.add_argument("--reference-lib", required=True, metavar="REF")
parser.add_argument("--reference-profile", required=True, metavar="REFJSON")
parser.add_argument(
"--reference-compatibility", action="append", default=[], metavar="MANIFEST"
)
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
parser.add_argument(
"--cache-dir",
default=str(Path.home() / ".cache" / "mocktail" / "function_index"),
help="where function indexes are kept; empty to disable",
)
arguments = parser.parse_args(sys.argv[1:] if argv is None else argv)
try:
return check(arguments)
except AnalyzerError as error:
print(f"roblox-abi-check: error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
# Copyright 2026 Mocktail Project Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Evidence-scoring solver that finds HostAbi addresses in a new libroblox.
Each function in both builds is indexed by `roblox_function_index.py`. A
reference function is matched through independent clues:
- the same instruction shape, unique in both builds
- callers and callees that are already matched
- the strings it references
- its data references, shifted by the distance mapped for those addresses
- its place between matched neighbours
A match is accepted only with a score of at least ACCEPT_SCORE and a lead of
at least ACCEPT_LEAD over the runner-up. `roblox_abi_targets.py` turns the
matches into a profile with the same shape as the one
`derive_roblox_host_abi_profile.py` writes, and `roblox_abi_check.py`
compares the two on one update.
"""
from __future__ import annotations
import bisect
import collections
from dataclasses import dataclass
import difflib
import importlib.util
from pathlib import Path
import sys
from typing import Any, Callable, Sequence
def _load_sibling(name: str, file_name: str):
if name in sys.modules:
return sys.modules[name]
path = Path(__file__).resolve().with_name(file_name)
specification = importlib.util.spec_from_file_location(name, path)
if specification is None or specification.loader is None:
raise RuntimeError(f"cannot load {file_name}")
module = importlib.util.module_from_spec(specification)
sys.modules[name] = module
specification.loader.exec_module(module)
return module
INDEX = _load_sibling("mocktail_roblox_function_index", "roblox_function_index.py")
TARGETS = _load_sibling("mocktail_roblox_abi_targets", "roblox_abi_targets.py")
DERIVER = INDEX.DERIVER
FunctionIndex = INDEX.FunctionIndex
Instruction = INDEX.Instruction
AnalyzerError = DERIVER.AnalyzerError
format_rva = DERIVER.format_rva
check_inputs = TARGETS.check_inputs
validate_fmod_source = TARGETS.validate_fmod_source
ACCEPT_SCORE = 0.6
ACCEPT_LEAD = 0.2
# Candidates that get the full, disassembling score after the cheap one.
FULL_SCORE_LIMIT = 64
# A data address used by more functions than this is too common to narrow
# the candidates by itself.
XREF_FANOUT_LIMIT = 64
# Scores are float sums; a lead that equals ACCEPT_LEAD on paper may be
# a few ulps short.
_TOLERANCE = 1e-9
def choose_candidate(
scored: Sequence[tuple[float, int]], tie_breaker: int | None = None
) -> int | None:
"""Returns the leading candidate, or None when no lead is clear.
`tie_breaker` picks among the candidates that clear ACCEPT_SCORE and sit
within ACCEPT_LEAD of the best; it cannot lift or overrule any other.
"""
if not scored:
return None
ranked = sorted(scored, key=lambda item: item[0], reverse=True)
best_score, best = ranked[0]
runner_up = ranked[1][0] if len(ranked) > 1 else 0.0
if best_score >= ACCEPT_SCORE and best_score - runner_up >= ACCEPT_LEAD - _TOLERANCE:
return best
tied = {
rva for value, rva in ranked
if value >= ACCEPT_SCORE and best_score - value < ACCEPT_LEAD - _TOLERANCE
}
return tie_breaker if tie_breaker in tied else None
def align_instructions(
reference_shapes: Sequence[str], candidate_shapes: Sequence[str]
) -> dict[int, int]:
"""Maps reference instruction indexes to candidate indexes of equal shape."""
matcher = difflib.SequenceMatcher(
None, reference_shapes, candidate_shapes, autojunk=False
)
mapping = {}
for block in matcher.get_matching_blocks():
for offset in range(block.size):
mapping[block.a + offset] = block.b + offset
return mapping
@dataclass(frozen=True)
class Match:
rva: int
score: float
lead: float
# "score" when the score alone decided, "vtable" when a slot broke a tie.
decided_by: str
def _share(expected: Sequence[Any], present: set[Any]) -> float | None:
if not expected:
return None
return sum(1 for item in expected if item in present) / len(expected)
def _weighted(parts: Sequence[tuple[float | None, float]]) -> float:
present = [(value, weight) for value, weight in parts if value is not None]
return sum(value * weight for value, weight in present) / sum(
weight for _, weight in present
)
class Solver(TARGETS.TargetDerivation):
def __init__(self, reference: Any, candidate: Any, cache_dir: Path | None = None):
self._start(FunctionIndex(reference, cache_dir), FunctionIndex(candidate, cache_dir))
@classmethod
def from_indexes(cls, reference: Any, candidate: Any) -> "Solver":
solver = cls.__new__(cls)
solver._start(reference, candidate)
return solver
def _start(self, reference: Any, candidate: Any) -> None:
self.reference = reference
self.candidate = candidate
self.pairs: dict[int, int] = {}
self._owners: dict[int, int] = {}
for start, other in self._shape_anchors().items():
self._pair(start, other)
self._propagate_calls()
self.data_map = self._map_data_references()
self.matches: dict[str, Match] = {}
self._claimed: dict[int, int] = {}
def _pair(self, start: int, other: int) -> None:
"""Pairs two functions, dropping any pair that held either one."""
owner = self._owners.get(other)
if owner is not None and owner != start:
del self.pairs[owner]
previous = self.pairs.get(start)
if previous is not None and previous != other:
del self._owners[previous]
self.pairs[start] = other
self._owners[other] = start
def _shape_anchors(self) -> dict[int, int]:
reference_hashes = collections.defaultdict(list)
candidate_hashes = collections.defaultdict(list)
for start, info in self.reference.functions.items():
reference_hashes[info.shape_hash].append(start)
for start, info in self.candidate.functions.items():
candidate_hashes[info.shape_hash].append(start)
pairs = {}
for shape_hash, starts in reference_hashes.items():
others = candidate_hashes.get(shape_hash, ())
if len(starts) == 1 and len(others) == 1:
pairs[starts[0]] = others[0]
return pairs
def _propagate_calls(self) -> None:
# Matched functions with as many calls pair their callees in order.
reference, candidate = self.reference.functions, self.candidate.functions
pending = list(self.pairs.items())
while pending:
discovered = []
for reference_start, candidate_start in pending:
reference_calls = reference[reference_start].calls
candidate_calls = candidate[candidate_start].calls
if len(reference_calls) != len(candidate_calls):
continue
for callee, other in zip(reference_calls, candidate_calls):
if (
callee in self.pairs
or other in self._owners
or callee not in reference
or other not in candidate
):
continue
size = reference[callee].size
if abs(size - candidate[other].size) <= max(16, size // 4):
self._pair(callee, other)
discovered.append((callee, other))
pending = discovered
def _map_data_references(self) -> dict[int, int]:
votes: dict[int, collections.Counter] = collections.defaultdict(collections.Counter)
for reference_start, candidate_start in self.pairs.items():
reference_refs = self.reference.functions[reference_start].refs
candidate_refs = self.candidate.functions[candidate_start].refs
if len(reference_refs) == len(candidate_refs):
for reference_target, candidate_target in zip(reference_refs, candidate_refs):
votes[reference_target][candidate_target] += 1
return {target: counter.most_common(1)[0][0] for target, counter in votes.items()}
def _gap_candidates(self, start: int) -> list[int]:
starts = self.reference.starts
index = bisect.bisect_left(starts, start)
below = next(
(self.pairs[starts[i]] for i in range(index - 1, -1, -1) if starts[i] in self.pairs),
self.candidate.text.address,
)
above = next(
(self.pairs[starts[i]] for i in range(index + 1, len(starts)) if starts[i] in self.pairs),
self.candidate.text.address + self.candidate.text.size,
)
if above <= below:
return []
low = bisect.bisect_left(self.candidate.starts, below)
high = bisect.bisect_right(self.candidate.starts, above)
return self.candidate.starts[low:high]
def _neighbour_candidates(self, start: int) -> set[int]:
found = set()
for callee in self.reference.functions[start].calls:
if callee in self.pairs:
found.update(self.candidate.callers.get(self.pairs[callee], ()))
for caller in self.reference.callers.get(start, ()):
if caller in self.pairs:
found.update(
target
for target in self.candidate.functions[self.pairs[caller]].calls
if target in self.candidate.functions
)
return found
def _pattern_candidates(self, start: int) -> set[int]:
# Data a function touches usually moves as one block, so its
# references shifted by a mapped distance find it in the candidate.
refs = set(self.reference.functions[start].refs)
distances = collections.Counter(
self.data_map[target] - target for target in refs if target in self.data_map
)
# A common address adds a vote but cannot bring in a candidate alone.
votes = collections.Counter()
found_by_rare = set()
for target in refs:
users = set()
for distance, _ in distances.most_common(3):
found = self.candidate.data_users.get(target + distance, ())
users.update(found)
if len(found) <= XREF_FANOUT_LIMIT:
found_by_rare.update(found)
votes.update(users)
needed = max(1, len(refs) // 2)
return {user for user in found_by_rare if votes[user] >= needed}
def _graph_score(self, start: int, other: int) -> float:
reference = self.reference.functions[start]
candidate = self.candidate.functions[other]
size = min(reference.size, candidate.size) / max(reference.size, candidate.size)
calls = [self.pairs[c] for c in reference.calls if c in self.pairs]
callers = [self.pairs[c] for c in self.reference.callers.get(start, ()) if c in self.pairs]
data = [self.data_map[t] for t in reference.refs if t in self.data_map]
return _weighted(
(
(size, 1.0),
(_share(calls, set(candidate.calls)), 2.0),
(_share(callers, set(self.candidate.callers.get(other, ()))), 2.0),
(_share(data, set(candidate.refs)), 2.0),
)
)
def _full_score(self, start: int, other: int, graph: float) -> float:
similarity = difflib.SequenceMatcher(
None, self.reference.shapes(start), self.candidate.shapes(other), autojunk=False
).ratio()
strings = [
text
for text in map(self.reference.string_at, self.reference.functions[start].refs)
if text is not None
]
candidate_strings = set(
map(self.candidate.string_at, self.candidate.functions[other].refs)
)
return _weighted(
(
(similarity, 3.0),
(_share(strings, candidate_strings), 2.0),
(graph, 5.0),
)
)
def match_function(
self,
name: str,
start: int,
contract: Callable[[Any, int], bool] | None = None,
vtable_slot: int | None = None,
) -> int:
"""Matches one reference function.
A contract drops candidates outright. `vtable_slot` is the candidate
that sits in the matching slot of an already located vtable; it only
breaks a tie between candidates that pass on their own evidence.
"""
if start not in self.reference.functions:
raise AnalyzerError(f"reference {name} is not a function start")
if name in self.matches:
return self.matches[name].rva
candidates = set(self._gap_candidates(start))
candidates |= self._neighbour_candidates(start)
candidates |= self._pattern_candidates(start)
if start in self.pairs:
candidates.add(self.pairs[start])
if vtable_slot in self.candidate.functions:
candidates.add(vtable_slot)
size = self.reference.functions[start].size
candidates = {
other
for other in candidates
if self._claimed.get(other, start) == start
and self.candidate.functions[other].size <= 8 * size + 64
and (contract is None or contract(self.candidate.image, other))
}
graph = {other: self._graph_score(start, other) for other in candidates}
shortlist = sorted(candidates, key=graph.__getitem__, reverse=True)[:FULL_SCORE_LIMIT]
if vtable_slot in candidates and vtable_slot not in shortlist:
shortlist.append(vtable_slot)
scored = [(self._full_score(start, other, graph[other]), other) for other in shortlist]
chosen = choose_candidate(scored)
decided_by = "score"
if chosen is None and vtable_slot is not None:
chosen = choose_candidate(scored, tie_breaker=vtable_slot)
decided_by = "vtable"
if chosen is None:
ranked = sorted(scored, reverse=True)[:2]
raise AnalyzerError(
f"{name} has no clear candidate: "
+ ", ".join(f"{format_rva(rva)}={value:.2f}" for value, rva in ranked)
)
chosen_score = next(value for value, other in scored if other == chosen)
runner_up = max((value for value, other in scored if other != chosen), default=0.0)
self.matches[name] = Match(chosen, chosen_score, chosen_score - runner_up, decided_by)
self._claimed[chosen] = start
self._pair(start, chosen)
return chosen
def _aligned(self, name: str, start: int) -> tuple[list[Instruction], list[Instruction], dict[int, int]]:
other = self.match_function(name, start)
reference = self.reference.instructions(start)
candidate = self.candidate.instructions(other)
mapping = align_instructions(
[item.shape for item in reference], [item.shape for item in candidate]
)
return reference, candidate, mapping
def map_instruction(self, name: str, rva: int) -> int:
start = self.reference.containing(rva)
if start is None:
raise AnalyzerError(f"reference {name} is outside every function")
reference, candidate, mapping = self._aligned(f"{name} function", start)
index = next((i for i, item in enumerate(reference) if item.address == rva), None)
if index is None or index not in mapping:
raise AnalyzerError(f"{name} instruction has no counterpart")
return candidate[mapping[index]].address
def map_data(self, name: str, function_name: str, start: int, target: int) -> int:
reference, candidate, mapping = self._aligned(function_name, start)
found = set()
for index, item in enumerate(reference):
if target not in item.rip_targets:
continue
if index not in mapping:
raise AnalyzerError(f"{name} reference instruction has no counterpart")
other = candidate[mapping[index]]
if len(other.rip_targets) != len(item.rip_targets):
raise AnalyzerError(f"{name} RIP-relative operand count changed")
found.update(
other.rip_targets[position]
for position, value in enumerate(item.rip_targets)
if value == target
)
if len(found) != 1:
raise AnalyzerError(f"{name} has no unique mapped data address")
return found.pop()
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
# Copyright 2026 Mocktail Project Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""HostAbi targets that `roblox_abi_solver.py` derives from its matches.
`TargetDerivation` turns matched functions into the HostAbi profile, the
derivation anchors and the runtime compatibility block, in the shape that
`derive_roblox_host_abi_profile.py` writes. It reuses the deriver's input
checks, contracts and post-processing.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
from typing import Any, Callable
def _load_index():
name = "mocktail_roblox_function_index"
if name in sys.modules:
return sys.modules[name]
path = Path(__file__).resolve().with_name("roblox_function_index.py")
specification = importlib.util.spec_from_file_location(name, path)
if specification is None or specification.loader is None:
raise RuntimeError("cannot load roblox_function_index.py")
module = importlib.util.module_from_spec(specification)
sys.modules[name] = module
specification.loader.exec_module(module)
return module
DERIVER = _load_index().DERIVER
AnalyzerError = DERIVER.AnalyzerError
format_rva = DERIVER.format_rva
FMOD_OUTPUT_FIELDS = DERIVER.FMOD_OUTPUT_FIELDS
FMOD_INPUT_FIELDS = DERIVER.FMOD_INPUT_FIELDS
fmod_slots = DERIVER.fmod_vtable_slots
def check_inputs(reference: Any, candidate: Any, reference_sidecar: dict[str, Any]) -> None:
"""Applies the deriver's identity and preflight checks to both builds."""
DERIVER.check_derivation_inputs(reference, candidate, reference_sidecar)
def validate_fmod_source(source: Any) -> int:
"""Checks a reference FMOD bridge the way the deriver does; returns its layout."""
if (
not isinstance(source, dict)
or not FMOD_OUTPUT_FIELDS <= source.keys()
or source.keys() - FMOD_OUTPUT_FIELDS - set(FMOD_INPUT_FIELDS) - {"vtable_layout_version"}
):
raise AnalyzerError("reference FMOD output-device profile is incomplete")
present = [field for field in FMOD_INPUT_FIELDS if field in source]
if present and len(present) != len(FMOD_INPUT_FIELDS):
raise AnalyzerError("reference FMOD input profile is incomplete")
layout = source.get("vtable_layout_version", 1)
if type(layout) is not int or layout not in (1, 2):
raise AnalyzerError("reference FMOD vtable layout is unsupported")
return layout
class TargetDerivation:
"""Mixin for `Solver`: needs `reference`, `candidate`, `matches`,
`match_function`, `map_data` and `map_instruction`."""
def _check_registry_allocator(
self,
reference_registry: int,
candidate_registry: int,
reference_allocate: int,
candidate_allocate: int,
) -> None:
# The registry initializer's tenth instruction calls the allocator bridge.
for index, start, allocate, side in (
(self.reference, reference_registry, reference_allocate, "reference"),
(self.candidate, candidate_registry, candidate_allocate, "candidate"),
):
code = index.instructions(start)
if len(code) <= 9 or code[9].shape != "call" or code[9].branch_target != allocate:
raise AnalyzerError(
f"{side} registry initializer does not call its allocator bridge"
)
def derive_profile(
self, reference_sidecar: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, Any]]:
reference_profile = reference_sidecar["profile"]
anchors = reference_sidecar["derivation_anchors"]
DERIVER.validate_profile_shape(reference_profile, anchors)
reference_image = self.reference.image
candidate_image = self.candidate.image
check_inputs(reference_image, candidate_image, reference_sidecar)
parse = DERIVER.parse_rva
bridges = []
found = {}
for entry in reference_profile["bridge_entries"]:
rva = self.match_function(entry["label"], parse(entry["rva"], entry["label"]))
found[entry["label"]] = rva
bridges.append({"rva": format_rva(rva), "kind": entry["kind"], "label": entry["label"]})
seeds = reference_profile["data_seeds"]
arena_initializer = parse(seeds["arena_initializer"], "arena initializer")
thread_initializer = parse(seeds["allocator_thread_initializer"], "thread initializer")
self.match_function("arena-initializer", arena_initializer)
self.match_function("allocator-thread-initializer", thread_initializer)
bootstrap = reference_profile["native_pre_jni_bootstrap"]
DERIVER.validate_reference_registry_slot(reference_image, bootstrap)
registry = self.match_function(
"registry-initializer", parse(bootstrap["registry_initializer"], "registry")
)
reference_allocate = parse(
DERIVER.bridge_map(reference_profile)["allocate"]["rva"], "reference allocate"
)
self._check_registry_allocator(
parse(bootstrap["registry_initializer"], "registry"),
registry, reference_allocate, found["allocate"],
)
registry_slot = DERIVER.derive_registry_slot(candidate_image, registry)
reference_init = reference_image.init_array_relocations()
candidate_init = candidate_image.init_array_relocations()
begin = DERIVER.LEADING_CONSTRUCTOR_BEGIN
count = DERIVER.LEADING_CONSTRUCTOR_COUNT
leading = candidate_init[begin : begin + count]
offsets = []
for index in range(begin, begin + count):
expected = parse(anchors["constructor_rvas"][str(index)], f"constructor {index}")
if reference_init[index] != expected:
raise AnalyzerError(f"reference constructor {index} differs from its anchor")
rva = self.match_function(f"constructor-{index}", expected)
if leading.count(rva) != 1:
raise AnalyzerError(f"constructor {index} left the leading constructors")
offsets.append(leading.index(rva))
if sorted(offsets) != list(range(count)):
raise AnalyzerError("leading constructors do not map one to one")
singleton = parse(anchors["jni_singleton_initializer_rva"], "JNI singleton")
jni_singleton = self.match_function("jni-singleton-initializer", singleton)
allocator_anchor = parse(anchors["allocator_object_initializer_rva"], "allocator anchor")
empty_anchor = parse(anchors["empty_string_initializer_rva"], "empty string anchor")
allocator_function = self.reference.containing(allocator_anchor)
empty_function = self.reference.containing(empty_anchor)
if allocator_function is None or empty_function is None:
raise AnalyzerError("reference anchor is outside every function")
slots = {
"allocator_object_slot": self.map_data(
"allocator object slot", "allocator-object-initializer function",
allocator_function, parse(seeds["allocator_object_slot"], "allocator slot"),
),
"empty_string_slot": self.map_data(
"empty string slot", "empty-string-initializer function",
empty_function, parse(seeds["empty_string_slot"], "empty string slot"),
),
"jni_singleton_slot": self.map_data(
"JNI singleton slot", "jni-singleton-initializer",
singleton, parse(seeds["jni_singleton_slot"], "JNI singleton slot"),
),
"arena_guard_slot": self.map_data(
"arena guard slot", "arena-initializer",
arena_initializer, parse(seeds["arena_guard_slot"], "arena guard slot"),
),
"arena_table_slot": self.map_data(
"arena table slot", "usable-size",
parse(DERIVER.bridge_map(reference_profile)["usable-size"]["rva"], "usable size"),
parse(seeds["arena_table_slot"], "arena table slot"),
),
}
for slot in slots.values():
candidate_image.require_writable_rva(slot, 8)
candidate_count = len(candidate_init)
reference_count = reference_profile["init_array_count"]
boundary = reference_profile["native_mimalloc_thread_initializer_after_constructor"]
if not begin <= boundary < begin + count:
raise AnalyzerError("native allocator TLS boundary is no longer verified")
thread_boundary = begin + offsets[boundary - begin]
native_ranges = DERIVER.remapped_constructor_ranges(
reference_profile["native_mimalloc_constructor_run_ranges"],
offsets, reference_count, candidate_count,
)
if not any(item["begin"] <= thread_boundary < item["end_exclusive"] for item in native_ranges):
raise AnalyzerError("native allocator TLS boundary is no longer verified")
profile = {
"elf_build_id": candidate_image.build_id,
"bridge_entries": bridges,
"data_seeds": {
"allocator_object_slot": format_rva(slots["allocator_object_slot"]),
"empty_string_slot": format_rva(slots["empty_string_slot"]),
"jni_singleton_slot": format_rva(slots["jni_singleton_slot"]),
"jni_singleton_bytes": seeds["jni_singleton_bytes"],
"arena_initializer": format_rva(self.matches["arena-initializer"].rva),
"allocator_thread_initializer": format_rva(
self.matches["allocator-thread-initializer"].rva
),
"arena_guard_slot": format_rva(slots["arena_guard_slot"]),
"arena_table_slot": format_rva(slots["arena_table_slot"]),
"arena_table_slot_count": seeds["arena_table_slot_count"],
},
"native_allocator": {
"allocate": format_rva(found["allocate"]),
"deallocate": format_rva(found["free"]),
},
"init_array_offset": format_rva(candidate_image.sections[".init_array"].address),
"init_array_count": candidate_count,
"constructor_run_ranges": DERIVER.remapped_constructor_ranges(
reference_profile["constructor_run_ranges"],
offsets, reference_count, candidate_count,
),
"native_mimalloc_constructor_run_ranges": native_ranges,
"native_mimalloc_thread_initializer_after_constructor": thread_boundary,
"native_pre_jni_bootstrap": {
"registry_initializer": format_rva(registry),
"registry_slot": format_rva(registry_slot),
},
"default_allocator_strategy": "native_mimalloc",
}
derived_anchors = {
"signature_version": 1,
"allocator_object_initializer_rva": format_rva(
self.map_instruction("allocator-object-initializer", allocator_anchor)
),
"empty_string_initializer_rva": format_rva(
self.map_instruction("empty-string-initializer", empty_anchor)
),
"jni_singleton_initializer_rva": format_rva(jni_singleton),
"constructor_rvas": {
str(index): format_rva(candidate_init[index])
for index in range(begin, begin + count)
},
}
DERIVER.validate_profile_shape(profile, derived_anchors)
return profile, derived_anchors
def derive_runtime_compatibility(self, source: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = {}
fullscreen = source.get("user_game_settings_fullscreen_setter_rva")
if fullscreen is not None:
reference_setter = DERIVER.parse_rva(fullscreen, "fullscreen setter")
if not DERIVER.has_fullscreen_setter_contract(self.reference.image, reference_setter):
raise AnalyzerError("reference fullscreen setter contract changed")
setter = self.match_function(
"fullscreen-setter", reference_setter, DERIVER.has_fullscreen_setter_contract
)
result["user_game_settings_fullscreen_setter_rva"] = format_rva(setter)
fmod = source.get("fmod_output_device_bridge")
if fmod is not None:
result["fmod_output_device_bridge"] = self._derive_fmod_bridge(fmod)
return result
def _derive_fmod_bridge(self, source: dict[str, Any]) -> dict[str, Any]:
layout = validate_fmod_source(source)
has_input = FMOD_INPUT_FIELDS[0] in source
slots = fmod_slots(layout, has_input)
parse = DERIVER.parse_rva
vtable = parse(source["vtable_rva"], "FMOD vtable")
reference_relocations = DERIVER.relative_relocation_map(self.reference.image)
methods = {}
unresolved = []
for field, slot in slots.items():
method = parse(source[field], field)
if reference_relocations.get(vtable + slot * 8) != method:
raise AnalyzerError("reference FMOD vtable does not match its methods")
try:
methods[field] = self.match_function(
f"fmod-{field}", method, self._fmod_contract(layout, field)
)
except AnalyzerError:
unresolved.append(field)
string_constructor = parse(source["string_constructor_rva"], "FMOD string constructor")
if not DERIVER.has_fmod_string_constructor_contract(self.reference.image, string_constructor):
raise AnalyzerError("reference FMOD string constructor contract changed")
candidate_string_constructor = self.match_function(
"fmod-string-constructor",
string_constructor,
DERIVER.has_fmod_string_constructor_contract,
)
relocations = DERIVER.relative_relocation_map(self.candidate.image)
located = self._locate_fmod_vtables(relocations, methods)
vtables = sorted({vtable for vtable, _ in located})
if len(vtables) != 1:
raise AnalyzerError(f"FMOD output vtable matched {len(vtables)} candidate locations")
# The located vtable breaks ties for the methods that did not resolve.
# Count and info share slots 5 and 6 in both layouts, so the vtable can
# fit both; exactly one layout must then hold every method.
solutions = []
for located_vtable, target_layout in located:
target_slots = fmod_slots(target_layout, has_input)
resolved = dict(methods)
try:
for field in unresolved:
resolved[field] = self.match_function(
f"fmod-{field}",
parse(source[field], field),
self._fmod_contract(target_layout, field),
relocations.get(located_vtable + target_slots[field] * 8),
)
except AnalyzerError:
continue
if self._locate_fmod_vtables(relocations, resolved) != [
(located_vtable, target_layout)
]:
continue
if target_layout == 2 and not DERIVER.has_fmod_device_lists_select_contract(
self.candidate.image, resolved["select_method_rva"]
):
continue
solutions.append((located_vtable, target_layout, target_slots, resolved))
if len(solutions) != 1:
raise AnalyzerError(f"FMOD output vtable holds every method in {len(solutions)} layouts")
located_vtable, target_layout, target_slots, methods = solutions[0]
self.candidate.image.require_relro_rva(
located_vtable, (max(target_slots.values()) + 1) * 8
)
result = {"vtable_rva": format_rva(located_vtable)}
result["string_constructor_rva"] = format_rva(candidate_string_constructor)
result.update((field, format_rva(rva)) for field, rva in methods.items())
if target_layout != 1:
result["vtable_layout_version"] = target_layout
return result
@staticmethod
def _fmod_contract(layout: int, field: str) -> Callable[[Any, int], bool] | None:
if layout == 2 and field == "select_method_rva":
return DERIVER.has_fmod_device_lists_select_contract
return None
@staticmethod
def _locate_fmod_vtables(
relocations: dict[int, int], methods: dict[str, int]
) -> list[tuple[int, int]]:
"""Returns (vtable, layout) for each vtable holding every method."""
located = []
for layout in (1, 2):
slots = fmod_slots(layout, True)
held = {slots[field]: rva for field, rva in methods.items()}
located.extend(
(vtable, layout)
for vtable in TargetDerivation._vtables_holding(relocations, held)
)
return sorted(located)
@staticmethod
def _vtables_holding(relocations: dict[int, int], methods: dict[int, int]) -> list[int]:
"""Returns every vtable whose slots hold all of `methods`."""
if not methods:
raise AnalyzerError("no vtable method was matched")
slot, method = next(iter(methods.items()))
return sorted(
offset - slot * 8
for offset, value in relocations.items()
if value == method
and offset >= slot * 8
and (offset - slot * 8) % 8 == 0
and all(
relocations.get(offset - slot * 8 + index * 8) == rva
for index, rva in methods.items()
)
)
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
# Copyright 2026 Mocktail Project Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Function index of one libroblox image for `roblox_abi_solver.py`.
Every function listed in `.eh_frame_hdr` is decoded once and reduced to a
normalized instruction shape, its direct calls and its RIP-relative data
references. Shapes drop branch targets, RIP displacements and immediates of
0x1000 and above, so a function moved by the linker keeps its shape.
"""
from __future__ import annotations
import bisect
import collections
from dataclasses import dataclass
import gzip
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import struct
import sys
import tempfile
from typing import Any
def _load_deriver():
name = "mocktail_host_abi_deriver_for_solver"
if name in sys.modules:
return sys.modules[name]
path = Path(__file__).resolve().with_name("derive_roblox_host_abi_profile.py")
specification = importlib.util.spec_from_file_location(name, path)
if specification is None or specification.loader is None:
raise RuntimeError("cannot load the HostAbi deriver")
module = importlib.util.module_from_spec(specification)
sys.modules[name] = module
specification.loader.exec_module(module)
return module
DERIVER = _load_deriver()
AnalyzerError = DERIVER.AnalyzerError
_RIP_OPERAND = re.compile(r"\[rip ([+-]) (0x[0-9a-f]+|\d+)\]")
_IMMEDIATE = re.compile(r"0x[0-9a-f]+|\b\d+\b")
# Smaller immediates are offsets and flags that stay put between builds.
# Larger ones are addresses and sizes that move.
_STABLE_IMMEDIATE_LIMIT = 0x1000
def _stable_immediate(match: re.Match) -> str:
return "I" if int(match.group(0), 0) >= _STABLE_IMMEDIATE_LIMIT else match.group(0)
@dataclass(frozen=True)
class Instruction:
address: int
shape: str
rip_targets: tuple[int, ...]
branch_target: int | None
def decode_function(
decoder: Any, code: bytes, start: int
) -> list[Instruction]:
end = start + len(code)
instructions = []
for address, size, mnemonic, operands in decoder.disasm_lite(code, start):
if (mnemonic == "call" or mnemonic.startswith("j")) and operands.startswith("0x"):
target = int(operands, 16)
if mnemonic == "call":
shape = "call"
else:
shape = mnemonic + (" in" if start <= target < end else " out")
instructions.append(Instruction(address, shape, (), target))
continue
rip_targets = []
for match in _RIP_OPERAND.finditer(operands):
displacement = int(match.group(2), 0)
rip_targets.append(
address + size + (-displacement if match.group(1) == "-" else displacement)
)
operands = _IMMEDIATE.sub(_stable_immediate, _RIP_OPERAND.sub("[rip]", operands))
instructions.append(
Instruction(address, f"{mnemonic} {operands}", tuple(rip_targets), None)
)
return instructions
@dataclass(frozen=True)
class FunctionInfo:
size: int
shape_hash: bytes
calls: tuple[int, ...]
refs: tuple[int, ...]
def function_table(image: Any) -> list[tuple[int, int]]:
"""Returns (start, size) for every FDE indexed by `.eh_frame_hdr`."""
header = image.sections.get(".eh_frame_hdr")
if header is None:
raise AnalyzerError("libroblox has no .eh_frame_hdr")
raw = image.section_bytes(header)
# Version 1, a 4-byte eh_frame_ptr, udata4 count, datarel sdata4 table.
if len(raw) < 12 or raw[0] != 1 or raw[2] != 0x03 or raw[3] != 0x3B:
raise AnalyzerError(".eh_frame_hdr uses an unsupported encoding")
if raw[1] == 0xFF or raw[1] & 0x0F not in (0x03, 0x0B):
raise AnalyzerError(".eh_frame_hdr eh_frame_ptr encoding is not 4 bytes")
count = struct.unpack_from("<I", raw, 8)[0]
if 12 + count * 8 > len(raw):
raise AnalyzerError(".eh_frame_hdr table exceeds its section")
table = []
for index in range(count):
location, fde = struct.unpack_from("<ii", raw, 12 + index * 8)
start = header.address + location
fde_rva = header.address + fde
offset = image.rva_to_offset(fde_rva, 16)
length, _, begin, size = struct.unpack_from("<IIiI", image.data, offset)
# An FDE starts with its length, CIE pointer, then a pcrel sdata4
# pc_begin and its udata4 range.
if length == 0xFFFFFFFF or fde_rva + 8 + begin != start:
raise AnalyzerError(".eh_frame FDE does not match its index entry")
table.append((start, size))
table.sort()
return table
_SOURCE_DIGEST = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:16]
def cache_path(cache_dir: Path, library_sha256: str) -> Path:
"""Cache file for one library; shapes depend on this code and capstone."""
version = getattr(DERIVER.capstone, "__version__", "unknown")
return cache_dir / f"{library_sha256}-{_SOURCE_DIGEST}-capstone{version}.json.gz"
def link_functions(
functions: dict[int, FunctionInfo],
) -> tuple[dict[int, list[int]], dict[int, list[int]]]:
"""Returns each function's callers and each data address's users, once each."""
callers: dict[int, list[int]] = collections.defaultdict(list)
data_users: dict[int, list[int]] = collections.defaultdict(list)
for start, info in functions.items():
for target in dict.fromkeys(info.calls):
if target in functions:
callers[target].append(start)
for target in dict.fromkeys(info.refs):
data_users[target].append(start)
return callers, data_users
def _int_list(value: Any) -> tuple[int, ...]:
if not isinstance(value, list) or not all(type(item) is int for item in value):
raise ValueError("cached index entry is malformed")
return tuple(value)
def _read_cache(path: Path) -> dict[int, FunctionInfo] | None:
"""Returns the cached functions, or None when the file is missing or bad."""
try:
with gzip.open(path, "rt", encoding="utf-8") as stream:
entries = json.load(stream)
if not isinstance(entries, list) or not entries:
raise ValueError("cached index is empty")
functions = {}
for entry in entries:
start, size, shape_hash, calls, refs = entry
if type(start) is not int or type(size) is not int:
raise ValueError("cached index entry is malformed")
functions[start] = FunctionInfo(
size, bytes.fromhex(shape_hash), _int_list(calls), _int_list(refs)
)
return functions
except (OSError, EOFError, ValueError, TypeError):
return None
def _write_cache(path: Path, functions: dict[int, FunctionInfo]) -> None:
# A cache that cannot be written only costs the next run a rebuild.
entries = [
[start, info.size, info.shape_hash.hex(), list(info.calls), list(info.refs)]
for start, info in functions.items()
]
try:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
except OSError:
return
try:
with os.fdopen(descriptor, "wb") as raw, gzip.open(raw, "wt", encoding="utf-8") as stream:
json.dump(entries, stream, separators=(",", ":"))
os.replace(temporary, path)
except OSError:
Path(temporary).unlink(missing_ok=True)
class FunctionIndex:
"""Normalized shapes, calls and data references of every function.
With `cache_dir`, the per-function data is kept in a file named after the
library's SHA-256, this module's source and the capstone version, and read
back instead of decoding the library again.
"""
def __init__(self, image: Any, cache_dir: Path | None = None):
DERIVER.require_capstone()
self.image = image
self.decoder = DERIVER.capstone.Cs(
DERIVER.capstone.CS_ARCH_X86, DERIVER.capstone.CS_MODE_64
)
self.text = image.sections[".text"]
self._decoded: dict[int, list[Instruction]] = {}
path = None if cache_dir is None else cache_path(Path(cache_dir), image.sha256)
functions = None if path is None else _read_cache(path)
if functions is None:
functions = self._decode_all()
if path is not None:
_write_cache(path, functions)
self.functions = functions
self.starts = sorted(self.functions)
self.callers, self.data_users = link_functions(self.functions)
def _decode_all(self) -> dict[int, FunctionInfo]:
text_end = self.text.address + self.text.size
functions = {}
for start, size in function_table(self.image):
if size == 0 or not self.text.address <= start < text_end:
continue
instructions = self.instructions(start, size, cache=False)
shape_hash = hashlib.blake2b(
"\n".join(item.shape for item in instructions).encode(), digest_size=8
).digest()
calls = tuple(
item.branch_target
for item in instructions
if item.branch_target is not None
and (item.shape == "call" or item.shape.endswith(" out"))
)
refs = tuple(target for item in instructions for target in item.rip_targets)
functions[start] = FunctionInfo(size, shape_hash, calls, refs)
return functions
def instructions(
self, start: int, size: int | None = None, cache: bool = True
) -> list[Instruction]:
if cache and start in self._decoded:
return self._decoded[start]
if size is None:
size = self.functions[start].size
offset = self.text.offset + start - self.text.address
decoded = decode_function(self.decoder, self.image.data[offset : offset + size], start)
if cache:
self._decoded[start] = decoded
return decoded
def shapes(self, start: int) -> list[str]:
# Scoring reads each candidate once; only aligned functions stay cached.
return [item.shape for item in self.instructions(start, cache=start in self._decoded)]
def containing(self, rva: int) -> int | None:
index = bisect.bisect_right(self.starts, rva) - 1
if index < 0:
return None
start = self.starts[index]
return start if rva < start + self.functions[start].size else None
def string_at(self, rva: int) -> bytes | None:
segment = self.image.segment_for_rva(rva)
if segment is None or not segment.contains_file_data(rva):
return None
offset = self.image.rva_to_offset(rva)
raw = self.image.data[offset : offset + 96]
end = raw.find(b"\0")
if end < 4 or not all(32 <= byte < 127 for byte in raw[:end]):
return None
return raw[:end]
+42 -13
View File
@@ -5,9 +5,33 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Nightcap for Linux</title>
<meta name="description" content="Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.">
<title>Nightcap: Roblox on Linux</title>
<meta name="description" content="Roblox on Linux, tuned for desktop play. A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.">
<meta name="google-site-verification" content="vznL8W2txRlmfwhGg7ZBPSxQaPV9OtaIUoYPvmd9aUA">
<link rel="canonical" href="https://coderdayton.github.io/nightcap/">
<link rel="icon" href="nightcap.svg" type="image/svg+xml">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Nightcap">
<meta property="og:title" content="Nightcap: Roblox on Linux">
<meta property="og:description" content="Roblox on Linux, tuned for desktop play. A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.">
<meta property="og:url" content="https://coderdayton.github.io/nightcap/">
<meta property="og:image" content="https://coderdayton.github.io/nightcap/preview.png">
<meta property="og:image:alt" content="Roblox running on Linux with Nightcap">
<meta name="twitter:card" content="summary_large_image">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Nightcap",
"description": "Roblox on Linux, tuned for desktop play. A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.",
"applicationCategory": "GameApplication",
"operatingSystem": "Linux",
"url": "https://coderdayton.github.io/nightcap/",
"downloadUrl": "https://github.com/CoderDayton/nightcap/releases/latest",
"license": "https://www.apache.org/licenses/LICENSE-2.0",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
}
</script>
<link rel="stylesheet" href="styles.css">
</head>
<body>
@@ -17,8 +41,9 @@
<a href="https://github.com/CoderDayton/nightcap" aria-label="Nightcap on GitHub" title="GitHub"><svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>
<a href="https://discord.gg/DbAUG9hBFU" aria-label="Nightcap on Discord" title="Discord"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg></a>
</p>
<p class="eyebrow">Roblox on Linux, tuned for desktop play</p>
<h1><img src="nightcap.svg" alt=""><span>Nightcap</span></h1>
<p class="lede">Play Roblox on Linux like it was made for your PC. Nightcap is Mocktail, tuned to feel fast and look sharp.</p>
<p class="lede">A Mocktail fork that keeps up with Roblox updates, with extra bug fixes, custom assets and more.</p>
<p class="actions">
<a class="button" href="https://github.com/CoderDayton/nightcap/releases/latest/download/Nightcap-x86_64.AppImage">Download AppImage</a>
<a class="button secondary" href="nightcap.flatpakref">Install with Flatpak</a>
@@ -50,16 +75,20 @@ cd nightcap &amp;&amp; make build
<section class="row">
<h2>What's different</h2>
<dl class="body facts">
<dt>Input lag</dt>
<dd>Lower. Frames are shown as soon as they are ready.</dd>
<dt>Textures</dt>
<dd>Sharper, with your own PNG overrides if you want them.</dd>
<dt>ETC2</dt>
<dd>Textures render correctly on desktop GPUs.</dd>
<dt>Error 319</dt>
<dd>No more kicks after joining a server.</dd>
<dt>RAM and screen</dt>
<dd>Roblox sees your real RAM and screen size.</dd>
<dt>Roblox updates</dt>
<dd>New Roblox versions work soon after they come out.</dd>
<dt>More games</dt>
<dd>Answers a server check some experiences use, so you can play those too.</dd>
<dt>Voice chat</dt>
<dd>Your microphone still works after a Roblox update.</dd>
<dt>Login</dt>
<dd>Verification checks and captchas finish instead of hanging.</dd>
<dt>Look</dt>
<dd>The new home screen and side menu, like on Windows.</dd>
<dt>Your own assets</dt>
<dd>Drop in your own cursors, fonts and skies. The folder layout matches Sober's.</dd>
<dt>Mouse and CPU</dt>
<dd>Optional raw mouse input, and less CPU use while idle.</dd>
</dl>
</section>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

+15 -2
View File
@@ -48,8 +48,21 @@ button:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; }
.top a:hover { color: var(--ink); }
.hero h1 {
.eyebrow {
display: inline-block;
margin: clamp(48px, 12vh, 130px) 0 0;
padding: 6px 12px;
border: 1px solid var(--line);
background: var(--code-bg);
color: var(--link);
font-size: .85rem;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.hero h1 {
margin: 18px 0 0;
font-size: clamp(3.6rem, 15vw, 9.5rem);
font-weight: 800;
line-height: .95;
@@ -66,7 +79,7 @@ button:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; }
}
.lede {
max-width: 30ch;
max-width: 34ch;
margin: 22px 0 0;
color: var(--ink);
font-size: 1.25rem;
+18
View File
@@ -0,0 +1,18 @@
#include "jni_method_names.h"
#include <unordered_map>
namespace jnivm::internal {
JniMethod LookupJniMethod(std::string_view name) {
static const auto* const kMethods =
new std::unordered_map<std::string_view, JniMethod>{
#define MOCKTAIL_JNI_METHOD_ENTRY(id, text) {text, JniMethod::id},
MOCKTAIL_JNI_METHOD_NAMES(MOCKTAIL_JNI_METHOD_ENTRY)
#undef MOCKTAIL_JNI_METHOD_ENTRY
};
const auto it = kMethods->find(name);
return it == kMethods->end() ? JniMethod::kUnknown : it->second;
}
} // namespace jnivm::internal
+266
View File
@@ -0,0 +1,266 @@
#ifndef MOCKTAIL_JNIVM_JNI_METHOD_NAMES_H_
#define MOCKTAIL_JNIVM_JNI_METHOD_NAMES_H_
#include <cstdint>
#include <string_view>
namespace jnivm::internal {
// Every Java method name the JNI dispatch matches on. GetMethodID and
// GetStaticMethodID resolve a name to its entry once, so dispatch compares
// enum values. A name that is not listed resolves to JniMethod::kUnknown.
#define MOCKTAIL_JNI_METHOD_NAMES(X) \
X(kA, "a") \
X(kApply, "apply") \
X(kAttachObserver, "attachObserver") \
X(kBootstrapTheApp, "bootstrapTheApp") \
X(kCheckInit, "checkInit") \
X(kClose, "close") \
X(kCommit, "commit") \
X(kConnect, "connect") \
X(kConstructor, "<init>") \
X(kContains, "contains") \
X(kCurrentActivity, "currentActivity") \
X(kCurrentTimeMillis, "currentTimeMillis") \
X(kD, "d") \
X(kDeleteCurrentUserValues, "deleteCurrentUserValues") \
X(kDeleteSecureValue, "deleteSecureValue") \
X(kDeleteUserValues, "deleteUserValues") \
X(kDetachObserver, "detachObserver") \
X(kDispose, "dispose") \
X(kDoSubscribeProtocolMethodResponseRaw, "doSubscribeProtocolMethodResponseRaw") \
X(kEdit, "edit") \
X(kEnableBuiltInAEC, "enableBuiltInAEC") \
X(kEnableBuiltInAGC, "enableBuiltInAGC") \
X(kEnableBuiltInNS, "enableBuiltInNS") \
X(kF, "f") \
X(kFindClass, "findClass") \
X(kForName, "forName") \
X(kG, "g") \
X(kGameActivityHideKeyboard, "gameActivity_hideKeyboard") \
X(kGameActivityOnAppReady, "gameActivity_onAppReady") \
X(kGameActivityOnDidLogInReceived, "gameActivity_onDidLogInReceived") \
X(kGameActivityOnDidLogOutReceived, "gameActivity_onDidLogOutReceived") \
X(kGameActivityOnDidSignUp, "gameActivity_onDidSignUp") \
X(kGameActivityOnDidSwitchAccountReceived, "gameActivity_onDidSwitchAccountReceived") \
X(kGameActivityOnEngineInitialized, "gameActivity_onEngineInitialized") \
X(kGameActivityOnExperienceStart, "gameActivity_onExperienceStart") \
X(kGameActivityOnExperienceStop, "gameActivity_onExperienceStop") \
X(kGameActivityOnFlagsFailed, "gameActivity_onFlagsFailed") \
X(kGameActivityOnFlagsLoaded, "gameActivity_onFlagsLoaded") \
X(kGameActivityOnGameLoaded, "gameActivity_onGameLoaded") \
X(kGameActivityOnLuaAppDidReturn, "gameActivity_onLuaAppDidReturn") \
X(kGameActivityOnLuaTextBoxChanged, "gameActivity_onLuaTextBoxChanged") \
X(kGameActivityOnLuaTextBoxPropertyChanged, "gameActivity_onLuaTextBoxPropertyChanged") \
X(kGameActivityOnMotionEventListening, "gameActivity_onMotionEventListening") \
X(kGameActivityOnRestartLuaApp, "gameActivity_onRestartLuaApp") \
X(kGameActivityOnScanQrCode, "gameActivity_onScanQrCode") \
X(kGameActivityOnScreenOrientationChanged, "gameActivity_onScreenOrientationChanged") \
X(kGameActivityOnScreenshotReady, "gameActivity_onScreenshotReady") \
X(kGameActivitySetAppUpgradeStatus, "gameActivity_setAppUpgradeStatus") \
X(kGameActivityShowKeyboard, "gameActivity_showKeyboard") \
X(kGameDidLeave, "gameDidLeave") \
X(kGet, "get") \
X(kGetAbsolutePath, "getAbsolutePath") \
X(kGetActivity, "getActivity") \
X(kGetAlternateName, "getAlternateName") \
X(kGetAndroidApiVersion, "getAndroidApiVersion") \
X(kGetApiBaseUrl, "getApiBaseUrl") \
X(kGetApiGatewayUrl, "getApiGatewayUrl") \
X(kGetApiVersion, "getApiVersion") \
X(kGetApplicationContext, "getApplicationContext") \
X(kGetApplicationInfo, "getApplicationInfo") \
X(kGetAppVersion, "getAppVersion") \
X(kGetAssetManager, "getAssetManager") \
X(kGetAssets, "getAssets") \
X(kGetBaseContext, "getBaseContext") \
X(kGetBaseURL, "getBaseURL") \
X(kGetBaseUrl, "getBaseUrl") \
X(kGetBrand, "getBrand") \
X(kGetBufferSizeInFrames, "getBufferSizeInFrames") \
X(kGetBuildId, "getBuildId") \
X(kGetBuildRelease, "getBuildRelease") \
X(kGetBuildType, "getBuildType") \
X(kGetBytes, "getBytes") \
X(kGetCacheDir, "getCacheDir") \
X(kGetCanonicalPath, "getCanonicalPath") \
X(kGetClassLoader, "getClassLoader") \
X(kGetClientSettingsUrl, "getClientSettingsUrl") \
X(kGetContext, "getContext") \
X(kGetCookie, "getCookie") \
X(kGetCurrentText, "getCurrentText") \
X(kGetCurrentTextBoxInfo, "getCurrentTextBoxInfo") \
X(kGetCurrentUser, "getCurrentUser") \
X(kGetDecorView, "getDecorView") \
X(kGetDefaultDisplay, "getDefaultDisplay") \
X(kGetDensityDpi, "getDensityDpi") \
X(kGetDevice, "getDevice") \
X(kGetDeviceManufacturer, "getDeviceManufacturer") \
X(kGetDeviceModel, "getDeviceModel") \
X(kGetDeviceStaticParams, "getDeviceStaticParams") \
X(kGetDisplay, "getDisplay") \
X(kGetDisplayName, "getDisplayName") \
X(kGetExternalFilesDir, "getExternalFilesDir") \
X(kGetFilesDir, "getFilesDir") \
X(kGetGameLocale, "getGameLocale") \
X(kGetHeight, "getHeight") \
X(kGetHint, "getHint") \
X(kGetHolder, "getHolder") \
X(kGetId, "getId") \
X(kGetImplementation, "getImplementation") \
X(kGetInstallerPackageName, "getInstallerPackageName") \
X(kGetInt, "getInt") \
X(kGetLastLoggedInUser, "getLastLoggedInUser") \
X(kGetLastLoggedInUserId, "getLastLoggedInUserId") \
X(kGetLastRaw, "getLastRaw") \
X(kGetLocale, "getLocale") \
X(kGetManufacturer, "getManufacturer") \
X(kGetMessageBus, "getMessageBus") \
X(kGetMessageBusConnection, "getMessageBusConnection") \
X(kGetMode, "getMode") \
X(kGetModel, "getModel") \
X(kGetNativeHelper, "getNativeHelper") \
X(kGetNativeTextBoxInfo, "getNativeTextBoxInfo") \
X(kGetOutputBlockSize, "getOutputBlockSize") \
X(kGetOutputSampleRate, "getOutputSampleRate") \
X(kGetPackageInfo, "getPackageInfo") \
X(kGetPackageManager, "getPackageManager") \
X(kGetPackageName, "getPackageName") \
X(kGetPath, "getPath") \
X(kGetPlaceholder, "getPlaceholder") \
X(kGetPlatformName, "getPlatformName") \
X(kGetPlatformSystemDialogHandler, "getPlatformSystemDialogHandler") \
X(kGetProduct, "getProduct") \
X(kGetProperty, "getProperty") \
X(kGetPublicIPv4Addresseses, "getPublicIPv4Addresseses") \
X(kGetRefreshRate, "getRefreshRate") \
X(kGetResources, "getResources") \
X(kGetRobloxLocale, "getRobloxLocale") \
X(kGetRootView, "getRootView") \
X(kGetRotation, "getRotation") \
X(kGetScreenHeight, "getScreenHeight") \
X(kGetScreenWidth, "getScreenWidth") \
X(kGetSdkVersion, "getSdkVersion") \
X(kGetSecureValue, "getSecureValue") \
X(kGetSecureValueForCurrentUser, "getSecureValueForCurrentUser") \
X(kGetSecureValueForUser, "getSecureValueForUser") \
X(kGetSettingsUrl, "getSettingsUrl") \
X(kGetSharedPreferences, "getSharedPreferences") \
X(kGetStreamMaxVolume, "getStreamMaxVolume") \
X(kGetStreamVolume, "getStreamVolume") \
X(kGetString, "getString") \
X(kGetSurface, "getSurface") \
X(kGetSystemDialogHandler, "getSystemDialogHandler") \
X(kGetSystemService, "getSystemService") \
X(kGetSystemTheme, "getSystemTheme") \
X(kGetTargetSdkVersion, "getTargetSdkVersion") \
X(kGetText, "getText") \
X(kGetTextBoxInfo, "getTextBoxInfo") \
X(kGetTheme, "getTheme") \
X(kGetUserId, "getUserId") \
X(kGetUsername, "getUsername") \
X(kGetUsers, "getUsers") \
X(kGetVideoCodecs, "getVideoCodecs") \
X(kGetWidth, "getWidth") \
X(kGetWindow, "getWindow") \
X(kGetWindowManager, "getWindowManager") \
X(kGetWWWBaseUrl, "getWWWBaseUrl") \
X(kGetWwwBaseUrl, "getWwwBaseUrl") \
X(kH, "h") \
X(kHasNext, "hasNext") \
X(kHasSystemFeature, "hasSystemFeature") \
X(kHideKeyboard, "hideKeyboard") \
X(kIdentityHashCode, "identityHashCode") \
X(kInit, "init") \
X(kInitPlayout, "initPlayout") \
X(kInitRecording, "initRecording") \
X(kIsAAudioSupported, "isAAudioSupported") \
X(kIsAudioConfigVerified, "isAudioConfigVerified") \
X(kIsAudioSourceMatchingRecordingSession, "isAudioSourceMatchingRecordingSession") \
X(kIsAvailable, "isAvailable") \
X(kIsBluetoothA2dpOn, "isBluetoothA2dpOn") \
X(kIsBluetoothOn, "isBluetoothOn") \
X(kIsBluetoothScoOn, "isBluetoothScoOn") \
X(kIsChangingConfigurations, "isChangingConfigurations") \
X(kIsCommunicationModeEnabled, "isCommunicationModeEnabled") \
X(kIsConnected, "isConnected") \
X(kIsDestroyed, "isDestroyed") \
X(kIsDeviceBlacklistedForOpenSLESUsage, "isDeviceBlacklistedForOpenSLESUsage") \
X(kIsEmpty, "isEmpty") \
X(kIsFinishing, "isFinishing") \
X(kIsLowLatencyInputSupported, "isLowLatencyInputSupported") \
X(kIsLowLatencyOutputSupported, "isLowLatencyOutputSupported") \
X(kIsProAudioSupported, "isProAudioSupported") \
X(kIsSystemThemeAvailable, "isSystemThemeAvailable") \
X(kIsValid, "isValid") \
X(kIterator, "iterator") \
X(kK1, "k1") \
X(kL0, "l0") \
X(kLength, "length") \
X(kLoadClass, "loadClass") \
X(kLowLatencyFlag, "lowLatencyFlag") \
X(kNanoTime, "nanoTime") \
X(kNativeGetCookiesForDomain, "nativeGetCookiesForDomain") \
X(kNativeGetCookiesInNetscapeFormat, "nativeGetCookiesInNetscapeFormat") \
X(kNativeGetTextBoxInfo, "nativeGetTextBoxInfo") \
X(kOnAppBridgeNotification, "onAppBridgeNotification") \
X(kOnDataModelNotificationCallback, "onDataModelNotificationCallback") \
X(kOnItemSet, "onItemSet") \
X(kOnLuaTextBoxChangedCallback, "onLuaTextBoxChangedCallback") \
X(kOnLuaTextBoxPropertyChangedCallback, "onLuaTextBoxPropertyChangedCallback") \
X(kOnSetCookie, "onSetCookie") \
X(kOpen, "open") \
X(kOpenNativeOverlay, "openNativeOverlay") \
X(kOpenWebActivity, "openWebActivity") \
X(kOptString, "optString") \
X(kOutputAAudioHeadphonesChanged, "OutputAAudioHeadphonesChanged") \
X(kProAudioFlag, "proAudioFlag") \
X(kQ, "q") \
X(kRequestOrientationAsDefault, "requestOrientationAsDefault") \
X(kRequestResponse, "requestResponse") \
X(kRestartInput, "restartInput") \
X(kRun, "run") \
X(kRunOnUiThread, "runOnUiThread") \
X(kSetAppBridgeNotificationListener, "setAppBridgeNotificationListener") \
X(kSetBaseUrl, "setBaseUrl") \
X(kSetCookie, "setCookie") \
X(kSetCookiesFromDisk, "setCookiesFromDisk") \
X(kSetCurrentUser, "setCurrentUser") \
X(kSetImeEditorInfoFields, "setImeEditorInfoFields") \
X(kSetImplementation, "setImplementation") \
X(kSetMicrophoneMute, "setMicrophoneMute") \
X(kSetRequestHandler, "setRequestHandler") \
X(kSetRequestHandlerRaw, "setRequestHandlerRaw") \
X(kSetSecureValue, "setSecureValue") \
X(kSetSecureValueForCurrentUser, "setSecureValueForCurrentUser") \
X(kSetSecureValueForUser, "setSecureValueForUser") \
X(kSetSoftKeyboardActive, "setSoftKeyboardActive") \
X(kSetState, "setState") \
X(kSetStreamVolume, "setStreamVolume") \
X(kSetWindowFlags, "setWindowFlags") \
X(kShowKeyboard, "showKeyboard") \
X(kShowLeaveAppPrompt, "showLeaveAppPrompt") \
X(kSize, "size") \
X(kStartPlayout, "startPlayout") \
X(kStartRecording, "startRecording") \
X(kStopPlayout, "stopPlayout") \
X(kStopRecording, "stopRecording") \
X(kSubscribe, "subscribe") \
X(kSubscribeProtocolMethodResponseRaw, "subscribeProtocolMethodResponseRaw") \
X(kSupportsAAudio, "supportsAAudio") \
X(kSupportsLowLatency, "supportsLowLatency") \
X(kSyncCookiesFromEngine, "syncCookiesFromEngine") \
X(kWrite, "write")
enum class JniMethod : std::uint16_t {
kUnknown = 0,
#define MOCKTAIL_JNI_METHOD_ENUMERATOR(id, name) id,
MOCKTAIL_JNI_METHOD_NAMES(MOCKTAIL_JNI_METHOD_ENUMERATOR)
#undef MOCKTAIL_JNI_METHOD_ENUMERATOR
};
JniMethod LookupJniMethod(std::string_view name);
} // namespace jnivm::internal
#endif // MOCKTAIL_JNIVM_JNI_METHOD_NAMES_H_
+823 -650
View File
File diff suppressed because it is too large. Load diff
+44
View File
@@ -69,6 +69,7 @@
#include "runtime/jnivm_platform_web_callbacks.h"
#include "runtime/owned_pthread.h"
#include "runtime/platform_cache_migration.h"
#include "runtime/asset_overlay_names.h"
#include "runtime/roblox_app_lifecycle.h"
#include "runtime/roblox_capability_resolver.h"
#include "runtime/roblox_platform_web_symbols.h"
@@ -1963,6 +1964,48 @@ extern "C" void MocktailConstructorWrapper() {
MaybePrintLibRobloxConstructorSummary(idx);
}
// Unmaps the rbxasset:// names that have a file in the asset overlay folder, so
// Roblox opens those files instead of downloading the stock asset. Must run
// before any libroblox code does: the segment loses PROT_EXEC while it is
// written.
void DisableOverlaidAssetMappings(uintptr_t base) {
const std::vector<std::string> names = mocktail::runtime::OverlayRbxAssetNames(
CurrentRuntimePaths().asset_overlay_dir());
const long page_size = sysconf(_SC_PAGESIZE);
if (names.empty() || page_size <= 0) {
return;
}
const auto* header = reinterpret_cast<const Elf64_Ehdr*>(base);
const auto* segments =
reinterpret_cast<const Elf64_Phdr*>(base + header->e_phoff);
std::size_t changed = 0;
for (std::size_t index = 0; index < header->e_phnum; ++index) {
const Elf64_Phdr& segment = segments[index];
if (segment.p_type != PT_LOAD || (segment.p_flags & PF_W) != 0 ||
segment.p_filesz == 0) {
continue;
}
const uintptr_t begin = base + segment.p_vaddr;
const uintptr_t page_mask = static_cast<uintptr_t>(page_size) - 1;
const uintptr_t page_begin = begin & ~page_mask;
const std::size_t span =
((begin + segment.p_filesz + page_mask) & ~page_mask) - page_begin;
const int original =
PROT_READ | ((segment.p_flags & PF_X) != 0 ? PROT_EXEC : 0);
if (mprotect(reinterpret_cast<void*>(page_begin), span,
PROT_READ | PROT_WRITE) != 0) {
continue;
}
changed += mocktail::runtime::DisableAssetNameMappings(
reinterpret_cast<std::uint8_t*>(begin), segment.p_filesz, names);
mprotect(reinterpret_cast<void*>(page_begin), span, original);
}
std::cout << " [asset] overlay holds " << names.size()
<< " rbxasset file(s); unmapped " << changed
<< " download name(s)\n"
<< std::flush;
}
extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
uintptr_t base) {
if (realpath == nullptr || std::strstr(realpath, "libroblox.so") == nullptr ||
@@ -1971,6 +2014,7 @@ extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
}
g_libroblox_base = base;
DisableOverlaidAssetMappings(base);
g_mocktail_abort_libroblox_base = base;
g_libroblox_base_static = base;
+64
View File
@@ -0,0 +1,64 @@
#include "runtime/asset_overlay_names.h"
#include <algorithm>
#include <cstring>
#include <system_error>
namespace mocktail {
namespace runtime {
std::vector<std::string> OverlayRbxAssetNames(
const std::filesystem::path& overlay_dir) {
std::vector<std::string> names;
const std::filesystem::path content = overlay_dir / "content";
std::error_code error;
std::filesystem::recursive_directory_iterator it(
content, std::filesystem::directory_options::skip_permission_denied,
error);
const std::filesystem::recursive_directory_iterator end;
for (; !error && it != end; it.increment(error)) {
std::error_code entry_error;
if (!it->is_regular_file(entry_error)) {
continue;
}
const std::filesystem::path relative =
it->path().lexically_relative(content);
names.push_back("rbxasset://" + relative.generic_string());
}
std::sort(names.begin(), names.end());
return names;
}
std::size_t DisableAssetNameMappings(std::uint8_t* data, std::size_t size,
const std::vector<std::string>& names) {
std::size_t changed = 0;
if (data == nullptr) {
return changed;
}
std::uint8_t* const end = data + size;
for (const std::string& name : names) {
// The trailing NUL is part of the needle, so a longer string never matches.
const std::size_t needle_size = name.size() + 1;
if (name.empty() || needle_size > size) {
continue;
}
std::uint8_t* cursor = data;
while (cursor + needle_size <= end) {
auto* found = static_cast<std::uint8_t*>(
memmem(cursor, static_cast<std::size_t>(end - cursor), name.c_str(),
needle_size));
if (found == nullptr) {
break;
}
if (found == data || found[-1] == 0) {
found[0] = '!';
++changed;
}
cursor = found + needle_size;
}
}
return changed;
}
} // namespace runtime
} // namespace mocktail
+13 -2
View File
@@ -349,6 +349,14 @@ RuntimePaths RuntimePaths::FromEnvironment(
paths.cookie_file_ = paths.auth_root_ / "roblox.cookie";
paths.use_real_sober_paths_ =
LegacyEnabled(environment, "MOCKTAIL_USE_REAL_SOBER_PATHS");
if (environment.HasNonEmpty("MOCKTAIL_ASSET_OVERLAY_DIR")) {
paths.asset_overlay_dir_ =
environment.GetOr("MOCKTAIL_ASSET_OVERLAY_DIR", "");
} else {
paths.asset_overlay_dir_ = paths.DefaultSoberAwarePath(
paths.sober_data_root_ / "asset_overlay",
paths.data_root_ / "asset_overlay");
}
return paths;
}
@@ -593,7 +601,8 @@ bool ExportRuntimePathEnvironment(const RuntimePaths& paths,
std::error_code filesystem_error;
for (const std::filesystem::path& directory :
{paths.android_runtime_root(), paths.android_cache_root(),
paths.vulkan_shader_cache_file().parent_path()}) {
paths.vulkan_shader_cache_file().parent_path(),
paths.asset_overlay_dir()}) {
if (!RuntimePaths::EnsureDirectory(directory, &filesystem_error)) {
if (error != nullptr) {
*error = "cannot create resolved runtime path: " + directory.string();
@@ -609,7 +618,9 @@ bool ExportRuntimePathEnvironment(const RuntimePaths& paths,
SetEnvironmentDefault("MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH",
paths.vulkan_shader_cache_file(), error) &&
SetEnvironmentDefault("MOCKTAIL_TEXTURE_OVERRIDE_DIR",
paths.config_root() / "textures", error);
paths.config_root() / "textures", error) &&
SetEnvironmentDefault("MOCKTAIL_ASSET_OVERLAY_DIR",
paths.asset_overlay_dir(), error);
}
bool PrepareManagedPayloadWorkingDirectory(const RuntimePaths& paths,
+67 -26
View File
@@ -43,6 +43,7 @@ struct AAsset {
std::string apk_entry;
std::vector<unsigned char> data;
bool transcoded = false;
bool is_overlay = false;
size_t offset = 0;
};
struct AAssetDir {};
@@ -115,25 +116,46 @@ bool FileExists(const std::string& path) {
return !path.empty() && ::stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode);
}
struct ResolvedAsset {
std::string path;
bool is_overlay = false;
};
struct AssetLookupCache {
std::mutex mutex;
std::unordered_map<std::string, std::string> resolved_paths;
std::unordered_map<std::string, ResolvedAsset> resolved_paths;
};
AssetLookupCache g_asset_lookup_cache;
constexpr std::size_t kMaxCachedAssetPaths = 1024 * 5;
std::string AssetCacheKey(const std::string& root,
const std::string& overlay,
const std::string& requested) {
std::string key;
key.reserve(root.size() + 1 + requested.size());
key.reserve(root.size() + 1 + overlay.size() + 1 + requested.size());
key.append(root);
key.push_back('\0');
key.append(overlay);
key.push_back('\0');
key.append(requested);
return key;
}
std::string ResolveAssetPath(const char* filename) {
// Stores a resolved asset, dropping one entry first when the cache is full.
ResolvedAsset CacheResolvedAsset(const std::string& cache_key,
ResolvedAsset resolved) {
std::scoped_lock lock(g_asset_lookup_cache.mutex);
auto& cached = g_asset_lookup_cache.resolved_paths;
if (cached.size() >= kMaxCachedAssetPaths &&
cached.find(cache_key) == cached.end()) {
cached.erase(cached.begin());
}
cached.insert_or_assign(cache_key, resolved);
return resolved;
}
ResolvedAsset ResolveAssetPath(const char* filename) {
if (filename == nullptr) {
return {};
}
@@ -145,13 +167,16 @@ std::string ResolveAssetPath(const char* filename) {
const std::string requested(requested_view);
const char* root_env = GetEnvNonEmpty("MOCKTAIL_ASSET_ROOT");
const std::string root = root_env != nullptr ? root_env : "rbx_bin/assets";
const std::string cache_key = AssetCacheKey(root, requested);
const char* overlay_env = GetEnvNonEmpty("MOCKTAIL_ASSET_OVERLAY_DIR");
const std::string overlay =
overlay_env != nullptr ? overlay_env : "rbx_bin/asset_overlay";
const std::string cache_key = AssetCacheKey(root, overlay, requested);
{
std::scoped_lock lock(g_asset_lookup_cache.mutex);
auto it = g_asset_lookup_cache.resolved_paths.find(cache_key);
if (it != g_asset_lookup_cache.resolved_paths.end()) {
if (FileExists(it->second)) {
if (FileExists(it->second.path)) {
return it->second;
}
g_asset_lookup_cache.resolved_paths.erase(it);
@@ -160,25 +185,33 @@ std::string ResolveAssetPath(const char* filename) {
const std::string stripped(StripAndroidAssetPrefix(requested_view));
const std::vector<std::string> candidates = {
if (!overlay.empty()) {
const std::vector<std::string> overlay_candidates = {
overlay + "/" + requested,
overlay + "/" + stripped,
overlay + "/content/" + requested,
overlay + "/content/" + stripped,
overlay + "/ExtraContent/" + requested,
overlay + "/ExtraContent/" + stripped,
};
for (const std::string& candidate : overlay_candidates) {
if (FileExists(candidate)) {
return CacheResolvedAsset(cache_key, {candidate, true});
}
}
}
const std::vector<std::string> root_candidates = {
root + "/" + requested,
root + "/" + stripped,
root + "/content/" + requested,
root + "/content/" + stripped,
root + "/ExtraContent/" + requested,
root + "/ExtraContent/" + stripped,
};
for (const std::string& candidate : candidates) {
for (const std::string& candidate : root_candidates) {
if (FileExists(candidate)) {
std::scoped_lock lock(g_asset_lookup_cache.mutex);
if (g_asset_lookup_cache.resolved_paths.size() >=
kMaxCachedAssetPaths &&
g_asset_lookup_cache.resolved_paths.find(cache_key) ==
g_asset_lookup_cache.resolved_paths.end()) {
g_asset_lookup_cache.resolved_paths.erase(
g_asset_lookup_cache.resolved_paths.begin());
}
g_asset_lookup_cache.resolved_paths.insert_or_assign(cache_key,
candidate);
return candidate;
return CacheResolvedAsset(cache_key, {candidate, false});
}
}
@@ -330,24 +363,31 @@ AAsset* AAssetManager_open(AAssetManager* mgr, const char* filename,
if (mgr == nullptr) {
return nullptr;
}
std::string path = ResolveAssetPath(filename);
if (path.empty()) {
ResolvedAsset resolved = ResolveAssetPath(filename);
if (resolved.path.empty()) {
if (AssetTraceEnabled()) {
std::fprintf(stderr, "[asset] miss %s\n",
filename != nullptr ? filename : "(null)");
}
return nullptr;
}
auto* asset = new AAsset;
asset->path = path;
asset->path = resolved.path;
asset->apk_entry = ApkEntryName(filename);
if (!LoadFile(path, &asset->data)) {
asset->is_overlay = resolved.is_overlay;
if (!LoadFile(resolved.path, &asset->data)) {
delete asset;
return nullptr;
}
if (MocktailUsesDirectVulkan()) {
asset->transcoded =
libc_shim::TranscodeEtc1SkyTextureForVulkan(path.c_str(), &asset->data);
libc_shim::TranscodeEtc1SkyTextureForVulkan(resolved.path.c_str(),
&asset->data);
}
if (AssetTraceEnabled()) {
std::fprintf(stderr, "[asset] open %s -> %s (%zu bytes)\n", filename,
path.c_str(), asset->data.size());
std::fprintf(stderr, "[asset] open %s -> %s (%zu bytes%s)\n", filename,
resolved.path.c_str(), asset->data.size(),
resolved.is_overlay ? " [overlay]" : "");
}
return asset;
}
@@ -464,7 +504,8 @@ int AAsset_openFileDescriptor(AAsset* asset, off_t* outStart,
int fd = -1;
off_t start = 0;
off_t descriptor_length = static_cast<off_t>(asset->data.size());
if (!OpenStoredApkEntryDescriptor(asset->apk_entry, &fd, &start,
if (asset->is_overlay ||
!OpenStoredApkEntryDescriptor(asset->apk_entry, &fd, &start,
&descriptor_length)) {
fd = ::open(asset->path.c_str(), O_RDONLY | O_CLOEXEC);
}
+16
View File
@@ -11,6 +11,14 @@ target_link_libraries(jnivm_test PRIVATE
GTest::gtest_main
)
add_executable(jnivm_fmod_write_allocation_test
jnivm_fmod_write_allocation_test.cc
)
target_link_libraries(jnivm_fmod_write_allocation_test PRIVATE
Mocktail::LegacyJni
GTest::gtest_main
)
add_executable(jnivm_fields_test jnivm_fields_test.cc)
target_link_libraries(jnivm_fields_test PRIVATE
Mocktail::LegacyJni
@@ -214,6 +222,12 @@ target_link_libraries(runtime_paths_test PRIVATE
GTest::gtest_main
)
add_executable(asset_overlay_names_test asset_overlay_names_test.cc)
target_link_libraries(asset_overlay_names_test PRIVATE
Mocktail::Runtime
GTest::gtest_main
)
add_executable(single_instance_lock_test single_instance_lock_test.cc)
target_link_libraries(single_instance_lock_test PRIVATE
Mocktail::Runtime
@@ -1020,6 +1034,7 @@ target_link_libraries(headless_graphics_test PRIVATE
include(GoogleTest)
gtest_discover_tests(jnivm_test)
gtest_discover_tests(jnivm_fields_test)
gtest_discover_tests(jnivm_fmod_write_allocation_test)
gtest_discover_tests(runtime_config_test)
gtest_discover_tests(runtime_config_file_test)
gtest_discover_tests(payload_update_preflight_test)
@@ -1045,6 +1060,7 @@ gtest_discover_tests(game_mode_link_namespace_test PROPERTIES
)
gtest_discover_tests(memory_limit_test)
gtest_discover_tests(runtime_paths_test)
gtest_discover_tests(asset_overlay_names_test)
gtest_discover_tests(single_instance_lock_test)
gtest_discover_tests(supported_launch_policy_test)
gtest_discover_tests(process_launch_policy_test)
+113
View File
@@ -55,6 +55,7 @@ class AssetManagerTest : public ::testing::Test {
root_ = MakeTempRoot();
ASSERT_FALSE(root_.empty());
setenv("MOCKTAIL_ASSET_ROOT", root_.c_str(), 1);
unsetenv("MOCKTAIL_ASSET_OVERLAY_DIR");
const char* backend = std::getenv("MOCKTAIL_GRAPHICS_BACKEND");
had_backend_ = backend != nullptr;
previous_backend_ = backend != nullptr ? backend : "";
@@ -63,6 +64,7 @@ class AssetManagerTest : public ::testing::Test {
void TearDown() override {
unsetenv("MOCKTAIL_ASSET_ROOT");
unsetenv("MOCKTAIL_ASSET_OVERLAY_DIR");
if (had_backend_) {
setenv("MOCKTAIL_GRAPHICS_BACKEND", previous_backend_.c_str(), 1);
} else {
@@ -298,4 +300,115 @@ TEST_F(AssetManagerTest, CacheSeparatesRuntimeAssetRootChanges) {
AAsset_close(second);
}
TEST_F(AssetManagerTest, OverlayAssetTakesPrecedenceOverRootAsset) {
WriteAsset("content/textures/Cursors/KeyboardMouse/ArrowCursor.png",
"stock-arrow");
const std::string overlay_root = MakeTempRoot();
ASSERT_FALSE(overlay_root.empty());
WriteAssetAt(overlay_root,
"content/textures/Cursors/KeyboardMouse/ArrowCursor.png",
"custom-overlay-arrow");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", overlay_root.c_str(), 1), 0);
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
// 1. Direct content/ path
AAsset* direct = AAssetManager_open(
manager, "content/textures/Cursors/KeyboardMouse/ArrowCursor.png", 0);
ASSERT_NE(direct, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(direct)),
static_cast<std::size_t>(AAsset_getLength(direct))),
"custom-overlay-arrow");
AAsset_close(direct);
// 2. rbxasset:// URI prefix without content/
AAsset* rbxasset = AAssetManager_open(
manager, "rbxasset://textures/Cursors/KeyboardMouse/ArrowCursor.png", 0);
ASSERT_NE(rbxasset, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(rbxasset)),
static_cast<std::size_t>(AAsset_getLength(rbxasset))),
"custom-overlay-arrow");
AAsset_close(rbxasset);
// 3. Bare textures/ prefix
AAsset* bare = AAssetManager_open(
manager, "textures/Cursors/KeyboardMouse/ArrowCursor.png", 0);
ASSERT_NE(bare, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(bare)),
static_cast<std::size_t>(AAsset_getLength(bare))),
"custom-overlay-arrow");
AAsset_close(bare);
}
TEST_F(AssetManagerTest, OverlayOpenFileDescriptorReadsDirectlyFromFile) {
const std::string overlay_root = MakeTempRoot();
ASSERT_FALSE(overlay_root.empty());
WriteAssetAt(overlay_root, "content/custom_shader.bin", "custom-overlay-shader");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", overlay_root.c_str(), 1), 0);
// Point APK path to non-existent or dummy file to ensure descriptor reads overlay
ASSERT_EQ(setenv("MOCKTAIL_ASSET_APK_PATH", "/nonexistent/apk/path.apk", 1), 0);
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
AAsset* asset = AAssetManager_open(manager, "content/custom_shader.bin", 0);
ASSERT_NE(asset, nullptr);
off_t start = -1;
off_t length = -1;
int fd = AAsset_openFileDescriptor(asset, &start, &length);
ASSERT_GE(fd, 0);
EXPECT_EQ(start, 0);
EXPECT_EQ(length, 21);
char buf[32] = {};
EXPECT_EQ(pread(fd, buf, 21, start), 21);
EXPECT_STREQ(buf, "custom-overlay-shader");
close(fd);
AAsset_close(asset);
unsetenv("MOCKTAIL_ASSET_APK_PATH");
}
TEST_F(AssetManagerTest, NonOverlaidAssetFallsBackToRootAsset) {
WriteAsset("content/sounds/oof.mp3", "stock-sound");
const std::string overlay_root = MakeTempRoot();
ASSERT_FALSE(overlay_root.empty());
WriteAssetAt(overlay_root, "content/textures/unrelated.png", "other-texture");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", overlay_root.c_str(), 1), 0);
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
AAsset* asset = AAssetManager_open(manager, "sounds/oof.mp3", 0);
ASSERT_NE(asset, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(asset)),
static_cast<std::size_t>(AAsset_getLength(asset))),
"stock-sound");
AAsset_close(asset);
}
TEST_F(AssetManagerTest, CacheSeparatesOverlayDirectoryChanges) {
WriteAsset("content/test.txt", "stock");
const std::string first_overlay = MakeTempRoot();
ASSERT_FALSE(first_overlay.empty());
WriteAssetAt(first_overlay, "content/test.txt", "overlay-1");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", first_overlay.c_str(), 1), 0);
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
AAsset* first = AAssetManager_open(manager, "content/test.txt", 0);
ASSERT_NE(first, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(first)),
static_cast<std::size_t>(AAsset_getLength(first))),
"overlay-1");
AAsset_close(first);
const std::string second_overlay = MakeTempRoot();
ASSERT_FALSE(second_overlay.empty());
WriteAssetAt(second_overlay, "content/test.txt", "overlay-2");
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", second_overlay.c_str(), 1), 0);
AAsset* second = AAssetManager_open(manager, "content/test.txt", 0);
ASSERT_NE(second, nullptr);
EXPECT_EQ(std::string(static_cast<const char*>(AAsset_getBuffer(second)),
static_cast<std::size_t>(AAsset_getLength(second))),
"overlay-2");
AAsset_close(second);
}
} // namespace
+122
View File
@@ -0,0 +1,122 @@
#include "runtime/asset_overlay_names.h"
#include <gtest/gtest.h>
#include <unistd.h>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <string>
#include <string_view>
#include <vector>
namespace {
using mocktail::runtime::DisableAssetNameMappings;
using mocktail::runtime::OverlayRbxAssetNames;
class TemporaryDirectory {
public:
TemporaryDirectory() {
char pattern[] = "/tmp/mocktail_asset_overlay_names_XXXXXX";
char* created = mkdtemp(pattern);
if (created != nullptr) {
path_ = created;
}
}
~TemporaryDirectory() {
std::error_code error;
std::filesystem::remove_all(path_, error);
}
const std::filesystem::path& path() const { return path_; }
private:
std::filesystem::path path_;
};
void WriteFile(const std::filesystem::path& path) {
std::filesystem::create_directories(path.parent_path());
std::ofstream(path) << "x";
}
// Joins strings the way a string table stores them: each followed by a NUL.
std::vector<std::uint8_t> StringTable(
const std::vector<std::string_view>& strings) {
std::vector<std::uint8_t> table;
for (std::string_view text : strings) {
table.insert(table.end(), text.begin(), text.end());
table.push_back(0);
}
return table;
}
std::string TextAt(const std::vector<std::uint8_t>& table, std::size_t offset) {
return std::string(reinterpret_cast<const char*>(table.data()) + offset);
}
TEST(AssetOverlayNamesTest, ListsFilesUnderContentAsRbxAssetNames) {
TemporaryDirectory overlay;
ASSERT_FALSE(overlay.path().empty());
WriteFile(overlay.path() / "content/textures/MouseLockedCursor.png");
WriteFile(overlay.path() /
"content/textures/Cursors/KeyboardMouse/IBeamCursor.png");
WriteFile(overlay.path() / "notes.txt");
std::filesystem::create_directories(overlay.path() / "content/empty");
EXPECT_EQ(OverlayRbxAssetNames(overlay.path()),
(std::vector<std::string>{
"rbxasset://textures/Cursors/KeyboardMouse/IBeamCursor.png",
"rbxasset://textures/MouseLockedCursor.png",
}));
}
TEST(AssetOverlayNamesTest, MissingOverlayFolderHasNoNames) {
TemporaryDirectory overlay;
ASSERT_FALSE(overlay.path().empty());
EXPECT_TRUE(OverlayRbxAssetNames(overlay.path() / "absent").empty());
EXPECT_TRUE(OverlayRbxAssetNames(overlay.path()).empty());
}
TEST(AssetOverlayNamesTest, ChangesOnlyTheNamedWholeStrings) {
const std::string name = "rbxasset://textures/MouseLockedCursor.png";
const std::string longer = name + ".bak";
const std::string prefixed = "x" + name;
std::vector<std::uint8_t> table =
StringTable({"first", name, longer, prefixed, name});
const std::size_t first_copy = 6;
const std::size_t longer_copy = first_copy + name.size() + 1;
const std::size_t prefixed_copy = longer_copy + longer.size() + 1;
const std::size_t second_copy = prefixed_copy + prefixed.size() + 1;
EXPECT_EQ(DisableAssetNameMappings(table.data(), table.size(), {name}), 2u);
EXPECT_EQ(TextAt(table, 0), "first");
EXPECT_EQ(TextAt(table, first_copy), "!" + name.substr(1));
EXPECT_EQ(TextAt(table, longer_copy), longer);
EXPECT_EQ(TextAt(table, prefixed_copy), prefixed);
EXPECT_EQ(TextAt(table, second_copy), "!" + name.substr(1));
}
TEST(AssetOverlayNamesTest, MatchesANameAtTheStartOfTheData) {
const std::string name = "rbxasset://textures/IBeamCursor.png";
std::vector<std::uint8_t> table = StringTable({name, "other"});
EXPECT_EQ(DisableAssetNameMappings(table.data(), table.size(), {name}), 1u);
EXPECT_EQ(TextAt(table, 0), "!" + name.substr(1));
}
TEST(AssetOverlayNamesTest, LeavesDataAloneWithoutAMatch) {
std::vector<std::uint8_t> table = StringTable({"alpha", "beta"});
const std::vector<std::uint8_t> before = table;
EXPECT_EQ(DisableAssetNameMappings(table.data(), table.size(),
{"rbxasset://missing.png", ""}),
0u);
EXPECT_EQ(table, before);
EXPECT_EQ(DisableAssetNameMappings(nullptr, 0, {"alpha"}), 0u);
}
} // namespace
@@ -29,6 +29,13 @@ import types
import unittest
from unittest import mock
from host_abi_regression import (
expected_runtime,
load_regression_build,
regression_library,
regression_manifest,
regression_sidecar,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
ANALYZER_PATH = PROJECT_ROOT / "scripts" / "derive_roblox_host_abi_profile.py"
@@ -836,5 +843,55 @@ class FallbackRuntimeCompatibilityAcceptanceTest(unittest.TestCase):
self.assertNotIn("fmod_output_device_bridge", derived)
# Each pair derives the second build from the first.
REGRESSION_PAIRS = (("2908", "2998"), ("2998", "3092"), ("3092", "3222"))
def regression_reference_inputs(build: dict, directory: Path) -> tuple[Path, Path]:
sidecar_path = directory / "reference_profile.json"
manifest_path = directory / "reference_compatibility.json"
sidecar_path.write_text(json.dumps(regression_sidecar(build)), encoding="utf-8")
manifest_path.write_text(json.dumps(regression_manifest(build)), encoding="utf-8")
return sidecar_path, manifest_path
@unittest.skipUnless(ANALYZER.capstone is not None, "Python capstone is unavailable")
class KnownBuildRegressionTest(unittest.TestCase):
def test_fixtures_match_their_build_identity(self) -> None:
for name in {name for pair in REGRESSION_PAIRS for name in pair}:
build = load_regression_build(name)
self.assertEqual(build["payload_id"].split("-", 1), [name, build["elf_build_id"]])
self.assertEqual(build["profile"]["elf_build_id"], build["elf_build_id"])
self.assertNotEqual(build["reference"]["elf_build_id"], build["elf_build_id"])
def test_derives_each_build_from_its_predecessor(self) -> None:
for reference_name, candidate_name in REGRESSION_PAIRS:
with self.subTest(reference=reference_name, candidate=candidate_name):
self.derive_pair(reference_name, candidate_name)
def derive_pair(self, reference_name: str, candidate_name: str) -> None:
reference = load_regression_build(reference_name)
candidate = load_regression_build(candidate_name)
reference_library = regression_library(reference)
candidate_library = regression_library(candidate)
if reference_library is None or candidate_library is None:
self.skipTest(f"exact {reference_name} and {candidate_name} payloads are unavailable")
with tempfile.TemporaryDirectory() as temporary:
sidecar_path, manifest_path = regression_reference_inputs(reference, Path(temporary))
sidecar = ANALYZER.validated_sidecar(sidecar_path)
with ANALYZER.ElfImage(reference_library) as reference_image, ANALYZER.ElfImage(
candidate_library
) as candidate_image:
profile, anchors = ANALYZER.derive_profile(
reference_image, candidate_image, sidecar
)
runtime = ANALYZER.derive_runtime_compatibility(
reference_image, candidate_image, (manifest_path,)
)
self.assertEqual(profile, candidate["profile"])
self.assertEqual(anchors, candidate["derivation_anchors"])
self.assertEqual(runtime, expected_runtime(reference, candidate))
if __name__ == "__main__":
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
{
"derivation_anchors": {
"allocator_object_initializer_rva": "0x2186a91",
"constructor_rvas": {
"2": "0x1cf9f31",
"3": "0x1cf5680",
"4": "0x1cf63c8",
"5": "0x1cf6309"
},
"empty_string_initializer_rva": "0x2192669",
"jni_singleton_initializer_rva": "0x1cf6309",
"signature_version": 1
},
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
"libroblox_sha256": "72c3b1a5c151b5ac0bfc5394ce3a91775884fbd777589ccab8ac1c4f9885f739",
"payload_id": "2908-63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
"profile": {
"bridge_entries": [
{
"kind": "allocate",
"label": "small-allocate",
"rva": "0x1cf69e2"
},
{
"kind": "usable_size",
"label": "usable-size",
"rva": "0x1cf9c3a"
},
{
"kind": "reallocate",
"label": "reallocate",
"rva": "0x21af33f"
},
{
"kind": "allocate",
"label": "allocate",
"rva": "0x1cf677e"
},
{
"kind": "aligned_allocate",
"label": "aligned-allocate-direct",
"rva": "0x1d12197"
},
{
"kind": "free",
"label": "free",
"rva": "0x1cfa1d9"
}
],
"constructor_run_ranges": [
{
"begin": 2,
"end_exclusive": 3
},
{
"begin": 5,
"end_exclusive": 3556
}
],
"data_seeds": {
"allocator_object_slot": "0x7571570",
"allocator_thread_initializer": "0x1d82be7",
"arena_guard_slot": "0x7a10730",
"arena_initializer": "0x1cf6df8",
"arena_table_slot": "0x7a107c0",
"arena_table_slot_count": 4194304,
"empty_string_slot": "0x767e428",
"jni_singleton_bytes": 1024,
"jni_singleton_slot": "0x7ad47e8"
},
"default_allocator_strategy": "native_mimalloc",
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
"init_array_count": 3556,
"init_array_offset": "0x6f78a18",
"native_allocator": {
"allocate": "0x1cf677e",
"deallocate": "0x1cfa1d9"
},
"native_mimalloc_constructor_run_ranges": [
{
"begin": 2,
"end_exclusive": 3556
}
],
"native_mimalloc_thread_initializer_after_constructor": 2,
"native_pre_jni_bootstrap": {
"registry_initializer": "0x21b1889",
"registry_slot": "0x7a14898"
}
},
"reference": {
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
"payload_sha256": "3e9c26c81186f93458ff65d8a8bc240c220974db02b8388b91340b77b336997d"
},
"runtime_compatibility": {
"fmod_output_device_bridge": {
"count_method_rva": "0x32736d2",
"current_method_rva": "0x3273722",
"info_method_rva": "0x3273772",
"select_method_rva": "0x32734a6",
"string_constructor_rva": "0x1cfd5ec",
"vtable_rva": "0x6b7eee8"
},
"user_game_settings_fullscreen_setter_rva": "0x44fee64"
},
"schema_version": 1,
"version_code": 2908,
"version_name": "2.734.917"
}
+113
View File
@@ -0,0 +1,113 @@
{
"derivation_anchors": {
"allocator_object_initializer_rva": "0x21bde7d",
"constructor_rvas": {
"2": "0x1d2f731",
"3": "0x1d2af84",
"4": "0x1d2bc08",
"5": "0x1d2aec0"
},
"empty_string_initializer_rva": "0x21c9a86",
"jni_singleton_initializer_rva": "0x1d2aec0",
"signature_version": 1
},
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"libroblox_sha256": "4343a6a900d1fca27ff8e10b9d9c86bee40a5d0ba547cb29903b7769f4f42a9d",
"payload_id": "2998-ade08266c67aee88ec9c1d00902150e1684dad3a",
"profile": {
"bridge_entries": [
{
"kind": "allocate",
"label": "small-allocate",
"rva": "0x1d2c1e2"
},
{
"kind": "usable_size",
"label": "usable-size",
"rva": "0x1d2f43a"
},
{
"kind": "reallocate",
"label": "reallocate",
"rva": "0x21e68bf"
},
{
"kind": "allocate",
"label": "allocate",
"rva": "0x1d2bf82"
},
{
"kind": "aligned_allocate",
"label": "aligned-allocate-direct",
"rva": "0x1d47a63"
},
{
"kind": "free",
"label": "free",
"rva": "0x1d2f9d9"
}
],
"constructor_run_ranges": [
{
"begin": 2,
"end_exclusive": 3
},
{
"begin": 5,
"end_exclusive": 3570
}
],
"data_seeds": {
"allocator_object_slot": "0x7652e70",
"allocator_thread_initializer": "0x1db7b57",
"arena_guard_slot": "0x7af0430",
"arena_initializer": "0x1d2c5f8",
"arena_table_slot": "0x7af04c0",
"arena_table_slot_count": 4194304,
"empty_string_slot": "0x775fd28",
"jni_singleton_bytes": 1024,
"jni_singleton_slot": "0x7bb4340"
},
"default_allocator_strategy": "native_mimalloc",
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"init_array_count": 3570,
"init_array_offset": "0x7057818",
"native_allocator": {
"allocate": "0x1d2bf82",
"deallocate": "0x1d2f9d9"
},
"native_mimalloc_constructor_run_ranges": [
{
"begin": 2,
"end_exclusive": 3570
}
],
"native_mimalloc_thread_initializer_after_constructor": 2,
"native_pre_jni_bootstrap": {
"registry_initializer": "0x21e8e09",
"registry_slot": "0x7af4598"
}
},
"reference": {
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
"payload_sha256": "72c3b1a5c151b5ac0bfc5394ce3a91775884fbd777589ccab8ac1c4f9885f739"
},
"runtime_compatibility": {
"fmod_output_device_bridge": {
"count_method_rva": "0x32d0ee0",
"current_method_rva": "0x32d0f30",
"info_method_rva": "0x32d0f80",
"input_count_method_rva": "0x32d12dc",
"input_current_method_rva": "0x32d13a6",
"input_info_method_rva": "0x32d12ea",
"input_select_method_rva": "0x32d0a56",
"select_method_rva": "0x32d0cb4",
"string_constructor_rva": "0x1d32df8",
"vtable_rva": "0x6c58040"
},
"user_game_settings_fullscreen_setter_rva": "0x45ad8aa"
},
"schema_version": 1,
"version_code": 2998,
"version_name": "2.736.1408"
}
+114
View File
@@ -0,0 +1,114 @@
{
"derivation_anchors": {
"allocator_object_initializer_rva": "0x2206883",
"constructor_rvas": {
"2": "0x1d76971",
"3": "0x1d721bf",
"4": "0x1d72e43",
"5": "0x1d72100"
},
"empty_string_initializer_rva": "0x2212949",
"jni_singleton_initializer_rva": "0x1d72100",
"signature_version": 1
},
"elf_build_id": "5f0704edd9064f566ee3d6df2bd2fabbcc709f03",
"libroblox_sha256": "8f7079c8977b88c8d9b61be201f7156b5041d7623863357f9ad6c3b0062531e9",
"payload_id": "3092-5f0704edd9064f566ee3d6df2bd2fabbcc709f03",
"profile": {
"bridge_entries": [
{
"kind": "allocate",
"label": "small-allocate",
"rva": "0x1d73422"
},
{
"kind": "usable_size",
"label": "usable-size",
"rva": "0x1d7667a"
},
{
"kind": "reallocate",
"label": "reallocate",
"rva": "0x222f4c0"
},
{
"kind": "allocate",
"label": "allocate",
"rva": "0x1d731c2"
},
{
"kind": "aligned_allocate",
"label": "aligned-allocate-direct",
"rva": "0x1d8e7f9"
},
{
"kind": "free",
"label": "free",
"rva": "0x1d76c19"
}
],
"constructor_run_ranges": [
{
"begin": 2,
"end_exclusive": 3
},
{
"begin": 5,
"end_exclusive": 3599
}
],
"data_seeds": {
"allocator_object_slot": "0x76e2da0",
"allocator_thread_initializer": "0x1dffad7",
"arena_guard_slot": "0x7b87830",
"arena_initializer": "0x1d73838",
"arena_table_slot": "0x7b878c0",
"arena_table_slot_count": 4194304,
"empty_string_slot": "0x77efb98",
"jni_singleton_bytes": 1024,
"jni_singleton_slot": "0x7c4b780"
},
"default_allocator_strategy": "native_mimalloc",
"elf_build_id": "5f0704edd9064f566ee3d6df2bd2fabbcc709f03",
"init_array_count": 3599,
"init_array_offset": "0x70e1618",
"native_allocator": {
"allocate": "0x1d731c2",
"deallocate": "0x1d76c19"
},
"native_mimalloc_constructor_run_ranges": [
{
"begin": 2,
"end_exclusive": 3599
}
],
"native_mimalloc_thread_initializer_after_constructor": 2,
"native_pre_jni_bootstrap": {
"registry_initializer": "0x22308d3",
"registry_slot": "0x7b8b998"
}
},
"reference": {
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"payload_sha256": "4343a6a900d1fca27ff8e10b9d9c86bee40a5d0ba547cb29903b7769f4f42a9d"
},
"runtime_compatibility": {
"fmod_output_device_bridge": {
"count_method_rva": "0x320c676",
"current_method_rva": "0x320c6c6",
"info_method_rva": "0x320c716",
"input_count_method_rva": "0x320ccf6",
"input_current_method_rva": "0x320d152",
"input_info_method_rva": "0x320cd04",
"input_select_method_rva": "0x320bd18",
"select_method_rva": "0x320bf78",
"string_constructor_rva": "0x1d7a038",
"vtable_layout_version": 2,
"vtable_rva": "0x6cd3ce0"
},
"user_game_settings_fullscreen_setter_rva": "0x45c5f42"
},
"schema_version": 1,
"version_code": 3092,
"version_name": "2.738.1397"
}
+114
View File
@@ -0,0 +1,114 @@
{
"derivation_anchors": {
"allocator_object_initializer_rva": "0x221bb6b",
"constructor_rvas": {
"2": "0x1e140f0",
"3": "0x1e14f8b",
"4": "0x1e13f80",
"5": "0x1e189db"
},
"empty_string_initializer_rva": "0x2227e95",
"jni_singleton_initializer_rva": "0x1e14f8b",
"signature_version": 1
},
"elf_build_id": "a67a91ef96c700c0730acd36a250c2f93cfafbc1",
"libroblox_sha256": "4892d8949918db453f72d3db64093bc985f3ed6dba870a6381d9f3be3b7a3338",
"payload_id": "3222-a67a91ef96c700c0730acd36a250c2f93cfafbc1",
"profile": {
"bridge_entries": [
{
"kind": "allocate",
"label": "small-allocate",
"rva": "0x1e154a2"
},
{
"kind": "usable_size",
"label": "usable-size",
"rva": "0x1e18647"
},
{
"kind": "reallocate",
"label": "reallocate",
"rva": "0x22446da"
},
{
"kind": "allocate",
"label": "allocate",
"rva": "0x1e15256"
},
{
"kind": "aligned_allocate",
"label": "aligned-allocate-direct",
"rva": "0x1e2eae7"
},
{
"kind": "free",
"label": "free",
"rva": "0x1e18c83"
}
],
"constructor_run_ranges": [
{
"begin": 3,
"end_exclusive": 4
},
{
"begin": 5,
"end_exclusive": 3678
}
],
"data_seeds": {
"allocator_object_slot": "0x732dec8",
"allocator_thread_initializer": "0x1e95df7",
"arena_guard_slot": "0x7a75430",
"arena_initializer": "0x1e15827",
"arena_table_slot": "0x7a754c0",
"arena_table_slot_count": 4194304,
"empty_string_slot": "0x79a6130",
"jni_singleton_bytes": 1024,
"jni_singleton_slot": "0x7a66658"
},
"default_allocator_strategy": "native_mimalloc",
"elf_build_id": "a67a91ef96c700c0730acd36a250c2f93cfafbc1",
"init_array_count": 3678,
"init_array_offset": "0x72a74f0",
"native_allocator": {
"allocate": "0x1e15256",
"deallocate": "0x1e18c83"
},
"native_mimalloc_constructor_run_ranges": [
{
"begin": 2,
"end_exclusive": 3678
}
],
"native_mimalloc_thread_initializer_after_constructor": 5,
"native_pre_jni_bootstrap": {
"registry_initializer": "0x2245a0c",
"registry_slot": "0x79a9478"
}
},
"reference": {
"elf_build_id": "5f0704edd9064f566ee3d6df2bd2fabbcc709f03",
"payload_sha256": "8f7079c8977b88c8d9b61be201f7156b5041d7623863357f9ad6c3b0062531e9"
},
"runtime_compatibility": {
"fmod_output_device_bridge": {
"count_method_rva": "0x2ee4890",
"current_method_rva": "0x2ee48e0",
"info_method_rva": "0x2ee4930",
"input_count_method_rva": "0x2ee4f10",
"input_current_method_rva": "0x2ee536c",
"input_info_method_rva": "0x2ee4f1e",
"input_select_method_rva": "0x2ee3f32",
"select_method_rva": "0x2ee4192",
"string_constructor_rva": "0x1e1c0b4",
"vtable_layout_version": 2,
"vtable_rva": "0x6e3d1d0"
},
"user_game_settings_fullscreen_setter_rva": "0x5d0af38"
},
"schema_version": 1,
"version_code": 3222,
"version_name": "2.742.1059"
}
+96
View File
@@ -0,0 +1,96 @@
# Copyright 2026 Mocktail Project Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Known Roblox builds shared by the HostAbi deriver and solver tests.
Every fixture holds answers that a real launch of that build accepted. A
build's library is used only when its bytes match the fixture's SHA-256.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[1]
PAYLOAD_ROOT = Path.home() / ".local" / "share" / "mocktail" / "payloads"
# Holds <payload_id>/libroblox.so for builds outside the payload store.
REGRESSION_CACHE = Path.home() / ".cache" / "mocktail" / "host_abi_regression"
REGRESSION_FIXTURES = PROJECT_ROOT / "tests" / "fixtures" / "host_abi_regression"
def load_regression_build(name: str) -> dict[str, Any]:
path = REGRESSION_FIXTURES / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
def regression_library(build: dict[str, Any]) -> Path | None:
for root in (PAYLOAD_ROOT, REGRESSION_CACHE):
library = root / build["payload_id"] / "libroblox.so"
if not library.is_file():
continue
digest = hashlib.sha256()
with library.open("rb") as stream:
for chunk in iter(lambda: stream.read(1 << 20), b""):
digest.update(chunk)
if digest.hexdigest() == build["libroblox_sha256"]:
return library
return None
def expected_runtime(reference: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
"""The candidate's runtime answers that the reference has inputs for."""
expected = json.loads(json.dumps(candidate["runtime_compatibility"]))
source = reference["runtime_compatibility"].get("fmod_output_device_bridge", {})
bridge = expected.get("fmod_output_device_bridge")
if bridge is not None:
# Input methods are derived only from a reference that lists them.
for field in [name for name in bridge if name.startswith("input_")]:
if field not in source:
del bridge[field]
return expected
def regression_sidecar(build: dict[str, Any]) -> dict[str, Any]:
"""Returns the build as the reference sidecar the deriver reads."""
sidecar = {
key: build[key]
for key in (
"schema_version",
"elf_build_id",
"payload_id",
"reference",
"profile",
"derivation_anchors",
)
}
sidecar["payload_sha256"] = build["libroblox_sha256"]
sidecar["payload_path"] = f"payloads/{build['payload_id']}"
return sidecar
def regression_manifest(build: dict[str, Any]) -> dict[str, Any]:
"""Returns the build's runtime answers as a reference compatibility manifest."""
return {
"schema_version": 1,
"profiles": [
{
"elf_build_id": build["elf_build_id"],
"allow_host_abi_bridges": True,
**build["runtime_compatibility"],
}
],
}
+80
View File
@@ -0,0 +1,80 @@
// Counts heap allocations made on the audio thread by FMOD AudioDevice.write.
#include "jnivm/jnivm.h"
#include <gtest/gtest.h>
#include <cstdint>
#include <cstdlib>
#include <memory>
#include <new>
namespace {
thread_local bool g_counting = false;
thread_local std::size_t g_allocations = 0;
void* CountedAllocate(std::size_t size) noexcept {
if (g_counting) {
++g_allocations;
}
return std::malloc(size == 0 ? 1 : size);
}
} // namespace
void* operator new(std::size_t size) {
if (void* memory = CountedAllocate(size)) {
return memory;
}
throw std::bad_alloc();
}
void* operator new[](std::size_t size) { return operator new(size); }
void* operator new(std::size_t size, const std::nothrow_t&) noexcept {
return CountedAllocate(size);
}
void* operator new[](std::size_t size, const std::nothrow_t&) noexcept {
return CountedAllocate(size);
}
void operator delete(void* memory) noexcept { std::free(memory); }
void operator delete[](void* memory) noexcept { std::free(memory); }
void operator delete(void* memory, std::size_t) noexcept { std::free(memory); }
void operator delete[](void* memory, std::size_t) noexcept {
std::free(memory);
}
namespace {
struct WriteProbe {
int writes = 0;
};
TEST(FmodAudioDeviceWrite, SteadyStateWriteDoesNotAllocate) {
auto vm = std::make_shared<jnivm::VM>();
JNIEnv* env = vm->GetJNIEnv();
auto probe = std::make_shared<WriteProbe>();
jnivm::FmodAudioDeviceCallbacks callbacks;
callbacks.write = [](void* context, const void*, const std::uint8_t*,
std::size_t) {
++static_cast<WriteProbe*>(context)->writes;
return true;
};
vm->SetFmodAudioDeviceCallbacks(probe, callbacks);
jclass device_class = env->FindClass("org/fmod/AudioDevice");
jobject device = env->NewObject(
device_class, env->GetMethodID(device_class, "<init>", "()V"));
const jmethodID write = env->GetMethodID(device_class, "write", "([BI)V");
jbyteArray block = env->NewByteArray(4096);
env->CallVoidMethod(device, write, block, 4096);
g_allocations = 0;
g_counting = true;
for (int i = 0; i < 64; ++i) {
env->CallVoidMethod(device, write, block, 4096);
}
g_counting = false;
EXPECT_EQ(g_allocations, 0u);
EXPECT_EQ(probe->writes, 65);
vm->ClearFmodAudioDeviceCallbacks();
}
} // namespace
+159
View File
@@ -3211,5 +3211,164 @@ TEST_F(JniVmTest, DeletedHighIndexStringHandleReadsAsEmpty) {
EXPECT_EQ(env->GetStringLength(high), 0);
}
std::size_t CountOccurrences(const std::string &text,
const std::string &needle) {
std::size_t count = 0;
for (std::size_t at = text.find(needle); at != std::string::npos;
at = text.find(needle, at + needle.size())) {
++count;
}
return count;
}
TEST_F(JniVmTest, UnhandledCallIsReportedOncePerClassAndMethod) {
JNIEnv *env = vm_->GetJNIEnv();
jclass first = env->FindClass("example/UnhandledFirst");
jclass second = env->FindClass("example/UnhandledSecond");
const jmethodID constructor = env->GetMethodID(first, "<init>", "()V");
jobject first_object = env->NewObject(first, constructor);
jobject second_object = env->NewObject(second, constructor);
const jmethodID method = env->GetMethodID(first, "zzReportedVoid", "(I)V");
::testing::internal::CaptureStderr();
env->CallVoidMethod(first_object, method, 1);
env->CallVoidMethod(first_object, method, 2);
CallVoidMethodVForTest(env, first_object, method, 3);
env->CallVoidMethod(second_object, method, 4);
const std::string output = ::testing::internal::GetCapturedStderr();
EXPECT_EQ(CountOccurrences(output, " [JNI] no handler for "
"example/UnhandledFirst.zzReportedVoid(I)V\n"),
1u);
EXPECT_EQ(CountOccurrences(output, " [JNI] no handler for "
"example/UnhandledSecond.zzReportedVoid(I)V\n"),
1u);
}
TEST_F(JniVmTest, UnhandledCallsAreReportedForEveryResultType) {
JNIEnv *env = vm_->GetJNIEnv();
jclass cls = env->FindClass("example/UnhandledResults");
jobject object =
env->NewObject(cls, env->GetMethodID(cls, "<init>", "()V"));
const auto id = [&](const char *name, const char *sig) {
return env->GetMethodID(cls, name, sig);
};
const auto static_id = [&](const char *name, const char *sig) {
return env->GetStaticMethodID(cls, name, sig);
};
::testing::internal::CaptureStderr();
EXPECT_EQ(env->CallObjectMethod(object, id("zzObject", "()Ljava/lang/Object;")),
nullptr);
EXPECT_EQ(env->CallBooleanMethod(object, id("zzBoolean", "()Z")), JNI_FALSE);
EXPECT_EQ(env->CallIntMethod(object, id("zzInt", "()I")), 0);
EXPECT_EQ(env->CallLongMethod(object, id("zzLong", "()J")), 0);
EXPECT_EQ(env->CallFloatMethod(object, id("zzFloat", "()F")), 0.0f);
EXPECT_EQ(env->CallDoubleMethod(object, id("zzDouble", "()D")), 0.0);
EXPECT_EQ(env->CallByteMethod(object, id("zzByte", "()B")), 0);
env->CallStaticVoidMethod(cls, static_id("zzStaticVoid", "()V"));
EXPECT_EQ(env->CallStaticObjectMethod(
cls, static_id("zzStaticObject", "()Ljava/lang/Object;")),
nullptr);
EXPECT_EQ(
env->CallStaticBooleanMethod(cls, static_id("zzStaticBoolean", "()Z")),
JNI_FALSE);
EXPECT_EQ(env->CallStaticIntMethod(cls, static_id("zzStaticInt", "()I")), 0);
EXPECT_EQ(env->CallStaticLongMethod(cls, static_id("zzStaticLong", "()J")),
0);
EXPECT_EQ(env->CallStaticFloatMethod(cls, static_id("zzStaticFloat", "()F")),
0.0f);
const std::string output = ::testing::internal::GetCapturedStderr();
const std::string prefix = " [JNI] no handler for example/UnhandledResults.";
const std::string static_prefix =
" [JNI] no handler for static example/UnhandledResults.";
for (const char *method :
{"zzObject()Ljava/lang/Object;", "zzBoolean()Z", "zzInt()I", "zzLong()J",
"zzFloat()F", "zzDouble()D", "zzByte()B"}) {
EXPECT_EQ(CountOccurrences(output, prefix + method + "\n"), 1u) << method;
}
for (const char *method :
{"zzStaticVoid()V", "zzStaticObject()Ljava/lang/Object;",
"zzStaticBoolean()Z", "zzStaticInt()I", "zzStaticLong()J",
"zzStaticFloat()F"}) {
EXPECT_EQ(CountOccurrences(output, static_prefix + method + "\n"), 1u)
<< method;
}
}
TEST_F(JniVmTest, HandledAndDeliberatelyIgnoredCallsAreNotReported) {
JNIEnv *env = vm_->GetJNIEnv();
jclass cls = env->FindClass("example/HandledProbe");
jobject object =
env->NewObject(cls, env->GetMethodID(cls, "<init>", "()V"));
env->SetIntField(object, env->GetFieldID(cls, "zzCount", "I"), 7);
jclass callback_class =
env->FindClass("com/roblox/engine/jni/EngineJavaCallback2");
jobject callback = env->NewObject(
callback_class, env->GetMethodID(callback_class, "<init>", "()V"));
jclass helper_class = env->FindClass("com/roblox/client/startup/NativeHelper");
jobject helper = env->NewObject(
helper_class, env->GetMethodID(helper_class, "<init>", "()V"));
jvalue no_args[1] = {};
::testing::internal::CaptureStderr();
(void)env->CallIntMethod(object, env->GetMethodID(cls, "getScreenWidth", "()I"));
EXPECT_EQ(env->CallIntMethod(object, env->GetMethodID(cls, "getZzCount", "()I")),
7);
env->CallVoidMethod(callback,
env->GetMethodID(callback_class, "c", "()V"));
env->CallVoidMethodA(
helper, env->GetMethodID(helper_class, "gameActivity_zzLater", "()V"),
no_args);
const std::string output = ::testing::internal::GetCapturedStderr();
EXPECT_EQ(output.find("no handler for"), std::string::npos) << output;
}
TEST_F(JniVmTest, UnmatchedRunAAndFVoidCallsAreReported) {
JNIEnv *env = vm_->GetJNIEnv();
jclass cls = env->FindClass("example/UnmatchedShortNames");
jobject object =
env->NewObject(cls, env->GetMethodID(cls, "<init>", "()V"));
jvalue no_args[1] = {};
::testing::internal::CaptureStderr();
env->CallVoidMethod(object, env->GetMethodID(cls, "run", "()V"));
env->CallVoidMethod(object, env->GetMethodID(cls, "f", "()V"));
env->CallVoidMethodA(object, env->GetMethodID(cls, "a", "()V"), no_args);
const std::string output = ::testing::internal::GetCapturedStderr();
const std::string prefix =
" [JNI] no handler for example/UnmatchedShortNames.";
for (const char *method : {"run()V", "f()V", "a()V"}) {
EXPECT_EQ(CountOccurrences(output, prefix + method + "\n"), 1u) << method;
}
}
TEST_F(JniVmTest, UnknownGameActivityCallbackIsRecordedOnEveryVoidPath) {
JNIEnv *env = vm_->GetJNIEnv();
jclass helper_class = env->FindClass("com/roblox/client/startup/NativeHelper");
jobject helper = env->NewObject(
helper_class, env->GetMethodID(helper_class, "<init>", "()V"));
jvalue no_args[1] = {};
::testing::internal::CaptureStderr();
env->CallVoidMethod(
helper, env->GetMethodID(helper_class, "gameActivity_zzViaV", "()V"));
env->CallVoidMethodA(
helper, env->GetMethodID(helper_class, "gameActivity_zzViaA", "()V"),
no_args);
const std::string output = ::testing::internal::GetCapturedStderr();
EXPECT_EQ(output.find("no handler for"), std::string::npos) << output;
for (const char *name : {"gameActivity_zzViaV", "gameActivity_zzViaA"}) {
EXPECT_EQ(env->GetBooleanField(helper,
env->GetFieldID(helper_class, name, "Z")),
JNI_TRUE)
<< name;
}
}
} // namespace
} // namespace jnivm
+35 -5
View File
@@ -15,6 +15,7 @@ readonly AUR_BIN_SRCINFO="${ROOT}/packaging/aur/nightcap-bin/.SRCINFO"
readonly AUR_PKGBUILD="${ROOT}/packaging/aur/nightcap-git/PKGBUILD"
readonly AUR_SRCINFO="${ROOT}/packaging/aur/nightcap-git/.SRCINFO"
readonly AUR_WORKFLOW="${ROOT}/.github/workflows/aur.yml"
readonly AUR_PUBLISH_WORKFLOW="${ROOT}/.github/workflows/aur-publish.yml"
readonly WORKFLOW="${ROOT}/.github/workflows/packages.yml"
readonly RELEASE_WORKFLOW="${ROOT}/.github/workflows/release.yml"
readonly README="${ROOT}/README.md"
@@ -93,11 +94,11 @@ grep -Fq 'git describe' "${AUR_PKGBUILD}" ||
grep -Fq 'git describe' "${AUR_WORKFLOW}" ||
Fail 'AUR workflow does not version from the release tag'
for pkgbuild in "${PKGBUILD}" "${AUR_STABLE_PKGBUILD}" "${AUR_BIN_PKGBUILD}"; do
grep -Fqx 'pkgver=0.3.1' "${pkgbuild}" ||
grep -Fqx 'pkgver=0.3.4' "${pkgbuild}" ||
Fail "package version does not match the release being prepared: ${pkgbuild}"
done
# Nightcap keeps its AUR recipes in the tree. Nothing publishes them.
# aur.yml only checks the nightcap-git recipe. Publishing is aur-publish.yml.
for unexpected in \
'aur.archlinux.org' \
'AUR_SSH_PRIVATE_KEY' \
@@ -106,6 +107,34 @@ for unexpected in \
Fail "AUR workflow should not publish: ${unexpected}"
fi
done
for expected in \
'workflow_call:' \
'workflow_dispatch:' \
'for package in nightcap nightcap-bin' \
'${archive}.sha256' \
'makepkg --printsrcinfo' \
'secrets.AUR_SSH_PRIVATE_KEY' \
'ssh://aur@aur.archlinux.org/' \
'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' \
'StrictHostKeyChecking=yes'; do
grep -Fq -- "${expected}" "${AUR_PUBLISH_WORKFLOW}" ||
Fail "AUR publish workflow is missing: ${expected}"
done
# The publish workflow holds the AUR key. It runs for a release tag or by hand,
# never for a push or a pull request, and it leaves nightcap-git to its own
# maintainer.
for unexpected in \
'pull_request' \
'push:' \
'nightcap-git' \
'StrictHostKeyChecking=no' \
'set -x'; do
if grep -Fq -- "${unexpected}" "${AUR_PUBLISH_WORKFLOW}"; then
Fail "AUR publish workflow should not contain: ${unexpected}"
fi
done
grep -Fq 'LINKER:--no-as-needed' "${STUB_CMAKE}" ||
Fail 'system shims can lose their host libc dependencies'
grep -Fq 'MOCKTAIL_MINIZIP_HAS_STREAM_TELL' "${STUB_CMAKE}" ||
@@ -155,13 +184,14 @@ for expected in \
'-DMOCKTAIL_PACKAGE_NAME=nightcap' \
'-DMOCKTAIL_PACKAGE_VERSION=' \
'Nightcap-x86_64.AppImage' \
'gh release upload'; do
'gh release upload' \
'uses: ./.github/workflows/aur-publish.yml'; do
grep -Fq -- "${expected}" "${RELEASE_WORKFLOW}" ||
Fail "release workflow is missing: ${expected}"
done
# Nightcap publishes its packages on the release page only. Pushing them to
# APT, RPM or AUR repositories is out of scope for this workflow.
# This workflow publishes on the release page only. It hands the AUR push to
# aur-publish.yml, and APT and RPM repositories are out of scope.
for unexpected in \
'createrepo' \
'reprepro' \
+528
View File
@@ -0,0 +1,528 @@
#!/usr/bin/env python3
# Copyright 2026 Mocktail Project Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest import mock
from host_abi_regression import (
REGRESSION_CACHE,
expected_runtime,
load_regression_build,
regression_library,
regression_manifest,
regression_sidecar,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
# Each pair solves the second build from the first.
REGRESSION_PAIRS = (
("2908", "2998"), ("2998", "3092"), ("3092", "3222"), ("3222", "3092")
)
def load_script(name: str, file_name: str):
specification = importlib.util.spec_from_file_location(
name, PROJECT_ROOT / "scripts" / file_name
)
if specification is None or specification.loader is None:
raise RuntimeError(f"cannot load {file_name}")
module = importlib.util.module_from_spec(specification)
sys.modules[specification.name] = module
specification.loader.exec_module(module)
return module
# The check script reuses the solver loaded under this module name.
SOLVER = load_script("mocktail_roblox_abi_solver", "roblox_abi_solver.py")
CHECK = load_script("mocktail_roblox_abi_check", "roblox_abi_check.py")
DERIVER = SOLVER.DERIVER
class CandidateChoiceTest(unittest.TestCase):
def test_accepts_a_clear_leader(self) -> None:
self.assertEqual(SOLVER.choose_candidate([(0.80, 0x10), (0.41, 0x20)]), 0x10)
def test_rejects_a_near_tie(self) -> None:
self.assertIsNone(SOLVER.choose_candidate([(0.80, 0x10), (0.70, 0x20)]))
def test_order_of_the_scores_does_not_matter(self) -> None:
self.assertEqual(SOLVER.choose_candidate([(0.41, 0x20), (0.80, 0x10)]), 0x10)
def test_a_lead_of_exactly_the_threshold_is_accepted(self) -> None:
self.assertEqual(SOLVER.choose_candidate([(0.70, 0x10), (0.50, 0x20)]), 0x10)
def test_a_single_candidate_still_needs_the_floor(self) -> None:
self.assertEqual(SOLVER.choose_candidate([(0.80, 0x10)]), 0x10)
self.assertIsNone(SOLVER.choose_candidate([(0.50, 0x10)]))
self.assertIsNone(SOLVER.choose_candidate([]))
class InstructionAlignmentTest(unittest.TestCase):
def test_maps_matching_instructions_across_an_insertion(self) -> None:
self.assertEqual(
SOLVER.align_instructions(["push", "mov", "ret"], ["push", "nop", "mov", "ret"]),
{0: 0, 1: 2, 2: 3},
)
def test_leaves_replaced_instructions_unmapped(self) -> None:
self.assertEqual(
SOLVER.align_instructions(["push", "add", "ret"], ["push", "sub", "ret"]),
{0: 0, 2: 2},
)
class VtableTieBreakTest(unittest.TestCase):
def test_breaks_a_tie_between_candidates_that_pass_on_their_own(self) -> None:
scored = [(0.90, 0x10), (0.85, 0x20)]
self.assertIsNone(SOLVER.choose_candidate(scored))
self.assertEqual(SOLVER.choose_candidate(scored, tie_breaker=0x20), 0x20)
def test_cannot_lift_a_candidate_below_the_floor(self) -> None:
scored = [(0.90, 0x10), (0.85, 0x20), (0.50, 0x30)]
self.assertIsNone(SOLVER.choose_candidate(scored, tie_breaker=0x30))
def test_cannot_overrule_a_clear_leader(self) -> None:
scored = [(0.95, 0x10), (0.65, 0x20)]
self.assertEqual(SOLVER.choose_candidate(scored, tie_breaker=0x20), 0x10)
def test_a_missing_slot_candidate_costs_nothing(self) -> None:
scored = [(0.90, 0x10), (0.30, 0x20)]
self.assertEqual(SOLVER.choose_candidate(scored, tie_breaker=0x99), 0x10)
def function(size: int = 32, shape: bytes = b"", calls=(), refs=()) -> object:
return SOLVER.INDEX.FunctionInfo(size, shape, tuple(calls), tuple(refs))
class FakeIndex:
"""The parts of FunctionIndex the solver reads, from hand-made data."""
def __init__(self, functions, data_users=None, instructions=None, image=None):
self.functions = functions
self.starts = sorted(functions)
self.callers = SOLVER.INDEX.link_functions(functions)[0]
self.data_users = data_users or {}
self._instructions = instructions or {}
self.image = image
def instructions(self, start):
return self._instructions[start]
def call(address: int, target: int):
return SOLVER.Instruction(address, "call", (), target)
def plain(address: int):
return SOLVER.Instruction(address, "nop ", (), None)
class FunctionLinkTest(unittest.TestCase):
def test_lists_each_caller_and_data_user_once(self) -> None:
callers, data_users = SOLVER.INDEX.link_functions({
0x100: function(calls=(0x300, 0x300, 0x300), refs=(0x900, 0x900)),
0x200: function(calls=(0x300,), refs=(0x900,)),
0x300: function(),
})
self.assertEqual(callers[0x300], [0x100, 0x200])
self.assertEqual(data_users[0x900], [0x100, 0x200])
class DataPatternTest(unittest.TestCase):
def solver(self, refs, data_users):
solver = SOLVER.Solver.from_indexes(
FakeIndex({0x100: function(refs=refs)}), FakeIndex({}, data_users)
)
solver.data_map = {target: target + 0x1000 for target in refs}
return solver
def test_an_address_used_by_too_many_functions_casts_no_votes(self) -> None:
hot_users = list(range(0x2000, 0x2001 + SOLVER.XREF_FANOUT_LIMIT))
solver = self.solver((0x9000, 0x9100), {0xA000: hot_users, 0xA100: [0x3000]})
self.assertEqual(solver._pattern_candidates(0x100), {0x3000})
def test_a_common_address_still_backs_a_candidate_found_by_a_rare_one(self) -> None:
hot_users = [0x3000, *range(0x2000, 0x2001 + SOLVER.XREF_FANOUT_LIMIT)]
refs = (0x9000, 0x9100, 0x9200, 0x9300)
solver = self.solver(refs, {0xA000: hot_users, 0xA100: [0x3000]})
self.assertEqual(solver._pattern_candidates(0x100), {0x3000})
def test_a_repeated_reference_casts_one_vote(self) -> None:
refs = (0x9000, 0x9100, 0x9200, 0x9300)
solver = self.solver(
refs, {0xA000: [0x4000, 0x4000, 0x4000], 0xA100: [0x5000], 0xA200: [0x5000]}
)
self.assertEqual(solver._pattern_candidates(0x100), {0x5000})
class OneToOnePairTest(unittest.TestCase):
def test_call_propagation_never_pairs_two_functions_with_one(self) -> None:
reference = FakeIndex({
0x100: function(shape=b"A", calls=(0x200, 0x300)),
0x200: function(shape=b"x1"),
0x300: function(shape=b"x2"),
})
candidate = FakeIndex({
0x1100: function(shape=b"A", calls=(0x1200, 0x1200)),
0x1200: function(shape=b"c"),
})
solver = SOLVER.Solver.from_indexes(reference, candidate)
self.assertEqual(solver.pairs, {0x100: 0x1100, 0x200: 0x1200})
def test_a_new_pair_replaces_the_one_holding_its_candidate(self) -> None:
solver = SOLVER.Solver.from_indexes(FakeIndex({}), FakeIndex({}))
solver._pair(0x200, 0x1200)
solver._pair(0x300, 0x1200)
self.assertEqual(solver.pairs, {0x300: 0x1200})
class RegistryAllocatorTest(unittest.TestCase):
def solver(self, candidate_call_index: int):
reference_code = [plain(0x100 + i) for i in range(12)]
reference_code[9] = call(0x109, 0x500)
candidate_code = [plain(0x1100 + i) for i in range(12)]
candidate_code[candidate_call_index] = call(0x1100 + candidate_call_index, 0x1500)
return SOLVER.Solver.from_indexes(
FakeIndex({0x100: function()}, instructions={0x100: reference_code}),
FakeIndex({0x1100: function()}, instructions={0x1100: candidate_code}),
)
def test_accepts_the_allocator_call_at_the_tenth_instruction(self) -> None:
self.solver(9)._check_registry_allocator(0x100, 0x1100, 0x500, 0x1500)
def test_rejects_an_allocator_call_anywhere_else(self) -> None:
with self.assertRaisesRegex(SOLVER.AnalyzerError, "allocator"):
self.solver(3)._check_registry_allocator(0x100, 0x1100, 0x500, 0x1500)
def test_a_reference_profile_without_its_free_bridge_is_rejected(self) -> None:
build = load_regression_build("3092")
sidecar = regression_sidecar(build)
sidecar["profile"]["bridge_entries"] = [
entry for entry in sidecar["profile"]["bridge_entries"] if entry["label"] != "free"
]
solver = SOLVER.Solver.from_indexes(FakeIndex({}), FakeIndex({}))
with self.assertRaisesRegex(SOLVER.AnalyzerError, "allocator bridges"):
solver.derive_profile(sidecar)
FMOD_FIELDS = {
"vtable_rva": "0x1000",
"string_constructor_rva": "0x10",
"count_method_rva": "0x20",
"info_method_rva": "0x30",
"current_method_rva": "0x40",
"select_method_rva": "0x50",
}
FMOD_INPUT_FIELDS = DERIVER.FMOD_INPUT_FIELDS
class FmodSourceTest(unittest.TestCase):
def test_accepts_both_layouts(self) -> None:
self.assertEqual(SOLVER.validate_fmod_source(FMOD_FIELDS), 1)
self.assertEqual(
SOLVER.validate_fmod_source({**FMOD_FIELDS, "vtable_layout_version": 2}), 2
)
def test_rejects_a_partial_input_profile(self) -> None:
source = {**FMOD_FIELDS, FMOD_INPUT_FIELDS[0]: "0x60"}
with self.assertRaisesRegex(SOLVER.AnalyzerError, "incomplete"):
SOLVER.validate_fmod_source(source)
def test_rejects_a_missing_output_field(self) -> None:
source = dict(FMOD_FIELDS)
del source["select_method_rva"]
with self.assertRaisesRegex(SOLVER.AnalyzerError, "incomplete"):
SOLVER.validate_fmod_source(source)
def test_rejects_an_unknown_layout(self) -> None:
for layout in (3, "2", True):
with self.subTest(layout=layout), self.assertRaisesRegex(
SOLVER.AnalyzerError, "layout"
):
SOLVER.validate_fmod_source({**FMOD_FIELDS, "vtable_layout_version": layout})
def test_finds_a_vtable_that_moved_to_the_other_layout(self) -> None:
vtable = 0x8000
relocations = {vtable + 5 * 8: 0xA, vtable + 6 * 8: 0xB, vtable + 8 * 8: 0xC}
methods = {"count_method_rva": 0xA, "info_method_rva": 0xB, "current_method_rva": 0xC}
self.assertEqual(
SOLVER.Solver._locate_fmod_vtables(relocations, methods), [(vtable, 2)]
)
class EhFrameHeaderTest(unittest.TestCase):
def test_rejects_an_eight_byte_frame_pointer(self) -> None:
# Version 1, eh_frame_ptr udata8, count udata4, table datarel sdata4.
raw = bytes((1, 0x04, 0x03, 0x3B)) + bytes(16)
header = types.SimpleNamespace(address=0x1000)
image = types.SimpleNamespace(
sections={".eh_frame_hdr": header}, section_bytes=lambda section: raw
)
with self.assertRaisesRegex(SOLVER.AnalyzerError, "eh_frame_ptr"):
SOLVER.INDEX.function_table(image)
class FmodLayoutTieTest(unittest.TestCase):
def test_resolves_current_and_select_from_the_located_vtable(self) -> None:
reference_vtable, vtable = 0x1000, 0x8000
source = {
"vtable_rva": hex(reference_vtable), "string_constructor_rva": "0x10",
"count_method_rva": "0x20", "info_method_rva": "0x30",
"current_method_rva": "0x40", "select_method_rva": "0x50",
}
reference_relocations = {
reference_vtable + slot * 8: method
for slot, method in ((5, 0x20), (6, 0x30), (7, 0x40), (17, 0x50))
}
candidate_relocations = {
vtable + slot * 8: method
for slot, method in ((5, 0xA), (6, 0xB), (7, 0xC), (17, 0xD))
}
reference_image = types.SimpleNamespace(require_relro_rva=lambda *_: None)
candidate_image = types.SimpleNamespace(require_relro_rva=lambda *_: None)
solver = SOLVER.Solver.from_indexes(
FakeIndex({}, image=reference_image), FakeIndex({}, image=candidate_image)
)
first_pass = {
"fmod-count_method_rva": 0xA,
"fmod-info_method_rva": 0xB,
"fmod-string-constructor": 0x99,
}
def match(name, start, contract=None, vtable_slot=None):
if name in first_pass:
return first_pass[name]
if vtable_slot is not None:
return vtable_slot
raise SOLVER.AnalyzerError(f"{name} has no clear candidate")
with mock.patch.object(solver, "match_function", side_effect=match), mock.patch.object(
DERIVER,
"relative_relocation_map",
side_effect=lambda image: (
reference_relocations if image is reference_image else candidate_relocations
),
), mock.patch.object(
DERIVER, "has_fmod_string_constructor_contract", return_value=True
), mock.patch.object(
DERIVER, "has_fmod_device_lists_select_contract", return_value=False
):
result = solver._derive_fmod_bridge(source)
self.assertEqual(result["vtable_rva"], DERIVER.format_rva(vtable))
self.assertEqual(result["current_method_rva"], DERIVER.format_rva(0xC))
self.assertEqual(result["select_method_rva"], DERIVER.format_rva(0xD))
self.assertNotIn("vtable_layout_version", result)
def regression_inputs(reference_name: str, candidate_name: str):
"""Both fixtures and libraries, or None when either library is missing."""
reference = load_regression_build(reference_name)
candidate = load_regression_build(candidate_name)
reference_library = regression_library(reference)
candidate_library = regression_library(candidate)
if reference_library is None or candidate_library is None:
return None
return reference, candidate, reference_library, candidate_library
def regression_images(test: unittest.TestCase, reference_name: str, candidate_name: str):
inputs = regression_inputs(reference_name, candidate_name)
if inputs is None:
test.skipTest(f"exact {reference_name} and {candidate_name} payloads are unavailable")
return inputs
@unittest.skipUnless(DERIVER.capstone is not None, "Python capstone is unavailable")
class InputCheckTest(unittest.TestCase):
def test_rejects_inputs_the_deriver_rejects(self) -> None:
reference, _, reference_library, candidate_library = regression_images(
self, "3092", "3222"
)
sidecar = regression_sidecar(reference)
with DERIVER.ElfImage(reference_library) as reference_image, DERIVER.ElfImage(
candidate_library
) as candidate_image:
SOLVER.check_inputs(reference_image, candidate_image, sidecar)
with self.assertRaisesRegex(SOLVER.AnalyzerError, "distinct"):
SOLVER.check_inputs(reference_image, reference_image, sidecar)
wrong = {**sidecar, "elf_build_id": "00" * 20}
with self.assertRaisesRegex(SOLVER.AnalyzerError, "identity"):
SOLVER.check_inputs(reference_image, candidate_image, wrong)
solver = SOLVER.Solver.from_indexes(
FakeIndex({}, image=reference_image), FakeIndex({}, image=candidate_image)
)
with self.assertRaisesRegex(SOLVER.AnalyzerError, "identity"):
solver.derive_profile(wrong)
@unittest.skipUnless(DERIVER.capstone is not None, "Python capstone is unavailable")
class FunctionIndexCacheTest(unittest.TestCase):
def library(self) -> Path:
library = regression_library(load_regression_build("3222"))
if library is None:
self.skipTest("exact 3222 payload is unavailable")
return library
def test_reuses_a_cached_index_without_decoding(self) -> None:
with tempfile.TemporaryDirectory() as cache, DERIVER.ElfImage(self.library()) as image:
built = SOLVER.FunctionIndex(image, Path(cache))
self.assertEqual(len(list(Path(cache).iterdir())), 1)
with mock.patch.object(
SOLVER.INDEX, "decode_function", side_effect=AssertionError("decoded")
):
loaded = SOLVER.FunctionIndex(image, Path(cache))
self.assertEqual(loaded.functions, built.functions)
self.assertEqual(loaded.callers, built.callers)
self.assertEqual(loaded.data_users, built.data_users)
def test_rebuilds_a_damaged_cache(self) -> None:
with tempfile.TemporaryDirectory() as cache, DERIVER.ElfImage(self.library()) as image:
built = SOLVER.FunctionIndex(image, Path(cache))
(entry,) = Path(cache).iterdir()
entry.write_bytes(b"not an index")
self.assertEqual(SOLVER.FunctionIndex(image, Path(cache)).functions, built.functions)
def test_a_new_disassembler_version_gets_a_new_entry(self) -> None:
before = SOLVER.INDEX.cache_path(Path("/cache"), "ab" * 32)
with mock.patch.object(DERIVER.capstone, "__version__", "0.0.0"):
after = SOLVER.INDEX.cache_path(Path("/cache"), "ab" * 32)
self.assertNotEqual(before, after)
class CheckReportTest(unittest.TestCase):
def test_lists_each_differing_field_by_path(self) -> None:
derived = {"profile": {"a": "0x1", "b": ["0x2", "0x3"]}, "runtime": {"c": "0x4"}}
solved = {"profile": {"a": "0x1", "b": ["0x2", "0x9"]}, "runtime": {}}
self.assertEqual(
CHECK.compare_results(derived, solved),
["profile.b[1]: deriver 0x3, solver 0x9", "runtime.c: deriver 0x4, solver missing"],
)
def test_exit_status_follows_agreement(self) -> None:
same = {"profile": {"a": "0x1"}}
self.assertEqual(CHECK.check_status(same, same, None, None)[0], 0)
self.assertEqual(CHECK.check_status(same, {"profile": {}}, None, None)[0], 1)
status, lines = CHECK.check_status(None, same, "no unique match", None)
self.assertEqual(status, 1)
self.assertIn("deriver failed: no unique match", lines)
self.assertEqual(CHECK.check_status(None, None, "a", "b")[0], 2)
@unittest.skipUnless(DERIVER.capstone is not None, "Python capstone is unavailable")
class CheckCommandTest(unittest.TestCase):
def run_check(self, reference: dict, reference_library, candidate_library):
with tempfile.TemporaryDirectory() as temporary:
sidecar = Path(temporary) / "reference.json"
sidecar.write_text(json.dumps(regression_sidecar(reference)), encoding="utf-8")
manifest = Path(temporary) / "compatibility.json"
manifest.write_text(json.dumps(regression_manifest(reference)), encoding="utf-8")
output = io.StringIO()
with contextlib.redirect_stdout(output):
status = CHECK.main([
"--reference-lib", str(reference_library),
"--reference-profile", str(sidecar),
"--reference-compatibility", str(manifest),
"--candidate-lib", str(candidate_library),
"--cache-dir", str(REGRESSION_CACHE / "function_index"),
])
return status, output.getvalue()
def test_reports_agreement_on_a_real_update(self) -> None:
reference, _, reference_library, candidate_library = regression_images(
self, "2998", "3092"
)
status, output = self.run_check(reference, reference_library, candidate_library)
self.assertEqual(status, 0, output)
self.assertIn("deriver and solver agree", output)
def test_rejects_a_wrong_reference_before_indexing(self) -> None:
reference, _, _, candidate_library = regression_images(self, "2998", "3092")
third = regression_library(load_regression_build("3222"))
if third is None:
self.skipTest("exact 3222 payload is unavailable")
with mock.patch.object(
SOLVER, "FunctionIndex", side_effect=AssertionError("indexed")
):
status, output = self.run_check(reference, third, candidate_library)
self.assertEqual(status, 2, output)
self.assertIn("solver failed: reference ELF bytes do not match", output)
def test_reports_any_error_as_that_side_failing(self) -> None:
reference, _, reference_library, candidate_library = regression_images(
self, "2998", "3092"
)
with mock.patch.object(
DERIVER, "derive_profile", side_effect=ValueError("bad slot")
), mock.patch.object(
SOLVER.Solver, "derive_profile", side_effect=KeyError("free")
), mock.patch.object(SOLVER, "FunctionIndex", return_value=FakeIndex({})):
status, output = self.run_check(reference, reference_library, candidate_library)
self.assertEqual(status, 2, output)
self.assertIn("deriver failed: ValueError: bad slot", output)
self.assertIn("solver failed: KeyError: 'free'", output)
# The smallest lead of any match the score decided alone, per pair. A
# change that brings matches closer to a tie fails here first.
WEAKEST_SCORE_LEAD = {
("2908", "2998"): 0.22,
("2998", "3092"): 0.22,
("3092", "3222"): 0.22,
("3222", "3092"): 0.21,
}
@unittest.skipUnless(DERIVER.capstone is not None, "Python capstone is unavailable")
class SolverRegressionTest(unittest.TestCase):
def test_solves_each_build_from_its_predecessor(self) -> None:
for reference_name, candidate_name in REGRESSION_PAIRS:
with self.subTest(reference=reference_name, candidate=candidate_name):
self.solve_pair(reference_name, candidate_name)
def solve_pair(self, reference_name: str, candidate_name: str) -> None:
reference, candidate, reference_library, candidate_library = regression_images(
self, reference_name, candidate_name
)
with DERIVER.ElfImage(reference_library) as reference_image, DERIVER.ElfImage(
candidate_library
) as candidate_image:
solver = SOLVER.Solver(
reference_image, candidate_image, REGRESSION_CACHE / "function_index"
)
profile, anchors = solver.derive_profile(regression_sidecar(reference))
runtime = solver.derive_runtime_compatibility(reference["runtime_compatibility"])
self.assertEqual(profile, candidate["profile"])
self.assertEqual(anchors, candidate["derivation_anchors"])
self.assertEqual(runtime, expected_runtime(reference, candidate))
weakest = min(
match.lead for match in solver.matches.values() if match.decided_by == "score"
)
self.assertGreaterEqual(
round(weakest, 2), WEAKEST_SCORE_LEAD[(reference_name, candidate_name)]
)
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -439,6 +439,36 @@ TEST(RuntimePathsTest, SelectsExistingRealSoberAssetPath) {
EXPECT_EQ(paths.DefaultAssetPath(), sober_assets);
}
TEST(RuntimePathsTest, SelectsExistingRealSoberAssetOverlayPath) {
TemporaryDirectory temporary;
ASSERT_FALSE(temporary.path().empty());
const std::filesystem::path home = temporary.path() / "home";
const std::filesystem::path sober_overlay =
home / ".var/app/org.vinegarhq.Sober/data/sober/asset_overlay";
ASSERT_TRUE(RuntimePaths::EnsureDirectory(sober_overlay));
const MapEnvironment environment({
{"HOME", home.string()},
{"MOCKTAIL_USE_REAL_SOBER_PATHS", "1"},
});
const RuntimePaths paths = RuntimePaths::FromEnvironment(
environment, temporary.path() / "empty-workspace");
EXPECT_EQ(paths.asset_overlay_dir(), sober_overlay);
}
TEST(RuntimePathsTest, RespectsExplicitAssetOverlayOverride) {
TemporaryDirectory temporary;
ASSERT_FALSE(temporary.path().empty());
const MapEnvironment environment({
{"HOME", temporary.path().string()},
{"MOCKTAIL_ASSET_OVERLAY_DIR", "/custom/asset_overlay"},
});
const RuntimePaths paths = RuntimePaths::FromEnvironment(
environment, temporary.path());
EXPECT_EQ(paths.asset_overlay_dir(), "/custom/asset_overlay");
}
TEST(RuntimePathsTest, PrefersWorkspaceAssets) {
TemporaryDirectory temporary;
ASSERT_FALSE(temporary.path().empty());
@@ -591,10 +621,13 @@ TEST(RuntimePathsTest, ExportsXdgBackedAndroidPathsAndCreatesParents) {
"MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH");
const ScopedProcessEnvironment texture_overrides(
"MOCKTAIL_TEXTURE_OVERRIDE_DIR");
const ScopedProcessEnvironment overlay_dir(
"MOCKTAIL_ASSET_OVERLAY_DIR");
ASSERT_EQ(unsetenv("MOCKTAIL_RUNTIME_ROOT"), 0);
ASSERT_EQ(unsetenv("MOCKTAIL_ANDROID_CACHE_HOST_ROOT"), 0);
ASSERT_EQ(unsetenv("MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH"), 0);
ASSERT_EQ(unsetenv("MOCKTAIL_TEXTURE_OVERRIDE_DIR"), 0);
ASSERT_EQ(unsetenv("MOCKTAIL_ASSET_OVERLAY_DIR"), 0);
const MapEnvironment environment({
{"HOME", temporary.path().string()},
@@ -614,10 +647,13 @@ TEST(RuntimePathsTest, ExportsXdgBackedAndroidPathsAndCreatesParents) {
paths.vulkan_shader_cache_file().c_str());
EXPECT_STREQ(std::getenv("MOCKTAIL_TEXTURE_OVERRIDE_DIR"),
(paths.config_root() / "textures").c_str());
EXPECT_STREQ(std::getenv("MOCKTAIL_ASSET_OVERLAY_DIR"),
paths.asset_overlay_dir().c_str());
EXPECT_TRUE(std::filesystem::is_directory(paths.android_runtime_root()));
EXPECT_TRUE(std::filesystem::is_directory(paths.android_cache_root()));
EXPECT_TRUE(std::filesystem::is_directory(
paths.vulkan_shader_cache_file().parent_path()));
EXPECT_TRUE(std::filesystem::is_directory(paths.asset_overlay_dir()));
}
TEST(RuntimePathsTest, PreservesExplicitAndroidPathOverrides) {
@@ -628,12 +664,15 @@ TEST(RuntimePathsTest, PreservesExplicitAndroidPathOverrides) {
"MOCKTAIL_ANDROID_CACHE_HOST_ROOT");
const ScopedProcessEnvironment shader_cache(
"MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH");
const ScopedProcessEnvironment overlay_dir(
"MOCKTAIL_ASSET_OVERLAY_DIR");
ASSERT_EQ(setenv("MOCKTAIL_RUNTIME_ROOT", "/explicit/runtime", 1), 0);
ASSERT_EQ(setenv("MOCKTAIL_ANDROID_CACHE_HOST_ROOT", "/explicit/cache", 1),
0);
ASSERT_EQ(setenv("MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH",
"/explicit/shader-cache", 1),
0);
ASSERT_EQ(setenv("MOCKTAIL_ASSET_OVERLAY_DIR", "/explicit/overlay", 1), 0);
const MapEnvironment environment({{"HOME", temporary.path().string()}});
const RuntimePaths paths =
@@ -646,6 +685,7 @@ TEST(RuntimePathsTest, PreservesExplicitAndroidPathOverrides) {
"/explicit/cache");
EXPECT_STREQ(std::getenv("MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH"),
"/explicit/shader-cache");
EXPECT_STREQ(std::getenv("MOCKTAIL_ASSET_OVERLAY_DIR"), "/explicit/overlay");
}
} // namespace
+38
View File
@@ -40,3 +40,41 @@ it in the override directory.
Overrides are resampled to the size the game asked for, so the replacement does
not have to match the original's dimensions.
## Files that ship with Roblox
A texture override only reaches ETC2 textures. To replace a file Roblox ships
with, such as a cursor, a font or a sky texture, use the asset overlay folder
instead. It needs no hash: the file goes at the same path Roblox uses.
```sh
dir=~/.local/share/mocktail/asset_overlay/content/textures/Cursors/KeyboardMouse
mkdir -p "$dir"
cp my-cursor.png "$dir/ArrowCursor.png"
```
Restart the game. The layout matches Sober's `asset_overlay` folder, so a file
set made for Sober can be copied in as it is. The Flatpak build keeps the
folder at `~/.var/app/io.github.CoderDayton.nightcap/data/mocktail/asset_overlay`.
Roblox downloads some of these files, such as the shift lock and text cursors,
and reads no local file for them. A file under `content/` in the overlay folder
still replaces one: at start, Nightcap changes that file's name inside the
loaded Roblox library, in memory only, so Roblox reads the local file. Nothing
is changed when the overlay folder holds no file. Roblox does not allow
modified clients, so use this at your own risk.
```sh
dir=~/.local/share/mocktail/asset_overlay/content/textures
mkdir -p "$dir/Cursors/KeyboardMouse"
cp my-shift-lock.png "$dir/MouseLockedCursor.png"
cp my-text-cursor.png "$dir/Cursors/KeyboardMouse/IBeamCursor.png"
```
Set `MOCKTAIL_ASSET_TRACE=1` to print every file Roblox opens as
`[asset] open <name> -> <path>`, and every file it asks for and does not get as
`[asset] miss <name>`. A line that ends in `[overlay]` came from the overlay
folder. `MOCKTAIL_ASSET_OVERLAY_DIR` points the lookup somewhere else.
An overlay file changes the texture's bytes, so a texture override keyed on the
original file's hash stops matching it. Use one of the two for a given texture.