Skip to content

Commit eb0f038

Browse files
committed
macos: the JIT window follows the mapping, and the app is entitled for it
pthread_jit_write_protect_np traps a process without com.apple.security.cs.allow-jit rather than failing, so 3.1.9 died in JitWriteWindow's constructor on the first Warden module every arm64 Mac received. The window was gated on arm64 macOS while the mapping it guards is gated on macOS and no Unicorn. Release builds have Unicorn, so no MAP_JIT page existed and the window was pure liability. Both read WOWEE_MAP_JIT now. A build without Unicorn does map MAP_JIT and cannot without the entitlement, so sign_app.sh takes one and the release grants it. verify_signature.sh fails on its absence: a missing entitlement is not a signing error, so every check passed on the build that crashed. Reported in #131
1 parent e4b71d0 commit eb0f038

9 files changed

Lines changed: 119 additions & 13 deletions

File tree

.github/workflows/build.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,11 @@ jobs:
308308
if [ "${MACOS_SIGNING_ENABLED}" = "true" ]; then
309309
IDENTITY="${APPLE_SIGNING_IDENTITY}"
310310
fi
311-
bash tools/macos/sign_app.sh Wowee.app "${IDENTITY}"
311+
bash tools/macos/sign_app.sh Wowee.app "${IDENTITY}" \
312+
tools/macos/wowee.entitlements
312313
bash tools/macos/sign_app.sh "Wowee Asset Extractor.app" "${IDENTITY}"
313-
bash tools/macos/verify_signature.sh Wowee.app "${IDENTITY}"
314+
bash tools/macos/verify_signature.sh Wowee.app "${IDENTITY}" \
315+
com.apple.security.cs.allow-jit
314316
bash tools/macos/verify_signature.sh \
315317
"Wowee Asset Extractor.app" "${IDENTITY}"
316318

.github/workflows/release.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,11 @@ jobs:
286286
if [ "${MACOS_SIGNING_ENABLED}" = "true" ]; then
287287
IDENTITY="${APPLE_SIGNING_IDENTITY}"
288288
fi
289-
bash tools/macos/sign_app.sh Wowee.app "${IDENTITY}"
289+
bash tools/macos/sign_app.sh Wowee.app "${IDENTITY}" \
290+
tools/macos/wowee.entitlements
290291
bash tools/macos/sign_app.sh "Wowee Asset Extractor.app" "${IDENTITY}"
291-
bash tools/macos/verify_signature.sh Wowee.app "${IDENTITY}"
292+
bash tools/macos/verify_signature.sh Wowee.app "${IDENTITY}" \
293+
com.apple.security.cs.allow-jit
292294
bash tools/macos/verify_signature.sh \
293295
"Wowee Asset Extractor.app" "${IDENTITY}"
294296

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## [v3.1.10] - 2026-08-27
4+
5+
### Fixed
6+
- **3.1.9 crashed at login on every Apple Silicon Mac.** `pthread_jit_write_protect_np` answers a process without `com.apple.security.cs.allow-jit` by trapping rather than by failing, and the app is signed with the hardened runtime and no entitlements at all, so the first Warden module the realm sent took the client out in `JitWriteWindow`'s constructor. Two things were wrong at once. The window was gated on arm64 macOS while the mapping it guards is gated on macOS *and* no Unicorn, and release builds have Unicorn, so the image was never mapped `MAP_JIT` and the window was pure liability; both now read one `WOWEE_MAP_JIT`, so the guard cannot come apart from the thing it guards again. And the entitlement is granted, because a build without Unicorn does map `MAP_JIT` and cannot without it. `verify_signature.sh` fails the release if the entitlement is not on the signature, which nothing checked before: a missing entitlement is not a signing error, so every existing check passed on the build that crashed. Reported in #131
7+
- **A random suffix named no stats.** The suffix tooltip read `itemStatName`, which answers nothing for Strength through Spirit because a query response prints those from fields of their own. A suffix has no such fields, so "of the Boar" and every other animal suffix listed a name and nothing under it. `itemModStatName` names them
8+
- **An item dragged out of a bag onto the world came back on the next bag update.** The drag release cleared the cursor whenever it landed on nothing, which is right for an action taken off a bar and wrong for an item out of a bag. The delete confirmation reads the cursor after the dispatch returns, so it found nothing to ask about
9+
- **The auction browse tab's column headers sorted nothing on a single page.** Clicking one re-queries with the ordering attached, which is that tab's design, but AzerothCore only sorts once the result runs past one page, so a search returning fifty rows or fewer came back in the realm's own order however the headers were clicked. The page is sorted before it is drawn with the keys the click already set, and a page the realm ordered is left alone
10+
- **An action-bar slot naming a key never found it.** `useItemById` walked the backpack and the four bags only, and a key lives in neither. `/use` with an equipment slot number reads that slot's item id and passes it here too, so the one call site written for equipped items could not work either. Both are searched last, so nothing that already resolved moves
11+
- **The objective tracker listed quests from every zone.** `trackerFilter` defaulted to 7, which ticks "quests in other zones"; three is the stock value. Clearing that bit filters on `CURRENT_MAP_QUESTS`, which `WatchFrame_GetCurrentMapQuests` built from the POIs the realm had sent rather than from the player's zone, so that is rebuilt from the quest log's zone headers
12+
- **Scrolling up scrolled down.** `hybridscrollframe.lua` is `if delta == 1 then up else down`, so a wheel reporting 2 or 3 went the wrong way. Down was never affected: every negative delta fails that test into the branch it wanted. The camera keeps the magnitude
13+
- **The helm and cloak switches came back on at every login.** `helmVisible_` was written by `toggleHelm` and nothing else, so it started true each session while the realm still had the flag set. It is read from `PLAYER_FLAGS` on create as well as on change, because login delivers the player as a CREATE block
14+
- **Every interactable answered the same hand cursor.** A vendor shows `Buy.blp`, a hostile the attack cursor and a corpse the loot cursor
15+
- **Every session began windowed with vsync at its default.** The window is built from a `WindowConfig` written by hand, so the saved fullscreen and vsync choices were never read. They are applied beside `applySavedAntiAliasing`, before the first frame
16+
- **An item dropped into the auction sell slot had no icon, and Create Auction stayed disabled.** `AuctionSellItemButton_OnEvent` is the only thing that draws that slot, it runs on `NEW_AUCTION_UPDATE`, and nothing fired it. `ValidateAuction` reads the stack and total counts that handler sets
17+
- **A quest item could not be used at all.** `UseContainerItem` chose equip on `inventoryType` alone; some quest items carry one, so the right-click sent `CMSG_AUTOEQUIP_ITEM` and the realm answered `ERR_NOT_EQUIPPABLE`. Class 12 is never equipment
18+
- **One frame of whatever memory held when the world map was opened.** The composite image's contents are undefined until the first composite pass and `ImGui::Image` drew it unconditionally. A zone change is not this case: `invalidateComposite` only says the picture is stale
19+
- **The world map dropdowns came up empty.** `SetMapToCurrentZone` raises a recenter flag and fires `WORLD_MAP_UPDATE` at once, but the move happens a frame later, so `GetCurrentMapContinent` answered 0 while the dropdowns were being built. Zero is the branch that runs `UIDropDownMenu_ClearAll` and asks `GetMapZones(0)`, and nothing asked again. The map now says when it has moved, and `setMapByIndex` returns whether the pair was taken
20+
- **An off-hand weapon swung with the main hand's animation.** Both places that asked for a dual wield tested `ONE_HAND` alone, so an off-hand-only weapon (INVTYPE 22) set no `hasOffHand`: no off-hand animation and no unsheathe from that hand. The off-hand swing also chose its chain from the main hand's `isFist`/`isDagger`, so a sword and a dagger each swung with the other's
21+
322
## [v3.1.8] - 2026-08-21
423

524
### Changed

include/game/jit_write.hpp

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,23 @@
88
// pointer anywhere in it, which is why it surfaces as a fault inside memset
99
// rather than as anything to do with the caller.
1010
//
11-
// Everywhere else this is nothing: x86-64 macOS needs no such call, and no
12-
// other platform has MAP_JIT at all.
11+
// Everywhere else this is nothing: x86-64 macOS needs no such call, no other
12+
// platform has MAP_JIT at all, and an emulated build never maps one.
1313

1414
#include <cstddef>
1515

16-
#if defined(__APPLE__) && (defined(__aarch64__) || defined(__arm64__))
16+
// Whether an executable image is mapped MAP_JIT here. Apple's hardened runtime
17+
// refuses an anonymous PROT_EXEC mapping without it; an emulated build copies
18+
// the image into the emulator instead of running it, so it never asks for
19+
// PROT_EXEC and never wants MAP_JIT. Both the mapping and the window below read
20+
// this one answer, because a window opened over memory that is not MAP_JIT
21+
// calls a JIT API this process was never entitled to use, and that call traps
22+
// the process rather than returning a failure.
23+
#if defined(__APPLE__) && !defined(HAVE_UNICORN)
24+
#define WOWEE_MAP_JIT 1
25+
#endif
26+
27+
#if defined(WOWEE_MAP_JIT) && (defined(__aarch64__) || defined(__arm64__))
1728
#include <pthread.h>
1829
#define WOWEE_JIT_WRITE_PROTECT 1
1930
#endif

src/game/warden_module.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,7 @@ bool WardenModule::parseExecutableFormat(const std::vector<uint8_t>& exeData) {
609609
#ifdef HAVE_UNICORN
610610
const int mmapProt = PROT_READ | PROT_WRITE;
611611
const int mmapFlags = MAP_PRIVATE | MAP_ANONYMOUS;
612-
#elif defined(__APPLE__)
612+
#elif defined(WOWEE_MAP_JIT)
613613
const int mmapProt = PROT_READ | PROT_WRITE | PROT_EXEC;
614614
const int mmapFlags = MAP_PRIVATE | MAP_ANONYMOUS | MAP_JIT;
615615
#else
@@ -682,7 +682,7 @@ bool WardenModule::parseExecutableFormat(const std::vector<uint8_t>& exeData) {
682682
#ifdef HAVE_UNICORN
683683
int mmapProt = PROT_READ | PROT_WRITE;
684684
int mmapFlags = MAP_PRIVATE | MAP_ANONYMOUS;
685-
#elif defined(__APPLE__)
685+
#elif defined(WOWEE_MAP_JIT)
686686
int mmapProt = PROT_READ | PROT_WRITE | PROT_EXEC;
687687
int mmapFlags = MAP_PRIVATE | MAP_ANONYMOUS | MAP_JIT;
688688
#else

tests/test_jit_write.cpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,24 @@ TEST_CASE("the window nests without re-arming early", "[jit]") {
8282

8383
#endif // !_WIN32 && MAP_JIT
8484

85+
TEST_CASE("the window follows the mapping, not the platform", "[jit]") {
86+
// The window costs nothing where it is needed and takes the process out
87+
// where it is not: pthread_jit_write_protect_np answers a process without
88+
// com.apple.security.cs.allow-jit by trapping, not by failing. So it must
89+
// open only where the image is actually mapped MAP_JIT.
90+
//
91+
// 3.1.9 crashed every arm64 Mac at login on exactly that gap. The window
92+
// was gated on arm64 macOS while the mapping was gated on macOS *and* no
93+
// Unicorn, and the release build has Unicorn: no MAP_JIT page existed, and
94+
// the first Warden module the server sent trapped in the constructor.
95+
// Reported in #131. Both read WOWEE_MAP_JIT now.
96+
#ifdef WOWEE_MAP_JIT
97+
SUCCEED("the module image is mapped MAP_JIT here");
98+
#else
99+
CHECK_FALSE(JitWriteWindow::required());
100+
#endif
101+
}
102+
85103
TEST_CASE("the window closes back to none", "[jit]") {
86104
// Whatever the platform, a balanced set of windows leaves none open. On
87105
// anything but arm64 macOS this is all the class does.

tools/macos/sign_app.sh

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
#!/bin/bash
22
set -euo pipefail
33

4-
APP_PATH="${1:?usage: sign_app.sh <app> [identity]}"
4+
APP_PATH="${1:?usage: sign_app.sh <app> [identity] [entitlements]}"
55
IDENTITY="${2:--}"
6+
ENTITLEMENTS="${3:-}"
7+
8+
if [ -n "${ENTITLEMENTS}" ] && [ ! -f "${ENTITLEMENTS}" ]; then
9+
echo "ERROR: entitlements file not found: ${ENTITLEMENTS}" >&2
10+
exit 1
11+
fi
612

713
# Homebrew bottles and downloaded resources can carry read-only modes or
814
# provenance/quarantine attributes. Both interfere with deterministic bundle
@@ -32,5 +38,17 @@ while IFS= read -r -d '' component; do
3238
fi
3339
done < <(find "${APP_PATH}/Contents" -type f -print0)
3440

35-
codesign --force --sign "${IDENTITY}" \
36-
--options runtime --timestamp "${APP_PATH}"
41+
# Entitlements belong on the outer signature and nowhere else: it is the one
42+
# that seals the main executable, and that executable is the process whose
43+
# capabilities are being asked for. The nested dylibs above never call for any
44+
# of them.
45+
#
46+
# Spelled as two calls rather than one with an argument array, because macOS
47+
# ships bash 3.2, where an empty array expanded under `set -u` is an error.
48+
if [ -n "${ENTITLEMENTS}" ]; then
49+
codesign --force --sign "${IDENTITY}" --entitlements "${ENTITLEMENTS}" \
50+
--options runtime --timestamp "${APP_PATH}"
51+
else
52+
codesign --force --sign "${IDENTITY}" \
53+
--options runtime --timestamp "${APP_PATH}"
54+
fi

tools/macos/verify_signature.sh

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
11
#!/bin/bash
22
set -euo pipefail
33

4-
APP_PATH="${1:?usage: verify_signature.sh <app> [identity]}"
4+
APP_PATH="${1:?usage: verify_signature.sh <app> [identity] [entitlement]}"
55
IDENTITY="${2:--}"
6+
REQUIRED_ENTITLEMENT="${3:-}"
67

78
codesign --verify --deep --strict --verbose=2 "${APP_PATH}"
89

10+
# A missing entitlement is not a signing error, so nothing above catches one.
11+
# It surfaces at runtime, on the machine of whoever downloaded the release, as
12+
# whatever the denied capability does when refused, and what
13+
# pthread_jit_write_protect_np does without com.apple.security.cs.allow-jit is
14+
# trap the process. Ad-hoc signatures carry no entitlements and want none:
15+
# without the hardened runtime there is nothing to ask for.
16+
if [ -n "${REQUIRED_ENTITLEMENT}" ] && [ "${IDENTITY}" != "-" ]; then
17+
granted="$(mktemp -t entitlements)"
18+
trap 'rm -f "${granted}"' EXIT
19+
# Older codesign writes a binary blob with a header on it and newer ones
20+
# write XML; plutil normalizes both, and reads the key rather than grepping
21+
# for it, so an entitlement present and set to false still fails.
22+
codesign --display --entitlements - --xml "${APP_PATH}" 2>/dev/null \
23+
| plutil -convert xml1 -o "${granted}" - 2>/dev/null || true
24+
value="$(/usr/libexec/PlistBuddy -c "Print :${REQUIRED_ENTITLEMENT}" \
25+
"${granted}" 2>/dev/null || true)"
26+
if [ "${value}" != "true" ]; then
27+
echo "ERROR: ${APP_PATH} is missing ${REQUIRED_ENTITLEMENT}" >&2
28+
exit 1
29+
fi
30+
fi
31+
932
checked=0
1033
while IFS= read -r -d '' component; do
1134
if ! file -b "${component}" | grep -q 'Mach-O'; then

tools/macos/wowee.entitlements

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<!-- Warden ships its module as code, and the client maps that module
6+
PROT_EXEC to run it. Under the hardened runtime an anonymous
7+
executable mapping needs MAP_JIT, MAP_JIT needs this entitlement, and
8+
pthread_jit_write_protect_np traps the process rather than returning
9+
an error when it is absent. See include/game/jit_write.hpp. -->
10+
<key>com.apple.security.cs.allow-jit</key>
11+
<true/>
12+
</dict>
13+
</plist>

0 commit comments

Comments
 (0)