mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 19:00:48 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4be3de14a | ||
|
|
876f8b8c32 | ||
|
|
c614fba032 | ||
|
|
046632cf99 | ||
|
|
a3a2b76a06 | ||
|
|
f333399e18 | ||
|
|
3062909209 | ||
|
|
049784d0f8 | ||
|
|
ccf910f064 | ||
|
|
23e7831149 | ||
|
|
5b2ee2c7f3 | ||
|
|
63ed62b4aa | ||
|
|
81de528668 | ||
|
|
ff81882cb0 | ||
|
|
a87373d003 | ||
|
|
b46098c211 | ||
|
|
144350090e | ||
|
|
3ed0418997 | ||
|
|
8410070eec | ||
|
|
a1166fb6ba | ||
|
|
c9eb4fb7f0 |
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
+31
-1
@@ -5,6 +5,36 @@ 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.
|
||||
- `--profile` traces now show how much GPU memory decoded ETC2 textures take, as the `emulated texture (MB)`
|
||||
and `compressed texture (MB)` counters. `scripts/summarize_profile_trace.py` prints the peak of each.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Joining a game from a website link while already in a game no longer freezes Nightcap. It leaves the
|
||||
current game and joins the new one, and leaving that game returns to the home screen.
|
||||
|
||||
### Changed
|
||||
|
||||
- The home screen and avatar editor have fewer long frames. PC profiles compile the app's menu scripts to
|
||||
native code and turn this off while you are in a game, so games run as before. Set
|
||||
`FFlagDebugLuauCodegenAll` in `fflags.json` to choose it yourself.
|
||||
- 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 +59,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.
|
||||
|
||||
@@ -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)
|
||||
@@ -278,6 +279,7 @@ add_library(mocktail_runtime STATIC
|
||||
src/runtime/game_session_coordinator.cc
|
||||
src/runtime/graphics_launch_policy.cc
|
||||
src/runtime/http_client_policy.cc
|
||||
src/runtime/luau_codegen_switch.cc
|
||||
src/runtime/memory_limit.cc
|
||||
src/runtime/owned_pthread.cc
|
||||
src/runtime/performance_policy.cc
|
||||
@@ -298,6 +300,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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## FFlag overrides
|
||||
@@ -125,6 +136,11 @@ caused by `Color3` errors in the newer SDUI page. An explicit
|
||||
PC profiles also set `FIntFlyoutTestVariantRollout12` to `2`, which shows the
|
||||
desktop side panel menu. An explicit override takes precedence.
|
||||
|
||||
PC profiles compile the app's menus to native code with
|
||||
`FFlagDebugLuauCodegenAll`, and turn it off while you are in a game. Setting
|
||||
it yourself turns the switching off and your value applies everywhere. See
|
||||
[docs/PERFORMANCE.md](docs/PERFORMANCE.md#native-code-in-the-menus).
|
||||
|
||||
```json
|
||||
{
|
||||
"FFlagExample": "True",
|
||||
|
||||
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 |
@@ -68,6 +68,12 @@ the CPU time of every thread together, so dividing it by the frame interval
|
||||
gives the number of cores busy. A long frame with little present-thread CPU
|
||||
was spent waiting.
|
||||
|
||||
`emulated texture (MB)` is the VRAM held by the decoded images that stand in
|
||||
for live ETC2/EAC textures, over every mip level and layer, upscaled where
|
||||
small-texture upscale applies. `compressed texture (MB)` is the size the same
|
||||
textures would take as ETC2/EAC. Both change only when such an image is
|
||||
created or destroyed. `summarize_profile_trace.py` prints each one's peak.
|
||||
|
||||
## The benchmark runs
|
||||
|
||||
Run every scenario before and after the change, on the same build type
|
||||
|
||||
+42
-2
@@ -129,14 +129,23 @@ runs cooler for a few frames per second less; measure before keeping it.
|
||||
|
||||
## Small texture upscaling
|
||||
|
||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE` (default `4`) redraws ETC2 textures of 64px or
|
||||
`MOCKTAIL_SMALL_TEXTURE_UPSCALE` (default `4`) stores ETC2 textures of 64px or
|
||||
smaller at N times their width and height. At the default that is 16 times the
|
||||
pixels per affected texture.
|
||||
|
||||
Upscaling alone adds no detail. Each original pixel becomes an N×N block of the
|
||||
same colour, and the GPU's filtering already blends a small texture that much
|
||||
when it draws it. The extra size only shows when a replacement PNG in
|
||||
`~/.config/mocktail/textures` has more detail than the original.
|
||||
|
||||
The cost lands in two places. Decoding and resampling is CPU work on up to 8
|
||||
threads while textures load, which shows up as a burst rather than a steady
|
||||
load. The enlarged textures then stay in GPU memory for the session and cost
|
||||
extra sampling bandwidth on every frame that uses them.
|
||||
extra sampling bandwidth on every frame that uses them. In Blade Ball the
|
||||
memory share is small: decoded textures came to 5.1 times their ETC2 size both
|
||||
with the default and with upscaling off. The `emulated texture (MB)` and
|
||||
`compressed texture (MB)` counters in a `--profile` trace give the figures for
|
||||
any game.
|
||||
|
||||
That decode does not run on the thread that submits the frame. On a device
|
||||
with a timeline semaphore, uploads decode on a background dispatcher and the
|
||||
@@ -151,6 +160,37 @@ Set it to `1` to turn upscaling off:
|
||||
MOCKTAIL_SMALL_TEXTURE_UPSCALE=1 mocktail
|
||||
```
|
||||
|
||||
## Native code in the menus
|
||||
|
||||
The home screen and avatar editor run Roblox's Lua app in the Luau interpreter,
|
||||
and its long frames come from that thread. PC profiles set
|
||||
`FFlagDebugLuauCodegenAll`, which compiles every script to native code. That
|
||||
makes the menus' Lua about 2.5 times cheaper for about 56 MB of compiled code.
|
||||
|
||||
In a game the same flag compiles every game script too: Blade Ball grew from 7
|
||||
MB to about 300 MB and hit Roblox's native code limit, which shows warnings in
|
||||
F9. So Nightcap turns the flag off when a game starts joining and back on when
|
||||
you return to the menus. Roblox reads the flag each time it compiles a script,
|
||||
and a game's compiled code is freed when you leave it. Games run with Roblox's
|
||||
own per-script choice, the same as without the flag.
|
||||
|
||||
The log shows each switch:
|
||||
|
||||
```text
|
||||
[luau-codegen] menus-only switch at +0x<offset in libroblox.so>
|
||||
[luau-codegen] native code for all scripts off
|
||||
[luau-codegen] native code for all scripts on
|
||||
```
|
||||
|
||||
Setting `FFlagDebugLuauCodegenAll` in `fflags.json` turns the switching off
|
||||
and your value applies everywhere. To keep the interpreter in the menus too:
|
||||
|
||||
```json
|
||||
{
|
||||
"FFlagDebugLuauCodegenAll": false
|
||||
}
|
||||
```
|
||||
|
||||
## Frame rate and present mode
|
||||
|
||||
`graphics.frame_rate_limit` (default `-1`) leaves Roblox's own in-game
|
||||
|
||||
@@ -30,6 +30,18 @@ bool Etc2UploadByteCounts(EtcFormat format, const VkExtent3D& extent,
|
||||
std::uint32_t layer_count, VkDeviceSize* compressed,
|
||||
VkDeviceSize* decoded);
|
||||
|
||||
// Sums every mip level and layer; `scale` multiplies the host image's
|
||||
// level-0 width and height only.
|
||||
bool Etc2ImageByteCounts(EtcFormat format, const VkExtent3D& extent,
|
||||
std::uint32_t mip_levels, std::uint32_t layers,
|
||||
std::uint32_t scale, VkDeviceSize* compressed,
|
||||
VkDeviceSize* decoded);
|
||||
|
||||
struct EmulatedTextureBytes {
|
||||
VkDeviceSize compressed = 0;
|
||||
VkDeviceSize decoded = 0;
|
||||
};
|
||||
|
||||
// Whether emulated ETC2 is reported to the application as a supported
|
||||
// feature, from the MOCKTAIL_ADVERTISE_ETC2 value: "0" hides it, anything
|
||||
// else (or unset) reports it. Hidden ETC2 still decodes ETC2 images the
|
||||
@@ -152,6 +164,10 @@ class VulkanEtc2Emulation final {
|
||||
// pending, after waiting for any decode still writing into them.
|
||||
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
|
||||
|
||||
// Traced as the "emulated texture (MB)" and "compressed texture (MB)"
|
||||
// counters.
|
||||
EmulatedTextureBytes TextureBytes() const;
|
||||
|
||||
struct State;
|
||||
|
||||
private:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,68 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
|
||||
#define MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "runtime/roblox_experience_presence.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Nightcap defaults this on and keeps it on only outside games; a user value
|
||||
// set in fflags.json or the overrides environment disables the switching.
|
||||
inline constexpr std::string_view kLuauCodegenAllClientSetting =
|
||||
"FFlagDebugLuauCodegenAll";
|
||||
inline constexpr std::string_view kLuauCodegenAllFastFlagName =
|
||||
"DebugLuauCodegenAll";
|
||||
|
||||
struct LoadedImageSegment {
|
||||
std::uintptr_t address = 0;
|
||||
std::size_t size = 0;
|
||||
bool writable = false;
|
||||
};
|
||||
|
||||
std::vector<LoadedImageSegment> LoadedImageSegments(std::uintptr_t image_base);
|
||||
|
||||
// Record layout: {u8 value; pad; const char* name; size_t length}, in a
|
||||
// writable segment filled by libroblox's static constructors. Returns null
|
||||
// unless exactly one record names a NUL-terminated copy of `name`.
|
||||
std::uint8_t* FindFastFlagValue(
|
||||
const std::vector<LoadedImageSegment>& segments, std::string_view name);
|
||||
|
||||
bool ClientSettingsSetLuauCodegenAll(std::string_view overrides_json);
|
||||
|
||||
bool LuauCodegenAllForPhase(RobloxExperiencePresencePhase phase);
|
||||
|
||||
// Locates the flag on the first notification; a byte that is not 0 or 1 is
|
||||
// left alone.
|
||||
class LuauCodegenMenuSwitch {
|
||||
public:
|
||||
using LocateFlagFn = std::function<std::uint8_t*()>;
|
||||
|
||||
LuauCodegenMenuSwitch(LocateFlagFn locate_flag,
|
||||
RobloxExperiencePresenceObserver next);
|
||||
|
||||
RobloxExperiencePresenceObserver observer();
|
||||
void OnPhase(RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request);
|
||||
|
||||
private:
|
||||
static void Notify(void* context, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request);
|
||||
|
||||
std::mutex mutex_;
|
||||
LocateFlagFn locate_flag_;
|
||||
bool located_ = false;
|
||||
std::uint8_t* flag_ = nullptr;
|
||||
const RobloxExperiencePresenceObserver next_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -10,8 +11,10 @@ namespace runtime {
|
||||
// Android's RbxKeyboard resolves the Roblox Font enum through
|
||||
// assets/android/fonts/font-mappings.json. The selected file lives below
|
||||
// assets/content/fonts while legacy SourceSansPro faces live in assets/fonts.
|
||||
// Emoji come from the color fonts Roblox ships next to the mapped faces.
|
||||
struct RobloxTextFontSelection {
|
||||
std::string primary_file;
|
||||
std::vector<std::string> emoji_files;
|
||||
float size_scale = 0.795F;
|
||||
bool exact_mapping = false;
|
||||
};
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
@@ -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]
|
||||
@@ -6,8 +6,9 @@
|
||||
Usage: summarize_profile_trace.py TRACE [TRACE ...]
|
||||
|
||||
Each trace gets its own column, so `before.json after.json` compares them.
|
||||
Frame times come from the "frame interval (ms)" counter; every other row is
|
||||
a slice name recorded by the Vulkan adapter.
|
||||
Frame times come from the "frame interval (ms)" counter, peaks from the
|
||||
texture counters; every other row is a slice name recorded by the Vulkan
|
||||
adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,6 +21,7 @@ import sys
|
||||
from typing import Iterable
|
||||
|
||||
FRAME_COUNTER = "frame interval (ms)"
|
||||
PEAK_COUNTERS = ("emulated texture (MB)", "compressed texture (MB)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -37,6 +39,7 @@ class Distribution:
|
||||
class Summary:
|
||||
frames: Distribution | None
|
||||
slices: dict[str, Distribution]
|
||||
peaks: dict[str, float]
|
||||
|
||||
|
||||
def _nearest_rank(ordered: list[float], percent: float) -> float:
|
||||
@@ -57,16 +60,21 @@ def _distribution(values: list[float]) -> Distribution:
|
||||
def summarize(events: Iterable[dict]) -> Summary:
|
||||
frames: list[float] = []
|
||||
slices: dict[str, list[float]] = {}
|
||||
peaks: dict[str, float] = {}
|
||||
for event in events:
|
||||
phase = event.get("ph")
|
||||
if phase == "C" and event.get("name") == FRAME_COUNTER:
|
||||
frames.append(float(event["args"]["value"]))
|
||||
elif phase == "C" and event.get("name") in PEAK_COUNTERS:
|
||||
value = float(event["args"]["value"])
|
||||
peaks[event["name"]] = max(peaks.get(event["name"], value), value)
|
||||
elif phase == "X":
|
||||
slices.setdefault(event["name"], []).append(event["dur"] / 1000)
|
||||
return Summary(
|
||||
frames=_distribution(frames) if frames else None,
|
||||
slices={name: _distribution(values)
|
||||
for name, values in slices.items()})
|
||||
for name, values in slices.items()},
|
||||
peaks=peaks)
|
||||
|
||||
|
||||
def _row(label: str, cells: list[str]) -> str:
|
||||
@@ -96,6 +104,11 @@ def to_markdown(traces: list[tuple[str, Summary]]) -> str:
|
||||
if any(frames):
|
||||
lines += [_metric_row(f"frame interval {field} (ms)", frames, field)
|
||||
for field in ("mean", "p50", "p95", "p99", "max")]
|
||||
for name in PEAK_COUNTERS:
|
||||
if any(name in summary.peaks for summary in summaries):
|
||||
lines.append(_row(f"{name} peak", [
|
||||
f"{summary.peaks[name]:.2f}" if name in summary.peaks else "-"
|
||||
for summary in summaries]))
|
||||
|
||||
# Largest total time first, taken from whichever trace spent the most.
|
||||
totals: dict[str, float] = {}
|
||||
|
||||
+42
-13
@@ -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 && 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>
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 419 KiB |
+15
-2
@@ -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;
|
||||
|
||||
@@ -92,6 +92,34 @@ bool Etc2UploadByteCounts(EtcFormat format, const VkExtent3D& extent,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Etc2ImageByteCounts(EtcFormat format, const VkExtent3D& extent,
|
||||
std::uint32_t mip_levels, std::uint32_t layers,
|
||||
std::uint32_t scale, VkDeviceSize* compressed,
|
||||
VkDeviceSize* decoded) {
|
||||
if (extent.width == 0 || extent.height == 0 || extent.depth == 0 ||
|
||||
mip_levels == 0 || layers == 0 || scale == 0 || compressed == nullptr ||
|
||||
decoded == nullptr) {
|
||||
return false;
|
||||
}
|
||||
const auto level = [](std::uint32_t size, std::uint32_t mip) {
|
||||
return std::max<VkDeviceSize>(1, static_cast<VkDeviceSize>(size) >> mip);
|
||||
};
|
||||
VkDeviceSize compressed_total = 0;
|
||||
VkDeviceSize decoded_total = 0;
|
||||
for (std::uint32_t mip = 0; mip < mip_levels && mip < 32; ++mip) {
|
||||
const VkDeviceSize depth = level(extent.depth, mip);
|
||||
compressed_total += ((level(extent.width, mip) + 3) / 4) *
|
||||
((level(extent.height, mip) + 3) / 4) * depth *
|
||||
EtcBlockBytes(format);
|
||||
decoded_total += level(extent.width * scale, mip) *
|
||||
level(extent.height * scale, mip) * depth *
|
||||
EtcDecodedTexelBytes(format);
|
||||
}
|
||||
*compressed = compressed_total * layers;
|
||||
*decoded = decoded_total * layers;
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool EmulationDisabledByEnvironment() {
|
||||
@@ -207,8 +235,21 @@ struct ImageRecord {
|
||||
// Decoded level-0 texels of a scaled image; every host mip is resampled
|
||||
// from them so the texture keeps its detail at every draw size.
|
||||
std::shared_ptr<const RgbaImage> base;
|
||||
EmulatedTextureBytes bytes;
|
||||
};
|
||||
|
||||
// `now` is read under the lock that produced `totals`, so concurrent changes
|
||||
// reach the trace in the order they happened.
|
||||
void TraceTextureBytes(const EmulatedTextureBytes& totals, std::uint64_t now) {
|
||||
ChromeTraceWriter* const trace = ActiveProfileTrace();
|
||||
if (trace == nullptr) {
|
||||
return;
|
||||
}
|
||||
constexpr double kMegabyte = 1024.0 * 1024.0;
|
||||
trace->Counter("emulated texture (MB)", now, totals.decoded / kMegabyte);
|
||||
trace->Counter("compressed texture (MB)", now, totals.compressed / kMegabyte);
|
||||
}
|
||||
|
||||
bool IsColorFormat(EtcFormat format) {
|
||||
return format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1 ||
|
||||
format == EtcFormat::kEtc2Rgba8;
|
||||
@@ -669,6 +710,8 @@ struct VulkanEtc2Emulation::State {
|
||||
std::unordered_map<VkPhysicalDevice, bool> physical_devices;
|
||||
std::vector<std::unique_ptr<HostDevice>> devices;
|
||||
std::unordered_map<VkImage, ImageRecord> images;
|
||||
// Sum over `images`; guarded by `mutex`.
|
||||
EmulatedTextureBytes texture_bytes;
|
||||
std::unordered_map<VkBuffer, BufferBinding> buffers;
|
||||
std::unordered_map<VkDeviceMemory, Mapping> mappings;
|
||||
std::unordered_map<VkCommandBuffer, CommandRecord> commands;
|
||||
@@ -1245,6 +1288,8 @@ void VulkanEtc2Emulation::DestroyDevice(VkDevice device) {
|
||||
// Nothing may free staging or the semaphore while a decode still writes it.
|
||||
state_->DrainDecodes();
|
||||
std::vector<std::unique_ptr<StagingBlock>> doomed;
|
||||
EmulatedTextureBytes totals;
|
||||
std::uint64_t now = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
for (auto it = state_->commands.begin(); it != state_->commands.end();) {
|
||||
@@ -1265,11 +1310,20 @@ void VulkanEtc2Emulation::DestroyDevice(VkDevice device) {
|
||||
}
|
||||
}
|
||||
for (auto it = state_->images.begin(); it != state_->images.end();) {
|
||||
it = it->second.device == device ? state_->images.erase(it) : std::next(it);
|
||||
if (it->second.device == device) {
|
||||
state_->texture_bytes.compressed -= it->second.bytes.compressed;
|
||||
state_->texture_bytes.decoded -= it->second.bytes.decoded;
|
||||
it = state_->images.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
totals = state_->texture_bytes;
|
||||
now = TraceClockNanos();
|
||||
state_->has_commands.store(!state_->commands.empty(),
|
||||
std::memory_order_release);
|
||||
}
|
||||
TraceTextureBytes(totals, now);
|
||||
if (const HostDevice* dev = state_->Find(device); dev != nullptr) {
|
||||
for (const auto& block : doomed) {
|
||||
DestroyStagingBlock(*dev, *block);
|
||||
@@ -1316,6 +1370,14 @@ VkResult VulkanEtc2Emulation::CreateImage(VkDevice device,
|
||||
}
|
||||
const VkResult result = dev->create_image(device, &host_info, allocator, image);
|
||||
if (result == VK_SUCCESS && image != nullptr) {
|
||||
EmulatedTextureBytes bytes;
|
||||
if (!Etc2ImageByteCounts(format.etc_format, create_info->extent,
|
||||
create_info->mipLevels, create_info->arrayLayers,
|
||||
scale, &bytes.compressed, &bytes.decoded)) {
|
||||
bytes = {};
|
||||
}
|
||||
EmulatedTextureBytes totals;
|
||||
std::uint64_t now = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->images[*image] = {device,
|
||||
@@ -1324,8 +1386,14 @@ VkResult VulkanEtc2Emulation::CreateImage(VkDevice device,
|
||||
create_info->extent.width,
|
||||
create_info->extent.height,
|
||||
nullptr,
|
||||
nullptr};
|
||||
nullptr,
|
||||
bytes};
|
||||
state_->texture_bytes.compressed += bytes.compressed;
|
||||
state_->texture_bytes.decoded += bytes.decoded;
|
||||
totals = state_->texture_bytes;
|
||||
now = TraceClockNanos();
|
||||
}
|
||||
TraceTextureBytes(totals, now);
|
||||
if (ShouldLog(&g_image_logs, 4)) {
|
||||
std::fprintf(stderr,
|
||||
" [vulkan] ETC2 image %ux%u mips=%u format=%d -> host "
|
||||
@@ -1346,12 +1414,33 @@ void VulkanEtc2Emulation::DestroyImage(VkDevice device, VkImage image,
|
||||
return;
|
||||
}
|
||||
if (dev->emulated) {
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->images.erase(image);
|
||||
EmulatedTextureBytes totals;
|
||||
std::uint64_t now = 0;
|
||||
bool changed = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
const auto it = state_->images.find(image);
|
||||
if (it != state_->images.end()) {
|
||||
state_->texture_bytes.compressed -= it->second.bytes.compressed;
|
||||
state_->texture_bytes.decoded -= it->second.bytes.decoded;
|
||||
state_->images.erase(it);
|
||||
totals = state_->texture_bytes;
|
||||
now = TraceClockNanos();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
TraceTextureBytes(totals, now);
|
||||
}
|
||||
}
|
||||
dev->destroy_image(device, image, allocator);
|
||||
}
|
||||
|
||||
EmulatedTextureBytes VulkanEtc2Emulation::TextureBytes() const {
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
return state_->texture_bytes;
|
||||
}
|
||||
|
||||
VkResult VulkanEtc2Emulation::CreateImageView(
|
||||
VkDevice device, const VkImageViewCreateInfo* create_info,
|
||||
const VkAllocationCallbacks* allocator, VkImageView* view) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -66,9 +66,11 @@
|
||||
#include "runtime/display_size.h"
|
||||
#include "runtime/environment.h"
|
||||
#include "runtime/discord_rpc.h"
|
||||
#include "runtime/luau_codegen_switch.h"
|
||||
#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 +1965,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 +2015,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;
|
||||
|
||||
@@ -4612,6 +4657,27 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
<< "; continuing without Rich Presence\n";
|
||||
}
|
||||
}
|
||||
mocktail::runtime::LuauCodegenMenuSwitch luau_codegen_switch(
|
||||
[&process_environment]() -> std::uint8_t* {
|
||||
if (process_environment.GetOr("MOCKTAIL_LUAU_CODEGEN_MENU_SWITCH",
|
||||
"0") != "1") {
|
||||
return nullptr;
|
||||
}
|
||||
const std::uintptr_t base = g_libroblox_base;
|
||||
std::uint8_t* flag = mocktail::runtime::FindFastFlagValue(
|
||||
mocktail::runtime::LoadedImageSegments(base),
|
||||
mocktail::runtime::kLuauCodegenAllFastFlagName);
|
||||
if (flag == nullptr) {
|
||||
std::cerr << " [luau-codegen] DebugLuauCodegenAll not found; "
|
||||
"leaving it on in games\n";
|
||||
} else {
|
||||
std::cout << " [luau-codegen] menus-only switch at +0x" << std::hex
|
||||
<< (reinterpret_cast<std::uintptr_t>(flag) - base)
|
||||
<< std::dec << '\n';
|
||||
}
|
||||
return flag;
|
||||
},
|
||||
discord_rpc.observer());
|
||||
const mocktail::runtime::InputCapabilityConfig& input_capabilities =
|
||||
runtime_config.input_capabilities();
|
||||
if (!runtime_config.frame_rate().valid()) {
|
||||
@@ -6790,7 +6856,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
jni_factory, present_boundary, std::move(surface_config),
|
||||
&dependencies.roblox_credential(),
|
||||
mocktail::runtime::RobloxExperienceSurfaceProvider{},
|
||||
discord_rpc.observer(),
|
||||
luau_codegen_switch.observer(),
|
||||
dependencies.clear_persisted_web_view_cookie(),
|
||||
runtime_config.microphone_enabled(),
|
||||
platform_web_symbols.account);
|
||||
|
||||
+10
-3
@@ -33,6 +33,7 @@
|
||||
#include "runtime/fleasion.h"
|
||||
#include "runtime/game_mode.h"
|
||||
#include "runtime/graphics_launch_policy.h"
|
||||
#include "runtime/luau_codegen_switch.h"
|
||||
#include "runtime/memory_limit.h"
|
||||
#include "runtime/payload_update_preflight.h"
|
||||
#include "runtime/performance_policy.h"
|
||||
@@ -1012,6 +1013,7 @@ int main(int argc, char* argv[]) {
|
||||
std::cout
|
||||
<< " [auth] explicit guest identity selected for production VM\n";
|
||||
}
|
||||
unsetenv("MOCKTAIL_LUAU_CODEGEN_MENU_SWITCH");
|
||||
if (runtime_config.config.desktop_playability()) {
|
||||
std::filesystem::path assets = paths.DefaultAssetPath();
|
||||
if (environment.HasNonEmpty("MOCKTAIL_ASSET_PATH")) {
|
||||
@@ -1034,10 +1036,13 @@ int main(int argc, char* argv[]) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
std::string desktop_client_settings;
|
||||
const std::string user_client_settings =
|
||||
environment.GetOr("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON", "{}");
|
||||
const bool user_set_luau_codegen =
|
||||
mocktail::runtime::ClientSettingsSetLuauCodegenAll(
|
||||
user_client_settings);
|
||||
if (!mocktail::runtime::MergeDesktopAppPolicyClientSettingsOverride(
|
||||
desktop_policy.policy_json,
|
||||
environment.GetOr("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON",
|
||||
"{}"),
|
||||
desktop_policy.policy_json, user_client_settings,
|
||||
&desktop_client_settings, &command_line_error)) {
|
||||
std::cerr << "[FATAL] Cannot activate desktop Roblox app-policy: "
|
||||
<< command_line_error << '\n';
|
||||
@@ -1048,6 +1053,8 @@ int main(int argc, char* argv[]) {
|
||||
std::cerr << "[FATAL] Cannot export desktop Roblox app-policy\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
setenv("MOCKTAIL_LUAU_CODEGEN_MENU_SWITCH",
|
||||
user_set_luau_codegen ? "0" : "1", 1);
|
||||
std::cout << " [runtime] desktop app-policy ready: normalized="
|
||||
<< desktop_policy.normalized_policy_count
|
||||
<< " updated=" << (desktop_policy.updated ? 1 : 0)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,150 @@
|
||||
#include "runtime/luau_codegen_switch.h"
|
||||
|
||||
#include <elf.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kNameSlotOffset = 8;
|
||||
constexpr std::size_t kRecordTailSize = 16;
|
||||
|
||||
std::string_view SegmentBytes(const LoadedImageSegment& segment) {
|
||||
return {reinterpret_cast<const char*>(segment.address), segment.size};
|
||||
}
|
||||
|
||||
std::vector<std::uintptr_t> NameAddresses(
|
||||
const std::vector<LoadedImageSegment>& segments, std::string_view name) {
|
||||
std::string needle(name);
|
||||
needle.push_back('\0');
|
||||
std::vector<std::uintptr_t> addresses;
|
||||
for (const LoadedImageSegment& segment : segments) {
|
||||
const std::string_view bytes = SegmentBytes(segment);
|
||||
for (std::size_t at = bytes.find(needle); at != std::string_view::npos;
|
||||
at = bytes.find(needle, at + 1)) {
|
||||
addresses.push_back(segment.address + at);
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<LoadedImageSegment> LoadedImageSegments(std::uintptr_t image_base) {
|
||||
std::vector<LoadedImageSegment> segments;
|
||||
if (image_base == 0) {
|
||||
return segments;
|
||||
}
|
||||
const auto* header = reinterpret_cast<const Elf64_Ehdr*>(image_base);
|
||||
if (std::memcmp(header->e_ident, ELFMAG, SELFMAG) != 0 ||
|
||||
header->e_ident[EI_CLASS] != ELFCLASS64 || header->e_phoff == 0) {
|
||||
return segments;
|
||||
}
|
||||
const auto* program = reinterpret_cast<const Elf64_Phdr*>(
|
||||
image_base + static_cast<std::uintptr_t>(header->e_phoff));
|
||||
for (std::size_t index = 0; index < header->e_phnum; ++index) {
|
||||
if (program[index].p_type != PT_LOAD || program[index].p_memsz == 0) {
|
||||
continue;
|
||||
}
|
||||
segments.push_back({image_base + program[index].p_vaddr,
|
||||
static_cast<std::size_t>(program[index].p_memsz),
|
||||
(program[index].p_flags & PF_W) != 0});
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
std::uint8_t* FindFastFlagValue(const std::vector<LoadedImageSegment>& segments,
|
||||
std::string_view name) {
|
||||
const std::vector<std::uintptr_t> names = NameAddresses(segments, name);
|
||||
if (names.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
std::uint8_t* found = nullptr;
|
||||
int matches = 0;
|
||||
for (const LoadedImageSegment& segment : segments) {
|
||||
if (!segment.writable || segment.size < kNameSlotOffset + kRecordTailSize) {
|
||||
continue;
|
||||
}
|
||||
const std::uintptr_t first =
|
||||
(segment.address + kNameSlotOffset + 7) & ~std::uintptr_t{7};
|
||||
const std::uintptr_t end = segment.address + segment.size;
|
||||
for (std::uintptr_t slot = first; slot + kRecordTailSize <= end;
|
||||
slot += 8) {
|
||||
std::uintptr_t pointer = 0;
|
||||
std::memcpy(&pointer, reinterpret_cast<const void*>(slot),
|
||||
sizeof(pointer));
|
||||
if (std::find(names.begin(), names.end(), pointer) == names.end()) {
|
||||
continue;
|
||||
}
|
||||
std::uint64_t length = 0;
|
||||
std::memcpy(&length, reinterpret_cast<const void*>(slot + 8),
|
||||
sizeof(length));
|
||||
if (length != name.size()) {
|
||||
continue;
|
||||
}
|
||||
found = reinterpret_cast<std::uint8_t*>(slot - kNameSlotOffset);
|
||||
++matches;
|
||||
}
|
||||
}
|
||||
return matches == 1 ? found : nullptr;
|
||||
}
|
||||
|
||||
bool ClientSettingsSetLuauCodegenAll(std::string_view overrides_json) {
|
||||
const nlohmann::json overrides =
|
||||
nlohmann::json::parse(overrides_json, nullptr, false, true);
|
||||
return overrides.is_object() &&
|
||||
overrides.contains(std::string(kLuauCodegenAllClientSetting));
|
||||
}
|
||||
|
||||
bool LuauCodegenAllForPhase(RobloxExperiencePresencePhase phase) {
|
||||
return phase == RobloxExperiencePresencePhase::kBrowsing;
|
||||
}
|
||||
|
||||
LuauCodegenMenuSwitch::LuauCodegenMenuSwitch(
|
||||
LocateFlagFn locate_flag, RobloxExperiencePresenceObserver next)
|
||||
: locate_flag_(std::move(locate_flag)), next_(next) {}
|
||||
|
||||
RobloxExperiencePresenceObserver LuauCodegenMenuSwitch::observer() {
|
||||
return {this, &LuauCodegenMenuSwitch::Notify};
|
||||
}
|
||||
|
||||
void LuauCodegenMenuSwitch::OnPhase(
|
||||
RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!located_) {
|
||||
located_ = true;
|
||||
flag_ = locate_flag_ ? locate_flag_() : nullptr;
|
||||
}
|
||||
if (flag_ != nullptr) {
|
||||
const std::uint8_t current = __atomic_load_n(flag_, __ATOMIC_RELAXED);
|
||||
const std::uint8_t wanted = LuauCodegenAllForPhase(phase) ? 1 : 0;
|
||||
if (current <= 1 && current != wanted) {
|
||||
__atomic_store_n(flag_, wanted, __ATOMIC_RELAXED);
|
||||
std::cout << " [luau-codegen] native code for all scripts "
|
||||
<< (wanted != 0 ? "on" : "off") << '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (next_.valid()) {
|
||||
next_.notify(next_.context, phase, request);
|
||||
}
|
||||
}
|
||||
|
||||
void LuauCodegenMenuSwitch::Notify(
|
||||
void* context, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request) {
|
||||
static_cast<LuauCodegenMenuSwitch*>(context)->OnPhase(phase, request);
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include "runtime/luau_codegen_switch.h"
|
||||
#include "runtime/runtime_paths.h"
|
||||
|
||||
namespace mocktail {
|
||||
@@ -357,6 +358,9 @@ bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json,
|
||||
return false;
|
||||
}
|
||||
overrides[std::string(kDesktopAppPolicyOverride)] = policy_json;
|
||||
// Native Luau code makes the app's menus faster. LuauCodegenMenuSwitch turns
|
||||
// it off in games, which keep Roblox's own per-script choice.
|
||||
overrides.emplace(std::string(kLuauCodegenAllClientSetting), "True");
|
||||
// The SDUI Charts page in 2.736/2.738 can pass a table to TextColor3 and
|
||||
// leave the whole LuaApp blank. Default to the working legacy page while
|
||||
// preserving explicit user overrides for testing newer UI patches.
|
||||
|
||||
@@ -1446,8 +1446,9 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
return Status::Ok();
|
||||
if (!launch_in_progress_ && active_launch_ == nullptr && game_active_ &&
|
||||
!pending_launch_requests_.empty()) {
|
||||
// Start the queued launch only after consuming this leave callback, so
|
||||
// it cannot reach the next controller.
|
||||
// Blocks a reentrant drain while the leave pumps main-thread messages.
|
||||
// The leave ends in the Native stage with no Lua app return, so the
|
||||
// queued launch starts on the next drain.
|
||||
controlled_switch_waiting_for_return_ = true;
|
||||
switch_active_game = true;
|
||||
}
|
||||
@@ -1456,7 +1457,7 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
std::fprintf(stderr,
|
||||
"[experience] leaving active game for queued launch\n");
|
||||
const Status leave_status = LeaveGame();
|
||||
if (!leave_status.ok()) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
controlled_switch_waiting_for_return_ = false;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -101,6 +102,24 @@ std::string LegacyFontName(int32_t font) {
|
||||
return "SourceSansPro-Regular.ttf";
|
||||
}
|
||||
|
||||
// TwemojiMozilla covers Unicode emoji; RobloxEmoji holds the private-use
|
||||
// Roblox and Robux symbols.
|
||||
constexpr const char* kEmojiFontNames[] = {"TwemojiMozilla.ttf",
|
||||
"RobloxEmoji.ttf"};
|
||||
|
||||
std::vector<std::string> EmojiFontFiles(
|
||||
const std::filesystem::path& asset_content_root) {
|
||||
std::vector<std::string> files;
|
||||
for (const char* name : kEmojiFontNames) {
|
||||
const std::filesystem::path candidate =
|
||||
asset_content_root / "fonts" / name;
|
||||
if (IsRegularFile(candidate)) {
|
||||
files.push_back(candidate.lexically_normal().string());
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RobloxTextFontSelection ResolveRobloxTextFont(
|
||||
@@ -111,6 +130,7 @@ RobloxTextFontSelection ResolveRobloxTextFont(
|
||||
}
|
||||
|
||||
const std::filesystem::path content(asset_content_root);
|
||||
selection.emoji_files = EmojiFontFiles(content);
|
||||
const std::optional<FontMapping> mapped = MappingFor(font, content);
|
||||
if (mapped.has_value()) {
|
||||
const std::filesystem::path candidate = content / "fonts" / mapped->file;
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace {
|
||||
|
||||
constexpr std::size_t kMaximumRasterTextBytes = 4096;
|
||||
constexpr std::uint64_t kMaximumOverlayPixels = 16ULL * 1024ULL * 1024ULL;
|
||||
constexpr std::size_t kMaximumFallbackFonts = 6;
|
||||
constexpr std::size_t kMaximumFallbackFonts = 8;
|
||||
|
||||
std::mutex g_active_overlay_mutex;
|
||||
RobloxTextSurfaceOverlay* g_active_overlay = nullptr;
|
||||
@@ -121,10 +121,35 @@ std::vector<std::string> ResolveFontFiles(
|
||||
} else if (!selection.primary_file.empty()) {
|
||||
files.push_back(selection.primary_file);
|
||||
}
|
||||
files.insert(files.end(), selection.emoji_files.begin(),
|
||||
selection.emoji_files.end());
|
||||
if (!FcInit()) {
|
||||
return files;
|
||||
}
|
||||
|
||||
// The sorted sans list rarely reaches an emoji font before the cap, so ask
|
||||
// for the system emoji font by name ahead of it.
|
||||
FcPattern* emoji = FcNameParse(reinterpret_cast<const FcChar8*>("emoji"));
|
||||
if (emoji != nullptr) {
|
||||
FcConfigSubstitute(nullptr, emoji, FcMatchPattern);
|
||||
FcDefaultSubstitute(emoji);
|
||||
FcResult emoji_result = FcResultNoMatch;
|
||||
FcPattern* matched = FcFontMatch(nullptr, emoji, &emoji_result);
|
||||
FcPatternDestroy(emoji);
|
||||
FcChar8* file = nullptr;
|
||||
if (matched != nullptr &&
|
||||
FcPatternGetString(matched, FC_FILE, 0, &file) == FcResultMatch &&
|
||||
file != nullptr && file[0] != '\0') {
|
||||
const std::string candidate(reinterpret_cast<const char*>(file));
|
||||
if (std::find(files.begin(), files.end(), candidate) == files.end()) {
|
||||
files.push_back(candidate);
|
||||
}
|
||||
}
|
||||
if (matched != nullptr) {
|
||||
FcPatternDestroy(matched);
|
||||
}
|
||||
}
|
||||
|
||||
FcPattern* pattern = FcNameParse(reinterpret_cast<const FcChar8*>("sans"));
|
||||
if (pattern == nullptr) {
|
||||
return files;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -265,6 +279,13 @@ target_link_libraries(roblox_desktop_app_policy_test PRIVATE
|
||||
)
|
||||
mocktail_apply_compile_options(roblox_desktop_app_policy_test)
|
||||
|
||||
add_executable(luau_codegen_switch_test luau_codegen_switch_test.cc)
|
||||
target_link_libraries(luau_codegen_switch_test PRIVATE
|
||||
Mocktail::Runtime
|
||||
GTest::gtest_main
|
||||
)
|
||||
mocktail_apply_compile_options(luau_codegen_switch_test)
|
||||
|
||||
add_executable(browser_tracker_service_test browser_tracker_service_test.cc)
|
||||
target_link_libraries(browser_tracker_service_test PRIVATE
|
||||
Mocktail::Services
|
||||
@@ -1020,6 +1041,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 +1067,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)
|
||||
@@ -1127,6 +1150,7 @@ if(MOCKTAIL_ENABLE_UPSTREAM_JNIVM)
|
||||
endif()
|
||||
gtest_discover_tests(libc_shim_test)
|
||||
gtest_discover_tests(discord_rpc_test)
|
||||
gtest_discover_tests(luau_codegen_switch_test)
|
||||
gtest_discover_tests(asset_manager_test)
|
||||
gtest_discover_tests(android_looper_test)
|
||||
gtest_discover_tests(android_native_window_test)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "runtime/luau_codegen_switch.h"
|
||||
|
||||
#include <elf.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::runtime {
|
||||
namespace {
|
||||
|
||||
constexpr char kStrings[] = "\0FFlagOther\0DebugLuauCodegenAll\0tail";
|
||||
|
||||
struct alignas(8) FlagRecord {
|
||||
std::uint8_t value = 1;
|
||||
std::uint8_t padding[7] = {};
|
||||
const char* name = nullptr;
|
||||
std::uint64_t name_length = 0;
|
||||
std::uint64_t next = 0;
|
||||
};
|
||||
|
||||
const char* NameInStrings() {
|
||||
return kStrings + 12;
|
||||
}
|
||||
|
||||
std::vector<LoadedImageSegment> Segments(std::vector<FlagRecord>& records) {
|
||||
return {
|
||||
{reinterpret_cast<std::uintptr_t>(kStrings), sizeof(kStrings), false},
|
||||
{reinterpret_cast<std::uintptr_t>(records.data()),
|
||||
records.size() * sizeof(FlagRecord), true},
|
||||
};
|
||||
}
|
||||
|
||||
TEST(LuauCodegenSwitchTest, FindsTheFlagRecordByName) {
|
||||
ASSERT_EQ(std::string(NameInStrings()), "DebugLuauCodegenAll");
|
||||
std::vector<FlagRecord> records(3);
|
||||
records[1].name = NameInStrings();
|
||||
records[1].name_length = 19;
|
||||
const auto segments = Segments(records);
|
||||
EXPECT_EQ(FindFastFlagValue(segments, kLuauCodegenAllFastFlagName),
|
||||
&records[1].value);
|
||||
}
|
||||
|
||||
TEST(LuauCodegenSwitchTest, RejectsWrongLengthMissingOrDuplicateRecords) {
|
||||
std::vector<FlagRecord> records(3);
|
||||
records[1].name = NameInStrings();
|
||||
records[1].name_length = 18;
|
||||
auto segments = Segments(records);
|
||||
EXPECT_EQ(FindFastFlagValue(segments, kLuauCodegenAllFastFlagName), nullptr);
|
||||
|
||||
records[1].name_length = 19;
|
||||
EXPECT_EQ(FindFastFlagValue(segments, "DebugLuauCodegen"), nullptr);
|
||||
|
||||
records[2].name = NameInStrings();
|
||||
records[2].name_length = 19;
|
||||
EXPECT_EQ(FindFastFlagValue(segments, kLuauCodegenAllFastFlagName), nullptr);
|
||||
}
|
||||
|
||||
TEST(LuauCodegenSwitchTest, IgnoresRecordsInReadOnlySegments) {
|
||||
std::vector<FlagRecord> records(1);
|
||||
records[0].name = NameInStrings();
|
||||
records[0].name_length = 19;
|
||||
auto segments = Segments(records);
|
||||
segments[1].writable = false;
|
||||
EXPECT_EQ(FindFastFlagValue(segments, kLuauCodegenAllFastFlagName), nullptr);
|
||||
}
|
||||
|
||||
TEST(LuauCodegenSwitchTest, ReadsLoadSegmentsFromTheElfHeader) {
|
||||
alignas(8) std::uint8_t image[0x400] = {};
|
||||
auto* header = reinterpret_cast<Elf64_Ehdr*>(image);
|
||||
std::memcpy(header->e_ident, ELFMAG, SELFMAG);
|
||||
header->e_ident[EI_CLASS] = ELFCLASS64;
|
||||
header->e_phoff = sizeof(Elf64_Ehdr);
|
||||
header->e_phnum = 3;
|
||||
auto* program = reinterpret_cast<Elf64_Phdr*>(image + header->e_phoff);
|
||||
program[0] = {PT_LOAD, PF_R | PF_X, 0, 0, 0, 0x200, 0x200, 0x1000};
|
||||
program[1] = {PT_DYNAMIC, PF_R | PF_W, 0, 0x200, 0, 0x10, 0x10, 8};
|
||||
program[2] = {PT_LOAD, PF_R | PF_W, 0, 0x200, 0, 0x80, 0x180, 0x1000};
|
||||
const auto base = reinterpret_cast<std::uintptr_t>(image);
|
||||
const auto segments = LoadedImageSegments(base);
|
||||
ASSERT_EQ(segments.size(), 2u);
|
||||
EXPECT_EQ(segments[0].address, base);
|
||||
EXPECT_EQ(segments[0].size, 0x200u);
|
||||
EXPECT_FALSE(segments[0].writable);
|
||||
EXPECT_EQ(segments[1].address, base + 0x200);
|
||||
EXPECT_EQ(segments[1].size, 0x180u);
|
||||
EXPECT_TRUE(segments[1].writable);
|
||||
|
||||
std::memset(image, 0, SELFMAG);
|
||||
EXPECT_TRUE(LoadedImageSegments(base).empty());
|
||||
}
|
||||
|
||||
TEST(LuauCodegenSwitchTest, DetectsAUserSetting) {
|
||||
EXPECT_FALSE(ClientSettingsSetLuauCodegenAll("{}"));
|
||||
EXPECT_FALSE(ClientSettingsSetLuauCodegenAll(R"({"FIntOther":1})"));
|
||||
EXPECT_FALSE(ClientSettingsSetLuauCodegenAll("not-json"));
|
||||
EXPECT_TRUE(
|
||||
ClientSettingsSetLuauCodegenAll(R"({"FFlagDebugLuauCodegenAll":true})"));
|
||||
EXPECT_TRUE(
|
||||
ClientSettingsSetLuauCodegenAll(R"({"FFlagDebugLuauCodegenAll":"False"})"));
|
||||
}
|
||||
|
||||
TEST(LuauCodegenSwitchTest, EnablesCodegenOnlyWhileBrowsing) {
|
||||
EXPECT_TRUE(LuauCodegenAllForPhase(RobloxExperiencePresencePhase::kBrowsing));
|
||||
EXPECT_FALSE(LuauCodegenAllForPhase(RobloxExperiencePresencePhase::kJoining));
|
||||
EXPECT_FALSE(LuauCodegenAllForPhase(RobloxExperiencePresencePhase::kPlaying));
|
||||
}
|
||||
|
||||
struct Forwarded {
|
||||
int count = 0;
|
||||
RobloxExperiencePresencePhase phase = RobloxExperiencePresencePhase::kBrowsing;
|
||||
};
|
||||
|
||||
void RecordForward(void* context, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest*) {
|
||||
auto* forwarded = static_cast<Forwarded*>(context);
|
||||
++forwarded->count;
|
||||
forwarded->phase = phase;
|
||||
}
|
||||
|
||||
TEST(LuauCodegenSwitchTest, WritesTheFlagPerPhaseAndForwards) {
|
||||
std::uint8_t flag = 1;
|
||||
int locate_calls = 0;
|
||||
Forwarded forwarded;
|
||||
LuauCodegenMenuSwitch menu_switch(
|
||||
[&] {
|
||||
++locate_calls;
|
||||
return &flag;
|
||||
},
|
||||
{&forwarded, &RecordForward});
|
||||
const RobloxExperiencePresenceObserver observer = menu_switch.observer();
|
||||
ASSERT_TRUE(observer.valid());
|
||||
|
||||
observer.notify(observer.context, RobloxExperiencePresencePhase::kJoining,
|
||||
nullptr);
|
||||
EXPECT_EQ(flag, 0);
|
||||
observer.notify(observer.context, RobloxExperiencePresencePhase::kPlaying,
|
||||
nullptr);
|
||||
EXPECT_EQ(flag, 0);
|
||||
observer.notify(observer.context, RobloxExperiencePresencePhase::kBrowsing,
|
||||
nullptr);
|
||||
EXPECT_EQ(flag, 1);
|
||||
EXPECT_EQ(locate_calls, 1);
|
||||
EXPECT_EQ(forwarded.count, 3);
|
||||
EXPECT_EQ(forwarded.phase, RobloxExperiencePresencePhase::kBrowsing);
|
||||
}
|
||||
|
||||
TEST(LuauCodegenSwitchTest, LeavesUnexpectedValuesAndMissingFlagsAlone) {
|
||||
std::uint8_t flag = 7;
|
||||
LuauCodegenMenuSwitch menu_switch([&] { return &flag; }, {});
|
||||
menu_switch.OnPhase(RobloxExperiencePresencePhase::kJoining, nullptr);
|
||||
EXPECT_EQ(flag, 7);
|
||||
|
||||
LuauCodegenMenuSwitch missing([] { return nullptr; }, {});
|
||||
missing.OnPhase(RobloxExperiencePresencePhase::kJoining, nullptr);
|
||||
EXPECT_TRUE(missing.observer().valid());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mocktail::runtime
|
||||
@@ -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' \
|
||||
|
||||
@@ -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()
|
||||
@@ -236,6 +236,22 @@ TEST(RobloxDesktopAppPolicyTest, DefaultsDesktopFlyoutSidePanelUnlessOverridden)
|
||||
"0");
|
||||
}
|
||||
|
||||
TEST(RobloxDesktopAppPolicyTest, DefaultsLuauCodegenAllUnlessOverridden) {
|
||||
const std::string policy =
|
||||
nlohmann::json({{"PlatformGroup", "Desktop"}}).dump();
|
||||
std::string merged;
|
||||
std::string error;
|
||||
ASSERT_TRUE(MergeDesktopAppPolicyClientSettingsOverride(policy, "{}", &merged,
|
||||
&error))
|
||||
<< error;
|
||||
EXPECT_EQ(nlohmann::json::parse(merged)["FFlagDebugLuauCodegenAll"], "True");
|
||||
|
||||
ASSERT_TRUE(MergeDesktopAppPolicyClientSettingsOverride(
|
||||
policy, R"({"FFlagDebugLuauCodegenAll":false})", &merged, &error))
|
||||
<< error;
|
||||
EXPECT_EQ(nlohmann::json::parse(merged)["FFlagDebugLuauCodegenAll"], false);
|
||||
}
|
||||
|
||||
TEST(RobloxDesktopAppPolicyTest, RejectsMalformedOrSymlinkedStorage) {
|
||||
TemporaryDirectory temporary;
|
||||
const std::filesystem::path default_path = temporary.path() / "default.json";
|
||||
|
||||
@@ -816,13 +816,9 @@ TEST(RobloxExperienceCompositionTest,
|
||||
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
|
||||
EXPECT_EQ(probe.leaves, 1);
|
||||
EXPECT_EQ(probe.starts.load(), 1);
|
||||
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
|
||||
EXPECT_EQ(probe.leaves, 1);
|
||||
EXPECT_EQ(probe.starts.load(), 1);
|
||||
|
||||
// The exact gameDidLeave callback is the safe boundary between the old
|
||||
// controller and the replacement server launch.
|
||||
composition.NotifyLuaAppDidReturn();
|
||||
// nativeAppBridgeV2LeaveGame ends in the Native stage and Roblox sends no
|
||||
// Lua app return, so the queued launch starts once the leave has returned.
|
||||
for (int attempt = 0; attempt < 1000 && probe.starts.load() < 2; ++attempt) {
|
||||
ASSERT_TRUE(composition.DrainLaunchRequests().ok());
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
|
||||
@@ -97,6 +97,23 @@ TEST(RobloxTextFontResolverTest, RejectsMappingPathTraversal) {
|
||||
(assets.root() / "assets/fonts/SourceSansPro-Regular.ttf").string());
|
||||
}
|
||||
|
||||
TEST(RobloxTextFontResolverTest, AddsBundledEmojiFontsAsFallbacks) {
|
||||
TemporaryFontAssets assets;
|
||||
ASSERT_FALSE(assets.root().empty());
|
||||
ASSERT_TRUE(assets.CreateFile("assets/fonts/SourceSansPro-Regular.ttf"));
|
||||
ASSERT_TRUE(assets.CreateFile("assets/content/fonts/RobloxEmoji.ttf"));
|
||||
ASSERT_TRUE(assets.CreateFile("assets/content/fonts/TwemojiMozilla.ttf"));
|
||||
|
||||
const RobloxTextFontSelection selection =
|
||||
ResolveRobloxTextFont(3, assets.content().string());
|
||||
|
||||
ASSERT_EQ(selection.emoji_files.size(), 2U);
|
||||
EXPECT_EQ(selection.emoji_files[0],
|
||||
(assets.content() / "fonts/TwemojiMozilla.ttf").string());
|
||||
EXPECT_EQ(selection.emoji_files[1],
|
||||
(assets.content() / "fonts/RobloxEmoji.ttf").string());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -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
|
||||
|
||||
@@ -89,6 +89,20 @@ class SummarizeTest(unittest.TestCase):
|
||||
self.assertIn("| frame interval p50 (ms) | 20.00 | 10.00 |", lines)
|
||||
self.assertIn("| a mean (ms) | 1.00 | 0.50 |", lines)
|
||||
|
||||
def test_texture_counters_report_their_peak(self) -> None:
|
||||
events = [
|
||||
{"name": "emulated texture (MB)", "ph": "C", "ts": ts, "pid": 1,
|
||||
"tid": 1, "args": {"value": value}}
|
||||
for ts, value in ((1, 10.0), (2, 300.0), (3, 120.0))
|
||||
]
|
||||
with_textures = summary.summarize(events)
|
||||
without = summary.summarize([])
|
||||
|
||||
self.assertEqual(with_textures.peaks["emulated texture (MB)"], 300.0)
|
||||
table = summary.to_markdown([("one", with_textures), ("two", without)])
|
||||
self.assertIn("| emulated texture (MB) peak | 300.00 | - |",
|
||||
table.splitlines())
|
||||
|
||||
def test_markdown_marks_missing_values(self) -> None:
|
||||
with_slice = summary.summarize([slice_event("a", 1_000)])
|
||||
without = summary.summarize([])
|
||||
|
||||
@@ -102,6 +102,37 @@ TEST(VulkanEtc2EmulationTest, CountsUploadBytesPerRegion) {
|
||||
EXPECT_EQ(decoded, 2U);
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, CountsWholeImageBytesAcrossMipsAndLayers) {
|
||||
VkDeviceSize compressed = 0;
|
||||
VkDeviceSize decoded = 0;
|
||||
// 64x64 with a full chain: 64, 32, 16, 8, 4, 2, 1.
|
||||
ASSERT_TRUE(Etc2ImageByteCounts(EtcFormat::kEtc2Rgb8, {64, 64, 1}, 7, 1, 1,
|
||||
&compressed, &decoded));
|
||||
EXPECT_EQ(compressed, 343U * 8U);
|
||||
EXPECT_EQ(decoded, 5461U * 4U);
|
||||
|
||||
// Two layers of EAC R11, 2 bytes per decoded texel.
|
||||
ASSERT_TRUE(Etc2ImageByteCounts(EtcFormat::kEacR11, {8, 4, 1}, 1, 2, 1,
|
||||
&compressed, &decoded));
|
||||
EXPECT_EQ(compressed, 2U * 2U * 8U);
|
||||
EXPECT_EQ(decoded, 2U * 32U * 2U);
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, CountsDecodedBytesAtTheUpscaledExtent) {
|
||||
VkDeviceSize compressed = 0;
|
||||
VkDeviceSize decoded = 0;
|
||||
// The host image is 256x256 with the same 7 levels: 256 down to 4.
|
||||
ASSERT_TRUE(Etc2ImageByteCounts(EtcFormat::kEtc2Rgba8, {64, 64, 1}, 7, 1, 4,
|
||||
&compressed, &decoded));
|
||||
EXPECT_EQ(compressed, 343U * 16U);
|
||||
EXPECT_EQ(decoded, 87376U * 4U);
|
||||
|
||||
EXPECT_FALSE(Etc2ImageByteCounts(EtcFormat::kEtc2Rgb8, {0, 4, 1}, 1, 1, 1,
|
||||
&compressed, &decoded));
|
||||
EXPECT_FALSE(Etc2ImageByteCounts(EtcFormat::kEtc2Rgb8, {4, 4, 1}, 0, 1, 1,
|
||||
&compressed, &decoded));
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, RejectsUnsupportedRegions) {
|
||||
VkDeviceSize compressed = 0;
|
||||
VkDeviceSize decoded = 0;
|
||||
@@ -147,6 +178,9 @@ VKAPI_ATTR VkResult VKAPI_CALL FakeCreateImage(VkDevice,
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
|
||||
VKAPI_ATTR void VKAPI_CALL FakeDestroyImage(VkDevice, VkImage,
|
||||
const VkAllocationCallbacks*) {}
|
||||
|
||||
VKAPI_ATTR void VKAPI_CALL FakeCopyImage(VkCommandBuffer, VkImage,
|
||||
VkImageLayout, VkImage, VkImageLayout,
|
||||
std::uint32_t, const VkImageCopy*) {
|
||||
@@ -295,6 +329,7 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
|
||||
if (is("vkDestroySemaphore"))
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(FakeDestroySemaphore);
|
||||
if (is("vkCreateImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateImage);
|
||||
if (is("vkDestroyImage")) return reinterpret_cast<PFN_vkVoidFunction>(FakeDestroyImage);
|
||||
if (is("vkCreateBuffer")) return reinterpret_cast<PFN_vkVoidFunction>(FakeCreateBuffer);
|
||||
if (is("vkGetBufferMemoryRequirements"))
|
||||
return reinterpret_cast<PFN_vkVoidFunction>(FakeBufferRequirements);
|
||||
@@ -313,6 +348,47 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL FakeGetDeviceProcAddr(
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, TotalsTheBytesOfLiveEmulatedImages) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x12);
|
||||
VkPhysicalDeviceMemoryProperties memory{};
|
||||
VulkanEtc2Emulation emulation;
|
||||
emulation.RegisterDevice(device, FakeHandle<VkPhysicalDevice>(0x20), true,
|
||||
memory, FakeGetDeviceProcAddr);
|
||||
|
||||
VkImageCreateInfo image_info{};
|
||||
image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
|
||||
image_info.imageType = VK_IMAGE_TYPE_3D;
|
||||
image_info.format = VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK;
|
||||
image_info.extent = {8, 8, 1};
|
||||
image_info.mipLevels = 1;
|
||||
image_info.arrayLayers = 1;
|
||||
VkImage first = VK_NULL_HANDLE;
|
||||
VkImage second = VK_NULL_HANDLE;
|
||||
ASSERT_EQ(emulation.CreateImage(device, &image_info, nullptr, &first),
|
||||
VK_SUCCESS);
|
||||
ASSERT_EQ(emulation.CreateImage(device, &image_info, nullptr, &second),
|
||||
VK_SUCCESS);
|
||||
image_info.format = VK_FORMAT_R8G8B8A8_UNORM;
|
||||
VkImage plain = VK_NULL_HANDLE;
|
||||
ASSERT_EQ(emulation.CreateImage(device, &image_info, nullptr, &plain),
|
||||
VK_SUCCESS);
|
||||
|
||||
EmulatedTextureBytes totals = emulation.TextureBytes();
|
||||
EXPECT_EQ(totals.compressed, 2U * 4U * 8U);
|
||||
EXPECT_EQ(totals.decoded, 2U * 64U * 4U);
|
||||
|
||||
emulation.DestroyImage(device, first, nullptr);
|
||||
emulation.DestroyImage(device, plain, nullptr);
|
||||
totals = emulation.TextureBytes();
|
||||
EXPECT_EQ(totals.compressed, 4U * 8U);
|
||||
EXPECT_EQ(totals.decoded, 64U * 4U);
|
||||
|
||||
emulation.DestroyDevice(device);
|
||||
totals = emulation.TextureBytes();
|
||||
EXPECT_EQ(totals.compressed, 0U);
|
||||
EXPECT_EQ(totals.decoded, 0U);
|
||||
}
|
||||
|
||||
TEST(VulkanEtc2EmulationTest, SkipsUploadOutsideTheMappedWindow) {
|
||||
const VkDevice device = FakeHandle<VkDevice>(0x10);
|
||||
const VkBuffer source = FakeHandle<VkBuffer>(0x200);
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user